mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-12 16:35:57 -05:00
Status redesign: Content (#40385)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user