From b4e5b039e04af3fec6ff80707ebb395d0c4a89b2 Mon Sep 17 00:00:00 2001 From: Echo Date: Fri, 26 Jun 2026 14:26:18 +0200 Subject: [PATCH] Refactor StatusContent (#39624) --- .storybook/preview.tsx | 13 +- .../mastodon/actions/importer/statuses.ts | 2 +- app/javascript/mastodon/components/poll.tsx | 25 ++- .../components/status/content.stories.tsx | 121 ++++++++++ .../mastodon/components/status/content.tsx | 210 ++++++++++++++++++ .../mastodon/components/status_content.jsx | 2 +- .../mastodon/features/emoji/types.ts | 2 +- app/javascript/mastodon/models/status.ts | 2 +- 8 files changed, 366 insertions(+), 11 deletions(-) create mode 100644 app/javascript/mastodon/components/status/content.stories.tsx create mode 100644 app/javascript/mastodon/components/status/content.tsx diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 0461b473264..e718a56a4f5 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -84,7 +84,7 @@ const preview: Preview = { // Get the locale from the global toolbar // and merge it with any parameters or args state. const { locale } = globals as { locale: string }; - const { state = {} } = parameters; + const { state = {}, stateFn } = parameters; const argsState: Record = {}; for (const [key, value] of Object.entries(args)) { @@ -106,6 +106,16 @@ const preview: Preview = { } } + let stateFnState: Record = {}; + if (typeof stateFn === 'function') { + stateFnState = + ( + stateFn as ( + args: Record, + ) => Record | undefined | null + )(args) ?? {}; + } + const reducer = reducerWithInitialState( { meta: { @@ -113,6 +123,7 @@ const preview: Preview = { }, }, state as Record, + stateFnState, argsState, ); diff --git a/app/javascript/mastodon/actions/importer/statuses.ts b/app/javascript/mastodon/actions/importer/statuses.ts index 77538449535..f34222a2992 100644 --- a/app/javascript/mastodon/actions/importer/statuses.ts +++ b/app/javascript/mastodon/actions/importer/statuses.ts @@ -20,7 +20,7 @@ export function normalizeStatus( ) { const normalStatus: StatusShape = { hidden: normalOldStatus?.hidden ?? false, - collapsed: normalOldStatus?.collapsed ?? false, + collapsed: normalOldStatus?.collapsed ?? null, content: '', contentHtml: '', muted: false, diff --git a/app/javascript/mastodon/components/poll.tsx b/app/javascript/mastodon/components/poll.tsx index c77ce8bcee7..9ed15fbf15e 100644 --- a/app/javascript/mastodon/components/poll.tsx +++ b/app/javascript/mastodon/components/poll.tsx @@ -15,7 +15,6 @@ import { fetchPoll, vote } from 'mastodon/actions/polls'; import { Icon } from 'mastodon/components/icon'; import { useIdentity } from 'mastodon/identity_context'; import type * as Model from 'mastodon/models/poll'; -import type { Status } from 'mastodon/models/status'; import { useAppDispatch, useAppSelector } from 'mastodon/store'; import { RelativeTimestamp } from './relative_timestamp'; @@ -40,12 +39,18 @@ const isPollExpired = (expiresAt: Model.Poll['expires_at']) => interface PollProps { pollId: string; - status: Status; + accountId: string; + statusUrl: string; lang?: string; disabled?: boolean; } -export const Poll: React.FC = ({ pollId, disabled, status }) => { +export const Poll: React.FC = ({ + pollId, + disabled, + accountId, + statusUrl, +}) => { // Third party hooks const poll = useAppSelector((state) => state.polls[pollId]); const identity = useIdentity(); @@ -110,14 +115,22 @@ export const Poll: React.FC = ({ pollId, disabled, status }) => { openModal({ modalType: 'INTERACTION', modalProps: { + accountId, intent: 'vote', - accountId: status.getIn(['account', 'id']), - url: status.get('uri'), + url: statusUrl, }, }), ); } - }, [voteDisabled, dispatch, identity, pollId, selected, status]); + }, [ + voteDisabled, + dispatch, + identity, + pollId, + selected, + accountId, + statusUrl, + ]); const handleReveal = useCallback(() => { setRevealed(true); diff --git a/app/javascript/mastodon/components/status/content.stories.tsx b/app/javascript/mastodon/components/status/content.stories.tsx new file mode 100644 index 00000000000..cb9bb9d3ec9 --- /dev/null +++ b/app/javascript/mastodon/components/status/content.stories.tsx @@ -0,0 +1,121 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { fn } from 'storybook/test'; + +import type { StatusTranslation } from '@/mastodon/models/status'; +import { statusFactoryState } from '@/testing/factories'; + +import { StatusContent } from './content'; + +interface StatusContentProps { + text: string; + collapsible: boolean; + clickable: boolean; + translatable: boolean; + translatedTo?: string; +} + +const onClickFn = fn().mockName('onClick'); +const onTranslateFn = fn().mockName('onTranslate'); + +const meta = { + title: 'Components/Status/StatusContent', + render(args) { + return ( +
+ +
+ ); + }, + args: { + text: 'This is status text.', + collapsible: true, + clickable: true, + translatable: true, + }, + argTypes: { + text: { + reduxPath: 'statuses.1.contentHtml', + }, + translatedTo: { + control: 'select', + options: ['en', 'de', 'fr'], + }, + }, + parameters: { + state: { + server: { + translationLanguages: { + item: { + en: ['en', 'de', 'fr'], + }, + }, + }, + }, + stateFn(args: StatusContentProps) { + let status = statusFactoryState(); + if (args.translatedTo) { + status = status.set('translation', { + contentHtml: `${args.text}

(in ${args.translatedTo})

`, + provider: 'Test Translation API', + spoiler_text: '', + spoilerHtml: '', + language: args.translatedTo, + detected_source_language: 'en', + } satisfies StatusTranslation); + } + return { + statuses: { + '1': status, + }, + }; + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const ReadMore: Story = { + args: { + text: [ + 'This is a long-form piece of text that wraps multiple lines.', + 'It is here to test what a longer status looks like.', + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + 'Pellentesque a ante placerat, egestas eros vitae, ornare orci.', + 'Phasellus fringilla felis vel purus fermentum, nec viverra eros fringilla.', + 'Mauris feugiat metus in dolor elementum, ultricies suscipit sapien tincidunt.', + 'Mauris vestibulum urna vel mauris sagittis, a blandit felis cursus.', + 'Sed nec sem dictum ligula hendrerit dignissim et non dolor.', + 'Cras maximus lorem sit amet aliquet faucibus.', + 'Donec tempus lectus vitae laoreet congue.', + 'Nulla congue nibh sed eros pulvinar pharetra.', + 'Fusce vel nibh quis nisi mollis volutpat quis vel erat.', + 'Fusce non metus non sapien volutpat elementum.', + 'Aenean elementum ipsum ut neque bibendum, eget blandit ex efficitur.', + 'Morbi semper eros at ipsum pellentesque mattis.', + 'Donec ultricies ante imperdiet placerat tempus.', + 'Vivamus vitae ante sit amet lectus porta mollis quis dictum quam.', + 'Cras dignissim ante at turpis scelerisque, non hendrerit ipsum vestibulum.', + 'Quisque ac nulla ac sem auctor posuere eget id ex.', + 'Phasellus cursus purus sit amet sollicitudin finibus.', + 'In varius justo eu metus dapibus, non imperdiet lorem efficitur.', + 'Nulla tincidunt odio eget ipsum auctor rhoncus.', + ] + .map((text) => `

${text}

`) + .join('\n'), + }, +}; + +export const Translated: Story = { + args: { + translatedTo: 'fr', + }, +}; diff --git a/app/javascript/mastodon/components/status/content.tsx b/app/javascript/mastodon/components/status/content.tsx new file mode 100644 index 00000000000..87ee0817ea2 --- /dev/null +++ b/app/javascript/mastodon/components/status/content.tsx @@ -0,0 +1,210 @@ +import type React from 'react'; +import { useCallback } from 'react'; + +import { FormattedMessage, useIntl } from 'react-intl'; + +import classnames from 'classnames'; + +import { toggleStatusCollapse } from '@/mastodon/actions/statuses'; +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'; + +import { EmojiHTML } from '../emoji/html'; +import { Icon } from '../icon'; +import { Poll } from '../poll'; + +import { useElementHandledLink } from './handled_link'; + +const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top) + +export const StatusContent: React.FC<{ + statusId: string; + onClick?: React.MouseEventHandler; + onTranslate?: React.MouseEventHandler; + collapsible?: boolean; +}> = ({ statusId, onClick, onTranslate, collapsible }) => { + const status = useAppSelector((state) => selectPlainStatus(state, statusId)); + const { signedIn } = useIdentity(); + const targetLanguages = useAppSelector( + (state) => + state.server.translationLanguages.item?.[status?.language ?? 'und'], + ); + const intl = useIntl(); + + // Determines if a long post should show the read more button. + const dispatch = useAppDispatch(); + const handleCollapse = useCallback( + (node: HTMLDivElement | null) => { + if (!node || status?.collapsed !== null || !collapsible) { + return; + } + + const text = node.querySelector(':scope > .status__content__text'); + + const collapsed = + (node.clientHeight > MAX_HEIGHT || + (text !== null && text.scrollWidth > text.clientWidth)) && + !status.spoiler_text; + + dispatch(toggleStatusCollapse(status.id, collapsed)); + }, + [collapsible, status, dispatch], + ); + + // Trigger the click event if clicking outside a link, button, or label inside a status. + const handleClick: React.MouseEventHandler = useCallback( + (event) => { + const { target } = event; + if ( + !onClick || + !(target instanceof Element) || + target.closest(':is(a, button, label)') + ) { + return; + } + onClick(event); + }, + [onClick], + ); + + const hrefToMention = useCallback( + (href: string) => status?.mentions.find((item) => item.url === href), + [status?.mentions], + ); + const hrefToCollectionId = useCallback( + (href: string) => + status?.tagged_collections.find((item) => item.url === href)?.id, + [status?.tagged_collections], + ); + const htmlHandlers = useElementHandledLink({ + hashtagAccountId: status?.account, + hrefToCollectionId, + hrefToMention, + }); + + if (!status) { + return null; + } + + const language = status.translation?.language ?? status.language; + + const renderReadMore = !!onClick && status.collapsed; + const readMoreButton = renderReadMore && ( + + ); + + const renderTranslate = + !!onTranslate && + signedIn && + ['public', 'unlisted'].includes(status.visibility) && + status.search_index && + status.search_index.trim().length > 0 && + targetLanguages?.includes(intl.locale.replace(/[_-].*/, '')); + const translateButton = renderTranslate && ( + + ); + + const poll = !!status.poll && ( + + ); + + const content = ( + + ); + + const classNames = classnames('status__content', { + 'status__content--with-action': onClick, + 'status__content--collapsed': renderReadMore, + }); + + if (!onClick) { + return ( +
+ {content} + {poll} + {translateButton} +
+ ); + } + + /* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */ + return ( + <> +
+ {content} + {poll} + {translateButton} +
+ + {readMoreButton} + + ); + /* eslint-enable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */ +}; + +const TranslateButton: React.FC<{ + onClick: React.MouseEventHandler; + translation?: StatusTranslation; +}> = ({ translation, onClick }) => { + if (!translation) { + return ( + + ); + } + + const language = preloadedLanguages?.find( + (lang) => lang[0] === translation.detected_source_language, + ); + const languageName = language + ? language[1] + : translation.detected_source_language; + const provider = translation.provider; + + return ( +
+ + +
+ +
+
+ ); +}; diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx index 7e5cccf1986..eb2149a290b 100644 --- a/app/javascript/mastodon/components/status_content.jsx +++ b/app/javascript/mastodon/components/status_content.jsx @@ -208,7 +208,7 @@ class StatusContent extends PureComponent { ); const poll = !!status.get('poll') && ( - + ); if (this.props.onClick) { diff --git a/app/javascript/mastodon/features/emoji/types.ts b/app/javascript/mastodon/features/emoji/types.ts index 2000d11c691..6d729c9bb50 100644 --- a/app/javascript/mastodon/features/emoji/types.ts +++ b/app/javascript/mastodon/features/emoji/types.ts @@ -73,7 +73,7 @@ export type CustomEmojiMapArg = | ExtraCustomEmojiMap | ImmutableList | CustomEmoji[] - | ApiCustomEmojiJSON[]; + | Pick[]; export type ExtraCustomEmojiMap = Record< string, diff --git a/app/javascript/mastodon/models/status.ts b/app/javascript/mastodon/models/status.ts index 666d6442664..9b54844e672 100644 --- a/app/javascript/mastodon/models/status.ts +++ b/app/javascript/mastodon/models/status.ts @@ -47,7 +47,7 @@ export interface StatusShape { pinned: boolean; filtered: FilterResult[]; sensitive: boolean; - collapsed: boolean; + collapsed: boolean | null; uri: string; url: string | null;