kopia lustrzana https://gitlab.com/soapbox-pub/soapbox
Process missing media dimensions
rodzic
311eaddb29
commit
ecdfa13053
|
@ -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));
|
||||
|
|
|
@ -173,7 +173,7 @@ const expandTimeline = (timelineId: string, path: string, params: Record<string,
|
|||
const { next, prev } = response.pagination();
|
||||
const data: APIEntity[] = await response.json();
|
||||
|
||||
dispatch(importFetchedStatuses(data));
|
||||
await dispatch(importFetchedStatuses(data));
|
||||
|
||||
const statusesFromGroups = (data as Status[]).filter((status) => !!status.group);
|
||||
dispatch(fetchGroupRelationships(statusesFromGroups.map((status: any) => status.group?.id)));
|
||||
|
|
|
@ -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'));
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -73,8 +73,9 @@ const StatusMedia: React.FC<IStatusMedia> = ({
|
|||
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
|
||||
/>
|
||||
|
|
|
@ -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<TEntity extends Entity>(
|
|||
const next = useListState(path, 'next');
|
||||
const prev = useListState(path, 'prev');
|
||||
|
||||
const fetchPage = async(req: EntityFn<void>, pos: 'start' | 'end', overwrite = false): Promise<void> => {
|
||||
const fetchPage = async (req: EntityFn<void>, pos: 'start' | 'end', overwrite = false): Promise<void> => {
|
||||
// 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<TEntity extends Entity>(
|
|||
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');
|
||||
|
|
|
@ -47,7 +47,7 @@ function useEntity<TEntity extends Entity>(
|
|||
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);
|
||||
|
|
|
@ -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<typeof attachmentSchema>;
|
||||
|
||||
export { attachmentSchema, type Attachment };
|
|
@ -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(),
|
||||
|
|
|
@ -17,6 +17,19 @@ function filteredArray<T extends z.ZodTypeAny>(schema: T) {
|
|||
));
|
||||
}
|
||||
|
||||
/** Validates individual items in an async array schema, dropping any that aren't valid. */
|
||||
function filteredArrayAsync<T extends z.ZodTypeAny>(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<T> => 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<T extends z.ZodRawShape>(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 };
|
||||
export { filteredArray, filteredArrayAsync, hexColorSchema, emojiSchema, contentSchema, dateSchema, jsonSchema, mimeSchema, coerceObject };
|
|
@ -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:",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
Ładowanie…
Reference in New Issue