diff --git a/app/soapbox/actions/blocks.ts b/app/soapbox/actions/blocks.ts index d3f625884..ef2f40359 100644 --- a/app/soapbox/actions/blocks.ts +++ b/app/soapbox/actions/blocks.ts @@ -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, 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, getState: () => RootState) => { +const expandBlocks = () => (dispatch: AppDispatch, getState: () => RootState) => { if (!isLoggedIn(getState)) return null; const nextLinkName = getNextLinkName(getState); diff --git a/app/soapbox/actions/familiar-followers.ts b/app/soapbox/actions/familiar-followers.ts index 2d8aa6786..2c82126c6 100644 --- a/app/soapbox/actions/familiar-followers.ts +++ b/app/soapbox/actions/familiar-followers.ts @@ -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 -} - -type FamiliarFollowersFetchRequestFailAction = { - type: typeof FAMILIAR_FOLLOWERS_FETCH_FAIL - id: string - error: any -} - -type AccountsImportAction = { - type: typeof ACCOUNTS_IMPORT - accounts: Array -} - -export type FamiliarFollowersActions = FamiliarFollowersFetchRequestAction | FamiliarFollowersFetchRequestSuccessAction | FamiliarFollowersFetchRequestFailAction | AccountsImportAction - -export const fetchAccountFamiliarFollowers = (accountId: string) => (dispatch: React.Dispatch, 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, diff --git a/app/soapbox/actions/interactions.ts b/app/soapbox/actions/interactions.ts index 40d981139..b2517301f 100644 --- a/app/soapbox/actions/interactions.ts +++ b/app/soapbox/actions/interactions.ts @@ -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)); diff --git a/app/soapbox/features/status/components/detailed-status.tsx b/app/soapbox/features/status/components/detailed-status.tsx index 15f2e86e8..cd81dd0d2 100644 --- a/app/soapbox/features/status/components/detailed-status.tsx +++ b/app/soapbox/features/status/components/detailed-status.tsx @@ -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, index: number) => void - onOpenVideo: (media: ImmutableList, start: number) => void - onToggleHidden: (status: StatusEntity) => void showMedia: boolean onOpenCompareHistoryModal: (status: StatusEntity) => void onToggleMediaVisibility: () => void diff --git a/app/soapbox/features/status/index.tsx b/app/soapbox/features/status/index.tsx index 6b41148c4..e7dbc8018 100644 --- a/app/soapbox/features/status/index.tsx +++ b/app/soapbox/features/status/index.tsx @@ -230,14 +230,6 @@ const Thread: React.FC = (props) => { dispatch(mentionCompose(account)); }; - const handleOpenMedia = (media: ImmutableList, index: number) => { - dispatch(openModal('MEDIA', { media, status, index })); - }; - - const handleOpenVideo = (media: ImmutableList, 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 = (props) => { ; + +export { eventSchema, type Event }; \ No newline at end of file diff --git a/app/soapbox/schemas/location.ts b/app/soapbox/schemas/location.ts new file mode 100644 index 000000000..cbc237222 --- /dev/null +++ b/app/soapbox/schemas/location.ts @@ -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; + +export { locationSchema, type Location }; \ No newline at end of file diff --git a/app/soapbox/schemas/status.ts b/app/soapbox/schemas/status.ts index ea55d5085..323ca5789 100644 --- a/app/soapbox/schemas/status.ts +++ b/app/soapbox/schemas/status.ts @@ -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; +type TransformableStatus = Omit; + +/** 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 = (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;