From ecdfa1305384aebf177a2a61caf75bec76a27a8c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 12 Mar 2025 14:00:04 -0500 Subject: [PATCH] Process missing media dimensions --- src/actions/importer/index.ts | 36 ++++++--------- src/actions/timelines.ts | 2 +- src/api/hooks/statuses/useBookmark.ts | 2 +- src/api/hooks/statuses/useReaction.ts | 4 +- src/components/status-media.tsx | 5 ++- src/entity-store/hooks/useEntities.ts | 6 +-- src/entity-store/hooks/useEntity.ts | 2 +- src/schemas/attachment.ts | 63 ++++++++++++++++++++++++++- src/schemas/status.ts | 4 +- src/schemas/utils.ts | 15 ++++++- vite.config.ts | 4 +- 11 files changed, 104 insertions(+), 39 deletions(-) diff --git a/src/actions/importer/index.ts b/src/actions/importer/index.ts index 07bddfaed..99c713bdc 100644 --- a/src/actions/importer/index.ts +++ b/src/actions/importer/index.ts @@ -1,6 +1,6 @@ import { importEntities } from 'soapbox/entity-store/actions.ts'; import { Entities } from 'soapbox/entity-store/entities.ts'; -import { Group, accountSchema, groupSchema } from 'soapbox/schemas/index.ts'; +import { Group, accountSchema, groupSchema, statusSchema } from 'soapbox/schemas/index.ts'; import { filteredArray } from 'soapbox/schemas/utils.ts'; import { getSettings } from '../settings.ts'; @@ -95,9 +95,12 @@ const importFetchedGroups = (groups: APIEntity[]) => { }; const importFetchedStatus = (status: APIEntity, idempotencyKey?: string) => - (dispatch: AppDispatch) => { + async (dispatch: AppDispatch) => { + const result = await statusSchema.safeParseAsync(status); + // Skip broken statuses - if (isBroken(status)) return; + if (!result.success) return; + status = result.data; if (status.reblog?.id) { dispatch(importFetchedStatus(status.reblog)); @@ -135,31 +138,18 @@ const importFetchedStatus = (status: APIEntity, idempotencyKey?: string) => dispatch(importStatus(status, idempotencyKey)); }; -// Sometimes Pleroma can return an empty account, -// or a repost can appear of a deleted account. Skip these statuses. -const isBroken = (status: APIEntity) => { - try { - // Skip empty accounts - // https://gitlab.com/soapbox-pub/soapbox/-/issues/424 - if (!status.account.id) return true; - // Skip broken reposts - // https://gitlab.com/soapbox-pub/rebased/-/issues/28 - if (status.reblog && !status.reblog.account.id) return true; - return false; - } catch (e) { - return true; - } -}; - const importFetchedStatuses = (statuses: APIEntity[]) => - (dispatch: AppDispatch, getState: () => RootState) => { + async (dispatch: AppDispatch, getState: () => RootState) => { const accounts: APIEntity[] = []; const normalStatuses: APIEntity[] = []; const polls: APIEntity[] = []; - function processStatus(status: APIEntity) { + async function processStatus(status: APIEntity) { + const result = await statusSchema.safeParseAsync(status); + // Skip broken statuses - if (isBroken(status)) return; + if (!result.success) return; + status = result.data; normalStatuses.push(status); accounts.push(status.account); @@ -186,7 +176,7 @@ const importFetchedStatuses = (statuses: APIEntity[]) => } } - statuses.forEach(processStatus); + await Promise.all((statuses.map(processStatus))); dispatch(importPolls(polls)); dispatch(importFetchedAccounts(accounts)); diff --git a/src/actions/timelines.ts b/src/actions/timelines.ts index fc9f83cf0..c68bcfdf5 100644 --- a/src/actions/timelines.ts +++ b/src/actions/timelines.ts @@ -173,7 +173,7 @@ const expandTimeline = (timelineId: string, path: string, params: Record !!status.group); dispatch(fetchGroupRelationships(statusesFromGroups.map((status: any) => status.group?.id))); diff --git a/src/api/hooks/statuses/useBookmark.ts b/src/api/hooks/statuses/useBookmark.ts index a64da3d28..5ba418a38 100644 --- a/src/api/hooks/statuses/useBookmark.ts +++ b/src/api/hooks/statuses/useBookmark.ts @@ -59,7 +59,7 @@ function useBookmark() { try { const response = await api.post(`/api/v1/statuses/${statusId}/bookmark`); - const result = statusSchema.parse(await response.json()); + const result = await statusSchema.parseAsync(await response.json()); if (result) { dispatch(importEntities([result], Entities.STATUSES, 'bookmarks', 'start')); } diff --git a/src/api/hooks/statuses/useReaction.ts b/src/api/hooks/statuses/useReaction.ts index 139ea977d..ade96f7ac 100644 --- a/src/api/hooks/statuses/useReaction.ts +++ b/src/api/hooks/statuses/useReaction.ts @@ -67,7 +67,7 @@ export function useReaction() { try { const response = await api.put(`/api/v1/pleroma/statuses/${status.id}/reactions/${emoji}`); - const result = statusSchema.parse(await response.json()); + const result = await statusSchema.parseAsync(await response.json()); if (result) { dispatch(importEntities([result], Entities.STATUSES)); } @@ -82,7 +82,7 @@ export function useReaction() { try { const response = await api.delete(`/api/v1/pleroma/statuses/${status.id}/reactions/${emoji}`); - const result = statusSchema.parse(await response.json()); + const result = await statusSchema.parseAsync(await response.json()); if (result) { dispatch(importEntities([result], Entities.STATUSES)); } diff --git a/src/components/status-media.tsx b/src/components/status-media.tsx index bfd5b79bf..9fdcb5a59 100644 --- a/src/components/status-media.tsx +++ b/src/components/status-media.tsx @@ -73,8 +73,9 @@ const StatusMedia: React.FC = ({ blurhash={video.blurhash ?? undefined} src={video.url} alt={video.description} - aspectRatio={Number(video.meta?.original?.aspect)} - height={285} + aspectRatio={video.meta?.original?.aspect} + height={video?.meta?.original?.height} + width={video?.meta?.original?.width} visible={showMedia} inline /> diff --git a/src/entity-store/hooks/useEntities.ts b/src/entity-store/hooks/useEntities.ts index f23ca8514..454c1883d 100644 --- a/src/entity-store/hooks/useEntities.ts +++ b/src/entity-store/hooks/useEntities.ts @@ -6,7 +6,7 @@ import { useApi } from 'soapbox/hooks/useApi.ts'; import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts'; import { useGetState } from 'soapbox/hooks/useGetState.ts'; -import { filteredArray } from 'soapbox/schemas/utils.ts'; +import { filteredArrayAsync } from 'soapbox/schemas/utils.ts'; import { realNumberSchema } from 'soapbox/utils/numbers.tsx'; import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess, invalidateEntityList } from '../actions.ts'; @@ -58,7 +58,7 @@ function useEntities( const next = useListState(path, 'next'); const prev = useListState(path, 'prev'); - const fetchPage = async(req: EntityFn, pos: 'start' | 'end', overwrite = false): Promise => { + const fetchPage = async (req: EntityFn, pos: 'start' | 'end', overwrite = false): Promise => { // Get `isFetching` state from the store again to prevent race conditions. const isFetching = selectListState(getState(), path, 'fetching'); if (isFetching) return; @@ -67,7 +67,7 @@ function useEntities( try { const response = await req(); const json = await response.json(); - const entities = filteredArray(schema).parse(json); + const entities = await filteredArrayAsync(schema).parseAsync(json); const parsedCount = realNumberSchema.safeParse(response.headers.get('x-total-count')); const totalCount = parsedCount.success ? parsedCount.data : undefined; const linkHeader = response.headers.get('link'); diff --git a/src/entity-store/hooks/useEntity.ts b/src/entity-store/hooks/useEntity.ts index 6c16583d6..2ca1a223c 100644 --- a/src/entity-store/hooks/useEntity.ts +++ b/src/entity-store/hooks/useEntity.ts @@ -47,7 +47,7 @@ function useEntity( try { const response = await setPromise(entityFn()); const json = await response.json(); - const entity = schema.parse(json); + const entity = await schema.parseAsync(json); dispatch(importEntities([entity], entityType)); } catch (e) { setError(e); diff --git a/src/schemas/attachment.ts b/src/schemas/attachment.ts index 20d7e33f7..26d53f68c 100644 --- a/src/schemas/attachment.ts +++ b/src/schemas/attachment.ts @@ -83,14 +83,75 @@ const attachmentSchema = z.discriminatedUnion('type', [ gifvAttachmentSchema, audioAttachmentSchema, unknownAttachmentSchema, -]).transform((attachment) => { +]).transform(async (attachment) => { if (!attachment.preview_url) { attachment.preview_url = attachment.url; } + if (attachment.type === 'image') { + if (!attachment.meta.original) { + try { + const { width, height } = await getImageDimensions(attachment.url); + attachment.meta.original = { width, height, aspect: width / height }; + } catch { + // Image metadata is not available + } + } + } + + if (attachment.type === 'video') { + if (!attachment.meta.original) { + try { + const { width, height } = await getVideoDimensions(attachment.url); + attachment.meta.original = { width, height, aspect: width / height }; + } catch { + // Video metadata is not available + } + } + } + return attachment; }); +async function getImageDimensions(url: string): Promise<{ width: number; height: number }> { + const response = await fetch(url); + const blob = await response.blob(); + + return await new Promise((resolve, reject) => { + const img = new Image(); + + img.onload = () => { + resolve({ width: img.width, height: img.height }); + URL.revokeObjectURL(img.src); // Cleanup + }; + + img.onerror = reject; + img.src = URL.createObjectURL(blob); + }); +} + +async function getVideoDimensions(url: string): Promise<{ width: number; height: number }> { + const response = await fetch(url); + const blob = await response.blob(); + + return await new Promise((resolve, reject) => { + const video = document.createElement('video'); + video.preload = 'metadata'; + + video.onloadedmetadata = () => { + video.currentTime = 0.1; // Force processing of video frames + }; + + video.onseeked = () => { + resolve({ width: video.videoWidth, height: video.videoHeight }); + URL.revokeObjectURL(video.src); + }; + + video.onerror = reject; + video.src = URL.createObjectURL(blob); + }); +} + type Attachment = z.infer; export { attachmentSchema, type Attachment }; \ No newline at end of file diff --git a/src/schemas/status.ts b/src/schemas/status.ts index 4ff040890..cfe89c93e 100644 --- a/src/schemas/status.ts +++ b/src/schemas/status.ts @@ -13,7 +13,7 @@ import { groupSchema } from './group.ts'; import { mentionSchema } from './mention.ts'; import { pollSchema } from './poll.ts'; import { tagSchema } from './tag.ts'; -import { contentSchema, dateSchema, filteredArray } from './utils.ts'; +import { contentSchema, dateSchema, filteredArray, filteredArrayAsync } from './utils.ts'; import type { Resolve } from 'soapbox/utils/types.ts'; @@ -48,7 +48,7 @@ const baseStatusSchema = z.object({ in_reply_to_id: z.string().nullable().catch(null), id: z.string(), language: z.string().nullable().catch(null), - media_attachments: filteredArray(attachmentSchema), + media_attachments: filteredArrayAsync(attachmentSchema), mentions: filteredArray(mentionSchema), muted: z.coerce.boolean(), pinned: z.coerce.boolean(), diff --git a/src/schemas/utils.ts b/src/schemas/utils.ts index edec90af8..1c6cc51d9 100644 --- a/src/schemas/utils.ts +++ b/src/schemas/utils.ts @@ -17,6 +17,19 @@ function filteredArray(schema: T) { )); } +/** Validates individual items in an async array schema, dropping any that aren't valid. */ +function filteredArrayAsync(schema: T) { + return z.any().array().catch([]) + .transform(async (arr) => { + const results = await Promise.all(arr.map(async (item) => { + const parsed = await schema.safeParseAsync(item); + return parsed.success ? parsed.data : undefined; + })); + + return results.filter((item): item is z.infer => Boolean(item)); + }); +} + /** Validates the string as an emoji. */ const emojiSchema = z.string().refine((v) => /\p{Extended_Pictographic}|[\u{1F1E6}-\u{1F1FF}]{2}/u.test(v)); @@ -42,4 +55,4 @@ function coerceObject(shape: T) { /** Validates a hex color code. */ const hexColorSchema = z.string().regex(/^#([a-f0-9]{3}|[a-f0-9]{4}|[a-f0-9]{6}|[a-f0-9]{8})$/i); -export { filteredArray, hexColorSchema, emojiSchema, contentSchema, dateSchema, jsonSchema, mimeSchema, coerceObject }; \ No newline at end of file +export { filteredArray, filteredArrayAsync, hexColorSchema, emojiSchema, contentSchema, dateSchema, jsonSchema, mimeSchema, coerceObject }; \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index d571ca6f9..78a3e2e66 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -131,13 +131,13 @@ function buildCSP(env: string | undefined): string { csp.push( "connect-src 'self' blob: https: wss: http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:*", "img-src 'self' data: blob: https: http://localhost:* http://127.0.0.1:*", - "media-src 'self' https: http://localhost:* http://127.0.0.1:*", + "media-src 'self' blob: https: http://localhost:* http://127.0.0.1:*", ); } else { csp.push( "connect-src 'self' blob: https: wss:", "img-src 'self' data: blob: https:", - "media-src 'self' https:", + "media-src 'self' blob: https:", ); }