mirror of
https://github.com/mastodon/mastodon.git
synced 2026-07-12 21:47:33 -05:00
Refactor StatusContent (#39624)
This commit is contained in:
parent
eb3c0c1176
commit
b4e5b039e0
|
|
@ -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<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(args)) {
|
||||
|
|
@ -106,6 +106,16 @@ const preview: Preview = {
|
|||
}
|
||||
}
|
||||
|
||||
let stateFnState: Record<string, unknown> = {};
|
||||
if (typeof stateFn === 'function') {
|
||||
stateFnState =
|
||||
(
|
||||
stateFn as (
|
||||
args: Record<string, unknown>,
|
||||
) => Record<string, unknown> | undefined | null
|
||||
)(args) ?? {};
|
||||
}
|
||||
|
||||
const reducer = reducerWithInitialState(
|
||||
{
|
||||
meta: {
|
||||
|
|
@ -113,6 +123,7 @@ const preview: Preview = {
|
|||
},
|
||||
},
|
||||
state as Record<string, unknown>,
|
||||
stateFnState,
|
||||
argsState,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<PollProps> = ({ pollId, disabled, status }) => {
|
||||
export const Poll: React.FC<PollProps> = ({
|
||||
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<PollProps> = ({ 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);
|
||||
|
|
|
|||
121
app/javascript/mastodon/components/status/content.stories.tsx
Normal file
121
app/javascript/mastodon/components/status/content.stories.tsx
Normal file
|
|
@ -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 (
|
||||
<div style={{ width: 'min(600px, 80vw)' }}>
|
||||
<StatusContent
|
||||
statusId='1'
|
||||
collapsible={args.collapsible}
|
||||
onClick={args.clickable ? onClickFn : undefined}
|
||||
onTranslate={args.translatable ? onTranslateFn : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
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}<p><em>(in ${args.translatedTo})</em></p>`,
|
||||
provider: 'Test Translation API',
|
||||
spoiler_text: '',
|
||||
spoilerHtml: '',
|
||||
language: args.translatedTo,
|
||||
detected_source_language: 'en',
|
||||
} satisfies StatusTranslation);
|
||||
}
|
||||
return {
|
||||
statuses: {
|
||||
'1': status,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
} satisfies Meta<StatusContentProps>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
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) => `<p>${text}</p>`)
|
||||
.join('\n'),
|
||||
},
|
||||
};
|
||||
|
||||
export const Translated: Story = {
|
||||
args: {
|
||||
translatedTo: 'fr',
|
||||
},
|
||||
};
|
||||
210
app/javascript/mastodon/components/status/content.tsx
Normal file
210
app/javascript/mastodon/components/status/content.tsx
Normal file
|
|
@ -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<HTMLButtonElement>;
|
||||
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<HTMLDivElement> = 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 && (
|
||||
<button
|
||||
className='status__content__read-more-button'
|
||||
type='button'
|
||||
onClick={onClick}
|
||||
key='read-more'
|
||||
>
|
||||
<FormattedMessage id='status.read_more' defaultMessage='Read more' />
|
||||
<Icon id='angle-right' icon={ChevronRightIcon} />
|
||||
</button>
|
||||
);
|
||||
|
||||
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 && (
|
||||
<TranslateButton onClick={onTranslate} translation={status.translation} />
|
||||
);
|
||||
|
||||
const poll = !!status.poll && (
|
||||
<Poll
|
||||
pollId={status.poll}
|
||||
statusUrl={status.uri}
|
||||
accountId={status.account}
|
||||
lang={language}
|
||||
/>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<EmojiHTML
|
||||
className='status__content__text status__content__text--visible translate'
|
||||
lang={language}
|
||||
htmlString={status.translation?.contentHtml ?? status.contentHtml}
|
||||
extraEmojis={status.emojis}
|
||||
{...htmlHandlers}
|
||||
/>
|
||||
);
|
||||
|
||||
const classNames = classnames('status__content', {
|
||||
'status__content--with-action': onClick,
|
||||
'status__content--collapsed': renderReadMore,
|
||||
});
|
||||
|
||||
if (!onClick) {
|
||||
return (
|
||||
<div className={classNames} ref={handleCollapse}>
|
||||
{content}
|
||||
{poll}
|
||||
{translateButton}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
|
||||
return (
|
||||
<>
|
||||
<div className={classNames} ref={handleCollapse} onClick={handleClick}>
|
||||
{content}
|
||||
{poll}
|
||||
{translateButton}
|
||||
</div>
|
||||
|
||||
{readMoreButton}
|
||||
</>
|
||||
);
|
||||
/* eslint-enable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
|
||||
};
|
||||
|
||||
const TranslateButton: React.FC<{
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
translation?: StatusTranslation;
|
||||
}> = ({ translation, onClick }) => {
|
||||
if (!translation) {
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
className='status__content__translate-button'
|
||||
onClick={onClick}
|
||||
>
|
||||
<FormattedMessage id='status.translate' defaultMessage='Translate' />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className='translate-button'>
|
||||
<button type='button' className='link-button' onClick={onClick}>
|
||||
<FormattedMessage
|
||||
id='status.show_original'
|
||||
defaultMessage='Show original'
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div className='translate-button__meta'>
|
||||
<FormattedMessage
|
||||
id='status.translated_from_with'
|
||||
defaultMessage='Translated from {lang} using {provider}'
|
||||
values={{ lang: languageName, provider }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -208,7 +208,7 @@ class StatusContent extends PureComponent {
|
|||
);
|
||||
|
||||
const poll = !!status.get('poll') && (
|
||||
<Poll pollId={status.get('poll')} status={status} lang={language} />
|
||||
<Poll pollId={status.get('poll')} statusUrl={status.get('uri')} accountId={status.getIn(['account', 'id'])} lang={language} />
|
||||
);
|
||||
|
||||
if (this.props.onClick) {
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ export type CustomEmojiMapArg =
|
|||
| ExtraCustomEmojiMap
|
||||
| ImmutableList<CustomEmoji>
|
||||
| CustomEmoji[]
|
||||
| ApiCustomEmojiJSON[];
|
||||
| Pick<ApiCustomEmojiJSON, 'shortcode' | 'static_url' | 'url'>[];
|
||||
|
||||
export type ExtraCustomEmojiMap = Record<
|
||||
string,
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export interface StatusShape {
|
|||
pinned: boolean;
|
||||
filtered: FilterResult[];
|
||||
sensitive: boolean;
|
||||
collapsed: boolean;
|
||||
collapsed: boolean | null;
|
||||
uri: string;
|
||||
url: string | null;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user