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 { importEntities } from 'soapbox/entity-store/actions.ts';
|
||||||
import { Entities } from 'soapbox/entity-store/entities.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 { filteredArray } from 'soapbox/schemas/utils.ts';
|
||||||
|
|
||||||
import { getSettings } from '../settings.ts';
|
import { getSettings } from '../settings.ts';
|
||||||
|
@ -95,9 +95,12 @@ const importFetchedGroups = (groups: APIEntity[]) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const importFetchedStatus = (status: APIEntity, idempotencyKey?: string) =>
|
const importFetchedStatus = (status: APIEntity, idempotencyKey?: string) =>
|
||||||
(dispatch: AppDispatch) => {
|
async (dispatch: AppDispatch) => {
|
||||||
|
const result = await statusSchema.safeParseAsync(status);
|
||||||
|
|
||||||
// Skip broken statuses
|
// Skip broken statuses
|
||||||
if (isBroken(status)) return;
|
if (!result.success) return;
|
||||||
|
status = result.data;
|
||||||
|
|
||||||
if (status.reblog?.id) {
|
if (status.reblog?.id) {
|
||||||
dispatch(importFetchedStatus(status.reblog));
|
dispatch(importFetchedStatus(status.reblog));
|
||||||
|
@ -135,31 +138,18 @@ const importFetchedStatus = (status: APIEntity, idempotencyKey?: string) =>
|
||||||
dispatch(importStatus(status, idempotencyKey));
|
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[]) =>
|
const importFetchedStatuses = (statuses: APIEntity[]) =>
|
||||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
async (dispatch: AppDispatch, getState: () => RootState) => {
|
||||||
const accounts: APIEntity[] = [];
|
const accounts: APIEntity[] = [];
|
||||||
const normalStatuses: APIEntity[] = [];
|
const normalStatuses: APIEntity[] = [];
|
||||||
const polls: APIEntity[] = [];
|
const polls: APIEntity[] = [];
|
||||||
|
|
||||||
function processStatus(status: APIEntity) {
|
async function processStatus(status: APIEntity) {
|
||||||
|
const result = await statusSchema.safeParseAsync(status);
|
||||||
|
|
||||||
// Skip broken statuses
|
// Skip broken statuses
|
||||||
if (isBroken(status)) return;
|
if (!result.success) return;
|
||||||
|
status = result.data;
|
||||||
|
|
||||||
normalStatuses.push(status);
|
normalStatuses.push(status);
|
||||||
accounts.push(status.account);
|
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(importPolls(polls));
|
||||||
dispatch(importFetchedAccounts(accounts));
|
dispatch(importFetchedAccounts(accounts));
|
||||||
|
|
|
@ -173,7 +173,7 @@ const expandTimeline = (timelineId: string, path: string, params: Record<string,
|
||||||
const { next, prev } = response.pagination();
|
const { next, prev } = response.pagination();
|
||||||
const data: APIEntity[] = await response.json();
|
const data: APIEntity[] = await response.json();
|
||||||
|
|
||||||
dispatch(importFetchedStatuses(data));
|
await dispatch(importFetchedStatuses(data));
|
||||||
|
|
||||||
const statusesFromGroups = (data as Status[]).filter((status) => !!status.group);
|
const statusesFromGroups = (data as Status[]).filter((status) => !!status.group);
|
||||||
dispatch(fetchGroupRelationships(statusesFromGroups.map((status: any) => status.group?.id)));
|
dispatch(fetchGroupRelationships(statusesFromGroups.map((status: any) => status.group?.id)));
|
||||||
|
|
|
@ -59,7 +59,7 @@ function useBookmark() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.post(`/api/v1/statuses/${statusId}/bookmark`);
|
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) {
|
if (result) {
|
||||||
dispatch(importEntities([result], Entities.STATUSES, 'bookmarks', 'start'));
|
dispatch(importEntities([result], Entities.STATUSES, 'bookmarks', 'start'));
|
||||||
}
|
}
|
||||||
|
|
|
@ -67,7 +67,7 @@ export function useReaction() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.put(`/api/v1/pleroma/statuses/${status.id}/reactions/${emoji}`);
|
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) {
|
if (result) {
|
||||||
dispatch(importEntities([result], Entities.STATUSES));
|
dispatch(importEntities([result], Entities.STATUSES));
|
||||||
}
|
}
|
||||||
|
@ -82,7 +82,7 @@ export function useReaction() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.delete(`/api/v1/pleroma/statuses/${status.id}/reactions/${emoji}`);
|
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) {
|
if (result) {
|
||||||
dispatch(importEntities([result], Entities.STATUSES));
|
dispatch(importEntities([result], Entities.STATUSES));
|
||||||
}
|
}
|
||||||
|
|
|
@ -73,8 +73,9 @@ const StatusMedia: React.FC<IStatusMedia> = ({
|
||||||
blurhash={video.blurhash ?? undefined}
|
blurhash={video.blurhash ?? undefined}
|
||||||
src={video.url}
|
src={video.url}
|
||||||
alt={video.description}
|
alt={video.description}
|
||||||
aspectRatio={Number(video.meta?.original?.aspect)}
|
aspectRatio={video.meta?.original?.aspect}
|
||||||
height={285}
|
height={video?.meta?.original?.height}
|
||||||
|
width={video?.meta?.original?.width}
|
||||||
visible={showMedia}
|
visible={showMedia}
|
||||||
inline
|
inline
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -6,7 +6,7 @@ import { useApi } from 'soapbox/hooks/useApi.ts';
|
||||||
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
|
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
|
||||||
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
|
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
|
||||||
import { useGetState } from 'soapbox/hooks/useGetState.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 { realNumberSchema } from 'soapbox/utils/numbers.tsx';
|
||||||
|
|
||||||
import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess, invalidateEntityList } from '../actions.ts';
|
import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess, invalidateEntityList } from '../actions.ts';
|
||||||
|
@ -58,7 +58,7 @@ function useEntities<TEntity extends Entity>(
|
||||||
const next = useListState(path, 'next');
|
const next = useListState(path, 'next');
|
||||||
const prev = useListState(path, 'prev');
|
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.
|
// Get `isFetching` state from the store again to prevent race conditions.
|
||||||
const isFetching = selectListState(getState(), path, 'fetching');
|
const isFetching = selectListState(getState(), path, 'fetching');
|
||||||
if (isFetching) return;
|
if (isFetching) return;
|
||||||
|
@ -67,7 +67,7 @@ function useEntities<TEntity extends Entity>(
|
||||||
try {
|
try {
|
||||||
const response = await req();
|
const response = await req();
|
||||||
const json = await response.json();
|
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 parsedCount = realNumberSchema.safeParse(response.headers.get('x-total-count'));
|
||||||
const totalCount = parsedCount.success ? parsedCount.data : undefined;
|
const totalCount = parsedCount.success ? parsedCount.data : undefined;
|
||||||
const linkHeader = response.headers.get('link');
|
const linkHeader = response.headers.get('link');
|
||||||
|
|
|
@ -47,7 +47,7 @@ function useEntity<TEntity extends Entity>(
|
||||||
try {
|
try {
|
||||||
const response = await setPromise(entityFn());
|
const response = await setPromise(entityFn());
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
const entity = schema.parse(json);
|
const entity = await schema.parseAsync(json);
|
||||||
dispatch(importEntities([entity], entityType));
|
dispatch(importEntities([entity], entityType));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e);
|
setError(e);
|
||||||
|
|
|
@ -83,14 +83,75 @@ const attachmentSchema = z.discriminatedUnion('type', [
|
||||||
gifvAttachmentSchema,
|
gifvAttachmentSchema,
|
||||||
audioAttachmentSchema,
|
audioAttachmentSchema,
|
||||||
unknownAttachmentSchema,
|
unknownAttachmentSchema,
|
||||||
]).transform((attachment) => {
|
]).transform(async (attachment) => {
|
||||||
if (!attachment.preview_url) {
|
if (!attachment.preview_url) {
|
||||||
attachment.preview_url = attachment.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;
|
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>;
|
type Attachment = z.infer<typeof attachmentSchema>;
|
||||||
|
|
||||||
export { attachmentSchema, type Attachment };
|
export { attachmentSchema, type Attachment };
|
|
@ -13,7 +13,7 @@ import { groupSchema } from './group.ts';
|
||||||
import { mentionSchema } from './mention.ts';
|
import { mentionSchema } from './mention.ts';
|
||||||
import { pollSchema } from './poll.ts';
|
import { pollSchema } from './poll.ts';
|
||||||
import { tagSchema } from './tag.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';
|
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),
|
in_reply_to_id: z.string().nullable().catch(null),
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
language: z.string().nullable().catch(null),
|
language: z.string().nullable().catch(null),
|
||||||
media_attachments: filteredArray(attachmentSchema),
|
media_attachments: filteredArrayAsync(attachmentSchema),
|
||||||
mentions: filteredArray(mentionSchema),
|
mentions: filteredArray(mentionSchema),
|
||||||
muted: z.coerce.boolean(),
|
muted: z.coerce.boolean(),
|
||||||
pinned: 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. */
|
/** Validates the string as an emoji. */
|
||||||
const emojiSchema = z.string().refine((v) => /\p{Extended_Pictographic}|[\u{1F1E6}-\u{1F1FF}]{2}/u.test(v));
|
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. */
|
/** 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);
|
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(
|
csp.push(
|
||||||
"connect-src 'self' blob: https: wss: http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:*",
|
"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:*",
|
"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 {
|
} else {
|
||||||
csp.push(
|
csp.push(
|
||||||
"connect-src 'self' blob: https: wss:",
|
"connect-src 'self' blob: https: wss:",
|
||||||
"img-src 'self' data: blob: https:",
|
"img-src 'self' data: blob: https:",
|
||||||
"media-src 'self' https:",
|
"media-src 'self' blob: https:",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Ładowanie…
Reference in New Issue