diff --git a/.github/ISSUE_TEMPLATE/2.server_bug_report.yml b/.github/ISSUE_TEMPLATE/2.server_bug_report.yml index 01f062b972..98801f07f1 100644 --- a/.github/ISSUE_TEMPLATE/2.server_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/2.server_bug_report.yml @@ -59,7 +59,7 @@ body: Any additional technical details you may have, like logs or error traces value: | If this is happening on your own Mastodon server, please fill out those: - - Ruby version: (from `ruby --version`, eg. v4.0.2) + - Ruby version: (from `ruby --version`, eg. v4.0.3) - Node.js version: (from `node --version`, eg. v22.16.0) validations: required: false diff --git a/.github/ISSUE_TEMPLATE/3.troubleshooting.yml b/.github/ISSUE_TEMPLATE/3.troubleshooting.yml index 9fe5df7ce3..9856ae4428 100644 --- a/.github/ISSUE_TEMPLATE/3.troubleshooting.yml +++ b/.github/ISSUE_TEMPLATE/3.troubleshooting.yml @@ -60,7 +60,7 @@ body: value: | Please at least include those informations: - Operating system: (eg. Ubuntu 24.04.2) - - Ruby version: (from `ruby --version`, eg. v4.0.2) + - Ruby version: (from `ruby --version`, eg. v4.0.3) - Node.js version: (from `node --version`, eg. v22.16.0) validations: required: false diff --git a/.github/actions/setup-javascript/action.yml b/.github/actions/setup-javascript/action.yml index f326d0dee4..7f29265c1c 100644 --- a/.github/actions/setup-javascript/action.yml +++ b/.github/actions/setup-javascript/action.yml @@ -9,7 +9,7 @@ runs: using: 'composite' steps: - name: Set up Node.js - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version-file: '.nvmrc' @@ -23,7 +23,7 @@ runs: shell: bash run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT - - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} diff --git a/.github/actions/setup-ruby/action.yml b/.github/actions/setup-ruby/action.yml index 9f50aa60ff..317c1256d5 100644 --- a/.github/actions/setup-ruby/action.yml +++ b/.github/actions/setup-ruby/action.yml @@ -17,7 +17,7 @@ runs: sudo apt-get install --no-install-recommends -y libicu-dev libidn11-dev libvips42 ${{ inputs.additional-system-dependencies }} - name: Set up Ruby - uses: ruby/setup-ruby@c984c1a20bb35a1cbda04477c816cea024418be9 # v1 + uses: ruby/setup-ruby@94e4d89d3e6c1c7599e0210d114c5ffb23f1a866 # v1 with: ruby-version: ${{ inputs.ruby-version }} bundler-cache: true diff --git a/.github/workflows/bundler-audit.yml b/.github/workflows/bundler-audit.yml index 964d93378d..0cbbeb5878 100644 --- a/.github/workflows/bundler-audit.yml +++ b/.github/workflows/bundler-audit.yml @@ -31,7 +31,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Ruby - uses: ruby/setup-ruby@c984c1a20bb35a1cbda04477c816cea024418be9 # v1 + uses: ruby/setup-ruby@94e4d89d3e6c1c7599e0210d114c5ffb23f1a866 # v1 with: bundler-cache: true diff --git a/.github/workflows/lint-haml.yml b/.github/workflows/lint-haml.yml index 33dc4e4fb6..b59a4b2d69 100644 --- a/.github/workflows/lint-haml.yml +++ b/.github/workflows/lint-haml.yml @@ -36,7 +36,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Ruby - uses: ruby/setup-ruby@c984c1a20bb35a1cbda04477c816cea024418be9 # v1 + uses: ruby/setup-ruby@94e4d89d3e6c1c7599e0210d114c5ffb23f1a866 # v1 with: bundler-cache: true diff --git a/.github/workflows/lint-ruby.yml b/.github/workflows/lint-ruby.yml index b68f50e033..ae4cc6faf2 100644 --- a/.github/workflows/lint-ruby.yml +++ b/.github/workflows/lint-ruby.yml @@ -38,7 +38,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Ruby - uses: ruby/setup-ruby@c984c1a20bb35a1cbda04477c816cea024418be9 # v1 + uses: ruby/setup-ruby@94e4d89d3e6c1c7599e0210d114c5ffb23f1a866 # v1 with: bundler-cache: true diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml index a8695cae8c..b8c525d9d4 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -43,7 +43,7 @@ jobs: onlyProduction: 'true' - name: Cache assets from compilation - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: | public/assets @@ -151,7 +151,7 @@ jobs: bin/flatware fan bin/rails db:test:prepare - name: Cache RSpec persistence file - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: | tmp/rspec/examples.txt @@ -167,7 +167,7 @@ jobs: - name: Upload coverage reports to Codecov if: matrix.ruby-version == '.ruby-version' - uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5 + uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5 with: files: coverage/lcov/*.lcov env: @@ -247,7 +247,7 @@ jobs: - name: Cache Playwright Chromium browser id: playwright-cache - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: ~/.cache/ms-playwright key: playwright-browsers-${{ runner.os }}-${{ hashFiles('yarn.lock') }} diff --git a/.ruby-version b/.ruby-version index 4d54daddb6..c4e41f9459 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -4.0.2 +4.0.3 diff --git a/Dockerfile b/Dockerfile index 6812939291..1f50c0f1a8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ ARG BASE_REGISTRY="docker.io" # Ruby image to use for base image, change with [--build-arg RUBY_VERSION="4.0.x"] # renovate: datasource=docker depName=docker.io/ruby -ARG RUBY_VERSION="4.0.2" +ARG RUBY_VERSION="4.0.3" # # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="22"] # renovate: datasource=node-version depName=node ARG NODE_MAJOR_VERSION="24" diff --git a/Gemfile b/Gemfile index 6d22ed3e19..f5d72aed1b 100644 --- a/Gemfile +++ b/Gemfile @@ -4,7 +4,7 @@ source 'https://rubygems.org' ruby '>= 3.3.0', '< 4.1.0' gem 'propshaft' -gem 'puma', '~> 7.0' +gem 'puma' gem 'rails', '~> 8.1.0' gem 'thor', '~> 1.2' diff --git a/Gemfile.lock b/Gemfile.lock index de97b60df9..57879896e9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -99,8 +99,8 @@ GEM ast (2.4.3) attr_required (1.0.2) aws-eventstream (1.4.0) - aws-partitions (1.1238.0) - aws-sdk-core (3.244.0) + aws-partitions (1.1242.0) + aws-sdk-core (3.246.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -108,10 +108,10 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.123.0) + aws-sdk-kms (1.124.0) aws-sdk-core (~> 3, >= 3.244.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.219.0) + aws-sdk-s3 (1.220.0) aws-sdk-core (~> 3, >= 3.244.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) @@ -132,7 +132,7 @@ GEM binding_of_caller (2.0.0) debug_inspector (>= 1.2.0) blurhash (0.1.8) - bootsnap (1.23.0) + bootsnap (1.24.1) msgpack (~> 1.2) brakeman (8.0.4) racc @@ -178,7 +178,7 @@ GEM bigdecimal rexml crass (1.0.6) - css_parser (2.0.0) + css_parser (2.1.0) addressable csv (3.3.5) database_cleaner-active_record (2.2.2) @@ -226,7 +226,7 @@ GEM elasticsearch-dsl (0.1.10) email_validator (2.2.4) activemodel - erb (6.0.3) + erb (6.0.4) erubi (1.13.1) et-orbi (1.4.0) tzinfo @@ -264,7 +264,7 @@ GEM excon (~> 1.0) formatador (>= 0.2, < 2.0) mime-types - fog-json (1.2.0) + fog-json (1.3.0) fog-core multi_json (~> 1.10) fog-openstack (1.1.5) @@ -314,7 +314,7 @@ GEM http-cookie (~> 1.0) http-form_data (~> 2.2) llhttp-ffi (~> 0.5.0) - http-cookie (1.1.4) + http-cookie (1.1.6) domain_name (~> 0.5) http-form_data (2.3.0) http_accept_language (2.1.1) @@ -343,7 +343,7 @@ GEM activesupport (>= 3.0) nokogiri (>= 1.6) io-console (0.8.2) - irb (1.17.0) + irb (1.18.0) pp (>= 0.6.0) prism (>= 1.3.0) rdoc (>= 4.0.0) @@ -458,7 +458,7 @@ GEM mutex_m (0.3.0) net-http (0.6.0) uri - net-imap (0.6.3) + net-imap (0.6.4) date net-protocol net-ldap (0.20.0) @@ -471,7 +471,7 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.5) - nokogiri (1.19.2) + nokogiri (1.19.3) mini_portile2 (~> 2.8.2) racc (~> 1.4) omniauth (2.1.4) @@ -625,7 +625,7 @@ GEM date stringio public_suffix (7.0.5) - puma (7.2.0) + puma (8.0.1) nio4r (~> 2.0) pundit (2.5.2) activesupport (>= 3.0.0) @@ -691,7 +691,7 @@ GEM tsort (>= 0.2) zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.4.1) + rake (13.4.2) rdf (3.3.4) bcp47_spec (~> 0.2) bigdecimal (~> 3.1, >= 3.1.5) @@ -848,7 +848,7 @@ GEM stackprof (0.2.28) starry (0.2.0) base64 - stoplight (5.8.0) + stoplight (5.8.2) concurrent-ruby zeitwerk stringio (3.2.0) @@ -1045,7 +1045,7 @@ DEPENDENCIES prometheus_exporter (~> 2.2) propshaft public_suffix (~> 7.0) - puma (~> 7.0) + puma pundit (~> 2.3) rack-attack (~> 6.6) rack-cors @@ -1097,7 +1097,7 @@ DEPENDENCIES xorcist (~> 1.1) RUBY VERSION - ruby 4.0.2 + ruby 4.0.3 BUNDLED WITH - 4.0.10 + 4.0.11 diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb index bdc163d4b6..3d5399d978 100644 --- a/app/controllers/api/v1/notifications_controller.rb +++ b/app/controllers/api/v1/notifications_controller.rb @@ -16,7 +16,7 @@ class Api::V1::NotificationsController < Api::BaseController @relationships = StatusRelationshipsPresenter.new(target_statuses_from_notifications, current_user&.account_id) end - render json: @notifications, each_serializer: REST::NotificationSerializer, relationships: @relationships + render json: @notifications, each_serializer: REST::NotificationSerializer, relationships: @relationships, supported_notification_types: params[:supported_types] end def unread_count @@ -29,7 +29,7 @@ class Api::V1::NotificationsController < Api::BaseController def show @notification = current_account.notifications.without_suspended.find(params[:id]) - render json: @notification, serializer: REST::NotificationSerializer + render json: @notification, serializer: REST::NotificationSerializer, supported_notification_types: params[:supported_types] end def clear @@ -98,6 +98,8 @@ class Api::V1::NotificationsController < Api::BaseController end def pagination_params(core_params) - params.slice(:limit, :account_id, :types, :exclude_types, :include_filtered).permit(:limit, :account_id, :include_filtered, types: [], exclude_types: []).merge(core_params) + params.slice(:limit, :account_id, :types, :exclude_types, :include_filtered, :supported_types) + .permit(:limit, :account_id, :include_filtered, types: [], exclude_types: [], supported_types: []) + .merge(core_params) end end diff --git a/app/controllers/api/v2/notifications_controller.rb b/app/controllers/api/v2/notifications_controller.rb index 848c361cfc..f54cd230b7 100644 --- a/app/controllers/api/v2/notifications_controller.rb +++ b/app/controllers/api/v2/notifications_controller.rb @@ -33,7 +33,7 @@ class Api::V2::NotificationsController < Api::BaseController 'app.notification_grouping.expand_accounts_param' => expand_accounts_param ) - render json: @presenter, serializer: REST::DedupNotificationGroupSerializer, relationships: @relationships, expand_accounts: expand_accounts_param + render json: @presenter, serializer: REST::DedupNotificationGroupSerializer, relationships: @relationships, expand_accounts: expand_accounts_param, supported_notification_types: params[:supported_types] end end @@ -48,7 +48,7 @@ class Api::V2::NotificationsController < Api::BaseController def show @notification = current_account.notifications.without_suspended.by_group_key(params[:group_key]).take! presenter = GroupedNotificationsPresenter.new(NotificationGroup.from_notifications([@notification])) - render json: presenter, serializer: REST::DedupNotificationGroupSerializer + render json: presenter, serializer: REST::DedupNotificationGroupSerializer, supported_notification_types: params[:supported_types] end def clear @@ -138,7 +138,9 @@ class Api::V2::NotificationsController < Api::BaseController end def pagination_params(core_params) - params.slice(:limit, :include_filtered, :types, :exclude_types, :grouped_types).permit(:limit, :include_filtered, types: [], exclude_types: [], grouped_types: []).merge(core_params) + params.slice(:limit, :include_filtered, :types, :exclude_types, :grouped_types, :supported_types) + .permit(:limit, :include_filtered, types: [], exclude_types: [], grouped_types: [], supported_types: []) + .merge(core_params) end def expand_accounts_param diff --git a/app/javascript/images/icons/icon_clock.svg b/app/javascript/images/icons/icon_clock.svg new file mode 100644 index 0000000000..cba7451c5b --- /dev/null +++ b/app/javascript/images/icons/icon_clock.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 9bd118934e..d855c9c3a2 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -8,6 +8,7 @@ import { browserHistory } from 'mastodon/components/router'; import { countableText } from 'mastodon/features/compose/util/counter'; import { search as emojiSearch } from 'mastodon/features/emoji/emoji_mart_search_light'; import { tagHistory } from 'mastodon/settings'; +import { fetchCustomEmojiData } from '@/mastodon/features/emoji/picker'; import { showAlert, showAlertForError } from './alerts'; import { useEmoji } from './emojis'; @@ -565,7 +566,7 @@ export function clearComposeSuggestions() { }; } -const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => { +const fetchComposeSuggestionsAccounts = throttle((dispatch, token) => { if (fetchComposeSuggestionsAccountsController) { fetchComposeSuggestionsAccountsController.abort(); } @@ -592,12 +593,13 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => }); }, 200, { leading: true, trailing: true }); -const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => { - const results = emojiSearch(token.replace(':', ''), { maxResults: 5 }); +const fetchComposeSuggestionsEmojis = async (dispatch, token) => { + const custom = await fetchCustomEmojiData(); + const results = emojiSearch(token.replace(':', ''), { maxResults: 5, custom }); dispatch(readyComposeSuggestionsEmojis(token, results)); }; -const fetchComposeSuggestionsTags = throttle((dispatch, getState, token) => { +const fetchComposeSuggestionsTags = throttle((dispatch, token) => { if (fetchComposeSuggestionsTagsController) { fetchComposeSuggestionsTagsController.abort(); } @@ -631,14 +633,14 @@ export function fetchComposeSuggestions(token) { return (dispatch, getState) => { switch (token[0]) { case ':': - fetchComposeSuggestionsEmojis(dispatch, getState, token); + void fetchComposeSuggestionsEmojis(dispatch, token); break; case '#': case '#': - fetchComposeSuggestionsTags(dispatch, getState, token); + fetchComposeSuggestionsTags(dispatch, token); break; default: - fetchComposeSuggestionsAccounts(dispatch, getState, token); + fetchComposeSuggestionsAccounts(dispatch, token); break; } }; diff --git a/app/javascript/mastodon/actions/custom_emojis.js b/app/javascript/mastodon/actions/custom_emojis.js deleted file mode 100644 index fb65f072dc..0000000000 --- a/app/javascript/mastodon/actions/custom_emojis.js +++ /dev/null @@ -1,40 +0,0 @@ -import api from '../api'; - -export const CUSTOM_EMOJIS_FETCH_REQUEST = 'CUSTOM_EMOJIS_FETCH_REQUEST'; -export const CUSTOM_EMOJIS_FETCH_SUCCESS = 'CUSTOM_EMOJIS_FETCH_SUCCESS'; -export const CUSTOM_EMOJIS_FETCH_FAIL = 'CUSTOM_EMOJIS_FETCH_FAIL'; - -export function fetchCustomEmojis() { - return (dispatch) => { - dispatch(fetchCustomEmojisRequest()); - - api().get('/api/v1/custom_emojis').then(response => { - dispatch(fetchCustomEmojisSuccess(response.data)); - }).catch(error => { - dispatch(fetchCustomEmojisFail(error)); - }); - }; -} - -export function fetchCustomEmojisRequest() { - return { - type: CUSTOM_EMOJIS_FETCH_REQUEST, - skipLoading: true, - }; -} - -export function fetchCustomEmojisSuccess(custom_emojis) { - return { - type: CUSTOM_EMOJIS_FETCH_SUCCESS, - custom_emojis, - skipLoading: true, - }; -} - -export function fetchCustomEmojisFail(error) { - return { - type: CUSTOM_EMOJIS_FETCH_FAIL, - error, - skipLoading: true, - }; -} diff --git a/app/javascript/mastodon/api/collections.ts b/app/javascript/mastodon/api/collections.ts index 862f98b60a..7f39651f02 100644 --- a/app/javascript/mastodon/api/collections.ts +++ b/app/javascript/mastodon/api/collections.ts @@ -34,11 +34,16 @@ export const apiGetCollection = (collectionId: string) => `v1_alpha/collections/${collectionId}`, ); -export const apiGetAccountCollections = (accountId: string) => +export const apiGetCollectionsCreatedByAccount = (accountId: string) => apiRequestGet( `v1_alpha/accounts/${accountId}/collections`, ); +export const apiGetCollectionsFeaturingAccount = (accountId: string) => + apiRequestGet( + `v1_alpha/accounts/${accountId}/in_collections`, + ); + export const apiAddCollectionItem = (collectionId: string, accountId: string) => apiRequestPost( `v1_alpha/collections/${collectionId}/items`, diff --git a/app/javascript/mastodon/features/account_timeline/components/badges.tsx b/app/javascript/mastodon/components/account_header/badges.tsx similarity index 98% rename from app/javascript/mastodon/features/account_timeline/components/badges.tsx rename to app/javascript/mastodon/components/account_header/badges.tsx index ec8b00f5ac..401f0d0fb1 100644 --- a/app/javascript/mastodon/features/account_timeline/components/badges.tsx +++ b/app/javascript/mastodon/components/account_header/badges.tsx @@ -4,6 +4,10 @@ import type { FC } from 'react'; import { FormattedMessage } from 'react-intl'; import { fetchRelationships } from '@/mastodon/actions/accounts'; +import { useAccount } from '@/mastodon/hooks/useAccount'; +import type { AccountRole } from '@/mastodon/models/account'; +import { useAppDispatch, useAppSelector } from '@/mastodon/store'; + import { AdminBadge, AutomatedBadge, @@ -11,10 +15,7 @@ import { BlockedBadge, GroupBadge, MutedBadge, -} from '@/mastodon/components/badge'; -import { useAccount } from '@/mastodon/hooks/useAccount'; -import type { AccountRole } from '@/mastodon/models/account'; -import { useAppDispatch, useAppSelector } from '@/mastodon/store'; +} from '../badge'; import classes from './styles.module.scss'; diff --git a/app/javascript/mastodon/features/account_timeline/components/buttons.tsx b/app/javascript/mastodon/components/account_header/buttons.tsx similarity index 95% rename from app/javascript/mastodon/features/account_timeline/components/buttons.tsx rename to app/javascript/mastodon/components/account_header/buttons.tsx index 0c24207b6a..737a3b9ca5 100644 --- a/app/javascript/mastodon/features/account_timeline/components/buttons.tsx +++ b/app/javascript/mastodon/components/account_header/buttons.tsx @@ -6,9 +6,6 @@ import { defineMessages, useIntl } from 'react-intl'; import classNames from 'classnames'; import { followAccount } from '@/mastodon/actions/accounts'; -import { CopyIconButton } from '@/mastodon/components/copy_icon_button'; -import { FollowButton } from '@/mastodon/components/follow_button'; -import { IconButton } from '@/mastodon/components/icon_button'; import { useAccount } from '@/mastodon/hooks/useAccount'; import { getAccountHidden } from '@/mastodon/selectors/accounts'; import { useAppDispatch, useAppSelector } from '@/mastodon/store'; @@ -16,6 +13,10 @@ import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react import NotificationsActiveIcon from '@/material-icons/400-24px/notifications_active-fill.svg?react'; import ShareIcon from '@/material-icons/400-24px/share.svg?react'; +import { CopyIconButton } from '../copy_icon_button'; +import { FollowButton } from '../follow_button'; +import { IconButton } from '../icon_button'; + import { AccountMenu } from './menu'; const messages = defineMessages({ diff --git a/app/javascript/mastodon/features/account_timeline/components/fields.tsx b/app/javascript/mastodon/components/account_header/fields.tsx similarity index 93% rename from app/javascript/mastodon/features/account_timeline/components/fields.tsx rename to app/javascript/mastodon/components/account_header/fields.tsx index 38e93b100f..ce2fe9d322 100644 --- a/app/javascript/mastodon/features/account_timeline/components/fields.tsx +++ b/app/javascript/mastodon/components/account_header/fields.tsx @@ -7,21 +7,21 @@ import classNames from 'classnames'; import IconVerified from '@/images/icons/icon_verified.svg?react'; import { openModal } from '@/mastodon/actions/modal'; -import { CustomEmojiProvider } from '@/mastodon/components/emoji/context'; -import type { EmojiHTMLProps } from '@/mastodon/components/emoji/html'; -import { EmojiHTML } from '@/mastodon/components/emoji/html'; -import { Icon } from '@/mastodon/components/icon'; -import { IconButton } from '@/mastodon/components/icon_button'; -import { MiniCard } from '@/mastodon/components/mini_card'; -import { useElementHandledLink } from '@/mastodon/components/status/handled_link'; +import { useFieldHtml } from '@/mastodon/features/account_timeline/hooks/useFieldHtml'; +import { cleanExtraEmojis } from '@/mastodon/features/emoji/normalize'; import { useAccount } from '@/mastodon/hooks/useAccount'; import { useResizeObserver } from '@/mastodon/hooks/useObserver'; +import type { AccountFieldShape } from '@/mastodon/models/account'; import { useAppDispatch } from '@/mastodon/store'; import MoreIcon from '@/material-icons/400-24px/more_horiz.svg?react'; -import { cleanExtraEmojis } from '../../emoji/normalize'; -import type { AccountField } from '../common'; -import { useFieldHtml } from '../hooks/useFieldHtml'; +import { CustomEmojiProvider } from '../emoji/context'; +import type { EmojiHTMLProps } from '../emoji/html'; +import { EmojiHTML } from '../emoji/html'; +import { Icon } from '../icon'; +import { IconButton } from '../icon_button'; +import { MiniCard } from '../mini_card'; +import { useElementHandledLink } from '../status/handled_link'; import classes from './styles.module.scss'; @@ -37,6 +37,12 @@ const dateFormatOptions: Intl.DateTimeFormatOptions = { minute: '2-digit', }; +export interface AccountField extends AccountFieldShape { + nameHasEmojis: boolean; + value_plain: string; + valueHasEmojis: boolean; +} + export const AccountHeaderFields: FC<{ accountId: string }> = ({ accountId, }) => { diff --git a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx b/app/javascript/mastodon/components/account_header/index.tsx similarity index 94% rename from app/javascript/mastodon/features/account_timeline/components/account_header.tsx rename to app/javascript/mastodon/components/account_header/index.tsx index 174f862365..9222d0eb32 100644 --- a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx +++ b/app/javascript/mastodon/components/account_header/index.tsx @@ -4,9 +4,6 @@ import classNames from 'classnames'; import { Helmet } from 'react-helmet'; import { openModal } from '@/mastodon/actions/modal'; -import { AccountBio } from '@/mastodon/components/account_bio'; -import { Avatar } from '@/mastodon/components/avatar'; -import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context'; import FollowRequestNoteContainer from '@/mastodon/features/account/containers/follow_request_note_container'; import { useLayout } from '@/mastodon/hooks/useLayout'; import { useVisibility } from '@/mastodon/hooks/useVisibility'; @@ -19,17 +16,20 @@ import type { Account } from '@/mastodon/models/account'; import { getAccountHidden } from '@/mastodon/selectors/accounts'; import { useAppSelector, useAppDispatch } from '@/mastodon/store'; -import { FamiliarFollowers } from '../../../components/familiar_followers'; +import { AccountBio } from '../account_bio'; +import { Avatar } from '../avatar'; +import { AnimateEmojiProvider } from '../emoji/context'; +import { FamiliarFollowers } from '../familiar_followers'; -import { AccountName } from './account_name'; -import { AccountSubscriptionForm } from './account_subscription_form'; import { AccountButtons } from './buttons'; import { AccountHeaderFields } from './fields'; import { MemorialNote } from './memorial_note'; import { MovedNote } from './moved_note'; +import { AccountName } from './name'; import { AccountNote } from './note'; import { AccountNumberFields } from './number_fields'; import classes from './styles.module.scss'; +import { AccountSubscriptionForm } from './subscription_form'; import { AccountTabs } from './tabs'; const titleFromAccount = (account: Account) => { diff --git a/app/javascript/mastodon/features/account_timeline/components/memorial_note.tsx b/app/javascript/mastodon/components/account_header/memorial_note.tsx similarity index 100% rename from app/javascript/mastodon/features/account_timeline/components/memorial_note.tsx rename to app/javascript/mastodon/components/account_header/memorial_note.tsx diff --git a/app/javascript/mastodon/features/account_timeline/components/menu.tsx b/app/javascript/mastodon/components/account_header/menu.tsx similarity index 99% rename from app/javascript/mastodon/features/account_timeline/components/menu.tsx rename to app/javascript/mastodon/components/account_header/menu.tsx index 366115ee29..936609e91f 100644 --- a/app/javascript/mastodon/features/account_timeline/components/menu.tsx +++ b/app/javascript/mastodon/components/account_header/menu.tsx @@ -21,7 +21,6 @@ import { import { openModal } from '@/mastodon/actions/modal'; import { initMuteModal } from '@/mastodon/actions/mutes'; import { initReport } from '@/mastodon/actions/reports'; -import { Dropdown } from '@/mastodon/components/dropdown_menu'; import { useAccount } from '@/mastodon/hooks/useAccount'; import { useIdentity } from '@/mastodon/identity_context'; import type { Account } from '@/mastodon/models/account'; @@ -40,6 +39,8 @@ import PersonRemoveIcon from '@/material-icons/400-24px/person_remove.svg?react' import ReportIcon from '@/material-icons/400-24px/report.svg?react'; import ShareIcon from '@/material-icons/400-24px/share.svg?react'; +import { Dropdown } from '../dropdown_menu'; + import classes from './styles.module.scss'; export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => { diff --git a/app/javascript/mastodon/features/account_timeline/components/moved_note.tsx b/app/javascript/mastodon/components/account_header/moved_note.tsx similarity index 87% rename from app/javascript/mastodon/features/account_timeline/components/moved_note.tsx rename to app/javascript/mastodon/components/account_header/moved_note.tsx index f2457dedd7..153c206bbc 100644 --- a/app/javascript/mastodon/features/account_timeline/components/moved_note.tsx +++ b/app/javascript/mastodon/components/account_header/moved_note.tsx @@ -2,9 +2,10 @@ import { FormattedMessage } from 'react-intl'; import { Link } from 'react-router-dom'; -import { DisplayName } from '@/mastodon/components/display_name'; -import { AvatarOverlay } from 'mastodon/components/avatar_overlay'; -import { useAppSelector } from 'mastodon/store'; +import { useAppSelector } from '@/mastodon/store'; + +import { AvatarOverlay } from '../avatar_overlay'; +import { DisplayName } from '../display_name'; export const MovedNote: React.FC<{ accountId: string; diff --git a/app/javascript/mastodon/features/account_timeline/components/account_name.tsx b/app/javascript/mastodon/components/account_header/name.tsx similarity index 89% rename from app/javascript/mastodon/features/account_timeline/components/account_name.tsx rename to app/javascript/mastodon/components/account_header/name.tsx index a1d646ac8b..2b3f1907b4 100644 --- a/app/javascript/mastodon/features/account_timeline/components/account_name.tsx +++ b/app/javascript/mastodon/components/account_header/name.tsx @@ -7,12 +7,7 @@ import classNames from 'classnames'; import Overlay from 'react-overlays/esm/Overlay'; -import FollowerIcon from '@/images/icons/icon_follower.svg?react'; import { showAlert } from '@/mastodon/actions/alerts'; -import { Badge } from '@/mastodon/components/badge'; -import { Button } from '@/mastodon/components/button'; -import { DisplayName } from '@/mastodon/components/display_name'; -import { Icon } from '@/mastodon/components/icon'; import { useAccount } from '@/mastodon/hooks/useAccount'; import { useRelationship } from '@/mastodon/hooks/useRelationship'; import { useAppDispatch, useAppSelector } from '@/mastodon/store'; @@ -21,6 +16,11 @@ import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react'; import HelpIcon from '@/material-icons/400-24px/help.svg?react'; import DomainIcon from '@/material-icons/400-24px/language.svg?react'; +import { FollowsYouBadge } from '../badge'; +import { Button } from '../button'; +import { DisplayName } from '../display_name'; +import { Icon } from '../icon'; + import { AccountBadges } from './badges'; import classes from './styles.module.scss'; @@ -60,17 +60,7 @@ export const AccountName: FC<{ accountId: string }> = ({ accountId }) => {

- {relationship?.followed_by && ( - } - label={ - - } - /> - )} + {relationship?.followed_by && } {handle} - diff --git a/app/javascript/mastodon/features/account_timeline/components/note.tsx b/app/javascript/mastodon/components/account_header/note.tsx similarity index 93% rename from app/javascript/mastodon/features/account_timeline/components/note.tsx rename to app/javascript/mastodon/components/account_header/note.tsx index 6bd727791a..d58c37aec9 100644 --- a/app/javascript/mastodon/features/account_timeline/components/note.tsx +++ b/app/javascript/mastodon/components/account_header/note.tsx @@ -5,11 +5,12 @@ import { defineMessages, useIntl } from 'react-intl'; import { fetchRelationships } from '@/mastodon/actions/accounts'; import { openModal } from '@/mastodon/actions/modal'; -import { Callout } from '@/mastodon/components/callout'; -import { IconButton } from '@/mastodon/components/icon_button'; import { useAppDispatch, useAppSelector } from '@/mastodon/store'; import EditIcon from '@/material-icons/400-24px/edit_square.svg?react'; +import { Callout } from '../callout'; +import { IconButton } from '../icon_button'; + import classes from './styles.module.scss'; const messages = defineMessages({ diff --git a/app/javascript/mastodon/features/account_timeline/components/number_fields.tsx b/app/javascript/mastodon/components/account_header/number_fields.tsx similarity index 91% rename from app/javascript/mastodon/features/account_timeline/components/number_fields.tsx rename to app/javascript/mastodon/components/account_header/number_fields.tsx index fb6301952f..50f7f67aab 100644 --- a/app/javascript/mastodon/features/account_timeline/components/number_fields.tsx +++ b/app/javascript/mastodon/components/account_header/number_fields.tsx @@ -4,15 +4,13 @@ import type { FC } from 'react'; import { FormattedMessage, useIntl } from 'react-intl'; import { openModal } from '@/mastodon/actions/modal'; -import { FormattedDateWrapper } from '@/mastodon/components/formatted_date'; -import { - NumberFields, - NumberFieldsItem, -} from '@/mastodon/components/number_fields'; -import { ShortNumber } from '@/mastodon/components/short_number'; import { useAccount } from '@/mastodon/hooks/useAccount'; import { useAppDispatch } from '@/mastodon/store'; +import { FormattedDateWrapper } from '../formatted_date'; +import { NumberFields, NumberFieldsItem } from '../number_fields'; +import { ShortNumber } from '../short_number'; + export const AccountNumberFields: FC<{ accountId: string }> = ({ accountId, }) => { diff --git a/app/javascript/mastodon/features/account_timeline/components/styles.module.scss b/app/javascript/mastodon/components/account_header/styles.module.scss similarity index 89% rename from app/javascript/mastodon/features/account_timeline/components/styles.module.scss rename to app/javascript/mastodon/components/account_header/styles.module.scss index 9c13026b97..7d87dde147 100644 --- a/app/javascript/mastodon/features/account_timeline/components/styles.module.scss +++ b/app/javascript/mastodon/components/account_header/styles.module.scss @@ -37,10 +37,6 @@ } } -.followerBadgeIcon { - color: var(--color-text-secondary); -} - .badges { margin-top: 8px; } @@ -50,13 +46,14 @@ gap: 2px; padding: 0; margin-top: 4px; - align-items: center; appearance: none; background: none; border: none; color: var(--color-text-secondary); font-size: 13px; transition: color 0.2s ease-in-out; + word-break: break-all; + text-align: left; > svg { width: 16px; @@ -203,6 +200,9 @@ $button-fallback-breakpoint: $button-breakpoint + 55px; .noteContent { white-space-collapse: preserve-breaks; + overflow-wrap: break-word; + word-break: break-all; + hyphens: auto; } .noteEditButton { @@ -275,10 +275,7 @@ $button-fallback-breakpoint: $button-breakpoint + 55px; &.fieldItem { border-color: var(--color-border-success-soft); - } - - dt { - padding-right: 24px; + padding-right: 32px; // 8px padding + 16px for the icon + 8px gap } } @@ -355,48 +352,10 @@ $button-fallback-breakpoint: $button-breakpoint + 55px; } } -.tabs, -.noTabs { - border-bottom: 1px solid var(--color-border-primary); -} - -.tabs { - display: flex; - gap: 16px; - padding: 0 16px; - - @container (width < 500px) { - a { - flex: 1 1 0px; - text-align: center; - } - } - - a { - display: block; - font-size: 15px; - font-weight: 500; - padding: 18px 0; - text-decoration: none; - color: var(--color-text-primary); - border-radius: 0; - transition: color 0.2s ease-in-out; - - &:not([aria-current='page']):is(:hover, :focus) { - color: var(--color-text-brand-soft); - } - } - - :global(.active) { - color: var(--color-text-brand); - border-bottom: 4px solid var(--color-border-brand); - padding-bottom: 14px; - } -} - .noTabs { width: 100%; border-width: 0 0 1px; + border-bottom: 1px solid var(--color-border-primary); } .bannerBase { diff --git a/app/javascript/mastodon/features/account_timeline/components/account_subscription_form.tsx b/app/javascript/mastodon/components/account_header/subscription_form.tsx similarity index 92% rename from app/javascript/mastodon/features/account_timeline/components/account_subscription_form.tsx rename to app/javascript/mastodon/components/account_header/subscription_form.tsx index e3fc8146e6..f78077bef7 100644 --- a/app/javascript/mastodon/features/account_timeline/components/account_subscription_form.tsx +++ b/app/javascript/mastodon/components/account_header/subscription_form.tsx @@ -8,16 +8,17 @@ import { Link } from 'react-router-dom'; import { AxiosError } from 'axios'; -import { apiSubscribeByEmail } from 'mastodon/api/accounts'; +import { apiSubscribeByEmail } from '@/mastodon/api/accounts'; import type { ValidationErrorResponse, ValidationError, -} from 'mastodon/api_types/errors'; -import { Button } from 'mastodon/components/button'; -import { DisplayName } from 'mastodon/components/display_name'; -import type { FieldStatus } from 'mastodon/components/form_fields'; -import { TextInputField } from 'mastodon/components/form_fields/text_input_field'; -import { useAppSelector } from 'mastodon/store'; +} from '@/mastodon/api_types/errors'; +import { useAppSelector } from '@/mastodon/store'; + +import { Button } from '../button'; +import { DisplayName } from '../display_name'; +import type { FieldStatus } from '../form_fields'; +import { TextInputField } from '../form_fields/text_input_field'; import classes from './styles.module.scss'; diff --git a/app/javascript/mastodon/features/account_timeline/components/tabs.tsx b/app/javascript/mastodon/components/account_header/tabs.tsx similarity index 79% rename from app/javascript/mastodon/features/account_timeline/components/tabs.tsx rename to app/javascript/mastodon/components/account_header/tabs.tsx index b2970412df..f2f4ebbe27 100644 --- a/app/javascript/mastodon/features/account_timeline/components/tabs.tsx +++ b/app/javascript/mastodon/components/account_header/tabs.tsx @@ -3,11 +3,12 @@ import type { FC } from 'react'; import { FormattedMessage } from 'react-intl'; import type { NavLinkProps } from 'react-router-dom'; -import { NavLink } from 'react-router-dom'; import { useAccount } from '@/mastodon/hooks/useAccount'; import { useAccountId } from '@/mastodon/hooks/useAccountId'; +import { TabLink, TabList } from '../tab_list'; + import classes from './styles.module.scss'; const isActive: Required['isActive'] = (match, location) => @@ -28,20 +29,20 @@ export const AccountTabs: FC = () => { } return ( -
- + + - + {show_media && ( - + - + )} {show_featured && ( - + - + )} -
+ ); }; diff --git a/app/javascript/mastodon/components/account_list_item/account_list_item.stories.tsx b/app/javascript/mastodon/components/account_list_item/account_list_item.stories.tsx index bad1667b12..62875b02f6 100644 --- a/app/javascript/mastodon/components/account_list_item/account_list_item.stories.tsx +++ b/app/javascript/mastodon/components/account_list_item/account_list_item.stories.tsx @@ -1,6 +1,8 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { accountFactoryState } from '@/testing/factories'; +import { accountFactoryState, relationshipsFactory } from '@/testing/factories'; + +import { PendingBadge } from '../badge'; import { AccountListItem } from './index'; @@ -26,12 +28,30 @@ type Story = StoryObj; export const Default: Story = {}; +export const FollowsYou: Story = { + parameters: { + state: { + relationships: { + '1': relationshipsFactory({ + followed_by: true, + }), + }, + }, + }, +}; + export const WithCustomStats: Story = { args: { stats: ['posts', 'last-active'], }, }; +export const WithCustomBadge: Story = { + args: { + badge: , + }, +}; + export const WithBorder: Story = { args: { withBorder: true, diff --git a/app/javascript/mastodon/components/account_list_item/index.tsx b/app/javascript/mastodon/components/account_list_item/index.tsx index 34ae8b0daf..04817cb33e 100644 --- a/app/javascript/mastodon/components/account_list_item/index.tsx +++ b/app/javascript/mastodon/components/account_list_item/index.tsx @@ -1,18 +1,23 @@ +import type { ReactNode } from 'react'; import { useMemo } from 'react'; import { FormattedMessage, useIntl } from 'react-intl'; import classNames from 'classnames'; -import { Account } from 'mastodon/components/account'; -import { VerifiedBadge } from 'mastodon/components/badge'; +import { FollowsYouBadge, VerifiedBadge } from 'mastodon/components/badge'; import { useAccount } from 'mastodon/hooks/useAccount'; import { useRelationship } from 'mastodon/hooks/useRelationship'; +import { domain } from 'mastodon/initial_state'; import type { Relationship } from 'mastodon/models/relationship'; +import { Avatar } from '../avatar'; +import { useAccountHandle } from '../display_name/default'; +import { DisplayNameSimple } from '../display_name/simple'; import { EmojiHTML } from '../emoji/html'; import { FollowButton } from '../follow_button'; import { FormattedDateWrapper } from '../formatted_date'; +import { ListItemLink, ListItemWrapper } from '../list_item'; import { NumberFields, NumberFieldsItem } from '../number_fields'; import { RelativeTimestamp } from '../relative_timestamp'; import { ShortNumber } from '../short_number'; @@ -29,9 +34,10 @@ type Stat = 'followers' | 'following' | 'posts' | 'joined' | 'last-active'; interface Props { accountId: string | undefined; stats?: Stat[]; - renderButton?: (options: RenderButtonOptions) => React.ReactNode; withBio?: boolean; withBorder?: boolean; + badge?: ReactNode; + renderButton?: (options: RenderButtonOptions) => React.ReactNode; } const DEFAULT_STATS: Stat[] = ['followers', 'posts', 'last-active']; @@ -48,10 +54,12 @@ export const AccountListItem: React.FC = ({ stats = DEFAULT_STATS, withBio = true, withBorder = true, + badge: badgeProp, renderButton = defaultRenderButton, }) => { const intl = useIntl(); const account = useAccount(accountId); + const handle = useAccountHandle(account, domain); const relationship = useRelationship(accountId); const createdThisYear = useMemo( @@ -63,22 +71,34 @@ export const AccountListItem: React.FC = ({ return null; } + const badge = + badgeProp ?? (relationship?.followed_by ? : null); + const firstVerifiedField = account.fields.find((item) => !!item.verified_at); return (
-
- - - {renderButton({ accountId, relationship })} -
+ } + sideContent={ + + {renderButton({ accountId, relationship })} + + } + > + + + {badge && {badge}} + + {stats.includes('followers') && ( diff --git a/app/javascript/mastodon/components/account_list_item/styles.module.scss b/app/javascript/mastodon/components/account_list_item/styles.module.scss index 61d0169bf3..69949508c5 100644 --- a/app/javascript/mastodon/components/account_list_item/styles.module.scss +++ b/app/javascript/mastodon/components/account_list_item/styles.module.scss @@ -10,22 +10,22 @@ } } -.header { - display: flex; - align-items: start; - width: 100%; +.main { + --list-item-padding: 0; } -.account { - --account-name-size: 15px; - --account-handle-color: var(--color-text-secondary); - --account-handle-size: 13px; - --account-bio-color: var(--color-text-primary); - --account-bio-size: 13px; - --account-outer-spacing: 0; +.displayName { + // Spacing for badge + margin-inline-end: 6px; +} - flex-grow: 1; - margin-inline-end: 16px; +.badge { + // Sort out vertical alignment next to name + vertical-align: -4px; +} + +.button { + align-self: start; } .verifiedBadge { diff --git a/app/javascript/mastodon/components/badge/index.tsx b/app/javascript/mastodon/components/badge/index.tsx index 1cc4b531c7..56a6788c3e 100644 --- a/app/javascript/mastodon/components/badge/index.tsx +++ b/app/javascript/mastodon/components/badge/index.tsx @@ -5,6 +5,8 @@ import { FormattedMessage, useIntl } from 'react-intl'; import classNames from 'classnames'; import AdminIcon from '@/images/icons/icon_admin.svg?react'; +import ClockIcon from '@/images/icons/icon_clock.svg?react'; +import FollowerIcon from '@/images/icons/icon_follower.svg?react'; import IconVerified from '@/images/icons/icon_verified.svg?react'; import type { OnAttributeHandler } from '@/mastodon/utils/html'; import BlockIcon from '@/material-icons/400-24px/block.svg?react'; @@ -32,6 +34,11 @@ interface BadgeProps extends React.ComponentPropsWithoutRef<'div'> { | 'danger'; } +type PresetBadgeProps = Omit< + BadgeProps, + 'label' | 'icon' | 'domain' | 'roleId' +>; + export const Badge: FC = ({ icon = , variant = 'default', @@ -83,13 +90,32 @@ export const GroupBadge: FC> = ({ label, ...props }) => ( /> ); -export const AutomatedBadge: FC<{ className?: string }> = ({ className }) => ( +export const AutomatedBadge: FC = (props) => ( } label={ } - className={className} + {...props} + /> +); + +export const FollowsYouBadge: FC = (props) => ( + } + label={ + + } + {...props} + /> +); + +export const PendingBadge: FC = (props) => ( + } + label={} + {...props} /> ); diff --git a/app/javascript/mastodon/components/badge/styles.module.scss b/app/javascript/mastodon/components/badge/styles.module.scss index d359bc38eb..b216e3eb20 100644 --- a/app/javascript/mastodon/components/badge/styles.module.scss +++ b/app/javascript/mastodon/components/badge/styles.module.scss @@ -1,6 +1,7 @@ .badge { color: var(--color-text-primary); font-size: 13px; + font-weight: 400; display: inline-flex; max-width: 100%; padding: 4px; @@ -61,7 +62,6 @@ .warning { background-color: var(--color-bg-warning-softest); - color: var(--color-text-warning); } .danger { diff --git a/app/javascript/mastodon/components/callout/styles.module.css b/app/javascript/mastodon/components/callout/styles.module.css index 95ca5e0b94..f7fbce491b 100644 --- a/app/javascript/mastodon/components/callout/styles.module.css +++ b/app/javascript/mastodon/components/callout/styles.module.css @@ -87,7 +87,7 @@ } .variantSubtle { - border: 1px solid var(--color-bg-brand-softest); + border: 1px solid var(--color-border-brand-soft); background-color: var(--color-bg-primary); .icon { diff --git a/app/javascript/mastodon/components/column_header.tsx b/app/javascript/mastodon/components/column_header.tsx index b8ab2bbe54..076ca3085e 100644 --- a/app/javascript/mastodon/components/column_header.tsx +++ b/app/javascript/mastodon/components/column_header.tsx @@ -276,9 +276,11 @@ export const ColumnHeader: React.FC = ({ ); + const HeadingElement = hasTitle ? 'h1' : 'div'; + const component = (
-

+ {hasTitle && ( <> {backButton} @@ -311,7 +313,7 @@ export const ColumnHeader: React.FC = ({ {extraButton} {collapseButton}

- +
= ({ image = 'default', title = ( @@ -26,11 +29,12 @@ export const EmptyState: React.FC<{ ), message, children, + className, }) => { const imageToRender = typeof image === 'string' ? images[image] : image; return ( -
+
{(title || message || imageToRender) && (
{imageToRender} diff --git a/app/javascript/mastodon/components/form_fields/text_input.module.scss b/app/javascript/mastodon/components/form_fields/text_input.module.scss index 105b956cc5..cbb45e0a3a 100644 --- a/app/javascript/mastodon/components/form_fields/text_input.module.scss +++ b/app/javascript/mastodon/components/form_fields/text_input.module.scss @@ -1,7 +1,6 @@ .input { box-sizing: border-box; display: block; - resize: vertical; width: 100%; padding: 10px 16px; font-family: inherit; @@ -50,6 +49,10 @@ } } +textarea.input { + resize: vertical; +} + .iconWrapper { position: relative; } diff --git a/app/javascript/mastodon/features/account_timeline/components/limited_account_hint.tsx b/app/javascript/mastodon/components/limited_account_hint.tsx similarity index 78% rename from app/javascript/mastodon/features/account_timeline/components/limited_account_hint.tsx rename to app/javascript/mastodon/components/limited_account_hint.tsx index f06bf574a7..b780e802e5 100644 --- a/app/javascript/mastodon/features/account_timeline/components/limited_account_hint.tsx +++ b/app/javascript/mastodon/components/limited_account_hint.tsx @@ -2,10 +2,11 @@ import { useCallback } from 'react'; import { FormattedMessage } from 'react-intl'; -import { revealAccount } from 'mastodon/actions/accounts_typed'; -import { Button } from 'mastodon/components/button'; -import { domain } from 'mastodon/initial_state'; -import { useAppDispatch } from 'mastodon/store'; +import { revealAccount } from '@/mastodon/actions/accounts_typed'; +import { domain } from '@/mastodon/initial_state'; +import { useAppDispatch } from '@/mastodon/store'; + +import { Button } from './button'; export const LimitedAccountHint: React.FC<{ accountId: string }> = ({ accountId, diff --git a/app/javascript/mastodon/components/modal_shell/index.tsx b/app/javascript/mastodon/components/modal_shell/index.tsx index 8b06087532..df8be5ef1f 100644 --- a/app/javascript/mastodon/components/modal_shell/index.tsx +++ b/app/javascript/mastodon/components/modal_shell/index.tsx @@ -1,16 +1,17 @@ import classNames from 'classnames'; +import { polymorphicForwardRef } from '@/types/polymorphic'; + interface ModalShellProps { className?: string; children?: React.ReactNode; } -export const ModalShell: React.FC = ({ - children, - className, -}) => { - return ( -
( + ({ as: Comp = 'form', children, className, ...restProps }, ref) => ( + = ({ )} > {children} -
- ); -}; + + ), +); +ModalShell.displayName = 'ModalShell'; export const ModalShellBody: React.FC = ({ children, diff --git a/app/javascript/mastodon/components/scrollable_list/components.tsx b/app/javascript/mastodon/components/scrollable_list/components.tsx index 9e90ce807e..5582c8e5b3 100644 --- a/app/javascript/mastodon/components/scrollable_list/components.tsx +++ b/app/javascript/mastodon/components/scrollable_list/components.tsx @@ -1,8 +1,10 @@ import type { ComponentPropsWithoutRef } from 'react'; -import { Children, forwardRef } from 'react'; +import { forwardRef } from 'react'; import classNames from 'classnames'; +import { hasReactChildren } from '@/mastodon/utils/has_react_children'; + import { LoadingIndicator } from '../loading_indicator'; export const Scrollable = forwardRef< @@ -36,7 +38,7 @@ export const ItemList = forwardRef< emptyMessage?: React.ReactNode; } >(({ isLoading, emptyMessage, className, children, ...otherProps }, ref) => { - if (!isLoading && Children.count(children) === 0 && emptyMessage) { + if (!isLoading && !hasReactChildren(children) && emptyMessage) { return (
{emptyMessage} diff --git a/app/javascript/mastodon/components/status_list.jsx b/app/javascript/mastodon/components/status_list.jsx index 817a9e9ce4..5e2c960ed1 100644 --- a/app/javascript/mastodon/components/status_list.jsx +++ b/app/javascript/mastodon/components/status_list.jsx @@ -8,7 +8,7 @@ import { debounce } from 'lodash'; import { TIMELINE_GAP, TIMELINE_PINNED_VIEW_ALL, TIMELINE_SUGGESTIONS } from 'mastodon/actions/timelines'; import { RegenerationIndicator } from 'mastodon/components/regeneration_indicator'; import { InlineFollowSuggestions } from 'mastodon/features/home_timeline/components/inline_follow_suggestions'; -import { PinnedShowAllButton } from '@/mastodon/features/account_timeline/v2/pinned_statuses'; +import { PinnedShowAllButton } from '@/mastodon/features/account_timeline/components/pinned_statuses'; import { StatusQuoteManager } from '../components/status_quoted'; diff --git a/app/javascript/mastodon/components/tab_list/index.tsx b/app/javascript/mastodon/components/tab_list/index.tsx new file mode 100644 index 0000000000..98651ff4d0 --- /dev/null +++ b/app/javascript/mastodon/components/tab_list/index.tsx @@ -0,0 +1,51 @@ +import type { ComponentPropsWithoutRef, FC } from 'react'; + +import classNames from 'classnames'; +import type { NavLinkProps } from 'react-router-dom'; +import { NavLink } from 'react-router-dom'; + +import classes from './styles.module.scss'; + +interface TabListProps { + /** + * Setting this will remove the default border and + * horizontal padding from the component + */ + plain?: boolean; +} + +/** + * Display a simple row of links as tabs. + * The current page will be highlighted automatically based on the link destination. + */ +export const TabList: FC> = ({ + plain, + className, + children, + ...otherProps +}) => { + return ( +
+ {children} +
+ ); +}; + +export const TabLink: FC = ({ + className, + children, + ...otherProps +}) => { + return ( + + {children} + + ); +}; diff --git a/app/javascript/mastodon/components/tab_list/styles.module.scss b/app/javascript/mastodon/components/tab_list/styles.module.scss new file mode 100644 index 0000000000..95a71114fd --- /dev/null +++ b/app/javascript/mastodon/components/tab_list/styles.module.scss @@ -0,0 +1,45 @@ +.tabList { + display: flex; + gap: 16px; +} + +.withSpaceAndBorder { + padding-inline: 16px; + border-bottom: 1px solid var(--color-border-primary); +} + +.tab { + display: block; + padding: 18px 0; + color: var(--color-text-primary); + font-size: 15px; + font-weight: 500; + text-decoration: none; + border-radius: 0; + transition: color 0.2s ease-in-out; + + @container (width < 500px) { + flex: 1 1 0px; + text-align: center; + } + + &:hover { + text-decoration: none; + } + + &:focus { + // Override silly global border radius on focused links + border-radius: 0; + outline-offset: 2px; + } + + &:not(:global(.active)):is(:hover, :focus) { + color: var(--color-text-brand-soft); + } + + &:global(.active) { + color: var(--color-text-brand); + border-bottom: 4px solid var(--color-border-brand); + padding-bottom: 14px; + } +} diff --git a/app/javascript/mastodon/components/tab_list/tab_list.stories.tsx b/app/javascript/mastodon/components/tab_list/tab_list.stories.tsx new file mode 100644 index 0000000000..56edb1b324 --- /dev/null +++ b/app/javascript/mastodon/components/tab_list/tab_list.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { TabList, TabLink } from './index'; + +const meta = { + title: 'Components/TabList', + component: TabList, + subcomponents: { TabLink }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + Activity + Media + Featured + + ), +}; + +export const Plain: Story = { + render: () => ( + + Activity + Media + Featured + + ), +}; diff --git a/app/javascript/mastodon/containers/compose_container.jsx b/app/javascript/mastodon/containers/compose_container.jsx index 3e6d20c74c..33fc3a79a3 100644 --- a/app/javascript/mastodon/containers/compose_container.jsx +++ b/app/javascript/mastodon/containers/compose_container.jsx @@ -1,6 +1,5 @@ import { Provider } from 'react-redux'; -import { fetchCustomEmojis } from 'mastodon/actions/custom_emojis'; import { fetchServer } from 'mastodon/actions/server'; import { hydrateStore } from 'mastodon/actions/store'; import { Router } from 'mastodon/components/router'; @@ -13,7 +12,6 @@ if (initialState) { store.dispatch(hydrateStore(initialState)); } -store.dispatch(fetchCustomEmojis()); store.dispatch(fetchServer()); const ComposeContainer = () => ( diff --git a/app/javascript/mastodon/containers/mastodon.jsx b/app/javascript/mastodon/containers/mastodon.jsx index ee861366a5..f697c5a8b0 100644 --- a/app/javascript/mastodon/containers/mastodon.jsx +++ b/app/javascript/mastodon/containers/mastodon.jsx @@ -5,8 +5,6 @@ import { Route } from 'react-router-dom'; import { Provider as ReduxProvider } from 'react-redux'; - -import { fetchCustomEmojis } from 'mastodon/actions/custom_emojis'; import { hydrateStore } from 'mastodon/actions/store'; import { connectUserStream } from 'mastodon/actions/streaming'; import ErrorBoundary from 'mastodon/components/error_boundary'; @@ -26,9 +24,6 @@ const title = isProduction() ? siteTitle : `${siteTitle} (Dev)`; const hydrateAction = hydrateStore(initialState); store.dispatch(hydrateAction); -if (initialState.meta.me) { - store.dispatch(fetchCustomEmojis()); -} export default class Mastodon extends PureComponent { identity = createIdentityContext(initialState); diff --git a/app/javascript/mastodon/features/account_edit/index.tsx b/app/javascript/mastodon/features/account_edit/index.tsx index d3a3c96707..0199c3efa4 100644 --- a/app/javascript/mastodon/features/account_edit/index.tsx +++ b/app/javascript/mastodon/features/account_edit/index.tsx @@ -107,11 +107,11 @@ export const messages = defineMessages({ }, profileTabTitle: { id: 'account_edit.profile_tab.title', - defaultMessage: 'Profile tab settings', + defaultMessage: 'Profile display settings', }, profileTabSubtitle: { id: 'account_edit.profile_tab.subtitle', - defaultMessage: 'Customize the tabs on your profile and what they display.', + defaultMessage: 'Customize how your profile is displayed.', }, advancedSettingsTitle: { id: 'account_edit.advanced_settings.title', diff --git a/app/javascript/mastodon/features/account_edit/modals/profile_display_modal.tsx b/app/javascript/mastodon/features/account_edit/modals/profile_display_modal.tsx index dc2d961c69..d832fe00e3 100644 --- a/app/javascript/mastodon/features/account_edit/modals/profile_display_modal.tsx +++ b/app/javascript/mastodon/features/account_edit/modals/profile_display_modal.tsx @@ -27,7 +27,8 @@ export const ProfileDisplayModal: FC = ({ onClose }) => { const handleToggleChange: ChangeEventHandler = useCallback( (event) => { const { name, checked } = event.target; - void dispatch(patchProfile({ [name]: checked })); + const targetChecked = name === 'hide_collections' ? !checked : checked; + void dispatch(patchProfile({ [name]: targetChecked })); }, [dispatch], ); @@ -101,6 +102,25 @@ export const ProfileDisplayModal: FC = ({ onClose }) => { /> } /> + + + } + hint={ + + } + />
= ({ ) as ImmutableList, ); const { collections, status: collectionsLoadStatus } = - useAccountCollections(accountId); + useCollectionsCreatedBy(accountId); const { listedCollections = [], unlistedCollections = [] } = Object.groupBy( collections, diff --git a/app/javascript/mastodon/features/account_gallery/index.tsx b/app/javascript/mastodon/features/account_gallery/index.tsx index 069f1242f7..feba6f454d 100644 --- a/app/javascript/mastodon/features/account_gallery/index.tsx +++ b/app/javascript/mastodon/features/account_gallery/index.tsx @@ -4,23 +4,23 @@ import { FormattedMessage } from 'react-intl'; import { List as ImmutableList, isList } from 'immutable'; -import { openModal } from 'mastodon/actions/modal'; -import { expandAccountMediaTimeline } from 'mastodon/actions/timelines'; -import { ColumnBackButton } from 'mastodon/components/column_back_button'; -import { RemoteHint } from 'mastodon/components/remote_hint'; -import ScrollableList from 'mastodon/components/scrollable_list'; -import { AccountHeader } from 'mastodon/features/account_timeline/components/account_header'; -import { LimitedAccountHint } from 'mastodon/features/account_timeline/components/limited_account_hint'; -import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error'; -import Column from 'mastodon/features/ui/components/column'; -import { useAccountId } from 'mastodon/hooks/useAccountId'; -import { useAccountVisibility } from 'mastodon/hooks/useAccountVisibility'; -import type { MediaAttachment } from 'mastodon/models/media_attachment'; +import { openModal } from '@/mastodon/actions/modal'; +import { expandAccountMediaTimeline } from '@/mastodon/actions/timelines'; +import { AccountHeader } from '@/mastodon/components/account_header'; +import { ColumnBackButton } from '@/mastodon/components/column_back_button'; +import { LimitedAccountHint } from '@/mastodon/components/limited_account_hint'; +import { RemoteHint } from '@/mastodon/components/remote_hint'; +import ScrollableList from '@/mastodon/components/scrollable_list'; +import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error'; +import Column from '@/mastodon/features/ui/components/column'; +import { useAccountId } from '@/mastodon/hooks/useAccountId'; +import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility'; +import type { MediaAttachment } from '@/mastodon/models/media_attachment'; import { useAppSelector, useAppDispatch, createAppSelector, -} from 'mastodon/store'; +} from '@/mastodon/store'; import { MediaItem } from './components/media_item'; diff --git a/app/javascript/mastodon/features/account_timeline/common.ts b/app/javascript/mastodon/features/account_timeline/common.ts deleted file mode 100644 index b036530e14..0000000000 --- a/app/javascript/mastodon/features/account_timeline/common.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { AccountFieldShape } from '@/mastodon/models/account'; - -export interface AccountField extends AccountFieldShape { - nameHasEmojis: boolean; - value_plain: string; - valueHasEmojis: boolean; -} diff --git a/app/javascript/mastodon/features/account_timeline/v2/featured_tags.tsx b/app/javascript/mastodon/features/account_timeline/components/featured_tags.tsx similarity index 96% rename from app/javascript/mastodon/features/account_timeline/v2/featured_tags.tsx rename to app/javascript/mastodon/features/account_timeline/components/featured_tags.tsx index b8061fced4..c4d43bb2b9 100644 --- a/app/javascript/mastodon/features/account_timeline/v2/featured_tags.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/featured_tags.tsx @@ -13,8 +13,8 @@ import { useOverflowButton } from '@/mastodon/hooks/useOverflow'; import { selectAccountFeaturedTags } from '@/mastodon/selectors/accounts'; import { useAppDispatch, useAppSelector } from '@/mastodon/store'; -import { useAccountContext } from './context'; -import classes from './styles.module.scss'; +import { useAccountContext } from '../hooks/useAccountContext'; +import classes from '../styles.module.scss'; export const FeaturedTags: FC<{ accountId: string }> = ({ accountId }) => { // Fetch tags. diff --git a/app/javascript/mastodon/features/account_timeline/v2/filters.tsx b/app/javascript/mastodon/features/account_timeline/components/filters.tsx similarity index 95% rename from app/javascript/mastodon/features/account_timeline/v2/filters.tsx rename to app/javascript/mastodon/features/account_timeline/components/filters.tsx index 2dc8254a20..e24ca64209 100644 --- a/app/javascript/mastodon/features/account_timeline/v2/filters.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/filters.tsx @@ -7,14 +7,13 @@ import { useParams } from 'react-router'; import Overlay from 'react-overlays/esm/Overlay'; +import { AccountTabs } from '@/mastodon/components/account_header/tabs'; import { Toggle } from '@/mastodon/components/form_fields'; import { Icon } from '@/mastodon/components/icon'; import KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down.svg?react'; -import { AccountTabs } from '../components/tabs'; - -import { useAccountContext } from './context'; -import classes from './styles.module.scss'; +import { useAccountContext } from '../hooks/useAccountContext'; +import classes from '../styles.module.scss'; export const AccountFilters: FC = () => { const { acct } = useParams<{ acct: string }>(); diff --git a/app/javascript/mastodon/features/account_timeline/components/info.tsx b/app/javascript/mastodon/features/account_timeline/components/info.tsx deleted file mode 100644 index bb99999c41..0000000000 --- a/app/javascript/mastodon/features/account_timeline/components/info.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import type { FC } from 'react'; - -import { FormattedMessage } from 'react-intl'; - -import type { Relationship } from '@/mastodon/models/relationship'; - -export const AccountInfo: FC<{ relationship?: Relationship }> = ({ - relationship, -}) => { - if (!relationship) { - return null; - } - return ( -
- {(relationship.followed_by || relationship.requested_by) && ( - - - - )} - {relationship.blocking && ( - - - - )} - {relationship.muting && ( - - - - )} - {relationship.domain_blocking && ( - - - - )} -
- ); -}; - -const AccountInfoFollower: FC<{ relationship: Relationship }> = ({ - relationship, -}) => { - if ( - relationship.followed_by && - (relationship.following || relationship.requested) - ) { - return ( - - ); - } else if (relationship.followed_by) { - return ( - - ); - } else if (relationship.requested_by) { - return ( - - ); - } - return null; -}; diff --git a/app/javascript/mastodon/features/account_timeline/components/pinned_statuses.tsx b/app/javascript/mastodon/features/account_timeline/components/pinned_statuses.tsx new file mode 100644 index 0000000000..11de336002 --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/components/pinned_statuses.tsx @@ -0,0 +1,55 @@ +import type { FC } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import IconPinned from '@/images/icons/icon_pinned.svg?react'; +import { Badge } from '@/mastodon/components/badge'; +import { Button } from '@/mastodon/components/button'; +import { Icon } from '@/mastodon/components/icon'; +import { StatusHeader } from '@/mastodon/components/status/header'; +import type { StatusHeaderRenderFn } from '@/mastodon/components/status/header'; + +import { useAccountContext } from '../hooks/useAccountContext'; +import classes from '../styles.module.scss'; + +export const renderPinnedStatusHeader: StatusHeaderRenderFn = ({ + featured, + ...args +}) => { + if (!featured) { + return ; + } + return ( + + } + label={ + + } + /> + + ); +}; + +export const PinnedShowAllButton: FC = () => { + const { onShowAllPinned } = useAccountContext(); + + return ( + + ); +}; diff --git a/app/javascript/mastodon/features/account_timeline/v2/tags_suggestions.tsx b/app/javascript/mastodon/features/account_timeline/components/tags_suggestions.tsx similarity index 98% rename from app/javascript/mastodon/features/account_timeline/v2/tags_suggestions.tsx rename to app/javascript/mastodon/features/account_timeline/components/tags_suggestions.tsx index f3ac52ca3b..555dc6e7c9 100644 --- a/app/javascript/mastodon/features/account_timeline/v2/tags_suggestions.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/tags_suggestions.tsx @@ -20,7 +20,7 @@ import { createAppSelector, } from '@/mastodon/store'; -import classes from './styles.module.scss'; +import classes from '../styles.module.scss'; const MAX_SUGGESTED_TAGS = 3; diff --git a/app/javascript/mastodon/features/account_timeline/v2/context.tsx b/app/javascript/mastodon/features/account_timeline/hooks/useAccountContext.ts similarity index 82% rename from app/javascript/mastodon/features/account_timeline/v2/context.tsx rename to app/javascript/mastodon/features/account_timeline/hooks/useAccountContext.ts index c95eba3733..46595425bd 100644 --- a/app/javascript/mastodon/features/account_timeline/v2/context.tsx +++ b/app/javascript/mastodon/features/account_timeline/hooks/useAccountContext.ts @@ -1,4 +1,3 @@ -import type { FC, ReactNode } from 'react'; import { createContext, useCallback, @@ -10,7 +9,7 @@ import { import { useStorageState } from '@/mastodon/hooks/useStorage'; interface AccountTimelineContextValue { - accountId: string; + accountId: string | null; boosts: boolean; replies: boolean; showAllPinned: boolean; @@ -19,13 +18,20 @@ interface AccountTimelineContextValue { onShowAllPinned: () => void; } -const AccountTimelineContext = +export const AccountTimelineContext = createContext(null); -export const AccountTimelineProvider: FC<{ - accountId: string; - children: ReactNode; -}> = ({ accountId, children }) => { +export function useAccountContext() { + const values = useContext(AccountTimelineContext); + if (!values) { + throw new Error( + 'useAccountFilters must be used within an AccountTimelineProvider', + ); + } + return values; +} + +export const useAccountContextValue = (accountId?: string | null) => { const storageOptions = { type: 'local', prefix: 'account-filters', @@ -62,9 +68,9 @@ export const AccountTimelineProvider: FC<{ }, []); // Memoize the context value to avoid unnecessary re-renders. - const value = useMemo( + return useMemo( () => ({ - accountId, + accountId: accountId ?? null, boosts, replies, showAllPinned, @@ -82,20 +88,4 @@ export const AccountTimelineProvider: FC<{ showAllPinned, ], ); - - return ( - - {children} - - ); }; - -export function useAccountContext() { - const values = useContext(AccountTimelineContext); - if (!values) { - throw new Error( - 'useAccountFilters must be used within an AccountTimelineProvider', - ); - } - return values; -} diff --git a/app/javascript/mastodon/features/account_timeline/hooks/usePinned.ts b/app/javascript/mastodon/features/account_timeline/hooks/usePinned.ts new file mode 100644 index 0000000000..2ff2c16965 --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/hooks/usePinned.ts @@ -0,0 +1,58 @@ +import { useEffect, useMemo } from 'react'; + +import { TIMELINE_PINNED_VIEW_ALL } from '@/mastodon/actions/timelines'; +import { + expandTimelineByKey, + timelineKey, +} from '@/mastodon/actions/timelines_typed'; +import { selectTimelineByKey } from '@/mastodon/selectors/timelines'; +import { useAppDispatch, useAppSelector } from '@/mastodon/store'; + +import { useAccountContext } from './useAccountContext'; + +export function usePinnedStatusIds({ + accountId, + tagged, + forceEmptyState = false, +}: { + accountId: string; + tagged?: string; + forceEmptyState?: boolean; +}) { + const pinnedKey = timelineKey({ + type: 'account', + userId: accountId, + tagged, + pinned: true, + replies: true, + }); + + const dispatch = useAppDispatch(); + useEffect(() => { + dispatch(expandTimelineByKey({ key: pinnedKey })); + }, [dispatch, pinnedKey]); + + const pinnedTimeline = useAppSelector((state) => + selectTimelineByKey(state, pinnedKey), + ); + + const { showAllPinned } = useAccountContext(); + + const pinnedTimelineItems = pinnedTimeline?.items; // Make a const to avoid the React Compiler complaining. + const pinnedStatusIds = useMemo(() => { + if (!pinnedTimelineItems || forceEmptyState) { + return undefined; + } + + if (pinnedTimelineItems.size <= 1 || showAllPinned) { + return pinnedTimelineItems; + } + return pinnedTimelineItems.slice(0, 1).push(TIMELINE_PINNED_VIEW_ALL); + }, [forceEmptyState, pinnedTimelineItems, showAllPinned]); + + return { + statusIds: pinnedStatusIds, + isLoading: !!pinnedTimeline?.isLoading, + showAllPinned, + }; +} diff --git a/app/javascript/mastodon/features/account_timeline/index.jsx b/app/javascript/mastodon/features/account_timeline/index.jsx deleted file mode 100644 index 70e607d5f2..0000000000 --- a/app/javascript/mastodon/features/account_timeline/index.jsx +++ /dev/null @@ -1,197 +0,0 @@ -import PropTypes from 'prop-types'; - -import { FormattedMessage } from 'react-intl'; - -import { List as ImmutableList } from 'immutable'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { connect } from 'react-redux'; - -import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error'; -import { me } from 'mastodon/initial_state'; -import { normalizeForLookup } from 'mastodon/reducers/accounts_map'; -import { getAccountHidden } from 'mastodon/selectors/accounts'; - -import { lookupAccount, fetchAccount } from '../../actions/accounts'; -import { expandAccountFeaturedTimeline, expandAccountTimeline, connectTimeline, disconnectTimeline } from '../../actions/timelines'; -import { ColumnBackButton } from '../../components/column_back_button'; -import { LoadingIndicator } from '../../components/loading_indicator'; -import StatusList from '../../components/status_list'; -import Column from '../ui/components/column'; -import { RemoteHint } from 'mastodon/components/remote_hint'; - -import { AccountHeader } from './components/account_header'; -import { LimitedAccountHint } from './components/limited_account_hint'; -import { FeaturedCarousel } from '@/mastodon/components/featured_carousel'; - -const emptyList = ImmutableList(); - -const mapStateToProps = (state, { params: { acct, id, tagged }, withReplies = false }) => { - const accountId = id || state.accounts_map[normalizeForLookup(acct)]; - - if (accountId === null) { - return { - isLoading: false, - isAccount: false, - statusIds: emptyList, - }; - } else if (!accountId) { - return { - isLoading: true, - statusIds: emptyList, - }; - } - - const path = withReplies ? `${accountId}:with_replies` : `${accountId}${tagged ? `:${tagged}` : ''}`; - - return { - accountId, - isAccount: !!state.getIn(['accounts', accountId]), - statusIds: state.getIn(['timelines', `account:${path}`, 'items'], emptyList), - isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']), - hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']), - suspended: state.getIn(['accounts', accountId, 'suspended'], false), - hidden: getAccountHidden(state, accountId), - blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false), - }; -}; - -class AccountTimeline extends ImmutablePureComponent { - - static propTypes = { - params: PropTypes.shape({ - acct: PropTypes.string, - id: PropTypes.string, - tagged: PropTypes.string, - }).isRequired, - accountId: PropTypes.string, - dispatch: PropTypes.func.isRequired, - statusIds: ImmutablePropTypes.list, - isLoading: PropTypes.bool, - hasMore: PropTypes.bool, - withReplies: PropTypes.bool, - blockedBy: PropTypes.bool, - isAccount: PropTypes.bool, - suspended: PropTypes.bool, - hidden: PropTypes.bool, - multiColumn: PropTypes.bool, - }; - - _load () { - const { accountId, withReplies, params: { tagged }, dispatch } = this.props; - - dispatch(fetchAccount(accountId)); - - if (!withReplies) { - dispatch(expandAccountFeaturedTimeline(accountId, { tagged })); - } - - dispatch(expandAccountTimeline(accountId, { withReplies, tagged })); - - if (accountId === me) { - dispatch(connectTimeline(`account:${me}`)); - } - } - - componentDidMount () { - const { params: { acct }, accountId, dispatch } = this.props; - - if (accountId) { - this._load(); - } else { - dispatch(lookupAccount(acct)); - } - } - - componentDidUpdate (prevProps) { - const { params: { acct, tagged }, accountId, withReplies, dispatch } = this.props; - - if (prevProps.accountId !== accountId && accountId) { - this._load(); - } else if (prevProps.params.acct !== acct) { - dispatch(lookupAccount(acct)); - } else if (prevProps.params.tagged !== tagged) { - if (!withReplies) { - dispatch(expandAccountFeaturedTimeline(accountId, { tagged })); - } - dispatch(expandAccountTimeline(accountId, { withReplies, tagged })); - } - - if (prevProps.accountId === me && accountId !== me) { - dispatch(disconnectTimeline({ timeline: `account:${me}` })); - } - } - - componentWillUnmount () { - const { dispatch, accountId } = this.props; - - if (accountId === me) { - dispatch(disconnectTimeline({ timeline: `account:${me}` })); - } - } - - handleLoadMore = maxId => { - this.props.dispatch(expandAccountTimeline(this.props.accountId, { maxId, withReplies: this.props.withReplies, tagged: this.props.params.tagged })); - }; - - render () { - const { accountId, statusIds, isLoading, hasMore, blockedBy, suspended, isAccount, hidden, multiColumn, remote, remoteUrl, params: { tagged } } = this.props; - - if (isLoading && statusIds.isEmpty()) { - return ( - - - - ); - } else if (!isLoading && !isAccount) { - return ( - - ); - } - - let emptyMessage; - - const forceEmptyState = suspended || blockedBy || hidden; - - if (suspended) { - emptyMessage = ; - } else if (hidden) { - emptyMessage = ; - } else if (blockedBy) { - emptyMessage = ; - } else if (remote && statusIds.isEmpty()) { - emptyMessage = ; - } else { - emptyMessage = ; - } - - return ( - - - - - - {!forceEmptyState && } - - } - alwaysPrepend - append={} - scrollKey='account_timeline' - statusIds={forceEmptyState ? emptyList : statusIds} - isLoading={isLoading} - hasMore={!forceEmptyState && hasMore} - onLoadMore={this.handleLoadMore} - emptyMessage={emptyMessage} - bindToDocument={!multiColumn} - timelineId='account' - withCounters - /> - - ); - } - -} - -export default connect(mapStateToProps)(AccountTimeline); diff --git a/app/javascript/mastodon/features/account_timeline/v2/index.tsx b/app/javascript/mastodon/features/account_timeline/index.tsx similarity index 85% rename from app/javascript/mastodon/features/account_timeline/v2/index.tsx rename to app/javascript/mastodon/features/account_timeline/index.tsx index 693813bdbe..ce1e99a45f 100644 --- a/app/javascript/mastodon/features/account_timeline/v2/index.tsx +++ b/app/javascript/mastodon/features/account_timeline/index.tsx @@ -12,8 +12,10 @@ import { expandTimelineByKey, timelineKey, } from '@/mastodon/actions/timelines_typed'; +import { AccountHeader } from '@/mastodon/components/account_header'; import { Column } from '@/mastodon/components/column'; import { ColumnBackButton } from '@/mastodon/components/column_back_button'; +import { LimitedAccountHint } from '@/mastodon/components/limited_account_hint'; import { LoadingIndicator } from '@/mastodon/components/loading_indicator'; import { RemoteHint } from '@/mastodon/components/remote_hint'; import StatusList from '@/mastodon/components/status_list'; @@ -26,23 +28,23 @@ import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility'; import { selectTimelineByKey } from '@/mastodon/selectors/timelines'; import { useAppDispatch, useAppSelector } from '@/mastodon/store'; -import { AccountHeader } from '../components/account_header'; -import { LimitedAccountHint } from '../components/limited_account_hint'; - -import { AccountTimelineProvider, useAccountContext } from './context'; -import { FeaturedTags } from './featured_tags'; -import { AccountFilters } from './filters'; +import { FeaturedTags } from './components/featured_tags'; +import { AccountFilters } from './components/filters'; +import { renderPinnedStatusHeader } from './components/pinned_statuses'; +import { TagSuggestions } from './components/tags_suggestions'; import { - renderPinnedStatusHeader, - usePinnedStatusIds, -} from './pinned_statuses'; + AccountTimelineContext, + useAccountContext, + useAccountContextValue, +} from './hooks/useAccountContext'; +import { usePinnedStatusIds } from './hooks/usePinned'; import classes from './styles.module.scss'; -import { TagSuggestions } from './tags_suggestions'; const emptyList = ImmutableList(); -const AccountTimelineV2: FC<{ multiColumn: boolean }> = ({ multiColumn }) => { +const AccountTimeline: FC<{ multiColumn: boolean }> = ({ multiColumn }) => { const accountId = useAccountId(); + const accountContext = useAccountContextValue(accountId); // Null means accountId does not exist (e.g. invalid acct). Undefined means loading. if (accountId === null) { @@ -59,13 +61,13 @@ const AccountTimelineV2: FC<{ multiColumn: boolean }> = ({ multiColumn }) => { // Add this key to remount the timeline when accountId changes. return ( - + - + ); }; @@ -183,4 +185,4 @@ const EmptyMessage: FC<{ accountId: string }> = ({ accountId }) => { }; // eslint-disable-next-line import/no-default-export -export default AccountTimelineV2; +export default AccountTimeline; diff --git a/app/javascript/mastodon/features/account_timeline/modals/field_modal.tsx b/app/javascript/mastodon/features/account_timeline/modals/field_modal.tsx index d2b05703c2..baf0c70f76 100644 --- a/app/javascript/mastodon/features/account_timeline/modals/field_modal.tsx +++ b/app/javascript/mastodon/features/account_timeline/modals/field_modal.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react'; import { FormattedMessage } from 'react-intl'; +import type { AccountField } from '@/mastodon/components/account_header/fields'; import { Button } from '@/mastodon/components/button'; import { EmojiHTML } from '@/mastodon/components/emoji/html'; import { @@ -10,7 +11,6 @@ import { ModalShellBody, } from '@/mastodon/components/modal_shell'; -import type { AccountField } from '../common'; import { useFieldHtml } from '../hooks/useFieldHtml'; import classes from './styles.module.scss'; diff --git a/app/javascript/mastodon/features/account_timeline/modals/join_modal.tsx b/app/javascript/mastodon/features/account_timeline/modals/join_modal.tsx index 0d21c82482..e5401fbebe 100644 --- a/app/javascript/mastodon/features/account_timeline/modals/join_modal.tsx +++ b/app/javascript/mastodon/features/account_timeline/modals/join_modal.tsx @@ -97,7 +97,7 @@ export const AccountJoinModal: FC<{ }, [anniversary, handle, dispatch, isMe]); return ( - + diff --git a/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss b/app/javascript/mastodon/features/account_timeline/styles.module.scss similarity index 100% rename from app/javascript/mastodon/features/account_timeline/v2/styles.module.scss rename to app/javascript/mastodon/features/account_timeline/styles.module.scss diff --git a/app/javascript/mastodon/features/account_timeline/v2/pinned_statuses.tsx b/app/javascript/mastodon/features/account_timeline/v2/pinned_statuses.tsx deleted file mode 100644 index 78dad72478..0000000000 --- a/app/javascript/mastodon/features/account_timeline/v2/pinned_statuses.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import type { FC } from 'react'; -import { useEffect, useMemo } from 'react'; - -import { FormattedMessage } from 'react-intl'; - -import classNames from 'classnames'; - -import IconPinned from '@/images/icons/icon_pinned.svg?react'; -import { TIMELINE_PINNED_VIEW_ALL } from '@/mastodon/actions/timelines'; -import { - expandTimelineByKey, - timelineKey, -} from '@/mastodon/actions/timelines_typed'; -import { Badge } from '@/mastodon/components/badge'; -import { Button } from '@/mastodon/components/button'; -import { Icon } from '@/mastodon/components/icon'; -import { StatusHeader } from '@/mastodon/components/status/header'; -import type { StatusHeaderRenderFn } from '@/mastodon/components/status/header'; -import { selectTimelineByKey } from '@/mastodon/selectors/timelines'; -import { useAppDispatch, useAppSelector } from '@/mastodon/store'; - -import { useAccountContext } from './context'; -import classes from './styles.module.scss'; - -export function usePinnedStatusIds({ - accountId, - tagged, - forceEmptyState = false, -}: { - accountId: string; - tagged?: string; - forceEmptyState?: boolean; -}) { - const pinnedKey = timelineKey({ - type: 'account', - userId: accountId, - tagged, - pinned: true, - replies: true, - }); - - const dispatch = useAppDispatch(); - useEffect(() => { - dispatch(expandTimelineByKey({ key: pinnedKey })); - }, [dispatch, pinnedKey]); - - const pinnedTimeline = useAppSelector((state) => - selectTimelineByKey(state, pinnedKey), - ); - - const { showAllPinned } = useAccountContext(); - - const pinnedTimelineItems = pinnedTimeline?.items; // Make a const to avoid the React Compiler complaining. - const pinnedStatusIds = useMemo(() => { - if (!pinnedTimelineItems || forceEmptyState) { - return undefined; - } - - if (pinnedTimelineItems.size <= 1 || showAllPinned) { - return pinnedTimelineItems; - } - return pinnedTimelineItems.slice(0, 1).push(TIMELINE_PINNED_VIEW_ALL); - }, [forceEmptyState, pinnedTimelineItems, showAllPinned]); - - return { - statusIds: pinnedStatusIds, - isLoading: !!pinnedTimeline?.isLoading, - showAllPinned, - }; -} - -export const renderPinnedStatusHeader: StatusHeaderRenderFn = ({ - featured, - ...args -}) => { - if (!featured) { - return ; - } - return ( - - } - label={ - - } - /> - - ); -}; - -export const PinnedShowAllButton: FC = () => { - const { onShowAllPinned } = useAccountContext(); - - return ( - - ); -}; diff --git a/app/javascript/mastodon/features/account_timeline/v2/status_header.tsx b/app/javascript/mastodon/features/account_timeline/v2/status_header.tsx deleted file mode 100644 index 5f0ff88685..0000000000 --- a/app/javascript/mastodon/features/account_timeline/v2/status_header.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import type { FC } from 'react'; - -import { Link } from 'react-router-dom'; - -import { RelativeTimestamp } from '@/mastodon/components/relative_timestamp'; -import type { StatusHeaderProps } from '@/mastodon/components/status/header'; -import { - StatusDisplayName, - StatusEditedAt, - StatusVisibility, -} from '@/mastodon/components/status/header'; -import type { Account } from '@/mastodon/models/account'; - -export const AccountStatusHeader: FC = ({ - status, - account, - children, - avatarSize = 48, - wrapperProps, - onHeaderClick, -}) => { - const statusAccount = status.get('account') as Account | undefined; - const editedAt = status.get('edited_at') as string; - - return ( - /* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */ -
- - - - {editedAt && } - - - - - {children} -
- ); -}; diff --git a/app/javascript/mastodon/features/collections/components/collection_list_item.module.scss b/app/javascript/mastodon/features/collections/components/collection_list_item.module.scss index 5d440d62f7..1079d1aebf 100644 --- a/app/javascript/mastodon/features/collections/components/collection_list_item.module.scss +++ b/app/javascript/mastodon/features/collections/components/collection_list_item.module.scss @@ -1,6 +1,5 @@ .wrapper { --list-item-padding: 16px; - --list-item-padding-block: 12px; --list-item-gap: 16px; &:not(.wrapperWithoutBorder) { diff --git a/app/javascript/mastodon/features/collections/components/collection_lockup.tsx b/app/javascript/mastodon/features/collections/components/collection_lockup.tsx index 9593f02e2d..22c584559f 100644 --- a/app/javascript/mastodon/features/collections/components/collection_lockup.tsx +++ b/app/javascript/mastodon/features/collections/components/collection_lockup.tsx @@ -111,7 +111,7 @@ export const CollectionLockup: React.FC = ({ sideContent={sideContent} > diff --git a/app/javascript/mastodon/features/collections/detail/accounts_list.tsx b/app/javascript/mastodon/features/collections/detail/accounts_list.tsx index 1127bdad24..01718381c9 100644 --- a/app/javascript/mastodon/features/collections/detail/accounts_list.tsx +++ b/app/javascript/mastodon/features/collections/detail/accounts_list.tsx @@ -2,8 +2,12 @@ import { useCallback, useMemo, useRef, useState } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { PendingBadge } from '@/mastodon/components/badge'; import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react'; -import type { ApiCollectionJSON } from 'mastodon/api_types/collections'; +import type { + ApiCollectionJSON, + CollectionAccountItem, +} from 'mastodon/api_types/collections'; import type { RenderButtonOptions } from 'mastodon/components/account_list_item'; import { AccountListItem, @@ -75,47 +79,53 @@ const SensitiveScreen: React.FC<{ ); }; -const getCollectionAccounts = createAppSelector( +type CollectionItemWithAccount = CollectionAccountItem & { + account?: Account | null; +}; + +const getCollectionItems = createAppSelector( [ (state) => state.accounts, (state, collectionId?: string) => state.collections.collections[collectionId ?? '']?.items, ], (accounts, collectionAccountItems) => - (collectionAccountItems ?? []).map(({ account_id }) => - account_id ? accounts.get(account_id) : null, + (collectionAccountItems ?? []).map( + (item): CollectionItemWithAccount => ({ + ...item, + account: item.account_id ? accounts.get(item.account_id) : null, + }), ), ); export const CollectionAccountsList: React.FC<{ - collection?: ApiCollectionJSON; - isLoading: boolean; -}> = ({ collection, isLoading }) => { + collection: ApiCollectionJSON; +}> = ({ collection }) => { const intl = useIntl(); const confirmRevoke = useConfirmRevoke(collection); const listHeadingRef = useRef(null); - const isOwnCollection = collection?.account_id === me; - const { account_id: collectionOwnerId, id } = collection ?? {}; + const isOwnCollection = collection.account_id === me; + const { account_id: collectionOwnerId, id } = collection; const relationships = useAppSelector((state) => state.relationships); const collectionAccounts = useAppSelector((state) => - getCollectionAccounts(state, id), + getCollectionItems(state, id), ); const { visibleAccounts, hiddenAccounts } = useMemo(() => { - const visibleAccounts: Account[] = []; - const hiddenAccounts: Account[] = []; + const visibleAccounts: CollectionItemWithAccount[] = []; + const hiddenAccounts: CollectionItemWithAccount[] = []; collectionAccounts.forEach((item) => { - if (!item) { - // We currently simply hide unavailable accounts, this includes - // accounts that are pending inclusion; at least for the collection - // owner we should display an indication of pending users + const { account, account_id } = item; + + if (!isOwnCollection && !account) { + // Hide unavailable accounts unless you own this collection return; } - const relationship = relationships.get(item.id); + const relationship = account_id ? relationships.get(account_id) : null; if (relationship?.blocking || relationship?.muting) { hiddenAccounts.push(item); } else { @@ -124,7 +134,7 @@ export const CollectionAccountsList: React.FC<{ }); return { visibleAccounts, hiddenAccounts }; - }, [collectionAccounts, relationships]); + }, [collectionAccounts, isOwnCollection, relationships]); const renderAccountItemButton = useCallback( ({ relationship, accountId }: RenderButtonOptions) => { @@ -159,15 +169,16 @@ export const CollectionAccountsList: React.FC<{ index, totalListLength, isLastElement, - }: TruncatedListItemInfo) => ( + }: TruncatedListItemInfo) => (
: null} renderButton={renderAccountItemButton} />
@@ -182,53 +193,41 @@ export const CollectionAccountsList: React.FC<{ tabIndex={-1} ref={listHeadingRef} > - {collection ? ( - - ) : ( - - )} + - {collection && ( - - - - ), - subtitle: ( - - ), - }} - renderListItem={renderListItem} - /> - - - )} + + + + ), + subtitle: ( + + ), + }} + renderListItem={renderListItem} + /> + + ); }; diff --git a/app/javascript/mastodon/features/collections/detail/index.tsx b/app/javascript/mastodon/features/collections/detail/index.tsx index d3fbd4c2cb..12701d1fd4 100644 --- a/app/javascript/mastodon/features/collections/detail/index.tsx +++ b/app/javascript/mastodon/features/collections/detail/index.tsx @@ -6,17 +6,24 @@ import { Helmet } from 'react-helmet'; import { useHistory, useLocation, useParams } from 'react-router'; import { Link } from 'react-router-dom'; -import { openModal } from '@/mastodon/actions/modal'; -import { useAccountHandle } from '@/mastodon/components/display_name/default'; +import HelpIcon from '@/material-icons/400-24px/help.svg?react'; import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; import ShareIcon from '@/material-icons/400-24px/share.svg?react'; -import type { ApiCollectionJSON } from 'mastodon/api_types/collections'; +import StarIcon from '@/material-icons/400-24px/star.svg?react'; +import { openModal } from 'mastodon/actions/modal'; +import type { + ApiCollectionJSON, + CollectionAccountItem, +} from 'mastodon/api_types/collections'; import { Badge } from 'mastodon/components/badge'; import { Callout } from 'mastodon/components/callout'; import { Column } from 'mastodon/components/column'; import { ColumnHeader } from 'mastodon/components/column_header'; import { DisplayName } from 'mastodon/components/display_name'; +import { useAccountHandle } from 'mastodon/components/display_name/default'; +import { FormattedDateWrapper } from 'mastodon/components/formatted_date'; import { IconButton } from 'mastodon/components/icon_button'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import { Scrollable } from 'mastodon/components/scrollable_list/components'; import { useAccount } from 'mastodon/hooks/useAccount'; import { domain, me } from 'mastodon/initial_state'; @@ -68,13 +75,15 @@ export const AuthorNote: React.FC<{ id: string }> = ({ id }) => { }; const RevokeControls: React.FC<{ + currentUserCollectionItem: CollectionAccountItem; collection: ApiCollectionJSON; -}> = ({ collection }) => { +}> = ({ currentUserCollectionItem, collection }) => { const authorAccount = useAccount(collection.account_id); const confirmRevoke = useConfirmRevoke(collection); return ( , - date: '{date}', // TODO: Data not yet provided by API + date: ( + + ), }} /> ); }; +export const PendingNote: React.FC = () => { + return ( + + } + > + + + ); +}; + const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({ collection, }) => { @@ -110,10 +146,11 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({ const history = useHistory(); const isOwnCollection = account_id === me; - const currentUserIndex = items.findIndex( + const currentUserCollectionItem = items.find( (account) => account.account_id === me, ); - const isCurrentUserInCollection = !isOwnCollection && currentUserIndex > -1; + const isCurrentUserInCollection = + !isOwnCollection && !!currentUserCollectionItem; const openShareModal = useCallback(() => { dispatch( @@ -136,6 +173,8 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({ } }, [history, openShareModal, isNewCollection, location.pathname]); + const hasPendingAccounts = items.some((item) => item.state === 'pending'); + return (
@@ -160,7 +199,13 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
{description &&

{description}

} - {isCurrentUserInCollection && } + {hasPendingAccounts && } + {isCurrentUserInCollection && ( + + )} ); }; @@ -174,7 +219,6 @@ export const CollectionDetailPage: React.FC<{ const collection = useAppSelector((state) => id ? state.collections.collections[id] : undefined, ); - const isLoading = !!id && !collection; useEffect(() => { if (id) { @@ -195,8 +239,14 @@ export const CollectionDetailPage: React.FC<{ /> - {collection && } - + {collection ? ( + <> + + + + ) : ( + + )} diff --git a/app/javascript/mastodon/features/collections/detail/revoke_collection_inclusion_modal.tsx b/app/javascript/mastodon/features/collections/detail/revoke_collection_inclusion_modal.tsx index a5fc887ff6..489a35f3ae 100644 --- a/app/javascript/mastodon/features/collections/detail/revoke_collection_inclusion_modal.tsx +++ b/app/javascript/mastodon/features/collections/detail/revoke_collection_inclusion_modal.tsx @@ -27,7 +27,7 @@ const messages = defineMessages({ }, }); -export function useConfirmRevoke(collection?: ApiCollectionJSON) { +export function useConfirmRevoke(collection?: ApiCollectionJSON | null) { const dispatch = useAppDispatch(); const { id, items = [] } = collection ?? {}; const ownCollectionItemId = items.find((item) => item.account_id === me)?.id; diff --git a/app/javascript/mastodon/features/collections/editor/accounts.tsx b/app/javascript/mastodon/features/collections/editor/accounts.tsx index dddd0242e0..3643072f10 100644 --- a/app/javascript/mastodon/features/collections/editor/accounts.tsx +++ b/app/javascript/mastodon/features/collections/editor/accounts.tsx @@ -6,16 +6,17 @@ import { useHistory } from 'react-router-dom'; import type { Map as ImmutableMap } from 'immutable'; -import { useComboboxItemProps } from '@/mastodon/components/form_fields/combobox_field'; import type { ApiMutedAccountJSON } from 'mastodon/api_types/accounts'; import type { ApiCollectionJSON } from 'mastodon/api_types/collections'; import { AccountListItem } from 'mastodon/components/account_list_item'; import { Avatar } from 'mastodon/components/avatar'; +import { PendingBadge } from 'mastodon/components/badge'; import { Button } from 'mastodon/components/button'; import { DisplayName } from 'mastodon/components/display_name'; import { useAccountHandle } from 'mastodon/components/display_name/default'; import { EmptyState } from 'mastodon/components/empty_state'; import { FormStack, ComboboxField } from 'mastodon/components/form_fields'; +import { useComboboxItemProps } from 'mastodon/components/form_fields/combobox_field'; import { ListItemContent, ListItemWrapper, @@ -31,12 +32,14 @@ import { domain } from 'mastodon/initial_state'; import type { Relationship } from 'mastodon/models/relationship'; import { addCollectionItem, - getCollectionItemIds, + getEditorCollectionItems, removeCollectionItem, updateCollectionEditorField, } from 'mastodon/reducers/slices/collections'; import { useAppDispatch, useAppSelector } from 'mastodon/store'; +import { PendingNote } from '../detail'; + import classes from './styles.module.scss'; import { WizardStepTitle } from './wizard_step_title'; @@ -44,8 +47,9 @@ const MAX_ACCOUNT_COUNT = 25; const AddedAccountItem: React.FC<{ accountId: string; + pending?: boolean; onRemove: (id: string) => void; -}> = ({ accountId, onRemove }) => { +}> = ({ accountId, pending, onRemove }) => { const handleRemoveAccount = useCallback(() => { onRemove(accountId); }, [accountId, onRemove]); @@ -62,7 +66,13 @@ const AddedAccountItem: React.FC<{ [handleRemoveAccount], ); - return ; + return ( + } + renderButton={renderButton} + /> + ); }; const SuggestedAccountItem: React.FC<{ id: string }> = ({ id }) => { @@ -183,22 +193,26 @@ export const CollectionAccounts: React.FC<{ const { id, items: collectionItems } = collection ?? {}; const isEditMode = !!id; - const addedAccountIds = useAppSelector( - (state) => state.collections.editor.accountIds, + const editorItemsFromState = useAppSelector( + (state) => state.collections.editor.items, ); - // In edit mode, we're bypassing state and just return collection items directly, - // since they're edited "live", saving after each addition/deletion - const accountIds = useMemo( + // In edit mode, we're bypassing our Redux state and just work on the + // collection items directly since they're edited "live", saving right + // after each addition/deletion + const editorItems = useMemo( () => - isEditMode ? getCollectionItemIds(collectionItems) : addedAccountIds, - [isEditMode, collectionItems, addedAccountIds], + isEditMode + ? getEditorCollectionItems(collectionItems) + : editorItemsFromState, + [isEditMode, collectionItems, editorItemsFromState], ); + const hasPendingItems = editorItems.some((item) => item.state === 'pending'); const [searchValue, setSearchValue] = useState(''); - const hasAccounts = accountIds.length > 0; - const hasMaxAccounts = accountIds.length === MAX_ACCOUNT_COUNT; + const hasItems = editorItems.length > 0; + const hasMaxItems = editorItems.length === MAX_ACCOUNT_COUNT; const { accounts: suggestedAccounts, @@ -208,7 +222,8 @@ export const CollectionAccounts: React.FC<{ } = useSearchAccounts({ withRelationships: true, // Don't suggest accounts that were already added - filterResults: (account) => !accountIds.includes(account.id), + filterResults: (account) => + !editorItems.find((item) => item.account_id === account.id), }); const relationships = useAppSelector((state) => state.relationships); @@ -236,24 +251,33 @@ export const CollectionAccounts: React.FC<{ (accountId: string) => { dispatch( updateCollectionEditorField({ - field: 'accountIds', - value: accountIds.filter((id) => id !== accountId), + field: 'items', + value: editorItems.filter((item) => item.account_id !== accountId), }), ); }, - [accountIds, dispatch], + [editorItems, dispatch], ); const addAccountItem = useCallback( (item: ApiMutedAccountJSON) => { dispatch( updateCollectionEditorField({ - field: 'accountIds', - value: [...accountIds, item.id], + field: 'items', + value: [ + ...editorItems, + { + account_id: item.id, + state: + item.feature_approval.current_user === 'manual' + ? 'pending' + : 'accepted', + }, + ], }), ); }, - [accountIds, dispatch], + [editorItems, dispatch], ); const instantRemoveAccountItem = useCallback( @@ -319,12 +343,10 @@ export const CollectionAccounts: React.FC<{ e.preventDefault(); if (!id) { - history.push(`/collections/new/details`, { - account_ids: accountIds, - }); + history.push('/collections/new/details'); } }, - [id, history, accountIds], + [id, history], ); const inputId = useId(); @@ -345,16 +367,17 @@ export const CollectionAccounts: React.FC<{ } /> )} + {hasPendingItems && }
- {hasAccounts && ( + {hasItems && ( )} @@ -410,14 +433,15 @@ export const CollectionAccounts: React.FC<{ /> } > - {accountIds.map((accountId, index) => ( + {editorItems.map(({ account_id, state }, index) => (
@@ -426,7 +450,7 @@ export const CollectionAccounts: React.FC<{
- {!isEditMode && hasAccounts && ( + {!isEditMode && hasItems && (
@@ -156,9 +156,11 @@ export const CollectionEditorPage: React.FC<{ ); }; -export const MaxCollectionsCallout: React.FC = () => ( +export const MaxCollectionsCallout: React.FC<{ className?: string }> = ({ + className, +}) => ( { - if (accountId && areCollectionsEnabled()) { - void dispatch(fetchAccountCollections({ accountId })); - } - }, [dispatch, accountId]); - - return useAppSelector((state) => selectAccountCollections(state, accountId)); -} - export const Collections: React.FC<{ multiColumn?: boolean; }> = ({ multiColumn }) => { @@ -59,37 +45,11 @@ export const Collections: React.FC<{ const me = useCurrentAccountId(); const accountId = useAccountId(); const account = useAccount(accountId); + const { path } = useRouteMatch(); - const { collections, status } = useAccountCollections(accountId); + const isOwnCollectionsPage = accountId === me; - const emptyMessage = - status === 'error' || !accountId ? ( - - ) : ( - <> - - -
- -
- - - - ); - - const canCreateMoreCollections = collections.length < userCollectionLimit; - const isOwnCollection = accountId === me; - const titleMessage = isOwnCollection + const titleMessage = isOwnCollectionsPage ? messages.headingMe : messages.headingOther; @@ -100,45 +60,41 @@ export const Collections: React.FC<{ name: , }); + const createdByTabMessage = isOwnCollectionsPage + ? messages.createdByYou + : messages.createdByAuthor; + return ( - - - - ) - } - /> + - {status === 'idle' && !canCreateMoreCollections && ( - - )} - - {collections.map((item, index) => ( - - ))} - +
+

{pageTitleHtml}

+ + + {intl.formatMessage(createdByTabMessage, { + name: , + })} + + {isOwnCollectionsPage && ( + + {intl.formatMessage(messages.featuringYou)} + + )} + +
+ + + +
diff --git a/app/javascript/mastodon/features/collections/overview/created_by_you.tsx b/app/javascript/mastodon/features/collections/overview/created_by_you.tsx new file mode 100644 index 0000000000..25b812e9bd --- /dev/null +++ b/app/javascript/mastodon/features/collections/overview/created_by_you.tsx @@ -0,0 +1,133 @@ +import { useEffect } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { Link } from 'react-router-dom'; + +import AddIcon from '@/material-icons/400-24px/add.svg?react'; +import { EmptyState } from 'mastodon/components/empty_state'; +import { Icon } from 'mastodon/components/icon'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; +import { ItemList } from 'mastodon/components/scrollable_list/components'; +import { useAccountId, useCurrentAccountId } from 'mastodon/hooks/useAccountId'; +import { + fetchCollectionsCreatedByAccount, + selectAccountCollections, +} from 'mastodon/reducers/slices/collections'; +import { useAppSelector, useAppDispatch } from 'mastodon/store'; + +import { CollectionListItem } from '../components/collection_list_item'; +import { + messages as editorMessages, + MaxCollectionsCallout, + userCollectionLimit, +} from '../editor'; +import classes from '../styles.module.scss'; +import { areCollectionsEnabled } from '../utils'; + +const CreateButton: React.FC = () => ( + + + + +); + +export const CollectionListError: React.FC = () => ( + + } + /> +); + +export function useCollectionsCreatedBy(accountId: string | null | undefined) { + const dispatch = useAppDispatch(); + + useEffect(() => { + if (accountId && areCollectionsEnabled()) { + void dispatch(fetchCollectionsCreatedByAccount({ accountId })); + } + }, [dispatch, accountId]); + + return useAppSelector((state) => + selectAccountCollections(state, accountId, 'createdBy'), + ); +} + +export const CollectionsCreatedByYou: React.FC = () => { + const me = useCurrentAccountId(); + const accountId = useAccountId(); + + const { collections, status } = useCollectionsCreatedBy(accountId); + + const canCreateMoreCollections = collections.length < userCollectionLimit; + const isOwnCollectionPage = accountId === me; + const showCreateButton = + isOwnCollectionPage && status === 'idle' && canCreateMoreCollections; + + if (status === 'error' || !accountId) { + return ; + } + + if (status === 'loading') { + return ; + } + + if (collections.length === 0) { + return ( + + } + message={ + + } + > + + + ); + } + + return ( + <> +
+

+ +

+ {showCreateButton && } +
+ + {!canCreateMoreCollections && ( + + )} + {collections.map((item, index) => ( + + ))} + + + ); +}; diff --git a/app/javascript/mastodon/features/collections/overview/featuring_you.tsx b/app/javascript/mastodon/features/collections/overview/featuring_you.tsx new file mode 100644 index 0000000000..3fb93bbfc9 --- /dev/null +++ b/app/javascript/mastodon/features/collections/overview/featuring_you.tsx @@ -0,0 +1,87 @@ +import { useEffect } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { EmptyState } from 'mastodon/components/empty_state'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; +import { ItemList } from 'mastodon/components/scrollable_list/components'; +import { useAccountId } from 'mastodon/hooks/useAccountId'; +import { + fetchCollectionsFeaturingAccount, + selectAccountCollections, +} from 'mastodon/reducers/slices/collections'; +import { useAppSelector, useAppDispatch } from 'mastodon/store'; + +import { CollectionListItem } from '../components/collection_list_item'; +import classes from '../styles.module.scss'; +import { areCollectionsEnabled } from '../utils'; + +import { CollectionListError } from './created_by_you'; + +function useCollectionsFeaturing(accountId: string | null | undefined) { + const dispatch = useAppDispatch(); + + useEffect(() => { + if (accountId && areCollectionsEnabled()) { + void dispatch(fetchCollectionsFeaturingAccount({ accountId })); + } + }, [dispatch, accountId]); + + return useAppSelector((state) => + selectAccountCollections(state, accountId, 'featuring'), + ); +} + +export const CollectionsFeaturingYou: React.FC = () => { + const accountId = useAccountId(); + + const { collections, status } = useCollectionsFeaturing(accountId); + + if (status === 'error' || !accountId) { + return ; + } + + if (status === 'loading') { + return ; + } + + if (collections.length === 0) { + return ( + + } + /> + ); + } + + return ( + <> +
+

+ +

+
+ + {collections.map((item, index) => ( + + ))} + + + ); +}; diff --git a/app/javascript/mastodon/features/collections/styles.module.scss b/app/javascript/mastodon/features/collections/styles.module.scss new file mode 100644 index 0000000000..ed2ba13a09 --- /dev/null +++ b/app/javascript/mastodon/features/collections/styles.module.scss @@ -0,0 +1,39 @@ +.header { + display: flex; + flex-direction: column; + gap: 8px; + padding-top: 24px; + padding-inline: 16px; + border-bottom: 1px solid var(--color-border-primary); +} + +.heading { + font-size: 22px; + font-weight: 500; + line-height: 1.2; + overflow-wrap: break-word; +} + +.listHeader { + display: flex; + justify-content: space-between; + align-items: center; + min-height: 28px; + padding-top: 16px; + padding-inline: 16px; +} + +.subHeading { + font-size: 15px; + font-weight: 500; + line-height: 1.2; + overflow-wrap: break-word; +} + +.maxCollectionsError { + margin: 8px 16px; +} + +.error { + flex-grow: 1; +} diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx index c7c5e88f8c..0013db281a 100644 --- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx +++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx @@ -5,8 +5,6 @@ import { defineMessages, FormattedMessage } from 'react-intl'; import classNames from 'classnames'; -import ImmutablePropTypes from 'react-immutable-proptypes'; - import { supportsPassiveEvents } from 'detect-passive-events'; import Overlay from 'react-overlays/Overlay'; @@ -14,7 +12,6 @@ import MoodIcon from '@/material-icons/400-20px/mood.svg?react'; import { IconButton } from 'mastodon/components/icon_button'; import { injectIntl } from '@/mastodon/components/intl'; -import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji'; import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components'; const messages = defineMessages({ @@ -156,7 +153,6 @@ class ModifierPicker extends PureComponent { class EmojiPickerMenuImpl extends PureComponent { static propTypes = { - custom_emojis: ImmutablePropTypes.list, frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string), loading: PropTypes.bool, onClose: PropTypes.func.isRequired, @@ -254,7 +250,7 @@ class EmojiPickerMenuImpl extends PureComponent { }; render() { - const { loading, style, intl, custom_emojis, skinTone, frequentlyUsedEmojis } = this.props; + const { loading, style, intl, skinTone, frequentlyUsedEmojis } = this.props; if (loading) { return
; @@ -264,32 +260,16 @@ class EmojiPickerMenuImpl extends PureComponent { const { modifierOpen } = this.state; - const categoriesSort = [ - 'recent', - 'people', - 'nature', - 'foods', - 'activity', - 'places', - 'objects', - 'symbols', - 'flags', - ]; - - categoriesSort.splice(1, 0, ...Array.from(categoriesFromEmojis(custom_emojis)).sort()); - return (
{ + this.setState({ loading: false }); + }); }).catch(() => { this.setState({ loading: false, active: false }); }); @@ -408,7 +388,6 @@ class EmojiPickerDropdown extends PureComponent {
state.get('custom_emojis'), -], emojis => emojis.filter(e => e.get('visible_in_picker')).sort((a, b) => { - const aShort = a.get('shortcode').toLowerCase(); - const bShort = b.get('shortcode').toLowerCase(); - - if (aShort < bShort) { - return -1; - } else if (aShort > bShort ) { - return 1; - } else { - return 0; - } -})); - const mapStateToProps = state => ({ - custom_emojis: getCustomEmojis(state), skinTone: state.getIn(['settings', 'skinTone']), frequentlyUsedEmojis: getFrequentlyUsedEmojis(state), }); diff --git a/app/javascript/mastodon/features/emoji/emoji.js b/app/javascript/mastodon/features/emoji/emoji.js index 859665a531..8f45c76ac7 100644 --- a/app/javascript/mastodon/features/emoji/emoji.js +++ b/app/javascript/mastodon/features/emoji/emoji.js @@ -168,29 +168,3 @@ const emojify = (str, customEmojis = {}) => { }; export default emojify; - -export const buildCustomEmojis = (customEmojis) => { - const emojis = []; - - customEmojis.forEach(emoji => { - const shortcode = emoji.get('shortcode'); - const url = autoPlayGif ? emoji.get('url') : emoji.get('static_url'); - const name = shortcode.replace(':', ''); - - emojis.push({ - id: name, - name, - short_names: [name], - text: '', - emoticons: [], - keywords: [name], - imageUrl: url, - custom: true, - customCategory: emoji.get('category'), - }); - }); - - return emojis; -}; - -export const categoriesFromEmojis = customEmojis => customEmojis.reduce((set, emoji) => set.add(emoji.get('category') ? `custom-${emoji.get('category')}` : 'custom'), new Set(['custom'])); diff --git a/app/javascript/mastodon/features/emoji/emoji_picker.tsx b/app/javascript/mastodon/features/emoji/emoji_picker.tsx index 37fc94dde7..233c451aad 100644 --- a/app/javascript/mastodon/features/emoji/emoji_picker.tsx +++ b/app/javascript/mastodon/features/emoji/emoji_picker.tsx @@ -1,49 +1,62 @@ +import type { FC } from 'react'; + import type { EmojiProps, PickerProps } from 'emoji-mart'; import EmojiRaw from 'emoji-mart/dist-es/components/emoji/nimble-emoji'; import PickerRaw from 'emoji-mart/dist-es/components/picker/nimble-picker'; -import { assetHost } from 'mastodon/utils/config'; +import { assetHost } from '@/mastodon/utils/config'; import { EMOJI_MODE_NATIVE } from './constants'; import EmojiData from './emoji_data.json'; import { useEmojiAppState } from './mode'; +import { usePickerEmojis } from './picker'; const backgroundImageFnDefault = () => `${assetHost}/emoji/sheet_16_0.png`; -const Emoji = ({ +export { fetchCustomEmojiData as loadCustomEmojiData } from './picker'; + +export const Picker: FC = ({ set = 'twitter', sheetSize = 32, sheetColumns = 62, sheetRows = 62, backgroundImageFn = backgroundImageFnDefault, ...props -}: EmojiProps) => { +}) => { + const { mode } = useEmojiAppState(); + const { customCategories, customEmojis } = usePickerEmojis(); + + if (!customEmojis) { + return null; + } + + return ( + + ); +}; + +export const Emoji: FC = ({ + set = 'twitter', + sheetSize = 32, + sheetColumns = 62, + sheetRows = 62, + backgroundImageFn = backgroundImageFnDefault, + ...props +}) => { const { mode } = useEmojiAppState(); return ( - ); -}; - -const Picker = ({ - set = 'twitter', - sheetSize = 32, - sheetColumns = 62, - sheetRows = 62, - backgroundImageFn = backgroundImageFnDefault, - ...props -}: PickerProps) => { - const { mode } = useEmojiAppState(); - return ( - ); }; - -export { Picker, Emoji }; diff --git a/app/javascript/mastodon/features/emoji/picker.ts b/app/javascript/mastodon/features/emoji/picker.ts new file mode 100644 index 0000000000..b40a3020e4 --- /dev/null +++ b/app/javascript/mastodon/features/emoji/picker.ts @@ -0,0 +1,85 @@ +import { useEffect, useState } from 'react'; + +import type { CategoryName, CustomEmoji } from 'emoji-mart'; + +import { autoPlayGif } from '@/mastodon/initial_state'; + +import { emojiLogger } from './utils'; + +const log = emojiLogger('picker'); + +let customEmojis: CustomEmoji[] | null = null; +let customCategories = [ + 'recent', + 'people', + 'nature', + 'foods', + 'activity', + 'places', + 'objects', + 'symbols', + 'flags', +] as CategoryName[]; + +export async function fetchCustomEmojiData() { + if (customEmojis !== null) { + return customEmojis; + } + + const { loadAllCustomEmoji } = await import('./database'); + const emojisRaw = await loadAllCustomEmoji(); + if (emojisRaw.length === 0) { + return []; + } + + const categories = new Set(['custom']); + const emojis = []; + for (const emoji of emojisRaw) { + const name = emoji.shortcode.replaceAll(':', ''); + emojis.push({ + name, + id: name, + custom: true, + short_names: [name], + imageUrl: autoPlayGif ? emoji.url : emoji.static_url, + customCategory: emoji.category, + }); + + if (emoji.category) { + categories.add(`custom-${emoji.category}`); + } + } + + customEmojis = emojis.toSorted((a, b) => { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + customCategories = customCategories.toSpliced( + 1, + 0, + ...(Array.from(categories).toSorted() as CategoryName[]), + ); + log( + 'loaded %d custom emojis in %d categories', + customEmojis.length, + categories.size, + ); + + return customEmojis; +} + +export function usePickerEmojis() { + const [, setLoaded] = useState(customEmojis !== null); + + useEffect(() => { + if (customEmojis === null) { + void fetchCustomEmojiData().then(() => { + setLoaded(true); + }); + } + }, []); + + return { + customEmojis, + customCategories, + }; +} diff --git a/app/javascript/mastodon/features/followers/components/empty.tsx b/app/javascript/mastodon/features/followers/components/empty.tsx index 3fb92c1061..edb675e80a 100644 --- a/app/javascript/mastodon/features/followers/components/empty.tsx +++ b/app/javascript/mastodon/features/followers/components/empty.tsx @@ -2,7 +2,7 @@ import type { FC, ReactNode } from 'react'; import { FormattedMessage } from 'react-intl'; -import { LimitedAccountHint } from '@/mastodon/features/account_timeline/components/limited_account_hint'; +import { LimitedAccountHint } from '@/mastodon/components/limited_account_hint'; import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility'; import type { Account } from '@/mastodon/models/account'; diff --git a/app/javascript/mastodon/features/followers/components/header.tsx b/app/javascript/mastodon/features/followers/components/header.tsx index 2733e1d0f4..f7b2359c02 100644 --- a/app/javascript/mastodon/features/followers/components/header.tsx +++ b/app/javascript/mastodon/features/followers/components/header.tsx @@ -3,8 +3,12 @@ import type { FC } from 'react'; import { FormattedMessage, useIntl } from 'react-intl'; import type { MessageDescriptor } from 'react-intl'; +import { Link } from 'react-router-dom'; + +import { Callout } from '@/mastodon/components/callout'; import { DisplayNameSimple } from '@/mastodon/components/display_name/simple'; import { useAccount } from '@/mastodon/hooks/useAccount'; +import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId'; import classes from '../styles.module.scss'; @@ -15,6 +19,7 @@ export const AccountListHeader: FC<{ }> = ({ accountId, total, titleText }) => { const intl = useIntl(); const account = useAccount(accountId); + const currentId = useCurrentAccountId(); return ( <>

@@ -31,6 +36,35 @@ export const AccountListHeader: FC<{ />

)} + {accountId === currentId && account?.hide_collections && ( + + {chunks}, + page: ( + + ), + modal: ( + + ), + field: ( + + ), + }} + /> + + )} ); }; diff --git a/app/javascript/mastodon/features/followers/styles.module.scss b/app/javascript/mastodon/features/followers/styles.module.scss index f58b345bec..6294044c3b 100644 --- a/app/javascript/mastodon/features/followers/styles.module.scss +++ b/app/javascript/mastodon/features/followers/styles.module.scss @@ -4,8 +4,12 @@ margin: 20px 16px 10px; } +.subtitle, +.callout { + margin: 10px 16px; +} + .subtitle { font-size: 14px; color: var(--color-text-secondary); - margin: 10px 16px; } diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_collection.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_collection.tsx index 6b70bf68cb..10466b5d3d 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_collection.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_collection.tsx @@ -24,9 +24,14 @@ export const NotificationCollection: React.FC<{ unread: boolean; }> = ({ notification, unread }) => { const { collection, type } = notification; - const collectionCreatorAccount = useAccount(collection.account_id); + + const collectionCreatorAccount = useAccount(collection?.account_id); const confirmRevoke = useConfirmRevoke(collection); + if (!collection) { + return null; + } + return (
+

{title}

{message &&

{message}

} @@ -99,6 +99,7 @@ export const ConfirmationModal: React.FC< {/* eslint-disable jsx-a11y/no-autofocus -- we are in a modal and thus autofocusing is justified */}