diff --git a/app/soapbox/__fixtures__/account-moved.json b/app/soapbox/__fixtures__/account-moved.json
new file mode 100644
index 0000000000..dbb194916a
--- /dev/null
+++ b/app/soapbox/__fixtures__/account-moved.json
@@ -0,0 +1,46 @@
+{
+ "id": "106801667066418367",
+ "username": "benis911",
+ "acct": "benis911",
+ "display_name": "",
+ "locked": false,
+ "bot": false,
+ "discoverable": null,
+ "group": false,
+ "created_at": "2021-08-22T00:00:00.000Z",
+ "note": "",
+ "url": "https://mastodon.social/@benis911",
+ "avatar": "https://mastodon.social/avatars/original/missing.png",
+ "avatar_static": "https://mastodon.social/avatars/original/missing.png",
+ "header": "https://mastodon.social/headers/original/missing.png",
+ "header_static": "https://mastodon.social/headers/original/missing.png",
+ "followers_count": 0,
+ "following_count": 0,
+ "statuses_count": 5,
+ "last_status_at": "2022-02-23",
+ "moved": {
+ "id": "107945464165013501",
+ "username": "alex",
+ "acct": "alex@fedibird.com",
+ "display_name": "",
+ "locked": false,
+ "bot": false,
+ "discoverable": false,
+ "group": false,
+ "created_at": "2020-01-27T00:00:00.000Z",
+ "note": "
",
+ "url": "https://fedibird.com/@alex",
+ "avatar": "https://mastodon.social/avatars/original/missing.png",
+ "avatar_static": "https://mastodon.social/avatars/original/missing.png",
+ "header": "https://mastodon.social/headers/original/missing.png",
+ "header_static": "https://mastodon.social/headers/original/missing.png",
+ "followers_count": 1,
+ "following_count": 1,
+ "statuses_count": 5,
+ "last_status_at": null,
+ "emojis": [],
+ "fields": []
+ },
+ "emojis": [],
+ "fields": []
+}
diff --git a/app/soapbox/__fixtures__/account-with-emojis.json b/app/soapbox/__fixtures__/account-with-emojis.json
new file mode 100644
index 0000000000..19025e150b
--- /dev/null
+++ b/app/soapbox/__fixtures__/account-with-emojis.json
@@ -0,0 +1,140 @@
+{
+ "acct": "alex",
+ "avatar": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png",
+ "avatar_static": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png",
+ "bot": false,
+ "created_at": "2020-01-08T01:25:43.000Z",
+ "display_name": "Alex Gleason 😂 :soapbox: :ablobcatrainbow:",
+ "emojis": [
+ {
+ "shortcode": "ablobcatrainbow",
+ "static_url": "https://gleasonator.com/emoji/blobcat/ablobcatrainbow.png",
+ "url": "https://gleasonator.com/emoji/blobcat/ablobcatrainbow.png",
+ "visible_in_picker": false
+ },
+ {
+ "shortcode": "soapbox",
+ "static_url": "https://gleasonator.com/emoji/Gleasonator/soapbox.png",
+ "url": "https://gleasonator.com/emoji/Gleasonator/soapbox.png",
+ "visible_in_picker": false
+ }
+ ],
+ "fields": [
+ {
+ "name": "Website",
+ "value": "https://alexgleason.me"
+ },
+ {
+ "name": "Soapbox :ablobcatrainbow:",
+ "value": "https://soapbox.pub :soapbox:"
+ },
+ {
+ "name": "Email",
+ "value": "alex@alexgleason.me"
+ },
+ {
+ "name": "Gender identity",
+ "value": "Soyboy"
+ },
+ {
+ "name": "Donate (PayPal)",
+ "value": "https://paypal.me/gleasonator"
+ },
+ {
+ "name": "$BTC",
+ "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n"
+ },
+ {
+ "name": "$ETH",
+ "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717"
+ },
+ {
+ "name": "$DOGE",
+ "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D"
+ },
+ {
+ "name": "$XMR",
+ "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK"
+ }
+ ],
+ "followers_count": 2476,
+ "following_count": 1584,
+ "fqn": "alex@gleasonator.com",
+ "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png",
+ "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png",
+ "id": "9v5bmRalQvjOy0ECcC",
+ "last_status_at": "2022-03-12T16:35:10",
+ "locked": false,
+ "note": "I create Fediverse software that empowers people online. :soapbox:
I'm vegan btw
Note: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.",
+ "pleroma": {
+ "accepts_chat_messages": true,
+ "also_known_as": [
+ "https://mitra.social/users/alex"
+ ],
+ "ap_id": "https://gleasonator.com/users/alex",
+ "background_image": null,
+ "birthday": "1993-07-03",
+ "favicon": "https://gleasonator.com/favicon.png",
+ "hide_favorites": true,
+ "hide_followers": false,
+ "hide_followers_count": false,
+ "hide_follows": false,
+ "hide_follows_count": false,
+ "is_admin": true,
+ "is_confirmed": true,
+ "is_moderator": false,
+ "is_suggested": true,
+ "relationship": {},
+ "skip_thread_containment": false,
+ "tags": []
+ },
+ "source": {
+ "fields": [
+ {
+ "name": "Website",
+ "value": "https://alexgleason.me"
+ },
+ {
+ "name": "Soapbox :ablobcatrainbow:",
+ "value": "https://soapbox.pub :soapbox:"
+ },
+ {
+ "name": "Email",
+ "value": "alex@alexgleason.me"
+ },
+ {
+ "name": "Gender identity",
+ "value": "Soyboy"
+ },
+ {
+ "name": "Donate (PayPal)",
+ "value": "https://paypal.me/gleasonator"
+ },
+ {
+ "name": "$BTC",
+ "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n"
+ },
+ {
+ "name": "$ETH",
+ "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717"
+ },
+ {
+ "name": "$DOGE",
+ "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D"
+ },
+ {
+ "name": "$XMR",
+ "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK"
+ }
+ ],
+ "note": "I create Fediverse software that empowers people online. :soapbox:\r\n\r\nI'm vegan btw\r\n\r\nNote: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.",
+ "pleroma": {
+ "actor_type": "Person",
+ "discoverable": false
+ },
+ "sensitive": false
+ },
+ "statuses_count": 23674,
+ "url": "https://gleasonator.com/users/alex",
+ "username": "alex"
+}
diff --git a/app/soapbox/__fixtures__/status-with-card.json b/app/soapbox/__fixtures__/status-with-card.json
new file mode 100644
index 0000000000..da2f83b940
--- /dev/null
+++ b/app/soapbox/__fixtures__/status-with-card.json
@@ -0,0 +1,210 @@
+{
+ "account": {
+ "acct": "alex",
+ "avatar": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png",
+ "avatar_static": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png",
+ "bot": false,
+ "created_at": "2020-01-08T01:25:43.000Z",
+ "display_name": "Alex Gleason",
+ "emojis": [
+ {
+ "shortcode": "soapbox",
+ "static_url": "https://gleasonator.com/emoji/Gleasonator/soapbox.png",
+ "url": "https://gleasonator.com/emoji/Gleasonator/soapbox.png",
+ "visible_in_picker": false
+ }
+ ],
+ "fields": [
+ {
+ "name": "Website",
+ "value": "https://alexgleason.me"
+ },
+ {
+ "name": "Soapbox :soapbox:",
+ "value": "https://soapbox.pub"
+ },
+ {
+ "name": "Email",
+ "value": "alex@alexgleason.me"
+ },
+ {
+ "name": "Gender identity",
+ "value": "Soyboy"
+ },
+ {
+ "name": "Donate (PayPal)",
+ "value": "https://paypal.me/gleasonator"
+ },
+ {
+ "name": "$BTC",
+ "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n"
+ },
+ {
+ "name": "$ETH",
+ "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717"
+ },
+ {
+ "name": "$DOGE",
+ "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D"
+ },
+ {
+ "name": "$XMR",
+ "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK"
+ }
+ ],
+ "followers_count": 2476,
+ "following_count": 1584,
+ "fqn": "alex@gleasonator.com",
+ "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png",
+ "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png",
+ "id": "9v5bmRalQvjOy0ECcC",
+ "last_status_at": "2022-03-12T16:35:10",
+ "locked": false,
+ "note": "I create Fediverse software that empowers people online. :soapbox:
I'm vegan btw
Note: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.",
+ "pleroma": {
+ "accepts_chat_messages": true,
+ "also_known_as": [
+ "https://mitra.social/users/alex"
+ ],
+ "ap_id": "https://gleasonator.com/users/alex",
+ "background_image": null,
+ "birthday": "1993-07-03",
+ "favicon": "https://gleasonator.com/favicon.png",
+ "hide_favorites": true,
+ "hide_followers": false,
+ "hide_followers_count": false,
+ "hide_follows": false,
+ "hide_follows_count": false,
+ "is_admin": true,
+ "is_confirmed": true,
+ "is_moderator": false,
+ "is_suggested": true,
+ "relationship": {},
+ "skip_thread_containment": false,
+ "tags": []
+ },
+ "source": {
+ "fields": [
+ {
+ "name": "Website",
+ "value": "https://alexgleason.me"
+ },
+ {
+ "name": "Soapbox :soapbox:",
+ "value": "https://soapbox.pub"
+ },
+ {
+ "name": "Email",
+ "value": "alex@alexgleason.me"
+ },
+ {
+ "name": "Gender identity",
+ "value": "Soyboy"
+ },
+ {
+ "name": "Donate (PayPal)",
+ "value": "https://paypal.me/gleasonator"
+ },
+ {
+ "name": "$BTC",
+ "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n"
+ },
+ {
+ "name": "$ETH",
+ "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717"
+ },
+ {
+ "name": "$DOGE",
+ "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D"
+ },
+ {
+ "name": "$XMR",
+ "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK"
+ }
+ ],
+ "note": "I create Fediverse software that empowers people online. :soapbox:\r\n\r\nI'm vegan btw\r\n\r\nNote: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.",
+ "pleroma": {
+ "actor_type": "Person",
+ "discoverable": false
+ },
+ "sensitive": false
+ },
+ "statuses_count": 23674,
+ "url": "https://gleasonator.com/users/alex",
+ "username": "alex"
+ },
+ "application": null,
+ "bookmarked": false,
+ "card": {
+ "author_name": "Alex Gleason",
+ "author_url": "https://soapbox.pub/author/alex/",
+ "blurhash": null,
+ "description": "On cryptocurrency I’ve always believed integrated donations would be a necessary part of the Fediverse. Admins do all the heavy lifting; it’s a thankless job. Meanwhile users want to help secure their new online home, but feel powerless to do so. I have been running an experimental payment platform based on Stripe alongside my Soapbox […]",
+ "embed_url": null,
+ "height": 338,
+ "html": "",
+ "image": "https://gleasonator.com/proxy/L2kUi5uxMdoC6LYYrnAdlJviPGQ/aHR0cHM6Ly9tZWRpYS5zb2FwYm94LnB1Yi91cGxvYWRzLzIwMjEvMDcvdi0xLTMtdGh1bWIucG5n/v-1-3-thumb.png",
+ "provider_name": "Soapbox",
+ "provider_url": "https://soapbox.pub",
+ "title": "Soapbox FE v1.3: The Crypto Release - Soapbox",
+ "type": "link",
+ "url": "https://soapbox.pub/blog/soapbox-fe-v1.3-cryptocurrency-release/",
+ "width": 600
+ },
+ "content": "Soapbox FE v1.3 released. Read about it here: https://soapbox.pub/blog/soapbox-fe-v1.3-cryptocurrency-release/
Enjoy!
",
+ "created_at": "2021-07-02T20:49:39.000Z",
+ "emojis": [],
+ "favourited": false,
+ "favourites_count": 29,
+ "id": "A8tEMYF2GNnfPcL4dc",
+ "in_reply_to_account_id": null,
+ "in_reply_to_id": null,
+ "language": null,
+ "media_attachments": [],
+ "mentions": [],
+ "muted": false,
+ "pinned": true,
+ "pleroma": {
+ "content": {
+ "text/plain": "Soapbox FE v1.3 released. Read about it here: https://soapbox.pub/blog/soapbox-fe-v1.3-cryptocurrency-release/Enjoy!"
+ },
+ "conversation_id": "16496668",
+ "direct_conversation_id": null,
+ "emoji_reactions": [
+ {
+ "count": 5,
+ "me": false,
+ "name": "❤️"
+ },
+ {
+ "count": 1,
+ "me": false,
+ "name": "👍"
+ }
+ ],
+ "expires_at": null,
+ "in_reply_to_account_acct": null,
+ "local": true,
+ "parent_visible": false,
+ "pinned_at": "2021-11-23T01:38:44.000Z",
+ "quote": null,
+ "quote_url": null,
+ "quote_visible": false,
+ "spoiler_text": {
+ "text/plain": ""
+ },
+ "thread_muted": false
+ },
+ "poll": null,
+ "reblog": null,
+ "reblogged": false,
+ "reblogs_count": 16,
+ "replies_count": 7,
+ "sensitive": false,
+ "spoiler_text": "",
+ "tags": [],
+ "text": null,
+ "uri": "https://gleasonator.com/objects/3eabaf63-47f4-4314-9ddb-ce7dbf46b393",
+ "url": "https://gleasonator.com/notice/A8tEMYF2GNnfPcL4dc",
+ "visibility": "public"
+}
diff --git a/app/soapbox/actions/importer/index.js b/app/soapbox/actions/importer/index.js
index 1668ad71ef..ee3af3ac3f 100644
Binary files a/app/soapbox/actions/importer/index.js and b/app/soapbox/actions/importer/index.js differ
diff --git a/app/soapbox/actions/importer/normalizer.js b/app/soapbox/actions/importer/normalizer.js
deleted file mode 100644
index d619329573..0000000000
Binary files a/app/soapbox/actions/importer/normalizer.js and /dev/null differ
diff --git a/app/soapbox/normalizers/__tests__/account-test.js b/app/soapbox/normalizers/__tests__/account-test.js
index 5199e3492a..9211a1cf84 100644
Binary files a/app/soapbox/normalizers/__tests__/account-test.js and b/app/soapbox/normalizers/__tests__/account-test.js differ
diff --git a/app/soapbox/normalizers/__tests__/attachment-test.js b/app/soapbox/normalizers/__tests__/attachment-test.js
new file mode 100644
index 0000000000..ecf3813e7b
Binary files /dev/null and b/app/soapbox/normalizers/__tests__/attachment-test.js differ
diff --git a/app/soapbox/normalizers/__tests__/card-test.js b/app/soapbox/normalizers/__tests__/card-test.js
new file mode 100644
index 0000000000..e8ac120b04
Binary files /dev/null and b/app/soapbox/normalizers/__tests__/card-test.js differ
diff --git a/app/soapbox/normalizers/__tests__/mention-test.js b/app/soapbox/normalizers/__tests__/mention-test.js
new file mode 100644
index 0000000000..e429a03b1e
Binary files /dev/null and b/app/soapbox/normalizers/__tests__/mention-test.js differ
diff --git a/app/soapbox/normalizers/__tests__/poll-test.js b/app/soapbox/normalizers/__tests__/poll-test.js
new file mode 100644
index 0000000000..4cc9dbbbc3
Binary files /dev/null and b/app/soapbox/normalizers/__tests__/poll-test.js differ
diff --git a/app/soapbox/normalizers/__tests__/status-test.js b/app/soapbox/normalizers/__tests__/status-test.js
index cd368ea0e5..2824615ccf 100644
Binary files a/app/soapbox/normalizers/__tests__/status-test.js and b/app/soapbox/normalizers/__tests__/status-test.js differ
diff --git a/app/soapbox/normalizers/account.ts b/app/soapbox/normalizers/account.ts
index d6e81033b0..7faa3282c9 100644
--- a/app/soapbox/normalizers/account.ts
+++ b/app/soapbox/normalizers/account.ts
@@ -1,12 +1,22 @@
+/**
+ * Account normalizer:
+ * Converts API accounts into our internal format.
+ * @see {@link https://docs.joinmastodon.org/entities/account/}
+ */
+import escapeTextContentForBrowser from 'escape-html';
import {
Map as ImmutableMap,
List as ImmutableList,
Record as ImmutableRecord,
} from 'immutable';
+import emojify from 'soapbox/features/emoji/emoji';
+import { normalizeEmoji } from 'soapbox/normalizers/emoji';
import { IAccount } from 'soapbox/types';
-import { mergeDefined } from 'soapbox/utils/normalizers';
+import { unescapeHTML } from 'soapbox/utils/html';
+import { mergeDefined, makeEmojiMap } from 'soapbox/utils/normalizers';
+// https://docs.joinmastodon.org/entities/account/
const AccountRecord = ImmutableRecord({
acct: '',
avatar: '',
@@ -45,6 +55,18 @@ const AccountRecord = ImmutableRecord({
should_refetch: false,
});
+// https://docs.joinmastodon.org/entities/field/
+const FieldRecord = ImmutableRecord({
+ name: '',
+ value: '',
+ verified_at: null,
+
+ // Internal fields
+ name_emojified: '',
+ value_emojified: '',
+ value_plain: '',
+});
+
// https://gitlab.com/soapbox-pub/soapbox-fe/-/issues/549
const normalizePleromaLegacyFields = (account: ImmutableMap) => {
return account.update('pleroma', ImmutableMap(), (pleroma: ImmutableMap) => {
@@ -61,6 +83,29 @@ const normalizePleromaLegacyFields = (account: ImmutableMap) => {
});
};
+// Add avatar, if missing
+const normalizeAvatar = (account: ImmutableMap) => {
+ const avatar = account.get('avatar');
+ const avatarStatic = account.get('avatar_static');
+ const missing = require('images/avatar-missing.png');
+
+ return account.withMutations(account => {
+ account.set('avatar', avatar || avatarStatic || missing);
+ account.set('avatar_static', avatarStatic || avatar || missing);
+ });
+};
+
+// Normalize custom fields
+const normalizeFields = (account: ImmutableMap) => {
+ return account.update('fields', ImmutableList(), fields => fields.map(FieldRecord));
+};
+
+// Normalize emojis
+const normalizeEmojis = (entity: ImmutableMap) => {
+ const emojis = entity.get('emojis', ImmutableList()).map(normalizeEmoji);
+ return entity.set('emojis', emojis);
+};
+
// Normalize Pleroma/Fedibird birthday
const normalizeBirthday = (account: ImmutableMap) => {
const birthday = [
@@ -99,19 +144,55 @@ const normalizeLocation = (account: ImmutableMap) => {
// Set username from acct, if applicable
const fixUsername = (account: ImmutableMap) => {
- return account.update('username', username => (
- username || (account.get('acct') || '').split('@')[0]
- ));
+ const acct = account.get('acct') || '';
+ const username = account.get('username') || '';
+ return account.set('username', username || acct.split('@')[0]);
+};
+
+// Set display name from username, if applicable
+const fixDisplayName = (account: ImmutableMap) => {
+ const displayName = account.get('display_name') || '';
+ return account.set('display_name', displayName.trim().length === 0 ? account.get('username') : displayName);
+};
+
+// Emojification, etc
+const addInternalFields = (account: ImmutableMap) => {
+ const emojiMap = makeEmojiMap(account.get('emojis'));
+
+ return account.withMutations((account: ImmutableMap) => {
+ // Emojify account properties
+ account.merge({
+ display_name_html: emojify(escapeTextContentForBrowser(account.get('display_name')), emojiMap),
+ note_emojified: emojify(account.get('note', ''), emojiMap),
+ note_plain: unescapeHTML(account.get('note', '')),
+ });
+
+ // Emojify fields
+ account.update('fields', ImmutableList(), fields => {
+ return fields.map((field: ImmutableMap) => {
+ return field.merge({
+ name_emojified: emojify(escapeTextContentForBrowser(field.get('name')), emojiMap),
+ value_emojified: emojify(field.get('value'), emojiMap),
+ value_plain: unescapeHTML(field.get('value')),
+ });
+ });
+ });
+ });
};
export const normalizeAccount = (account: ImmutableMap): IAccount => {
return AccountRecord(
account.withMutations(account => {
normalizePleromaLegacyFields(account);
+ normalizeEmojis(account);
+ normalizeAvatar(account);
+ normalizeFields(account);
normalizeVerified(account);
normalizeBirthday(account);
normalizeLocation(account);
fixUsername(account);
+ fixDisplayName(account);
+ addInternalFields(account);
}),
);
};
diff --git a/app/soapbox/normalizers/attachment.ts b/app/soapbox/normalizers/attachment.ts
new file mode 100644
index 0000000000..9599fc1cee
--- /dev/null
+++ b/app/soapbox/normalizers/attachment.ts
@@ -0,0 +1,45 @@
+/**
+ * Attachment normalizer:
+ * Converts API attachments into our internal format.
+ * @see {@link https://docs.joinmastodon.org/entities/attachment/}
+ */
+import {
+ Map as ImmutableMap,
+ Record as ImmutableRecord,
+} from 'immutable';
+
+import { mergeDefined } from 'soapbox/utils/normalizers';
+
+// https://docs.joinmastodon.org/entities/attachment/
+const AttachmentRecord = ImmutableRecord({
+ blurhash: undefined,
+ description: '',
+ id: '',
+ meta: ImmutableMap(),
+ pleroma: ImmutableMap(),
+ preview_url: '',
+ remote_url: null,
+ type: 'unknown',
+ url: '',
+
+ // Internal fields
+ // TODO: Remove these? They're set in selectors/index.js
+ account: null,
+ status: null,
+});
+
+// Ensure attachments have required fields
+export const normalizeAttachment = (attachment: ImmutableMap) => {
+ const url = [
+ attachment.get('url'),
+ attachment.get('preview_url'),
+ attachment.get('remote_url'),
+ ].find(url => url) || '';
+
+ const base = ImmutableMap({
+ url,
+ preview_url: url,
+ });
+
+ return AttachmentRecord(attachment.mergeWith(mergeDefined, base));
+};
diff --git a/app/soapbox/normalizers/card.ts b/app/soapbox/normalizers/card.ts
new file mode 100644
index 0000000000..c9ac76adb0
--- /dev/null
+++ b/app/soapbox/normalizers/card.ts
@@ -0,0 +1,28 @@
+/**
+ * Card normalizer:
+ * Converts API cards into our internal format.
+ * @see {@link https://docs.joinmastodon.org/entities/card/}
+ */
+import { Record as ImmutableRecord, Map as ImmutableMap } from 'immutable';
+
+// https://docs.joinmastodon.org/entities/card/
+const CardRecord = ImmutableRecord({
+ author_name: '',
+ author_url: '',
+ blurhash: null,
+ description: '',
+ embed_url: '',
+ height: 0,
+ html: '',
+ image: null,
+ provider_name: '',
+ provider_url: '',
+ title: '',
+ type: 'link',
+ url: '',
+ width: 0,
+});
+
+export const normalizeCard = (card: ImmutableMap) => {
+ return CardRecord(card);
+};
diff --git a/app/soapbox/normalizers/emoji.ts b/app/soapbox/normalizers/emoji.ts
new file mode 100644
index 0000000000..f450af2533
--- /dev/null
+++ b/app/soapbox/normalizers/emoji.ts
@@ -0,0 +1,19 @@
+/**
+ * Emoji normalizer:
+ * Converts API emojis into our internal format.
+ * @see {@link https://docs.joinmastodon.org/entities/emoji/}
+ */
+import { Record as ImmutableRecord, Map as ImmutableMap } from 'immutable';
+
+// https://docs.joinmastodon.org/entities/emoji/
+const EmojiRecord = ImmutableRecord({
+ category: '',
+ shortcode: '',
+ static_url: '',
+ url: '',
+ visible_in_picker: true,
+});
+
+export const normalizeEmoji = (emoji: ImmutableMap) => {
+ return EmojiRecord(emoji);
+};
diff --git a/app/soapbox/normalizers/instance.ts b/app/soapbox/normalizers/instance.ts
index 1ae2801d34..8e0fe02a6c 100644
--- a/app/soapbox/normalizers/instance.ts
+++ b/app/soapbox/normalizers/instance.ts
@@ -1,3 +1,8 @@
+/**
+ * Instance normalizer:
+ * Converts API instances into our internal format.
+ * @see {@link https://docs.joinmastodon.org/entities/instance/}
+ */
import {
Map as ImmutableMap,
List as ImmutableList,
@@ -9,6 +14,7 @@ import { mergeDefined } from 'soapbox/utils/normalizers';
import { isNumber } from 'soapbox/utils/numbers';
// Use Mastodon defaults
+// https://docs.joinmastodon.org/entities/instance/
const InstanceRecord = ImmutableRecord({
approval_required: false,
contact_account: ImmutableMap(),
diff --git a/app/soapbox/normalizers/mention.ts b/app/soapbox/normalizers/mention.ts
new file mode 100644
index 0000000000..9982020655
--- /dev/null
+++ b/app/soapbox/normalizers/mention.ts
@@ -0,0 +1,24 @@
+/**
+ * Mention normalizer:
+ * Converts API mentions into our internal format.
+ * @see {@link https://docs.joinmastodon.org/entities/mention/}
+ */
+import {
+ Map as ImmutableMap,
+ Record as ImmutableRecord,
+} from 'immutable';
+
+import { normalizeAccount } from 'soapbox/normalizers/account';
+
+// https://docs.joinmastodon.org/entities/mention/
+const MentionRecord = ImmutableRecord({
+ id: '',
+ acct: '',
+ username: '',
+ url: '',
+});
+
+export const normalizeMention = (mention: ImmutableMap) => {
+ // Simply normalize it as an account then cast it as a mention ¯\_(ツ)_/¯
+ return MentionRecord(normalizeAccount(mention));
+};
diff --git a/app/soapbox/normalizers/notification.ts b/app/soapbox/normalizers/notification.ts
index ab369a52b2..e0f4666188 100644
--- a/app/soapbox/normalizers/notification.ts
+++ b/app/soapbox/normalizers/notification.ts
@@ -1,3 +1,8 @@
+/**
+ * Notification normalizer:
+ * Converts API notifications into our internal format.
+ * @see {@link https://docs.joinmastodon.org/entities/notification/}
+ */
import {
Map as ImmutableMap,
Record as ImmutableRecord,
diff --git a/app/soapbox/normalizers/poll.ts b/app/soapbox/normalizers/poll.ts
new file mode 100644
index 0000000000..fa127702e7
--- /dev/null
+++ b/app/soapbox/normalizers/poll.ts
@@ -0,0 +1,88 @@
+/**
+ * Poll normalizer:
+ * Converts API polls into our internal format.
+ * @see {@link https://docs.joinmastodon.org/entities/poll/}
+ */
+import escapeTextContentForBrowser from 'escape-html';
+import {
+ Map as ImmutableMap,
+ List as ImmutableList,
+ Record as ImmutableRecord,
+} from 'immutable';
+
+import emojify from 'soapbox/features/emoji/emoji';
+import { normalizeEmoji } from 'soapbox/normalizers/emoji';
+import { makeEmojiMap } from 'soapbox/utils/normalizers';
+
+// https://docs.joinmastodon.org/entities/poll/
+const PollRecord = ImmutableRecord({
+ emojis: ImmutableList(),
+ expired: false,
+ expires_at: new Date(),
+ id: '',
+ multiple: false,
+ options: ImmutableList(),
+ voters_count: 0,
+ votes_count: 0,
+ own_votes: null,
+ voted: false,
+});
+
+// Sub-entity of Poll
+const PollOptionRecord = ImmutableRecord({
+ title: '',
+ votes_count: 0,
+
+ // Internal fields
+ title_emojified: '',
+});
+
+// Normalize emojis
+const normalizeEmojis = (entity: ImmutableMap) => {
+ return entity.update('emojis', ImmutableList(), emojis => {
+ return emojis.map(normalizeEmoji);
+ });
+};
+
+const normalizePollOption = (option: ImmutableMap, emojis: ImmutableList> = ImmutableList()) => {
+ const emojiMap = makeEmojiMap(emojis);
+ const titleEmojified = emojify(escapeTextContentForBrowser(option.get('title')), emojiMap);
+
+ return PollOptionRecord(
+ option.set('title_emojified', titleEmojified),
+ );
+};
+
+// Normalize poll options
+const normalizePollOptions = (poll: ImmutableMap) => {
+ const emojis = poll.get('emojis');
+
+ return poll.update('options', (options: ImmutableList>) => {
+ return options.map(option => normalizePollOption(option, emojis));
+ });
+};
+
+// Normalize own_votes to `null` if empty (like Mastodon)
+const normalizePollOwnVotes = (poll: ImmutableMap) => {
+ return poll.update('own_votes', ownVotes => {
+ return ownVotes?.size > 0 ? ownVotes : null;
+ });
+};
+
+// Whether the user voted in the poll
+const normalizePollVoted = (poll: ImmutableMap) => {
+ return poll.update('voted', voted => {
+ return typeof voted === 'boolean' ? voted : poll.get('own_votes')?.size > 0;
+ });
+};
+
+export const normalizePoll = (poll: ImmutableMap) => {
+ return PollRecord(
+ poll.withMutations((poll: ImmutableMap) => {
+ normalizeEmojis(poll);
+ normalizePollOptions(poll);
+ normalizePollOwnVotes(poll);
+ normalizePollVoted(poll);
+ }),
+ );
+};
diff --git a/app/soapbox/normalizers/status.ts b/app/soapbox/normalizers/status.ts
index 1abfe539db..2a1039f45a 100644
--- a/app/soapbox/normalizers/status.ts
+++ b/app/soapbox/normalizers/status.ts
@@ -1,15 +1,22 @@
-import escapeTextContentForBrowser from 'escape-html';
+/**
+ * Status normalizer:
+ * Converts API statuses into our internal format.
+ * @see {@link https://docs.joinmastodon.org/entities/status/}
+ */
import {
Map as ImmutableMap,
List as ImmutableList,
Record as ImmutableRecord,
} from 'immutable';
-import emojify from 'soapbox/features/emoji/emoji';
-import { normalizeAccount } from 'soapbox/normalizers/account';
+import { normalizeAttachment } from 'soapbox/normalizers/attachment';
+import { normalizeCard } from 'soapbox/normalizers/card';
+import { normalizeEmoji } from 'soapbox/normalizers/emoji';
+import { normalizeMention } from 'soapbox/normalizers/mention';
+import { normalizePoll } from 'soapbox/normalizers/poll';
import { IStatus } from 'soapbox/types';
-import { mergeDefined, makeEmojiMap } from 'soapbox/utils/normalizers';
+// https://docs.joinmastodon.org/entities/status/
const StatusRecord = ImmutableRecord({
account: null,
application: null,
@@ -49,91 +56,12 @@ const StatusRecord = ImmutableRecord({
spoilerHtml: '',
});
-// https://docs.joinmastodon.org/entities/attachment/
-const AttachmentRecord = ImmutableRecord({
- blurhash: undefined,
- description: '',
- id: '',
- meta: ImmutableMap(),
- pleroma: ImmutableMap(),
- preview_url: '',
- remote_url: null,
- type: 'unknown',
- url: '',
-
- // Internal fields
- account: null,
- status: null,
-});
-
-// https://docs.joinmastodon.org/entities/mention/
-const MentionRecord = ImmutableRecord({
- id: '',
- acct: '',
- username: '',
- url: '',
-});
-
-// https://docs.joinmastodon.org/entities/poll/
-const PollRecord = ImmutableRecord({
- emojis: ImmutableList(),
- expired: false,
- expires_at: new Date(),
- id: '',
- multiple: false,
- options: ImmutableList(),
- voters_count: 0,
- votes_count: 0,
- own_votes: null,
- voted: false,
-});
-
-// Sub-entity of Poll
-const PollOptionRecord = ImmutableRecord({
- title: '',
- votes_count: 0,
-
- // Internal fields
- title_emojified: '',
-});
-
-// https://docs.joinmastodon.org/entities/emoji/
-const EmojiRecord = ImmutableRecord({
- category: '',
- shortcode: '',
- static_url: '',
- url: '',
- visible_in_picker: true,
-});
-
-// Ensure attachments have required fields
-// https://docs.joinmastodon.org/entities/attachment/
-const normalizeAttachment = (attachment: ImmutableMap) => {
- const url = [
- attachment.get('url'),
- attachment.get('preview_url'),
- attachment.get('remote_url'),
- ].find(url => url) || '';
-
- const base = ImmutableMap({
- url,
- preview_url: url,
- });
-
- return AttachmentRecord(attachment.mergeWith(mergeDefined, base));
-};
-
const normalizeAttachments = (status: ImmutableMap) => {
return status.update('media_attachments', ImmutableList(), attachments => {
return attachments.map(normalizeAttachment);
});
};
-// Normalize mentions
-const normalizeMention = (mention: ImmutableMap) => {
- return MentionRecord(normalizeAccount(mention));
-};
-
const normalizeMentions = (status: ImmutableMap) => {
return status.update('mentions', ImmutableList(), mentions => {
return mentions.map(normalizeMention);
@@ -143,54 +71,10 @@ const normalizeMentions = (status: ImmutableMap) => {
// Normalize emojis
const normalizeEmojis = (entity: ImmutableMap) => {
return entity.update('emojis', ImmutableList(), emojis => {
- return emojis.map(EmojiRecord);
+ return emojis.map(normalizeEmoji);
});
};
-const normalizePollOption = (option: ImmutableMap, emojis: ImmutableList> = ImmutableList()) => {
- const emojiMap = makeEmojiMap(emojis);
- const titleEmojified = emojify(escapeTextContentForBrowser(option.get('title')), emojiMap);
-
- return PollOptionRecord(
- option.set('title_emojified', titleEmojified),
- );
-};
-
-// Normalize poll options
-const normalizePollOptions = (poll: ImmutableMap) => {
- const emojis = poll.get('emojis');
-
- return poll.update('options', (options: ImmutableList>) => {
- return options.map(option => normalizePollOption(option, emojis));
- });
-};
-
-// Normalize own_votes to `null` if empty (like Mastodon)
-const normalizePollOwnVotes = (poll: ImmutableMap) => {
- return poll.update('own_votes', ownVotes => {
- return ownVotes?.size > 0 ? ownVotes : null;
- });
-};
-
-// Whether the user voted in the poll
-const normalizePollVoted = (poll: ImmutableMap) => {
- return poll.update('voted', voted => {
- return typeof voted === 'boolean' ? voted : poll.get('own_votes')?.size > 0;
- });
-};
-
-// Normalize the actual poll
-const normalizePoll = (poll: ImmutableMap) => {
- return PollRecord(
- poll.withMutations((poll: ImmutableMap) => {
- normalizeEmojis(poll);
- normalizePollOptions(poll);
- normalizePollOwnVotes(poll);
- normalizePollVoted(poll);
- }),
- );
-};
-
// Normalize the poll in the status, if applicable
const normalizeStatusPoll = (status: ImmutableMap) => {
if (status.hasIn(['poll', 'options'])) {
@@ -200,6 +84,15 @@ const normalizeStatusPoll = (status: ImmutableMap) => {
}
};
+// Normalize card
+const normalizeStatusCard = (status: ImmutableMap) => {
+ if (status.get('card')) {
+ return status.update('card', ImmutableMap(), normalizeCard);
+ } else {
+ return status.set('card', null);
+ }
+};
+
// Fix order of mentions
const fixMentionsOrder = (status: ImmutableMap) => {
const mentions = status.get('mentions', ImmutableList());
@@ -249,6 +142,7 @@ export const normalizeStatus = (status: ImmutableMap): IStatus => {
normalizeMentions(status);
normalizeEmojis(status);
normalizeStatusPoll(status);
+ normalizeStatusCard(status);
fixMentionsOrder(status);
addSelfMention(status);
fixQuote(status);
diff --git a/app/soapbox/reducers/__tests__/accounts-test.js b/app/soapbox/reducers/__tests__/accounts-test.js
index 973ac4f1ea..4b7915d324 100644
Binary files a/app/soapbox/reducers/__tests__/accounts-test.js and b/app/soapbox/reducers/__tests__/accounts-test.js differ
diff --git a/app/soapbox/reducers/accounts.js b/app/soapbox/reducers/accounts.js
index 3e573244de..c601ed928d 100644
Binary files a/app/soapbox/reducers/accounts.js and b/app/soapbox/reducers/accounts.js differ