Attachment stories (#39661)

This commit is contained in:
Echo
2026-07-01 11:32:34 +02:00
committed by GitHub
parent cd97f4c6c7
commit eaeb098ba4
6 changed files with 444 additions and 145 deletions

View File

@@ -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 (
<div style={{ width: 'min(600px, 80vw)' }}>
<StatusAttachments statusId='1' contextType='home' />
</div>
);
},
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<StatusAttachmentsStoryProps>;
export default meta;
type Story = StoryObj<typeof meta>;
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,
},
};

View File

@@ -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<MediaAttachment>,
);
const immutableAttachments = useAppSelector((state) => {
return state.statuses.getIn([
statusId,
'media_attachments',
]) as Immutable.List<MediaAttachment>;
});
const mediaFilters = useAppSelector((state) =>
selectMediaMatchFilters(state, { statusId, contextType }),
);

View File

@@ -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<StatusStoryProps> = (props) => {
isReply,
isPoll,
isQuote,
attachments,
attachment1,
attachment2,
attachment3,
contentWarning,
hasFavourited,
@@ -108,107 +100,17 @@ const StatusStoryComponent: FC<StatusStoryProps> = (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<StatusStoryProps> = (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<string, AttachmentTypes>,
...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',
},
};

View File

@@ -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<AttachmentArgs>;
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' });
}
}

View File

@@ -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;
}

View File

@@ -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<ApiPreviewCardJSON> = (
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: '<p>Here is an example card</p>',
width: 100,
height: 100,
image: null,
embed_url: 'https://example.com/embed',
image_description: '',
blurhash: '',
published_at: null,
authors: [],
...options,
});
export const collectionFactoryAPI: FactoryFunction<ApiCollectionJSON> = (
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<ApiRelationshipJSON> = ({
id,
...data