diff --git a/app/images/avatar-missing.png b/app/images/avatar-missing.png
new file mode 100644
index 000000000..b3e6b5709
Binary files /dev/null and b/app/images/avatar-missing.png differ
diff --git a/app/images/avatar-missing.svg b/app/images/avatar-missing.svg
new file mode 100644
index 000000000..7eb156089
--- /dev/null
+++ b/app/images/avatar-missing.svg
@@ -0,0 +1,116 @@
+
+
diff --git a/app/soapbox/__fixtures__/mitra-context.json b/app/soapbox/__fixtures__/mitra-context.json
new file mode 100644
index 000000000..91b48420c
--- /dev/null
+++ b/app/soapbox/__fixtures__/mitra-context.json
@@ -0,0 +1,107 @@
+[
+ {
+ "id": "017ed503-bc96-301a-e871-2c23b30ddd05",
+ "uri": "https://mitra.social/objects/017ed503-bc96-301a-e871-2c23b30ddd05",
+ "created_at": "2022-02-07T16:28:18.966874Z",
+ "account": {
+ "id": "017ed4f9-c121-2ae6-0805-15516cce02c3",
+ "username": "alex",
+ "acct": "alex",
+ "url": "https://mitra.social/users/alex",
+ "display_name": null,
+ "created_at": "2022-02-07T16:17:24.769229Z",
+ "note": null,
+ "avatar": null,
+ "header": null,
+ "fields": [],
+ "followers_count": 1,
+ "following_count": 1,
+ "statuses_count": 3,
+ "source": null,
+ "wallet_address": null
+ },
+ "content": "@silverpill sup!",
+ "in_reply_to_id": null,
+ "reblog": null,
+ "visibility": "public",
+ "replies_count": 1,
+ "favourites_count": 0,
+ "reblogs_count": 0,
+ "media_attachments": [],
+ "mentions": [
+ {
+ "id": "dd4ebc18-269d-4c7b-a310-03d29c6ab551",
+ "username": "silverpill",
+ "acct": "silverpill",
+ "url": "https://mitra.social/users/silverpill"
+ }
+ ],
+ "tags": [],
+ "favourited": false,
+ "reblogged": false,
+ "ipfs_cid": null,
+ "token_id": null,
+ "token_tx_id": null
+ },
+ {
+ "id": "017ed505-5926-392f-256a-f86d5075df70",
+ "uri": "https://mitra.social/objects/017ed505-5926-392f-256a-f86d5075df70",
+ "created_at": "2022-02-07T16:30:04.582771Z",
+ "account": {
+ "id": "dd4ebc18-269d-4c7b-a310-03d29c6ab551",
+ "username": "silverpill",
+ "acct": "silverpill",
+ "url": "https://mitra.social/users/silverpill",
+ "display_name": "silverpill",
+ "created_at": "2021-11-06T21:08:57.441927Z",
+ "note": "Admin of mitra.social instance. It is running experimental ActivityPub server Mitra.",
+ "avatar": "https://mitra.social/media/6a785bf7dd05f61c3590e8935aa49156a499ac30fd1e402f79e7e164adb36e2c.png",
+ "header": null,
+ "fields": [
+ {
+ "name": "Matrix",
+ "value": "@silverpill:poa.st"
+ },
+ {
+ "name": "Alt",
+ "value": "@silverpill@poa.st"
+ },
+ {
+ "name": "Code",
+ "value": "https://codeberg.org/silverpill/"
+ },
+ {
+ "name": "$XMR",
+ "value": "884y9LmsWY7PQNsyR7bJy1dvj91tuF5spVabyCnPk4KfQtSuzFbQobTFC7xSemJgVW1FWAwnJbjTZX5zZWbBrfkv62DB62d"
+ }
+ ],
+ "followers_count": 27,
+ "following_count": 15,
+ "statuses_count": 110,
+ "source": null,
+ "wallet_address": null
+ },
+ "content": "@alex welcome",
+ "in_reply_to_id": "017ed503-bc96-301a-e871-2c23b30ddd05",
+ "reblog": null,
+ "visibility": "public",
+ "replies_count": 0,
+ "favourites_count": 1,
+ "reblogs_count": 0,
+ "media_attachments": [],
+ "mentions": [
+ {
+ "id": "017ed4f9-c121-2ae6-0805-15516cce02c3",
+ "username": "alex",
+ "acct": "alex",
+ "url": "https://mitra.social/users/alex"
+ }
+ ],
+ "tags": [],
+ "favourited": true,
+ "reblogged": false,
+ "ipfs_cid": null,
+ "token_id": null,
+ "token_tx_id": null
+ }
+]
diff --git a/app/soapbox/actions/__tests__/statuses-test.js b/app/soapbox/actions/__tests__/statuses-test.js
new file mode 100644
index 000000000..71a0596a4
--- /dev/null
+++ b/app/soapbox/actions/__tests__/statuses-test.js
@@ -0,0 +1,29 @@
+import { Map as ImmutableMap } from 'immutable';
+
+import { STATUSES_IMPORT } from 'soapbox/actions/importer';
+import { __stub } from 'soapbox/api';
+import { mockStore } from 'soapbox/test_helpers';
+
+import { fetchContext } from '../statuses';
+
+describe('fetchContext()', () => {
+ it('handles Mitra context', done => {
+ const statuses = require('soapbox/__fixtures__/mitra-context.json');
+
+ __stub(mock => {
+ mock.onGet('/api/v1/statuses/017ed505-5926-392f-256a-f86d5075df70/context')
+ .reply(200, statuses);
+ });
+
+ const store = mockStore(ImmutableMap());
+
+ store.dispatch(fetchContext('017ed505-5926-392f-256a-f86d5075df70')).then(context => {
+ const actions = store.getActions();
+
+ expect(actions[3].type).toEqual(STATUSES_IMPORT);
+ expect(actions[3].statuses[0].id).toEqual('017ed503-bc96-301a-e871-2c23b30ddd05');
+
+ done();
+ }).catch(console.error);
+ });
+});
diff --git a/app/soapbox/actions/importer/normalizer.js b/app/soapbox/actions/importer/normalizer.js
index 66524b556..7c5aa8bd3 100644
--- a/app/soapbox/actions/importer/normalizer.js
+++ b/app/soapbox/actions/importer/normalizer.js
@@ -15,6 +15,13 @@ const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => {
export function normalizeAccount(account) {
account = { ...account };
+ // Some backends can return null, or omit these required fields
+ if (!account.emojis) account.emojis = [];
+ if (!account.display_name) account.display_name = '';
+ if (!account.note) account.note = '';
+ if (!account.avatar) account.avatar = account.avatar_static || require('images/avatar-missing.png');
+ if (!account.avatar_static) account.avatar_static = account.avatar;
+
const emojiMap = makeEmojiMap(account);
const displayName = account.display_name.trim().length === 0 ? account.username : account.display_name;
@@ -41,6 +48,10 @@ export function normalizeAccount(account) {
export function normalizeStatus(status, normalOldStatus, expandSpoilers) {
const normalStatus = { ...status };
+ // Some backends can return null, or omit these required fields
+ if (!normalStatus.emojis) normalStatus.emojis = [];
+ if (!normalStatus.spoiler_text) normalStatus.spoiler_text = '';
+
// Copy the pleroma object too, so we can modify our copy
if (status.pleroma) {
normalStatus.pleroma = { ...status.pleroma };
diff --git a/app/soapbox/actions/statuses.js b/app/soapbox/actions/statuses.js
index 150b6d8c7..12dfaa576 100644
--- a/app/soapbox/actions/statuses.js
+++ b/app/soapbox/actions/statuses.js
@@ -143,10 +143,18 @@ export function fetchContext(id) {
dispatch({ type: CONTEXT_FETCH_REQUEST, id });
return api(getState).get(`/api/v1/statuses/${id}/context`).then(({ data: context }) => {
- const { ancestors, descendants } = context;
- const statuses = ancestors.concat(descendants);
- dispatch(importFetchedStatuses(statuses));
- dispatch({ type: CONTEXT_FETCH_SUCCESS, id, ancestors, descendants });
+ if (Array.isArray(context)) {
+ // Mitra: returns a list of statuses
+ dispatch(importFetchedStatuses(context));
+ } else if (typeof context === 'object') {
+ // Standard Mastodon API returns a map with `ancestors` and `descendants`
+ const { ancestors, descendants } = context;
+ const statuses = ancestors.concat(descendants);
+ dispatch(importFetchedStatuses(statuses));
+ dispatch({ type: CONTEXT_FETCH_SUCCESS, id, ancestors, descendants });
+ } else {
+ throw context;
+ }
return context;
}).catch(error => {
if (error.response && error.response.status === 404) {
diff --git a/app/soapbox/components/avatar.js b/app/soapbox/components/avatar.js
index d0df7959b..1bbca72cc 100644
--- a/app/soapbox/components/avatar.js
+++ b/app/soapbox/components/avatar.js
@@ -28,22 +28,14 @@ export default class Avatar extends React.PureComponent {
height: `${size}px`,
};
- // Only render the image if src is provided
- if (account.get('avatar')) {
- return (
-