diff --git a/app/javascript/mastodon/components/status/attachments.stories.tsx b/app/javascript/mastodon/components/status/attachments.stories.tsx new file mode 100644 index 00000000000..78a2bf01d14 --- /dev/null +++ b/app/javascript/mastodon/components/status/attachments.stories.tsx @@ -0,0 +1,190 @@ +import { List, Map } from 'immutable'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { + accountFactoryImmutable, + cardFactoryAPI, + collectionFactoryAPI, + statusFactoryAPI, + statusFactoryImmutable, + statusQuotedFactoryAPI, +} from '@/testing/factories'; + +import { StatusAttachments } from './attachments'; +import type { AttachmentArgs } from './testing'; +import { attachmentArgTypes, attachmentFactory } from './testing'; + +interface StatusAttachmentsStoryProps extends AttachmentArgs { + isFiltered: boolean; + isSensitive: boolean; + isPictureInPicture: boolean; + isQuote: boolean; +} + +const meta = { + title: 'Components/Status/StatusAttachments', + render() { + return ( +
+ +
+ ); + }, + args: { + attachment1: 'image', + attachment2: undefined, + attachment3: undefined, + isFiltered: false, + isSensitive: false, + isPictureInPicture: false, + isQuote: false, + }, + argTypes: { + ...attachmentArgTypes, + isFiltered: { + control: 'boolean', + }, + isSensitive: { + control: 'boolean', + }, + isPictureInPicture: { + control: 'boolean', + }, + isQuote: { + control: 'boolean', + }, + }, + parameters: { + state: { + filters: { + '1': Map({ + id: '1', + title: 'Test', + context: List(['home']), + expires_at: null, + filter_action: 'blur', + }), + }, + accounts: { + '1': accountFactoryImmutable(), + }, + server: { + translationLanguages: { + item: { + xx: ['en', 'de', 'fr'], + }, + }, + }, + }, + stateFn(args: StatusAttachmentsStoryProps) { + const status = statusFactoryAPI(); + + if (args.isFiltered) { + status.filtered = [ + { + filter: { + id: '1', + title: 'Test', + context: 'home', + expires_at: '', + filter_action: 'warn', + }, + keyword_matches: [], + status_matches: [], + }, + ]; + } + + if (args.isSensitive) { + status.sensitive = true; + } + + if (args.isQuote) { + status.quote = { + state: 'accepted', + quoted_status: statusQuotedFactoryAPI({ id: '2' }), + }; + } + + if (args.attachment1 === 'card') { + status.card = cardFactoryAPI({ + image: + 'https://images.pexels.com/photos/16859306/pexels-photo-16859306.jpeg', + }); + } else if (args.attachment1 === 'collection') { + status.tagged_collections = [collectionFactoryAPI()]; + } else if (args.attachment1) { + status.media_attachments.push( + ...attachmentFactory( + args.attachment1, + args.attachment2, + args.attachment3, + ), + ); + } + + return { + statuses: { + '1': statusFactoryImmutable(status), + }, + picture_in_picture: args.isPictureInPicture + ? { + statusId: '1', + accountId: '1', + type: args.attachment1 === 'audio' ? 'audio' : 'video', + } + : undefined, + }; + }, + }, +} as Meta; + +export default meta; + +type Story = StoryObj; + +export const Image: Story = {}; + +export const Video: Story = { + args: { + attachment1: 'video', + }, +}; + +export const Audio: Story = { + args: { + attachment1: 'audio', + }, +}; + +export const Card: Story = { + args: { + attachment1: 'card', + }, +}; + +export const Collection: Story = { + args: { + attachment1: 'collection', + }, +}; + +export const Sensitive: Story = { + args: { + isSensitive: true, + }, +}; + +export const Filtered: Story = { + args: { + isFiltered: true, + }, +}; + +export const PictureInPicture: Story = { + args: { + attachment1: 'video', + isPictureInPicture: true, + }, +}; diff --git a/app/javascript/mastodon/components/status/attachments.tsx b/app/javascript/mastodon/components/status/attachments.tsx index f830a1e0dd8..f942886b1e9 100644 --- a/app/javascript/mastodon/components/status/attachments.tsx +++ b/app/javascript/mastodon/components/status/attachments.tsx @@ -120,13 +120,12 @@ const MediaAttachments: React.FC<{ const description = attachment.translation?.description ?? attachment.description; - const immutableAttachments = useAppSelector( - (state) => - state.statuses.getIn( - statusId, - 'media_attachments', - ) as Immutable.List, - ); + const immutableAttachments = useAppSelector((state) => { + return state.statuses.getIn([ + statusId, + 'media_attachments', + ]) as Immutable.List; + }); const mediaFilters = useAppSelector((state) => selectMediaMatchFilters(state, { statusId, contextType }), ); diff --git a/app/javascript/mastodon/components/status/status.stories.tsx b/app/javascript/mastodon/components/status/status.stories.tsx index 6afabcc9016..0a6418f2820 100644 --- a/app/javascript/mastodon/components/status/status.stories.tsx +++ b/app/javascript/mastodon/components/status/status.stories.tsx @@ -6,16 +6,16 @@ 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 { accountFactoryImmutable, - mediaAttachmentFactoryAPI, pollFactoryImmutable, statusFactoryAPI, statusFactoryImmutable, } from '@/testing/factories'; +import type { AttachmentArgs } from './testing'; +import { attachmentArgTypes, attachmentFactory } from './testing'; import { TypedStatus } from './types'; type ContextTypes = @@ -29,16 +29,7 @@ type ContextTypes = | 'search' | 'thread'; -type AttachmentTypes = - | 'image-1' - | 'image-2' - | 'image-3' - | 'video' - | 'audio' - | 'gifv' - | 'unknown'; - -interface StatusStoryProps { +interface StatusStoryProps extends AttachmentArgs { // Contents text: string; visibility: StatusVisibility; @@ -46,7 +37,6 @@ interface StatusStoryProps { isReply?: boolean; isPoll?: boolean; isQuote?: boolean; - attachments?: AttachmentTypes; contentWarning?: string; // Interactions @@ -84,7 +74,9 @@ const StatusStoryComponent: FC = (props) => { isReply, isPoll, isQuote, - attachments, + attachment1, + attachment2, + attachment3, contentWarning, hasFavourited, @@ -108,107 +100,17 @@ const StatusStoryComponent: FC = (props) => { const { account, status } = useMemo(() => { const account = accountFactoryImmutable(); - const media_attachments: ApiMediaAttachmentJSON[] = []; - switch (attachments) { - // Use fall through add attachments depending on count. - case 'image-3': - media_attachments.push( - mediaAttachmentFactoryAPI({ - 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( - mediaAttachmentFactoryAPI({ - 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( - mediaAttachmentFactoryAPI({ - 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( - mediaAttachmentFactoryAPI({ - type: 'video', - url: 'https://www.pexels.com/download/video/11760787/', - meta: { - original: { - width: 2160, - height: 4096, - }, - }, - }), - ); - break; - case 'audio': - media_attachments.push( - mediaAttachmentFactoryAPI({ - type: 'audio', - url: 'https://upload.wikimedia.org/wikipedia/commons/4/40/Elephant_voice_-_trumpeting.ogg', - }), - ); - break; - case 'gifv': - media_attachments.push( - mediaAttachmentFactoryAPI({ - type: 'gifv', - url: 'https://www.pexels.com/download/video/11760787/', - meta: { - original: { - width: 2160, - height: 4096, - }, - }, - }), - ); - break; - case 'unknown': - media_attachments.push( - mediaAttachmentFactoryAPI({ type: attachments }), - ); - break; - } - return { account, status: statusFactoryImmutable({ text, spoiler_text: contentWarning, visibility, - media_attachments, + media_attachments: attachmentFactory( + attachment1, + attachment2, + attachment3, + ), reblogged: hasReblogged, favourited: hasFavourited, bookmarked: hasBookmarked, @@ -247,10 +149,12 @@ const StatusStoryComponent: FC = (props) => { }), }; }, [ - attachments, text, contentWarning, visibility, + attachment1, + attachment2, + attachment3, hasReblogged, hasFavourited, hasBookmarked, @@ -367,27 +271,17 @@ const meta = { isPoll: categoryContents, isQuote: categoryContents, text: categoryContents, - attachments: { + attachment1: { ...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, + ...attachmentArgTypes.attachment1, + }, + attachment2: { + ...categoryContents, + ...attachmentArgTypes.attachment2, + }, + attachment3: { + ...categoryContents, + ...attachmentArgTypes.attachment3, }, contentWarning: categoryContents, @@ -445,7 +339,9 @@ const meta = { isPoll: false, isQuote: false, contentWarning: '', - attachments: undefined, + attachment1: undefined, + attachment2: undefined, + attachment3: undefined, hasFavourited: false, hasReblogged: false, @@ -518,19 +414,21 @@ export const LongText: Story = { export const Images: Story = { args: { - attachments: 'image-3', + attachment1: 'image', + attachment2: 'image', + attachment3: 'image', }, }; export const Video: Story = { args: { - attachments: 'video', + attachment1: 'video', }, }; export const Audio: Story = { args: { - attachments: 'audio', + attachment1: 'audio', }, }; diff --git a/app/javascript/mastodon/components/status/testing.ts b/app/javascript/mastodon/components/status/testing.ts new file mode 100644 index 00000000000..06d51403d84 --- /dev/null +++ b/app/javascript/mastodon/components/status/testing.ts @@ -0,0 +1,163 @@ +import type { ArgTypes } from '@storybook/react-vite'; + +import type { MediaAttachmentType } from '@/mastodon/api_types/media_attachments'; +import { mediaAttachmentFactoryAPI } from '@/testing/factories'; + +export type MainAttachmentType = MediaAttachmentType | 'collection' | 'card'; +export type ExtraAttachmentType = Exclude< + MediaAttachmentType, + 'video' | 'audio' +>; + +export interface AttachmentArgs { + attachment1?: MainAttachmentType; + attachment2?: ExtraAttachmentType; + attachment3?: ExtraAttachmentType; +} + +export const attachmentArgTypes = { + attachment1: { + control: 'select', + options: [ + 'image', + 'gifv', + 'video', + 'unknown', + 'audio', + 'collection', + 'card', + ] satisfies MainAttachmentType[], + }, + attachment2: { + control: 'select', + options: ['image', 'gifv', 'unknown'] satisfies ExtraAttachmentType[], + }, + attachment3: { + control: 'select', + options: ['image', 'gifv', 'unknown'] satisfies ExtraAttachmentType[], + }, +} as const satisfies ArgTypes; + +export function attachmentFactory( + attachment1?: MainAttachmentType, + attachment2?: ExtraAttachmentType, + attachment3?: ExtraAttachmentType, +) { + if (!attachment1 || attachment1 === 'card' || attachment1 === 'collection') { + return []; + } + + const attachments = [mainMediaAttachmentFactory(attachment1)]; + if (attachment2) { + attachments.push(extraAttachmentFactory(attachment2, '2')); + } + if (attachment3) { + attachments.push(extraAttachmentFactory(attachment3, '3')); + } + return attachments; +} + +export function mainMediaAttachmentFactory(type: MediaAttachmentType) { + if (type === 'video') { + return mediaAttachmentFactoryAPI({ + type: 'video', + url: 'https://www.pexels.com/download/video/11760787/', + preview_url: + 'https://images.pexels.com/photos/16859306/pexels-photo-16859306.jpeg', + meta: { + original: { + width: 2160, + height: 4096, + }, + }, + }); + } + + if (type === 'audio') { + return mediaAttachmentFactoryAPI({ + type: 'audio', + url: 'https://upload.wikimedia.org/wikipedia/commons/4/40/Elephant_voice_-_trumpeting.ogg', + preview_url: + 'https://images.pexels.com/photos/16859306/pexels-photo-16859306.jpeg', + }); + } + + return extraAttachmentFactory(type, '1'); +} + +type MediaData = Record< + '1' | '2' | '3', + { url: string; width: number; height: number } +>; + +const imageIdToData = { + '1': { + url: 'https://cataas.com/cat/bYBTjiFUqjUPIBUD', + width: 1280, + height: 964, + }, + '2': { + url: 'https://cataas.com/cat/YFaQ4xWYoWURSz37', + width: 964, + height: 1280, + }, + '3': { + url: 'https://cataas.com/cat/EbVq9zMc4Xxv7s73', + width: 960, + height: 1280, + }, +} satisfies MediaData; +const gifIdToData = { + '1': { + url: 'https://www.pexels.com/download/video/11760787/', + width: 2160, + height: 4096, + }, + '2': { + url: 'https://www.pexels.com/download/video/19787248/', + width: 3840, + height: 2160, + }, + '3': { + url: 'https://www.pexels.com/download/video/37411294/', + width: 3840, + height: 2160, + }, +} satisfies MediaData; + +export function extraAttachmentFactory( + type: ExtraAttachmentType, + id: '1' | '2' | '3', +) { + const metaData = type === 'image' ? imageIdToData[id] : gifIdToData[id]; + switch (type) { + case 'image': + return mediaAttachmentFactoryAPI({ + id, + type: 'image', + url: metaData.url, + meta: { + original: { + width: metaData.width, + height: metaData.height, + size: `${metaData.width}x${metaData.height}`, + aspect: metaData.width / metaData.height, + }, + }, + }); + case 'gifv': + return mediaAttachmentFactoryAPI({ + id, + type: 'gifv', + url: metaData.url, + meta: { + original: { + width: metaData.width, + height: metaData.height, + }, + }, + }); + case 'unknown': + return mediaAttachmentFactoryAPI({ id, type: 'unknown' }); + } +} diff --git a/app/javascript/mastodon/selectors/statuses.ts b/app/javascript/mastodon/selectors/statuses.ts index 4f9962d09f7..e705fa022e7 100644 --- a/app/javascript/mastodon/selectors/statuses.ts +++ b/app/javascript/mastodon/selectors/statuses.ts @@ -30,7 +30,7 @@ export const selectAccountStatus = createAppSelector( [ selectPlainStatus, (state, statusId: string) => { - const accountId = state.statuses.getIn(statusId, 'account'); + const accountId = state.statuses.getIn([statusId, 'account']); if (typeof accountId !== 'string') { return null; } @@ -52,7 +52,7 @@ export const selectExpandedStatus = createAppSelector( [ selectAccountStatus, (state, statusId: string) => { - const reblogId = state.statuses.getIn(statusId, 'reblog'); + const reblogId = state.statuses.getIn([statusId, 'reblog']); if (typeof reblogId !== 'string') { return null; } diff --git a/app/javascript/testing/factories.ts b/app/javascript/testing/factories.ts index 77a7a719c99..6ab9bd13765 100644 --- a/app/javascript/testing/factories.ts +++ b/app/javascript/testing/factories.ts @@ -3,6 +3,7 @@ import { fromJS } from 'immutable'; import type { PartialDeep } from 'type-fest'; import { normalizeStatus } from '@/mastodon/actions/importer/statuses'; +import type { ApiCollectionJSON } from '@/mastodon/api_types/collections'; import type { ApiAudioAttachmentJSON, ApiGifvAttachmentJSON, @@ -14,7 +15,10 @@ import type { import type { ApiPollJSON } from '@/mastodon/api_types/polls'; import type { ApiQuotedStatusJSON } from '@/mastodon/api_types/quotes'; import type { ApiRelationshipJSON } from '@/mastodon/api_types/relationships'; -import type { ApiStatusJSON } from '@/mastodon/api_types/statuses'; +import type { + ApiPreviewCardJSON, + ApiStatusJSON, +} from '@/mastodon/api_types/statuses'; import type { CustomEmojiData, UnicodeEmojiData, @@ -173,7 +177,7 @@ const baseAttachment = { id: '1', url: 'https://example.com/image/1', preview_url: 'https://example.com/image/1/preview', - blurhash: '', + blurhash: 'LGF5]+Yk^6#M@-5c,1J5@[or[Q6.', } as const; const imageMeta = { width: 100, @@ -321,6 +325,51 @@ export const pollFactoryImmutable = ( })) ?? [], }); +export const cardFactoryAPI: FactoryFunction = ( + options = {}, +) => ({ + url: 'https://example.com', + title: 'Example Card', + description: 'This is an example card', + language: 'en', + type: 'link', + author_name: 'A. Person', + author_url: 'https://example.com/person', + provider_name: 'example', + provider_url: 'https://example.com/provider', + html: '

Here is an example card

', + width: 100, + height: 100, + image: null, + embed_url: 'https://example.com/embed', + image_description: '', + blurhash: '', + published_at: null, + authors: [], + ...options, +}); + +export const collectionFactoryAPI: FactoryFunction = ( + options = {}, +) => ({ + id: '1', + account_id: '1', + name: 'Test Collection', + uri: options.url ?? 'https://example.com/collection', + url: options.uri ?? 'https://example.com/collection', + local: true, + item_count: options.items?.length ?? 0, + description: 'This is a test collection.', + tag: null, + language: 'en', + sensitive: false, + discoverable: true, + created_at: '2023-01-01', + updated_at: '2023-01-01', + items: [], + ...options, +}); + export const relationshipsFactoryAPI: FactoryFunction = ({ id, ...data