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' && (
+
+
+
+
+ )}