From 0f44c634985ccdd41a627f704573cc18f665ff49 Mon Sep 17 00:00:00 2001 From: Echo Date: Thu, 18 Jun 2026 12:38:29 +0200 Subject: [PATCH] Add Status component stories (#39475) --- .storybook/preview.tsx | 41 +- .storybook/storybook.d.ts | 6 + .../mastodon/api_types/media_attachments.ts | 12 +- app/javascript/mastodon/api_types/quotes.ts | 2 +- .../components/status/status.stories.tsx | 540 ++++++++++++++++++ .../mastodon/components/status/types.ts | 51 +- app/javascript/mastodon/utils/types.ts | 14 +- app/javascript/testing/factories.ts | 162 +++++- 8 files changed, 798 insertions(+), 30 deletions(-) create mode 100644 app/javascript/mastodon/components/status/status.stories.tsx diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 4fb559ccf96..0461b473264 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -16,6 +16,7 @@ import { importLegacyShortcodes, importEmojiData, } from '@/mastodon/features/emoji/loader'; +import { IdentityContext } from '@/mastodon/identity_context'; import type { LocaleData } from '@/mastodon/locales'; import { reducerWithInitialState } from '@/mastodon/reducers'; import { defaultMiddleware } from '@/mastodon/store/store'; @@ -55,15 +56,28 @@ const preview: Preview = { description: 'Theme for the story', toolbar: { title: 'Theme', - icon: 'circlehollow', - items: [{ value: 'light' }, { value: 'dark' }], - dynamicTitle: true, + items: [ + { value: 'light', icon: 'circlehollow' }, + { value: 'dark', icon: 'circle' }, + ], + }, + }, + loggedIn: { + description: 'Whether a user is logged in', + toolbar: { + title: 'Logged in', + icon: 'user', + items: [ + { value: 'true', title: 'logged in' }, + { value: 'false', title: 'logged out' }, + ], }, }, }, initialGlobals: { locale: 'en', theme: 'light', + loggedIn: 'true', }, decorators: [ (Story, { parameters, globals, args, argTypes }) => { @@ -115,7 +129,7 @@ const preview: Preview = { ); }, (Story, { globals }) => { - const currentLocale = (globals.locale as string) || 'en'; + const currentLocale = globals.locale || 'en'; const [messages, setMessages] = useState< Record> >({}); @@ -143,7 +157,7 @@ const preview: Preview = { ); }, (Story, { globals }) => { - const theme = (globals.theme as string) || 'light'; + const theme = globals.theme; useEffect(() => { document.body.setAttribute('data-color-scheme', theme); }, [theme]); @@ -164,12 +178,27 @@ const preview: Preview = { /> ), + (Story, { globals }) => { + const signedIn = globals.loggedIn !== 'false'; + return ( + + + + ); + }, ], loaders: [ mswLoader, importCustomEmojiData, importLegacyShortcodes, - ({ globals: { locale } }) => importEmojiData(locale as string), + ({ globals: { locale } }) => importEmojiData(locale), ], parameters: { layout: 'centered', diff --git a/.storybook/storybook.d.ts b/.storybook/storybook.d.ts index 47624d1e9c6..4d439124158 100644 --- a/.storybook/storybook.d.ts +++ b/.storybook/storybook.d.ts @@ -15,6 +15,12 @@ declare module 'storybook/internal/csf' { | `${RootPathKeys}.${string}` | [RootPathKeys, ...(string | number)[]]; } + + export interface Globals { + locale: string; + theme: 'light' | 'dark'; + loggedIn: 'true' | 'false'; + } } export {}; diff --git a/app/javascript/mastodon/api_types/media_attachments.ts b/app/javascript/mastodon/api_types/media_attachments.ts index e4cfe958819..6242569b82c 100644 --- a/app/javascript/mastodon/api_types/media_attachments.ts +++ b/app/javascript/mastodon/api_types/media_attachments.ts @@ -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; } diff --git a/app/javascript/mastodon/api_types/quotes.ts b/app/javascript/mastodon/api_types/quotes.ts index f42a3eb7289..2a5e8b4e45b 100644 --- a/app/javascript/mastodon/api_types/quotes.ts +++ b/app/javascript/mastodon/api_types/quotes.ts @@ -22,7 +22,7 @@ interface ApiNestedQuoteJSON { interface ApiQuoteAcceptedJSON { state: 'accepted'; quoted_status: Omit & { - quote: ApiNestedQuoteJSON | ApiQuoteEmptyJSON; + quote?: ApiNestedQuoteJSON | ApiQuoteEmptyJSON; }; } diff --git a/app/javascript/mastodon/components/status/status.stories.tsx b/app/javascript/mastodon/components/status/status.stories.tsx new file mode 100644 index 00000000000..a9244f56c94 --- /dev/null +++ b/app/javascript/mastodon/components/status/status.stories.tsx @@ -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 = (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 ( +
+
+ ); +}; + +// 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, + }, + 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; + +export default meta; + +type Story = StoryObj; + +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, + }, +}; diff --git a/app/javascript/mastodon/components/status/types.ts b/app/javascript/mastodon/components/status/types.ts index ca6fb388a95..52eb26a8059 100644 --- a/app/javascript/mastodon/components/status/types.ts +++ b/app/javascript/mastodon/components/status/types.ts @@ -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; + 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; diff --git a/app/javascript/mastodon/utils/types.ts b/app/javascript/mastodon/utils/types.ts index 2383dbc50db..c6922210b4a 100644 --- a/app/javascript/mastodon/utils/types.ts +++ b/app/javascript/mastodon/utils/types.ts @@ -11,6 +11,12 @@ * type PersonWithSomeOptional = SomeOptional; */ +export type DeepPartial = T extends object + ? { + [K in keyof T]?: DeepPartial; + } + : T; + export type SomeRequired = T & Required>; export type SomeOptional = Pick> & Partial>; @@ -21,10 +27,14 @@ export type OmitValueType = { [K in keyof T as T[K] extends V ? never : K]: T[K]; }; -export type AnyFunction = (...args: never) => unknown; - export type OmitUnion = TBase & Omit; +export type PickValueType = { + [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 `${infer T}_${infer U}` ? `${T}${Capitalize>}` diff --git a/app/javascript/testing/factories.ts b/app/javascript/testing/factories.ts index e28f4613687..bcd61e3065e 100644 --- a/app/javascript/testing/factories.ts +++ b/app/javascript/testing/factories.ts @@ -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 = { @@ -87,18 +98,153 @@ export const statusFactory: FactoryFunction = ({ tags: [], emojis: [], tagged_collections: [], - contentHtml: data.text ?? '

This is a test status.

', + content: + data.text + ?.split('\n') + .map((line) => `

${line}

`) + .join('\n') ?? '

This is a test status.

', ...data, }); export const statusFactoryState = ( options: FactoryOptions = {}, -) => - ImmutableMap({ - ...(statusFactory(options) as unknown as Record), - 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 = Omit< + DeepPartial, + 'type' +>; + +export const imageAttachmentFactory = ( + data: MediaFactoryArg = {}, +): ApiImageAttachmentJSON => ({ + ...baseAttachment, + ...data, + type: 'image', + meta: { + original: { ...imageMeta, ...data.meta?.original }, + small: { ...imageMeta, ...data.meta?.small }, + }, +}); + +export const videoAttachmentFactory = ( + data: MediaFactoryArg = {}, +): 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 => ({ + ...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 => ({ + ...baseAttachment, + ...data, + type: 'gifv', + meta: { + original: { ...videoMeta, ...data.meta?.original }, + small: { ...imageMeta, ...data.meta?.small }, + }, +}); + +export function mediaAttachmentFactory( + data: DeepPartial = {}, +): ApiMediaAttachmentJSON { + switch (data.type ?? 'image') { + case 'image': + return imageAttachmentFactory( + data as DeepPartial, + ); + case 'video': + return videoAttachmentFactory( + data as DeepPartial, + ); + case 'audio': + return audioAttachmentFactory( + data as DeepPartial, + ); + case 'gifv': + return gifvAttachmentFactory(data as DeepPartial); + default: { + return { + ...baseAttachment, + meta: {}, + ...data, + type: 'unknown', + }; + } + } +} + +export const pollFactory: FactoryFunction = (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 = ({ id,