Status interaction action (#39681)

This commit is contained in:
Echo
2026-07-02 15:12:13 +02:00
committed by GitHub
parent 72085f7d52
commit eb167fdc3b
9 changed files with 414 additions and 79 deletions

View File

@@ -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,
}),
);
});

View File

@@ -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',

View File

@@ -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,

View File

@@ -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<HTMLDivElement>;
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<StatusContainerProps>;
// 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<StatusProps>;

View File

@@ -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<StatusContainerForQuotesProps>;
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<QuotedStatusProps> = ({
return (
<div className='status__quote'>
<StatusContainerWithChildren
<TypedStatusContainer
isQuotedPost
id={quotedStatusId}
contextType={contextType}
@@ -372,16 +359,17 @@ export const QuotedStatus: React.FC<QuotedStatusProps> = ({
nestingLevel={nestingLevel + 1}
/>
)}
</StatusContainerWithChildren>
</TypedStatusContainer>
</div>
);
};
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 (
<StatusContainerWithChildren {...props}>
<TypedStatusContainer {...props}>
<QuotedStatus
quote={quote}
parentQuotePostId={status?.get('id') as string}
contextType={props.contextType}
/>
</StatusContainerWithChildren>
</TypedStatusContainer>
);
}
return <StatusContainerWithChildren {...props} />;
return <TypedStatusContainer {...props} />;
};

View File

@@ -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';

View File

@@ -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}.",

View File

@@ -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';

View File

@@ -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<typeof conditionals>) {
return {
...conditions,
allowed: Object.values(conditions).every(Boolean),
};
}
const { isLoggedIn, isMine, isPublic, isQuoted, isNotDirect } =
conditionals;
const interactions: Record<
StatusInteractionIntent,
Partial<typeof conditionals> & { 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) =>