Add Status component stories (#39475)

This commit is contained in:
Echo
2026-06-18 12:38:29 +02:00
committed by GitHub
parent b3a100bc72
commit 0f44c63498
8 changed files with 798 additions and 30 deletions

View File

@@ -7,7 +7,7 @@ export type MediaAttachmentType =
| 'unknown'
| 'audio';
interface BaseApiMediaAttachmentJSON {
export interface BaseApiMediaAttachmentJSON {
id: string;
type: MediaAttachmentType;
url: string;
@@ -19,7 +19,7 @@ interface BaseApiMediaAttachmentJSON {
blurhash: string;
}
interface ApiImageAttachmentJSON extends BaseApiMediaAttachmentJSON {
export interface ApiImageAttachmentJSON extends BaseApiMediaAttachmentJSON {
type: 'image';
meta: {
original: ApiImageAttachmentMetaJSON;
@@ -27,7 +27,7 @@ interface ApiImageAttachmentJSON extends BaseApiMediaAttachmentJSON {
};
}
interface ApiAudioAttachmentJSON extends BaseApiMediaAttachmentJSON {
export interface ApiAudioAttachmentJSON extends BaseApiMediaAttachmentJSON {
type: 'audio';
meta: {
colors: ApiColorsAttachmentMetaJSON;
@@ -36,7 +36,7 @@ interface ApiAudioAttachmentJSON extends BaseApiMediaAttachmentJSON {
};
}
interface ApiVideoAttachmentJSON extends BaseApiMediaAttachmentJSON {
export interface ApiVideoAttachmentJSON extends BaseApiMediaAttachmentJSON {
type: 'video';
meta: {
colors: ApiColorsAttachmentMetaJSON;
@@ -49,7 +49,7 @@ interface ApiVideoAttachmentJSON extends BaseApiMediaAttachmentJSON {
};
}
interface ApiGifvAttachmentJSON extends BaseApiMediaAttachmentJSON {
export interface ApiGifvAttachmentJSON extends BaseApiMediaAttachmentJSON {
type: 'gifv';
meta: {
original: ApiVideoAttachmentMetaJSON;
@@ -57,7 +57,7 @@ interface ApiGifvAttachmentJSON extends BaseApiMediaAttachmentJSON {
};
}
interface ApiUnknownAttachmentJSON extends BaseApiMediaAttachmentJSON {
export interface ApiUnknownAttachmentJSON extends BaseApiMediaAttachmentJSON {
type: 'unknown';
meta: unknown;
}

View File

@@ -22,7 +22,7 @@ interface ApiNestedQuoteJSON {
interface ApiQuoteAcceptedJSON {
state: 'accepted';
quoted_status: Omit<ApiStatusJSON, 'quote'> & {
quote: ApiNestedQuoteJSON | ApiQuoteEmptyJSON;
quote?: ApiNestedQuoteJSON | ApiQuoteEmptyJSON;
};
}

View File

@@ -0,0 +1,540 @@
import type { FC } from 'react';
import { useMemo } from 'react';
import { Map as ImmutableMap } from 'immutable';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { fn } from 'storybook/test';
import type { ApiMediaAttachmentJSON } from '@/mastodon/api_types/media_attachments';
import type { StatusVisibility } from '@/mastodon/api_types/statuses';
import {
accountFactoryState,
mediaAttachmentFactory,
pollFactory,
statusFactory,
statusFactoryState,
} from '@/testing/factories';
import { TypedStatus } from './types';
type ContextTypes =
| 'account'
| 'bookmarks'
| 'detailed'
| 'favourites'
| 'home'
| 'notifications'
| 'public'
| 'search'
| 'thread';
type AttachmentTypes =
| 'image-1'
| 'image-2'
| 'image-3'
| 'video'
| 'audio'
| 'gifv'
| 'unknown';
interface StatusStoryProps {
// Contents
text: string;
visibility: StatusVisibility;
isReblog?: boolean;
isReply?: boolean;
isPoll?: boolean;
isQuote?: boolean;
attachments?: AttachmentTypes;
contentWarning?: string;
// Interactions
hasFavourited?: boolean;
hasReblogged?: boolean;
hasBookmarked?: boolean;
hasReplied?: boolean;
hasFilter?: boolean;
hasVoted?: boolean;
disableActions?: boolean;
showTranslate?: boolean;
// Display
showThread?: boolean;
contextType?: ContextTypes;
showCounters?: boolean;
favouriteCount?: number;
reblogCount?: number;
replyCount?: number;
hidden?: boolean;
muted?: boolean;
showPrepend?: boolean;
}
const otherAccount = accountFactoryState({
id: '2',
display_name: 'Another user',
});
const StatusStoryComponent: FC<StatusStoryProps> = (props) => {
const {
text,
visibility,
isReblog,
isReply,
isPoll,
isQuote,
attachments,
contentWarning,
hasFavourited,
hasReblogged,
hasBookmarked,
hasFilter,
hasVoted,
showTranslate,
disableActions = false,
contextType,
showThread,
showCounters,
favouriteCount = 0,
replyCount = 0,
reblogCount = 0,
hidden,
muted,
showPrepend = true,
} = props;
const { account, status } = useMemo(() => {
const account = accountFactoryState();
const media_attachments: ApiMediaAttachmentJSON[] = [];
switch (attachments) {
// Use fall through add attachments depending on count.
case 'image-3':
media_attachments.push(
mediaAttachmentFactory({
id: '2',
url: 'https://cataas.com/cat/EbVq9zMc4Xxv7s73',
meta: {
original: {
width: 960,
height: 1280,
size: '960x1280',
aspect: 0.75,
},
},
}),
);
// eslint-disable-next-line no-fallthrough
case 'image-2':
media_attachments.push(
mediaAttachmentFactory({
id: '3',
url: 'https://cataas.com/cat/YFaQ4xWYoWURSz37',
meta: {
original: {
width: 964,
height: 1280,
size: '964x1280',
aspect: 0.753125,
},
},
}),
);
// eslint-disable-next-line no-fallthrough
case 'image-1':
media_attachments.push(
mediaAttachmentFactory({
id: '4',
url: 'https://cataas.com/cat/bYBTjiFUqjUPIBUD',
meta: {
original: {
width: 1280,
height: 964,
size: '1280x964',
aspect: 1.32780083,
},
},
}),
);
break;
case 'video':
media_attachments.push(
mediaAttachmentFactory({
type: 'video',
url: 'https://www.pexels.com/download/video/11760787/',
meta: {
original: {
width: 2160,
height: 4096,
},
},
}),
);
break;
case 'audio':
media_attachments.push(
mediaAttachmentFactory({
type: 'audio',
url: 'https://upload.wikimedia.org/wikipedia/commons/4/40/Elephant_voice_-_trumpeting.ogg',
}),
);
break;
case 'gifv':
media_attachments.push(
mediaAttachmentFactory({
type: 'gifv',
url: 'https://www.pexels.com/download/video/11760787/',
meta: {
original: {
width: 2160,
height: 4096,
},
},
}),
);
break;
case 'unknown':
media_attachments.push(mediaAttachmentFactory({ type: attachments }));
break;
}
return {
account,
status: statusFactoryState({
text,
spoiler_text: contentWarning,
visibility,
media_attachments,
reblogged: hasReblogged,
favourited: hasFavourited,
bookmarked: hasBookmarked,
in_reply_to_account_id: isReply ? '2' : undefined,
in_reply_to_id: isReply ? '2' : undefined,
quote: isQuote
? {
state: 'accepted',
quoted_status: { ...statusFactory(), quote: undefined },
}
: undefined,
favourites_count: favouriteCount,
reblogs_count: reblogCount,
replies_count: replyCount,
language: showTranslate ? 'xx' : undefined,
}).withMutations((status) => {
status.set('account', account);
status.set('matched_filters', hasFilter ? ['test'] : false);
status.set('matched_media_filters', hasFilter ? ['test'] : false);
status.set('hidden', hidden);
// StatusActionBar checks specifically for null so undefined doesn't work.
if (!status.get('in_reply_to_id')) {
status.set('in_reply_to_id', null);
}
if (isReblog) {
status.set(
'reblog',
statusFactoryState({ id: '2' }).set('account', otherAccount),
);
}
if (isPoll) {
status.set('poll', hasVoted ? '2' : '1');
}
}),
};
}, [
attachments,
text,
contentWarning,
visibility,
hasReblogged,
hasFavourited,
hasBookmarked,
isReply,
isQuote,
favouriteCount,
reblogCount,
replyCount,
showTranslate,
hasFilter,
hidden,
isReblog,
isPoll,
hasVoted,
]);
return (
<div style={{ width: 'min(600px, 80vw)' }}>
<TypedStatus
{...staticProps}
key={JSON.stringify(props)} // Update on any props change. Required because Status has updateOnProps set.
status={status}
account={isReblog ? account : undefined}
isQuotedPost={isQuote}
showActions={!disableActions}
contextType={contextType}
withCounters={showCounters}
// Either we are showing a thread (in a timeline) or it's a full reply chain view.
showThread={isReply && showThread}
previousId={isReply && !showThread ? '2' : undefined}
rootId={isReply && !showThread ? '2' : undefined}
nextInReplyToId={isReply && !showThread ? '1' : undefined}
muted={muted}
hidden={hidden && !contentWarning && !hasFilter}
skipPrepend={!showPrepend}
withDismiss={contextType === 'notifications'}
/>
</div>
);
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const staticProps = Object.fromEntries(
// As Storybook auto-names from args only,
// we need to manually name these for proper action tracking.
Object.entries({
onReply: fn(),
onFavourite: fn(),
onMention: fn(),
onOpenMedia: fn(),
onOpenVideo: fn(),
onQuote: fn(),
onReblog: fn(),
onToggleCollapsed: fn(),
onToggleHidden: fn(),
onTranslate: fn(),
onAddFilter: fn(),
onBlock: fn(),
onClick: fn(),
onDelete: fn(),
onDirect: fn(),
onEmbed: fn(),
onHeightChange: fn(),
onInteractionModal: fn(),
onPin: fn(),
deployPictureInPicture: fn(),
} as const)
.map(([key, value]) => [key, value.mockName(key)])
.concat([
[
'pictureInPicture',
ImmutableMap<'inUse' | 'available', boolean>({
inUse: false,
available: true,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Casting to solves infinite recursion errors.
}) as any,
],
]),
);
const categoryContents = {
table: {
category: 'contents',
},
} as const;
const categoryInteraction = {
table: {
category: 'interactions',
},
} as const;
const categoryDisplay = {
table: {
category: 'display',
},
} as const;
const meta = {
title: 'Components/Status/Status',
component: StatusStoryComponent,
argTypes: {
// Contents
visibility: {
...categoryContents,
control: 'inline-radio',
options: [
'direct',
'private',
'public',
'unlisted',
] satisfies StatusVisibility[],
},
isReblog: categoryContents,
isReply: categoryContents,
isPoll: categoryContents,
isQuote: categoryContents,
text: categoryContents,
attachments: {
...categoryContents,
control: 'select',
options: [
'One image',
'Two images',
'Three images',
'Video',
'Audio',
'GIF',
'Other',
],
mapping: {
'One image': 'image-1',
'Two images': 'image-2',
'Three images': 'image-3',
Video: 'video',
Audio: 'audio',
GIF: 'gifv',
Other: 'unknown',
} satisfies Record<string, AttachmentTypes>,
},
contentWarning: categoryContents,
// Interactions
hasFavourited: categoryInteraction,
hasReblogged: categoryInteraction,
hasBookmarked: categoryInteraction,
hasFilter: categoryInteraction,
hasVoted: {
...categoryInteraction,
if: {
arg: 'isPoll',
truthy: true,
},
},
disableActions: categoryInteraction,
showTranslate: categoryInteraction,
// Display
showCounters: categoryDisplay,
favouriteCount: categoryDisplay,
reblogCount: categoryDisplay,
replyCount: categoryDisplay,
showPrepend: categoryDisplay,
showThread: {
...categoryDisplay,
if: {
arg: 'showPrepend',
truthy: true,
},
},
contextType: {
...categoryDisplay,
control: 'select',
options: [
'account',
'bookmarks',
'detailed',
'favourites',
'home',
'notifications',
'public',
'search',
'thread',
] satisfies ContextTypes[],
},
hidden: categoryDisplay,
muted: categoryDisplay,
},
args: {
text: 'This is a status',
visibility: 'public',
isReblog: false,
isReply: false,
isPoll: false,
isQuote: false,
contentWarning: '',
attachments: undefined,
hasFavourited: false,
hasReblogged: false,
hasBookmarked: false,
hasFilter: false,
hasVoted: false,
disableActions: false,
showTranslate: false,
favouriteCount: 0,
reblogCount: 0,
replyCount: 0,
showCounters: true,
contextType: 'home',
showPrepend: true,
showThread: false,
hidden: false,
muted: false,
} satisfies StatusStoryProps,
parameters: {
state: {
accounts: {
'2': otherAccount,
},
polls: {
'1': pollFactory(),
'2': pollFactory({
voted: true,
voters_count: 1,
votes_count: 1,
own_votes: [0],
}),
},
server: {
translationLanguages: {
item: {
xx: ['en', 'de', 'fr'],
},
},
},
},
controls: {
disableSaveFromUI: true,
},
},
} satisfies Meta<typeof StatusStoryComponent>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Reply: Story = {
args: {
isReply: true,
},
};
export const LongText: Story = {
args: {
text: [
'This is a long-form piece of text that wraps multiple lines.',
'It is here to test what a longer status looks like.',
'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
'Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
].join('\n'),
},
};
export const Images: Story = {
args: {
attachments: 'image-3',
},
};
export const Video: Story = {
args: {
attachments: 'video',
},
};
export const Audio: Story = {
args: {
attachments: 'audio',
},
};
export const Poll: Story = {
args: {
isPoll: true,
hasVoted: true,
},
};

View File

@@ -1,16 +1,49 @@
import type { ComponentClass, MouseEventHandler, ReactNode } from 'react';
import type { ComponentType, MouseEventHandler, ReactNode } from 'react';
import type { Account } from '@/mastodon/models/account';
import type { Account as TAccount } from '@/mastodon/models/account';
import type { Status as TStatus } from '@/mastodon/models/status';
import Status from '../status';
import type { StatusHeaderRenderFn } from './header';
// Taken from the Status component.
export interface StatusProps {
account?: Account;
status: TStatus;
account?: TAccount;
children?: ReactNode;
previousId?: string;
nextInReplyToId?: string;
rootId?: string;
onClick?: MouseEventHandler<HTMLDivElement>;
onReply: (status: TStatus) => void;
onFavourite: (status: TStatus) => void;
onReblog: (status: TStatus, event?: unknown) => void;
onQuote: (status: TStatus) => void;
onDelete?: (status: TStatus) => void;
onDirect?: (status: TStatus) => void;
onMention: (account: TAccount) => void;
onPin?: (status: TStatus) => void;
onOpenMedia: (
statusId: string,
media: unknown,
index: number,
lang?: string,
) => void;
onOpenVideo: (
statusId: string,
media: unknown,
lang?: string,
options?: unknown,
) => void;
onBlock?: (status: TStatus) => void;
onAddFilter?: (status: TStatus) => void;
onEmbed?: (status: TStatus) => void;
onHeightChange?: () => void;
onToggleHidden: (status: TStatus) => void;
onToggleCollapsed: (status: TStatus, isCollapsed: boolean) => void;
onTranslate: (status: TStatus) => void;
onInteractionModal?: (type: string, status: TStatus) => void;
muted?: boolean;
hidden?: boolean;
unread?: boolean;
@@ -26,12 +59,16 @@ export interface StatusProps {
scrollKey?: string;
skipPrepend?: boolean;
avatarSize?: number;
deployPictureInPicture: (
status: TStatus,
type: string,
mediaProps: unknown,
) => void;
unfocusable?: boolean;
headerRenderFn?: StatusHeaderRenderFn;
pictureInPicture: Immutable.Map<'inUse' | 'available', boolean>;
contextType?: string;
withCounters?: boolean;
}
export type StatusComponent = ComponentClass<
StatusProps,
{ showMedia?: boolean; showDespiteFilter?: boolean }
>;
export const TypedStatus = Status as ComponentType<StatusProps>;

View File

@@ -11,6 +11,12 @@
* type PersonWithSomeOptional = SomeOptional<Person, 'name' >;
*/
export type DeepPartial<T> = T extends object
? {
[K in keyof T]?: DeepPartial<T[K]>;
}
: T;
export type SomeRequired<T, K extends keyof T> = T & Required<Pick<T, K>>;
export type SomeOptional<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>> &
Partial<Pick<T, K>>;
@@ -21,10 +27,14 @@ export type OmitValueType<T, V> = {
[K in keyof T as T[K] extends V ? never : K]: T[K];
};
export type AnyFunction = (...args: never) => unknown;
export type OmitUnion<TUnion, TBase> = TBase & Omit<TUnion, keyof TBase>;
export type PickValueType<T, V> = {
[K in keyof T as T[K] extends V | undefined ? K : never]: T[K];
};
export type AnyFunction = (...args: never) => unknown;
export type SnakeToCamelCase<S extends string> =
S extends `${infer T}_${infer U}`
? `${T}${Capitalize<SnakeToCamelCase<U>>}`

View File

@@ -1,5 +1,15 @@
import { Map as ImmutableMap, List } from 'immutable';
import { fromJS } from 'immutable';
import { normalizeStatus } from '@/mastodon/actions/importer/statuses';
import type {
ApiAudioAttachmentJSON,
ApiGifvAttachmentJSON,
ApiImageAttachmentJSON,
ApiMediaAttachmentJSON,
ApiVideoAttachmentJSON,
BaseApiMediaAttachmentJSON,
} from '@/mastodon/api_types/media_attachments';
import type { ApiPollJSON } from '@/mastodon/api_types/polls';
import type { ApiRelationshipJSON } from '@/mastodon/api_types/relationships';
import type { ApiStatusJSON } from '@/mastodon/api_types/statuses';
import type {
@@ -9,6 +19,7 @@ import type {
import { createAccountFromServerJSON } from '@/mastodon/models/account';
import type { AnnualReport } from '@/mastodon/models/annual_report';
import type { Status } from '@/mastodon/models/status';
import type { DeepPartial } from '@/mastodon/utils/types';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
type FactoryOptions<T> = {
@@ -87,18 +98,153 @@ export const statusFactory: FactoryFunction<ApiStatusJSON> = ({
tags: [],
emojis: [],
tagged_collections: [],
contentHtml: data.text ?? '<p>This is a test status.</p>',
content:
data.text
?.split('\n')
.map((line) => `<p>${line}</p>`)
.join('\n') ?? '<p>This is a test status.</p>',
...data,
});
export const statusFactoryState = (
options: FactoryOptions<ApiStatusJSON> = {},
) =>
ImmutableMap<string, unknown>({
...(statusFactory(options) as unknown as Record<string, unknown>),
account: options.account?.id ?? '1',
tags: List(options.tags),
}) as unknown as Status;
) => fromJS(normalizeStatus(statusFactory(options))) as unknown as Status;
const baseAttachment = {
id: '1',
url: 'https://example.com/image/1',
preview_url: 'https://example.com/image/1/preview',
blurhash: '',
} as const;
const imageMeta = {
width: 100,
height: 100,
aspect: 1,
size: '100x100',
} as const;
const videoMeta = {
width: 100,
height: 100,
frame_rate: '24',
duration: 120,
bitrate: 100,
} as const;
const colorsMeta = {
background: '#ffffff',
foreground: '#000000',
accent: '#ff0000',
} as const;
type MediaFactoryArg<T extends BaseApiMediaAttachmentJSON> = Omit<
DeepPartial<T>,
'type'
>;
export const imageAttachmentFactory = (
data: MediaFactoryArg<ApiImageAttachmentJSON> = {},
): ApiImageAttachmentJSON => ({
...baseAttachment,
...data,
type: 'image',
meta: {
original: { ...imageMeta, ...data.meta?.original },
small: { ...imageMeta, ...data.meta?.small },
},
});
export const videoAttachmentFactory = (
data: MediaFactoryArg<ApiVideoAttachmentJSON> = {},
): ApiVideoAttachmentJSON => ({
...baseAttachment,
...data,
type: 'video',
meta: {
colors: { ...colorsMeta, ...data.meta?.colors },
original: { ...videoMeta, ...data.meta?.original },
small: { ...imageMeta, ...data.meta?.small },
focus: {
x: 0,
y: 0,
...data.meta?.focus,
},
},
});
export const audioAttachmentFactory = (
data: MediaFactoryArg<ApiAudioAttachmentJSON> = {},
): ApiAudioAttachmentJSON => ({
...baseAttachment,
...data,
type: 'audio',
meta: {
colors: { ...colorsMeta, ...data.meta?.colors },
original: { ...videoMeta, ...data.meta?.original },
small: { ...imageMeta, ...data.meta?.small },
},
});
export const gifvAttachmentFactory = (
data: MediaFactoryArg<ApiGifvAttachmentJSON> = {},
): ApiGifvAttachmentJSON => ({
...baseAttachment,
...data,
type: 'gifv',
meta: {
original: { ...videoMeta, ...data.meta?.original },
small: { ...imageMeta, ...data.meta?.small },
},
});
export function mediaAttachmentFactory(
data: DeepPartial<ApiMediaAttachmentJSON> = {},
): ApiMediaAttachmentJSON {
switch (data.type ?? 'image') {
case 'image':
return imageAttachmentFactory(
data as DeepPartial<ApiImageAttachmentJSON>,
);
case 'video':
return videoAttachmentFactory(
data as DeepPartial<ApiVideoAttachmentJSON>,
);
case 'audio':
return audioAttachmentFactory(
data as DeepPartial<ApiAudioAttachmentJSON>,
);
case 'gifv':
return gifvAttachmentFactory(data as DeepPartial<ApiGifvAttachmentJSON>);
default: {
return {
...baseAttachment,
meta: {},
...data,
type: 'unknown',
};
}
}
}
export const pollFactory: FactoryFunction<ApiPollJSON> = (data = {}) => ({
id: '1',
expires_at: '',
expired: false,
multiple: false,
voters_count: 0,
votes_count: 0,
voted: false,
options: [
{
title: 'Option 1',
votes_count: 0,
},
{
title: 'Option 2',
votes_count: 0,
},
],
emojis: [],
...data,
});
export const relationshipsFactory: FactoryFunction<ApiRelationshipJSON> = ({
id,