From 651e51a82eba542c4d8c3fec5edd16420ea1ff3e Mon Sep 17 00:00:00 2001 From: Echo Date: Thu, 14 Aug 2025 17:04:32 +0200 Subject: [PATCH] Allow editing status quote policy (#35762) --- .../mastodon/actions/statuses_typed.ts | 11 +- app/javascript/mastodon/api/statuses.ts | 21 +- app/javascript/mastodon/api_types/quotes.ts | 12 +- app/javascript/mastodon/api_types/statuses.ts | 9 +- .../mastodon/components/dropdown/index.tsx | 114 +++++++ .../mastodon/components/dropdown_selector.tsx | 6 +- .../mastodon/components/status_action_bar.jsx | 12 +- .../mastodon/containers/status_container.jsx | 4 + .../compose/components/privacy_dropdown.jsx | 2 +- .../features/status/components/action_bar.jsx | 12 +- .../mastodon/features/status/index.jsx | 6 + .../features/ui/components/modal_root.jsx | 2 + .../ui/components/visibility_modal.tsx | 293 ++++++++++++++++++ app/javascript/mastodon/locales/en.json | 16 +- app/javascript/mastodon/reducers/statuses.js | 17 + .../mastodon/store/typed_functions.ts | 5 +- app/javascript/mastodon/utils/environment.ts | 6 +- .../styles/mastodon/components.scss | 59 +++- 18 files changed, 591 insertions(+), 16 deletions(-) create mode 100644 app/javascript/mastodon/components/dropdown/index.tsx create mode 100644 app/javascript/mastodon/features/ui/components/visibility_modal.tsx diff --git a/app/javascript/mastodon/actions/statuses_typed.ts b/app/javascript/mastodon/actions/statuses_typed.ts index cc9c389cdab..f34d9f2bc37 100644 --- a/app/javascript/mastodon/actions/statuses_typed.ts +++ b/app/javascript/mastodon/actions/statuses_typed.ts @@ -1,8 +1,10 @@ import { createAction } from '@reduxjs/toolkit'; -import { apiGetContext } from 'mastodon/api/statuses'; +import { apiGetContext, apiSetQuotePolicy } from 'mastodon/api/statuses'; import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; +import type { ApiQuotePolicy } from '../api_types/quotes'; + import { importFetchedStatuses } from './importer'; export const fetchContext = createDataLoadingThunk( @@ -23,3 +25,10 @@ export const fetchContext = createDataLoadingThunk( export const completeContextRefresh = createAction<{ statusId: string }>( 'status/context/complete', ); + +export const setStatusQuotePolicy = createDataLoadingThunk( + 'status/setQuotePolicy', + ({ statusId, policy }: { statusId: string; policy: ApiQuotePolicy }) => { + return apiSetQuotePolicy(statusId, policy); + }, +); diff --git a/app/javascript/mastodon/api/statuses.ts b/app/javascript/mastodon/api/statuses.ts index 48eff2a692f..123f2759d09 100644 --- a/app/javascript/mastodon/api/statuses.ts +++ b/app/javascript/mastodon/api/statuses.ts @@ -1,5 +1,10 @@ -import api, { getAsyncRefreshHeader } from 'mastodon/api'; -import type { ApiContextJSON } from 'mastodon/api_types/statuses'; +import api, { apiRequestPut, getAsyncRefreshHeader } from 'mastodon/api'; +import type { + ApiContextJSON, + ApiStatusJSON, +} from 'mastodon/api_types/statuses'; + +import type { ApiQuotePolicy } from '../api_types/quotes'; export const apiGetContext = async (statusId: string) => { const response = await api().request({ @@ -12,3 +17,15 @@ export const apiGetContext = async (statusId: string) => { refresh: getAsyncRefreshHeader(response), }; }; + +export const apiSetQuotePolicy = async ( + statusId: string, + policy: ApiQuotePolicy, +) => { + return apiRequestPut( + `v1/statuses/${statusId}/interaction_policy`, + { + quote_approval_policy: policy, + }, + ); +}; diff --git a/app/javascript/mastodon/api_types/quotes.ts b/app/javascript/mastodon/api_types/quotes.ts index 8c0ea10fc3c..981c047c136 100644 --- a/app/javascript/mastodon/api_types/quotes.ts +++ b/app/javascript/mastodon/api_types/quotes.ts @@ -1,7 +1,7 @@ import type { ApiStatusJSON } from './statuses'; export type ApiQuoteState = 'accepted' | 'pending' | 'revoked' | 'unauthorized'; -export type ApiQuotePolicy = 'public' | 'followers' | 'nobody'; +export type ApiQuotePolicy = 'public' | 'followers' | 'nobody' | 'unknown'; interface ApiQuoteEmptyJSON { state: Exclude; @@ -21,3 +21,13 @@ interface ApiQuoteAcceptedJSON { } export type ApiQuoteJSON = ApiQuoteAcceptedJSON | ApiQuoteEmptyJSON; + +export interface ApiQuotePolicyJSON { + automatic: ApiQuotePolicy[]; + manual: ApiQuotePolicy[]; + current_user: ApiQuotePolicy; +} + +export function isQuotePolicy(policy: string): policy is ApiQuotePolicy { + return ['public', 'followers', 'nobody'].includes(policy); +} diff --git a/app/javascript/mastodon/api_types/statuses.ts b/app/javascript/mastodon/api_types/statuses.ts index cd0b1001ac5..0127f6334bf 100644 --- a/app/javascript/mastodon/api_types/statuses.ts +++ b/app/javascript/mastodon/api_types/statuses.ts @@ -4,7 +4,7 @@ import type { ApiAccountJSON } from './accounts'; import type { ApiCustomEmojiJSON } from './custom_emoji'; import type { ApiMediaAttachmentJSON } from './media_attachments'; import type { ApiPollJSON } from './polls'; -import type { ApiQuoteJSON } from './quotes'; +import type { ApiQuoteJSON, ApiQuotePolicyJSON } from './quotes'; // See app/modals/status.rb export type StatusVisibility = @@ -120,9 +120,16 @@ export interface ApiStatusJSON { card?: ApiPreviewCardJSON; poll?: ApiPollJSON; quote?: ApiQuoteJSON; + quote_approval?: ApiQuotePolicyJSON; } export interface ApiContextJSON { ancestors: ApiStatusJSON[]; descendants: ApiStatusJSON[]; } + +export interface ApiStatusSourceJSON { + id: string; + text: string; + spoiler_text: string; +} diff --git a/app/javascript/mastodon/components/dropdown/index.tsx b/app/javascript/mastodon/components/dropdown/index.tsx new file mode 100644 index 00000000000..1e442f8159e --- /dev/null +++ b/app/javascript/mastodon/components/dropdown/index.tsx @@ -0,0 +1,114 @@ +import { useCallback, useId, useMemo, useRef, useState } from 'react'; +import type { ComponentPropsWithoutRef, FC } from 'react'; + +import { FormattedMessage } from 'react-intl'; +import type { MessageDescriptor } from 'react-intl'; + +import classNames from 'classnames'; + +import Overlay from 'react-overlays/Overlay'; + +import type { SelectItem } from '../dropdown_selector'; +import { DropdownSelector } from '../dropdown_selector'; + +interface DropdownProps { + title: string; + disabled?: boolean; + items: SelectItem[]; + onChange: (value: string) => void; + current: string; + emptyText?: MessageDescriptor; + classPrefix: string; +} + +export const Dropdown: FC< + DropdownProps & Omit, keyof DropdownProps> +> = ({ + title, + disabled, + items, + current, + onChange, + classPrefix, + className, + ...buttonProps +}) => { + const buttonRef = useRef(null); + const accessibilityId = useId(); + + const [open, setOpen] = useState(false); + const handleToggle = useCallback(() => { + if (!disabled) { + setOpen((prevOpen) => !prevOpen); + } + }, [disabled]); + const handleClose = useCallback(() => { + setOpen(false); + }, []); + const currentText = useMemo( + () => items.find((i) => i.value === current)?.text, + [current, items], + ); + return ( + <> + + + + {({ props, placement }) => ( +
+
+ +
+
+ )} +
+ + ); +}; diff --git a/app/javascript/mastodon/components/dropdown_selector.tsx b/app/javascript/mastodon/components/dropdown_selector.tsx index 99bbd182e56..9299e7d6bd7 100644 --- a/app/javascript/mastodon/components/dropdown_selector.tsx +++ b/app/javascript/mastodon/components/dropdown_selector.tsx @@ -13,8 +13,8 @@ const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true; -export interface SelectItem { - value: string; +export interface SelectItem { + value: Value; icon?: string; iconComponent?: IconProp; text: string; @@ -24,7 +24,7 @@ export interface SelectItem { interface Props { value: string; - classNamePrefix: string; + classNamePrefix?: string; style?: React.CSSProperties; items: SelectItem[]; onChange: (value: string) => void; diff --git a/app/javascript/mastodon/components/status_action_bar.jsx b/app/javascript/mastodon/components/status_action_bar.jsx index 663fc53407c..69ca9817a2c 100644 --- a/app/javascript/mastodon/components/status_action_bar.jsx +++ b/app/javascript/mastodon/components/status_action_bar.jsx @@ -29,6 +29,7 @@ import { Dropdown } from 'mastodon/components/dropdown_menu'; import { me } from '../initial_state'; import { IconButton } from './icon_button'; +import { isFeatureEnabled } from '../utils/environment'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, @@ -68,6 +69,7 @@ const messages = defineMessages({ filter: { id: 'status.filter', defaultMessage: 'Filter this post' }, openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' }, revokeQuote: { id: 'status.revoke_quote', defaultMessage: 'Remove my post from @{name}’s post' }, + quotePolicyChange: { id: 'status.quote_policy_change', defaultMessage: 'Change who can quote' }, }); const mapStateToProps = (state, { status }) => { @@ -89,6 +91,7 @@ class StatusActionBar extends ImmutablePureComponent { onReblog: PropTypes.func, onDelete: PropTypes.func, onRevokeQuote: PropTypes.func, + onQuotePolicyChange: PropTypes.func, onDirect: PropTypes.func, onMention: PropTypes.func, onMute: PropTypes.func, @@ -200,7 +203,11 @@ class StatusActionBar extends ImmutablePureComponent { handleRevokeQuoteClick = () => { this.props.onRevokeQuote(this.props.status); - } + }; + + handleQuotePolicyChange = () => { + this.props.onQuotePolicyChange(this.props.status); + }; handleBlockClick = () => { const { status, relationship, onBlock, onUnblock } = this.props; @@ -291,6 +298,9 @@ class StatusActionBar extends ImmutablePureComponent { if (writtenByMe || withDismiss) { menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); + if (writtenByMe && isFeatureEnabled('outgoing_quotes') && !['private', 'direct'].includes(status.get('visibility'))) { + menu.push({ text: intl.formatMessage(messages.quotePolicyChange), action: this.handleQuotePolicyChange }); + } menu.push(null); } diff --git a/app/javascript/mastodon/containers/status_container.jsx b/app/javascript/mastodon/containers/status_container.jsx index f3bc8fe5ff3..7d5c6e4f2f7 100644 --- a/app/javascript/mastodon/containers/status_container.jsx +++ b/app/javascript/mastodon/containers/status_container.jsx @@ -115,6 +115,10 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({ dispatch(openModal({ modalType: 'CONFIRM_REVOKE_QUOTE', modalProps: { statusId: status.get('id'), quotedStatusId: status.getIn(['quote', 'quoted_status']) }})); }, + onQuotePolicyChange(status) { + dispatch(openModal({ modalType: 'COMPOSE_PRIVACY', modalProps: { statusId: status.get('id') } })); + }, + onEdit (status) { dispatch((_, getState) => { let state = getState(); diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx b/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx index 15df5ab7297..258291ae492 100644 --- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx +++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx @@ -14,7 +14,7 @@ import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react'; import { DropdownSelector } from 'mastodon/components/dropdown_selector'; import { Icon } from 'mastodon/components/icon'; -const messages = defineMessages({ +export const messages = defineMessages({ public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' }, unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Quiet public' }, diff --git a/app/javascript/mastodon/features/status/components/action_bar.jsx b/app/javascript/mastodon/features/status/components/action_bar.jsx index 5d6625fc103..15f193510d7 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.jsx +++ b/app/javascript/mastodon/features/status/components/action_bar.jsx @@ -26,6 +26,7 @@ import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/ import { IconButton } from '../../../components/icon_button'; import { Dropdown } from 'mastodon/components/dropdown_menu'; import { me } from '../../../initial_state'; +import { isFeatureEnabled } from '@/mastodon/utils/environment'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, @@ -62,6 +63,7 @@ const messages = defineMessages({ unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' }, revokeQuote: { id: 'status.revoke_quote', defaultMessage: 'Remove my post from @{name}’s post' }, + quotePolicyChange: { id: 'status.quote_policy_change', defaultMessage: 'Change who can quote' }, }); const mapStateToProps = (state, { status }) => { @@ -84,6 +86,7 @@ class ActionBar extends PureComponent { onBookmark: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired, onRevokeQuote: PropTypes.func, + onQuotePolicyChange: PropTypes.func, onEdit: PropTypes.func.isRequired, onDirect: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired, @@ -122,7 +125,11 @@ class ActionBar extends PureComponent { handleRevokeQuoteClick = () => { this.props.onRevokeQuote(this.props.status); - } + }; + + handleQuotePolicyChange = () => { + this.props.onQuotePolicyChange(this.props.status); + }; handleRedraftClick = () => { this.props.onDelete(this.props.status, true); @@ -240,6 +247,9 @@ class ActionBar extends PureComponent { } menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); + if (isFeatureEnabled('outgoing_quotes') && !['private', 'direct'].includes(status.get('visibility'))) { + menu.push({ text: intl.formatMessage(messages.quotePolicyChange), action: this.handleQuotePolicyChange }); + } menu.push(null); menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick }); menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick, dangerous: true }); diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx index 7bdcdb89711..3cfda6e837f 100644 --- a/app/javascript/mastodon/features/status/index.jsx +++ b/app/javascript/mastodon/features/status/index.jsx @@ -265,6 +265,11 @@ class Status extends ImmutablePureComponent { dispatch(openModal({ modalType: 'CONFIRM_REVOKE_QUOTE', modalProps: { statusId: status.get('id'), quotedStatusId: status.getIn(['quote', 'quoted_status']) }})); }; + handleQuotePolicyChange = (status) => { + const { dispatch } = this.props; + dispatch(openModal({ modalType: 'COMPOSE_PRIVACY', modalProps: { statusId: status.get('id') } })); + }; + handleEditClick = (status) => { const { dispatch, askReplyConfirmation } = this.props; @@ -642,6 +647,7 @@ class Status extends ImmutablePureComponent { onBookmark={this.handleBookmarkClick} onDelete={this.handleDeleteClick} onRevokeQuote={this.handleRevokeQuoteClick} + onQuotePolicyChange={this.handleQuotePolicyChange} onEdit={this.handleEditClick} onDirect={this.handleDirectClick} onMention={this.handleMentionClick} diff --git a/app/javascript/mastodon/features/ui/components/modal_root.jsx b/app/javascript/mastodon/features/ui/components/modal_root.jsx index 3b7a24faaf4..c02cacd6595 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.jsx +++ b/app/javascript/mastodon/features/ui/components/modal_root.jsx @@ -43,6 +43,7 @@ import { ImageModal } from './image_modal'; import MediaModal from './media_modal'; import { ModalPlaceholder } from './modal_placeholder'; import VideoModal from './video_modal'; +import { VisibilityModal } from './visibility_modal'; export const MODAL_COMPONENTS = { 'MEDIA': () => Promise.resolve({ default: MediaModal }), @@ -76,6 +77,7 @@ export const MODAL_COMPONENTS = { 'CLOSED_REGISTRATIONS': ClosedRegistrationsModal, 'IGNORE_NOTIFICATIONS': IgnoreNotificationsModal, 'ANNUAL_REPORT': AnnualReportModal, + 'COMPOSE_PRIVACY': () => Promise.resolve({ default: VisibilityModal }), }; export default class ModalRoot extends PureComponent { diff --git a/app/javascript/mastodon/features/ui/components/visibility_modal.tsx b/app/javascript/mastodon/features/ui/components/visibility_modal.tsx new file mode 100644 index 00000000000..82a1a482a3f --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/visibility_modal.tsx @@ -0,0 +1,293 @@ +import { forwardRef, useCallback, useId, useMemo } from 'react'; +import type { FC } from 'react'; + +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import { changeComposeVisibility } from '@/mastodon/actions/compose'; +import { setStatusQuotePolicy } from '@/mastodon/actions/statuses_typed'; +import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes'; +import { isQuotePolicy } from '@/mastodon/api_types/quotes'; +import type { StatusVisibility } from '@/mastodon/api_types/statuses'; +import { Dropdown } from '@/mastodon/components/dropdown'; +import type { SelectItem } from '@/mastodon/components/dropdown_selector'; +import { IconButton } from '@/mastodon/components/icon_button'; +import { messages as privacyMessages } from '@/mastodon/features/compose/components/privacy_dropdown'; +import { + createAppSelector, + useAppDispatch, + useAppSelector, +} from '@/mastodon/store'; +import CloseIcon from '@/material-icons/400-24px/close.svg?react'; + +import type { BaseConfirmationModalProps } from './confirmation_modals/confirmation_modal'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, + buttonTitle: { + id: 'visibility_modal.button_title', + defaultMessage: 'Set visibility', + }, + quotePublic: { + id: 'visibility_modal.quote_public', + defaultMessage: 'Anyone', + }, + quoteFollowers: { + id: 'visibility_modal.quote_followers', + defaultMessage: 'Followers only', + }, + quoteNobody: { + id: 'visibility_modal.quote_nobody', + defaultMessage: 'No one', + }, +}); + +interface VisibilityModalProps extends BaseConfirmationModalProps { + statusId: string; +} + +const selectStatusPolicy = createAppSelector( + [(state) => state.statuses, (_state, statusId: string) => statusId], + (statuses, statusId) => { + const status = statuses.get(statusId); + if (!status) { + return 'public'; + } + const policy = + (status.getIn(['quote_approval', 'automatic', 0]) as string) || 'nobody'; + const visibility = status.get('visibility') as StatusVisibility; + + // If the status is private or direct, it cannot be quoted by anyone. + if (visibility === 'private' || visibility === 'direct') { + return 'nobody'; + } + + // If the status has a specific quote policy, return it. + if (isQuotePolicy(policy)) { + return policy; + } + + // Otherwise, return the default based on visibility. + if (visibility === 'unlisted') { + return 'followers'; + } + return 'public'; + }, +); + +export const VisibilityModal: FC = forwardRef( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + ({ onClose, statusId }, ref) => { + const intl = useIntl(); + const currentVisibility = useAppSelector( + (state) => + (state.statuses.getIn([statusId, 'visibility'], 'public') as + | StatusVisibility + | undefined) ?? 'public', + ); + const currentQuotePolicy = useAppSelector((state) => + selectStatusPolicy(state, statusId), + ); + const disableQuotePolicy = + currentVisibility === 'private' || currentVisibility === 'direct'; + const isSaving = useAppSelector( + (state) => + state.statuses.getIn([statusId, 'isSavingQuotePolicy']) === true, + ); + + const visibilityItems = useMemo[]>( + () => [ + { + value: 'public', + text: intl.formatMessage(privacyMessages.public_short), + meta: intl.formatMessage(privacyMessages.public_long), + }, + { + value: 'unlisted', + text: intl.formatMessage(privacyMessages.unlisted_short), + meta: intl.formatMessage(privacyMessages.unlisted_long), + }, + { + value: 'private', + text: intl.formatMessage(privacyMessages.private_short), + meta: intl.formatMessage(privacyMessages.private_long), + }, + { + value: 'direct', + text: intl.formatMessage(privacyMessages.direct_short), + meta: intl.formatMessage(privacyMessages.direct_long), + }, + ], + [intl], + ); + const quoteItems = useMemo[]>( + () => [ + { value: 'public', text: intl.formatMessage(messages.quotePublic) }, + { + value: 'followers', + text: intl.formatMessage(messages.quoteFollowers), + }, + { value: 'nobody', text: intl.formatMessage(messages.quoteNobody) }, + ], + [intl], + ); + + const dispatch = useAppDispatch(); + const handleVisibilityChange = useCallback( + (value: string) => { + // Published statuses cannot change visibility. + if (statusId) { + return; + } + dispatch(changeComposeVisibility(value)); + }, + [dispatch, statusId], + ); + const handleQuotePolicyChange = useCallback( + (value: string) => { + if (isQuotePolicy(value) && !disableQuotePolicy) { + void dispatch(setStatusQuotePolicy({ policy: value, statusId })); + } + }, + [disableQuotePolicy, dispatch, statusId], + ); + + const privacyDropdownId = useId(); + const quoteDropdownId = useId(); + + return ( +
+
+ + + {(chunks) => ( + {chunks} + )} + +
+
+
+ ( + {chunks} + ), + }} + tagName='p' + /> +
+
+ + + +
+
+
+ ); + }, +); +VisibilityModal.displayName = 'VisibilityModal'; + +const QuotePolicyHelper: FC<{ + policy: ApiQuotePolicy; + visibility: StatusVisibility; +}> = ({ policy, visibility }) => { + if (visibility === 'unlisted' && policy !== 'nobody') { + return ( +

+ +

+ ); + } + + if (visibility === 'private') { + return ( +

+ +

+ ); + } + + if (visibility === 'direct') { + return ( +

+ +

+ ); + } + + return null; +}; diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 6b796f9f81d..bee9b0410f0 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -292,6 +292,7 @@ "domain_pill.your_handle": "Your handle:", "domain_pill.your_server": "Your digital home, where all of your posts live. Don’t like this one? Transfer servers at any time and bring your followers, too.", "domain_pill.your_username": "Your unique identifier on this server. It’s possible to find users with the same username on different servers.", + "dropdown.empty": "Select an option", "embed.instructions": "Embed this post on your website by copying the code below.", "embed.preview": "Here is what it will look like:", "emoji_button.activity": "Activity", @@ -884,6 +885,7 @@ "status.quote_error.pending_approval": "Post pending", "status.quote_error.pending_approval_popout.body": "Quotes shared across the Fediverse may take time to display, as different servers have different protocols.", "status.quote_error.pending_approval_popout.title": "Pending quote? Remain calm", + "status.quote_policy_change": "Change who can quote", "status.quote_post_author": "Quoted a post by @{name}", "status.read_more": "Read more", "status.reblog": "Boost", @@ -959,5 +961,17 @@ "video.skip_forward": "Skip forward", "video.unmute": "Unmute", "video.volume_down": "Volume down", - "video.volume_up": "Volume up" + "video.volume_up": "Volume up", + "visibility_modal.button_title": "Set visibility", + "visibility_modal.header": "Visibility and interaction", + "visibility_modal.helper.direct_quoting": "Private mentions can't be quoted.", + "visibility_modal.helper.privacy_editing": "Published posts cannot change their visibility.", + "visibility_modal.helper.private_quoting": "Follower-only posts can't be quoted.", + "visibility_modal.helper.unlisted_quoting": "When people quote you, their post will also be hidden from trending timelines.", + "visibility_modal.instructions": "Control who can interact with this post. Global settings can be found under Preferences > Other.", + "visibility_modal.privacy_label": "Privacy", + "visibility_modal.quote_followers": "Followers only", + "visibility_modal.quote_label": "Change who can quote", + "visibility_modal.quote_nobody": "No one", + "visibility_modal.quote_public": "Anyone" } diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js index 239ab13920e..13ff5e016e5 100644 --- a/app/javascript/mastodon/reducers/statuses.js +++ b/app/javascript/mastodon/reducers/statuses.js @@ -29,6 +29,7 @@ import { STATUS_FETCH_REQUEST, STATUS_FETCH_FAIL, } from '../actions/statuses'; +import { setStatusQuotePolicy } from '../actions/statuses_typed'; const importStatus = (state, status) => state.set(status.id, fromJS(status)); @@ -70,6 +71,22 @@ const initialState = ImmutableMap(); /** @type {import('@reduxjs/toolkit').Reducer} */ export default function statuses(state = initialState, action) { + if (setStatusQuotePolicy.pending.match(action)) { + const status = state.get(action.meta.arg.statusId); + if (status) { + return state.setIn([action.meta.arg.statusId, 'isSavingQuotePolicy'], true); + } + } else if (setStatusQuotePolicy.fulfilled.match(action)) { + const status = state.get(action.payload.id); + if (status) { + return state + .setIn([action.payload.id, 'quote_approval'], action.payload.quote_approval) + .deleteIn([action.payload.id, 'isSavingQuotePolicy']); + } + } else if (setStatusQuotePolicy.rejected.match(action)) { + return state.deleteIn([action.meta.arg.statusId, 'isSavingQuotePolicy']); + } + switch(action.type) { case STATUS_FETCH_REQUEST: return state.setIn([action.id, 'isLoading'], true); diff --git a/app/javascript/mastodon/store/typed_functions.ts b/app/javascript/mastodon/store/typed_functions.ts index 69f6028be2e..3204d13ee42 100644 --- a/app/javascript/mastodon/store/typed_functions.ts +++ b/app/javascript/mastodon/store/typed_functions.ts @@ -40,7 +40,10 @@ interface AppThunkConfig { fulfilledMeta: AppMeta; rejectedMeta: AppMeta; } -type AppThunkApi = Pick, 'getState' | 'dispatch'>; +export type AppThunkApi = Pick< + GetThunkAPI, + 'getState' | 'dispatch' +>; interface AppThunkOptions { useLoadingBar?: boolean; diff --git a/app/javascript/mastodon/utils/environment.ts b/app/javascript/mastodon/utils/environment.ts index c5fe46bc931..fc4448740f4 100644 --- a/app/javascript/mastodon/utils/environment.ts +++ b/app/javascript/mastodon/utils/environment.ts @@ -12,7 +12,11 @@ export function isProduction() { else return import.meta.env.PROD; } -export type Features = 'modern_emojis'; +export type Features = + | 'modern_emojis' + | 'outgoing_quotes' + | 'fasp' + | 'http_message_signatures'; export function isFeatureEnabled(feature: Features) { return initialState?.features.includes(feature) ?? false; diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index cca92a46d94..1de7b74c501 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -5402,7 +5402,8 @@ a.status-card { } .privacy-dropdown__dropdown, -.language-dropdown__dropdown { +.language-dropdown__dropdown, +.visibility-dropdown__dropdown { box-shadow: var(--dropdown-shadow); background: var(--dropdown-background-color); backdrop-filter: $backdrop-blur-filter; @@ -5431,7 +5432,8 @@ a.status-card { z-index: 9999; } -.privacy-dropdown__option { +.privacy-dropdown__option, +.visibility-dropdown__option { font-size: 14px; line-height: 20px; letter-spacing: 0.25px; @@ -5577,6 +5579,39 @@ a.status-card { } } +.visibility-dropdown { + &__overlay[data-popper-placement] { + z-index: 9999; + } + + &__label.disabled { + cursor: default; + opacity: 0.5; + } + + &__button { + color: $primary-text-color; + background: var(--dropdown-background-color); + border: 1px solid var(--dropdown-border-color); + padding: 8px 12px; + width: 100%; + text-align: left; + border-radius: 4px; + font-size: 14px; + height: 40px; + + &:disabled { + cursor: default; + } + } + + &__helper { + margin-top: 4px; + font-size: 0.8em; + color: $dark-text-color; + } +} + .search { margin-bottom: 32px; position: relative; @@ -5869,6 +5904,17 @@ a.status-card { } } +.modal-root label { + cursor: pointer; + display: block; + + > span { + display: block; + font-weight: 500; + margin-bottom: 8px; + } +} + .video-modal .video-player { max-height: 80vh; max-width: 100vw; @@ -6375,6 +6421,15 @@ a.status-card { letter-spacing: 0.25px; overflow-y: auto; + &__description { + margin: 24px 24px 0; + color: $darker-text-color; + + a { + color: inherit; + } + } + &__form { display: flex; flex-direction: column;