From 85c01b8aa994cae49c9b9c94552c517aabff1bbc Mon Sep 17 00:00:00 2001 From: Echo Date: Mon, 6 Jul 2026 12:00:41 +0200 Subject: [PATCH] Status Action Bar (#39702) --- .../mastodon/components/status/action_bar.tsx | 597 ++++++++++++++++++ .../components/status/attachments.tsx | 7 +- .../status/boost_button.stories.tsx | 43 +- .../components/status/boost_button.tsx | 66 +- .../components/status/boost_button_utils.ts | 47 +- .../mastodon/components/status/content.tsx | 4 +- .../components/status_action_bar/index.jsx | 7 +- .../picture_in_picture/components/footer.tsx | 2 +- .../features/status/components/action_bar.jsx | 7 +- app/javascript/mastodon/hooks/useStatus.ts | 10 + app/javascript/mastodon/selectors/accounts.ts | 5 + app/javascript/mastodon/selectors/statuses.ts | 77 ++- 12 files changed, 748 insertions(+), 124 deletions(-) create mode 100644 app/javascript/mastodon/components/status/action_bar.tsx create mode 100644 app/javascript/mastodon/hooks/useStatus.ts diff --git a/app/javascript/mastodon/components/status/action_bar.tsx b/app/javascript/mastodon/components/status/action_bar.tsx new file mode 100644 index 00000000000..a1f45c653f5 --- /dev/null +++ b/app/javascript/mastodon/components/status/action_bar.tsx @@ -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 = ({ + 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 ( +
+
+ +
+
+ +
+
+ +
+
+ +
+ + {(dismissQuoteHint) => ( + + )} + +
+ ); +}; + +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 ( + + + + ); +}; + +interface MenuItemsParams { + status: StatusShape; + account?: Account; + conditions: StatusConditions; + interactions: StatusInteractionsAllowed; + onStatusInteraction: (intent: StatusInteractionIntent) => () => void; + withDismiss?: boolean; + permissions: number; + intl: ReturnType; + 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; +} diff --git a/app/javascript/mastodon/components/status/attachments.tsx b/app/javascript/mastodon/components/status/attachments.tsx index f942886b1e9..b36f77dc8d0 100644 --- a/app/javascript/mastodon/components/status/attachments.tsx +++ b/app/javascript/mastodon/components/status/attachments.tsx @@ -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; diff --git a/app/javascript/mastodon/components/status/boost_button.stories.tsx b/app/javascript/mastodon/components/status/boost_button.stories.tsx index 5ea0040212e..de2b6bd963c 100644 --- a/app/javascript/mastodon/components/status/boost_button.stories.tsx +++ b/app/javascript/mastodon/components/status/boost_button.stories.tsx @@ -38,30 +38,35 @@ const meta = { }, }, render: (args) => ( - 0} /> + 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; 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; export const Default: Story = {}; diff --git a/app/javascript/mastodon/components/status/boost_button.tsx b/app/javascript/mastodon/components/status/boost_button.tsx index d65018cdafd..27c7e95a62c 100644 --- a/app/javascript/mastodon/components/status/boost_button.tsx +++ b/app/javascript/mastodon/components/status/boost_button.tsx @@ -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 = ({ status, counters }) => { +const StandaloneBoostButton: FC = ({ + 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 = ({ 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 ( @@ -88,22 +87,23 @@ const renderMenuItem: RenderItemFn = (item, index, onClick) => ( ); interface ReblogButtonProps { - status: Status; + statusId: string; counters?: boolean; } type ActionMenuItemWithIcon = SetRequired; -const BoostOrQuoteMenu: FC = ({ status, counters }) => { +const BoostOrQuoteMenu: FC = ({ 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 = ({ 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 = ({ 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 = ({ status, counters }) => { } if (event.shiftKey) { - dispatch(toggleReblog(status.get('id'), true)); + dispatch(toggleReblog(statusId, true)); return false; } @@ -182,7 +181,7 @@ const BoostOrQuoteMenu: FC = ({ status, counters }) => { return true; }, - [dispatch, isLoggedIn, showLoginPrompt, status, quoteApproval, statusId], + [dispatch, isLoggedIn, showLoginPrompt, quoteApproval, statusId], ); return ( @@ -202,12 +201,11 @@ const BoostOrQuoteMenu: FC = ({ 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} /> ); diff --git a/app/javascript/mastodon/components/status/boost_button_utils.ts b/app/javascript/mastodon/components/status/boost_button_utils.ts index 5fc77ed30b6..eab41958973 100644 --- a/app/javascript/mastodon/components/status/boost_button_utils.ts +++ b/app/javascript/mastodon/components/status/boost_button_utils.ts @@ -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; - 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, diff --git a/app/javascript/mastodon/components/status/content.tsx b/app/javascript/mastodon/components/status/content.tsx index 87ee0817ea2..5ddec7b4073 100644 --- a/app/javascript/mastodon/components/status/content.tsx +++ b/app/javascript/mastodon/components/status/content.tsx @@ -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; collapsible?: boolean; }> = ({ statusId, onClick, onTranslate, collapsible }) => { - const status = useAppSelector((state) => selectPlainStatus(state, statusId)); + const status = useStatus(statusId); const { signedIn } = useIdentity(); const targetLanguages = useAppSelector( (state) => diff --git a/app/javascript/mastodon/components/status_action_bar/index.jsx b/app/javascript/mastodon/components/status_action_bar/index.jsx index 1ad8b2002f9..27204fb15e7 100644 --- a/app/javascript/mastodon/components/status_action_bar/index.jsx +++ b/app/javascript/mastodon/components/status_action_bar/index.jsx @@ -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 {
- +
diff --git a/app/javascript/mastodon/features/picture_in_picture/components/footer.tsx b/app/javascript/mastodon/features/picture_in_picture/components/footer.tsx index 83e6820ac82..c331a704fac 100644 --- a/app/javascript/mastodon/features/picture_in_picture/components/footer.tsx +++ b/app/javascript/mastodon/features/picture_in_picture/components/footer.tsx @@ -170,7 +170,7 @@ export const Footer: React.FC<{ counter={status.get('replies_count') as number} /> - + { 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 {
- +
diff --git a/app/javascript/mastodon/hooks/useStatus.ts b/app/javascript/mastodon/hooks/useStatus.ts new file mode 100644 index 00000000000..faa4ec6695c --- /dev/null +++ b/app/javascript/mastodon/hooks/useStatus.ts @@ -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)); +} diff --git a/app/javascript/mastodon/selectors/accounts.ts b/app/javascript/mastodon/selectors/accounts.ts index d0e014b12bb..d8cb0bb319c 100644 --- a/app/javascript/mastodon/selectors/accounts.ts +++ b/app/javascript/mastodon/selectors/accounts.ts @@ -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, diff --git a/app/javascript/mastodon/selectors/statuses.ts b/app/javascript/mastodon/selectors/statuses.ts index 592c0bd31a3..544e9d110fa 100644 --- a/app/javascript/mastodon/selectors/statuses.ts +++ b/app/javascript/mastodon/selectors/statuses.ts @@ -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; 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; + +export const selectStatusInteractionsAllowed = createAppSelector( + [selectStatusInteractions], + ({ statusId, ...interactions }) => ({ + statusId, + ...(Object.fromEntries( + Object.entries(interactions).map(([key, { allowed }]) => [key, allowed]), + ) as Record), + }), +); +export type StatusInteractionsAllowed = ReturnType< + typeof selectStatusInteractionsAllowed +>; export const selectStatusIntentAllowed = createAppSelector( [