From eb167fdc3b6e24c2037ec0dc0e8ddd489f918ca4 Mon Sep 17 00:00:00 2001 From: Echo Date: Thu, 2 Jul 2026 15:12:13 +0200 Subject: [PATCH] Status interaction action (#39681) --- app/javascript/mastodon/actions/alerts.ts | 11 + .../mastodon/actions/interactions_typed.ts | 256 +++++++++++++++++- .../components/status/status.stories.tsx | 17 +- .../mastodon/components/status/types.ts | 68 +++-- .../mastodon/components/status_quoted.tsx | 44 ++- .../features/interaction_modal/index.tsx | 4 +- app/javascript/mastodon/locales/en.json | 3 + app/javascript/mastodon/selectors/accounts.ts | 12 +- app/javascript/mastodon/selectors/statuses.ts | 78 +++++- 9 files changed, 414 insertions(+), 79 deletions(-) diff --git a/app/javascript/mastodon/actions/alerts.ts b/app/javascript/mastodon/actions/alerts.ts index 4fd293e2521..3c2974db829 100644 --- a/app/javascript/mastodon/actions/alerts.ts +++ b/app/javascript/mastodon/actions/alerts.ts @@ -7,6 +7,8 @@ import type { AxiosResponse } from 'axios'; import type { Alert } from 'mastodon/models/alert'; +import { createAppThunk } from '../store/typed_functions'; + interface ApiErrorResponse { error?: string; } @@ -74,3 +76,12 @@ export const showAlertForError = (error: unknown, skipNotFound = false) => { message: messages.unexpectedMessage, }); }; + +export const showGenericAlert = createAppThunk((_arg, { dispatch }) => { + dispatch( + showAlert({ + title: messages.unexpectedTitle, + message: messages.unexpectedMessage, + }), + ); +}); diff --git a/app/javascript/mastodon/actions/interactions_typed.ts b/app/javascript/mastodon/actions/interactions_typed.ts index 36f9f85b9cc..a2c43489987 100644 --- a/app/javascript/mastodon/actions/interactions_typed.ts +++ b/app/javascript/mastodon/actions/interactions_typed.ts @@ -1,13 +1,263 @@ +import { defineMessages } from 'react-intl'; + import { apiReblog, apiUnreblog, apiRevokeQuote, apiGetQuotes, -} from 'mastodon/api/interactions'; -import type { StatusVisibility } from 'mastodon/models/status'; -import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; +} from '@/mastodon/api/interactions'; +import type { StatusContextType } from '@/mastodon/components/status/types'; +import type { VisibilityModalCallback } from '@/mastodon/features/ui/components/visibility_modal'; +import type { StatusShape, StatusVisibility } from '@/mastodon/models/status'; +import { + createAppThunk, + createDataLoadingThunk, +} from '@/mastodon/store/typed_functions'; +import { deleteModal } from '../initial_state'; +import { selectStatusInteractions } from '../selectors/statuses'; + +import { showAlert, showGenericAlert } from './alerts'; +import { replyCompose } from './compose'; +import { quoteComposeById } from './compose_typed'; import { importFetchedStatus, importFetchedStatuses } from './importer'; +import { + bookmark, + favourite, + pin, + unbookmark, + unfavourite, + unpin, +} from './interactions'; +import { openModal } from './modal'; +import { + deleteStatus, + editStatus, + muteStatus, + setStatusQuotePolicy, + unmuteStatus, +} from './statuses'; + +export type StatusInteractionIntent = + | 'bookmark' + | 'delete' + | 'editQuotePolicy' + | 'edit' + | 'embed' + | 'favourite' + | 'filter' + | 'mute' + | 'pin' + | 'quote' + | 'reblog' + | 'redraft' + | 'reply' + | 'report' + | 'revokeQuote'; + +const messages = defineMessages({ + noEdits: { + id: 'status.cannot_edit', + defaultMessage: 'You are not allowed to edit this post', + }, + privateStatus: { + id: 'status.public_only', + defaultMessage: 'This status is private', + }, + notQuoted: { + id: 'status.not_quoted', + defaultMessage: 'You are not quoted in this status', + }, +}); + +export const statusInteraction = createAppThunk( + ( + { + statusId, + contextType, + intent, + }: { + statusId: string; + contextType?: StatusContextType; + intent: StatusInteractionIntent; + }, + { getState, dispatch }, + ) => { + const state = getState(); + const statusImmutable = state.statuses.get(statusId); + if (!statusImmutable) { + dispatch(showGenericAlert()); + return; + } + const status = statusImmutable.toJS() as unknown as StatusShape; + + // Check the permissions and respond if it's not allowed. + const interactions = selectStatusInteractions(state, statusId); + const permissions = interactions[intent]; + if (!permissions.allowed) { + // Must strictly check for false, as values can be undefined. + if (permissions.isLoggedIn === false) { + dispatch( + openModal({ + modalType: 'INTERACTION', + modalProps: { + intent, + accountId: status.account, + url: status.uri, + }, + }), + ); + } else if (permissions.isMine === false) { + dispatch(showAlert({ message: messages.noEdits })); + } else if ( + permissions.isNotDirect === false || + permissions.isPublic === false + ) { + dispatch(showAlert({ message: messages.privateStatus })); + } else if (permissions.isQuoted === false) { + dispatch(showAlert({ message: messages.notQuoted })); + } + return; + } + + // Handle intents for all statuses. + switch (intent) { + case 'bookmark': + if (status.bookmarked) { + dispatch(unbookmark(statusImmutable)); + } else { + dispatch(bookmark(statusImmutable)); + } + return; + case 'delete': + if (!deleteModal) { + void dispatch(deleteStatus(statusId)); + } else { + dispatch( + openModal({ + modalType: 'CONFIRM_DELETE_STATUS', + modalProps: { statusId }, + }), + ); + } + return; + case 'edit': { + const composerText = state.compose.get('text'); + if (typeof composerText === 'string' && composerText.trim()) { + dispatch( + openModal({ + modalType: 'CONFIRM_EDIT_STATUS', + modalProps: { statusId }, + }), + ); + } else { + dispatch(editStatus(statusId)); + } + return; + } + case 'editQuotePolicy': + dispatch( + openModal({ + modalType: 'COMPOSE_PRIVACY', + modalProps: { + statusId, + onChange: ((_, policy) => { + void dispatch(setStatusQuotePolicy({ statusId, policy })); + }) satisfies VisibilityModalCallback, + }, + }), + ); + return; + case 'embed': + dispatch( + openModal({ + modalType: 'EMBED', + modalProps: { id: statusId }, + }), + ); + return; + case 'filter': + dispatch( + openModal({ + modalType: 'FILTER', + modalProps: { statusId, contextType }, + }), + ); + return; + case 'favourite': + if (status.favourited) { + dispatch(unfavourite(statusImmutable)); + } else { + dispatch(favourite(statusImmutable)); + } + return; + case 'mute': + if (status.muted) { + dispatch(unmuteStatus(statusId)); + } else { + dispatch(muteStatus(statusId)); + } + return; + case 'pin': + if (status.pinned) { + dispatch(unpin(statusImmutable)); + } else { + dispatch(pin(statusImmutable)); + } + return; + case 'quote': + dispatch(quoteComposeById(statusId)); + return; + case 'reblog': + if (status.reblogged) { + void dispatch(unreblog({ statusId })); + } else { + void dispatch(reblog({ statusId, visibility: status.visibility })); + } + return; + case 'redraft': + if (!deleteModal) { + void dispatch(deleteStatus(statusId, true)); + } else { + dispatch( + openModal({ + modalType: 'CONFIRM_DELETE_STATUS', + modalProps: { + statusId, + withRedraft: true, + }, + }), + ); + } + return; + case 'reply': + dispatch(replyCompose(statusImmutable)); + return; + case 'report': + dispatch( + openModal({ + modalType: 'REPORT', + modalProps: { + accountId: status.account, + statusId: statusId, + }, + }), + ); + return; + case 'revokeQuote': + dispatch( + openModal({ + modalType: 'CONFIRM_REVOKE_QUOTE', + modalProps: { + statusId: statusId, + quotedStatusId: status.quote?.quoted_status, + }, + }), + ); + return; + } + }, +); export const reblog = createDataLoadingThunk( 'status/reblog', diff --git a/app/javascript/mastodon/components/status/status.stories.tsx b/app/javascript/mastodon/components/status/status.stories.tsx index 0a6418f2820..379e37a54ed 100644 --- a/app/javascript/mastodon/components/status/status.stories.tsx +++ b/app/javascript/mastodon/components/status/status.stories.tsx @@ -16,19 +16,9 @@ import { import type { AttachmentArgs } from './testing'; import { attachmentArgTypes, attachmentFactory } from './testing'; +import type { StatusContextType } from './types'; import { TypedStatus } from './types'; -type ContextTypes = - | 'account' - | 'bookmarks' - | 'detailed' - | 'favourites' - | 'home' - | 'notifications' - | 'public' - | 'search' - | 'thread'; - interface StatusStoryProps extends AttachmentArgs { // Contents text: string; @@ -51,7 +41,7 @@ interface StatusStoryProps extends AttachmentArgs { // Display showThread?: boolean; - contextType?: ContextTypes; + contextType?: StatusContextType; showCounters?: boolean; favouriteCount?: number; reblogCount?: number; @@ -319,6 +309,7 @@ const meta = { options: [ 'account', 'bookmarks', + 'composer', 'detailed', 'favourites', 'home', @@ -326,7 +317,7 @@ const meta = { 'public', 'search', 'thread', - ] satisfies ContextTypes[], + ] satisfies StatusContextType[], }, hidden: categoryDisplay, muted: categoryDisplay, diff --git a/app/javascript/mastodon/components/status/types.ts b/app/javascript/mastodon/components/status/types.ts index 52eb26a8059..5d440773b9d 100644 --- a/app/javascript/mastodon/components/status/types.ts +++ b/app/javascript/mastodon/components/status/types.ts @@ -1,5 +1,6 @@ import type { ComponentType, MouseEventHandler, ReactNode } from 'react'; +import StatusContainer from '@/mastodon/containers/status_container'; import type { Account as TAccount } from '@/mastodon/models/account'; import type { Status as TStatus } from '@/mastodon/models/status'; @@ -7,15 +8,55 @@ import Status from '../status'; import type { StatusHeaderRenderFn } from './header'; -// Taken from the Status component. -export interface StatusProps { - status: TStatus; +export type StatusContextType = + | 'account' + | 'bookmarks' + | 'composer' + | 'detailed' + | 'favourites' + | 'home' + | `list:${string}` + | 'notifications' + | 'public' + | 'search' + | 'thread'; + +export interface StatusContainerProps { + id?: string | null; account?: TAccount; children?: ReactNode; previousId?: string; - nextInReplyToId?: string; rootId?: string; onClick?: MouseEventHandler; + muted?: boolean; + hidden?: boolean; + unread?: boolean; + featured?: boolean; + showThread?: boolean; + showActions?: boolean; + isQuotedPost?: boolean; + shouldHighlightOnMount?: boolean; + contextType?: StatusContextType; + withCounters?: boolean; + unfocusable?: boolean; + headerRenderFn?: StatusHeaderRenderFn; + getScrollPosition?: () => null | { height: number; top: number }; + updateScrollBottom?: (snapshot: number) => void; + cacheMediaWidth?: (width: number) => void; + cachedMediaWidth?: number; + scrollKey?: string; + skipPrepend?: boolean; + avatarSize?: number; + withDismiss?: boolean; +} + +export const TypedStatusContainer = + StatusContainer as ComponentType; + +// Taken from the Status component. +export interface StatusProps extends StatusContainerProps { + status: TStatus; + nextInReplyToId?: string; onReply: (status: TStatus) => void; onFavourite: (status: TStatus) => void; onReblog: (status: TStatus, event?: unknown) => void; @@ -44,31 +85,12 @@ export interface StatusProps { onToggleCollapsed: (status: TStatus, isCollapsed: boolean) => void; onTranslate: (status: TStatus) => void; onInteractionModal?: (type: string, status: TStatus) => void; - muted?: boolean; - hidden?: boolean; - unread?: boolean; - featured?: boolean; - showThread?: boolean; - showActions?: boolean; - isQuotedPost?: boolean; - shouldHighlightOnMount?: boolean; - getScrollPosition?: () => null | { height: number; top: number }; - updateScrollBottom?: (snapshot: number) => void; - cacheMediaWidth?: (width: number) => void; - cachedMediaWidth?: number; - scrollKey?: string; - skipPrepend?: boolean; - avatarSize?: number; deployPictureInPicture: ( status: TStatus, type: string, mediaProps: unknown, ) => void; - unfocusable?: boolean; - headerRenderFn?: StatusHeaderRenderFn; pictureInPicture: Immutable.Map<'inUse' | 'available', boolean>; - contextType?: string; - withCounters?: boolean; } export const TypedStatus = Status as ComponentType; diff --git a/app/javascript/mastodon/components/status_quoted.tsx b/app/javascript/mastodon/components/status_quoted.tsx index 23f559ce5ce..d66d70d86ea 100644 --- a/app/javascript/mastodon/components/status_quoted.tsx +++ b/app/javascript/mastodon/components/status_quoted.tsx @@ -1,16 +1,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { ComponentType, ReactNode } from 'react'; import { defineMessage, FormattedMessage, useIntl } from 'react-intl'; import type { Map as ImmutableMap } from 'immutable'; +import type { Merge } from 'type-fest'; + import CancelFillIcon from '@/material-icons/400-24px/cancel-fill.svg?react'; import { fetchRelationships } from 'mastodon/actions/accounts'; import { revealAccount } from 'mastodon/actions/accounts_typed'; import { fetchStatus } from 'mastodon/actions/statuses'; 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'; @@ -23,6 +23,8 @@ import { Button } from './button'; import { IconButton } from './icon_button'; import type { StatusHeaderRenderFn } from './status/header'; import { StatusHeader } from './status/header'; +import { TypedStatusContainer } from './status/types'; +import type { StatusContainerProps, StatusContextType } from './status/types'; const MAX_QUOTE_POSTS_NESTING_LEVEL = 1; @@ -143,24 +145,9 @@ const FilteredQuote: React.FC<{ ); }; -// Adds a wrapper around StatusContainer as the types aren't inheriting correctly with Redux + React 19. -// TODO: Remove this after the Status component is in TS. -interface StatusContainerForQuotesProps { - id?: string | null; - contextType?: string; - isQuotedPost?: boolean; - avatarSize?: number; - headerRenderFn?: StatusHeaderRenderFn; - children?: ReactNode; - [key: string]: unknown; -} - -const StatusContainerWithChildren = - StatusContainer as unknown as ComponentType; - interface QuotedStatusProps { quote: QuoteMap; - contextType?: string; + contextType?: StatusContextType; parentQuotePostId?: string | null; variant?: 'full' | 'link'; nestingLevel?: number; @@ -354,7 +341,7 @@ export const QuotedStatus: React.FC = ({ return (
- = ({ nestingLevel={nestingLevel + 1} /> )} - +
); }; -export interface StatusQuoteManagerProps { - id: string; - contextType?: string; - [key: string]: unknown; -} +export type StatusQuoteManagerProps = Merge< + StatusContainerProps, + { + id: string; + } +>; /** * This wrapper component takes a status ID and, if the associated status @@ -399,15 +387,15 @@ export const StatusQuoteManager = (props: StatusQuoteManagerProps) => { if (quote) { return ( - + - + ); } - return ; + return ; }; diff --git a/app/javascript/mastodon/features/interaction_modal/index.tsx b/app/javascript/mastodon/features/interaction_modal/index.tsx index 4e8707e5d59..be43a922cc5 100644 --- a/app/javascript/mastodon/features/interaction_modal/index.tsx +++ b/app/javascript/mastodon/features/interaction_modal/index.tsx @@ -26,8 +26,6 @@ const messages = defineMessages({ }, }); -type InteractionIntent = 'follow' | 'reblog' | 'favourite' | 'reply' | 'vote'; - interface LoginFormMessage { type: | 'fetchInteractionURL' @@ -36,7 +34,7 @@ interface LoginFormMessage { uri_or_domain: string; template?: string; param?: string; - intent?: InteractionIntent; + intent?: string; } const PERSISTENCE_KEY = 'mastodon_home'; diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 508b0540828..caf632ac137 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -1231,6 +1231,7 @@ "status.block": "Block @{name}", "status.bookmark": "Bookmark", "status.cancel_reblog_private": "Unboost", + "status.cannot_edit": "You are not allowed to edit this post", "status.cannot_quote": "You are not allowed to quote this post", "status.cannot_reblog": "This post cannot be boosted", "status.contains_quote": "Contains quote", @@ -1264,8 +1265,10 @@ "status.more": "More", "status.mute": "Mute @{name}", "status.mute_conversation": "Mute conversation", + "status.not_quoted": "You are not quoted in this status", "status.open": "Expand this post", "status.pin": "Pin on profile", + "status.public_only": "This status is private", "status.quote": "Quote", "status.quote.cancel": "Cancel quote", "status.quote_error.blocked_account_hint.title": "This post is hidden because you've blocked @{name}.", diff --git a/app/javascript/mastodon/selectors/accounts.ts b/app/javascript/mastodon/selectors/accounts.ts index 74538d366ac..d0e014b12bb 100644 --- a/app/javascript/mastodon/selectors/accounts.ts +++ b/app/javascript/mastodon/selectors/accounts.ts @@ -1,16 +1,16 @@ import type { Map as ImmutableMap } from 'immutable'; import { Record as ImmutableRecord, List as ImmutableList } from 'immutable'; -import { me } from 'mastodon/initial_state'; -import { accountDefaultValues } from 'mastodon/models/account'; +import { me } from '@/mastodon/initial_state'; +import { accountDefaultValues } from '@/mastodon/models/account'; import type { Account, AccountShape, AccountShapeFull, -} from 'mastodon/models/account'; -import type { Relationship } from 'mastodon/models/relationship'; -import { createAppSelector } from 'mastodon/store'; -import type { RootState } from 'mastodon/store'; +} from '@/mastodon/models/account'; +import type { Relationship } from '@/mastodon/models/relationship'; +import type { RootState } from '@/mastodon/store'; +import { createAppSelector } from '@/mastodon/store/typed_functions'; import type { ApiHashtagJSON } from '../api_types/tags'; diff --git a/app/javascript/mastodon/selectors/statuses.ts b/app/javascript/mastodon/selectors/statuses.ts index e705fa022e7..592c0bd31a3 100644 --- a/app/javascript/mastodon/selectors/statuses.ts +++ b/app/javascript/mastodon/selectors/statuses.ts @@ -1,8 +1,11 @@ import type { OrderedSet as ImmutableOrderedSet } from 'immutable'; -import { createAppSelector } from 'mastodon/store'; - -import type { ExpandedStatusShape, StatusShape } from '../models/status'; +import type { StatusInteractionIntent } from '@/mastodon/actions/interactions_typed'; +import type { + ExpandedStatusShape, + StatusShape, +} from '@/mastodon/models/status'; +import { createAppSelector } from '@/mastodon/store/typed_functions'; import { selectPlainAccount } from './accounts'; import type { FilterShape } from './filters'; @@ -71,6 +74,75 @@ export const selectExpandedStatus = createAppSelector( }, ); +export const selectStatusConditions = createAppSelector( + [ + selectPlainStatus, + (state) => state.meta.get('me', null) as string | null, + (state, statusId: string) => + state.statuses.getIn([ + state.statuses.getIn([statusId, 'quote', 'quoted_status']), + 'account', + ]) as string | undefined | null, + ], + (status, currentAccountId, quotedAccountId) => ({ + isPublic: status && ['public', 'unlisted'].includes(status.visibility), + isLoggedIn: !!currentAccountId, + isMine: status && !!currentAccountId && status.account === currentAccountId, + isQuoted: + status && !!currentAccountId && quotedAccountId === currentAccountId, + isNotDirect: status && status.visibility !== 'direct', + }), +); + +export const selectStatusInteractions = createAppSelector( + [(_, statusId: string) => statusId, selectStatusConditions], + (statusId, conditionals) => { + function addAllowed(conditions: Partial) { + return { + ...conditions, + allowed: Object.values(conditions).every(Boolean), + }; + } + + const { isLoggedIn, isMine, isPublic, isQuoted, isNotDirect } = + conditionals; + + const interactions: Record< + StatusInteractionIntent, + Partial & { allowed: boolean } + > = { + bookmark: addAllowed({ isLoggedIn }), + delete: addAllowed({ isMine }), + edit: addAllowed({ isMine }), + editQuotePolicy: addAllowed({ isMine, isPublic }), + embed: addAllowed({ isPublic }), + favourite: addAllowed({ isLoggedIn }), + filter: addAllowed({ isLoggedIn }), + mute: addAllowed({ isMine }), + pin: addAllowed({ isMine, isNotDirect }), + quote: addAllowed({ isLoggedIn }), + reblog: addAllowed({ isLoggedIn }), + redraft: addAllowed({ isMine }), + reply: addAllowed({ isLoggedIn }), + report: addAllowed({ isLoggedIn }), + revokeQuote: addAllowed({ isQuoted }), + }; + + return { + statusId, + ...interactions, + }; + }, +); + +export const selectStatusIntentAllowed = createAppSelector( + [ + selectStatusInteractions, + (_state, _statusId: string, intent: StatusInteractionIntent) => intent, + ], + (allowedInteractions, intent) => allowedInteractions[intent].allowed, +); + export const selectPictureInPicture = createAppSelector( [ (state, statusId: string) =>