Merge branch 'status-schema' into 'develop'

Status schema improvements

See merge request soapbox-pub/soapbox!2521
environments/review-develop-3zknud/deployments/3404
Alex Gleason 1 year ago
commit 917b45bdc5

@ -6,9 +6,8 @@ import api, { getLinks } from '../api';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts } from './importer';
import type { AnyAction } from '@reduxjs/toolkit';
import type { AxiosError } from 'axios';
import type { RootState } from 'soapbox/store';
import type { AppDispatch, RootState } from 'soapbox/store';
const BLOCKS_FETCH_REQUEST = 'BLOCKS_FETCH_REQUEST';
const BLOCKS_FETCH_SUCCESS = 'BLOCKS_FETCH_SUCCESS';
@ -18,7 +17,7 @@ const BLOCKS_EXPAND_REQUEST = 'BLOCKS_EXPAND_REQUEST';
const BLOCKS_EXPAND_SUCCESS = 'BLOCKS_EXPAND_SUCCESS';
const BLOCKS_EXPAND_FAIL = 'BLOCKS_EXPAND_FAIL';
const fetchBlocks = () => (dispatch: React.Dispatch<AnyAction>, getState: () => RootState) => {
const fetchBlocks = () => (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return null;
const nextLinkName = getNextLinkName(getState);
@ -54,7 +53,7 @@ function fetchBlocksFail(error: AxiosError) {
};
}
const expandBlocks = () => (dispatch: React.Dispatch<AnyAction>, getState: () => RootState) => {
const expandBlocks = () => (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return null;
const nextLinkName = getNextLinkName(getState);

@ -1,40 +1,14 @@
import { RootState } from 'soapbox/store';
import { AppDispatch, RootState } from 'soapbox/store';
import api from '../api';
import { ACCOUNTS_IMPORT, importFetchedAccounts } from './importer';
import type { APIEntity } from 'soapbox/types/entities';
import { importFetchedAccounts } from './importer';
export const FAMILIAR_FOLLOWERS_FETCH_REQUEST = 'FAMILIAR_FOLLOWERS_FETCH_REQUEST';
export const FAMILIAR_FOLLOWERS_FETCH_SUCCESS = 'FAMILIAR_FOLLOWERS_FETCH_SUCCESS';
export const FAMILIAR_FOLLOWERS_FETCH_FAIL = 'FAMILIAR_FOLLOWERS_FETCH_FAIL';
type FamiliarFollowersFetchRequestAction = {
type: typeof FAMILIAR_FOLLOWERS_FETCH_REQUEST
id: string
}
type FamiliarFollowersFetchRequestSuccessAction = {
type: typeof FAMILIAR_FOLLOWERS_FETCH_SUCCESS
id: string
accounts: Array<APIEntity>
}
type FamiliarFollowersFetchRequestFailAction = {
type: typeof FAMILIAR_FOLLOWERS_FETCH_FAIL
id: string
error: any
}
type AccountsImportAction = {
type: typeof ACCOUNTS_IMPORT
accounts: Array<APIEntity>
}
export type FamiliarFollowersActions = FamiliarFollowersFetchRequestAction | FamiliarFollowersFetchRequestSuccessAction | FamiliarFollowersFetchRequestFailAction | AccountsImportAction
export const fetchAccountFamiliarFollowers = (accountId: string) => (dispatch: React.Dispatch<FamiliarFollowersActions>, getState: () => RootState) => {
export const fetchAccountFamiliarFollowers = (accountId: string) => (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({
type: FAMILIAR_FOLLOWERS_FETCH_REQUEST,
id: accountId,
@ -44,7 +18,7 @@ export const fetchAccountFamiliarFollowers = (accountId: string) => (dispatch: R
.then(({ data }) => {
const accounts = data.find(({ id }: { id: string }) => id === accountId).accounts;
dispatch(importFetchedAccounts(accounts) as AccountsImportAction);
dispatch(importFetchedAccounts(accounts));
dispatch({
type: FAMILIAR_FOLLOWERS_FETCH_SUCCESS,
id: accountId,

@ -160,7 +160,7 @@ const favourite = (status: StatusEntity) =>
dispatch(favouriteRequest(status));
api(getState).post(`/api/v1/statuses/${status.get('id')}/favourite`).then(function(response) {
api(getState).post(`/api/v1/statuses/${status.id}/favourite`).then(function(response) {
dispatch(favouriteSuccess(status));
}).catch(function(error) {
dispatch(favouriteFail(status, error));
@ -173,7 +173,7 @@ const unfavourite = (status: StatusEntity) =>
dispatch(unfavouriteRequest(status));
api(getState).post(`/api/v1/statuses/${status.get('id')}/unfavourite`).then(() => {
api(getState).post(`/api/v1/statuses/${status.id}/unfavourite`).then(() => {
dispatch(unfavouriteSuccess(status));
}).catch(error => {
dispatch(unfavouriteFail(status, error));

@ -15,14 +15,10 @@ import { getActualStatus } from 'soapbox/utils/status';
import StatusInteractionBar from './status-interaction-bar';
import type { List as ImmutableList } from 'immutable';
import type { Attachment as AttachmentEntity, Group, Status as StatusEntity } from 'soapbox/types/entities';
import type { Group, Status as StatusEntity } from 'soapbox/types/entities';
interface IDetailedStatus {
status: StatusEntity
onOpenMedia: (media: ImmutableList<AttachmentEntity>, index: number) => void
onOpenVideo: (media: ImmutableList<AttachmentEntity>, start: number) => void
onToggleHidden: (status: StatusEntity) => void
showMedia: boolean
onOpenCompareHistoryModal: (status: StatusEntity) => void
onToggleMediaVisibility: () => void

@ -230,14 +230,6 @@ const Thread: React.FC<IThread> = (props) => {
dispatch(mentionCompose(account));
};
const handleOpenMedia = (media: ImmutableList<AttachmentEntity>, index: number) => {
dispatch(openModal('MEDIA', { media, status, index }));
};
const handleOpenVideo = (media: ImmutableList<AttachmentEntity>, time: number) => {
dispatch(openModal('VIDEO', { media, time }));
};
const handleHotkeyOpenMedia = (e?: KeyboardEvent) => {
const { onOpenMedia, onOpenVideo } = props;
const firstAttachment = status?.media_attachments.get(0);
@ -478,9 +470,6 @@ const Thread: React.FC<IThread> = (props) => {
<DetailedStatus
status={status}
onOpenVideo={handleOpenVideo}
onOpenMedia={handleOpenMedia}
onToggleHidden={handleToggleHidden}
showMedia={showMedia}
onToggleMediaVisibility={handleToggleMediaVisibility}
onOpenCompareHistoryModal={handleOpenCompareHistoryModal}

@ -49,7 +49,7 @@ const accountSchema = z.object({
verified: z.boolean().default(false),
website: z.string().catch(''),
/**
/*
* Internal fields
*/
display_name_html: z.string().catch(''),
@ -57,7 +57,7 @@ const accountSchema = z.object({
note_emojified: z.string().catch(''),
relationship: relationshipSchema.nullable().catch(null),
/**
/*
* Misc
*/
other_settings: z.any(),
@ -99,7 +99,7 @@ const accountSchema = z.object({
// Notes
account.note_emojified = emojify(account.note, customEmojiMap);
/**
/*
* Todo
* - internal fields
* - donor

@ -62,6 +62,12 @@ const audioAttachmentSchema = baseAttachmentSchema.extend({
type: z.literal('audio'),
meta: z.object({
duration: z.number().optional().catch(undefined),
colors: z.object({
background: z.string().optional().catch(undefined),
foreground: z.string().optional().catch(undefined),
accent: z.string().optional().catch(undefined),
duration: z.number().optional().catch(undefined),
}).optional().catch(undefined),
}).catch({}),
});

@ -0,0 +1,20 @@
import { z } from 'zod';
import { attachmentSchema } from './attachment';
import { locationSchema } from './location';
const eventSchema = z.object({
name: z.string().catch(''),
start_time: z.string().datetime().nullable().catch(null),
end_time: z.string().datetime().nullable().catch(null),
join_mode: z.enum(['free', 'restricted', 'invite']).nullable().catch(null),
participants_count: z.number().catch(0),
location: locationSchema.nullable().catch(null),
join_state: z.enum(['pending', 'reject', 'accept']).nullable().catch(null),
banner: attachmentSchema.nullable().catch(null),
links: z.array(attachmentSchema).nullable().catch(null),
});
type Event = z.infer<typeof eventSchema>;
export { eventSchema, type Event };

@ -0,0 +1,23 @@
import { z } from 'zod';
const locationSchema = z.object({
url: z.string().url().catch(''),
description: z.string().catch(''),
country: z.string().catch(''),
locality: z.string().catch(''),
region: z.string().catch(''),
postal_code: z.string().catch(''),
street: z.string().catch(''),
origin_id: z.string().catch(''),
origin_provider: z.string().catch(''),
type: z.string().catch(''),
timezone: z.string().catch(''),
geom: z.object({
coordinates: z.tuple([z.number(), z.number()]).nullable().catch(null),
srid: z.string().catch(''),
}).nullable().catch(null),
});
type Location = z.infer<typeof locationSchema>;
export { locationSchema, type Location };

@ -1,18 +1,19 @@
import escapeTextContentForBrowser from 'escape-html';
import { z } from 'zod';
import emojify from 'soapbox/features/emoji';
import { stripCompatibilityFeatures, unescapeHTML } from 'soapbox/utils/html';
import { accountSchema } from './account';
import { attachmentSchema } from './attachment';
import { cardSchema } from './card';
import { customEmojiSchema } from './custom-emoji';
import { eventSchema } from './event';
import { groupSchema } from './group';
import { mentionSchema } from './mention';
import { pollSchema } from './poll';
import { tagSchema } from './tag';
import { contentSchema, dateSchema, filteredArray } from './utils';
const tombstoneSchema = z.object({
reason: z.enum(['deleted']),
});
import { contentSchema, dateSchema, filteredArray, makeCustomEmojiMap } from './utils';
const baseStatusSchema = z.object({
account: accountSchema,
@ -39,27 +40,97 @@ const baseStatusSchema = z.object({
mentions: filteredArray(mentionSchema),
muted: z.coerce.boolean(),
pinned: z.coerce.boolean(),
pleroma: z.object({}).optional().catch(undefined),
pleroma: z.object({
quote_visible: z.boolean().catch(true),
}).optional().catch(undefined),
poll: pollSchema.nullable().catch(null),
quote: z.literal(null).catch(null),
quotes_count: z.number().catch(0),
reblog: z.literal(null).catch(null),
reblogged: z.coerce.boolean(),
reblogs_count: z.number().catch(0),
replies_count: z.number().catch(0),
sensitive: z.coerce.boolean(),
spoiler_text: contentSchema,
tags: filteredArray(tagSchema),
tombstone: tombstoneSchema.nullable().optional(),
tombstone: z.object({
reason: z.enum(['deleted']),
}).nullable().optional().catch(undefined),
uri: z.string().url().catch(''),
url: z.string().url().catch(''),
visibility: z.string().catch('public'),
});
type BaseStatus = z.infer<typeof baseStatusSchema>;
type TransformableStatus = Omit<BaseStatus, 'reblog' | 'quote' | 'pleroma'>;
/** Creates search index from the status. */
const buildSearchIndex = (status: TransformableStatus): string => {
const pollOptionTitles = status.poll ? status.poll.options.map(({ title }) => title) : [];
const mentionedUsernames = status.mentions.map(({ acct }) => `@${acct}`);
const fields = [
status.spoiler_text,
status.content,
...pollOptionTitles,
...mentionedUsernames,
];
const searchContent = unescapeHTML(fields.join('\n\n')) || '';
return new DOMParser().parseFromString(searchContent, 'text/html').documentElement.textContent || '';
};
type Translation = {
content: string
provider: string
}
/** Add internal fields to the status. */
const transformStatus = <T extends TransformableStatus>(status: T) => {
const emojiMap = makeCustomEmojiMap(status.emojis);
const contentHtml = stripCompatibilityFeatures(emojify(status.content, emojiMap));
const spoilerHtml = emojify(escapeTextContentForBrowser(status.spoiler_text), emojiMap);
return {
...status,
contentHtml,
spoilerHtml,
search_index: buildSearchIndex(status),
hidden: false,
filtered: [],
showFiltered: false, // TODO: this should be removed from the schema and done somewhere else
approval_status: 'approval' as const,
translation: undefined as Translation | undefined,
expectsCard: false,
};
};
const embeddedStatusSchema = baseStatusSchema
.transform(transformStatus)
.nullable()
.catch(null);
const statusSchema = baseStatusSchema.extend({
quote: baseStatusSchema.nullable().catch(null),
reblog: baseStatusSchema.nullable().catch(null),
});
quote: embeddedStatusSchema,
reblog: embeddedStatusSchema,
pleroma: z.object({
event: eventSchema,
quote: embeddedStatusSchema,
quote_visible: z.boolean().catch(true),
}).optional().catch(undefined),
}).transform(({ pleroma, ...status }) => {
return {
...status,
event: pleroma?.event,
quote: pleroma?.quote || status.quote || null,
// There's apparently no better way to do this...
// Just trying to remove the `event` and `quote` keys from the object.
pleroma: pleroma ? (() => {
const { event, quote, ...rest } = pleroma;
return rest;
})() : undefined,
};
}).transform(transformStatus);
type Status = z.infer<typeof statusSchema>;

Loading…
Cancel
Save