diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 827629d8e651e7..58805c68fb0827 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -31,8 +31,8 @@ jobs: strategy: fail-fast: false matrix: - language: ['javascript', 'ruby'] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] + language: ['actions', 'javascript', 'ruby'] + # CodeQL supports [ 'actions', 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: diff --git a/.ruby-version b/.ruby-version index 4f5e69734c9582..1cf8253024ccd6 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.5 +3.4.6 diff --git a/.storybook/preview-body.html b/.storybook/preview-body.html new file mode 100644 index 00000000000000..1870d95b8fe3db --- /dev/null +++ b/.storybook/preview-body.html @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index a6684e2067aa51..3ad88c51070c5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,34 @@ All notable changes to this project will be documented in this file. +## [4.4.4] - 2025-09-16 + +### Security + +- Update dependencies + +### Fixed + +- Fix missing memoization in `Web::PushNotificationWorker` (#36085 by @ClearlyClaire) +- Fix unresponsive areas around GIFV modals in some cases (#36059 by @ClearlyClaire) +- Fix missing `beforeUnload` confirmation when a poll is being authored (#36030 by @ClearlyClaire) +- Fix processing of remote edited statuses with new media and no text (#35970 by @unfokus) +- Fix polls not being displayed in moderation interface (#35644 and #35933 by @ThisIsMissEm) +- Fix WebUI handling of deleted quoted posts (#35909 and #35918 by @ClearlyClaire and @diondiondion) +- Fix “Edit” and “Delete & Redraft” on a poll not inserting empty option (#35892 by @ClearlyClaire) +- Fix loading of some compatibility CSS on some configurations (#35876 by @shleeable) +- Fix HttpLog not being enabled with `RAILS_LOG_LEVEL=debug` (#35833 by @mjankowski) +- Fix self-destruct scheduler behavior on some Redis setups (#35823 by @ClearlyClaire) +- Fix `tootctl admin create` not bypassing reserved username checks (#35779 by @ClearlyClaire) +- Fix interaction policy changes in implicit updates not being saved (#35751 by @ClearlyClaire) +- Fix quote revocation not being streamed (#35710 by @ClearlyClaire) +- Fix export of large user archives by enabling Zip64 (#35850 by @ClearlyClaire) + +### Changed + +- Change labels for quote policy settings (#35893 by @ClearlyClaire) +- Change standalone “Share” page to redirect to web interface after posting (#35763 by @ChaosExAnima) + ## [4.4.3] - 2025-08-05 ### Security diff --git a/Dockerfile b/Dockerfile index 38723228547cc5..e7f2f7f495f663 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="3.4.x"] # renovate: datasource=docker depName=docker.io/ruby -ARG RUBY_VERSION="3.4.5" +ARG RUBY_VERSION="3.4.6" # # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"] # renovate: datasource=node-version depName=node ARG NODE_MAJOR_VERSION="22" @@ -183,7 +183,7 @@ FROM build AS libvips # libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"] # renovate: datasource=github-releases depName=libvips packageName=libvips/libvips -ARG VIPS_VERSION=8.17.1 +ARG VIPS_VERSION=8.17.2 # libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"] ARG VIPS_URL=https://github.com/libvips/libvips/releases/download diff --git a/Gemfile b/Gemfile index b80621bf9ded48..b6ecb0126d8f54 100644 --- a/Gemfile +++ b/Gemfile @@ -88,7 +88,7 @@ gem 'sidekiq-scheduler', '~> 6.0' gem 'sidekiq-unique-jobs', '> 8' gem 'simple_form', '~> 5.2' gem 'simple-navigation', '~> 4.4' -gem 'stoplight', github: 'ClearlyClaire/stoplight', ref: 'f13e0c0d5e6d34af8d3cfc888871caa84237db42' +gem 'stoplight' gem 'strong_migrations' gem 'tty-prompt', '~> 0.23', require: false gem 'twitter-text', '~> 3.1.0' diff --git a/Gemfile.lock b/Gemfile.lock index 0f6f85585c858f..c3af4d7c62ca96 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,11 +1,3 @@ -GIT - remote: https://github.com/ClearlyClaire/stoplight.git - revision: f13e0c0d5e6d34af8d3cfc888871caa84237db42 - ref: f13e0c0d5e6d34af8d3cfc888871caa84237db42 - specs: - stoplight (5.3.1) - zeitwerk - GIT remote: https://github.com/mastodon/webpush.git revision: 9631ac63045cfabddacc69fc06e919b4c13eb913 @@ -129,7 +121,7 @@ GEM erubi (>= 1.0.0) rack (>= 0.9.0) rouge (>= 1.0.0) - bigdecimal (3.2.2) + bigdecimal (3.2.3) bindata (2.5.1) binding_of_caller (1.0.1) debug_inspector (>= 1.2.0) @@ -308,8 +300,8 @@ GEM highline (3.1.2) reline hiredis (0.6.3) - hiredis-client (0.25.2) - redis-client (= 0.25.2) + hiredis-client (0.25.3) + redis-client (= 0.25.3) hkdf (0.3.0) htmlentities (4.3.4) http (5.3.1) @@ -645,7 +637,7 @@ GEM public_suffix (6.0.2) puma (6.6.1) nio4r (~> 2.0) - pundit (2.5.0) + pundit (2.5.1) activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) @@ -725,7 +717,7 @@ GEM reline redcarpet (3.6.1) redis (4.8.1) - redis-client (0.25.2) + redis-client (0.25.3) connection_pool regexp_parser (2.11.2) reline (0.6.2) @@ -735,7 +727,7 @@ GEM responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) - rexml (3.4.1) + rexml (3.4.4) rotp (6.3.0) rouge (4.6.0) rpam2 (4.0.2) @@ -791,10 +783,10 @@ GEM rubocop-i18n (3.2.3) lint_roller (~> 1.1) rubocop (>= 1.72.1) - rubocop-performance (1.25.0) + rubocop-performance (1.26.0) lint_roller (~> 1.1) rubocop (>= 1.75.0, < 2.0) - rubocop-ast (>= 1.38.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) rubocop-rails (2.33.3) activesupport (>= 4.2.0) lint_roller (~> 1.1) @@ -817,7 +809,7 @@ GEM ruby-vips (2.2.5) ffi (~> 1.12) logger - rubyzip (3.0.2) + rubyzip (3.1.0) rufus-scheduler (3.9.2) fugit (~> 1.1, >= 1.11.1) safety_net_attestation (0.4.0) @@ -861,6 +853,8 @@ GEM stackprof (0.2.27) starry (0.2.0) base64 + stoplight (5.3.5) + zeitwerk stringio (3.1.7) strong_migrations (2.5.0) activerecord (>= 7.1) @@ -1092,7 +1086,7 @@ DEPENDENCIES simplecov (~> 0.22) simplecov-lcov (~> 0.8) stackprof - stoplight! + stoplight strong_migrations test-prof thor (~> 1.2) diff --git a/app/controllers/activitypub/contexts_controller.rb b/app/controllers/activitypub/contexts_controller.rb index 2a7e69cff9b654..4daa75552e22f5 100644 --- a/app/controllers/activitypub/contexts_controller.rb +++ b/app/controllers/activitypub/contexts_controller.rb @@ -26,7 +26,8 @@ def account_required? end def set_conversation - @conversation = Conversation.local.find(params[:id]) + account_id, status_id = params[:id].split('-') + @conversation = Conversation.local.find_by(parent_account_id: account_id, parent_status_id: status_id) end def set_items diff --git a/app/controllers/activitypub/quote_authorizations_controller.rb b/app/controllers/activitypub/quote_authorizations_controller.rb index fa635d636a69e4..f2f5313e1ad3c6 100644 --- a/app/controllers/activitypub/quote_authorizations_controller.rb +++ b/app/controllers/activitypub/quote_authorizations_controller.rb @@ -9,7 +9,7 @@ class ActivityPub::QuoteAuthorizationsController < ActivityPub::BaseController before_action :set_quote_authorization def show - expires_in 0, public: @quote.status.distributable? && public_fetch_mode? + expires_in 30.seconds, public: true if @quote.status.distributable? && public_fetch_mode? render json: @quote, serializer: ActivityPub::QuoteAuthorizationSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' end @@ -21,6 +21,8 @@ def pundit_user def set_quote_authorization @quote = Quote.accepted.where(quoted_account: @account).find(params[:id]) + return not_found unless @quote.status.present? && @quote.quoted_status.present? + authorize @quote.status, :show? rescue Mastodon::NotPermittedError not_found diff --git a/app/controllers/concerns/api/interaction_policies_concern.rb b/app/controllers/concerns/api/interaction_policies_concern.rb index 21a4cf6c56f03b..5b63705a9bf703 100644 --- a/app/controllers/concerns/api/interaction_policies_concern.rb +++ b/app/controllers/concerns/api/interaction_policies_concern.rb @@ -4,10 +4,9 @@ module Api::InteractionPoliciesConcern extend ActiveSupport::Concern def quote_approval_policy - # TODO: handle `nil` separately - return nil unless Mastodon::Feature.outgoing_quotes_enabled? && status_params[:quote_approval_policy].present? + return nil unless Mastodon::Feature.outgoing_quotes_enabled? - case status_params[:quote_approval_policy] + case status_params[:quote_approval_policy].presence || current_user.setting_default_quote_policy when 'public' Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16 when 'followers' diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 0c597291cf9f2f..7afa89b4db716f 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -285,6 +285,10 @@ def app_store_url_android 'https://play.google.com/store/apps/details?id=org.joinmastodon.android' end + def within_authorization_flow? + session[:user_return_to].present? && Rails.application.routes.recognize_path(session[:user_return_to])[:controller] == 'oauth/authorizations' + end + private def storage_host_var diff --git a/app/helpers/statuses_helper.rb b/app/helpers/statuses_helper.rb index 7e42dd4623fca7..b16f68ed0ba63a 100644 --- a/app/helpers/statuses_helper.rb +++ b/app/helpers/statuses_helper.rb @@ -67,4 +67,16 @@ def visibility_icon(status) def prefers_autoplay? ActiveModel::Type::Boolean.new.cast(params[:autoplay]) || current_user&.setting_auto_play_gif end + + def render_seo_schema(status) + json = ActiveModelSerializers::SerializableResource.new( + status, + serializer: SEO::SocialMediaPostingSerializer, + adapter: SEO::Adapter + ).to_json + + # rubocop:disable Rails/OutputSafety + content_tag(:script, json_escape(json).html_safe, type: 'application/ld+json') + # rubocop:enable Rails/OutputSafety + end end diff --git a/app/javascript/entrypoints/public.tsx b/app/javascript/entrypoints/public.tsx index db72be651563f6..dd1956446daeeb 100644 --- a/app/javascript/entrypoints/public.tsx +++ b/app/javascript/entrypoints/public.tsx @@ -351,6 +351,31 @@ const setInputDisabled = ( } }; +const setInputHint = ( + input: HTMLInputElement | HTMLSelectElement, + hintPrefix: string, +) => { + const fieldWrapper = input.closest('.fields-group > .input'); + if (!fieldWrapper) return; + + const hint = fieldWrapper.dataset[`${hintPrefix}Hint`]; + const hintElement = + fieldWrapper.querySelector(':scope > .hint'); + + if (hint) { + if (hintElement) { + hintElement.textContent = hint; + } else { + const newHintElement = document.createElement('span'); + newHintElement.className = 'hint'; + newHintElement.textContent = hint; + fieldWrapper.appendChild(newHintElement); + } + } else { + hintElement?.remove(); + } +}; + Rails.delegate( document, '#account_statuses_cleanup_policy_enabled', @@ -379,6 +404,8 @@ const updateDefaultQuotePrivacyFromPrivacy = ( ); if (!select) return; + setInputHint(select, privacySelect.value); + if (privacySelect.value === 'private') { select.value = 'nobody'; setInputDisabled(select, true); diff --git a/app/javascript/mastodon/actions/compose_typed.ts b/app/javascript/mastodon/actions/compose_typed.ts index d5b0726ed92fc0..f3aa37e20db31f 100644 --- a/app/javascript/mastodon/actions/compose_typed.ts +++ b/app/javascript/mastodon/actions/compose_typed.ts @@ -16,6 +16,7 @@ import type { Status } from '../models/status'; import { showAlert } from './alerts'; import { focusCompose } from './compose'; +import { openModal } from './modal'; const messages = defineMessages({ quoteErrorUpload: { @@ -110,7 +111,15 @@ export const quoteCompose = createAppThunk( export const quoteComposeByStatus = createAppThunk( (status: Status, { dispatch, getState }) => { - const composeState = getState().compose; + const state = getState(); + const composeState = state.compose; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const wasQuietPostHintModalDismissed: boolean = + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + state.settings.getIn( + ['dismissed_banners', 'quote/quiet_post_hint'], + false, + ); if (composeState.get('is_uploading')) { dispatch(showAlert({ message: messages.quoteErrorUpload })); @@ -121,6 +130,16 @@ export const quoteComposeByStatus = createAppThunk( status.getIn(['quote_approval', 'current_user']) !== 'manual' ) { dispatch(showAlert({ message: messages.quoteErrorUnauthorized })); + } else if ( + status.get('visibility') === 'unlisted' && + !wasQuietPostHintModalDismissed + ) { + dispatch( + openModal({ + modalType: 'CONFIRM_QUIET_QUOTE', + modalProps: { status }, + }), + ); } else { dispatch(quoteCompose(status)); } diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index 450bdd65fd3f01..cdb91dc8a8e6a6 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -21,6 +21,15 @@ export function normalizeFilterResult(result) { return normalResult; } +function stripQuoteFallback(text) { + const wrapper = document.createElement('div'); + wrapper.innerHTML = text; + + wrapper.querySelector('.quote-inline')?.remove(); + + return wrapper.innerHTML; +} + export function normalizeStatus(status, normalOldStatus, options = undefined) { const normalStatus = { ...status }; @@ -84,7 +93,7 @@ export function normalizeStatus(status, normalOldStatus, options = undefined) { } else { // If the status has a CW but no contents, treat the CW as if it were the // status' contents, to avoid having a CW toggle with seemingly no effect. - if (normalStatus.spoiler_text && !normalStatus.content) { + if (normalStatus.spoiler_text && !normalStatus.content && !normalStatus.quote) { normalStatus.content = normalStatus.spoiler_text; normalStatus.spoiler_text = ''; } @@ -102,6 +111,11 @@ export function normalizeStatus(status, normalOldStatus, options = undefined) { normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap); normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive; + // Remove quote fallback link from the DOM so it doesn't mess with paragraph margins + if (normalStatus.quote) { + normalStatus.contentHtml = stripQuoteFallback(normalStatus.contentHtml); + } + if (normalStatus.url && !(normalStatus.url.startsWith('http://') || normalStatus.url.startsWith('https://'))) { normalStatus.url = null; } @@ -152,6 +166,11 @@ export function normalizeStatusTranslation(translation, status) { spoiler_text: translation.spoiler_text, }; + // Remove quote fallback link from the DOM so it doesn't mess with paragraph margins + if (status.get('quote')) { + normalTranslation.contentHtml = stripQuoteFallback(normalTranslation.contentHtml); + } + return normalTranslation; } diff --git a/app/javascript/mastodon/api_types/quotes.ts b/app/javascript/mastodon/api_types/quotes.ts index b4fe380d5222db..f42a3eb7289024 100644 --- a/app/javascript/mastodon/api_types/quotes.ts +++ b/app/javascript/mastodon/api_types/quotes.ts @@ -4,6 +4,7 @@ export type ApiQuoteState = 'accepted' | 'pending' | 'revoked' | 'unauthorized'; export type ApiQuotePolicy = | 'public' | 'followers' + | 'following' | 'nobody' | 'unsupported_policy'; export type ApiUserQuotePolicy = 'automatic' | 'manual' | 'denied' | 'unknown'; diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.jsx.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.jsx.snap deleted file mode 100644 index 9d1b236fad0bbe..00000000000000 --- a/app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.jsx.snap +++ /dev/null @@ -1,27 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[` > renders display name + account name 1`] = ` - - - Foo

", - } - } - /> -
- - - @ - bar@baz - -
-`; diff --git a/app/javascript/mastodon/components/__tests__/display_name-test.jsx b/app/javascript/mastodon/components/__tests__/display_name-test.jsx deleted file mode 100644 index 05a0f47170f468..00000000000000 --- a/app/javascript/mastodon/components/__tests__/display_name-test.jsx +++ /dev/null @@ -1,19 +0,0 @@ -import { fromJS } from 'immutable'; - -import renderer from 'react-test-renderer'; - -import { DisplayName } from '../display_name'; - -describe('', () => { - it('renders display name + account name', () => { - const account = fromJS({ - username: 'bar', - acct: 'bar@baz', - display_name_html: '

Foo

', - }); - const component = renderer.create(); - const tree = component.toJSON(); - - expect(tree).toMatchSnapshot(); - }); -}); diff --git a/app/javascript/mastodon/components/alert/alert.stories.tsx b/app/javascript/mastodon/components/alert/alert.stories.tsx new file mode 100644 index 00000000000000..4d5f8acb65b9d9 --- /dev/null +++ b/app/javascript/mastodon/components/alert/alert.stories.tsx @@ -0,0 +1,110 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { fn, expect } from 'storybook/test'; + +import { Alert } from '.'; + +const meta = { + title: 'Components/Alert', + component: Alert, + args: { + isActive: true, + animateFrom: 'side', + title: '', + message: '', + action: '', + onActionClick: fn(), + }, + argTypes: { + isActive: { + control: 'boolean', + type: 'boolean', + description: 'Animate to the active (displayed) state of the alert', + }, + animateFrom: { + control: 'radio', + type: 'string', + options: ['side', 'below'], + description: + 'Direction that the alert animates in from when activated. `side` is dependent on reading direction, defaulting to left in ltr languages.', + }, + title: { + control: 'text', + type: 'string', + description: '(Optional) title of the alert', + }, + message: { + control: 'text', + type: 'string', + description: 'Main alert text', + }, + action: { + control: 'text', + type: 'string', + description: + 'Label of the alert action (requires `onActionClick` handler)', + }, + }, + tags: ['test'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Simple: Story = { + args: { + message: 'Post published.', + }, + render: (args) => ( +
+ +
+ ), +}; + +export const WithAction: Story = { + args: { + ...Simple.args, + action: 'Open', + }, + render: Simple.render, + play: async ({ args, canvas, userEvent }) => { + const button = await canvas.findByRole('button', { name: 'Open' }); + await userEvent.click(button); + await expect(args.onActionClick).toHaveBeenCalled(); + }, +}; + +export const WithTitle: Story = { + args: { + title: 'Warning:', + message: 'This is an alert', + }, + render: Simple.render, +}; + +export const WithDismissButton: Story = { + args: { + message: 'More replies found', + action: 'Show', + onDismiss: fn(), + }, + render: Simple.render, +}; + +export const InSizedContainer: Story = { + args: WithDismissButton.args, + render: (args) => ( +
+ +
+ ), +}; diff --git a/app/javascript/mastodon/components/alert/index.tsx b/app/javascript/mastodon/components/alert/index.tsx new file mode 100644 index 00000000000000..1009e77524bdfc --- /dev/null +++ b/app/javascript/mastodon/components/alert/index.tsx @@ -0,0 +1,68 @@ +import { useIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import CloseIcon from '@/material-icons/400-24px/close.svg?react'; + +import { IconButton } from '../icon_button'; + +/** + * Snackbar/Toast-style notification component. + */ +export const Alert: React.FC<{ + isActive?: boolean; + animateFrom?: 'side' | 'below'; + title?: string; + message: string; + action?: string; + onActionClick?: () => void; + onDismiss?: () => void; +}> = ({ + isActive, + animateFrom = 'side', + title, + message, + action, + onActionClick, + onDismiss, +}) => { + const intl = useIntl(); + + const hasAction = Boolean(action && onActionClick); + + return ( +
+ + {Boolean(title) && ( + {title} + )} + {message} + + + {hasAction && ( + + )} + + {onDismiss && ( + + )} +
+ ); +}; diff --git a/app/javascript/mastodon/components/alerts_controller.tsx b/app/javascript/mastodon/components/alerts_controller.tsx index 26749fa10376e3..aa97feeca58dac 100644 --- a/app/javascript/mastodon/components/alerts_controller.tsx +++ b/app/javascript/mastodon/components/alerts_controller.tsx @@ -3,16 +3,16 @@ import { useState, useEffect } from 'react'; import { useIntl } from 'react-intl'; import type { IntlShape } from 'react-intl'; -import classNames from 'classnames'; - import { dismissAlert } from 'mastodon/actions/alerts'; import type { - Alert, + Alert as AlertType, TranslatableString, TranslatableValues, } from 'mastodon/models/alert'; import { useAppSelector, useAppDispatch } from 'mastodon/store'; +import { Alert } from './alert'; + const formatIfNeeded = ( intl: IntlShape, message: TranslatableString, @@ -25,8 +25,8 @@ const formatIfNeeded = ( return message; }; -const Alert: React.FC<{ - alert: Alert; +const TimedAlert: React.FC<{ + alert: AlertType; dismissAfter: number; }> = ({ alert: { key, title, message, values, action, onClick }, @@ -62,29 +62,13 @@ const Alert: React.FC<{ }, [dispatch, setActive, key, dismissAfter]); return ( -
-
- {title && ( - - {formatIfNeeded(intl, title, values)} - - )} - - - {formatIfNeeded(intl, message, values)} - - - {action && ( - - )} -
-
+ ); }; @@ -98,7 +82,11 @@ export const AlertsController: React.FC = () => { return (
{alerts.map((alert, idx) => ( - + ))}
); diff --git a/app/javascript/mastodon/components/dismissable_banner.tsx b/app/javascript/mastodon/components/dismissable_banner.tsx index c850323615d065..a874f4792e4c7f 100644 --- a/app/javascript/mastodon/components/dismissable_banner.tsx +++ b/app/javascript/mastodon/components/dismissable_banner.tsx @@ -45,7 +45,7 @@ export function useDismissableBannerState({ id }: Props) { }, [id, dispatch, isVisible, dismissed]); return { - isVisible, + wasDismissed: !isVisible, dismiss, }; } @@ -55,11 +55,11 @@ export const DismissableBanner: React.FC> = ({ children, }) => { const intl = useIntl(); - const { isVisible, dismiss } = useDismissableBannerState({ + const { wasDismissed, dismiss } = useDismissableBannerState({ id, }); - if (!isVisible) { + if (wasDismissed) { return null; } diff --git a/app/javascript/mastodon/components/display_name.tsx b/app/javascript/mastodon/components/display_name.tsx deleted file mode 100644 index 8409244827ebb4..00000000000000 --- a/app/javascript/mastodon/components/display_name.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import React from 'react'; - -import type { List } from 'immutable'; - -import type { Account } from 'mastodon/models/account'; - -import { autoPlayGif } from '../initial_state'; - -import { Skeleton } from './skeleton'; - -interface Props { - account?: Account; - others?: List; - localDomain?: string; -} - -export class DisplayName extends React.PureComponent { - handleMouseEnter: React.ReactEventHandler = ({ - currentTarget, - }) => { - if (autoPlayGif) { - return; - } - - const emojis = - currentTarget.querySelectorAll('img.custom-emoji'); - - emojis.forEach((emoji) => { - const originalSrc = emoji.getAttribute('data-original'); - if (originalSrc != null) emoji.src = originalSrc; - }); - }; - - handleMouseLeave: React.ReactEventHandler = ({ - currentTarget, - }) => { - if (autoPlayGif) { - return; - } - - const emojis = - currentTarget.querySelectorAll('img.custom-emoji'); - - emojis.forEach((emoji) => { - const staticSrc = emoji.getAttribute('data-static'); - if (staticSrc != null) emoji.src = staticSrc; - }); - }; - - render() { - const { others, localDomain } = this.props; - - let displayName: React.ReactNode, - suffix: React.ReactNode, - account: Account | undefined; - - if (others && others.size > 0) { - account = others.first(); - } else if (this.props.account) { - account = this.props.account; - } - - if (others && others.size > 1) { - displayName = others - .take(2) - .map((a) => ( - - - - )) - .reduce((prev, cur) => [prev, ', ', cur]); - - if (others.size - 2 > 0) { - suffix = `+${others.size - 2}`; - } - } else if (account) { - let acct = account.get('acct'); - - if (!acct.includes('@') && localDomain) { - acct = `${acct}@${localDomain}`; - } - - displayName = ( - - - - ); - suffix = @{acct}; - } else { - displayName = ( - - - - - - ); - suffix = ( - - - - ); - } - - return ( - - {displayName} {suffix} - - ); - } -} diff --git a/app/javascript/mastodon/components/display_name/default.tsx b/app/javascript/mastodon/components/display_name/default.tsx new file mode 100644 index 00000000000000..57ae24ab26f7ac --- /dev/null +++ b/app/javascript/mastodon/components/display_name/default.tsx @@ -0,0 +1,36 @@ +import { useMemo } from 'react'; +import type { ComponentPropsWithoutRef, FC } from 'react'; + +import { Skeleton } from '../skeleton'; + +import type { DisplayNameProps } from './index'; +import { DisplayNameWithoutDomain } from './no-domain'; + +export const DisplayNameDefault: FC< + Omit & ComponentPropsWithoutRef<'span'> +> = ({ account, localDomain, className, ...props }) => { + const username = useMemo(() => { + if (!account) { + return null; + } + let acct = account.get('acct'); + + if (!acct.includes('@') && localDomain) { + acct = `${acct}@${localDomain}`; + } + return `@${acct}`; + }, [account, localDomain]); + + return ( + + {' '} + + {username ?? } + + + ); +}; diff --git a/app/javascript/mastodon/components/display_name/display_name.stories.tsx b/app/javascript/mastodon/components/display_name/display_name.stories.tsx index ccd7dcbb916d8b..d546fdd135ea82 100644 --- a/app/javascript/mastodon/components/display_name/display_name.stories.tsx +++ b/app/javascript/mastodon/components/display_name/display_name.stories.tsx @@ -18,8 +18,6 @@ const meta = { username: 'mastodon@mastodon.social', name: 'Test User 🧪', loading: false, - simple: false, - noDomain: false, localDomain: 'mastodon.social', }, tags: [], @@ -50,13 +48,13 @@ export const Loading: Story = { export const NoDomain: Story = { args: { - noDomain: true, + variant: 'noDomain', }, }; export const Simple: Story = { args: { - simple: true, + variant: 'simple', }, }; @@ -76,6 +74,6 @@ export const Linked: Story = { acct: username, }) : undefined; - return ; + return ; }, }; diff --git a/app/javascript/mastodon/components/display_name/index.tsx b/app/javascript/mastodon/components/display_name/index.tsx index 6bd4addded5018..06bc380a10b2c2 100644 --- a/app/javascript/mastodon/components/display_name/index.tsx +++ b/app/javascript/mastodon/components/display_name/index.tsx @@ -1,110 +1,37 @@ import type { ComponentPropsWithoutRef, FC } from 'react'; -import { useMemo } from 'react'; -import classNames from 'classnames'; import type { LinkProps } from 'react-router-dom'; import { Link } from 'react-router-dom'; -import { EmojiHTML } from '@/mastodon/features/emoji/emoji_html'; import type { Account } from '@/mastodon/models/account'; -import { isModernEmojiEnabled } from '@/mastodon/utils/environment'; -import { Skeleton } from '../skeleton'; +import { DisplayNameDefault } from './default'; +import { DisplayNameWithoutDomain } from './no-domain'; +import { DisplayNameSimple } from './simple'; -interface Props { +export interface DisplayNameProps { account?: Account; localDomain?: string; - simple?: boolean; - noDomain?: boolean; + variant?: 'default' | 'simple' | 'noDomain'; } -export const DisplayName: FC> = ({ - account, - localDomain, - simple = false, - noDomain = false, - className, - ...props -}) => { - const username = useMemo(() => { - if (!account || noDomain) { - return null; - } - let acct = account.get('acct'); - - if (!acct.includes('@') && localDomain) { - acct = `${acct}@${localDomain}`; - } - return `@${acct}`; - }, [account, localDomain, noDomain]); - - if (!account) { - if (simple) { - return null; - } - return ( - - - - - - - {!noDomain && ( - -   - - - )} - - ); - } - const accountName = isModernEmojiEnabled() - ? account.get('display_name') - : account.get('display_name_html'); - if (simple) { - return ( - - - - ); +export const DisplayName: FC< + DisplayNameProps & ComponentPropsWithoutRef<'span'> +> = ({ variant = 'default', ...props }) => { + if (variant === 'simple') { + return ; + } else if (variant === 'noDomain') { + return ; } - - return ( - - - - - {username && ( -  {username} - )} - - ); + return ; }; export const LinkedDisplayName: FC< - Props & { asProps?: ComponentPropsWithoutRef<'span'> } & Partial -> = ({ - account, - asProps = {}, - className, - localDomain, - simple, - noDomain, - ...linkProps -}) => { - const displayProps = { - account, - className, - localDomain, - simple, - noDomain, - ...asProps, - }; + Omit & { + displayProps: DisplayNameProps & ComponentPropsWithoutRef<'span'>; + } +> = ({ displayProps, children, ...linkProps }) => { + const { account } = displayProps; if (!account) { return ; } @@ -113,9 +40,11 @@ export const LinkedDisplayName: FC< + {children} ); diff --git a/app/javascript/mastodon/components/display_name/no-domain.tsx b/app/javascript/mastodon/components/display_name/no-domain.tsx new file mode 100644 index 00000000000000..ccb9a62ab768e4 --- /dev/null +++ b/app/javascript/mastodon/components/display_name/no-domain.tsx @@ -0,0 +1,39 @@ +import type { ComponentPropsWithoutRef, FC } from 'react'; + +import classNames from 'classnames'; + +import { EmojiHTML } from '@/mastodon/features/emoji/emoji_html'; +import { isModernEmojiEnabled } from '@/mastodon/utils/environment'; + +import { Skeleton } from '../skeleton'; + +import type { DisplayNameProps } from './index'; + +export const DisplayNameWithoutDomain: FC< + Omit & + ComponentPropsWithoutRef<'span'> +> = ({ account, className, children, ...props }) => { + return ( + + + {account ? ( + + ) : ( + + + + )} + + {children} + + ); +}; diff --git a/app/javascript/mastodon/components/display_name/simple.tsx b/app/javascript/mastodon/components/display_name/simple.tsx new file mode 100644 index 00000000000000..3190c4384b2dcc --- /dev/null +++ b/app/javascript/mastodon/components/display_name/simple.tsx @@ -0,0 +1,23 @@ +import type { ComponentPropsWithoutRef, FC } from 'react'; + +import { EmojiHTML } from '@/mastodon/features/emoji/emoji_html'; +import { isModernEmojiEnabled } from '@/mastodon/utils/environment'; + +import type { DisplayNameProps } from './index'; + +export const DisplayNameSimple: FC< + Omit & + ComponentPropsWithoutRef<'span'> +> = ({ account, ...props }) => { + if (!account) { + return null; + } + const accountName = isModernEmojiEnabled() + ? account.get('display_name') + : account.get('display_name_html'); + return ( + + + + ); +}; diff --git a/app/javascript/mastodon/components/dropdown/index.tsx b/app/javascript/mastodon/components/dropdown/index.tsx index 1e442f8159e512..b6a04b9027f429 100644 --- a/app/javascript/mastodon/components/dropdown/index.tsx +++ b/app/javascript/mastodon/components/dropdown/index.tsx @@ -1,22 +1,28 @@ import { useCallback, useId, useMemo, useRef, useState } from 'react'; import type { ComponentPropsWithoutRef, FC } from 'react'; -import { FormattedMessage } from 'react-intl'; +import { useIntl } from 'react-intl'; import type { MessageDescriptor } from 'react-intl'; import classNames from 'classnames'; import Overlay from 'react-overlays/Overlay'; +import UnfoldMoreIcon from '@/material-icons/400-24px/unfold_more.svg?react'; + import type { SelectItem } from '../dropdown_selector'; import { DropdownSelector } from '../dropdown_selector'; +import { Icon } from '../icon'; + +import { matchWidth } from './utils'; interface DropdownProps { - title: string; disabled?: boolean; items: SelectItem[]; onChange: (value: string) => void; current: string; + labelId: string; + descriptionId?: string; emptyText?: MessageDescriptor; classPrefix: string; } @@ -24,39 +30,59 @@ interface DropdownProps { export const Dropdown: FC< DropdownProps & Omit, keyof DropdownProps> > = ({ - title, disabled, items, current, onChange, + labelId, + descriptionId, classPrefix, className, + id, ...buttonProps }) => { + const intl = useIntl(); const buttonRef = useRef(null); - const accessibilityId = useId(); + const uniqueId = useId(); + const buttonId = id ?? `${uniqueId}-button`; + const listboxId = `${uniqueId}-listbox`; const [open, setOpen] = useState(false); + const handleToggle = useCallback(() => { if (!disabled) { - setOpen((prevOpen) => !prevOpen); + setOpen((prevOpen) => { + buttonRef.current?.focus(); + return !prevOpen; + }); } }, [disabled]); + const handleClose = useCallback(() => { setOpen(false); + buttonRef.current?.focus(); }, []); + const currentText = useMemo( - () => items.find((i) => i.value === current)?.text, - [current, items], + () => + items.find((i) => i.value === current)?.text ?? + intl.formatMessage({ + id: 'dropdown.empty', + defaultMessage: 'Select an option', + }), + [current, intl, items], ); + return ( <> {({ props, placement }) => ( @@ -96,7 +123,7 @@ export const Dropdown: FC< `${classPrefix}__dropdown`, placement, )} - id={accessibilityId} + id={listboxId} > = { + name: 'sameWidth', + enabled: true, + phase: 'beforeWrite', + requires: ['computeStyles'], + fn: ({ state }) => { + if (state.styles.popper) { + state.styles.popper.width = `${state.rects.reference.width}px`; + } + }, + effect: ({ state }) => { + const reference = state.elements.reference as HTMLElement; + state.elements.popper.style.width = `${reference.offsetWidth}px`; + }, +}; diff --git a/app/javascript/mastodon/components/dropdown_menu.tsx b/app/javascript/mastodon/components/dropdown_menu.tsx index 442934f532038b..d489bc0f11eca6 100644 --- a/app/javascript/mastodon/components/dropdown_menu.tsx +++ b/app/javascript/mastodon/components/dropdown_menu.tsx @@ -36,6 +36,7 @@ import { import type { MenuItem } from 'mastodon/models/dropdown_menu'; import { useAppDispatch, useAppSelector } from 'mastodon/store'; +import { Icon } from './icon'; import type { IconProp } from './icon'; import { IconButton } from './icon_button'; @@ -68,6 +69,27 @@ interface DropdownMenuProps { onItemClick?: ItemClickFn; } +export const DropdownMenuItemContent: React.FC<{ item: MenuItem }> = ({ + item, +}) => { + if (item === null) { + return null; + } + + const { text, description, icon } = item; + return ( + <> + {icon && } + + {text} + {Boolean(description) && ( + {description} + )} + + + ); +}; + export const DropdownMenu = ({ items, loading, @@ -164,13 +186,16 @@ export const DropdownMenu = ({ (e: React.MouseEvent | React.KeyboardEvent) => { const i = Number(e.currentTarget.getAttribute('data-index')); const item = items?.[i]; + const isItemDisabled = Boolean( + item && typeof item === 'object' && 'disabled' in item && item.disabled, + ); - onClose(); - - if (!item) { + if (!item || isItemDisabled) { return; } + onClose(); + if (typeof onItemClick === 'function') { e.preventDefault(); onItemClick(item, i); @@ -200,7 +225,7 @@ export const DropdownMenu = ({ return
  • ; } - const { text, dangerous } = option; + const { text, highlighted, disabled, dangerous } = option; let element: React.ReactElement; @@ -211,8 +236,9 @@ export const DropdownMenu = ({ onClick={handleItemClick} onKeyUp={handleItemKeyUp} data-index={i} + aria-disabled={disabled} > - {text} + ); } else if (isExternalLinkItem(option)) { @@ -227,7 +253,7 @@ export const DropdownMenu = ({ onKeyUp={handleItemKeyUp} data-index={i} > - {text} + ); } else { @@ -239,7 +265,7 @@ export const DropdownMenu = ({ onKeyUp={handleItemKeyUp} data-index={i} > - {text} + ); } @@ -247,6 +273,7 @@ export const DropdownMenu = ({ return (
  • ({ ); }; -interface DropdownProps { +interface DropdownProps { children?: React.ReactElement; icon?: string; iconComponent?: IconProp; @@ -325,7 +352,7 @@ interface DropdownProps { const offset = [5, 5] as OffsetValue; const popperConfig = { strategy: 'fixed' } as UsePopperOptions; -export const Dropdown = ({ +export const Dropdown = ({ children, icon, iconComponent, diff --git a/app/javascript/mastodon/components/dropdown_selector.tsx b/app/javascript/mastodon/components/dropdown_selector.tsx index 384488924dd629..12744506bb14f6 100644 --- a/app/javascript/mastodon/components/dropdown_selector.tsx +++ b/app/javascript/mastodon/components/dropdown_selector.tsx @@ -40,24 +40,10 @@ export const DropdownSelector: React.FC = ({ onClose, onChange, }) => { - const nodeRef = useRef(null); + const listRef = useRef(null); const focusedItemRef = useRef(null); const [currentValue, setCurrentValue] = useState(value); - const handleDocumentClick = useCallback( - (e: MouseEvent | TouchEvent) => { - if ( - nodeRef.current && - e.target instanceof Node && - !nodeRef.current.contains(e.target) - ) { - onClose(); - e.stopPropagation(); - } - }, - [nodeRef, onClose], - ); - const handleClick = useCallback( ( e: React.MouseEvent | React.KeyboardEvent, @@ -89,30 +75,30 @@ export const DropdownSelector: React.FC = ({ break; case 'ArrowDown': element = - nodeRef.current?.children[index + 1] ?? - nodeRef.current?.firstElementChild; + listRef.current?.children[index + 1] ?? + listRef.current?.firstElementChild; break; case 'ArrowUp': element = - nodeRef.current?.children[index - 1] ?? - nodeRef.current?.lastElementChild; + listRef.current?.children[index - 1] ?? + listRef.current?.lastElementChild; break; case 'Tab': if (e.shiftKey) { element = - nodeRef.current?.children[index - 1] ?? - nodeRef.current?.lastElementChild; + listRef.current?.children[index - 1] ?? + listRef.current?.lastElementChild; } else { element = - nodeRef.current?.children[index + 1] ?? - nodeRef.current?.firstElementChild; + listRef.current?.children[index + 1] ?? + listRef.current?.firstElementChild; } break; case 'Home': - element = nodeRef.current?.firstElementChild; + element = listRef.current?.firstElementChild; break; case 'End': - element = nodeRef.current?.lastElementChild; + element = listRef.current?.lastElementChild; break; } @@ -124,12 +110,24 @@ export const DropdownSelector: React.FC = ({ e.stopPropagation(); } }, - [nodeRef, items, onClose, handleClick, setCurrentValue], + [items, onClose, handleClick, setCurrentValue], ); useEffect(() => { + const handleDocumentClick = (e: MouseEvent | TouchEvent) => { + if ( + listRef.current && + e.target instanceof Node && + !listRef.current.contains(e.target) + ) { + onClose(); + e.stopPropagation(); + } + }; + document.addEventListener('click', handleDocumentClick, { capture: true }); document.addEventListener('touchend', handleDocumentClick, listenerOptions); + focusedItemRef.current?.focus({ preventScroll: true }); return () => { @@ -142,10 +140,10 @@ export const DropdownSelector: React.FC = ({ listenerOptions, ); }; - }, [handleDocumentClick]); + }, [onClose]); return ( -
      +
        {items.map((item) => (
      • = ({ pollId, disabled, status }) => { openModal({ modalType: 'INTERACTION', modalProps: { - type: 'vote', accountId: status.getIn(['account', 'id']), url: status.get('uri'), }, diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index b4488996d2dc47..35049de7ace7b9 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -32,7 +32,7 @@ import { displayMedia, enableEmojiReaction, isShowItem, isHideItem } from '../in import { Avatar } from './avatar'; import { AvatarOverlay } from './avatar_overlay'; -import { DisplayName } from './display_name'; +import { LinkedDisplayName } from './display_name'; import { getHashtagBarForStatus } from './hashtag_bar'; import { RelativeTimestamp } from './relative_timestamp'; import StatusActionBar from './status_action_bar'; @@ -418,13 +418,21 @@ class Status extends ImmutablePureComponent { let visibilityName = status.get('limited_scope') || status.get('visibility_ex') || status.get('visibility'); if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { - const display_name_html = { __html: status.getIn(['account', 'display_name_html']) }; + const name = ( + + ) prepend = (
        - }} /> +
        ); @@ -602,17 +610,15 @@ class Status extends ImmutablePureComponent { {withReference} {withExpiration} {withLimited} - + {status.get('edited_at') && *} - +
        {statusAvatar}
        - - - +
        {isQuotedPost && !!this.props.onQuoteCancel && ( ( - 0} /> diff --git a/app/javascript/mastodon/components/status/boost_button.tsx b/app/javascript/mastodon/components/status/boost_button.tsx new file mode 100644 index 00000000000000..449ce9f9757afb --- /dev/null +++ b/app/javascript/mastodon/components/status/boost_button.tsx @@ -0,0 +1,314 @@ +import { useCallback, useMemo } from 'react'; +import type { FC, KeyboardEvent, MouseEvent, MouseEventHandler } from 'react'; + +import { useIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import { insertReferenceCompose } from '@/mastodon/actions/compose'; +import { quoteComposeById } from '@/mastodon/actions/compose_typed'; +import { toggleReblog } from '@/mastodon/actions/interactions'; +import { openModal } from '@/mastodon/actions/modal'; +import type { ActionMenuItem } from '@/mastodon/models/dropdown_menu'; +import type { Status } from '@/mastodon/models/status'; +import { useAppDispatch, useAppSelector } from '@/mastodon/store'; +import { isFeatureEnabled } from '@/mastodon/utils/environment'; +import type { SomeRequired } from '@/mastodon/utils/types'; + +import type { RenderItemFn, RenderItemFnHandlers } from '../dropdown_menu'; +import { Dropdown, DropdownMenuItemContent } from '../dropdown_menu'; +import { IconButton } from '../icon_button'; + +import type { MenuItemState } from './boost_button_utils'; +import { + boostItemState, + messages, + quoteItemState, + referenceItemState, + selectStatusState, +} from './boost_button_utils'; + +const renderMenuItem: RenderItemFn = ( + item, + index, + handlers, + focusRefCallback, +) => ( + +); + +interface ReblogButtonProps { + status: Status; + counters?: boolean; +} + +type ActionMenuItemWithIcon = SomeRequired; + +export const StatusBoostButton: FC = ({ + status, + counters, +}) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const statusState = useAppSelector((state) => + selectStatusState(state, status), + ); + const { + isLoggedIn, + isReblogged, + isReblogAllowed, + isQuoteAutomaticallyAccepted, + isQuoteManuallyAccepted, + } = statusState; + + const isMenuDisabled = + !isQuoteAutomaticallyAccepted && + !isQuoteManuallyAccepted && + !isReblogAllowed; + + const statusId = status.get('id') as string; + const statusUrl = status.get('url') as string; + const wasBoosted = !!status.get('reblogged'); + const isQuoteUiDisabled = !isFeatureEnabled('outgoing_quotes'); + + const showLoginPrompt = useCallback(() => { + dispatch( + openModal({ + modalType: 'INTERACTION', + modalProps: { + accountId: status.getIn(['account', 'id']), + url: status.get('uri'), + }, + }), + ); + }, [dispatch, status]); + + const items = useMemo(() => { + const boostItem = boostItemState(statusState); + const boostWithModalItem = boostItemState(statusState, true); + const quoteItem = quoteItemState(statusState); + const quoteWithLinkItem = quoteItemState(statusState, true); + const referenceItem = referenceItemState(statusState); + const generateItem = ( + item: MenuItemState, + action: (event: MouseEvent | KeyboardEvent | React.TouchEvent) => void, + highlighted: boolean | undefined = undefined, + ): ActionMenuItemWithIcon => { + return { + text: intl.formatMessage(item.title), + description: item.meta ? intl.formatMessage(item.meta) : undefined, + icon: item.iconComponent, + highlighted, + disabled: item.disabled, + action, + }; + }; + + if (isQuoteUiDisabled) { + return [ + generateItem( + boostItem, + () => { + dispatch(toggleReblog(statusId, true, false)); + }, + wasBoosted, + ), + generateItem( + boostWithModalItem, + () => { + dispatch(toggleReblog(statusId, false, true)); + }, + wasBoosted, + ), + generateItem(referenceItem, () => { + dispatch(insertReferenceCompose(0, statusUrl, 'BT')); + }), + ] satisfies [ + ActionMenuItemWithIcon, + ActionMenuItemWithIcon, + ActionMenuItemWithIcon, + ]; + } + + return [ + generateItem( + boostItem, + () => { + dispatch(toggleReblog(statusId, true, false)); + }, + wasBoosted, + ), + generateItem( + boostWithModalItem, + () => { + dispatch(toggleReblog(statusId, false, true)); + }, + wasBoosted, + ), + generateItem(quoteItem, () => { + dispatch(quoteComposeById(statusId)); + }), + generateItem(quoteWithLinkItem, () => { + dispatch(insertReferenceCompose(0, statusUrl, 'RE')); + }), + generateItem(referenceItem, () => { + dispatch(insertReferenceCompose(0, statusUrl, 'BT')); + }), + ] satisfies [ + ActionMenuItemWithIcon, + ActionMenuItemWithIcon, + ActionMenuItemWithIcon, + ActionMenuItemWithIcon, + ActionMenuItemWithIcon, + ]; + }, [ + dispatch, + intl, + statusId, + statusState, + wasBoosted, + statusUrl, + isQuoteUiDisabled, + ]); + + const boostIcon = items[0].icon; + + const handleDropdownOpen = useCallback( + (event: MouseEvent | KeyboardEvent) => { + if (!isLoggedIn) { + showLoginPrompt(); + return false; + } + + if (event.shiftKey) { + dispatch(toggleReblog(status.get('id'), true)); + return false; + } + return true; + }, + [dispatch, isLoggedIn, showLoginPrompt, status], + ); + + return ( + + + + ); +}; + +interface ReblogMenuItemProps { + item: ActionMenuItem; + index: number; + handlers: RenderItemFnHandlers; + focusRefCallback?: (c: HTMLAnchorElement | HTMLButtonElement | null) => void; +} + +const ReblogMenuItem: FC = ({ + index, + item, + handlers, + focusRefCallback, +}) => { + const { text, highlighted, disabled } = item; + + return ( +
      • + +
      • + ); +}; + +// Legacy helpers + +// Switch between the legacy and new reblog button based on feature flag. +export const BoostButton: FC = (props) => { + return ; +}; + +export const LegacyReblogButton: FC = ({ + status, + counters, +}) => { + const intl = useIntl(); + const statusState = useAppSelector((state) => + selectStatusState(state, status), + ); + + const { title, meta, iconComponent, disabled } = useMemo( + () => boostItemState(statusState), + [statusState], + ); + + const dispatch = useAppDispatch(); + const handleClick: MouseEventHandler = useCallback( + (event) => { + if (statusState.isLoggedIn) { + dispatch(toggleReblog(status.get('id') as string, event.shiftKey)); + } else { + dispatch( + openModal({ + modalType: 'INTERACTION', + modalProps: { + accountId: status.getIn(['account', 'id']), + url: status.get('uri'), + }, + }), + ); + } + }, + [dispatch, status, statusState.isLoggedIn], + ); + + return ( + + ); +}; diff --git a/app/javascript/mastodon/components/status/boost_button_utils.ts b/app/javascript/mastodon/components/status/boost_button_utils.ts new file mode 100644 index 00000000000000..fd8ef99cd2a5b0 --- /dev/null +++ b/app/javascript/mastodon/components/status/boost_button_utils.ts @@ -0,0 +1,214 @@ +import { defineMessages } from 'react-intl'; +import type { MessageDescriptor } from 'react-intl'; + +import { isHideItem } from '@/mastodon/initial_state'; +import type { Status, StatusVisibility } from '@/mastodon/models/status'; +import { createAppSelector } from '@/mastodon/store'; +import FormatQuote from '@/material-icons/400-24px/format_quote-fill.svg?react'; +import FormatQuoteOff from '@/material-icons/400-24px/format_quote_off-fill.svg?react'; +import ReferenceIcon from '@/material-icons/400-24px/link.svg?react'; +import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; +import RepeatActiveIcon from '@/svg-icons/repeat_active.svg?react'; +import RepeatDisabledIcon from '@/svg-icons/repeat_disabled.svg?react'; +import RepeatPrivateIcon from '@/svg-icons/repeat_private.svg?react'; +import RepeatPrivateActiveIcon from '@/svg-icons/repeat_private_active.svg?react'; + +import type { IconProp } from '../icon'; + +export const messages = defineMessages({ + all_disabled: { + id: 'status.all_disabled', + defaultMessage: 'Boosts and quotes are disabled', + }, + quote: { id: 'status.quote', defaultMessage: 'Quote' }, + quote_cannot: { + id: 'status.cannot_quote', + defaultMessage: 'You are not allowed to quote this post', + }, + quote_followers_only: { + id: 'status.quote_followers_only', + defaultMessage: 'Only followers can quote this post', + }, + quote_manual_review: { + id: 'status.quote_manual_review', + defaultMessage: 'Author will manually review', + }, + quote_private: { + id: 'status.quote_private', + defaultMessage: 'Private posts cannot be quoted', + }, + reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, + reblog_or_quote: { + id: 'status.reblog_or_quote', + defaultMessage: 'Boost or quote', + }, + reblog_cancel: { + id: 'status.cancel_reblog_private', + defaultMessage: 'Unboost', + }, + reblog_private: { + id: 'status.reblog_private', + defaultMessage: 'Share again with your followers', + }, + reblog_cannot: { + id: 'status.cannot_reblog', + defaultMessage: 'This post cannot be boosted', + }, + request_quote: { + id: 'status.request_quote', + defaultMessage: 'Request to quote', + }, + reblog_with_detail: { + id: 'status.reblog_with_detail', + defaultMessage: 'Boost with visibility', + }, + reference_link: { + id: 'status.reference_link', + defaultMessage: 'Insert post link', + }, + reference_disabled: { + id: 'status.cannot_reference', + defaultMessage: 'This server cannot receive link', + }, + quote_link: { id: 'status.quote_link', defaultMessage: 'Insert quote link' }, +}); + +export const selectStatusState = createAppSelector( + [ + (state) => state.meta.get('me') as string | undefined, + (_, status: Status) => status, + ], + (userId, status) => { + const isPublic = [ + 'public', + 'unlisted', + 'public_unlisted', + 'login', + ].includes(status.get('visibility_ex') as StatusVisibility); + const isMineAndPrivate = + userId === status.getIn(['account', 'id']) && + status.get('visibility_ex') === 'private'; + return { + isLoggedIn: !!userId, + isPublic, + isMine: userId === status.getIn(['account', 'id']), + isPrivateReblog: + userId === status.getIn(['account', 'id']) && + status.get('visibility_ex') === 'private', + isReblogged: !!status.get('reblogged'), + isReblogAllowed: isPublic || isMineAndPrivate, + isQuoteAutomaticallyAccepted: + status.getIn(['quote_approval', 'current_user']) === 'automatic' && + (isPublic || isMineAndPrivate), + isQuoteManuallyAccepted: + status.getIn(['quote_approval', 'current_user']) === 'manual' && + (isPublic || isMineAndPrivate), + isQuoteFollowersOnly: + status.getIn(['quote_approval', 'automatic', 0]) === 'followers' || + status.getIn(['quote_approval', 'manual', 0]) === 'followers', + isStatusReferenceAvailableServer: !!status.getIn([ + 'account', + 'server_features', + 'status_reference', + ]), + }; + }, +); + +export type StatusState = ReturnType; + +export interface MenuItemState { + title: MessageDescriptor; + meta?: MessageDescriptor; + iconComponent: IconProp; + disabled?: boolean; +} + +export function boostItemState( + { isPublic, isPrivateReblog, isReblogged }: StatusState, + isForceModal = false, +): MenuItemState { + if (isReblogged) { + return { + title: messages.reblog_cancel, + iconComponent: isPublic ? RepeatActiveIcon : RepeatPrivateActiveIcon, + }; + } + const iconText: MenuItemState = { + title: isForceModal ? messages.reblog_with_detail : messages.reblog, + iconComponent: RepeatIcon, + }; + + if (isPrivateReblog) { + iconText.meta = messages.reblog_private; + iconText.iconComponent = RepeatPrivateIcon; + } else if (!isPublic) { + iconText.meta = messages.reblog_cannot; + iconText.iconComponent = RepeatDisabledIcon; + iconText.disabled = true; + } + return iconText; +} + +export function quoteItemState( + { + isLoggedIn, + isMine, + isQuoteAutomaticallyAccepted, + isQuoteManuallyAccepted, + isQuoteFollowersOnly, + isPublic, + }: StatusState, + isLink = false, +): MenuItemState { + const iconText: MenuItemState = { + title: messages.quote, + iconComponent: FormatQuote, + }; + + if (!isPublic && !isMine) { + iconText.disabled = true; + iconText.iconComponent = FormatQuoteOff; + iconText.meta = messages.quote_private; + } else if (isQuoteAutomaticallyAccepted) { + iconText.title = messages.quote; + } else if (isQuoteManuallyAccepted) { + iconText.title = messages.request_quote; + iconText.meta = messages.quote_manual_review; + // We don't show the disabled state when logged out + } else if (isLoggedIn) { + iconText.disabled = true; + iconText.iconComponent = FormatQuoteOff; + iconText.meta = isQuoteFollowersOnly + ? messages.quote_followers_only + : messages.quote_cannot; + } + + if (isLink) { + iconText.title = messages.quote_link; + } + + return iconText; +} + +export function referenceItemState({ + isPublic, + isStatusReferenceAvailableServer, +}: StatusState): MenuItemState { + const iconText: MenuItemState = { + title: messages.reference_link, + iconComponent: ReferenceIcon, + }; + + if (!isPublic) { + iconText.disabled = true; + } else if ( + isHideItem('status_reference_unavailable_server') && + !isStatusReferenceAvailableServer + ) { + iconText.disabled = true; + iconText.meta = messages.reference_disabled; + } + + return iconText; +} diff --git a/app/javascript/mastodon/components/status/reblog_button.tsx b/app/javascript/mastodon/components/status/reblog_button.tsx deleted file mode 100644 index 56762696f20a65..00000000000000 --- a/app/javascript/mastodon/components/status/reblog_button.tsx +++ /dev/null @@ -1,546 +0,0 @@ -import { useCallback, useMemo } from 'react'; -import type { - FC, - KeyboardEvent, - MouseEvent, - MouseEventHandler, - SVGProps, -} from 'react'; - -import type { MessageDescriptor } from 'react-intl'; -import { defineMessages, useIntl } from 'react-intl'; - -import classNames from 'classnames'; - -import { insertReferenceCompose } from '@/mastodon/actions/compose'; -import { quoteComposeById } from '@/mastodon/actions/compose_typed'; -import { toggleReblog } from '@/mastodon/actions/interactions'; -import { openModal } from '@/mastodon/actions/modal'; -import { isHideItem } from '@/mastodon/initial_state'; -import type { ActionMenuItem } from '@/mastodon/models/dropdown_menu'; -import type { Status, StatusVisibility } from '@/mastodon/models/status'; -import { - createAppSelector, - useAppDispatch, - useAppSelector, -} from '@/mastodon/store'; -import { isFeatureEnabled } from '@/mastodon/utils/environment'; -import FormatQuote from '@/material-icons/400-24px/format_quote-fill.svg?react'; -import FormatQuoteOff from '@/material-icons/400-24px/format_quote_off-fill.svg?react'; -import ReferenceIcon from '@/material-icons/400-24px/link.svg?react'; -import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; -import RepeatActiveIcon from '@/svg-icons/repeat_active.svg?react'; -import RepeatDisabledIcon from '@/svg-icons/repeat_disabled.svg?react'; -import RepeatPrivateIcon from '@/svg-icons/repeat_private.svg?react'; -import RepeatPrivateActiveIcon from '@/svg-icons/repeat_private_active.svg?react'; - -import type { RenderItemFn, RenderItemFnHandlers } from '../dropdown_menu'; -import { Dropdown } from '../dropdown_menu'; -import { Icon } from '../icon'; -import { IconButton } from '../icon_button'; - -const messages = defineMessages({ - all_disabled: { - id: 'status.all_disabled', - defaultMessage: 'Boosts and quotes are disabled', - }, - quote: { id: 'status.quote', defaultMessage: 'Quote' }, - quote_cannot: { - id: 'status.cannot_quote', - defaultMessage: 'Quotes are disabled on this post', - }, - quote_followers_only: { - id: 'status.quote_followers_only', - defaultMessage: 'Only followers can quote this post', - }, - quote_manual_review: { - id: 'status.quote_manual_review', - defaultMessage: 'Author will manually review', - }, - quote_private: { - id: 'status.quote_private', - defaultMessage: 'Private posts cannot be quoted', - }, - reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, - reblog_or_quote: { - id: 'status.reblog_or_quote', - defaultMessage: 'Boost or quote', - }, - reblog_cancel: { - id: 'status.cancel_reblog_private', - defaultMessage: 'Unboost', - }, - reblog_private: { - id: 'status.reblog_private', - defaultMessage: 'Share again with your followers', - }, - reblog_cannot: { - id: 'status.cannot_reblog', - defaultMessage: 'This post cannot be boosted', - }, - request_quote: { - id: 'status.request_quote', - defaultMessage: 'Request to quote', - }, - reblog_with_detail: { - id: 'status.reblog_with_detail', - defaultMessage: 'Boost with visibility', - }, - reference_link: { - id: 'status.reference_link', - defaultMessage: 'Insert post link', - }, - reference_disabled: { - id: 'status.cannot_reference', - defaultMessage: 'This server cannot receive link', - }, - quote_link: { id: 'status.quote_link', defaultMessage: 'Insert quote link' }, -}); - -interface ReblogButtonProps { - status: Status; - counters?: boolean; - isQuoteUiDisabled?: boolean; -} - -export const StatusReblogButton: FC = ({ - status, - counters, - isQuoteUiDisabled, -}) => { - const intl = useIntl(); - - const statusState = useAppSelector((state) => - selectStatusState(state, status), - ); - const { - isLoggedIn, - isReblogged, - isReblogAllowed, - isQuoteAutomaticallyAccepted, - isQuoteManuallyAccepted, - } = statusState; - const { iconComponent } = useMemo( - () => reblogIconText(statusState, false), - [statusState], - ); - const disabled = - !isQuoteAutomaticallyAccepted && - !isQuoteManuallyAccepted && - !isReblogAllowed; - - const dispatch = useAppDispatch(); - const statusId = status.get('id') as string; - const statusUrl = status.get('url') as string; - const items: (ActionMenuItem & { - message: { id: string; defaultMessage: string }; // kmyblue bugfix - })[] = useMemo( - () => - [ - { - text: 'reblog', - message: messages.reblog, // kmyblue bugfix - action: () => { - if (isLoggedIn) { - dispatch(toggleReblog(statusId, true)); - } - }, - }, - { - text: 'reblog_with_detail', - message: messages.reblog_with_detail, // kmyblue bugfix - action: () => { - if (isLoggedIn) { - dispatch(toggleReblog(statusId, true, true)); - } - }, - }, - { - text: 'quote', - message: messages.quote, // kmyblue bugfix - action: () => { - if (isLoggedIn) { - dispatch(quoteComposeById(statusId)); - } - }, - }, - { - text: 'quote_link', - message: messages.quote_link, // kmyblue bugfix - action: () => { - if (isLoggedIn) { - dispatch(insertReferenceCompose(0, statusUrl, 'RE')); - } - }, - }, - { - text: 'reference', - message: messages.reference_link, // kmyblue bugfix - action: () => { - if (isLoggedIn) { - dispatch(insertReferenceCompose(0, statusUrl, 'BT')); - } - }, - }, - ].filter(({ text }) => { - if (['quote', 'quote_link'].includes(text)) { - return !isQuoteUiDisabled; - } - - if (text === 'reblog_with_detail') { - return !isReblogged; - } - - return true; - }), - [dispatch, isLoggedIn, statusId, statusUrl, isQuoteUiDisabled, isReblogged], - ); - - const handleDropdownOpen = useCallback( - (event: MouseEvent | KeyboardEvent) => { - if (!isLoggedIn) { - dispatch( - openModal({ - modalType: 'INTERACTION', - modalProps: { - type: 'reblog', - accountId: status.getIn(['account', 'id']), - url: status.get('uri'), - }, - }), - ); - } else if (event.shiftKey) { - dispatch(toggleReblog(status.get('id'), true)); - return false; - } - return true; - }, - [dispatch, isLoggedIn, status], - ); - - const renderMenuItem: RenderItemFn = useCallback( - (item, index, handlers, focusRefCallback) => ( - - ), - [status], - ); - - // kmyblue bugfix - const dropdownItems = useMemo( - () => - items.map((item) => ({ - text: intl.formatMessage(item.message), - key: item.text, - action: item.action, - })), - [items, intl], - ); - - return ( - - - - ); -}; - -interface ReblogMenuItemProps { - status: Status; - item: ActionMenuItem & { key?: string }; // kmyblue bugfix - index: number; - handlers: RenderItemFnHandlers; - focusRefCallback?: (c: HTMLAnchorElement | HTMLButtonElement | null) => void; -} - -const ReblogMenuItem: FC = ({ - status, - index, - item: { key: text }, // kmyblue bugfix - handlers, - focusRefCallback, -}) => { - text ??= ''; // kmyblue bugfix - const intl = useIntl(); - const statusState = useAppSelector((state) => - selectStatusState(state, status), - ); - const { title, meta, iconComponent, disabled } = useMemo(() => { - switch (text) { - case 'quote': - return quoteIconText(statusState, false); - case 'quote_link': - return quoteIconText(statusState, true); - case 'reblog': - return reblogIconText(statusState, false); - case 'reblog_with_detail': - return reblogIconText(statusState, true); - case 'reference': - return referenceIconText(statusState); - default: - return quoteIconText(statusState, true); - } - }, [statusState, text]); - const active = useMemo( - () => - ['reblog', 'reblog_with_detail'].includes(text) && - !!status.get('reblogged'), - [status, text], - ); - - return ( -
      • - -
      • - ); -}; - -// Legacy helpers - -// Switch between the legacy and new reblog button based on feature flag. -export const ReblogButton: FC = (props) => { - return ( - - ); -}; - -export const LegacyReblogButton: FC = ({ - status, - counters, -}) => { - const intl = useIntl(); - const statusState = useAppSelector((state) => - selectStatusState(state, status), - ); - - const { title, meta, iconComponent, disabled } = useMemo( - () => reblogIconText(statusState, false), - [statusState], - ); - - const dispatch = useAppDispatch(); - const handleClick: MouseEventHandler = useCallback( - (event) => { - if (statusState.isLoggedIn) { - dispatch(toggleReblog(status.get('id') as string, event.shiftKey)); - } else { - dispatch( - openModal({ - modalType: 'INTERACTION', - modalProps: { - type: 'reblog', - accountId: status.getIn(['account', 'id']), - url: status.get('uri'), - }, - }), - ); - } - }, - [dispatch, status, statusState.isLoggedIn], - ); - - return ( - - ); -}; - -// Helpers for copy and state for status. -const selectStatusState = createAppSelector( - [ - (state) => state.meta.get('me') as string | undefined, - (_, status: Status) => status, - ], - (userId, status) => { - const isPublic = [ - 'public', - 'public_unlisted', - 'login', - 'unlisted', - ].includes(status.get('visibility_ex') as StatusVisibility); - const isMineAndPrivate = - userId === status.getIn(['account', 'id']) && - status.get('visibility_ex') === 'private'; - return { - isLoggedIn: !!userId, - isPublic, - isMine: userId === status.getIn(['account', 'id']), - isPrivateReblog: - userId === status.getIn(['account', 'id']) && - status.get('visibility_ex') === 'private', - isReblogged: !!status.get('reblogged'), - isReblogAllowed: isPublic || isMineAndPrivate, - isQuoteAutomaticallyAccepted: - status.getIn(['quote_approval', 'current_user']) === 'automatic' && - (isPublic || isMineAndPrivate), - isQuoteManuallyAccepted: - status.getIn(['quote_approval', 'current_user']) === 'manual' && - (isPublic || isMineAndPrivate), - isQuoteFollowersOnly: - status.getIn(['quote_approval', 'automatic', 0]) === 'followers' || - status.getIn(['quote_approval', 'manual', 0]) === 'followers', - isStatusReferenceAvailableServer: !!status.getIn([ - 'account', - 'server_features', - 'status_reference', - ]), - }; - }, -); -type StatusState = ReturnType; - -interface IconText { - title: MessageDescriptor; - meta?: MessageDescriptor; - iconComponent: FC>; - disabled?: boolean; -} - -function referenceIconText({ - isPublic, - isStatusReferenceAvailableServer, -}: StatusState): IconText { - const iconText: IconText = { - title: messages.reference_link, - iconComponent: ReferenceIcon, - }; - - if (!isPublic) { - iconText.disabled = true; - } else if ( - isHideItem('status_reference_unavailable_server') && - !isStatusReferenceAvailableServer - ) { - iconText.disabled = true; - iconText.meta = messages.reference_disabled; - } - return iconText; -} - -function reblogIconText( - { isPublic, isPrivateReblog, isReblogged }: StatusState, - isForceModal: boolean, -): IconText { - if (isReblogged) { - return { - title: messages.reblog_cancel, - iconComponent: isPublic ? RepeatActiveIcon : RepeatPrivateActiveIcon, - }; - } - const iconText: IconText = { - title: isForceModal ? messages.reblog_with_detail : messages.reblog, - iconComponent: RepeatIcon, - }; - - if (isPrivateReblog) { - iconText.meta = messages.reblog_private; - iconText.iconComponent = RepeatPrivateIcon; - } else if (!isPublic) { - iconText.meta = messages.reblog_cannot; - iconText.iconComponent = RepeatDisabledIcon; - iconText.disabled = true; - } - return iconText; -} - -function quoteIconText( - { - isMine, - isQuoteAutomaticallyAccepted, - isQuoteManuallyAccepted, - isQuoteFollowersOnly, - isPublic, - }: StatusState, - isLink: boolean, -): IconText { - const iconText: IconText = { - title: messages.quote, - iconComponent: FormatQuote, - }; - - if (!isPublic && !isMine) { - iconText.disabled = true; - iconText.iconComponent = FormatQuoteOff; - iconText.meta = messages.quote_private; - } else if (isQuoteAutomaticallyAccepted) { - iconText.title = messages.quote; - } else if (isQuoteManuallyAccepted) { - iconText.title = messages.request_quote; - iconText.meta = messages.quote_manual_review; - } else { - iconText.disabled = true; - iconText.iconComponent = FormatQuoteOff; - iconText.meta = isQuoteFollowersOnly - ? messages.quote_followers_only - : messages.quote_cannot; - } - - if (isLink) { - iconText.title = messages.quote_link; - } - - return iconText; -} diff --git a/app/javascript/mastodon/components/status_action_bar/index.jsx b/app/javascript/mastodon/components/status_action_bar/index.jsx index f13a0c1909d278..b4de5977e586dd 100644 --- a/app/javascript/mastodon/components/status_action_bar/index.jsx +++ b/app/javascript/mastodon/components/status_action_bar/index.jsx @@ -25,7 +25,7 @@ import { enableEmojiReaction , bookmarkCategoryNeeded, simpleTimelineMenu, me, i import { IconButton } from '../icon_button'; import { isFeatureEnabled } from '../../utils/environment'; -import { ReblogButton } from '../status/reblog_button'; +import { BoostButton } from '../status/boost_button'; import { RemoveQuoteHint } from './remove_quote_hint'; @@ -133,7 +133,7 @@ class StatusActionBar extends ImmutablePureComponent { if (signedIn) { this.props.onReply(this.props.status); } else { - this.props.onInteractionModal('reply', this.props.status); + this.props.onInteractionModal(this.props.status); } }; @@ -151,7 +151,7 @@ class StatusActionBar extends ImmutablePureComponent { if (signedIn) { this.props.onFavourite(this.props.status); } else { - this.props.onInteractionModal('favourite', this.props.status); + this.props.onInteractionModal(this.props.status); } }; @@ -445,7 +445,7 @@ class StatusActionBar extends ImmutablePureComponent {
        - +
        diff --git a/app/javascript/mastodon/components/status_action_bar/remove_quote_hint.tsx b/app/javascript/mastodon/components/status_action_bar/remove_quote_hint.tsx index 6046dad035d847..dec9c3ef38cb2f 100644 --- a/app/javascript/mastodon/components/status_action_bar/remove_quote_hint.tsx +++ b/app/javascript/mastodon/components/status_action_bar/remove_quote_hint.tsx @@ -1,4 +1,4 @@ -import { useRef } from 'react'; +import { useEffect, useRef, useState, useId } from 'react'; import { FormattedMessage, useIntl } from 'react-intl'; @@ -14,6 +14,13 @@ import { Icon } from '../icon'; const DISMISSABLE_BANNER_ID = 'notifications/remove_quote_hint'; +/** + * We don't want to show this hint in the UI more than once, + * so the first time it renders, we store a unique component ID + * here to prevent any other hints from being displayed after it. + */ +let firstHintId: string | null = null; + export const RemoveQuoteHint: React.FC<{ canShowHint: boolean; className?: string; @@ -22,14 +29,36 @@ export const RemoveQuoteHint: React.FC<{ const anchorRef = useRef(null); const intl = useIntl(); - const { isVisible, dismiss } = useDismissableBannerState({ + const { wasDismissed, dismiss } = useDismissableBannerState({ id: DISMISSABLE_BANNER_ID, }); + const shouldShowHint = !wasDismissed && canShowHint; + + const uniqueId = useId(); + const [isOnlyHint, setIsOnlyHint] = useState(false); + useEffect(() => { + if (!shouldShowHint) { + return () => null; + } + + if (!firstHintId) { + firstHintId = uniqueId; + setIsOnlyHint(true); + } + + return () => { + if (firstHintId === uniqueId) { + firstHintId = null; + setIsOnlyHint(false); + } + }; + }, [shouldShowHint, uniqueId]); + return (
        {children(dismiss)} - {isVisible && canShowHint && ( + {shouldShowHint && isOnlyHint && ( { diff --git a/app/javascript/mastodon/components/status_quoted.tsx b/app/javascript/mastodon/components/status_quoted.tsx index 3ac720bbc08109..8c8216701a66ba 100644 --- a/app/javascript/mastodon/components/status_quoted.tsx +++ b/app/javascript/mastodon/components/status_quoted.tsx @@ -1,39 +1,33 @@ -import { useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { FormattedMessage } from 'react-intl'; -import classNames from 'classnames'; - import type { Map as ImmutableMap } from 'immutable'; import { LearnMoreLink } from 'mastodon/components/learn_more_link'; import StatusContainer from 'mastodon/containers/status_container'; +import { domain } from 'mastodon/initial_state'; +import type { Account } from 'mastodon/models/account'; import type { Status } from 'mastodon/models/status'; import type { RootState } from 'mastodon/store'; import { useAppDispatch, useAppSelector } from 'mastodon/store'; +import { revealAccount } from '../actions/accounts_typed'; import { fetchStatus } from '../actions/statuses'; -import { makeGetStatus } from '../selectors'; +import { makeGetStatusWithExtraInfo } from '../selectors'; +import { getAccountHidden } from '../selectors/accounts'; -const MAX_QUOTE_POSTS_NESTING_LEVEL = 1; +import { Button } from './button'; -const QuoteWrapper: React.FC<{ - isError?: boolean; - children: React.ReactElement; -}> = ({ isError, children }) => { - return ( -
        - {children} -
        - ); -}; +const MAX_QUOTE_POSTS_NESTING_LEVEL = 1; const NestedQuoteLink: React.FC<{ status: Status }> = ({ status }) => { - const accountId = status.get('account') as string; + const accountObjectOrId = status.get('account') as string | Account; + const accountId = + typeof accountObjectOrId === 'string' + ? accountObjectOrId + : accountObjectOrId.id; + const account = useAppSelector((state) => accountId ? state.accounts.get(accountId) : undefined, ); @@ -55,11 +49,38 @@ const NestedQuoteLink: React.FC<{ status: Status }> = ({ status }) => { ); }; -type QuoteMap = ImmutableMap<'state' | 'quoted_status', string | null>; type GetStatusSelector = ( state: RootState, props: { id?: string | null; contextType?: string }, -) => Status | null; +) => { + status: Status | null; + loadingState: 'not-found' | 'loading' | 'filtered' | 'complete'; +}; + +type QuoteMap = ImmutableMap<'state' | 'quoted_status', string | null>; + +const LimitedAccountHint: React.FC<{ accountId: string }> = ({ accountId }) => { + const dispatch = useAppDispatch(); + const reveal = useCallback(() => { + dispatch(revealAccount({ id: accountId })); + }, [dispatch, accountId]); + + return ( + <> + + + + ); +}; interface QuotedStatusProps { quote: QuoteMap; @@ -86,31 +107,48 @@ export const QuotedStatus: React.FC = ({ ); const quotedStatusId = quote.get('quoted_status'); - const status = useAppSelector((state) => - quotedStatusId ? state.statuses.get(quotedStatusId) : undefined, + const getStatusSelector = useMemo( + () => makeGetStatusWithExtraInfo() as GetStatusSelector, + [], + ); + const { status, loadingState } = useAppSelector((state) => + getStatusSelector(state, { id: quotedStatusId, contextType }), + ); + + const accountId: string | null = status?.get('account') + ? (status.get('account') as Account).id + : null; + const hiddenAccount = useAppSelector( + (state) => accountId && getAccountHidden(state, accountId), ); - const shouldLoadQuote = !status?.get('isLoading') && quoteState !== 'deleted'; + const shouldFetchQuote = + !status?.get('isLoading') && + quoteState !== 'deleted' && + loadingState === 'not-found'; + const isLoaded = loadingState === 'complete'; + + const isFetchingQuoteRef = useRef(false); useEffect(() => { - if (shouldLoadQuote && quotedStatusId) { + if (isLoaded) { + isFetchingQuoteRef.current = false; + } + }, [isLoaded]); + + useEffect(() => { + if (shouldFetchQuote && quotedStatusId && !isFetchingQuoteRef.current) { dispatch( fetchStatus(quotedStatusId, { parentQuotePostId, alsoFetchContext: false, }), ); + isFetchingQuoteRef.current = true; } - }, [shouldLoadQuote, quotedStatusId, parentQuotePostId, dispatch]); - - // In order to find out whether the quoted post should be completely hidden - // due to a matching filter, we run it through the selector used by `status_container`. - // If this returns null even though `status` exists, it's because it's filtered. - const getStatus = useMemo(() => makeGetStatus(), []) as GetStatusSelector; - const statusWithExtraData = useAppSelector((state) => - getStatus(state, { id: quotedStatusId, contextType }), - ); - const isFilteredAndHidden = status && statusWithExtraData === null; + }, [shouldFetchQuote, quotedStatusId, parentQuotePostId, dispatch]); + + const isFilteredAndHidden = loadingState === 'filtered'; let quoteError: React.ReactNode = null; @@ -130,27 +168,27 @@ export const QuotedStatus: React.FC = ({ /> -
        - -

        ); + } else if (quoteState === 'revoked') { + quoteError = ( + + ); } else if ( !status || !quotedStatusId || quoteState === 'deleted' || quoteState === 'rejected' || - quoteState === 'revoked' || quoteState === 'unauthorized' ) { quoteError = ( @@ -159,10 +197,26 @@ export const QuotedStatus: React.FC = ({ defaultMessage='Post unavailable' /> ); + } else if (hiddenAccount && accountId) { + quoteError = ; } if (quoteError) { - return {quoteError}; + const hasRemoveButton = contextType === 'composer' && !!onQuoteCancel; + + return ( +
        + {quoteError} + {hasRemoveButton && ( + + )} +
        + ); } if (variant === 'link' && status) { @@ -174,7 +228,7 @@ export const QuotedStatus: React.FC = ({ childQuote && nestingLevel <= MAX_QUOTE_POSTS_NESTING_LEVEL; return ( - +
        {/* @ts-expect-error Status is not yet typed */} = ({ /> )} - +
        ); }; diff --git a/app/javascript/mastodon/components/status_thread_label.tsx b/app/javascript/mastodon/components/status_thread_label.tsx index b18aca6dcbe60e..e1fc3b8cdacdac 100644 --- a/app/javascript/mastodon/components/status_thread_label.tsx +++ b/app/javascript/mastodon/components/status_thread_label.tsx @@ -2,9 +2,10 @@ import { FormattedMessage } from 'react-intl'; import ReplyIcon from '@/material-icons/400-24px/reply.svg?react'; import { Icon } from 'mastodon/components/icon'; -import { DisplayedName } from 'mastodon/features/notifications_v2/components/displayed_name'; import { useAppSelector } from 'mastodon/store'; +import { LinkedDisplayName } from './display_name'; + export const StatusThreadLabel: React.FC<{ accountId: string; inReplyToAccountId: string; @@ -27,7 +28,13 @@ export const StatusThreadLabel: React.FC<{ }} + values={{ + name: ( + + ), + }} /> ); } else { diff --git a/app/javascript/mastodon/containers/status_container.jsx b/app/javascript/mastodon/containers/status_container.jsx index c6ac6d724dec79..76af31cb59c594 100644 --- a/app/javascript/mastodon/containers/status_container.jsx +++ b/app/javascript/mastodon/containers/status_container.jsx @@ -269,11 +269,10 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({ dispatch(deployPictureInPicture({statusId: status.get('id'), accountId: status.getIn(['account', 'id']), playerType: type, props: mediaProps})); }, - onInteractionModal (type, status) { + onInteractionModal (status) { dispatch(openModal({ modalType: 'INTERACTION', modalProps: { - type, accountId: status.getIn(['account', 'id']), url: status.get('uri'), }, diff --git a/app/javascript/mastodon/features/account/components/follow_request_note.jsx b/app/javascript/mastodon/features/account/components/follow_request_note.jsx index d57fd030b2d9e2..9c20f1e0626259 100644 --- a/app/javascript/mastodon/features/account/components/follow_request_note.jsx +++ b/app/javascript/mastodon/features/account/components/follow_request_note.jsx @@ -6,6 +6,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import CheckIcon from '@/material-icons/400-24px/check.svg?react'; import CloseIcon from '@/material-icons/400-24px/close.svg?react'; import { Icon } from 'mastodon/components/icon'; +import { DisplayName } from '@/mastodon/components/display_name'; export default class FollowRequestNote extends ImmutablePureComponent { @@ -19,7 +20,7 @@ export default class FollowRequestNote extends ImmutablePureComponent { return (
        - }} /> + }} />
        diff --git a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx index 1cecdd7028ec9c..2b380645feeafb 100644 --- a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx @@ -7,6 +7,7 @@ import { Helmet } from 'react-helmet'; import { NavLink } from 'react-router-dom'; import { AccountBio } from '@/mastodon/components/account_bio'; +import { DisplayName } from '@/mastodon/components/display_name'; import CheckIcon from '@/material-icons/400-24px/check.svg?react'; import LockIcon from '@/material-icons/400-24px/lock.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; @@ -851,7 +852,6 @@ export const AccountHeader: React.FC<{ ); } - const displayNameHtml = { __html: account.display_name_html }; const fields = account.fields; const isLocal = !account.acct.includes('@'); const username = account.acct.split('@')[0]; @@ -940,7 +940,7 @@ export const AccountHeader: React.FC<{

        - + @{username} diff --git a/app/javascript/mastodon/features/account_timeline/components/familiar_followers.tsx b/app/javascript/mastodon/features/account_timeline/components/familiar_followers.tsx index 41bd8ab4ba82fc..cc6434c6ee6436 100644 --- a/app/javascript/mastodon/features/account_timeline/components/familiar_followers.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/familiar_followers.tsx @@ -1,33 +1,26 @@ import { FormattedMessage } from 'react-intl'; -import { Link } from 'react-router-dom'; - import { Avatar } from '@/mastodon/components/avatar'; import { AvatarGroup } from '@/mastodon/components/avatar_group'; +import { LinkedDisplayName } from '@/mastodon/components/display_name'; import type { Account } from '@/mastodon/models/account'; import { useFetchFamiliarFollowers } from '../hooks/familiar_followers'; -const AccountLink: React.FC<{ account?: Account }> = ({ account }) => { - if (!account) { - return null; - } - - return ( - - ); -}; - const FamiliarFollowersReadout: React.FC<{ familiarFollowers: Account[] }> = ({ familiarFollowers, }) => { const messageData = { - name1: , - name2: , + name1: ( + + ), + name2: ( + + ), othersCount: familiarFollowers.length - 2, }; diff --git a/app/javascript/mastodon/features/account_timeline/components/moved_note.tsx b/app/javascript/mastodon/features/account_timeline/components/moved_note.tsx index 51dbb93c8b6d42..f2457dedd74712 100644 --- a/app/javascript/mastodon/features/account_timeline/components/moved_note.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/moved_note.tsx @@ -2,8 +2,8 @@ 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 { DisplayName } from 'mastodon/components/display_name'; import { useAppSelector } from 'mastodon/store'; export const MovedNote: React.FC<{ @@ -20,15 +20,7 @@ export const MovedNote: React.FC<{ id='account.moved_to' defaultMessage='{name} has indicated that their new account is now:' values={{ - name: ( - - - - ), + name: , }} />

        diff --git a/app/javascript/mastodon/features/annual_report/highlighted_post.tsx b/app/javascript/mastodon/features/annual_report/highlighted_post.tsx index 3a2a70713da64b..7edbb2e614ff54 100644 --- a/app/javascript/mastodon/features/annual_report/highlighted_post.tsx +++ b/app/javascript/mastodon/features/annual_report/highlighted_post.tsx @@ -6,6 +6,7 @@ import { useCallback } from 'react'; import { FormattedMessage } from 'react-intl'; +import { DisplayName } from '@/mastodon/components/display_name'; import { toggleStatusSpoilers } from 'mastodon/actions/statuses'; import { DetailedStatus } from 'mastodon/features/status/components/detailed_status'; import { me } from 'mastodon/initial_state'; @@ -79,11 +80,7 @@ export const HighlightedPost: React.FC<{ id='annual_report.summary.highlighted_post.possessive' defaultMessage="{name}'s" values={{ - name: account && ( - - ), + name: , }} /> diff --git a/app/javascript/mastodon/features/compose/components/quoted_post.tsx b/app/javascript/mastodon/features/compose/components/quoted_post.tsx index 335e7ce610dc5e..f09d6fcd3443ff 100644 --- a/app/javascript/mastodon/features/compose/components/quoted_post.tsx +++ b/app/javascript/mastodon/features/compose/components/quoted_post.tsx @@ -11,7 +11,9 @@ export const ComposeQuotedStatus: FC = () => { const quotedStatusId = useAppSelector( (state) => state.compose.get('quoted_status_id') as string | null, ); + const isEditing = useAppSelector((state) => !!state.compose.get('id')); + const quote = useMemo( () => quotedStatusId @@ -22,16 +24,20 @@ export const ComposeQuotedStatus: FC = () => { : null, [quotedStatusId], ); + const dispatch = useAppDispatch(); const handleQuoteCancel = useCallback(() => { dispatch(quoteComposeCancel()); }, [dispatch]); + if (!quote) { return null; } + return ( ); diff --git a/app/javascript/mastodon/features/compose/components/visibility_button.tsx b/app/javascript/mastodon/features/compose/components/visibility_button.tsx index 2117fb9035ef28..cc6e667f079872 100644 --- a/app/javascript/mastodon/features/compose/components/visibility_button.tsx +++ b/app/javascript/mastodon/features/compose/components/visibility_button.tsx @@ -166,13 +166,17 @@ const searchabilityOptions = { const PrivacyModalButton: FC = ({ disabled = false }) => { const intl = useIntl(); - const { visibility, searchability, quotePolicy, circleId } = useAppSelector( - (state) => ({ - visibility: state.compose.get('privacy') as StatusVisibility, - searchability: state.compose.get('searchability') as StatusSearchability, - quotePolicy: state.compose.get('quote_policy') as ApiQuotePolicy, - circleId: state.compose.get('circle_id') as string, - }), + const quotePolicy = useAppSelector( + (state) => state.compose.get('quote_policy') as ApiQuotePolicy, + ); + const visibility = useAppSelector( + (state) => state.compose.get('privacy') as StatusVisibility, + ); + const searchability = useAppSelector( + (state) => state.compose.get('searchability') as StatusSearchability, + ); + const circleId = useAppSelector( + (state) => state.compose.get('circle_id') as string, ); const { icon, iconComponent } = useMemo(() => { diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx b/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx index f701ab0f0453b7..9aae588bcc75c9 100644 --- a/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx +++ b/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx @@ -25,6 +25,7 @@ import StatusContent from 'mastodon/components/status_content'; import { Dropdown } from 'mastodon/components/dropdown_menu'; import { autoPlayGif } from 'mastodon/initial_state'; import { makeGetStatus } from 'mastodon/selectors'; +import { LinkedDisplayName } from '@/mastodon/components/display_name'; const messages = defineMessages({ more: { id: 'status.more', defaultMessage: 'More' }, @@ -139,15 +140,8 @@ export const Conversation = ({ conversation, scrollKey }) => { menu.push({ text: intl.formatMessage(messages.delete), action: handleDelete }); - const names = accounts.map(a => ( - - - - - + const names = accounts.map((account) => ( + )).reduce((prev, cur) => [prev, ', ', cur]); const handlers = { diff --git a/app/javascript/mastodon/features/emoji/emoji_html.tsx b/app/javascript/mastodon/features/emoji/emoji_html.tsx index 0bd10009226d4d..e143c9fc166f8b 100644 --- a/app/javascript/mastodon/features/emoji/emoji_html.tsx +++ b/app/javascript/mastodon/features/emoji/emoji_html.tsx @@ -1,5 +1,7 @@ import type { ComponentPropsWithoutRef, ElementType } from 'react'; +import { isModernEmojiEnabled } from '@/mastodon/utils/environment'; + import { useEmojify } from './hooks'; import type { CustomEmojiMapArg } from './types'; @@ -13,7 +15,7 @@ type EmojiHTMLProps = Omit< shallow?: boolean; }; -export const EmojiHTML = ({ +export const ModernEmojiHTML = ({ extraEmojis, htmlString, as: Wrapper = 'div', // Rename for syntax highlighting @@ -34,3 +36,14 @@ export const EmojiHTML = ({ ); }; + +export const EmojiHTML = ( + props: EmojiHTMLProps, +) => { + if (isModernEmojiEnabled()) { + return ; + } + const { as: asElement, htmlString, extraEmojis, ...rest } = props; + const Wrapper = asElement ?? 'div'; + return ; +}; diff --git a/app/javascript/mastodon/features/explore/components/author_link.jsx b/app/javascript/mastodon/features/explore/components/author_link.jsx index 764ae753412d95..cf92ebc78b36a0 100644 --- a/app/javascript/mastodon/features/explore/components/author_link.jsx +++ b/app/javascript/mastodon/features/explore/components/author_link.jsx @@ -1,9 +1,8 @@ import PropTypes from 'prop-types'; -import { Link } from 'react-router-dom'; - import { Avatar } from 'mastodon/components/avatar'; import { useAppSelector } from 'mastodon/store'; +import { LinkedDisplayName } from '@/mastodon/components/display_name'; export const AuthorLink = ({ accountId }) => { const account = useAppSelector(state => state.getIn(['accounts', accountId])); @@ -13,10 +12,9 @@ export const AuthorLink = ({ accountId }) => { } return ( - + - - + ); }; diff --git a/app/javascript/mastodon/features/interaction_modal/index.tsx b/app/javascript/mastodon/features/interaction_modal/index.tsx index 40d498d1ae15c1..d17521d1e3dea7 100644 --- a/app/javascript/mastodon/features/interaction_modal/index.tsx +++ b/app/javascript/mastodon/features/interaction_modal/index.tsx @@ -7,15 +7,10 @@ import classNames from 'classnames'; import { escapeRegExp } from 'lodash'; import { useDebouncedCallback } from 'use-debounce'; -import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react'; -import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react'; -import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; -import ReplyIcon from '@/material-icons/400-24px/reply.svg?react'; -import StarIcon from '@/material-icons/400-24px/star.svg?react'; +import { DisplayName } from '@/mastodon/components/display_name'; import { openModal, closeModal } from 'mastodon/actions/modal'; import { apiRequest } from 'mastodon/api'; import { Button } from 'mastodon/components/button'; -import { Icon } from 'mastodon/components/icon'; import { domain as localDomain, registrationsOpen, @@ -408,18 +403,15 @@ const LoginForm: React.FC<{ const InteractionModal: React.FC<{ accountId: string; url: string; - type: 'reply' | 'reblog' | 'favourite' | 'follow' | 'vote'; -}> = ({ accountId, url, type }) => { +}> = ({ accountId, url }) => { const dispatch = useAppDispatch(); - const displayNameHtml = useAppSelector( - (state) => state.accounts.get(accountId)?.display_name_html ?? '', - ); const signupUrl = useAppSelector( (state) => (state.server.getIn(['server', 'registrations', 'url'], null) || '/auth/sign_up') as string, ); - const name = ; + const account = useAppSelector((state) => state.accounts.get(accountId)); + const name = ; const handleSignupClick = useCallback(() => { dispatch( @@ -437,93 +429,6 @@ const InteractionModal: React.FC<{ ); }, [dispatch]); - let title: React.ReactNode, - icon: React.ReactNode, - actionPrompt: React.ReactNode; - - switch (type) { - case 'reply': - icon = ; - title = ( - - ); - actionPrompt = ( - - ); - break; - case 'reblog': - icon = ; - title = ( - - ); - actionPrompt = ( - - ); - break; - case 'favourite': - icon = ; - title = ( - - ); - actionPrompt = ( - - ); - break; - case 'follow': - icon = ; - title = ( - - ); - actionPrompt = ( - - ); - break; - case 'vote': - icon = ; - title = ( - - ); - actionPrompt = ( - - ); - break; - } - let signupButton; if (sso_redirect) { @@ -559,9 +464,18 @@ const InteractionModal: React.FC<{

        - {icon} {title} +

        -

        {actionPrompt}

        +

        + +

        diff --git a/app/javascript/mastodon/features/notifications/components/notification.jsx b/app/javascript/mastodon/features/notifications/components/notification.jsx index 84ac0020ba8033..c0009d52095b84 100644 --- a/app/javascript/mastodon/features/notifications/components/notification.jsx +++ b/app/javascript/mastodon/features/notifications/components/notification.jsx @@ -21,6 +21,7 @@ import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; import StarIcon from '@/material-icons/400-24px/star-fill.svg?react'; import { Account } from 'mastodon/components/account'; import EmojiView from 'mastodon/components/emoji_view'; +import { LinkedDisplayName } from '@/mastodon/components/display_name'; import { Icon } from 'mastodon/components/icon'; import { Hotkeys } from 'mastodon/components/hotkeys'; import { StatusQuoteManager } from 'mastodon/components/status_quoted'; @@ -598,8 +599,10 @@ class Notification extends ImmutablePureComponent { } const targetAccount = report.get('target_account'); - const targetDisplayNameHtml = { __html: targetAccount.get('display_name_html') }; - const targetLink = ; + const targetLink = ; return ( @@ -621,8 +624,7 @@ class Notification extends ImmutablePureComponent { render () { const { notification } = this.props; const account = notification.get('account'); - const displayNameHtml = { __html: account.get('display_name_html') }; - const link = ; + const link = ; switch(notification.get('type')) { case 'follow': diff --git a/app/javascript/mastodon/features/notifications/components/notification_request.jsx b/app/javascript/mastodon/features/notifications/components/notification_request.jsx index 381bb1153f4759..76ac99894e3edc 100644 --- a/app/javascript/mastodon/features/notifications/components/notification_request.jsx +++ b/app/javascript/mastodon/features/notifications/components/notification_request.jsx @@ -16,6 +16,7 @@ import { acceptNotificationRequest, dismissNotificationRequest } from 'mastodon/ import { initReport } from 'mastodon/actions/reports'; import { Avatar } from 'mastodon/components/avatar'; import { CheckBox } from 'mastodon/components/check_box'; +import { DisplayName } from '@/mastodon/components/display_name'; import { IconButton } from 'mastodon/components/icon_button'; import { Dropdown } from 'mastodon/components/dropdown_menu'; import { makeGetAccount } from 'mastodon/selectors'; @@ -96,7 +97,7 @@ export const NotificationRequest = ({ id, accountId, notificationsCount, checked
        - +
        @{account?.get('acct')} diff --git a/app/javascript/mastodon/features/notifications/components/select_with_label.tsx b/app/javascript/mastodon/features/notifications/components/select_with_label.tsx index b25f8e66be201e..0b755702d932d6 100644 --- a/app/javascript/mastodon/features/notifications/components/select_with_label.tsx +++ b/app/javascript/mastodon/features/notifications/components/select_with_label.tsx @@ -143,16 +143,14 @@ export const SelectWithLabel: React.FC> = ({
        -
        - -
        +
        ); diff --git a/app/javascript/mastodon/features/notifications_v2/components/displayed_name.tsx b/app/javascript/mastodon/features/notifications_v2/components/displayed_name.tsx deleted file mode 100644 index 82ecb93ee5fdb9..00000000000000 --- a/app/javascript/mastodon/features/notifications_v2/components/displayed_name.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Link } from 'react-router-dom'; - -import { useAppSelector } from 'mastodon/store'; - -export const DisplayedName: React.FC<{ - accountIds: string[]; -}> = ({ accountIds }) => { - const lastAccountId = accountIds[0] ?? '0'; - const account = useAppSelector((state) => state.accounts.get(lastAccountId)); - - if (!account) return null; - - return ( - - - - ); -}; diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_admin_report.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_admin_report.tsx index e41a6b2736c3c4..03f047fb7fe3a7 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_admin_report.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_admin_report.tsx @@ -2,6 +2,7 @@ import { FormattedMessage, useIntl, defineMessages } from 'react-intl'; import classNames from 'classnames'; +import { DisplayName } from '@/mastodon/components/display_name'; import FlagIcon from '@/material-icons/400-24px/flag-fill.svg?react'; import { Icon } from 'mastodon/components/icon'; import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; @@ -42,11 +43,9 @@ export const NotificationAdminReport: React.FC<{ if (!account || !targetAccount) return null; - const domain = account.acct.split('@')[1]; - const values = { - name: {domain ?? `@${account.acct}`}, - target: @{targetAccount.acct}, + name: , + target: , category: intl.formatMessage(messages[report.category]), count: report.status_ids.length, }; diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_group_with_status.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_group_with_status.tsx index 41320e3ece1510..3787ae43bf9ec2 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_group_with_status.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_group_with_status.tsx @@ -3,6 +3,7 @@ import type { JSX } from 'react'; import classNames from 'classnames'; +import { LinkedDisplayName } from '@/mastodon/components/display_name'; import { replyComposeById } from 'mastodon/actions/compose'; import { navigateToStatus } from 'mastodon/actions/statuses'; import { Avatar } from 'mastodon/components/avatar'; @@ -16,7 +17,6 @@ import type { EmojiReactionGroup } from 'mastodon/models/notification_group'; import { NOTIFICATIONS_GROUP_MAX_AVATARS } from 'mastodon/models/notification_group'; import { useAppSelector, useAppDispatch } from 'mastodon/store'; -import { DisplayedName } from './displayed_name'; import { EmbeddedStatus } from './embedded_status'; const AVATAR_SIZE = 28; @@ -65,15 +65,18 @@ export const NotificationGroupWithStatus: React.FC<{ additionalContent, }) => { const dispatch = useAppDispatch(); + const account = useAppSelector((state) => + state.accounts.get(accountIds.at(0) ?? ''), + ); const label = useMemo( () => labelRenderer( - , + , count, labelSeeMoreHref, ), - [labelRenderer, accountIds, count, labelSeeMoreHref], + [labelRenderer, account, count, labelSeeMoreHref], ); const isPrivateMention = useAppSelector( diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_with_status.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_with_status.tsx index f31eeeaf19ea6b..cf3a70333aec03 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_with_status.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_with_status.tsx @@ -2,6 +2,7 @@ import { useMemo } from 'react'; import classNames from 'classnames'; +import { LinkedDisplayName } from '@/mastodon/components/display_name'; import { replyComposeById } from 'mastodon/actions/compose'; import { toggleReblog, toggleFavourite } from 'mastodon/actions/interactions'; import { @@ -15,7 +16,6 @@ import { StatusQuoteManager } from 'mastodon/components/status_quoted'; import { getStatusHidden } from 'mastodon/selectors/filters'; import { useAppSelector, useAppDispatch } from 'mastodon/store'; -import { DisplayedName } from './displayed_name'; import type { LabelRenderer } from './notification_group_with_status'; export const NotificationWithStatus: React.FC<{ @@ -41,9 +41,16 @@ export const NotificationWithStatus: React.FC<{ }) => { const dispatch = useAppDispatch(); + const account = useAppSelector((state) => + state.accounts.get(accountIds.at(0) ?? ''), + ); const label = useMemo( - () => labelRenderer(, count), - [labelRenderer, accountIds, count], + () => + labelRenderer( + , + count, + ), + [labelRenderer, account, count], ); const isPrivateMention = useAppSelector( diff --git a/app/javascript/mastodon/features/picture_in_picture/components/footer.tsx b/app/javascript/mastodon/features/picture_in_picture/components/footer.tsx index fae53a5283b672..720182c1c8fa37 100644 --- a/app/javascript/mastodon/features/picture_in_picture/components/footer.tsx +++ b/app/javascript/mastodon/features/picture_in_picture/components/footer.tsx @@ -92,7 +92,6 @@ export const Footer: React.FC<{ openModal({ modalType: 'INTERACTION', modalProps: { - type: 'reply', accountId: status.getIn(['account', 'id']), url: status.get('uri'), }, @@ -113,7 +112,6 @@ export const Footer: React.FC<{ openModal({ modalType: 'INTERACTION', modalProps: { - type: 'favourite', accountId: status.getIn(['account', 'id']), url: status.get('uri'), }, @@ -135,7 +133,6 @@ export const Footer: React.FC<{ openModal({ modalType: 'INTERACTION', modalProps: { - type: 'reblog', accountId: status.getIn(['account', 'id']), url: status.get('uri'), }, diff --git a/app/javascript/mastodon/features/status/components/action_bar.jsx b/app/javascript/mastodon/features/status/components/action_bar.jsx index a2006fdb4cd863..0d05ab4e8d143f 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.jsx +++ b/app/javascript/mastodon/features/status/components/action_bar.jsx @@ -23,7 +23,7 @@ import { Dropdown } from 'mastodon/components/dropdown_menu'; import { enableEmojiReaction , bookmarkCategoryNeeded, me, isHideItem } from '../../../initial_state'; import EmojiPickerDropdown from '../../compose/containers/emoji_picker_dropdown_container'; import { isFeatureEnabled } from '@/mastodon/utils/environment'; -import { ReblogButton } from '@/mastodon/components/status/reblog_button'; +import { BoostButton } from '@/mastodon/components/status/boost_button'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, @@ -369,7 +369,7 @@ class ActionBar extends PureComponent {
        - +
        diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx index 5cfddfde683210..64f1b50b844650 100644 --- a/app/javascript/mastodon/features/status/index.jsx +++ b/app/javascript/mastodon/features/status/index.jsx @@ -193,7 +193,6 @@ class Status extends ImmutablePureComponent { dispatch(openModal({ modalType: 'INTERACTION', modalProps: { - type: 'favourite', accountId: status.getIn(['account', 'id']), url: status.get('uri'), }, @@ -246,7 +245,6 @@ class Status extends ImmutablePureComponent { dispatch(openModal({ modalType: 'INTERACTION', modalProps: { - type: 'reply', accountId: status.getIn(['account', 'id']), url: status.get('uri'), }, @@ -264,7 +262,6 @@ class Status extends ImmutablePureComponent { dispatch(openModal({ modalType: 'INTERACTION', modalProps: { - type: 'reblog', accountId: status.getIn(['account', 'id']), url: status.get('uri'), }, diff --git a/app/javascript/mastodon/features/ui/components/actions_modal.tsx b/app/javascript/mastodon/features/ui/components/actions_modal.tsx index da42b863927f41..2577b21a17cc85 100644 --- a/app/javascript/mastodon/features/ui/components/actions_modal.tsx +++ b/app/javascript/mastodon/features/ui/components/actions_modal.tsx @@ -1,6 +1,7 @@ import classNames from 'classnames'; import { Link } from 'react-router-dom'; +import { DropdownMenuItemContent } from 'mastodon/components/dropdown_menu'; import type { MenuItem } from 'mastodon/models/dropdown_menu'; import { isActionItem, @@ -18,14 +19,14 @@ export const ActionsModal: React.FC<{ return
      • ; } - const { text, dangerous } = option; + const { text, highlighted, disabled, dangerous } = option; let element: React.ReactElement; if (isActionItem(option)) { element = ( - ); } else if (isExternalLinkItem(option)) { @@ -38,21 +39,22 @@ export const ActionsModal: React.FC<{ onClick={onClick} data-index={i} > - {text} + ); } else { element = ( - {text} + ); } return (
      • diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx b/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx index 7a0bfe6a944c5d..19ffe2bae52f1d 100644 --- a/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx +++ b/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx @@ -43,10 +43,6 @@ export const ConfirmationModal: React.FC< onSecondary?.(); }, [onClose, onSecondary]); - const handleCancel = useCallback(() => { - onClose(); - }, [onClose]); - return (
        @@ -58,7 +54,7 @@ export const ConfirmationModal: React.FC<
        -
        )} - +
        - + +