mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-27 23:38:52 -05:00
Status redesign: Update menu (#40636)
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -372,7 +372,6 @@ function getMenuItems({
|
||||
),
|
||||
action: () => {
|
||||
dispatch(
|
||||
// @ts-expect-error this action is not typed yet
|
||||
followAccount(account.id, {
|
||||
reblogs: !relationship.showing_reblogs,
|
||||
}),
|
||||
|
||||
@@ -141,7 +141,6 @@ export function useFollowButton({
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
// @ts-expect-error this action is not typed yet
|
||||
dispatch(followAccount(accountId, { ref: reference }));
|
||||
}
|
||||
}, [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
/>
|
||||
•
|
||||
<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>
|
||||
•
|
||||
<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>
|
||||
•
|
||||
<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 && (
|
||||
<>
|
||||
•
|
||||
{applicationDisplay}
|
||||
</>
|
||||
)}
|
||||
•
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -225,7 +225,7 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
|
||||
statusId={status.id}
|
||||
withDismiss={withDismiss}
|
||||
withCounters={withCounters}
|
||||
onlyInteractions={variant === 'page'}
|
||||
onlyResponses={variant === 'page'}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
Reference in New Issue
Block a user