diff --git a/CHANGELOG.md b/CHANGELOG.md index cb546c08d4..15b458e0f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,14 @@ All notable changes to this project will be documented in this file. ### Added -- **Add support for allowing and authoring quotes** (#35355, #35578, #35614, #35618, #35624, #35626, #35652, #35629, #35665, #35653, #35670, #35677, #35690, #35697, #35689, #35699, #35700, #35701, #35709, #35714, #35713, #35715, #35725, #35749, #35769, #35780, #35762, #35804, #35808, #35805, #35819, #35824, #35828, #35822, #35835, #35865, #35860, #35832, #35891, #35894, #35895, #35820, #35917, #35924, #35925, #35914, #35930, #35941, #35939, #35948, #35955, #35967, #35990, #35991, #35975, #35971, #36002, #35986, #36031, #36034, #36038, #36054, #36052, #36055, #36065, #36068, #36083, #36087, #36080, #36091, #36090, #36118, #36119, #36128, #36094, #36129, #36138, #36132, #36151, #36158, #36171, #36194, #36220, #36169, #36130, #36249, #36153, #36299, #36291, #36301, #36315, #36317, #36364, #36383, #36381, #36459, #36464, #36461, #36516, #36528, #36549, #36550 and #36559 by @ChaosExAnima, @ClearlyClaire, @Lycolia, @diondiondion, and @tribela)\ +- **Add support for allowing and authoring quotes** (#35355, #35578, #35614, #35618, #35624, #35626, #35652, #35629, #35665, #35653, #35670, #35677, #35690, #35697, #35689, #35699, #35700, #35701, #35709, #35714, #35713, #35715, #35725, #35749, #35769, #35780, #35762, #35804, #35808, #35805, #35819, #35824, #35828, #35822, #35835, #35865, #35860, #35832, #35891, #35894, #35895, #35820, #35917, #35924, #35925, #35914, #35930, #35941, #35939, #35948, #35955, #35967, #35990, #35991, #35975, #35971, #36002, #35986, #36031, #36034, #36038, #36054, #36052, #36055, #36065, #36068, #36083, #36087, #36080, #36091, #36090, #36118, #36119, #36128, #36094, #36129, #36138, #36132, #36151, #36158, #36171, #36194, #36220, #36169, #36130, #36249, #36153, #36299, #36291, #36301, #36315, #36317, #36364, #36383, #36381, #36459, #36464, #36461, #36516, #36528, #36549, #36550, #36559, #36693, #36704, #36690, #36689, #36696, #36721 and #36695 by @ChaosExAnima, @ClearlyClaire, @Lycolia, @diondiondion, and @tribela)\ This includes a revamp of the composer interface.\ See https://blog.joinmastodon.org/2025/09/introducing-quote-posts/ for a user-centric overview of the feature, and https://docs.joinmastodon.org/client/quotes/ for API documentation. - **Add support for fetching and refreshing replies to the web UI** (#35210, #35496, #35575, #35500, #35577, #35602, #35603, #35654, #36141, #36237, #36172, #36256, #36271, #36334, #36382, #36239, #36484, #36481, #36583, #36627 and #36547 by @ClearlyClaire, @diondiondion, @Gargron and @renchap) - **Add ability to block words in usernames** (#35407, #35655, and #35806 by @ClearlyClaire and @Gargron) -- Add ability to individually disable local or remote feeds for visitors or logged-in users `disabled` value to server setting for live and topic feeds, as well as user permission to bypass that (#36338, #36467, #36497, #36563, #36577, #36585, and #36607 by @ClearlyClaire)\ - This splits the `timeline_preview` setting into four more granular settings controlling live feeds and topic (hashtag, trending link) feeds, with 3 values each: `public`, `authenticated`, `disabled`.\ +- Add ability to individually disable local or remote feeds for visitors or logged-in users `disabled` value to server setting for live and topic feeds, as well as user permission to bypass that (#36338, #36467, #36497, #36563, #36577, #36585, #36607 and #36703 by @ClearlyClaire)\ + This splits the `timeline_preview` setting into four more granular settings controlling live feeds and topic (hashtag, trending link) feeds.\ + The setting for local topic feeds has 2 values: `public` and `authenticated`. Every other setting has 3 values: `public`, `authenticated`, `disabled`.\ When `disabled`, users with the “View live and topic feeds” will still be able to view them. - Add support for displaying of quote posts in Moderator UI (#35964 by @ThisIsMissEm) - Add support for displaying link previews for Admin UI (#35958 by @ThisIsMissEm) @@ -44,6 +45,8 @@ All notable changes to this project will be documented in this file. - Change display of blocked and muted quoted users (#36619 by @ClearlyClaire)\ This adds `blocked_account`, `blocked_domain` and `muted_account` values to the `state` attribute of `Quote` and `ShallowQuote` REST API entities. - Change submitting an empty post to show an error rather than failing silently (#36650 by @diondiondion) +- Change "Privacy and reach" settings from "Public profile" to their own top-level category (#27294 by @ChaelCodes) +- Change number of times quote verification is retried to better deal with temporary failures (#36698 by @ClearlyClaire) - Change display of content warnings in Admin UI (#35935 by @ThisIsMissEm) - Change styling of column banners (#36531 by @ClearlyClaire) - Change recommended Node version to 24 (LTS) (#36539 by @renchap) @@ -75,6 +78,7 @@ All notable changes to this project will be documented in this file. - Fix URL comparison for mentions in case of empty path (#36613 and #36626 by @ClearlyClaire) - Fix hashtags not being picked up when full-width hash sign is used (#36103 and #36625 by @ClearlyClaire and @Gargron) - Fix layout of severed relationships when purged events are listed (#36593 by @mejofi) +- Fix Skeleton placeholders being animated when setting to reduce animations is enabled (#36716 by @ClearlyClaire) - Fix vacuum tasks being interrupted by a single batch failure (#36606 by @Gargron) - Fix handling of unreachable network error for search services (#36587 by @mjankowski) - Fix bookmarks export when a bookmarked status is soft-deleted (#36576 by @ClearlyClaire) diff --git a/Gemfile.lock b/Gemfile.lock index 2a0da53d4b..417b89ffae 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -128,7 +128,7 @@ GEM blurhash (0.1.8) bootsnap (1.18.6) msgpack (~> 1.2) - brakeman (7.0.2) + brakeman (7.1.1) racc browser (6.2.0) builder (3.3.0) @@ -224,7 +224,7 @@ GEM mail (~> 2.7) email_validator (2.2.4) activemodel - erb (5.1.1) + erb (5.1.3) erubi (1.13.1) et-orbi (1.4.0) tzinfo @@ -337,7 +337,7 @@ GEM activesupport (>= 3.0) nokogiri (>= 1.6) io-console (0.8.1) - irb (1.15.2) + irb (1.15.3) pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) @@ -621,7 +621,7 @@ GEM activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (3.2.3) + rack (3.2.4) rack-attack (6.8.0) rack (>= 1.0, < 4) rack-cors (3.0.0) @@ -691,7 +691,7 @@ GEM readline (~> 0.0) rdf-normalize (0.7.0) rdf (~> 3.3) - rdoc (6.15.0) + rdoc (6.15.1) erb psych (>= 4.0.0) tsort @@ -791,7 +791,7 @@ GEM ruby-vips (2.2.5) ffi (~> 1.12) logger - rubyzip (3.2.1) + rubyzip (3.2.2) rufus-scheduler (3.9.2) fugit (~> 1.1, >= 1.11.1) safety_net_attestation (0.5.0) @@ -805,7 +805,7 @@ GEM securerandom (0.4.1) shoulda-matchers (6.5.0) activesupport (>= 5.2.0) - sidekiq (8.0.8) + sidekiq (8.0.9) connection_pool (>= 2.5.0) json (>= 2.9.0) logger (>= 1.6.2) diff --git a/app/controllers/activitypub/quote_authorizations_controller.rb b/app/controllers/activitypub/quote_authorizations_controller.rb index f2f5313e1a..f4a1505550 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 30.seconds, public: true if @quote.status.distributable? && public_fetch_mode? + expires_in 30.seconds, public: true if @quote.quoted_status.distributable? && public_fetch_mode? render json: @quote, serializer: ActivityPub::QuoteAuthorizationSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' end @@ -23,7 +23,7 @@ class ActivityPub::QuoteAuthorizationsController < ActivityPub::BaseController @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? + authorize @quote.quoted_status, :show? rescue Mastodon::NotPermittedError not_found end diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index da03ddb5a6..dd3e7b530d 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -56,7 +56,6 @@ export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT'; export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE'; export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE'; export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE'; -export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE'; export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE'; export const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE'; @@ -796,13 +795,6 @@ export function changeComposeSpoilerText(text) { }; } -export function changeComposeVisibility(value) { - return { - type: COMPOSE_VISIBILITY_CHANGE, - value, - }; -} - export function insertEmojiCompose(position, emoji, needsSpace) { return { type: COMPOSE_EMOJI_INSERT, diff --git a/app/javascript/mastodon/actions/compose_typed.ts b/app/javascript/mastodon/actions/compose_typed.ts index 0f9bf5cfb3..6b38b25c25 100644 --- a/app/javascript/mastodon/actions/compose_typed.ts +++ b/app/javascript/mastodon/actions/compose_typed.ts @@ -13,10 +13,11 @@ import { } from 'mastodon/store/typed_functions'; import type { ApiQuotePolicy } from '../api_types/quotes'; -import type { Status } from '../models/status'; +import type { Status, StatusVisibility } from '../models/status'; +import type { RootState } from '../store'; import { showAlert } from './alerts'; -import { focusCompose } from './compose'; +import { changeCompose, focusCompose } from './compose'; import { importFetchedStatuses } from './importer'; import { openModal } from './modal'; @@ -41,6 +42,10 @@ const messages = defineMessages({ id: 'quote_error.unauthorized', defaultMessage: 'You are not authorized to quote this post.', }, + quoteErrorPrivateMention: { + id: 'quote_error.private_mentions', + defaultMessage: 'Quoting is not allowed with direct mentions.', + }, }); type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & { @@ -67,6 +72,39 @@ const simulateModifiedApiResponse = ( return data; }; +export const changeComposeVisibility = createAppThunk( + 'compose/visibility_change', + (visibility: StatusVisibility, { dispatch, getState }) => { + if (visibility !== 'direct') { + return visibility; + } + + const state = getState(); + const quotedStatusId = state.compose.get('quoted_status_id') as + | string + | null; + if (!quotedStatusId) { + return visibility; + } + + // Remove the quoted status + dispatch(quoteComposeCancel()); + const quotedStatus = state.statuses.get(quotedStatusId) as Status | null; + if (!quotedStatus) { + return visibility; + } + + // Append the quoted status URL to the compose text + const url = quotedStatus.get('url') as string; + const text = state.compose.get('text') as string; + if (!text.includes(url)) { + const newText = text.trim() ? `${text}\n\n${url}` : url; + dispatch(changeCompose(newText)); + } + return visibility; + }, +); + export const changeUploadCompose = createDataLoadingThunk( 'compose/changeUpload', async ( @@ -130,6 +168,8 @@ export const quoteComposeByStatus = createAppThunk( if (composeState.get('id')) { dispatch(showAlert({ message: messages.quoteErrorEdit })); + } else if (composeState.get('privacy') === 'direct') { + dispatch(showAlert({ message: messages.quoteErrorPrivateMention })); } else if (composeState.get('poll')) { dispatch(showAlert({ message: messages.quoteErrorPoll })); } else if ( @@ -173,6 +213,17 @@ export const quoteComposeById = createAppThunk( }, ); +const composeStateForbidsLink = (composeState: RootState['compose']) => { + return ( + composeState.get('quoted_status_id') || + composeState.get('is_submitting') || + composeState.get('poll') || + composeState.get('is_uploading') || + composeState.get('id') || + composeState.get('privacy') === 'direct' + ); +}; + export const pasteLinkCompose = createDataLoadingThunk( 'compose/pasteLink', async ({ url }: { url: string }) => { @@ -183,15 +234,12 @@ export const pasteLinkCompose = createDataLoadingThunk( limit: 2, }); }, - (data, { dispatch, getState }) => { + (data, { dispatch, getState, requestId }) => { const composeState = getState().compose; if ( - composeState.get('quoted_status_id') || - composeState.get('is_submitting') || - composeState.get('poll') || - composeState.get('is_uploading') || - composeState.get('id') + composeStateForbidsLink(composeState) || + composeState.get('fetching_link') !== requestId // Request has been cancelled ) return; @@ -207,6 +255,17 @@ export const pasteLinkCompose = createDataLoadingThunk( dispatch(quoteComposeById(data.statuses[0].id)); } }, + { + useLoadingBar: false, + condition: (_, { getState }) => + !getState().compose.get('fetching_link') && + !composeStateForbidsLink(getState().compose), + }, +); + +// Ideally this would cancel the action and the HTTP request, but this is good enough +export const cancelPasteLinkCompose = createAction( + 'compose/cancelPasteLinkCompose', ); export const quoteComposeCancel = createAction('compose/quoteComposeCancel'); diff --git a/app/javascript/mastodon/features/compose/components/compose_form.jsx b/app/javascript/mastodon/features/compose/components/compose_form.jsx index 299de12e7e..770f776049 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.jsx +++ b/app/javascript/mastodon/features/compose/components/compose_form.jsx @@ -140,7 +140,10 @@ class ComposeForm extends ImmutablePureComponent { return; } - this.props.onSubmit(missingAltTextModal && this.props.missingAltText && this.props.privacy !== 'direct'); + this.props.onSubmit({ + missingAltText: missingAltTextModal && this.props.missingAltText && this.props.privacy !== 'direct', + quoteToPrivate: this.props.quoteToPrivate, + }); if (e) { e.preventDefault(); diff --git a/app/javascript/mastodon/features/compose/components/quote_placeholder.tsx b/app/javascript/mastodon/features/compose/components/quote_placeholder.tsx new file mode 100644 index 0000000000..706594e9cb --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/quote_placeholder.tsx @@ -0,0 +1,48 @@ +import { useCallback } from 'react'; +import type { FC } from 'react'; + +import { defineMessages, useIntl } from 'react-intl'; + +import { cancelPasteLinkCompose } from '@/mastodon/actions/compose_typed'; +import { useAppDispatch } from '@/mastodon/store'; +import CancelFillIcon from '@/material-icons/400-24px/cancel-fill.svg?react'; +import { DisplayName } from 'mastodon/components/display_name'; +import { IconButton } from 'mastodon/components/icon_button'; +import { Skeleton } from 'mastodon/components/skeleton'; + +const messages = defineMessages({ + quote_cancel: { id: 'status.quote.cancel', defaultMessage: 'Cancel quote' }, +}); + +export const QuotePlaceholder: FC = () => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const handleQuoteCancel = useCallback(() => { + dispatch(cancelPasteLinkCompose()); + }, [dispatch]); + + return ( +
+
+
+
+ +
+
+ +
+ +
+
+ +
+
+
+ ); +}; diff --git a/app/javascript/mastodon/features/compose/components/quoted_post.tsx b/app/javascript/mastodon/features/compose/components/quoted_post.tsx index f09d6fcd34..8be3c7e62c 100644 --- a/app/javascript/mastodon/features/compose/components/quoted_post.tsx +++ b/app/javascript/mastodon/features/compose/components/quoted_post.tsx @@ -7,11 +7,17 @@ import { quoteComposeCancel } from '@/mastodon/actions/compose_typed'; import { QuotedStatus } from '@/mastodon/components/status_quoted'; import { useAppDispatch, useAppSelector } from '@/mastodon/store'; +import { QuotePlaceholder } from './quote_placeholder'; + export const ComposeQuotedStatus: FC = () => { const quotedStatusId = useAppSelector( (state) => state.compose.get('quoted_status_id') as string | null, ); + const isFetchingLink = useAppSelector( + (state) => !!state.compose.get('fetching_link'), + ); + const isEditing = useAppSelector((state) => !!state.compose.get('id')); const quote = useMemo( @@ -30,7 +36,9 @@ export const ComposeQuotedStatus: FC = () => { dispatch(quoteComposeCancel()); }, [dispatch]); - if (!quote) { + if (isFetchingLink && !quote) { + return ; + } else if (!quote) { return null; } diff --git a/app/javascript/mastodon/features/compose/components/visibility_button.tsx b/app/javascript/mastodon/features/compose/components/visibility_button.tsx index 1ea504ab1a..d939405020 100644 --- a/app/javascript/mastodon/features/compose/components/visibility_button.tsx +++ b/app/javascript/mastodon/features/compose/components/visibility_button.tsx @@ -5,8 +5,10 @@ import { defineMessages, useIntl } from 'react-intl'; import classNames from 'classnames'; -import { changeComposeVisibility } from '@/mastodon/actions/compose'; -import { setComposeQuotePolicy } from '@/mastodon/actions/compose_typed'; +import { + changeComposeVisibility, + setComposeQuotePolicy, +} from '@/mastodon/actions/compose_typed'; import { openModal } from '@/mastodon/actions/modal'; import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes'; import type { StatusVisibility } from '@/mastodon/api_types/statuses'; diff --git a/app/javascript/mastodon/features/compose/containers/compose_form_container.js b/app/javascript/mastodon/features/compose/containers/compose_form_container.js index 3dad46bc52..15b1c7cc41 100644 --- a/app/javascript/mastodon/features/compose/containers/compose_form_container.js +++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js @@ -12,6 +12,7 @@ import { } from 'mastodon/actions/compose'; import { pasteLinkCompose } from 'mastodon/actions/compose_typed'; import { openModal } from 'mastodon/actions/modal'; +import { PRIVATE_QUOTE_MODAL_ID } from 'mastodon/features/ui/components/confirmation_modals/private_quote_notify'; import ComposeForm from '../components/compose_form'; @@ -32,6 +33,10 @@ const mapStateToProps = state => ({ isUploading: state.getIn(['compose', 'is_uploading']), anyMedia: state.getIn(['compose', 'media_attachments']).size > 0, missingAltText: state.getIn(['compose', 'media_attachments']).some(media => ['image', 'gifv'].includes(media.get('type')) && (media.get('description') ?? '').length === 0), + quoteToPrivate: + !!state.getIn(['compose', 'quoted_status_id']) + && state.getIn(['compose', 'privacy']) === 'private' + && !state.getIn(['settings', 'dismissed_banners', PRIVATE_QUOTE_MODAL_ID]), isInReply: state.getIn(['compose', 'in_reply_to']) !== null, lang: state.getIn(['compose', 'language']), maxChars: state.getIn(['server', 'server', 'configuration', 'statuses', 'max_characters'], 500), @@ -43,12 +48,17 @@ const mapDispatchToProps = (dispatch, props) => ({ dispatch(changeCompose(text)); }, - onSubmit (missingAltText) { + onSubmit ({ missingAltText, quoteToPrivate }) { if (missingAltText) { dispatch(openModal({ modalType: 'CONFIRM_MISSING_ALT_TEXT', modalProps: {}, })); + } else if (quoteToPrivate) { + dispatch(openModal({ + modalType: 'CONFIRM_PRIVATE_QUOTE_NOTIFY', + modalProps: {}, + })); } else { dispatch(submitCompose((status) => { if (props.redirectOnSuccess) { diff --git a/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js b/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js index 6d3eef13aa..803dcb1a4a 100644 --- a/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js +++ b/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js @@ -1,8 +1,7 @@ import { connect } from 'react-redux'; -import { changeComposeVisibility } from '../../../actions/compose'; -import { openModal, closeModal } from '../../../actions/modal'; -import { isUserTouching } from '../../../is_mobile'; +import { changeComposeVisibility } from '@/mastodon/actions/compose_typed'; + import PrivacyDropdown from '../components/privacy_dropdown'; const mapStateToProps = state => ({ diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx index bcccc11044..140413d1a9 100644 --- a/app/javascript/mastodon/features/status/index.jsx +++ b/app/javascript/mastodon/features/status/index.jsx @@ -299,6 +299,12 @@ class Status extends ImmutablePureComponent { dispatch(openModal({ modalType: 'COMPOSE_PRIVACY', modalProps: { statusId, onChange: handleChange } })); }; + handleQuote = (status) => { + const { dispatch } = this.props; + + dispatch(quoteComposeById(status.get('id'))); + }; + handleEditClick = (status) => { const { dispatch, askReplyConfirmation } = this.props; @@ -625,6 +631,7 @@ class Status extends ImmutablePureComponent { onDelete={this.handleDeleteClick} onRevokeQuote={this.handleRevokeQuoteClick} onQuotePolicyChange={this.handleQuotePolicyChange} + onQuote={this.handleQuote} onEdit={this.handleEditClick} onDirect={this.handleDirectClick} onMention={this.handleMentionClick} 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 47f9fca890..cfa50855a8 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 @@ -18,6 +18,7 @@ export const ConfirmationModal: React.FC< onSecondary?: () => void; onConfirm: () => void; closeWhenConfirm?: boolean; + extraContent?: React.ReactNode; } & BaseConfirmationModalProps > = ({ title, @@ -29,6 +30,7 @@ export const ConfirmationModal: React.FC< secondary, onSecondary, closeWhenConfirm = true, + extraContent, }) => { const handleClick = useCallback(() => { if (closeWhenConfirm) { @@ -49,6 +51,8 @@ export const ConfirmationModal: React.FC<

{title}

{message &&

{message}

} + + {extraContent}
diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modals/private_quote_notify.tsx b/app/javascript/mastodon/features/ui/components/confirmation_modals/private_quote_notify.tsx new file mode 100644 index 0000000000..ef917a1027 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/confirmation_modals/private_quote_notify.tsx @@ -0,0 +1,88 @@ +import { forwardRef, useCallback, useState } from 'react'; + +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import { submitCompose } from '@/mastodon/actions/compose'; +import { changeSetting } from '@/mastodon/actions/settings'; +import { CheckBox } from '@/mastodon/components/check_box'; +import { useAppDispatch } from '@/mastodon/store'; + +import { ConfirmationModal } from './confirmation_modal'; +import type { BaseConfirmationModalProps } from './confirmation_modal'; +import classes from './styles.module.css'; + +export const PRIVATE_QUOTE_MODAL_ID = 'quote/private_notify'; + +const messages = defineMessages({ + title: { + id: 'confirmations.private_quote_notify.title', + defaultMessage: 'Share with followers and mentioned users?', + }, + message: { + id: 'confirmations.private_quote_notify.message', + defaultMessage: + 'The person you are quoting and other mentions ' + + "will be notified and will be able to view your post, even if they're not following you.", + }, + confirm: { + id: 'confirmations.private_quote_notify.confirm', + defaultMessage: 'Publish post', + }, + cancel: { + id: 'confirmations.private_quote_notify.cancel', + defaultMessage: 'Back to editing', + }, +}); + +export const PrivateQuoteNotify = forwardRef< + HTMLDivElement, + BaseConfirmationModalProps +>( + ( + { onClose }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _ref, + ) => { + const intl = useIntl(); + + const [dismiss, setDismissed] = useState(false); + const handleDismissToggle = useCallback(() => { + setDismissed((prev) => !prev); + }, []); + + const dispatch = useAppDispatch(); + const handleConfirm = useCallback(() => { + dispatch(submitCompose()); + if (dismiss) { + dispatch( + changeSetting(['dismissed_banners', PRIVATE_QUOTE_MODAL_ID], true), + ); + } + }, [dismiss, dispatch]); + + return ( + + {' '} + + + } + /> + ); + }, +); +PrivateQuoteNotify.displayName = 'PrivateQuoteNotify'; diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modals/styles.module.css b/app/javascript/mastodon/features/ui/components/confirmation_modals/styles.module.css new file mode 100644 index 0000000000..f685c4525f --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/confirmation_modals/styles.module.css @@ -0,0 +1,7 @@ +.checkbox_wrapper { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 1rem 0; + cursor: pointer; +} diff --git a/app/javascript/mastodon/features/ui/components/modal_root.jsx b/app/javascript/mastodon/features/ui/components/modal_root.jsx index 944feb325e..ad5f16d94d 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.jsx +++ b/app/javascript/mastodon/features/ui/components/modal_root.jsx @@ -47,6 +47,7 @@ import MediaModal from './media_modal'; import { ModalPlaceholder } from './modal_placeholder'; import VideoModal from './video_modal'; import { VisibilityModal } from './visibility_modal'; +import { PrivateQuoteNotify } from './confirmation_modals/private_quote_notify'; export const MODAL_COMPONENTS = { 'MEDIA': () => Promise.resolve({ default: MediaModal }), @@ -66,6 +67,7 @@ export const MODAL_COMPONENTS = { 'CONFIRM_LOG_OUT': () => Promise.resolve({ default: ConfirmLogOutModal }), 'CONFIRM_FOLLOW_TO_LIST': () => Promise.resolve({ default: ConfirmFollowToListModal }), 'CONFIRM_MISSING_ALT_TEXT': () => Promise.resolve({ default: ConfirmMissingAltTextModal }), + 'CONFIRM_PRIVATE_QUOTE_NOTIFY': () => Promise.resolve({ default: PrivateQuoteNotify }), 'CONFIRM_REVOKE_QUOTE': () => Promise.resolve({ default: ConfirmRevokeQuoteModal }), 'CONFIRM_QUIET_QUOTE': () => Promise.resolve({ default: QuietPostQuoteInfoModal }), 'MUTE': MuteModal, diff --git a/app/javascript/mastodon/features/ui/components/visibility_modal.tsx b/app/javascript/mastodon/features/ui/components/visibility_modal.tsx index afd9ee7ed0..7bc7e0ab97 100644 --- a/app/javascript/mastodon/features/ui/components/visibility_modal.tsx +++ b/app/javascript/mastodon/features/ui/components/visibility_modal.tsx @@ -128,9 +128,12 @@ export const VisibilityModal: FC = forwardRef( const disableVisibility = !!statusId; const disableQuotePolicy = visibility === 'private' || visibility === 'direct'; - const disablePublicVisibilities: boolean = useAppSelector( + const disablePublicVisibilities = useAppSelector( selectDisablePublicVisibilities, ); + const isQuotePost = useAppSelector( + (state) => state.compose.get('quoted_status_id') !== null, + ); const visibilityItems = useMemo[]>(() => { const items: SelectItem[] = [ @@ -315,6 +318,21 @@ export const VisibilityModal: FC = forwardRef( id={quoteDescriptionId} /> + + {isQuotePost && visibility === 'direct' && ( +
+ + +
+ )}