Status redesign: Update menu (#40636)

This commit is contained in:
Echo
2026-09-21 19:21:44 +00:00
committed by GitHub
parent e1b88b48ef
commit 3b43a085d0
11 changed files with 678 additions and 521 deletions

View File

@@ -150,7 +150,7 @@ export function fetchAccountFail(id, error) {
* @param {boolean} [options.reblogs]
* @param {boolean} [options.notify]
* @param {string} [options.ref]
* @returns {function(): void}
* @returns {import('@reduxjs/toolkit').ThunkAction}
*/
export function followAccount(id, options = { reblogs: true }) {
return (dispatch, getState) => {

View File

@@ -71,7 +71,6 @@ const AccountButtonsOther: FC<
const dispatch = useAppDispatch();
const handleNotifyToggle = useCallback(() => {
if (account) {
// @ts-expect-error this action is not typed yet
dispatch(followAccount(account.id, { notify: !relationship?.notifying }));
}
}, [dispatch, account, relationship]);

View File

@@ -372,7 +372,6 @@ function getMenuItems({
),
action: () => {
dispatch(
// @ts-expect-error this action is not typed yet
followAccount(account.id, {
reblogs: !relationship.showing_reblogs,
}),

View File

@@ -141,7 +141,6 @@ export function useFollowButton({
}),
);
} else {
// @ts-expect-error this action is not typed yet
dispatch(followAccount(accountId, { ref: reference }));
}
}, [

View File

@@ -1,5 +1,4 @@
import type React from 'react';
import { useCallback, useMemo } from 'react';
import { useCallback } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
@@ -15,44 +14,15 @@ import {
ShareFatIcon,
} from '@phosphor-icons/react';
import {
muteAccount,
unblockAccount,
unmuteAccount,
} from '@/mastodon/actions/accounts';
import { initBlockModal } from '@/mastodon/actions/blocks';
import { directCompose, mentionCompose } from '@/mastodon/actions/compose';
import {
initDomainBlockModal,
unblockDomain,
} from '@/mastodon/actions/domain_blocks';
import type { StatusInteractionIntent } from '@/mastodon/actions/interactions';
import { statusInteraction } from '@/mastodon/actions/interactions';
import { fetchStatus } from '@/mastodon/actions/statuses';
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
import { useRelationship } from '@/mastodon/hooks/useRelationship';
import { useStatus } from '@/mastodon/hooks/useStatus';
import { useIdentity } from '@/mastodon/identity_context';
import { useAccountStatus } from '@/mastodon/hooks/useStatus';
import { quickBoosting } from '@/mastodon/initial_state';
import type { Account } from '@/mastodon/models/account';
import type { MenuItem as DropdownItem } from '@/mastodon/models/dropdown_menu';
import type { Relationship } from '@/mastodon/models/relationship';
import type { StatusShape } from '@/mastodon/models/status';
import {
PERMISSION_MANAGE_FEDERATION,
PERMISSION_MANAGE_USERS,
} from '@/mastodon/permissions';
import type {
StatusConditions,
StatusInteractionsAllowed,
} from '@/mastodon/selectors/statuses';
import {
selectStatusConditions,
selectStatusInteractionsAllowed,
} from '@/mastodon/selectors/statuses';
import type { AppDispatch } from '@/mastodon/store';
import type { AccountStatusShape } from '@/mastodon/models/status';
import { selectStatusConditions } from '@/mastodon/selectors/statuses';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { isRedesignEnabled } from '@/mastodon/utils/environment';
import {
Button,
@@ -71,7 +41,7 @@ import {
} from '../menu';
import { boostItemState, quoteItemState } from './boost_button_utils';
import { useStatusContext } from './hooks';
import { useStatusContext, useStatusMenuActions } from './hooks';
import { RemoveQuoteHint } from './legacy/action_bar/remove_quote_hint';
import classes from './styles.module.scss';
@@ -79,83 +49,26 @@ interface StatusActionBarProps {
statusId: string;
withDismiss?: boolean;
withCounters?: boolean;
onlyInteractions?: boolean;
/** Only show methods to respond (reply, boost, like) and not sharing, bookmarking, and the overflow menu. */
onlyResponses?: boolean;
}
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' },
edit: { id: 'status.edit', defaultMessage: 'Edit' },
direct: { id: 'status.direct', defaultMessage: 'Privately mention @{name}' },
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
reply: { id: 'status.reply', defaultMessage: 'Reply' },
share: { id: 'status.share', defaultMessage: 'Share' },
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
favourite: { id: 'status.favourite', defaultMessage: 'Favorite' },
removeFavourite: {
id: 'status.remove_favourite',
defaultMessage: 'Remove from favorites',
},
open: { id: 'status.open', defaultMessage: 'Expand this status' },
report: { id: 'status.report', defaultMessage: 'Report @{name}' },
muteConversation: {
id: 'status.mute_conversation',
defaultMessage: 'Mute conversation',
},
unmuteConversation: {
id: 'status.unmute_conversation',
defaultMessage: 'Unmute conversation',
},
pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
embed: { id: 'status.embed', defaultMessage: 'Get embed code' },
admin_account: {
id: 'status.admin_account',
defaultMessage: 'Open moderation interface for @{name}',
},
admin_status: {
id: 'status.admin_status',
defaultMessage: 'Open this post in the moderation interface',
},
admin_domain: {
id: 'status.admin_domain',
defaultMessage: 'Open moderation interface for {domain}',
},
copy: { id: 'status.copy', defaultMessage: 'Copy link to post' },
blockDomain: {
id: 'account.block_domain',
defaultMessage: 'Block domain {domain}',
},
unblockDomain: {
id: 'account.unblock_domain',
defaultMessage: 'Unblock domain {domain}',
},
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
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',
},
});
export const StatusActionBar: React.FC<StatusActionBarProps> = ({
statusId,
withDismiss,
withCounters,
onlyInteractions,
onlyResponses,
}) => {
const status = useStatus(statusId);
const status = useAccountStatus(statusId);
const quotedAccountId = useAppSelector(
(state) =>
state.statuses.getIn([status?.quote?.quoted_status, 'account']) ?? null,
@@ -215,7 +128,7 @@ export const StatusActionBar: React.FC<StatusActionBarProps> = ({
const shouldShowQuoteRemovalHint =
isQuotingMe && contextType === 'notifications';
const interactionButtons = (
const responseButtons = (
<>
<Button
size='sm'
@@ -239,20 +152,20 @@ export const StatusActionBar: React.FC<StatusActionBarProps> = ({
title={favouriteTitle}
leadingIcon={favouriteIcon}
onClick={handleFavouriteClick}
className={classNames(!onlyInteractions && classes.actionsButtonGap)}
className={classNames(!onlyResponses && classes.actionsButtonGap)}
>
{withCounters && status.favourites_count}
</ToggleButton>
</>
);
if (onlyInteractions) {
return <div className={classes.actions}>{interactionButtons}</div>;
if (onlyResponses) {
return <div className={classes.actions}>{responseButtons}</div>;
}
return (
<div className={classes.actions}>
{interactionButtons}
{responseButtons}
{isPublic && (
<IconButton
@@ -271,7 +184,6 @@ export const StatusActionBar: React.FC<StatusActionBarProps> = ({
active={status.bookmarked}
icon={bookmarkIcon}
onClick={handleBookmarkClick}
aria-pressed={status.bookmarked}
>
{!status.bookmarked ? (
<FormattedMessage id='status.bookmark' defaultMessage='Bookmark' />
@@ -373,60 +285,14 @@ const QuotesFilledIcon = iconWeight(QuotesIcon, 'fill');
const StatusActionMenu: React.FC<{
dismissQuoteHint: () => void;
status: StatusShape;
status: AccountStatusShape;
withDismiss?: boolean;
}> = ({ status, dismissQuoteHint, withDismiss }) => {
const account = useAppSelector((state) => state.accounts.get(status.account));
const { contextType } = useStatusContext();
const { permissions } = useIdentity();
const relationship = useRelationship(account?.id);
const intl = useIntl();
const dispatch = useAppDispatch();
const conditions = useAppSelector((state) =>
selectStatusConditions(state, status.id),
);
const interactions = useAppSelector((state) =>
selectStatusInteractionsAllowed(state, status.id),
);
const statusInteractionFactory = useCallback(
(intent: StatusInteractionIntent) => {
return () => {
dispatch(
statusInteraction({ statusId: status.id, contextType, intent }),
);
};
},
[contextType, dispatch, status.id],
);
const menu = useStatusMenuActions({ status, contextType, withDismiss });
const menu = useMemo(
() =>
getMenuItems({
status,
account,
conditions,
interactions,
onStatusInteraction: statusInteractionFactory,
withDismiss,
permissions,
intl,
relationship,
dispatch,
}),
[
status,
account,
conditions,
interactions,
statusInteractionFactory,
withDismiss,
permissions,
intl,
relationship,
dispatch,
],
);
const onOpen = useCallback(() => {
// Replicates needsStatusRefresh of the Dropdown component.
if (quickBoosting && !status.quote_approval) {
@@ -458,7 +324,9 @@ const StatusActionMenu: React.FC<{
);
};
const StatusActionItem: React.FC<{ item: DropdownItem }> = ({ item }) => {
export const StatusActionItem: React.FC<{ item: DropdownItem }> = ({
item,
}) => {
if (!item) {
return <MenuItemDivider />;
}
@@ -479,277 +347,3 @@ const StatusActionItem: React.FC<{ item: DropdownItem }> = ({ item }) => {
return <MenuItem {...commonProps} onClick={item.action} />;
};
interface MenuItemsParams {
status: StatusShape;
account?: Account;
conditions: StatusConditions;
interactions: StatusInteractionsAllowed;
onStatusInteraction: (intent: StatusInteractionIntent) => () => void;
withDismiss?: boolean;
permissions: number;
intl: ReturnType<typeof useIntl>;
relationship?: Relationship | null;
dispatch: AppDispatch;
}
function getMenuItems({
status,
account,
conditions,
interactions,
onStatusInteraction,
withDismiss,
permissions,
intl,
relationship,
dispatch,
}: MenuItemsParams) {
const menu: DropdownItem[] = [];
const statusId = status.id;
const statusUrl = status.url ?? status.uri;
const { isPublic, isLocal, isLoggedIn, isMine } = conditions;
menu.push({
text: intl.formatMessage(messages.open),
to: `/@${account?.acct}/${statusId}`,
});
if (isPublic && !isLocal) {
menu.push({
text: intl.formatMessage(messages.openOriginalPage),
href: statusUrl,
});
}
menu.push({
text: intl.formatMessage(messages.copy),
action: onStatusInteraction('copy'),
});
if (isPublic && 'share' in navigator) {
menu.push({
text: intl.formatMessage(messages.share),
action: () => {
void navigator.share({
url: statusUrl,
});
},
});
}
if (interactions.embed) {
menu.push({
text: intl.formatMessage(messages.embed),
action: onStatusInteraction('embed'),
});
}
if (!isLoggedIn) {
return menu;
}
if (quickBoosting) {
menu.push(null);
const quoteItem = quoteItemState(conditions);
menu.push({
text: intl.formatMessage(quoteItem.title),
description: quoteItem.meta
? intl.formatMessage(quoteItem.meta)
: undefined,
disabled: quoteItem.disabled,
action: onStatusInteraction('quote'),
});
}
menu.push(null);
if (interactions.pin) {
menu.push({
text: intl.formatMessage(status.pinned ? messages.unpin : messages.pin),
action: onStatusInteraction('pin'),
});
menu.push(null);
}
if (interactions.mute || withDismiss) {
menu.push({
text: intl.formatMessage(
status.muted ? messages.unmuteConversation : messages.muteConversation,
),
action: onStatusInteraction('mute'),
});
if (interactions.editQuotePolicy && !isRedesignEnabled()) {
menu.push({
text: intl.formatMessage(messages.quotePolicyChange),
action: onStatusInteraction('editQuotePolicy'),
});
}
menu.push(null);
}
if (interactions.edit && interactions.delete && interactions.redraft) {
menu.push({
text: intl.formatMessage(messages.edit),
action: onStatusInteraction('edit'),
});
menu.push({
text: intl.formatMessage(messages.delete),
action: onStatusInteraction('delete'),
dangerous: true,
});
menu.push({
text: intl.formatMessage(messages.redraft),
action: onStatusInteraction('redraft'),
dangerous: true,
});
}
if (isMine || !account) {
// Add the filter to handle the edge case of not having account data.
if (interactions.filter) {
menu.push(null);
menu.push({
text: intl.formatMessage(messages.filter),
action: onStatusInteraction('filter'),
dangerous: true,
});
}
return menu;
}
if (!account.invalid_handle) {
menu.push({
text: intl.formatMessage(messages.mention, {
name: account.username,
}),
action: () => {
dispatch(mentionCompose(account));
},
});
menu.push({
text: intl.formatMessage(messages.direct, {
name: account.username,
}),
action: () => {
dispatch(directCompose(account));
},
});
menu.push(null);
}
if (interactions.revokeQuote) {
menu.push({
text: intl.formatMessage(messages.revokeQuote, {
name: account.username,
}),
action: onStatusInteraction('revokeQuote'),
dangerous: true,
});
}
const isMuted = !!relationship?.get('muting');
menu.push({
text: intl.formatMessage(isMuted ? messages.unmute : messages.mute, {
name: account.username,
}),
action: () => {
if (isMuted) {
dispatch(unmuteAccount(account.id));
} else {
dispatch(muteAccount(account.id));
}
},
dangerous: !isMuted,
});
const isBlocking = !!relationship?.blocking;
menu.push({
text: intl.formatMessage(isBlocking ? messages.unblock : messages.block, {
name: account.username,
}),
action: () => {
if (isBlocking) {
dispatch(unblockAccount(account.id));
} else {
dispatch(initBlockModal(account));
}
},
dangerous: !isBlocking,
});
if (interactions.filter) {
menu.push(null);
menu.push({
text: intl.formatMessage(messages.filter),
action: onStatusInteraction('filter'),
dangerous: true,
});
}
menu.push(null);
menu.push({
text: intl.formatMessage(messages.report, {
name: account.username,
}),
action: onStatusInteraction('report'),
dangerous: true,
});
const domain = account.acct.split('@')[1];
if (!isLocal) {
menu.push(null);
const isDomainBlocking = !!relationship?.domain_blocking;
menu.push({
text: intl.formatMessage(
isDomainBlocking ? messages.unblockDomain : messages.blockDomain,
{ domain },
),
action: () => {
if (isDomainBlocking) {
dispatch(unblockDomain(domain));
} else {
dispatch(initDomainBlockModal(account));
}
},
dangerous: !isDomainBlocking,
});
}
const canManageUsers =
(permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS;
const canManageFederation =
(permissions & PERMISSION_MANAGE_FEDERATION) ===
PERMISSION_MANAGE_FEDERATION && !isLocal;
if (!canManageUsers && !canManageFederation) {
return menu;
}
menu.push(null);
if (canManageUsers) {
menu.push({
text: intl.formatMessage(messages.admin_account, {
name: account.username,
}),
href: `/admin/accounts/${status.account}`,
});
menu.push({
text: intl.formatMessage(messages.admin_status),
href: `/admin/accounts/${status.account}/statuses/${status.id}`,
});
}
if (canManageFederation) {
menu.push({
text: intl.formatMessage(messages.admin_domain, {
domain,
}),
href: `/admin/instances/${domain}`,
});
}
return menu;
}

View File

@@ -4,24 +4,59 @@ import { defineMessages, useIntl } from 'react-intl';
import { useHistory } from 'react-router';
import { mentionComposeById } from '@/mastodon/actions/compose';
import {
followAccount,
muteAccount,
unblockAccount,
unmuteAccount,
} from '@/mastodon/actions/accounts';
import { initBlockModal } from '@/mastodon/actions/blocks';
import {
mentionComposeById,
directCompose,
mentionCompose,
} from '@/mastodon/actions/compose';
import {
initDomainBlockModal,
unblockDomain,
} from '@/mastodon/actions/domain_blocks';
import type { StatusInteractionIntent } from '@/mastodon/actions/interactions_typed';
import { statusInteraction } from '@/mastodon/actions/interactions_typed';
import { openModal } from '@/mastodon/actions/modal';
import { toggleStatusSpoilers } from '@/mastodon/actions/statuses';
import { useRelationship } from '@/mastodon/hooks/useRelationship';
import { useExpandedStatus } from '@/mastodon/hooks/useStatus';
import { useToggle } from '@/mastodon/hooks/useToggle';
import { useIdentity } from '@/mastodon/identity_context';
import { quickBoosting } from '@/mastodon/initial_state';
import type { MenuItem as DropdownItem } from '@/mastodon/models/dropdown_menu';
import type { Relationship } from '@/mastodon/models/relationship';
import type {
AccountStatusShape,
ExpandedStatusShape,
StatusShape,
} from '@/mastodon/models/status';
import {
PERMISSION_MANAGE_FEDERATION,
PERMISSION_MANAGE_USERS,
} from '@/mastodon/permissions';
import { selectStatusFilters } from '@/mastodon/selectors/filters';
import type {
StatusConditions,
StatusInteractionsAllowed,
} from '@/mastodon/selectors/statuses';
import {
selectStatusConditions,
selectStatusInteractionsAllowed,
} from '@/mastodon/selectors/statuses';
import { useAppSelector, useAppDispatch } from '@/mastodon/store';
import type { AppDispatch } from '@/mastodon/store';
import { isRedesignEnabled } from '@/mastodon/utils/environment';
import type { OnElementHandler } from '@/mastodon/utils/html';
import { FOCUS_TARGET } from '../navigation_focus_target';
import { quoteItemState } from './boost_button_utils';
import { useElementHandledLink } from './handled_link';
import type { StatusContextType } from './types';
@@ -34,19 +69,6 @@ export function useStatusContext() {
return use(StatusContext);
}
const messages = defineMessages({
quote_noun: {
id: 'status.quote_noun',
defaultMessage: 'Quote',
description: 'Quote as a noun',
},
contains_quote: {
id: 'status.contains_quote',
defaultMessage: 'Contains quote',
},
boosted: { id: 'status.reblogged_by', defaultMessage: '{name} boosted' },
});
export function useStatusHandlers({
status,
contextType,
@@ -208,6 +230,19 @@ export function useStatusHandlers({
}
export type StatusHandlers = ReturnType<typeof useStatusHandlers>;
const screenReaderMessages = defineMessages({
quote_noun: {
id: 'status.quote_noun',
defaultMessage: 'Quote',
description: 'Quote as a noun',
},
contains_quote: {
id: 'status.contains_quote',
defaultMessage: 'Contains quote',
},
boosted: { id: 'status.reblogged_by', defaultMessage: '{name} boosted' },
});
const domParser = new DOMParser();
export function useTextForScreenReader({
statusId,
@@ -232,12 +267,14 @@ export function useTextForScreenReader({
.documentElement.textContent;
const values = [
isQuote ? intl.formatMessage(messages.quote_noun) : undefined,
isQuote ? intl.formatMessage(screenReaderMessages.quote_noun) : undefined,
displayName.length === 0
? status.account.acct.split('@')[0]
: displayName,
spoilerText && status.hidden ? spoilerText : contentText,
status.quote ? intl.formatMessage(messages.contains_quote) : undefined,
status.quote
? intl.formatMessage(screenReaderMessages.contains_quote)
: undefined,
intl.formatDate(status.created_at, {
hour: '2-digit',
minute: '2-digit',
@@ -246,7 +283,7 @@ export function useTextForScreenReader({
}),
status.account.acct,
reblogAcct
? intl.formatMessage(messages.boosted, { name: reblogAcct })
? intl.formatMessage(screenReaderMessages.boosted, { name: reblogAcct })
: false,
].filter((val) => !!val);
@@ -300,3 +337,451 @@ export const onStatusLinksDisabled: OnElementHandler<AccountStatusShape> = (
}
return undefined;
};
const menuMessages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' },
edit: { id: 'status.edit', defaultMessage: 'Edit' },
follow: { id: 'status.follow', defaultMessage: 'Follow @{name}' },
direct: { id: 'status.direct', defaultMessage: 'Privately mention @{name}' },
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
muteBoosts: {
id: 'account.mute_boosts',
defaultMessage: 'Mute boosts from @{name}',
},
unmuteBoosts: {
id: 'account.unmute_boosts',
defaultMessage: 'Unmute boosts from @{name}',
},
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
share: { id: 'status.share', defaultMessage: 'Share' },
open: { id: 'status.open', defaultMessage: 'Expand this status' },
report: { id: 'status.report', defaultMessage: 'Report @{name}' },
muteConversation: {
id: 'status.mute_conversation',
defaultMessage: 'Mute conversation',
},
unmuteConversation: {
id: 'status.unmute_conversation',
defaultMessage: 'Unmute conversation',
},
pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
embed: { id: 'status.embed', defaultMessage: 'Get embed code' },
admin_account: {
id: 'status.admin_account',
defaultMessage: 'Open moderation interface for @{name}',
},
admin_status: {
id: 'status.admin_status',
defaultMessage: 'Open this post in the moderation interface',
},
admin_domain: {
id: 'status.admin_domain',
defaultMessage: 'Open moderation interface for {domain}',
},
copy: { id: 'status.copy', defaultMessage: 'Copy link to post' },
blockDomain: {
id: 'account.block_domain',
defaultMessage: 'Block domain {domain}',
},
unblockDomain: {
id: 'account.unblock_domain',
defaultMessage: 'Unblock domain {domain}',
},
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
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',
},
});
export function useStatusMenuActions({
status,
contextType,
withDismiss = false,
}: {
status: Omit<AccountStatusShape, 'reblog'>;
withDismiss?: boolean;
contextType?: StatusContextType;
}) {
const account = status.account;
const { permissions } = useIdentity();
const relationship = useRelationship(account.id);
const intl = useIntl();
const dispatch = useAppDispatch();
const conditions = useAppSelector((state) =>
selectStatusConditions(state, status.id),
);
const interactions = useAppSelector((state) =>
selectStatusInteractionsAllowed(state, status.id),
);
const statusInteractionFactory = useCallback(
(intent: StatusInteractionIntent) => {
return () => {
dispatch(
statusInteraction({ statusId: status.id, contextType, intent }),
);
};
},
[contextType, dispatch, status.id],
);
return useMemo(
() =>
getMenuItems({
status,
conditions,
interactions,
onStatusInteraction: statusInteractionFactory,
withDismiss,
permissions,
intl,
relationship,
contextType,
dispatch,
}),
[
status,
conditions,
interactions,
statusInteractionFactory,
withDismiss,
permissions,
intl,
relationship,
contextType,
dispatch,
],
);
}
interface MenuItemsParams {
status: AccountStatusShape;
conditions: StatusConditions;
interactions: StatusInteractionsAllowed;
onStatusInteraction: (intent: StatusInteractionIntent) => () => void;
withDismiss?: boolean;
permissions: number;
intl: ReturnType<typeof useIntl>;
relationship?: Relationship | null;
contextType?: StatusContextType;
dispatch: AppDispatch;
}
function getMenuItems({
status,
conditions,
interactions,
onStatusInteraction,
withDismiss,
permissions,
intl,
relationship,
contextType,
dispatch,
}: MenuItemsParams) {
const menu: DropdownItem[] = [];
const statusId = status.id;
const account = status.account;
const statusUrl = status.url ?? status.uri;
const { isPublic, isLocal, isLoggedIn, isMine } = conditions;
if (contextType !== 'detailed') {
menu.push({
text: intl.formatMessage(menuMessages.open),
to: `/@${account.acct}/${statusId}`,
});
}
if (isPublic && !isLocal) {
menu.push({
text: intl.formatMessage(menuMessages.openOriginalPage),
href: statusUrl,
});
}
menu.push({
text: intl.formatMessage(menuMessages.copy),
action: onStatusInteraction('copy'),
});
if (isPublic && 'share' in navigator) {
menu.push({
text: intl.formatMessage(menuMessages.share),
action: () => {
void navigator.share({
url: statusUrl,
});
},
});
}
if (interactions.embed) {
menu.push({
text: intl.formatMessage(menuMessages.embed),
action: onStatusInteraction('embed'),
});
}
if (!isLoggedIn) {
return menu;
}
if (quickBoosting) {
menu.push(null);
const quoteItem = quoteItemState(conditions);
menu.push({
text: intl.formatMessage(quoteItem.title),
description: quoteItem.meta
? intl.formatMessage(quoteItem.meta)
: undefined,
disabled: quoteItem.disabled,
action: onStatusInteraction('quote'),
});
}
menu.push(null);
if (interactions.pin) {
menu.push({
text: intl.formatMessage(
status.pinned ? menuMessages.unpin : menuMessages.pin,
),
action: onStatusInteraction('pin'),
});
menu.push(null);
}
if (interactions.mute || withDismiss) {
menu.push({
text: intl.formatMessage(
status.muted
? menuMessages.unmuteConversation
: menuMessages.muteConversation,
),
action: onStatusInteraction('mute'),
});
if (interactions.editQuotePolicy && !isRedesignEnabled()) {
menu.push({
text: intl.formatMessage(menuMessages.quotePolicyChange),
action: onStatusInteraction('editQuotePolicy'),
});
}
menu.push(null);
}
if (interactions.edit && interactions.delete && interactions.redraft) {
menu.push({
text: intl.formatMessage(menuMessages.edit),
action: onStatusInteraction('edit'),
});
menu.push({
text: intl.formatMessage(menuMessages.delete),
action: onStatusInteraction('delete'),
dangerous: true,
});
menu.push({
text: intl.formatMessage(menuMessages.redraft),
action: onStatusInteraction('redraft'),
dangerous: true,
});
}
if (isMine) {
// Add the filter to handle the edge case of not having account data.
if (interactions.filter) {
menu.push(null);
menu.push({
text: intl.formatMessage(menuMessages.filter),
action: onStatusInteraction('filter'),
dangerous: true,
});
}
return menu;
}
if (!account.invalid_handle) {
if (relationship && !relationship.following) {
menu.push({
text: intl.formatMessage(menuMessages.follow, {
name: account.username,
}),
action: () => {
dispatch(followAccount(account.id));
},
});
}
menu.push({
text: intl.formatMessage(menuMessages.mention, {
name: account.username,
}),
action: () => {
dispatch(mentionCompose(account));
},
});
menu.push({
text: intl.formatMessage(menuMessages.direct, {
name: account.username,
}),
action: () => {
dispatch(directCompose(account));
},
});
menu.push(null);
}
if (interactions.revokeQuote) {
menu.push({
text: intl.formatMessage(menuMessages.revokeQuote, {
name: account.username,
}),
action: onStatusInteraction('revokeQuote'),
dangerous: true,
});
}
if (relationship?.following && !relationship.muting) {
const isMutingBoosts = relationship.showing_reblogs;
menu.push({
text: intl.formatMessage(
isMutingBoosts ? menuMessages.muteBoosts : menuMessages.unmuteBoosts,
{ name: account.username },
),
action: () => {
dispatch(
followAccount(account.id, {
reblogs: !isMutingBoosts,
}),
);
},
dangerous: true,
});
}
const isMuted = !!relationship?.muting;
menu.push({
text: intl.formatMessage(
isMuted ? menuMessages.unmute : menuMessages.mute,
{
name: account.username,
},
),
action: () => {
if (isMuted) {
dispatch(unmuteAccount(account.id));
} else {
dispatch(muteAccount(account.id));
}
},
dangerous: !isMuted,
});
const isBlocking = !!relationship?.blocking;
menu.push({
text: intl.formatMessage(
isBlocking ? menuMessages.unblock : menuMessages.block,
{
name: account.username,
},
),
action: () => {
if (isBlocking) {
dispatch(unblockAccount(account.id));
} else {
dispatch(initBlockModal(account));
}
},
dangerous: !isBlocking,
});
if (interactions.filter) {
menu.push(null);
menu.push({
text: intl.formatMessage(menuMessages.filter),
action: onStatusInteraction('filter'),
dangerous: true,
});
}
menu.push(null);
menu.push({
text: intl.formatMessage(menuMessages.report, {
name: account.username,
}),
action: onStatusInteraction('report'),
dangerous: true,
});
const domain = account.acct.split('@')[1];
if (!isLocal) {
menu.push(null);
const isDomainBlocking = !!relationship?.domain_blocking;
menu.push({
text: intl.formatMessage(
isDomainBlocking
? menuMessages.unblockDomain
: menuMessages.blockDomain,
{ domain },
),
action: () => {
if (isDomainBlocking) {
dispatch(unblockDomain(domain));
} else {
dispatch(initDomainBlockModal(account));
}
},
dangerous: !isDomainBlocking,
});
}
const canManageUsers =
(permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS;
const canManageFederation =
(permissions & PERMISSION_MANAGE_FEDERATION) ===
PERMISSION_MANAGE_FEDERATION && !isLocal;
if (!canManageUsers && !canManageFederation) {
return menu;
}
menu.push(null);
if (canManageUsers) {
menu.push({
text: intl.formatMessage(menuMessages.admin_account, {
name: account.username,
}),
href: `/admin/accounts/${account.id}`,
});
menu.push({
text: intl.formatMessage(menuMessages.admin_status),
href: `/admin/accounts/${account.id}/statuses/${status.id}`,
});
}
if (canManageFederation) {
menu.push({
text: intl.formatMessage(menuMessages.admin_domain, {
domain,
}),
href: `/admin/instances/${domain}`,
});
}
return menu;
}

View File

@@ -52,67 +52,68 @@ export const StatusMeta: React.FC<{
return (
<div className={classes.meta}>
<FormattedMessage
id='status.replies_count'
defaultMessage='{count, plural, one {{counter} reply} other {{counter} replies}}'
values={{
count: status.replies_count,
counter: <AnimatedNumber value={status.replies_count} />,
}}
tagName='span'
/>
&bull;
<Link to={`${baseStatusLink}/quotes`}>
<ol>
<FormattedMessage
id='status.quotes_count'
defaultMessage='{count, plural, one {{counter} quote} other {{counter} quotes}}'
id='status.replies_count'
defaultMessage='{count, plural, one {{counter} reply} other {{counter} replies}}'
values={{
count: status.quotes_count,
counter: <AnimatedNumber value={status.quotes_count} />,
count: status.replies_count,
counter: <AnimatedNumber value={status.replies_count} />,
}}
tagName='li'
/>
</Link>
&bull;
<Link to={`${baseStatusLink}/reblogs`}>
<FormattedMessage
id='status.reblogs_count'
defaultMessage='{count, plural, one {{counter} boost} other {{counter} boosts}}'
values={{
count: status.reblogs_count,
counter: <AnimatedNumber value={status.reblogs_count} />,
}}
/>
</Link>
&bull;
<Link
to={`${baseStatusLink}/favourites`}
className={classes.actionsButtonGap}
>
<FormattedMessage
id='status.likes_count'
defaultMessage='{count, plural, one {{counter} like} other {{counter} likes}}'
values={{
count: status.favourites_count,
counter: <AnimatedNumber value={status.favourites_count} />,
}}
/>
</Link>
<FormattedDateWrapper
value={createdAt}
year='numeric'
month='short'
day='2-digit'
hour='2-digit'
minute='2-digit'
/>
{applicationDisplay && (
<>
&bull;
{applicationDisplay}
</>
)}
&bull;
<span>{visibility}</span>
<li>
<Link to={`${baseStatusLink}/quotes`}>
<FormattedMessage
id='status.quotes_count'
defaultMessage='{count, plural, one {{counter} quote} other {{counter} quotes}}'
values={{
count: status.quotes_count,
counter: <AnimatedNumber value={status.quotes_count} />,
}}
/>
</Link>
</li>
<li>
<Link to={`${baseStatusLink}/reblogs`}>
<FormattedMessage
id='status.reblogs_count'
defaultMessage='{count, plural, one {{counter} boost} other {{counter} boosts}}'
values={{
count: status.reblogs_count,
counter: <AnimatedNumber value={status.reblogs_count} />,
}}
/>
</Link>
</li>
<li>
<Link to={`${baseStatusLink}/favourites`}>
<FormattedMessage
id='status.likes_count'
defaultMessage='{count, plural, one {{counter} like} other {{counter} likes}}'
values={{
count: status.favourites_count,
counter: <AnimatedNumber value={status.favourites_count} />,
}}
/>
</Link>
</li>
</ol>
<ol>
<li>
<FormattedDateWrapper
value={createdAt}
year='numeric'
month='short'
day='2-digit'
hour='2-digit'
minute='2-digit'
/>
</li>
{applicationDisplay && <li>{applicationDisplay}</li>}
<li>{visibility}</li>
</ol>
</div>
);
};

View File

@@ -225,7 +225,7 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
statusId={status.id}
withDismiss={withDismiss}
withCounters={withCounters}
onlyInteractions={variant === 'page'}
onlyResponses={variant === 'page'}
/>
)}

View File

@@ -232,7 +232,7 @@
@include mixins.type-micro;
display: flex;
gap: var(--space-1);
justify-content: space-between;
color: var(--color-text-secondary);
padding-block-start: var(--space-2);
margin-block-start: var(--space-2);
@@ -240,6 +240,17 @@
margin-inline: calc(-1 * var(--space-4));
padding-inline: var(--space-4);
> ol {
display: flex;
gap: var(--space-1);
}
li:not(:first-child) {
&::before {
content: '• ';
}
}
a {
color: inherit;
text-decoration: none;

View File

@@ -6,28 +6,38 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
import { useParams } from 'react-router';
import { BookmarkSimpleIcon } from '@phosphor-icons/react';
import { Helmet } from '@unhead/react/helmet';
import { statusInteraction } from '@/mastodon/actions/interactions_typed';
import { ToggleIconButton } from '@/mastodon/components/button/redesign';
import { Column } from '@/mastodon/components/column';
import {
ColumnHeader,
ColumnSettingsMenu,
} from '@/mastodon/components/column_header';
import { DisplayNameSimple } from '@/mastodon/components/display_name/simple';
import { useIconWeight } from '@/mastodon/components/icon';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import {
FOCUS_TARGET,
NavigationFocusTarget,
} from '@/mastodon/components/navigation_focus_target';
import { StatusActionItem } from '@/mastodon/components/status/action_bar';
import {
useStatusMenuActions,
useTextForScreenReader,
} from '@/mastodon/components/status/hooks';
import { StatusRedesign as Status } from '@/mastodon/components/status/status';
import { ScrollContainer } from '@/mastodon/containers/scroll_container';
import type { ShouldUpdateScrollFn } from '@/mastodon/containers/scroll_container/default_should_update_scroll';
import { useExpandedStatus } from '@/mastodon/hooks/useStatus';
import type { ExpandedStatusShape } from '@/mastodon/models/status';
import {
getAncestorsIds,
getDescendantsIds,
} from '@/mastodon/selectors/contexts';
import { useAppSelector } from '@/mastodon/store';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { BundleColumnError } from '../ui/components/bundle_column_error';
import { useColumnsContext } from '../ui/util/columns_context';
@@ -86,6 +96,8 @@ export const StatusPage: React.FC = () => {
getDescendantsIds(state, statusId),
);
const screenReaderText = useTextForScreenReader({ statusId });
const statusFocusRef = useRef<HTMLDivElement>(null);
const shouldUpdateScroll: ShouldUpdateScrollFn = useCallback(
(prevLocation, location) => {
@@ -108,6 +120,21 @@ export const StatusPage: React.FC = () => {
[],
);
const dispatch = useAppDispatch();
const handleBookmarkClick = useCallback(() => {
dispatch(
statusInteraction({
statusId,
intent: 'bookmark',
contextType: 'detailed',
}),
);
}, [dispatch, statusId]);
const bookmarkIcon = useIconWeight(
BookmarkSimpleIcon,
status?.bookmarked && 'fill',
);
if (isLoading) {
return (
<Column>
@@ -166,17 +193,37 @@ export const StatusPage: React.FC = () => {
withBackButton
title={columnTitle}
extraButtons={
<ColumnSettingsMenu
label={
<FormattedMessage
id='status.options'
defaultMessage='Post options'
/>
}
>
WIP: This menu will contain post actions from the new Status
component
</ColumnSettingsMenu>
<>
<ToggleIconButton
size='sm'
variant='ghost'
active={status.bookmarked}
icon={bookmarkIcon}
onClick={handleBookmarkClick}
>
{!status.bookmarked ? (
<FormattedMessage
id='status.bookmark'
defaultMessage='Bookmark'
/>
) : (
<FormattedMessage
id='status.remove_bookmark'
defaultMessage='Remove bookmark'
/>
)}
</ToggleIconButton>
<ColumnSettingsMenu
label={
<FormattedMessage
id='status.options'
defaultMessage='Post options'
/>
}
>
<StatusMenuItems status={status} />
</ColumnSettingsMenu>
</>
}
/>
@@ -204,6 +251,7 @@ export const StatusPage: React.FC = () => {
className={classes.mainStatus}
tabIndex={0}
ref={statusFocusRef}
aria-label={screenReaderText}
>
<Status id={statusId} contextType='detailed' />
</NavigationFocusTarget>
@@ -215,10 +263,17 @@ export const StatusPage: React.FC = () => {
)}
<div className={classes.threadEnd}>
<FormattedMessage
id='status.thread_end'
defaultMessage='You’ve reached the end of the conversation.'
/>
{descendantIds.length > 0 ? (
<FormattedMessage
id='status.thread_end'
defaultMessage='You’ve reached the end of the conversation.'
/>
) : (
<FormattedMessage
id='status.thread_none'
defaultMessage='Nothing else has been added to the conversation yet.'
/>
)}
</div>
<RefreshController
@@ -241,6 +296,16 @@ export const StatusPage: React.FC = () => {
);
};
const StatusMenuItems: React.FC<{ status: ExpandedStatusShape }> = ({
status,
}) => {
const menu = useStatusMenuActions({ status, contextType: 'detailed' });
return menu.map((item, index) => (
<StatusActionItem key={index} item={item} />
));
};
const StatusRelativeList: React.FC<{
statusIds: string[];
rootId: string;

View File

@@ -107,6 +107,7 @@
"account.menu.unmute": "Unmute account",
"account.moved_to": "{name} has indicated that their new account is now:",
"account.mute": "Mute @{name}",
"account.mute_boosts": "Mute boosts from @{name}",
"account.mute_notifications_short": "Mute notifications",
"account.mute_short": "Mute",
"account.muted": "Muted",
@@ -145,6 +146,7 @@
"account.unendorse": "Don't feature on profile",
"account.unfollow": "Unfollow",
"account.unmute": "Unmute @{name}",
"account.unmute_boosts": "Unmute boosts from @{name}",
"account.unmute_notifications_short": "Unmute notifications",
"account.unmute_short": "Unmute",
"account.view_profile": "View profile",
@@ -1390,6 +1392,7 @@
"status.favourite": "Favorite",
"status.favourites_count": "{count, plural, one {{counter} favorite} other {{counter} favorites}}",
"status.filter": "Filter this post",
"status.follow": "Follow @{name}",
"status.header.message_to_me": "{author} to You {count, plural, =0 {} one {+ # other} other {+ # others}}",
"status.header.to_followers": "{author} to Followers {count, plural, =0 {} one {+ # other} other {+ # others}}",
"status.history.created": "{name} created {date}",
@@ -1458,6 +1461,7 @@
"status.show_more_all": "Show more for all",
"status.show_original": "Show original",
"status.thread_end": "You’ve reached the end of the conversation.",
"status.thread_none": "Nothing else has been added to the conversation yet.",
"status.title": "Post by {name}",
"status.title.message": "Message by {name}",
"status.title.with_attachments": "{user} posted {attachmentCount, plural, one {an attachment} other {{attachmentCount} attachments}}",