mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-28 14:03:41 -05:00
Status redesign: Single page and threading (#40625)
This commit is contained in:
@@ -2,12 +2,13 @@ import type { ComponentProps } from 'react';
|
||||
|
||||
import { FormattedDate } from 'react-intl';
|
||||
|
||||
export const FormattedDateWrapper = (
|
||||
props: ComponentProps<typeof FormattedDate> & { className?: string },
|
||||
) => (
|
||||
export const FormattedDateWrapper = ({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<typeof FormattedDate> & { className?: string }) => (
|
||||
<FormattedDate {...props}>
|
||||
{(date) => (
|
||||
<time dateTime={tryIsoString(props.value)} className={props.className}>
|
||||
<time dateTime={tryIsoString(props.value)} className={className}>
|
||||
{date}
|
||||
</time>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import {
|
||||
ArrowsClockwiseIcon,
|
||||
BookmarkSimpleIcon,
|
||||
@@ -77,6 +79,7 @@ interface StatusActionBarProps {
|
||||
statusId: string;
|
||||
withDismiss?: boolean;
|
||||
withCounters?: boolean;
|
||||
onlyInteractions?: boolean;
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -150,6 +153,7 @@ export const StatusActionBar: React.FC<StatusActionBarProps> = ({
|
||||
statusId,
|
||||
withDismiss,
|
||||
withCounters,
|
||||
onlyInteractions,
|
||||
}) => {
|
||||
const status = useStatus(statusId);
|
||||
const quotedAccountId = useAppSelector(
|
||||
@@ -211,8 +215,8 @@ export const StatusActionBar: React.FC<StatusActionBarProps> = ({
|
||||
const shouldShowQuoteRemovalHint =
|
||||
isQuotingMe && contextType === 'notifications';
|
||||
|
||||
return (
|
||||
<div className={classes.actions}>
|
||||
const interactionButtons = (
|
||||
<>
|
||||
<Button
|
||||
size='sm'
|
||||
clipPadding
|
||||
@@ -235,10 +239,20 @@ export const StatusActionBar: React.FC<StatusActionBarProps> = ({
|
||||
title={favouriteTitle}
|
||||
leadingIcon={favouriteIcon}
|
||||
onClick={handleFavouriteClick}
|
||||
className={classes.actionsButtonGap}
|
||||
className={classNames(!onlyInteractions && classes.actionsButtonGap)}
|
||||
>
|
||||
{withCounters && status.favourites_count}
|
||||
</ToggleButton>
|
||||
</>
|
||||
);
|
||||
|
||||
if (onlyInteractions) {
|
||||
return <div className={classes.actions}>{interactionButtons}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.actions}>
|
||||
{interactionButtons}
|
||||
|
||||
{isPublic && (
|
||||
<IconButton
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { FormattedDate, FormattedMessage } from 'react-intl';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
@@ -10,16 +10,15 @@ import type {
|
||||
StatusVisibility,
|
||||
} from '@/mastodon/models/status';
|
||||
|
||||
import { AnimatedNumber } from '../animated_number';
|
||||
import { FormattedDateWrapper } from '../formatted_date';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
import { statusLink } from './utils';
|
||||
|
||||
export const StatusMeta: React.FC<
|
||||
{
|
||||
status: Pick<
|
||||
AnyStatusShape,
|
||||
'account' | 'application' | 'created_at' | 'id' | 'visibility'
|
||||
>;
|
||||
} & React.ComponentPropsWithRef<'span'>
|
||||
> = ({ status, ...props }) => {
|
||||
export const StatusMeta: React.FC<{
|
||||
status: AnyStatusShape;
|
||||
}> = ({ status }) => {
|
||||
const { created_at, application } = status;
|
||||
|
||||
const createdAt = useMemo(() => {
|
||||
@@ -38,9 +37,67 @@ export const StatusMeta: React.FC<
|
||||
return null;
|
||||
}
|
||||
|
||||
const createdLink = (
|
||||
<Link to={statusLink(status)}>
|
||||
<FormattedDate
|
||||
let applicationDisplay: React.ReactNode = null;
|
||||
if (application?.website) {
|
||||
applicationDisplay = (
|
||||
<a href={application.website} target='_blank' rel='noopener noreferrer'>
|
||||
{application.name}
|
||||
</a>
|
||||
);
|
||||
} else if (application?.name) {
|
||||
applicationDisplay = <span>{application.name}</span>;
|
||||
}
|
||||
|
||||
const baseStatusLink = statusLink(status);
|
||||
|
||||
return (
|
||||
<div className={classes.meta}>
|
||||
<FormattedMessage
|
||||
id='status.replies_count'
|
||||
defaultMessage='{count, plural, one {{counter} reply} other {{counter} replies}}'
|
||||
values={{
|
||||
count: status.replies_count,
|
||||
counter: <AnimatedNumber value={status.replies_count} />,
|
||||
}}
|
||||
tagName='span'
|
||||
/>
|
||||
•
|
||||
<Link to={`${baseStatusLink}/quotes`}>
|
||||
<FormattedMessage
|
||||
id='status.quotes_count'
|
||||
defaultMessage='{count, plural, one {{counter} quote} other {{counter} quotes}}'
|
||||
values={{
|
||||
count: status.quotes_count,
|
||||
counter: <AnimatedNumber value={status.quotes_count} />,
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
•
|
||||
<Link to={`${baseStatusLink}/reblogs`}>
|
||||
<FormattedMessage
|
||||
id='status.reblogs_count'
|
||||
defaultMessage='{count, plural, one {{counter} boost} other {{counter} boosts}}'
|
||||
values={{
|
||||
count: status.reblogs_count,
|
||||
counter: <AnimatedNumber value={status.reblogs_count} />,
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
•
|
||||
<Link
|
||||
to={`${baseStatusLink}/favourites`}
|
||||
className={classes.actionsButtonGap}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='status.likes_count'
|
||||
defaultMessage='{count, plural, one {{counter} like} other {{counter} likes}}'
|
||||
values={{
|
||||
count: status.favourites_count,
|
||||
counter: <AnimatedNumber value={status.favourites_count} />,
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
<FormattedDateWrapper
|
||||
value={createdAt}
|
||||
year='numeric'
|
||||
month='short'
|
||||
@@ -48,43 +105,15 @@ export const StatusMeta: React.FC<
|
||||
hour='2-digit'
|
||||
minute='2-digit'
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
|
||||
let applicationLink: React.ReactNode = application?.name;
|
||||
if (application?.website) {
|
||||
applicationLink = (
|
||||
<a href={application.website} target='_blank' rel='noopener noreferrer'>
|
||||
{application.name}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span {...props}>
|
||||
{applicationLink ? (
|
||||
<FormattedMessage
|
||||
id='status.meta'
|
||||
defaultMessage='{createdAt} on {source} {sep} {visibility}'
|
||||
values={{
|
||||
createdAt: createdLink,
|
||||
source: applicationLink,
|
||||
visibility,
|
||||
sep: <>•</>,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='status.meta.no_application'
|
||||
defaultMessage='{createdAt} {sep} {visibility}'
|
||||
values={{
|
||||
createdAt: createdLink,
|
||||
visibility,
|
||||
sep: <>•</>,
|
||||
}}
|
||||
/>
|
||||
{applicationDisplay && (
|
||||
<>
|
||||
•
|
||||
{applicationDisplay}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
•
|
||||
<span>{visibility}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -31,12 +31,11 @@
|
||||
}
|
||||
|
||||
.replyIcon {
|
||||
border-block-start: 2px solid var(--color-border-primary);
|
||||
border-inline-start: 2px solid var(--color-border-primary);
|
||||
border-block-start: var(--bw-thick) solid var(--color-border-primary);
|
||||
border-inline-start: var(--bw-thick) solid var(--color-border-primary);
|
||||
border-top-left-radius: var(--radius-4);
|
||||
box-sizing: border-box;
|
||||
margin: var(--space-2) var(--col-gap) 0 var(--space-1);
|
||||
width: calc(var(--avatar-size) / 2 + 1px);
|
||||
width: calc(var(--avatar-size) / 2 - 1px);
|
||||
height: calc(100% - var(--space-2));
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
|
||||
showThread,
|
||||
headerContents,
|
||||
variant = contextToVariant(contextType),
|
||||
nextId,
|
||||
}) => {
|
||||
// Select data from store
|
||||
const { status, parent } = useAppSelector((state) =>
|
||||
@@ -96,6 +97,10 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
|
||||
);
|
||||
const contentWrapperId = useId();
|
||||
|
||||
const isNextReplyingToMe = useAppSelector(
|
||||
(state) => state.statuses.getIn([nextId, 'in_reply_to_id']) === statusId,
|
||||
);
|
||||
|
||||
// Handlers
|
||||
const {
|
||||
isFiltered,
|
||||
@@ -147,10 +152,15 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
|
||||
variant === 'page' && classes.variantPage,
|
||||
isQuotedPost && classes.isQuote,
|
||||
status.visibility === 'direct' && classes.isMessage,
|
||||
variant === 'thread' &&
|
||||
isNextReplyingToMe &&
|
||||
!showThread &&
|
||||
classes.connectNextReply,
|
||||
)}
|
||||
data-featured={featured ? 'true' : null}
|
||||
aria-label={screenReaderText}
|
||||
data-nosnippet={status.account.noindex || undefined}
|
||||
data-connect-next={nextId ? isNextReplyingToMe : undefined}
|
||||
>
|
||||
{!skipPrepend && (
|
||||
<StatusPrepend
|
||||
@@ -210,17 +220,16 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
|
||||
|
||||
{(variant === 'page' || (showActions && !isQuotedPost)) && (
|
||||
<footer className={classes.footer}>
|
||||
{variant === 'page' && (
|
||||
<StatusMeta status={status} className={classes.meta} />
|
||||
)}
|
||||
|
||||
{showActions && !isQuotedPost && (
|
||||
<StatusActionBar
|
||||
statusId={status.id}
|
||||
withDismiss={withDismiss}
|
||||
withCounters={withCounters}
|
||||
onlyInteractions={variant === 'page'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{variant === 'page' && <StatusMeta status={status} />}
|
||||
</footer>
|
||||
)}
|
||||
</StatusHotkeys>
|
||||
|
||||
@@ -16,13 +16,13 @@
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.variantPage {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
}
|
||||
|
||||
.variantThread {
|
||||
--avatar-size: 32px;
|
||||
|
||||
padding: var(--space-2) 0;
|
||||
}
|
||||
|
||||
.variantPage {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -70,6 +70,18 @@
|
||||
grid-column: var(--content-col);
|
||||
}
|
||||
|
||||
.connectNextReply {
|
||||
&::after {
|
||||
content: '';
|
||||
grid-column: 1;
|
||||
grid-row: 2 / span 2;
|
||||
border-inline-start: var(--bw-thick) solid var(--color-border-primary);
|
||||
width: calc(var(--avatar-size) / 2 - 1px);
|
||||
margin-inline-end: var(--col-gap);
|
||||
justify-self: end;
|
||||
}
|
||||
}
|
||||
|
||||
.translate {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -219,13 +231,21 @@
|
||||
.meta {
|
||||
@include mixins.type-micro;
|
||||
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
color: var(--color-text-secondary);
|
||||
padding-block-start: var(--space-2);
|
||||
margin-block-start: var(--space-2);
|
||||
border-block-start: var(--border-default);
|
||||
margin-inline: calc(-1 * var(--space-4));
|
||||
padding-inline: var(--space-4);
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
&:hover,
|
||||
&:hover * {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { ShouldUpdateScroll } from 'scroll-behavior';
|
||||
|
||||
import type { MastodonLocation } from 'mastodon/components/router';
|
||||
|
||||
export type ShouldUpdateScrollFn = (
|
||||
prevLocationContext: MastodonLocation | null,
|
||||
locationContext: MastodonLocation,
|
||||
) => boolean;
|
||||
export type ShouldUpdateScrollFn = ShouldUpdateScroll<MastodonLocation>;
|
||||
|
||||
/**
|
||||
* ScrollBehavior will automatically scroll to the top on navigations
|
||||
|
||||
@@ -23,10 +23,7 @@ export interface ScrollBehaviorContextType {
|
||||
registerElement: (
|
||||
key: string,
|
||||
element: HTMLElement,
|
||||
shouldUpdateScroll: (
|
||||
prevLocationContext: MastodonLocation | null,
|
||||
locationContext: MastodonLocation,
|
||||
) => boolean,
|
||||
shouldUpdateScroll: ShouldUpdateScrollFn,
|
||||
) => void;
|
||||
unregisterElement: (key: string) => void;
|
||||
scrollBehavior?: ScrollBehaviorInstance;
|
||||
|
||||
18
app/javascript/mastodon/features/status/index.tsx
Normal file
18
app/javascript/mastodon/features/status/index.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
|
||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||
import { isRedesignStatusEnabled } from '@/mastodon/utils/environment';
|
||||
|
||||
const LazyStatusRedesign = lazy(() =>
|
||||
import('./redesign').then(({ StatusPage }) => ({ default: StatusPage })),
|
||||
);
|
||||
const LazyStatusLegacy = lazy(() => import('./legacy'));
|
||||
|
||||
const StatusPage = () => (
|
||||
<Suspense fallback={<LoadingIndicator />}>
|
||||
{isRedesignStatusEnabled() ? <LazyStatusRedesign /> : <LazyStatusLegacy />}
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default StatusPage;
|
||||
38
app/javascript/mastodon/features/status/redesign.module.scss
Normal file
38
app/javascript/mastodon/features/status/redesign.module.scss
Normal file
@@ -0,0 +1,38 @@
|
||||
@use '@/styles/mastodon/mixins';
|
||||
|
||||
.mainStatus {
|
||||
border-bottom: var(--border-default);
|
||||
}
|
||||
|
||||
.thread {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-block-end: var(--border-default);
|
||||
|
||||
> article {
|
||||
padding-inline: var(--space-4);
|
||||
padding-block-start: var(--space-2);
|
||||
|
||||
&:first-child,
|
||||
&[data-connect-next='false'] + article {
|
||||
padding-block-start: var(--space-4);
|
||||
}
|
||||
|
||||
&[data-connect-next='false'],
|
||||
&:last-child {
|
||||
padding-block-end: var(--space-4);
|
||||
}
|
||||
|
||||
&[data-connect-next='false'] {
|
||||
border-block-end: var(--border-default);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.threadEnd {
|
||||
@include mixins.type-meta;
|
||||
|
||||
color: var(--color-text-tertiary);
|
||||
padding: var(--space-10) var(--space-4);
|
||||
text-align: center;
|
||||
}
|
||||
263
app/javascript/mastodon/features/status/redesign.tsx
Normal file
263
app/javascript/mastodon/features/status/redesign.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import type React from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { Helmet } from '@unhead/react/helmet';
|
||||
|
||||
import { Column } from '@/mastodon/components/column';
|
||||
import {
|
||||
ColumnHeader,
|
||||
ColumnSettingsMenu,
|
||||
} from '@/mastodon/components/column_header';
|
||||
import { DisplayNameSimple } from '@/mastodon/components/display_name/simple';
|
||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||
import {
|
||||
FOCUS_TARGET,
|
||||
NavigationFocusTarget,
|
||||
} from '@/mastodon/components/navigation_focus_target';
|
||||
import { StatusRedesign as Status } from '@/mastodon/components/status/status';
|
||||
import { ScrollContainer } from '@/mastodon/containers/scroll_container';
|
||||
import type { ShouldUpdateScrollFn } from '@/mastodon/containers/scroll_container/default_should_update_scroll';
|
||||
import { useExpandedStatus } from '@/mastodon/hooks/useStatus';
|
||||
import {
|
||||
getAncestorsIds,
|
||||
getDescendantsIds,
|
||||
} from '@/mastodon/selectors/contexts';
|
||||
import { useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import { BundleColumnError } from '../ui/components/bundle_column_error';
|
||||
import { useColumnsContext } from '../ui/util/columns_context';
|
||||
import {
|
||||
attachFullscreenListener,
|
||||
detachFullscreenListener,
|
||||
isFullscreen,
|
||||
} from '../ui/util/fullscreen';
|
||||
|
||||
import { RefreshController } from './components/refresh_controller';
|
||||
import classes from './redesign.module.scss';
|
||||
|
||||
const messages = defineMessages({
|
||||
revealAll: {
|
||||
id: 'status.show_more_all',
|
||||
defaultMessage: 'Show more for all',
|
||||
},
|
||||
hideAll: { id: 'status.show_less_all', defaultMessage: 'Show less for all' },
|
||||
statusTitleWithAttachments: {
|
||||
id: 'status.title.with_attachments',
|
||||
defaultMessage:
|
||||
'{user} posted {attachmentCount, plural, one {an attachment} other {# attachments}}',
|
||||
},
|
||||
detailedStatus: {
|
||||
id: 'status.detailed_status',
|
||||
defaultMessage: 'Detailed conversation view',
|
||||
},
|
||||
});
|
||||
|
||||
export const StatusPage: React.FC = () => {
|
||||
const { statusId } = useParams<{ acct: string; statusId: string }>();
|
||||
const { multiColumn } = useColumnsContext();
|
||||
const intl = useIntl();
|
||||
|
||||
const [fullscreen, setFullscreen] = useState(isFullscreen);
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setFullscreen(isFullscreen());
|
||||
};
|
||||
attachFullscreenListener(handler);
|
||||
|
||||
return () => {
|
||||
detachFullscreenListener(handler);
|
||||
};
|
||||
});
|
||||
|
||||
const status = useExpandedStatus(statusId, 'force');
|
||||
const isLoading = useAppSelector(
|
||||
(state) => !!state.statuses.getIn([statusId, 'isLoading']),
|
||||
);
|
||||
|
||||
const ancestorIds = useAppSelector((state) =>
|
||||
getAncestorsIds(state, statusId),
|
||||
);
|
||||
const descendantIds = useAppSelector((state) =>
|
||||
getDescendantsIds(state, statusId),
|
||||
);
|
||||
|
||||
const statusFocusRef = useRef<HTMLDivElement>(null);
|
||||
const shouldUpdateScroll: ShouldUpdateScrollFn = useCallback(
|
||||
(prevLocation, location) => {
|
||||
// Do not change scroll when opening a modal
|
||||
if (
|
||||
location.state?.mastodonModalKey !==
|
||||
prevLocation?.state?.mastodonModalKey
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Scroll to focused post if it is loaded
|
||||
if (statusFocusRef.current) {
|
||||
return [0, statusFocusRef.current.offsetTop];
|
||||
}
|
||||
|
||||
// Do not scroll otherwise, `componentDidUpdate` will take care of that
|
||||
return false;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Column>
|
||||
<LoadingIndicator />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
|
||||
}
|
||||
|
||||
const { account } = status;
|
||||
const isLocal = !account.acct.includes('@');
|
||||
const isIndexable = !account.noindex;
|
||||
|
||||
const columnTitle =
|
||||
status.visibility === 'direct' ? (
|
||||
<FormattedMessage
|
||||
id='status.title.message'
|
||||
defaultMessage='Message by {name}'
|
||||
values={{
|
||||
name: <DisplayNameSimple account={account} />,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='status.title'
|
||||
defaultMessage='Post by {name}'
|
||||
values={{
|
||||
name: <DisplayNameSimple account={account} />,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const accountName = account.display_name || account.username;
|
||||
const titleText =
|
||||
!!status.search_index &&
|
||||
(status.search_index.length > 30
|
||||
? `${status.search_index.slice(0, 30)}…`
|
||||
: status.search_index);
|
||||
|
||||
const pageTitle = titleText
|
||||
? `${accountName}: "${titleText}"`
|
||||
: intl.formatMessage(messages.statusTitleWithAttachments, {
|
||||
user: accountName,
|
||||
attachmentCount: status.media_attachments.length,
|
||||
});
|
||||
|
||||
return (
|
||||
<Column
|
||||
bindToDocument={!multiColumn}
|
||||
label={intl.formatMessage(messages.detailedStatus)}
|
||||
>
|
||||
<ColumnHeader
|
||||
withBackButton
|
||||
title={columnTitle}
|
||||
extraButtons={
|
||||
<ColumnSettingsMenu
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='status.options'
|
||||
defaultMessage='Post options'
|
||||
/>
|
||||
}
|
||||
>
|
||||
WIP: This menu will contain post actions from the new Status
|
||||
component
|
||||
</ColumnSettingsMenu>
|
||||
}
|
||||
/>
|
||||
|
||||
<ScrollContainer
|
||||
scrollKey='thread'
|
||||
shouldUpdateScroll={shouldUpdateScroll}
|
||||
>
|
||||
<div
|
||||
className={classNames({
|
||||
fullscreen,
|
||||
})}
|
||||
>
|
||||
{
|
||||
// Length needs to be greater than 1, as ancestorIds includes the current root ID.
|
||||
ancestorIds.length > 1 && (
|
||||
<div className={classes.thread}>
|
||||
<StatusRelativeList statusIds={ancestorIds} rootId={statusId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<NavigationFocusTarget
|
||||
as='div'
|
||||
focusTargetName={FOCUS_TARGET.POST}
|
||||
className={classes.mainStatus}
|
||||
tabIndex={0}
|
||||
ref={statusFocusRef}
|
||||
>
|
||||
<Status id={statusId} contextType='detailed' />
|
||||
</NavigationFocusTarget>
|
||||
|
||||
{descendantIds.length > 0 && (
|
||||
<div className={classes.thread}>
|
||||
<StatusRelativeList statusIds={descendantIds} rootId={statusId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={classes.threadEnd}>
|
||||
<FormattedMessage
|
||||
id='status.thread_end'
|
||||
defaultMessage='You’ve reached the end of the conversation.'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RefreshController
|
||||
isLocal={isLocal}
|
||||
statusId={status.id}
|
||||
statusCreatedAt={status.created_at}
|
||||
/>
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
|
||||
<Helmet>
|
||||
<title>{pageTitle}</title>
|
||||
<meta
|
||||
name='robots'
|
||||
content={isLocal && isIndexable ? 'all' : 'noindex'}
|
||||
/>
|
||||
<link rel='canonical' href={status.url ?? status.uri} />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
const StatusRelativeList: React.FC<{
|
||||
statusIds: string[];
|
||||
rootId: string;
|
||||
}> = ({ statusIds, rootId }) => {
|
||||
return (
|
||||
statusIds
|
||||
// Omits the current post ID, but it still is in statusIds so nextId can link correctly.
|
||||
.filter((statusId) => statusId !== rootId)
|
||||
.map((statusId, index) => (
|
||||
<Status
|
||||
key={statusId}
|
||||
id={statusId}
|
||||
rootId={rootId}
|
||||
contextType='thread'
|
||||
previousId={statusIds[index - 1]}
|
||||
nextId={statusIds[index + 1]}
|
||||
/>
|
||||
))
|
||||
);
|
||||
};
|
||||
@@ -11,31 +11,36 @@ import {
|
||||
import { useAppDispatch, useAppSelector } from '../store';
|
||||
|
||||
type IdType = string | null | undefined;
|
||||
type FetchArg = boolean | 'force';
|
||||
|
||||
export function useStatus(id: IdType, fetch = false) {
|
||||
export function useStatus(id: IdType, fetch: FetchArg = false) {
|
||||
const status = useAppSelector((state) => selectPlainStatus(state, id));
|
||||
|
||||
useStatusFetch(fetch && id);
|
||||
useStatusFetch(fetch && id, { force: fetch === 'force' });
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/** Gets status with full account information, fetching missing data if enabled. */
|
||||
export function useAccountStatus(id: IdType, fetch = false) {
|
||||
export function useAccountStatus(id: IdType, fetch: FetchArg = false) {
|
||||
const status = useAppSelector((state) => selectAccountStatus(state, id));
|
||||
|
||||
useStatusFetch(fetch && id, { withAccount: true });
|
||||
useStatusFetch(fetch && id, { withAccount: true, force: fetch === 'force' });
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/** Adds reblog status and account information to standard Status */
|
||||
export function useExpandedStatus(id: IdType, fetch = false) {
|
||||
export function useExpandedStatus(id: IdType, fetch: FetchArg = false) {
|
||||
const status = useAppSelector((state) =>
|
||||
selectExpandedStatus(state, id ?? undefined),
|
||||
);
|
||||
|
||||
useStatusFetch(fetch && id, { withAccount: true, withReblog: true });
|
||||
useStatusFetch(fetch && id, {
|
||||
withAccount: true,
|
||||
withReblog: true,
|
||||
force: fetch === 'force',
|
||||
});
|
||||
|
||||
return status;
|
||||
}
|
||||
@@ -45,7 +50,8 @@ export function useStatusFetch(
|
||||
{
|
||||
withAccount,
|
||||
withReblog,
|
||||
}: { withAccount?: boolean; withReblog?: boolean } = {},
|
||||
force: forceFetch,
|
||||
}: { withAccount?: boolean; withReblog?: boolean; force?: boolean } = {},
|
||||
) {
|
||||
const status = useAppSelector((state) =>
|
||||
selectPlainStatus(state, id || null),
|
||||
@@ -63,11 +69,20 @@ export function useStatusFetch(
|
||||
return;
|
||||
}
|
||||
if (!status) {
|
||||
dispatch(fetchStatus(id));
|
||||
dispatch(fetchStatus(id, { forceFetch }));
|
||||
} else if (withAccount && status.account && !account) {
|
||||
dispatch(fetchAccount(status.account));
|
||||
} else if (withReblog && status.reblog && !reblog) {
|
||||
dispatch(fetchStatus(status.reblog));
|
||||
dispatch(fetchStatus(status.reblog, { forceFetch }));
|
||||
}
|
||||
}, [account, dispatch, id, reblog, status, withAccount, withReblog]);
|
||||
}, [
|
||||
account,
|
||||
dispatch,
|
||||
forceFetch,
|
||||
id,
|
||||
reblog,
|
||||
status,
|
||||
withAccount,
|
||||
withReblog,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1391,6 +1391,7 @@
|
||||
"status.header.to_followers": "{displayName} to Followers",
|
||||
"status.history.created": "{name} created {date}",
|
||||
"status.history.edited": "{name} edited {date}",
|
||||
"status.likes_count": "{count, plural, one {{counter} like} other {{counter} likes}}",
|
||||
"status.likes_title": "Post Likes",
|
||||
"status.link_preview.authors": "{count, plural, one {Find the author in the Fediverse:} other {Find the authors in the Fediverse:}}",
|
||||
"status.load_more": "Load more",
|
||||
@@ -1398,8 +1399,6 @@
|
||||
"status.media.show": "Click to show",
|
||||
"status.media_hidden": "Media hidden",
|
||||
"status.mention": "Mention @{name}",
|
||||
"status.meta": "{createdAt} on {source} {sep} {visibility}",
|
||||
"status.meta.no_application": "{createdAt} {sep} {visibility}",
|
||||
"status.more": "More",
|
||||
"status.mute": "Mute @{name}",
|
||||
"status.mute_conversation": "Mute conversation",
|
||||
@@ -1444,6 +1443,7 @@
|
||||
"status.remove_quote": "Remove",
|
||||
"status.replied_in_thread": "Replied in thread",
|
||||
"status.replied_to": "Replied to {name}",
|
||||
"status.replies_count": "{count, plural, one {{counter} reply} other {{counter} replies}}",
|
||||
"status.reply": "Reply",
|
||||
"status.replyAll": "Reply to thread",
|
||||
"status.report": "Report @{name}",
|
||||
@@ -1454,6 +1454,7 @@
|
||||
"status.show_less_all": "Show less for all",
|
||||
"status.show_more_all": "Show more for all",
|
||||
"status.show_original": "Show original",
|
||||
"status.thread_end": "You’ve reached the end of the conversation.",
|
||||
"status.title": "Post by {name}",
|
||||
"status.title.message": "Message by {name}",
|
||||
"status.title.with_attachments": "{user} posted {attachmentCount, plural, one {an attachment} other {{attachmentCount} attachments}}",
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { createAppSelector } from 'mastodon/store';
|
||||
|
||||
export const getAncestorsIds = createAppSelector(
|
||||
[(_, id: string) => id, (state) => state.contexts.inReplyTos],
|
||||
[(_, id?: string) => id, (state) => state.contexts.inReplyTos],
|
||||
(statusId, inReplyTos) => {
|
||||
if (!statusId) {
|
||||
return [];
|
||||
}
|
||||
const ancestorsIds: string[] = [];
|
||||
|
||||
let currentId: string | undefined = statusId;
|
||||
|
||||
@@ -21,7 +21,8 @@ export const getStatusList = createAppSelector(
|
||||
export const selectPlainStatus = createAppSelector(
|
||||
[(state, statusId?: string | null) => state.statuses.get(statusId ?? '')],
|
||||
(status) => {
|
||||
if (!status) {
|
||||
// Check for statuses that are just `{isLoading: true}`.
|
||||
if (!status?.get('id')) {
|
||||
return null;
|
||||
}
|
||||
return status.toJS() as unknown as StatusShape;
|
||||
|
||||
Reference in New Issue
Block a user