Refactor: Typing statuses (#39374)

This commit is contained in:
Echo
2026-06-12 11:33:47 +02:00
committed by GitHub
parent b902c82505
commit a7e80c6261
6 changed files with 388 additions and 20 deletions

View File

@@ -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(/<br\s*\/?>/g, '\n')
.replace(/<\/p><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;
}

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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<string, unknown>;
export type Card = RecordOf<ApiPreviewCardJSON>;
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<ApiCustomEmojiJSON, 'shortcode' | 'static_url' | 'url'>[];
media_attachments: MediaAttachmentShape[];
mentions: ApiMentionJSON[];
poll?: string;
quote?: Omit<ApiQuoteJSON, 'quoted_status'> & {
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<ApiPreviewCardJSON, 'authors'> & {
authors: (Omit<ApiPreviewCardAuthorJSON, 'author'> & {
accountId?: string;
})[];
};
export type Card = RecordOf<CardShape>;
export type MediaAttachment = Immutable.Map<string, unknown>;
export type MediaAttachmentShape = Omit<
ApiMediaAttachmentJSON,
'remote_url'
> & {
remote_url: string | null;
translation?: string;
};
export type CollectionAttachment = RecordOf<ApiCollectionJSON>;
export type FilterResult = Omit<ApiFilterResultJSON, 'filter'> & {
filter: string;
};
export type StatusTranslation = Omit<ApiStatusTranslationJSON, 'poll'>;

View File

@@ -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<string>,
],
(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;
},
);

View File

@@ -80,12 +80,13 @@ export const statusFactory: FactoryFunction<ApiStatusJSON> = ({
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 ?? '<p>This is a test status.</p>',
...data,
});