Files
Mastodon/app/javascript/mastodon/components/status/action_bar.tsx
T

600 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type React from 'react';
import { useCallback, useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl';
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 { quickBoosting } from '@/mastodon/initial_state';
import type { Account } from '@/mastodon/models/account';
import type { MenuItem } 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 { useAppDispatch, useAppSelector } from '@/mastodon/store';
import BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg?react';
import BookmarkBorderIcon from '@/material-icons/400-24px/bookmark.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react';
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
import StarBorderIcon from '@/material-icons/400-24px/star.svg?react';
import { Dropdown } from '../dropdown_menu';
import { IconButton } from '../icon_button';
import { RemoveQuoteHint } from '../status_action_bar/remove_quote_hint';
import { BoostButton } from './boost_button';
import { quoteItemState } from './boost_button_utils';
import type { StatusContextType } from './types';
interface StatusActionBarProps {
statusId: string;
contextType?: StatusContextType;
withDismiss?: boolean;
withCounters?: boolean;
scrollKey?: string;
}
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' },
more: { id: 'status.more', defaultMessage: 'More' },
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
favourite: { id: 'status.favourite', defaultMessage: 'Favorite' },
removeFavourite: {
id: 'status.remove_favourite',
defaultMessage: 'Remove from favorites',
},
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
removeBookmark: {
id: 'status.remove_bookmark',
defaultMessage: 'Remove bookmark',
},
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,
contextType,
withDismiss,
withCounters,
scrollKey,
}) => {
const status = useStatus(statusId);
const quotedAccountId = useAppSelector(
(state) =>
state.statuses.getIn([status?.quote?.quoted_status, 'account']) ?? null,
);
const currentAccountId = useCurrentAccountId();
const dispatch = useAppDispatch();
const handleReplyClick = useCallback(() => {
dispatch(statusInteraction({ statusId, intent: 'reply' }));
}, [dispatch, statusId]);
const handleFavouriteClick = useCallback(() => {
dispatch(statusInteraction({ statusId, intent: 'favourite' }));
}, [dispatch, statusId]);
const handleBookmarkClick = useCallback(() => {
dispatch(statusInteraction({ statusId, intent: 'bookmark' }));
}, [dispatch, statusId]);
const intl = useIntl();
if (!status) {
return null;
}
const isReply =
!status.in_reply_to_id || status.in_reply_to_account_id === status.account;
const replyTitle = isReply
? intl.formatMessage(messages.reply)
: intl.formatMessage(messages.replyAll);
const replyIcon = isReply ? 'reply' : 'reply-all';
const replyIconComponent = isReply ? ReplyIcon : ReplyAllIcon;
const bookmarkTitle = intl.formatMessage(
status.bookmarked ? messages.removeBookmark : messages.bookmark,
);
const favouriteTitle = intl.formatMessage(
status.favourited ? messages.removeFavourite : messages.favourite,
);
const isQuotingMe = quotedAccountId === currentAccountId;
const shouldShowQuoteRemovalHint =
isQuotingMe && contextType === 'notifications';
return (
<div className='status__action-bar'>
<div className='status__action-bar__button-wrapper'>
<IconButton
className='status__action-bar__button'
title={replyTitle}
icon={replyIcon}
iconComponent={replyIconComponent}
onClick={handleReplyClick}
counter={status.replies_count}
/>
</div>
<div className='status__action-bar__button-wrapper'>
<BoostButton statusId={statusId} counters={withCounters} />
</div>
<div className='status__action-bar__button-wrapper'>
<IconButton
className='status__action-bar__button star-icon'
animate
active={status.favourited}
title={favouriteTitle}
icon='star'
iconComponent={status.favourited ? StarIcon : StarBorderIcon}
onClick={handleFavouriteClick}
counter={withCounters ? status.favourites_count : undefined}
/>
</div>
<div className='status__action-bar__button-wrapper'>
<IconButton
className='status__action-bar__button bookmark-icon'
disabled={!currentAccountId}
active={status.bookmarked}
title={bookmarkTitle}
icon='bookmark'
iconComponent={status.bookmarked ? BookmarkIcon : BookmarkBorderIcon}
onClick={handleBookmarkClick}
/>
</div>
<RemoveQuoteHint
className='status__action-bar__button-wrapper'
canShowHint={shouldShowQuoteRemovalHint}
>
{(dismissQuoteHint) => (
<StatusActionMenu
dismissQuoteHint={dismissQuoteHint}
status={status}
contextType={contextType}
withDismiss={withDismiss}
scrollKey={scrollKey}
/>
)}
</RemoveQuoteHint>
</div>
);
};
const StatusActionMenu: React.FC<{
dismissQuoteHint: () => void;
status: StatusShape;
contextType?: StatusContextType;
scrollKey?: string;
withDismiss?: boolean;
}> = ({ status, dismissQuoteHint, contextType, scrollKey, withDismiss }) => {
const account = useAppSelector((state) => state.accounts.get(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],
);
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 handleOpen = useCallback(() => {
// Replicates needsStatusRefresh of the Dropdown component.
if (quickBoosting && !status.quote_approval) {
dispatch(
fetchStatus(status.id, { forceFetch: true, alsoFetchContext: false }),
);
}
dismissQuoteHint();
return true;
}, [dismissQuoteHint, dispatch, status.id, status.quote_approval]);
return (
<Dropdown scrollKey={scrollKey} items={menu} onOpen={handleOpen}>
<IconButton
className='status__action-bar__button'
icon='ellipsis-h'
iconComponent={MoreHorizIcon}
title={intl.formatMessage(messages.more)}
/>
</Dropdown>
);
};
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: MenuItem[] = [];
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: () => {
void navigator.clipboard.writeText(statusUrl);
},
});
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) {
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;
}
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;
}