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 This is a test status.