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