Status Action Bar (#39702)

This commit is contained in:
Echo
2026-07-06 12:00:41 +02:00
committed by GitHub
parent cded1fc58d
commit 85c01b8aa9
12 changed files with 748 additions and 124 deletions

View File

@@ -0,0 +1,597 @@
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,
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}
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;
}

View File

@@ -5,6 +5,7 @@ import type { DeployPictureInPictureCallback } from '@/mastodon/actions/picture_
import { deployPictureInPicture } from '@/mastodon/actions/picture_in_picture';
import { CollectionPreviewCard } from '@/mastodon/features/collections/components/collection_preview_card';
import Card from '@/mastodon/features/status/components/card';
import { useExpandedStatus } from '@/mastodon/hooks/useStatus';
import { displayMedia } from '@/mastodon/initial_state';
import type {
MediaAttachment,
@@ -12,7 +13,6 @@ import type {
} from '@/mastodon/models/status';
import { isMediaAttachmentOfType } from '@/mastodon/models/status';
import {
selectExpandedStatus,
selectMediaMatchFilters,
selectPictureInPicture,
} from '@/mastodon/selectors/statuses';
@@ -25,10 +25,7 @@ export const StatusAttachments: React.FC<{
statusId: string;
contextType?: string;
}> = ({ statusId, contextType }) => {
// Selectors
const status = useAppSelector((state) =>
selectExpandedStatus(state, statusId),
);
const status = useExpandedStatus(statusId);
if (!status) {
return null;

View File

@@ -38,30 +38,35 @@ const meta = {
},
},
render: (args) => (
<BoostButton status={argsToStatus(args)} counters={args.reblogCount > 0} />
<BoostButton statusId='1' counters={args.reblogCount > 0} />
),
parameters: {
stateFn({
reblogCount,
visibility,
quoteAllowed,
alreadyBoosted,
}: StoryProps) {
return {
statuses: {
'1': statusFactoryImmutable({
reblogs_count: reblogCount,
visibility,
reblogged: alreadyBoosted,
quote_approval: {
automatic: [],
manual: [],
current_user: quoteAllowed ? 'automatic' : 'denied',
},
}),
},
};
},
},
} satisfies Meta<StoryProps>;
export default meta;
function argsToStatus({
reblogCount,
visibility,
quoteAllowed,
alreadyBoosted,
}: StoryProps) {
return statusFactoryImmutable({
reblogs_count: reblogCount,
visibility,
reblogged: alreadyBoosted,
quote_approval: {
automatic: [],
manual: [],
current_user: quoteAllowed ? 'automatic' : 'denied',
},
});
}
type Story = StoryObj<typeof meta>;
export const Default: Story = {};

View File

@@ -11,28 +11,28 @@ import { quoteComposeById } from '@/mastodon/actions/compose_typed';
import { toggleReblog } from '@/mastodon/actions/interactions';
import { openModal } from '@/mastodon/actions/modal';
import { fetchStatus } from '@/mastodon/actions/statuses';
import { useStatus } from '@/mastodon/hooks/useStatus';
import { quickBoosting } from '@/mastodon/initial_state';
import type { ActionMenuItem } from '@/mastodon/models/dropdown_menu';
import type { Status } from '@/mastodon/models/status';
import { selectStatusConditions } from '@/mastodon/selectors/statuses';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import type { RenderItemFn } from '../dropdown_menu';
import { Dropdown, DropdownMenuItemContent } from '../dropdown_menu';
import { IconButton } from '../icon_button';
import {
boostItemState,
messages,
quoteItemState,
selectStatusState,
} from './boost_button_utils';
import { boostItemState, messages, quoteItemState } from './boost_button_utils';
const StandaloneBoostButton: FC<ReblogButtonProps> = ({ status, counters }) => {
const StandaloneBoostButton: FC<ReblogButtonProps> = ({
statusId,
counters,
}) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const status = useStatus(statusId);
const statusState = useAppSelector((state) =>
selectStatusState(state, status),
selectStatusConditions(state, statusId),
);
const { title, meta, iconComponent, disabled } = useMemo(
() => boostItemState(statusState),
@@ -42,36 +42,35 @@ const StandaloneBoostButton: FC<ReblogButtonProps> = ({ status, counters }) => {
const handleClick: MouseEventHandler = useCallback(
(event) => {
if (statusState.isLoggedIn) {
dispatch(toggleReblog(status.get('id') as string, event.shiftKey));
dispatch(toggleReblog(statusId, event.shiftKey));
} else {
dispatch(
openModal({
modalType: 'INTERACTION',
modalProps: {
intent: 'reblog',
accountId: status.getIn(['account', 'id']),
url: status.get('uri'),
accountId: status?.account,
url: status?.uri,
},
}),
);
}
},
[dispatch, status, statusState.isLoggedIn],
[dispatch, status, statusId, statusState.isLoggedIn],
);
return (
<IconButton
disabled={disabled}
active={!!status.get('reblogged')}
active={!!status?.reblogged}
title={intl.formatMessage(meta ?? title)}
icon='retweet'
iconComponent={iconComponent}
className='status__action-bar__button'
onClick={!disabled ? handleClick : undefined}
counter={
counters
? (status.get('reblogs_count') as number) +
(status.get('quotes_count') as number)
counters && status
? status.reblogs_count + status.quotes_count
: undefined
}
/>
@@ -88,22 +87,23 @@ const renderMenuItem: RenderItemFn<ActionMenuItem> = (item, index, onClick) => (
);
interface ReblogButtonProps {
status: Status;
statusId: string;
counters?: boolean;
}
type ActionMenuItemWithIcon = SetRequired<ActionMenuItem, 'icon'>;
const BoostOrQuoteMenu: FC<ReblogButtonProps> = ({ status, counters }) => {
const BoostOrQuoteMenu: FC<ReblogButtonProps> = ({ statusId, counters }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const status = useStatus(statusId);
const statusState = useAppSelector((state) =>
selectStatusState(state, status),
selectStatusConditions(state, statusId),
);
const {
isLoggedIn,
isReblogged,
isReblogAllowed,
isBoosted,
isBoostingAllowed,
isQuoteAutomaticallyAccepted,
isQuoteManuallyAccepted,
} = statusState;
@@ -111,11 +111,10 @@ const BoostOrQuoteMenu: FC<ReblogButtonProps> = ({ status, counters }) => {
const isMenuDisabled =
!isQuoteAutomaticallyAccepted &&
!isQuoteManuallyAccepted &&
!isReblogAllowed;
!isBoostingAllowed;
const statusId = status.get('id') as string;
const wasBoosted = !!status.get('reblogged');
const quoteApproval = status.get('quote_approval');
const wasBoosted = !!status?.reblogged;
const quoteApproval = status?.quote_approval;
const showLoginPrompt = useCallback(() => {
dispatch(
@@ -123,8 +122,8 @@ const BoostOrQuoteMenu: FC<ReblogButtonProps> = ({ status, counters }) => {
modalType: 'INTERACTION',
modalProps: {
intent: 'reblog',
accountId: status.getIn(['account', 'id']),
url: status.get('uri'),
accountId: status?.account,
url: status?.uri,
},
}),
);
@@ -170,7 +169,7 @@ const BoostOrQuoteMenu: FC<ReblogButtonProps> = ({ status, counters }) => {
}
if (event.shiftKey) {
dispatch(toggleReblog(status.get('id'), true));
dispatch(toggleReblog(statusId, true));
return false;
}
@@ -182,7 +181,7 @@ const BoostOrQuoteMenu: FC<ReblogButtonProps> = ({ status, counters }) => {
return true;
},
[dispatch, isLoggedIn, showLoginPrompt, status, quoteApproval, statusId],
[dispatch, isLoggedIn, showLoginPrompt, quoteApproval, statusId],
);
return (
@@ -202,12 +201,11 @@ const BoostOrQuoteMenu: FC<ReblogButtonProps> = ({ status, counters }) => {
className='status__action-bar__button'
iconComponent={boostIcon}
counter={
counters
? (status.get('reblogs_count') as number) +
(status.get('quotes_count') as number)
counters && status
? status.reblogs_count + status.quotes_count
: undefined
}
active={isReblogged}
active={isBoosted}
/>
</Dropdown>
);

View File

@@ -1,8 +1,7 @@
import { defineMessages } from 'react-intl';
import type { MessageDescriptor } from 'react-intl';
import type { Status, StatusVisibility } from '@/mastodon/models/status';
import { createAppSelector } from '@/mastodon/store';
import type { StatusConditions } from '@/mastodon/selectors/statuses';
import FormatQuote from '@/material-icons/400-24px/format_quote-fill.svg?react';
import FormatQuoteOff from '@/material-icons/400-24px/format_quote_off-fill.svg?react';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
@@ -62,42 +61,6 @@ export const messages = defineMessages({
},
});
export const selectStatusState = createAppSelector(
[
(state) => state.meta.get('me') as string | undefined,
(_, status: Status) => status,
],
(userId, status) => {
const isPublic = ['public', 'unlisted'].includes(
status.get('visibility') as StatusVisibility,
);
const isMineAndPrivate =
userId === status.getIn(['account', 'id']) &&
status.get('visibility') === 'private';
return {
isLoggedIn: !!userId,
isPublic,
isMine: userId === status.getIn(['account', 'id']),
isPrivateReblog:
userId === status.getIn(['account', 'id']) &&
status.get('visibility') === 'private',
isReblogged: !!status.get('reblogged'),
isReblogAllowed: isPublic || isMineAndPrivate,
isQuoteAutomaticallyAccepted:
status.getIn(['quote_approval', 'current_user']) === 'automatic' &&
(isPublic || isMineAndPrivate),
isQuoteManuallyAccepted:
status.getIn(['quote_approval', 'current_user']) === 'manual' &&
(isPublic || isMineAndPrivate),
isQuoteFollowersOnly:
status.getIn(['quote_approval', 'automatic', 0]) === 'followers' ||
status.getIn(['quote_approval', 'manual', 0]) === 'followers',
};
},
);
export type StatusState = ReturnType<typeof selectStatusState>;
export interface MenuItemState {
title: MessageDescriptor;
meta?: MessageDescriptor;
@@ -108,9 +71,9 @@ export interface MenuItemState {
export function boostItemState({
isPublic,
isPrivateReblog,
isReblogged,
}: StatusState): MenuItemState {
if (isReblogged) {
isBoosted,
}: StatusConditions): MenuItemState {
if (isBoosted) {
return {
title: messages.reblog_cancel,
iconComponent: isPublic ? RepeatActiveIcon : RepeatPrivateActiveIcon,
@@ -139,7 +102,7 @@ export function quoteItemState({
isQuoteManuallyAccepted,
isQuoteFollowersOnly,
isPublic,
}: StatusState): MenuItemState {
}: StatusConditions): MenuItemState {
const iconText: MenuItemState = {
title: messages.quote,
iconComponent: FormatQuote,

View File

@@ -6,10 +6,10 @@ import { FormattedMessage, useIntl } from 'react-intl';
import classnames from 'classnames';
import { toggleStatusCollapse } from '@/mastodon/actions/statuses';
import { useStatus } from '@/mastodon/hooks/useStatus';
import { useIdentity } from '@/mastodon/identity_context';
import { languages as preloadedLanguages } from '@/mastodon/initial_state';
import type { StatusTranslation } from '@/mastodon/models/status';
import { selectPlainStatus } from '@/mastodon/selectors/statuses';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
@@ -27,7 +27,7 @@ export const StatusContent: React.FC<{
onTranslate?: React.MouseEventHandler<HTMLButtonElement>;
collapsible?: boolean;
}> = ({ statusId, onClick, onTranslate, collapsible }) => {
const status = useAppSelector((state) => selectPlainStatus(state, statusId));
const status = useStatus(statusId);
const { signedIn } = useIdentity();
const targetLanguages = useAppSelector(
(state) =>

View File

@@ -26,7 +26,8 @@ import { IconButton } from '../icon_button';
import { injectIntl } from '../intl';
import { BoostButton } from '../status/boost_button';
import { RemoveQuoteHint } from './remove_quote_hint';
import { quoteItemState, selectStatusState } from '../status/boost_button_utils';
import { quoteItemState } from '../status/boost_button_utils';
import { selectStatusConditions } from '@/mastodon/selectors/statuses';
const messages = defineMessages({
@@ -71,7 +72,7 @@ const mapStateToProps = (state, { status }) => {
return ({
relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]),
quotedAccountId: quotedStatusId ? state.getIn(['statuses', quotedStatusId, 'account']) : null,
statusQuoteState: selectStatusState(state, status),
statusQuoteState: selectStatusConditions(state, status.get('id')),
});
};
@@ -393,7 +394,7 @@ class StatusActionBar extends ImmutablePureComponent {
<IconButton className='status__action-bar__button' title={replyTitle} icon={isReply ? 'reply' : replyIcon} iconComponent={isReply ? ReplyIcon : replyIconComponent} onClick={this.handleReplyClick} counter={status.get('replies_count')} />
</div>
<div className='status__action-bar__button-wrapper'>
<BoostButton status={status} counters={withCounters} />
<BoostButton statusId={status.get('id')} counters={withCounters} />
</div>
<div className='status__action-bar__button-wrapper'>
<IconButton className='status__action-bar__button star-icon' animate active={status.get('favourited')} title={favouriteTitle} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />

View File

@@ -170,7 +170,7 @@ export const Footer: React.FC<{
counter={status.get('replies_count') as number}
/>
<BoostButton counters status={status} />
<BoostButton counters statusId={statusId} />
<IconButton
className='status__action-bar-button star-icon'

View File

@@ -21,7 +21,8 @@ import { IconButton } from '../../../components/icon_button';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { me, quickBoosting } from '../../../initial_state';
import { BoostButton } from '@/mastodon/components/status/boost_button';
import { quoteItemState, selectStatusState } from '@/mastodon/components/status/boost_button_utils';
import { quoteItemState } from '@/mastodon/components/status/boost_button_utils';
import { selectStatusConditions } from '@/mastodon/selectors/statuses';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
@@ -62,7 +63,7 @@ const mapStateToProps = (state, { status }) => {
return ({
relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]),
quotedAccountId: quotedStatusId ? state.getIn(['statuses', quotedStatusId, 'account']) : null,
statusQuoteState: selectStatusState(state, status),
statusQuoteState: selectStatusConditions(state, status.get('id')),
});
};
@@ -330,7 +331,7 @@ class ActionBar extends PureComponent {
<div className='detailed-status__action-bar'>
<div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} iconComponent={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? ReplyIcon : replyIconComponent} onClick={this.handleReplyClick} /></div>
<div className='detailed-status__button'>
<BoostButton status={status} />
<BoostButton statusId={status.get('id')} />
</div>
<div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={favouriteTitle} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} /></div>
<div className='detailed-status__button'><IconButton className='bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={bookmarkTitle} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} /></div>

View File

@@ -0,0 +1,10 @@
import { selectExpandedStatus, selectPlainStatus } from '../selectors/statuses';
import { useAppSelector } from '../store';
export function useStatus(id: string) {
return useAppSelector((state) => selectPlainStatus(state, id));
}
export function useExpandedStatus(id: string) {
return useAppSelector((state) => selectExpandedStatus(state, id));
}

View File

@@ -59,6 +59,11 @@ export const selectPlainAccount = createAppSelector(
(account) => (account ? (account.toJS() as AccountShapeFull) : null),
);
export const selectIsAccountLocal = createAppSelector(
[selectPlainAccount],
(account) => !!account && account.username === account.acct,
);
export const getAccountHidden = createAppSelector(
[
(state, id: string) => state.accounts.get(id)?.hidden,

View File

@@ -7,7 +7,7 @@ import type {
} from '@/mastodon/models/status';
import { createAppSelector } from '@/mastodon/store/typed_functions';
import { selectPlainAccount } from './accounts';
import { selectIsAccountLocal, selectPlainAccount } from './accounts';
import type { FilterShape } from './filters';
import { getFilters } from './filters';
@@ -83,16 +83,42 @@ export const selectStatusConditions = createAppSelector(
state.statuses.getIn([statusId, 'quote', 'quoted_status']),
'account',
]) as string | undefined | null,
(state, statusId: string) =>
selectIsAccountLocal(
state,
state.statuses.getIn([statusId, 'account']) as string,
),
],
(status, currentAccountId, quotedAccountId) => ({
isPublic: status && ['public', 'unlisted'].includes(status.visibility),
isLoggedIn: !!currentAccountId,
isMine: status && !!currentAccountId && status.account === currentAccountId,
isQuoted:
status && !!currentAccountId && quotedAccountId === currentAccountId,
isNotDirect: status && status.visibility !== 'direct',
}),
(status, userId, quotedAccountId, isLocal) => {
const isPublic =
!!status && ['public', 'unlisted'].includes(status.visibility);
const isMine = status?.account === userId;
const isMineAndPrivate =
userId === status?.account && status.visibility === 'private';
const quoteApproval = status?.quote_approval?.current_user;
return {
isPublic,
isLocal,
isLoggedIn: !!userId,
isMine,
isNotMine: !isMine,
isQuoted: !!status && !!userId && quotedAccountId === userId,
isNotDirect: !!status && status.visibility !== 'direct',
isPrivateReblog:
userId === status?.account && status.visibility === 'private',
isBoosted: status?.reblogged ?? false,
isBoostingAllowed: isPublic || isMineAndPrivate,
isQuoteAutomaticallyAccepted:
quoteApproval === 'automatic' && (isPublic || isMineAndPrivate),
isQuoteManuallyAccepted:
quoteApproval === 'manual' && (isPublic || isMineAndPrivate),
isQuoteFollowersOnly:
status?.quote_approval?.automatic[0] === 'followers' ||
status?.quote_approval?.manual[0] === 'followers',
};
},
);
export type StatusConditions = ReturnType<typeof selectStatusConditions>;
export const selectStatusInteractions = createAppSelector(
[(_, statusId: string) => statusId, selectStatusConditions],
@@ -104,8 +130,15 @@ export const selectStatusInteractions = createAppSelector(
};
}
const { isLoggedIn, isMine, isPublic, isQuoted, isNotDirect } =
conditionals;
const {
isLoggedIn,
isMine,
isNotMine,
isPublic,
isLocal,
isQuoted,
isNotDirect,
} = conditionals;
const interactions: Record<
StatusInteractionIntent,
@@ -115,17 +148,17 @@ export const selectStatusInteractions = createAppSelector(
delete: addAllowed({ isMine }),
edit: addAllowed({ isMine }),
editQuotePolicy: addAllowed({ isMine, isPublic }),
embed: addAllowed({ isPublic }),
embed: addAllowed({ isPublic, isLocal }),
favourite: addAllowed({ isLoggedIn }),
filter: addAllowed({ isLoggedIn }),
filter: addAllowed({ isLoggedIn, isNotMine }),
mute: addAllowed({ isMine }),
pin: addAllowed({ isMine, isNotDirect }),
quote: addAllowed({ isLoggedIn }),
reblog: addAllowed({ isLoggedIn }),
redraft: addAllowed({ isMine }),
reply: addAllowed({ isLoggedIn }),
report: addAllowed({ isLoggedIn }),
revokeQuote: addAllowed({ isQuoted }),
report: addAllowed({ isLoggedIn, isNotMine }),
revokeQuote: addAllowed({ isQuoted, isNotMine }),
};
return {
@@ -134,6 +167,20 @@ export const selectStatusInteractions = createAppSelector(
};
},
);
export type StatusInteractions = ReturnType<typeof selectStatusInteractions>;
export const selectStatusInteractionsAllowed = createAppSelector(
[selectStatusInteractions],
({ statusId, ...interactions }) => ({
statusId,
...(Object.fromEntries(
Object.entries(interactions).map(([key, { allowed }]) => [key, allowed]),
) as Record<StatusInteractionIntent, boolean>),
}),
);
export type StatusInteractionsAllowed = ReturnType<
typeof selectStatusInteractionsAllowed
>;
export const selectStatusIntentAllowed = createAppSelector(
[