Status redesign: Content (#40385)

This commit is contained in:
Echo
2026-09-07 09:04:06 +00:00
committed by GitHub
parent 42ee6618fc
commit 0d102fb2da
13 changed files with 265 additions and 228 deletions

View File

@@ -31,7 +31,7 @@ export interface ApiImageAttachmentJSON extends BaseApiMediaAttachmentJSON {
export interface ApiAudioAttachmentJSON extends BaseApiMediaAttachmentJSON {
type: 'audio';
meta: {
colors: ApiColorsAttachmentMetaJSON;
colors?: ApiColorsAttachmentMetaJSON;
original: ApiVideoAttachmentMetaJSON;
small: ApiImageAttachmentMetaJSON;
};
@@ -40,7 +40,7 @@ export interface ApiAudioAttachmentJSON extends BaseApiMediaAttachmentJSON {
export interface ApiVideoAttachmentJSON extends BaseApiMediaAttachmentJSON {
type: 'video';
meta: {
colors: ApiColorsAttachmentMetaJSON;
colors?: ApiColorsAttachmentMetaJSON;
original: ApiVideoAttachmentMetaJSON;
small: ApiImageAttachmentMetaJSON;
focus?: ApiFocusAttachmentMetaJSON;

View File

@@ -15,7 +15,7 @@ interface Props {
'id' | 'acct' | 'avatar' | 'avatar_static'
>;
alt?: string;
size?: number;
size?: number | null;
style?: React.CSSProperties;
inline?: boolean;
animate?: boolean;
@@ -41,11 +41,14 @@ export const Avatar: React.FC<Props> = ({
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const style = {
...styleFromParent,
width: `${size}px`,
height: `${size}px`,
};
const style =
size !== null
? {
...styleFromParent,
width: `${size}px`,
height: `${size}px`,
}
: styleFromParent;
const src = hovering || animate ? account?.avatar : account?.avatar_static;

View File

@@ -31,7 +31,7 @@ export const EmojiHTML = <
onElement,
onAttribute,
extraArgs,
ref,
children,
...props
}: PolymorphicProps<EmojiHTMLProps<Arg>, As>) => {
const contents = useMemo(
@@ -47,8 +47,10 @@ export const EmojiHTML = <
return (
<CustomEmojiProvider emojis={extraEmojis}>
<AnimateEmojiProvider {...props} ref={ref}>
<AnimateEmojiProvider {...props}>
{contents}
{children}
</AnimateEmojiProvider>
</CustomEmojiProvider>
);

View File

@@ -3,6 +3,8 @@ import { useCallback, useMemo } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
import {
ArrowsClockwiseIcon,
BookmarkSimpleIcon,
@@ -202,7 +204,7 @@ export const StatusActionBar: React.FC<StatusActionBarProps> = ({
isQuotingMe && contextType === 'notifications';
return (
<div className={classes.actions}>
<div className={classNames(classes.actions, classes.buttonAlign)}>
<Button
size='sm'
variant='ghost'

View File

@@ -212,9 +212,9 @@ const MediaAttachments: React.FC<{
alt={description}
lang={language}
poster={attachment.preview_url || defaultPosterUrl}
backgroundColor={colors.background}
foregroundColor={colors.foreground}
accentColor={colors.accent}
backgroundColor={colors?.background}
foregroundColor={colors?.foreground}
accentColor={colors?.accent}
duration={original.duration}
deployPictureInPicture={handleDeployPictureInPicture}
sensitive={sensitive}

View File

@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
import { fn } from 'storybook/test';
import type { StatusTranslation } from '@/mastodon/models/status';
import { statusFactoryImmutable } from '@/testing/factories';
import { statusFactoryState } from '@/testing/factories';
import { StatusContent } from './content';
@@ -20,10 +20,22 @@ const onTranslateFn = fn().mockName('onTranslate');
const meta = {
title: 'Components/Status/StatusContent',
render(args) {
const status = statusFactoryState();
if (args.translatedTo) {
status.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 (
<div style={{ width: 'min(600px, 80vw)' }}>
<StatusContent
statusId='1'
status={status}
collapsible={args.collapsible}
onClick={args.clickable ? onClickFn : undefined}
onTranslate={args.translatable ? onTranslateFn : undefined}
@@ -56,24 +68,6 @@ const meta = {
},
},
},
stateFn(args: StatusContentProps) {
let status = statusFactoryImmutable();
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>;

View File

@@ -1,173 +1,138 @@
import type React from 'react';
import { useCallback } from 'react';
import { useCallback, useState } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import classnames from 'classnames';
import classNames from 'classnames';
import { CaretRightIcon } from '@phosphor-icons/react';
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 { useAppDispatch, useAppSelector } from '@/mastodon/store';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import type {
ExpandedStatusShape,
StatusShape,
StatusTranslation,
} from '@/mastodon/models/status';
import { useAppSelector } from '@/mastodon/store';
import { Button } from '../button/redesign';
import { EmojiHTML } from '../emoji/html';
import { Icon } from '../icon';
import { Poll } from '../poll';
import { useHandlersForStatus } from './hooks';
import classes from './styles.module.scss';
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
export const StatusContent: React.FC<{
statusId: string;
statusContent?: string;
onClick?: React.MouseEventHandler;
onTranslate?: React.MouseEventHandler<HTMLButtonElement>;
collapsible?: boolean;
}> = ({ statusId, statusContent, onClick, onTranslate, collapsible }) => {
const status = useStatus(statusId);
export const StatusContent: React.FC<
{
status: StatusShape | ExpandedStatusShape;
statusContent?: string;
onTranslate?: () => void;
onReadMore?: () => void;
collapsible?: boolean;
} & React.ComponentPropsWithRef<'div'>
> = ({
status,
statusContent,
onTranslate,
onReadMore,
collapsible,
children,
className,
...props
}) => {
const { signedIn } = useIdentity();
const targetLanguages = useAppSelector(
(state) =>
state.server.translationLanguages.item?.[status?.language ?? 'und'],
(state) => state.server.translationLanguages.item?.[status.language],
);
const intl = useIntl();
// Determines if a long post should show the read more button.
const dispatch = useAppDispatch();
const handleCollapse = useCallback(
const [collapsed, setCollapsed] = useState(false);
const onRef = useCallback(
(node: HTMLDivElement | null) => {
if (!node || status?.collapsed !== null || !collapsible) {
if (!node || collapsed) {
return;
}
const text = node.querySelector(':scope > .status__content__text');
const collapsed =
setCollapsed(
(node.clientHeight > MAX_HEIGHT ||
(text !== null && text.scrollWidth > text.clientWidth)) &&
!status.spoiler_text;
dispatch(toggleStatusCollapse(status.id, collapsed));
node.scrollWidth > node.clientWidth) &&
!status.spoiler_text,
);
},
[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],
[collapsed, status.spoiler_text],
);
const htmlHandlers = useHandlersForStatus(status);
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 &&
const isCollapsed = !!onReadMore && collapsible && collapsed;
const renderTranslate = !!(
onTranslate &&
!isCollapsed &&
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} />
status.search_index?.trim().length &&
targetLanguages?.includes(intl.locale.replace(/[_-].*/, ''))
);
const poll = !!status.poll && (
<Poll
pollId={status.poll}
statusUrl={status.uri}
accountId={status.account}
lang={language}
/>
);
const content = (
return (
<EmojiHTML
className='status__content__text status__content__text--visible translate'
{...props}
className={classNames(
className,
classes.content,
isCollapsed && classes.collapsed,
)}
ref={onRef}
lang={language}
htmlString={
statusContent ?? status.translation?.contentHtml ?? status.contentHtml
}
extraEmojis={status.emojis}
{...htmlHandlers}
/>
>
{children}
{renderTranslate && (
<TranslateButton
translation={status.translation}
onTranslate={onTranslate}
/>
)}
{isCollapsed && (
<Button
size='sm'
variant='ghost'
onClick={onReadMore}
trailingIcon={CaretRightIcon}
className={classNames(classes.contentReadMore, classes.buttonAlign)}
>
<FormattedMessage id='status.read_more' defaultMessage='Read more' />
</Button>
)}
</EmojiHTML>
);
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>;
onTranslate: React.MouseEventHandler<HTMLButtonElement>;
translation?: StatusTranslation;
}> = ({ translation, onClick }) => {
}> = ({ translation, onTranslate }) => {
if (!translation) {
return (
<button
type='button'
className='status__content__translate-button'
onClick={onClick}
<Button
size='sm'
variant='ghost'
onClick={onTranslate}
className={classes.buttonAlign}
>
<FormattedMessage id='status.translate' defaultMessage='Translate' />
</button>
</Button>
);
}
@@ -181,12 +146,12 @@ const TranslateButton: React.FC<{
return (
<div className='translate-button'>
<button type='button' className='link-button' onClick={onClick}>
<Button size='sm' variant='ghost' onClick={onTranslate}>
<FormattedMessage
id='status.show_original'
defaultMessage='Show original'
/>
</button>
</Button>
<div className='translate-button__meta'>
<FormattedMessage

View File

@@ -131,18 +131,6 @@ export function useStatusHandlers({
[onOpenCallback],
);
const onHeaderClick: React.MouseEventHandler = useCallback(
(event) => {
// Only handle clicks on the empty space above the content
if (event.target !== event.currentTarget && event.detail >= 1) {
return;
}
onOpenClick(event);
},
[onOpenClick],
);
const acct = status?.account.acct;
const onOpenProfile = useCallback(() => {
if (acct) {
@@ -190,7 +178,6 @@ export function useStatusHandlers({
onOpenClick,
onExpandedToggle,
onFilterToggle,
onHeaderClick,
onMention,
onOpen: () => {
onOpenCallback();
@@ -208,7 +195,6 @@ export function useStatusHandlers({
handlerFactory,
onExpandedToggle,
onFilterToggle,
onHeaderClick,
onMention,
onOpenCallback,
onOpenClick,

View File

@@ -18,14 +18,12 @@ interface StatusRedesignHeaderProps {
status: Pick<AccountStatusShape, 'id' | 'account' | 'created_at'>;
children?: React.ReactNode;
className?: string;
avatarSize?: number;
}
export const StatusRedesignHeader: React.FC<StatusRedesignHeaderProps> = ({
status,
children,
className,
avatarSize = 40,
}) => {
const account = status.account;
const handle = useAccountHandle(account);
@@ -44,11 +42,16 @@ export const StatusRedesignHeader: React.FC<StatusRedesignHeaderProps> = ({
return (
<header className={classNames(className, classes.header)}>
<Link {...accountLinkProps} role='presentation' tabIndex={-1}>
<Avatar account={account} size={avatarSize} />
<Link
{...accountLinkProps}
role='presentation'
tabIndex={-1}
className={classes.headerAvatar}
>
<Avatar account={account} size={null} />
</Link>
<div className={classes.headerNameWrapper}>
<div>
<p className={classes.headerName}>
<Link
{...accountLinkProps}

View File

@@ -3,9 +3,10 @@
// Header
.header {
display: flex;
display: grid;
grid-template-columns: subgrid;
grid-template-rows: 1fr;
align-items: center;
gap: var(--space-xs);
color: var(--color-text-secondary);
a {
@@ -23,8 +24,14 @@
}
}
.headerNameWrapper {
flex-grow: 1;
.headerAvatar {
padding-inline-end: var(--col-gap);
> span {
display: block;
width: var(--avatar-size);
height: var(--avatar-size);
}
}
.headerName {

View File

@@ -21,6 +21,7 @@ import type { StatusContextType } from './types';
interface StatusStoryProps extends AttachmentArgs {
// Contents
text: string;
tags: string;
visibility: StatusVisibility;
isReblog?: boolean;
isReply?: boolean;
@@ -169,6 +170,7 @@ const meta = {
isPoll: categoryContents,
isQuote: categoryContents,
text: categoryContents,
tags: categoryContents,
attachment1: {
...categoryContents,
...attachmentArgTypes.attachment1,
@@ -232,6 +234,7 @@ const meta = {
},
args: {
text: 'This is a status',
tags: '',
visibility: 'public',
isReblog: false,
isReply: false,
@@ -283,7 +286,8 @@ const meta = {
},
},
stateFn({
text,
text: textBase,
tags: tagsStr,
contentWarning,
visibility,
attachment1,
@@ -301,6 +305,28 @@ const meta = {
}: StatusStoryProps) {
const account = accountFactoryImmutable();
const tags = tagsStr
.split(',')
.map((tagStr) => {
const tag = tagStr.trim().replace(/^#/, '');
if (!tag) {
return null;
}
return {
name: tag,
url: `https://example.com/tags/${tag}`,
};
})
.filter((tag) => !!tag);
let text = textBase.trim();
if (tags.length > 0) {
const tagText = tags
.map((tag) => `<a href="${tag.url}" rel="tag">#${tag.name}</a>`)
.join(' ');
text += `\n${tagText}`;
}
const status = statusFactoryImmutable({
text,
spoiler_text: contentWarning,
@@ -310,6 +336,7 @@ const meta = {
attachment2,
attachment3,
),
tags,
reblogged: hasReblogged,
favourited: hasFavourited,
bookmarked: hasBookmarked,

View File

@@ -12,6 +12,7 @@ import { ContentWarning } from '../content_warning';
import { FilterWarning } from '../filter_warning';
import { computeHashtagBarForStatus, HashtagBar } from '../hashtag_bar';
import { Hotkeys } from '../hotkeys';
import { Poll } from '../poll';
import { StatusActionBar } from './action_bar';
import { StatusAttachments } from './attachments';
@@ -53,8 +54,6 @@ const selectStatusReblog = createAppSelector(
export const StatusRedesign: React.FC<StatusRedesignProps> = ({
id,
muted,
rootId,
unread,
skipPrepend,
unfocusable,
contextType,
@@ -64,7 +63,6 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
showActions = true,
scrollKey,
children,
avatarSize = 40,
withCounters,
withDismiss,
onOpen,
@@ -86,7 +84,7 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
reblogAcct: parent?.account.acct,
isQuote: isQuotedPost,
});
const { statusContent, hashtagsInBar } = useMemo(
const { statusContent, hashtagsInBar = [] } = useMemo(
(): Partial<ReturnType<typeof computeHashtagBarForStatus>> =>
status ? computeHashtagBarForStatus(status) : {},
[status],
@@ -95,10 +93,8 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
// Handlers
const {
showDespiteFilter,
onHeaderClick,
onExpandedToggle,
onFilterToggle,
onOpenClick,
onTranslate,
...handlers
} = useStatusHandlers({ status, contextType, onOpen });
@@ -125,10 +121,7 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
if (hidden) {
return (
<StatusHotkeys
{...hotkeysProps}
className={classNames('status__wrapper', { focusable: !muted })}
>
<StatusHotkeys {...hotkeysProps}>
<span>{status.account.display_name || status.account.username}</span>
{status.spoiler_text && <span>{status.spoiler_text}</span>}
{expanded && <span>{status.content}</span>}
@@ -139,17 +132,7 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
return (
<StatusHotkeys
{...hotkeysProps}
className={classNames(
classes.root,
'status__wrapper',
`status__wrapper-${status.visibility}`,
{
'status__wrapper-reply': !!status.in_reply_to_id,
'status__wrapper--in-thread': !!rootId,
unread,
focusable: !muted,
},
)}
className={classNames(classes.root)}
data-featured={featured ? 'true' : null}
aria-label={screenReaderText}
data-nosnippet={status.account.noindex || undefined}
@@ -162,7 +145,7 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
/>
)}
<StatusRedesignHeader status={status} avatarSize={avatarSize}>
<StatusRedesignHeader status={status} className={classes.header}>
{headerContents}
</StatusRedesignHeader>
@@ -183,37 +166,43 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
)}
{expanded && (
<>
<StatusContent
statusId={status.id}
statusContent={statusContent}
onClick={onOpenClick}
onTranslate={onTranslate}
collapsible
/>
<StatusAttachments statusId={status.id} contextType={contextType} />
{hashtagsInBar && (
<HashtagBar
hashtags={hashtagsInBar}
<StatusContent
status={status}
statusContent={statusContent}
onReadMore={handlers.onOpen}
onTranslate={onTranslate}
collapsible
>
{!!status.poll && (
<Poll
pollId={status.poll}
statusUrl={status.uri}
accountId={status.account.id}
lang={status.translation?.language ?? status.language}
/>
)}
<StatusAttachments statusId={status.id} contextType={contextType} />
{children}
</>
</StatusContent>
)}
{showActions && !isQuotedPost && (
<StatusActionBar
scrollKey={scrollKey}
statusId={status.id}
contextType={contextType}
withDismiss={withDismiss}
withCounters={withCounters}
/>
)}
<footer className={classes.footer}>
{expanded && hashtagsInBar.length > 0 && (
<HashtagBar hashtags={hashtagsInBar} accountId={status.account.id} />
)}
{showActions && !isQuotedPost && (
<StatusActionBar
scrollKey={scrollKey}
statusId={status.id}
contextType={contextType}
withDismiss={withDismiss}
withCounters={withCounters}
/>
)}
</footer>
</StatusHotkeys>
);
};

View File

@@ -1,12 +1,67 @@
@use '@/styles/mastodon/mixins';
.root {
display: flex;
flex-direction: column;
gap: var(--space-xs);
--col-gap: var(--space-xs);
--avatar-size: 40px;
display: grid;
grid-template-columns: calc(var(--avatar-size) + var(--col-gap)) auto;
grid-template-rows:
minmax(calc(var(--avatar-size) + var(--col-gap)), auto)
1fr auto;
row-gap: var(--space-xs);
}
.header {
grid-column: span 2;
}
.content,
.footer {
grid-column: 2;
}
.content {
@include mixins.type-body;
p:not(:last-child) {
margin-block-end: 1em;
}
a {
color: var(--color-text-brand);
}
&.collapsed {
position: relative;
max-height: 15lh;
overflow-y: clip;
&::after {
content: '';
position: absolute;
inset-inline: 0;
inset-block-end: 0;
height: calc(var(--space-4xl) * 2);
background: linear-gradient(
to bottom,
transparent,
var(--color-bg-blend)
);
pointer-events: none;
}
}
}
.contentReadMore {
position: absolute;
inset-block-end: 0;
inset-inline-start: 0;
z-index: 1;
}
.actions {
display: flex;
margin-inline-start: calc(-1 * var(--space-sm));
button:not(:hover, :active) {
opacity: 0.7;
@@ -16,3 +71,7 @@
.actionsButtonGap {
margin-inline-end: auto;
}
.buttonAlign {
margin-inline-start: calc(-1 * var(--space-sm));
}