From 07dc097f42e2548088ff53565ebcc6a87dcd92a1 Mon Sep 17 00:00:00 2001 From: Echo Date: Wed, 16 Sep 2026 15:58:23 +0000 Subject: [PATCH] Status redesign: Boosts and replies (#40555) --- .../components/status/prepend.module.scss | 62 +++++++ .../mastodon/components/status/prepend.tsx | 168 ++++++++++++------ .../mastodon/components/status/status.tsx | 9 +- .../components/status/styles.module.scss | 20 ++- app/javascript/mastodon/hooks/useStatus.ts | 72 +++++++- 5 files changed, 269 insertions(+), 62 deletions(-) create mode 100644 app/javascript/mastodon/components/status/prepend.module.scss diff --git a/app/javascript/mastodon/components/status/prepend.module.scss b/app/javascript/mastodon/components/status/prepend.module.scss new file mode 100644 index 00000000000..3645d2d518a --- /dev/null +++ b/app/javascript/mastodon/components/status/prepend.module.scss @@ -0,0 +1,62 @@ +@use '@/styles/mastodon/mixins'; + +.root { + @include mixins.type-label-md; + + display: grid; + grid-template-columns: subgrid; + grid-template-rows: 1fr; + align-items: center; + color: var(--color-text-secondary); + grid-column: span 2; + + a { + color: inherit; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + // When hovering over any link with the account ID, underline them all. + &:has(a[data-id]:hover) a[data-id] { + text-decoration: underline; + } +} + +.reblogIcon { + padding-inline-end: var(--col-gap); + justify-self: end; + color: var(--color-text-brand); +} + +.replyIcon { + border-block-start: 2px solid var(--color-border-primary); + border-inline-start: 2px solid var(--color-border-primary); + border-top-left-radius: var(--radius-md); + box-sizing: border-box; + margin: var(--space-xs) var(--col-gap) 0 var(--space-2xs); + width: calc(var(--avatar-size) / 2 + 1px); + height: calc(100% - var(--space-xs)); + justify-self: end; +} + +.contents { + display: flex; + align-items: center; + gap: var(--space-2xs); +} + +// Add a tag to override .root a +a.account { + font-weight: bold; + color: var(--color-text-primary); +} + +.text { + @include mixins.line-clamp(1); + + display: block; + margin-block-start: var(--space-2xs); +} diff --git a/app/javascript/mastodon/components/status/prepend.tsx b/app/javascript/mastodon/components/status/prepend.tsx index d69eb8b621c..188225e4d1f 100644 --- a/app/javascript/mastodon/components/status/prepend.tsx +++ b/app/javascript/mastodon/components/status/prepend.tsx @@ -1,67 +1,135 @@ +import type React from 'react'; + import { FormattedMessage } from 'react-intl'; -import type { ExpandedStatusShape } from '@/mastodon/models/status'; -import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; -import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; +import { Link } from 'react-router-dom'; -import { LinkedDisplayName } from '../display_name'; +import { ArrowsClockwiseIcon } from '@phosphor-icons/react'; + +import { useAccountStatus } from '@/mastodon/hooks/useStatus'; +import type { ExpandedStatusShape } from '@/mastodon/models/status'; + +import { Avatar } from '../avatar'; +import { DisplayName } from '../display_name'; +import { EmojiHTML } from '../emoji/html'; import { Icon } from '../icon'; -import { StatusThreadLabel } from '../status_thread_label'; +import { RelativeTimestamp } from '../relative_timestamp'; + +import { onStatusLinksDisabled } from './hooks'; +import classes from './prepend.module.scss'; +import { statusLink } from './utils'; export const StatusPrepend: React.FC<{ status: ExpandedStatusShape; + reblogId?: string; showThread?: boolean; - isReblog?: boolean; -}> = ({ status, showThread, isReblog }) => { - if (isReblog) { - return ( -
-
- -
+}> = ({ status, showThread, reblogId }) => { + if (!reblogId && (!showThread || !status.in_reply_to_id)) { + return null; + } + + return ( + <> + {!!reblogId && } + {showThread && !!status.in_reply_to_id && ( + + )} + + ); +}; + +const StatusPrependReblog: React.FC<{ reblogId: string }> = ({ reblogId }) => { + const status = useAccountStatus(reblogId, true); + if (!status) { + return null; + } + + const account = status.account; + const accountLinkProps = { + to: { + pathname: `/@${account.acct}`, + state: { reference: 'status' }, + }, + title: `@${account.acct}`, + 'data-id': account.id, + 'data-hover-card-account': account.id, + 'data-hover-card-reference': 'status', + }; + return ( +
+ + + + + + + + + ), }} - tagName='span' /> -
- ); - } - - if (status.visibility === 'direct') { - return ( -
-
- -
- -
- ); - } - - if (showThread && status.in_reply_to_account_id) { - return ( - - ); - } - - return null; +  • + + +
+ ); +}; + +const StatusPrependReply: React.FC<{ replyId: string }> = ({ replyId }) => { + const status = useAccountStatus(replyId, true); + + if (!status) { + return null; + } + + const account = status.account; + const accountLinkProps = { + to: { + pathname: `/@${account.acct}`, + state: { reference: 'status' }, + }, + title: `@${account.acct}`, + 'data-id': account.id, + 'data-hover-card-account': account.id, + 'data-hover-card-reference': 'status', + }; + + const language = status.translation?.language ?? status.language; + + return ( +
+
+ +
+ + + + + + + + • + + + + + + + + +
+
+ ); }; diff --git a/app/javascript/mastodon/components/status/status.tsx b/app/javascript/mastodon/components/status/status.tsx index b3b7af29151..bc99a5fe179 100644 --- a/app/javascript/mastodon/components/status/status.tsx +++ b/app/javascript/mastodon/components/status/status.tsx @@ -113,8 +113,6 @@ export const StatusRedesign: React.FC = ({ return null; // loading state } - const actualStatus = parent ?? status; - const hotkeysProps = { handlers: { ...handlers, @@ -148,6 +146,7 @@ export const StatusRedesign: React.FC = ({ variant === 'thread' && classes.variantThread, variant === 'page' && classes.variantPage, isQuotedPost && classes.isQuote, + status.visibility === 'direct' && classes.isMessage, )} data-featured={featured ? 'true' : null} aria-label={screenReaderText} @@ -155,8 +154,8 @@ export const StatusRedesign: React.FC = ({ > {!skipPrepend && ( )} @@ -168,7 +167,7 @@ export const StatusRedesign: React.FC = ({ selectPlainStatus(state, id)); +import { fetchAccount } from '../actions/accounts'; +import { fetchStatus } from '../actions/statuses'; +import { selectPlainAccount } from '../selectors/accounts'; +import { + selectAccountStatus, + selectExpandedStatus, + selectPlainStatus, +} from '../selectors/statuses'; +import { useAppDispatch, useAppSelector } from '../store'; + +type IdType = string | null | undefined; + +export function useStatus(id: IdType, fetch = false) { + const status = useAppSelector((state) => selectPlainStatus(state, id)); + + useStatusFetch(fetch && id); + + return status; +} + +/** Gets status with full account information, fetching missing data if enabled. */ +export function useAccountStatus(id: IdType, fetch = false) { + const status = useAppSelector((state) => selectAccountStatus(state, id)); + + useStatusFetch(fetch && id, { withAccount: true }); + + return status; } /** Adds reblog status and account information to standard Status */ -export function useExpandedStatus(id?: string | null) { - return useAppSelector((state) => +export function useExpandedStatus(id: IdType, fetch = false) { + const status = useAppSelector((state) => selectExpandedStatus(state, id ?? undefined), ); + + useStatusFetch(fetch && id, { withAccount: true, withReblog: true }); + + return status; +} + +export function useStatusFetch( + id: IdType | false, + { + withAccount, + withReblog, + }: { withAccount?: boolean; withReblog?: boolean } = {}, +) { + const status = useAppSelector((state) => + selectPlainStatus(state, id || null), + ); + const account = useAppSelector((state) => + selectPlainAccount(state, status?.account), + ); + const reblog = useAppSelector((state) => + selectPlainStatus(state, status?.reblog), + ); + const dispatch = useAppDispatch(); + + useEffect(() => { + if (!id) { + return; + } + if (!status) { + dispatch(fetchStatus(id)); + } else if (withAccount && !account) { + dispatch(fetchAccount(status.account)); + } else if (withReblog && status.reblog && !reblog) { + dispatch(fetchStatus(status.reblog)); + } + }, [account, dispatch, id, reblog, status, withAccount, withReblog]); }