mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-30 20:37:43 -05:00
Refactor: Typing statuses (#39374)
This commit is contained in:
187
app/javascript/mastodon/actions/importer/statuses.ts
Normal file
187
app/javascript/mastodon/actions/importer/statuses.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'>;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user