mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-28 07:01:07 -05:00
Status redesign: Boosts and replies (#40555)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className='status__prepend'>
|
||||
<div className='status__prepend__icon'>
|
||||
<Icon id='retweet' icon={RepeatIcon} />
|
||||
</div>
|
||||
}> = ({ status, showThread, reblogId }) => {
|
||||
if (!reblogId && (!showThread || !status.in_reply_to_id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!!reblogId && <StatusPrependReblog reblogId={reblogId} />}
|
||||
{showThread && !!status.in_reply_to_id && (
|
||||
<StatusPrependReply replyId={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 (
|
||||
<div className={classes.root}>
|
||||
<Icon icon={ArrowsClockwiseIcon} className={classes.reblogIcon} />
|
||||
|
||||
<span className={classes.contents}>
|
||||
<Link {...accountLinkProps} role='presentation' tabIndex={-1}>
|
||||
<Avatar account={account} />
|
||||
</Link>
|
||||
<FormattedMessage
|
||||
id='status.reblogged_by'
|
||||
defaultMessage='{name} boosted'
|
||||
values={{
|
||||
name: (
|
||||
<LinkedDisplayName
|
||||
displayProps={{
|
||||
account: status.account,
|
||||
variant: 'simple',
|
||||
}}
|
||||
className='status__display-name muted'
|
||||
/>
|
||||
<Link {...accountLinkProps} className={classes.account}>
|
||||
<DisplayName variant='simple' account={account} />
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
tagName='span'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status.visibility === 'direct') {
|
||||
return (
|
||||
<div className='status__prepend'>
|
||||
<div className='status__prepend__icon'>
|
||||
<Icon id='at' icon={AlternateEmailIcon} />
|
||||
</div>
|
||||
<FormattedMessage
|
||||
id='status.direct_indicator'
|
||||
defaultMessage='Private mention'
|
||||
tagName='span'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (showThread && status.in_reply_to_account_id) {
|
||||
return (
|
||||
<StatusThreadLabel
|
||||
accountId={status.account.id}
|
||||
inReplyToAccountId={status.in_reply_to_account_id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
•
|
||||
<RelativeTimestamp timestamp={status.created_at} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className={classes.root}>
|
||||
<div className={classes.replyIcon} />
|
||||
|
||||
<div>
|
||||
<span className={classes.contents}>
|
||||
<Link {...accountLinkProps} role='presentation' tabIndex={-1}>
|
||||
<Avatar account={account} />
|
||||
</Link>
|
||||
<Link {...accountLinkProps} className={classes.account}>
|
||||
<DisplayName variant='simple' account={account} />
|
||||
</Link>
|
||||
•
|
||||
<Link to={statusLink(status)}>
|
||||
<RelativeTimestamp timestamp={status.created_at} />
|
||||
</Link>
|
||||
</span>
|
||||
|
||||
<Link to={statusLink(status)} className={classes.text}>
|
||||
<EmojiHTML
|
||||
as='blockquote'
|
||||
lang={language}
|
||||
htmlString={status.translation?.contentHtml ?? status.contentHtml}
|
||||
extraEmojis={status.emojis}
|
||||
onElement={onStatusLinksDisabled}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -113,8 +113,6 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
|
||||
return null; // loading state
|
||||
}
|
||||
|
||||
const actualStatus = parent ?? status;
|
||||
|
||||
const hotkeysProps = {
|
||||
handlers: {
|
||||
...handlers,
|
||||
@@ -148,6 +146,7 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
|
||||
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<StatusRedesignProps> = ({
|
||||
>
|
||||
{!skipPrepend && (
|
||||
<StatusPrepend
|
||||
status={actualStatus}
|
||||
isReblog={!!parent}
|
||||
status={status}
|
||||
reblogId={parent?.id}
|
||||
showThread={showThread}
|
||||
/>
|
||||
)}
|
||||
@@ -168,7 +167,7 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
|
||||
<TranslateButton status={status} onTranslate={onTranslate} />
|
||||
|
||||
<StatusWarning
|
||||
statusId={actualStatus.id}
|
||||
statusId={status.id}
|
||||
dismissedFilter={showDespiteFilter}
|
||||
onFilterToggle={onFilterToggle}
|
||||
wrapperId={contentWrapperId}
|
||||
|
||||
@@ -27,6 +27,25 @@
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.isMessage {
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
mask-repeat: repeat;
|
||||
mask-image: url('@/images/composer_message.svg');
|
||||
mask-size: 311.36px auto;
|
||||
background-color: var(--color-border-brand);
|
||||
opacity: 0.15;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.variantPage,
|
||||
.isQuote {
|
||||
--content-col: span 2;
|
||||
@@ -40,7 +59,6 @@
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.root :global(.status__prepend),
|
||||
.spoiler,
|
||||
.translate,
|
||||
.contentWrapper,
|
||||
|
||||
@@ -1,13 +1,73 @@
|
||||
import { selectExpandedStatus, selectPlainStatus } from '../selectors/statuses';
|
||||
import { useAppSelector } from '../store';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export function useStatus(id?: string | null) {
|
||||
return useAppSelector((state) => 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]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user