From a7e80c62615233a305fdb9f39e1e0451f04e987f Mon Sep 17 00:00:00 2001 From: Echo Date: Fri, 12 Jun 2026 11:33:47 +0200 Subject: [PATCH] Refactor: Typing statuses (#39374) --- .../mastodon/actions/importer/statuses.ts | 187 ++++++++++++++++++ .../mastodon/api_types/media_attachments.ts | 81 +++++++- app/javascript/mastodon/api_types/statuses.ts | 19 +- app/javascript/mastodon/models/status.ts | 96 ++++++++- app/javascript/mastodon/selectors/statuses.ts | 22 ++- app/javascript/testing/factories.ts | 3 +- 6 files changed, 388 insertions(+), 20 deletions(-) create mode 100644 app/javascript/mastodon/actions/importer/statuses.ts diff --git a/app/javascript/mastodon/actions/importer/statuses.ts b/app/javascript/mastodon/actions/importer/statuses.ts new file mode 100644 index 00000000000..77538449535 --- /dev/null +++ b/app/javascript/mastodon/actions/importer/statuses.ts @@ -0,0 +1,187 @@ +import escapeTextContentForBrowser from 'escape-html'; + +import type { ApiMediaAttachmentJSON } from '@/mastodon/api_types/media_attachments'; +import type { + ApiFilterResultJSON, + ApiStatusJSON, +} from '@/mastodon/api_types/statuses'; +import type { + FilterResult, + MediaAttachmentShape, + StatusShape, +} from '@/mastodon/models/status'; + +const domParser = new DOMParser(); + +export function normalizeStatus( + status: ApiStatusJSON, + normalOldStatus?: StatusShape, + { bogusQuotePolicy = false, expandSpoilers = false } = {}, +) { + const normalStatus: StatusShape = { + hidden: normalOldStatus?.hidden ?? false, + collapsed: normalOldStatus?.collapsed ?? false, + content: '', + contentHtml: '', + muted: false, + pinned: false, + bookmarked: false, + favourited: false, + quote_approval: null, + reblogged: false, + + ...status, + + account: status.account.id, + media_attachments: [], + poll: status.poll?.id, + reblog: status.reblog?.id, + quote: undefined, + filtered: [], + application: { + name: 'Web', + ...status.application, + }, + }; + + if (bogusQuotePolicy) { + normalStatus.quote_approval = null; + } + + if (status.quote?.quoted_status) { + normalStatus.quote = { + ...status.quote, + quoted_status: status.quote.quoted_status.id, + }; + } + + if (status.card) { + normalStatus.card = { + ...status.card, + authors: status.card.authors.map((author) => ({ + name: author.name, + url: author.url, + accountId: author.account?.id, + })), + }; + } + + if (status.filtered) { + normalStatus.filtered = status.filtered.map(normalizeFilterResult); + } + + // Only calculate these values when status first encountered and + // when the underlying values change. Otherwise keep the ones + // already in the reducer + if ( + normalOldStatus?.content === normalStatus.content && + normalOldStatus.spoiler_text === normalStatus.spoiler_text + ) { + normalStatus.search_index = normalOldStatus.search_index; + normalStatus.contentHtml = normalOldStatus.contentHtml; + normalStatus.spoilerHtml = normalOldStatus.spoilerHtml; + normalStatus.spoiler_text = normalOldStatus.spoiler_text; + normalStatus.hidden = normalOldStatus.hidden; + + if (normalOldStatus.translation) { + normalStatus.translation = normalOldStatus.translation; + } + } else { + // If the status has a CW but no contents, treat the CW as if it were the + // status' contents, to avoid having a CW toggle with seemingly no effect. + if ( + normalStatus.spoiler_text && + !normalStatus.content && + !normalStatus.quote + ) { + normalStatus.content = normalStatus.spoiler_text; + normalStatus.spoiler_text = ''; + } + + const spoilerText = normalStatus.spoiler_text ?? ''; + const searchContent = [spoilerText, status.content] + .concat( + status.poll?.options + ? status.poll.options.map((option) => option.title) + : [], + ) + .concat(status.media_attachments.map((att) => att.description)) + .join('\n\n') + .replace(//g, '\n') + .replace(/<\/p>

/g, '\n\n'); + + normalStatus.search_index = domParser.parseFromString( + searchContent, + 'text/html', + ).documentElement.textContent; + normalStatus.contentHtml = normalStatus.content; + normalStatus.spoilerHtml = escapeTextContentForBrowser(spoilerText); + normalStatus.hidden = expandSpoilers + ? false + : spoilerText.length > 0 || normalStatus.sensitive; + + // Remove quote fallback link from the DOM so it doesn't mess with paragraph margins + if (normalStatus.quote && normalStatus.contentHtml) { + normalStatus.contentHtml = stripQuoteFallback(normalStatus.contentHtml); + } + + if ( + normalStatus.url && + !( + normalStatus.url.startsWith('http://') || + normalStatus.url.startsWith('https://') + ) + ) { + normalStatus.url = null; + } + + normalStatus.url ??= normalStatus.uri; + + normalStatus.media_attachments = status.media_attachments.map( + (attachment) => + normalizeMediaAttachment( + attachment, + normalOldStatus?.media_attachments, + ), + ); + } + + if (normalOldStatus) { + normalStatus.quote_approval ??= normalOldStatus.quote_approval; + } + + return normalStatus; +} + +function normalizeMediaAttachment( + attachment: ApiMediaAttachmentJSON, + oldAttachments?: MediaAttachmentShape[], +): MediaAttachmentShape { + const { remote_url } = attachment; + return { + ...attachment, + remote_url: + remote_url && /^https?:\/\//.test(remote_url) ? remote_url : null, + translation: oldAttachments?.find( + (oldAttachment) => + oldAttachment.id === attachment.id && + oldAttachment.description === attachment.description, + )?.translation, + }; +} + +function normalizeFilterResult(input: ApiFilterResultJSON): FilterResult { + return { + ...input, + filter: input.filter.id, + }; +} + +function stripQuoteFallback(text: string) { + const wrapper = document.createElement('div'); + wrapper.innerHTML = text; + + wrapper.querySelector('.quote-inline')?.remove(); + + return wrapper.innerHTML; +} diff --git a/app/javascript/mastodon/api_types/media_attachments.ts b/app/javascript/mastodon/api_types/media_attachments.ts index fc027ccd2a5..e4cfe958819 100644 --- a/app/javascript/mastodon/api_types/media_attachments.ts +++ b/app/javascript/mastodon/api_types/media_attachments.ts @@ -7,16 +7,85 @@ export type MediaAttachmentType = | 'unknown' | 'audio'; -export interface ApiMediaAttachmentJSON { +interface BaseApiMediaAttachmentJSON { id: string; type: MediaAttachmentType; url: string; preview_url: string; - remoteUrl: string; - preview_remote_url: string; - text_url: string; - // TODO: how to define this? - meta: unknown; + remote_url?: string; + preview_remote_url?: string; + text_url?: string; description?: string; blurhash: string; } + +interface ApiImageAttachmentJSON extends BaseApiMediaAttachmentJSON { + type: 'image'; + meta: { + original: ApiImageAttachmentMetaJSON; + small: ApiImageAttachmentMetaJSON; + }; +} + +interface ApiAudioAttachmentJSON extends BaseApiMediaAttachmentJSON { + type: 'audio'; + meta: { + colors: ApiColorsAttachmentMetaJSON; + original: ApiVideoAttachmentMetaJSON; + small: ApiImageAttachmentMetaJSON; + }; +} + +interface ApiVideoAttachmentJSON extends BaseApiMediaAttachmentJSON { + type: 'video'; + meta: { + colors: ApiColorsAttachmentMetaJSON; + original: ApiVideoAttachmentMetaJSON; + small: ApiImageAttachmentMetaJSON; + focus: { + x: number; + y: number; + }; + }; +} + +interface ApiGifvAttachmentJSON extends BaseApiMediaAttachmentJSON { + type: 'gifv'; + meta: { + original: ApiVideoAttachmentMetaJSON; + small: ApiImageAttachmentMetaJSON; + }; +} + +interface ApiUnknownAttachmentJSON extends BaseApiMediaAttachmentJSON { + type: 'unknown'; + meta: unknown; +} + +export type ApiMediaAttachmentJSON = + | ApiImageAttachmentJSON + | ApiAudioAttachmentJSON + | ApiVideoAttachmentJSON + | ApiGifvAttachmentJSON + | ApiUnknownAttachmentJSON; + +export interface ApiImageAttachmentMetaJSON { + width: number; + height: number; + size: string; + aspect: number; +} + +export interface ApiVideoAttachmentMetaJSON { + width: number; + height: number; + frame_rate: string; + duration: number; + bitrate: number; +} + +export interface ApiColorsAttachmentMetaJSON { + background: string; + foreground: string; + accent: string; +} diff --git a/app/javascript/mastodon/api_types/statuses.ts b/app/javascript/mastodon/api_types/statuses.ts index d61d8ceed06..1d5353fd4fa 100644 --- a/app/javascript/mastodon/api_types/statuses.ts +++ b/app/javascript/mastodon/api_types/statuses.ts @@ -1,6 +1,7 @@ // See app/serializers/rest/status_serializer.rb import type { ApiAccountJSON } from './accounts'; +import type { ApiCollectionJSON } from './collections'; import type { ApiCustomEmojiJSON } from './custom_emoji'; import type { ApiMediaAttachmentJSON } from './media_attachments'; import type { ApiPollJSON } from './polls'; @@ -94,11 +95,11 @@ export interface ApiStatusJSON { url: string; replies_count: number; reblogs_count: number; - favorites_count: number; + favourites_count: number; quotes_count: number; edited_at?: string; - favorited?: boolean; + favourited?: boolean; reblogged?: boolean; muted?: boolean; bookmarked?: boolean; @@ -116,6 +117,7 @@ export interface ApiStatusJSON { tags: ApiTagJSON[]; emojis: ApiCustomEmojiJSON[]; + tagged_collections: ApiCollectionJSON[]; card?: ApiPreviewCardJSON; poll?: ApiPollJSON; @@ -134,6 +136,19 @@ export interface ApiStatusSourceJSON { spoiler_text: string; } +export interface ApiStatusTranslationJSON { + detected_source_language: string; + language: string; + provider: string; + contentHtml: string; + spoilerHtml: string; + spoiler_text: string; + poll?: { + id: string; + options: { title: string }[]; + }; +} + export function isStatusVisibility( visibility: string, ): visibility is StatusVisibility { diff --git a/app/javascript/mastodon/models/status.ts b/app/javascript/mastodon/models/status.ts index 668546ea261..dc4138330fc 100644 --- a/app/javascript/mastodon/models/status.ts +++ b/app/javascript/mastodon/models/status.ts @@ -1,15 +1,103 @@ import type { RecordOf } from 'immutable'; -import type { ApiCollectionJSON } from 'mastodon/api_types/collections'; -import type { ApiPreviewCardJSON } from 'mastodon/api_types/statuses'; +import type { ApiCollectionJSON } from '@/mastodon/api_types/collections'; +import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji'; +import type { ApiMediaAttachmentJSON } from '@/mastodon/api_types/media_attachments'; +import type { + ApiQuoteJSON, + ApiQuotePolicyJSON, +} from '@/mastodon/api_types/quotes'; +import type { + ApiFilterResultJSON, + ApiMentionJSON, + ApiPreviewCardAuthorJSON, + ApiPreviewCardJSON, + ApiStatusTranslationJSON, + ApiTagJSON, + StatusVisibility, +} from '@/mastodon/api_types/statuses'; -export type { StatusVisibility } from 'mastodon/api_types/statuses'; +export type { StatusVisibility } from '@/mastodon/api_types/statuses'; // Temporary until we type it correctly export type Status = Immutable.Map; -export type Card = RecordOf; +export interface StatusShape { + id: string; + account: string; + created_at: string; + edited_at?: string; + application: { + name: string; + website?: string; + }; + hidden: boolean; + language: string; + muted: boolean; + pinned: boolean; + filtered: FilterResult[]; + sensitive: boolean; + collapsed: boolean; + uri: string; + url: string | null; + + // Content + content: string; + contentHtml: string; + in_reply_to_account_id?: string; + in_reply_to_id?: string; + search_index?: string; + spoilerHtml?: string; + spoiler_text?: string; + + // Embeds + card?: CardShape; + emojis: Pick[]; + media_attachments: MediaAttachmentShape[]; + mentions: ApiMentionJSON[]; + poll?: string; + quote?: Omit & { + quoted_status?: string; + }; + reblog?: string; + tagged_collections: ApiCollectionJSON[]; + tags: ApiTagJSON[]; + translation?: StatusTranslation; + + // Interactions + bookmarked: boolean; + favourited: boolean; + favourites_count: number; + quote_approval: ApiQuotePolicyJSON | null; + quotes_count: number; + reblogged: boolean; + reblogs_count: number; + replies_count: number; + visibility: StatusVisibility; +} + +export type CardShape = Omit & { + authors: (Omit & { + accountId?: string; + })[]; +}; + +export type Card = RecordOf; export type MediaAttachment = Immutable.Map; +export type MediaAttachmentShape = Omit< + ApiMediaAttachmentJSON, + 'remote_url' +> & { + remote_url: string | null; + translation?: string; +}; + export type CollectionAttachment = RecordOf; + +export type FilterResult = Omit & { + filter: string; +}; + +export type StatusTranslation = Omit; diff --git a/app/javascript/mastodon/selectors/statuses.ts b/app/javascript/mastodon/selectors/statuses.ts index 4d045e924a7..dde0b8b2270 100644 --- a/app/javascript/mastodon/selectors/statuses.ts +++ b/app/javascript/mastodon/selectors/statuses.ts @@ -1,15 +1,23 @@ -import { createSelector } from '@reduxjs/toolkit'; import type { OrderedSet as ImmutableOrderedSet } from 'immutable'; -import type { RootState } from 'mastodon/store'; +import { createAppSelector } from 'mastodon/store'; -export const getStatusList = createSelector( +import type { StatusShape } from '../models/status'; + +export const getStatusList = createAppSelector( [ - ( - state: RootState, - type: 'favourites' | 'bookmarks' | 'pins' | 'trending', - ) => + (state, type: 'favourites' | 'bookmarks' | 'pins' | 'trending') => state.status_lists.getIn([type, 'items']) as ImmutableOrderedSet, ], (items) => items.toList(), ); + +export const selectPlainStatus = createAppSelector( + [(state, statusId: string) => state.statuses.get(statusId)], + (status) => { + if (!status) { + return null; + } + return status.toJS() as unknown as StatusShape; + }, +); diff --git a/app/javascript/testing/factories.ts b/app/javascript/testing/factories.ts index 8ac03ec2ad1..e28f4613687 100644 --- a/app/javascript/testing/factories.ts +++ b/app/javascript/testing/factories.ts @@ -80,12 +80,13 @@ export const statusFactory: FactoryFunction = ({ replies_count: 0, reblogs_count: 0, quotes_count: 0, - favorites_count: 0, + favourites_count: 0, account: accountFactory(), media_attachments: [], mentions: [], tags: [], emojis: [], + tagged_collections: [], contentHtml: data.text ?? '

This is a test status.

', ...data, });