From 3e849244f9d1c77eee4614193832f9b263961ab0 Mon Sep 17 00:00:00 2001
From: Konrad Pozniak <connyduck@users.noreply.github.com>
Date: Fri, 15 Apr 2022 13:20:27 +0200
Subject: [PATCH] move Html parsing to ViewData (#2414)

* move Html parsing to ViewData

* refactor reports to use viewdata

* cleanup code

* refactor conversations

* fix getEditableText

* rename StatusParsingHelper

* fix tests

* commit db schema file

* add file header

* rename helper function to parseAsMastodonHtml

* order imports correctly

* move mapping off main thread to default dispatcher

* fix ktlint
---
 .../33.json                                   | 809 ++++++++++++++++++
 .../components/account/AccountActivity.kt     |  24 +-
 .../components/account/AccountFieldAdapter.kt |   3 +-
 .../conversation/ConversationAdapter.kt       |  12 +-
 .../conversation/ConversationEntity.kt        | 133 +--
 .../conversation/ConversationViewData.kt      |  87 ++
 .../conversation/ConversationViewHolder.java  |  20 +-
 .../conversation/ConversationsFragment.kt     |  30 +-
 .../conversation/ConversationsViewModel.kt    |  79 +-
 .../components/report/ReportViewModel.kt      |  12 +-
 .../report/adapter/StatusViewHolder.kt        |  58 +-
 .../report/adapter/StatusesAdapter.kt         |  12 +-
 .../timeline/TimelineTypeMappers.kt           |  16 +-
 .../viewmodel/CachedTimelineViewModel.kt      |  11 +-
 .../viewmodel/NetworkTimelineViewModel.kt     |   6 +-
 .../keylesspalace/tusky/db/AppDatabase.java   |  39 +-
 .../tusky/db/ConversationsDao.kt              |   5 +-
 .../com/keylesspalace/tusky/db/Converters.kt  |  21 -
 .../com/keylesspalace/tusky/di/AppModule.kt   |   1 +
 .../keylesspalace/tusky/di/NetworkModule.kt   |   9 +-
 .../com/keylesspalace/tusky/entity/Account.kt |  55 +-
 .../tusky/entity/Announcement.kt              |   3 +-
 .../com/keylesspalace/tusky/entity/Card.kt    |   9 +-
 .../com/keylesspalace/tusky/entity/Status.kt  |  74 +-
 .../tusky/json/SpannedTypeAdapter.kt          |  54 --
 .../tusky/util/StatusParsingHelper.kt         |  62 ++
 .../keylesspalace/tusky/util/ViewDataUtils.kt |   3 -
 .../tusky/viewdata/StatusViewData.kt          |  53 +-
 .../tusky/BottomSheetActivityTest.kt          |   3 +-
 .../tusky/ComposeActivityTest.kt              |   3 +-
 .../com/keylesspalace/tusky/FilterTest.kt     |   3 +-
 .../tusky/StatusComparisonTest.kt             |  14 +-
 .../NetworkTimelinePagingSourceTest.kt        |   5 +
 .../tusky/components/timeline/StatusMocker.kt |   4 +-
 34 files changed, 1232 insertions(+), 500 deletions(-)
 create mode 100644 app/schemas/com.keylesspalace.tusky.db.AppDatabase/33.json
 create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt
 delete mode 100644 app/src/main/java/com/keylesspalace/tusky/json/SpannedTypeAdapter.kt
 create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt

diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/33.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/33.json
new file mode 100644
index 00000000..e6d8ec7d
--- /dev/null
+++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/33.json
@@ -0,0 +1,809 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 33,
+    "identityHash": "920a0e0c9a600bd236f6bf959b469c18",
+    "entities": [
+      {
+        "tableName": "DraftEntity",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountId",
+            "columnName": "accountId",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "inReplyToId",
+            "columnName": "inReplyToId",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "contentWarning",
+            "columnName": "contentWarning",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "sensitive",
+            "columnName": "sensitive",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "visibility",
+            "columnName": "visibility",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "attachments",
+            "columnName": "attachments",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "poll",
+            "columnName": "poll",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "failedToSend",
+            "columnName": "failedToSend",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "AccountEntity",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "domain",
+            "columnName": "domain",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accessToken",
+            "columnName": "accessToken",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isActive",
+            "columnName": "isActive",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountId",
+            "columnName": "accountId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "username",
+            "columnName": "username",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "displayName",
+            "columnName": "displayName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "profilePictureUrl",
+            "columnName": "profilePictureUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "notificationsEnabled",
+            "columnName": "notificationsEnabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "notificationsMentioned",
+            "columnName": "notificationsMentioned",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "notificationsFollowed",
+            "columnName": "notificationsFollowed",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "notificationsFollowRequested",
+            "columnName": "notificationsFollowRequested",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "notificationsReblogged",
+            "columnName": "notificationsReblogged",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "notificationsFavorited",
+            "columnName": "notificationsFavorited",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "notificationsPolls",
+            "columnName": "notificationsPolls",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "notificationsSubscriptions",
+            "columnName": "notificationsSubscriptions",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "notificationsSignUps",
+            "columnName": "notificationsSignUps",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "notificationSound",
+            "columnName": "notificationSound",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "notificationVibration",
+            "columnName": "notificationVibration",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "notificationLight",
+            "columnName": "notificationLight",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "defaultPostPrivacy",
+            "columnName": "defaultPostPrivacy",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "defaultMediaSensitivity",
+            "columnName": "defaultMediaSensitivity",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "alwaysShowSensitiveMedia",
+            "columnName": "alwaysShowSensitiveMedia",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "alwaysOpenSpoiler",
+            "columnName": "alwaysOpenSpoiler",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "mediaPreviewEnabled",
+            "columnName": "mediaPreviewEnabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lastNotificationId",
+            "columnName": "lastNotificationId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "activeNotifications",
+            "columnName": "activeNotifications",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "emojis",
+            "columnName": "emojis",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "tabPreferences",
+            "columnName": "tabPreferences",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "notificationsFilter",
+            "columnName": "notificationsFilter",
+            "affinity": "TEXT",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "index_AccountEntity_domain_accountId",
+            "unique": true,
+            "columnNames": [
+              "domain",
+              "accountId"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)"
+          }
+        ],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "InstanceEntity",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, PRIMARY KEY(`instance`))",
+        "fields": [
+          {
+            "fieldPath": "instance",
+            "columnName": "instance",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "emojiList",
+            "columnName": "emojiList",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "maximumTootCharacters",
+            "columnName": "maximumTootCharacters",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "maxPollOptions",
+            "columnName": "maxPollOptions",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "maxPollOptionLength",
+            "columnName": "maxPollOptionLength",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "minPollDuration",
+            "columnName": "minPollDuration",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "maxPollDuration",
+            "columnName": "maxPollDuration",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "charactersReservedPerUrl",
+            "columnName": "charactersReservedPerUrl",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "version",
+            "columnName": "version",
+            "affinity": "TEXT",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "instance"
+          ],
+          "autoGenerate": false
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "TimelineStatusEntity",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
+        "fields": [
+          {
+            "fieldPath": "serverId",
+            "columnName": "serverId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "timelineUserId",
+            "columnName": "timelineUserId",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "authorServerId",
+            "columnName": "authorServerId",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "inReplyToId",
+            "columnName": "inReplyToId",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "inReplyToAccountId",
+            "columnName": "inReplyToAccountId",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "emojis",
+            "columnName": "emojis",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "reblogsCount",
+            "columnName": "reblogsCount",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "favouritesCount",
+            "columnName": "favouritesCount",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "reblogged",
+            "columnName": "reblogged",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookmarked",
+            "columnName": "bookmarked",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "favourited",
+            "columnName": "favourited",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sensitive",
+            "columnName": "sensitive",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "spoilerText",
+            "columnName": "spoilerText",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "visibility",
+            "columnName": "visibility",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "attachments",
+            "columnName": "attachments",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "mentions",
+            "columnName": "mentions",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "tags",
+            "columnName": "tags",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "application",
+            "columnName": "application",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "reblogServerId",
+            "columnName": "reblogServerId",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "reblogAccountId",
+            "columnName": "reblogAccountId",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "poll",
+            "columnName": "poll",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "muted",
+            "columnName": "muted",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "expanded",
+            "columnName": "expanded",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "contentCollapsed",
+            "columnName": "contentCollapsed",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "contentShowing",
+            "columnName": "contentShowing",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pinned",
+            "columnName": "pinned",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "serverId",
+            "timelineUserId"
+          ],
+          "autoGenerate": false
+        },
+        "indices": [
+          {
+            "name": "index_TimelineStatusEntity_authorServerId_timelineUserId",
+            "unique": false,
+            "columnNames": [
+              "authorServerId",
+              "timelineUserId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "TimelineAccountEntity",
+            "onDelete": "NO ACTION",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "authorServerId",
+              "timelineUserId"
+            ],
+            "referencedColumns": [
+              "serverId",
+              "timelineUserId"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "TimelineAccountEntity",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))",
+        "fields": [
+          {
+            "fieldPath": "serverId",
+            "columnName": "serverId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "timelineUserId",
+            "columnName": "timelineUserId",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "localUsername",
+            "columnName": "localUsername",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "username",
+            "columnName": "username",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "displayName",
+            "columnName": "displayName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "avatar",
+            "columnName": "avatar",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "emojis",
+            "columnName": "emojis",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bot",
+            "columnName": "bot",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "serverId",
+            "timelineUserId"
+          ],
+          "autoGenerate": false
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "ConversationEntity",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))",
+        "fields": [
+          {
+            "fieldPath": "accountId",
+            "columnName": "accountId",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accounts",
+            "columnName": "accounts",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "unread",
+            "columnName": "unread",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lastStatus.id",
+            "columnName": "s_id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lastStatus.url",
+            "columnName": "s_url",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "lastStatus.inReplyToId",
+            "columnName": "s_inReplyToId",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "lastStatus.inReplyToAccountId",
+            "columnName": "s_inReplyToAccountId",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "lastStatus.account",
+            "columnName": "s_account",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lastStatus.content",
+            "columnName": "s_content",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lastStatus.createdAt",
+            "columnName": "s_createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lastStatus.emojis",
+            "columnName": "s_emojis",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lastStatus.favouritesCount",
+            "columnName": "s_favouritesCount",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lastStatus.favourited",
+            "columnName": "s_favourited",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lastStatus.bookmarked",
+            "columnName": "s_bookmarked",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lastStatus.sensitive",
+            "columnName": "s_sensitive",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lastStatus.spoilerText",
+            "columnName": "s_spoilerText",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lastStatus.attachments",
+            "columnName": "s_attachments",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lastStatus.mentions",
+            "columnName": "s_mentions",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lastStatus.tags",
+            "columnName": "s_tags",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "lastStatus.showingHiddenContent",
+            "columnName": "s_showingHiddenContent",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lastStatus.expanded",
+            "columnName": "s_expanded",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lastStatus.collapsed",
+            "columnName": "s_collapsed",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lastStatus.muted",
+            "columnName": "s_muted",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lastStatus.poll",
+            "columnName": "s_poll",
+            "affinity": "TEXT",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id",
+            "accountId"
+          ],
+          "autoGenerate": false
+        },
+        "indices": [],
+        "foreignKeys": []
+      }
+    ],
+    "views": [],
+    "setupQueries": [
+      "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+      "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '920a0e0c9a600bd236f6bf959b469c18')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt
index c218e111..114a6cd0 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt
@@ -78,7 +78,7 @@ import com.keylesspalace.tusky.util.emojify
 import com.keylesspalace.tusky.util.getDomain
 import com.keylesspalace.tusky.util.hide
 import com.keylesspalace.tusky.util.loadAvatar
-import com.keylesspalace.tusky.util.openLink
+import com.keylesspalace.tusky.util.parseAsMastodonHtml
 import com.keylesspalace.tusky.util.setClickableText
 import com.keylesspalace.tusky.util.show
 import com.keylesspalace.tusky.util.viewBinding
@@ -375,12 +375,11 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
             }
         }
         viewModel.accountFieldData.observe(
-            this,
-            {
-                accountFieldAdapter.fields = it
-                accountFieldAdapter.notifyDataSetChanged()
-            }
-        )
+            this
+        ) {
+            accountFieldAdapter.fields = it
+            accountFieldAdapter.notifyDataSetChanged()
+        }
         viewModel.noteSaved.observe(this) {
             binding.saveNoteInfo.visible(it, View.INVISIBLE)
         }
@@ -395,11 +394,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
             adapter.refreshContent()
         }
         viewModel.isRefreshing.observe(
-            this,
-            { isRefreshing ->
-                binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true
-            }
-        )
+            this
+        ) { isRefreshing ->
+            binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true
+        }
         binding.swipeToRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
     }
 
@@ -410,7 +408,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
         binding.accountUsernameTextView.text = usernameFormatted
         binding.accountDisplayNameTextView.text = account.name.emojify(account.emojis, binding.accountDisplayNameTextView, animateEmojis)
 
-        val emojifiedNote = account.note.emojify(account.emojis, binding.accountNoteTextView, animateEmojis)
+        val emojifiedNote = account.note.parseAsMastodonHtml().emojify(account.emojis, binding.accountNoteTextView, animateEmojis)
         setClickableText(binding.accountNoteTextView, emojifiedNote, emptyList(), null, this)
 
         // accountFieldAdapter.fields = account.fields ?: emptyList()
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountFieldAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountFieldAdapter.kt
index 093dbcfb..d51bb145 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountFieldAdapter.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountFieldAdapter.kt
@@ -29,6 +29,7 @@ import com.keylesspalace.tusky.util.BindingHolder
 import com.keylesspalace.tusky.util.Either
 import com.keylesspalace.tusky.util.createClickableText
 import com.keylesspalace.tusky.util.emojify
+import com.keylesspalace.tusky.util.parseAsMastodonHtml
 import com.keylesspalace.tusky.util.setClickableText
 
 class AccountFieldAdapter(
@@ -65,7 +66,7 @@ class AccountFieldAdapter(
             val emojifiedName = field.name.emojify(emojis, nameTextView, animateEmojis)
             nameTextView.text = emojifiedName
 
-            val emojifiedValue = field.value.emojify(emojis, valueTextView, animateEmojis)
+            val emojifiedValue = field.value.parseAsMastodonHtml().emojify(emojis, valueTextView, animateEmojis)
             setClickableText(valueTextView, emojifiedValue, emptyList(), null, linkListener)
 
             if (field.verifiedAt != null) {
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt
index 89c1ad0f..0c946514 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt
@@ -26,7 +26,7 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions
 class ConversationAdapter(
     private val statusDisplayOptions: StatusDisplayOptions,
     private val listener: StatusActionListener
-) : PagingDataAdapter<ConversationEntity, ConversationViewHolder>(CONVERSATION_COMPARATOR) {
+) : PagingDataAdapter<ConversationViewData, ConversationViewHolder>(CONVERSATION_COMPARATOR) {
 
     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder {
         val view = LayoutInflater.from(parent.context).inflate(R.layout.item_conversation, parent, false)
@@ -37,17 +37,13 @@ class ConversationAdapter(
         holder.setupWithConversation(getItem(position))
     }
 
-    fun item(position: Int): ConversationEntity? {
-        return getItem(position)
-    }
-
     companion object {
-        val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback<ConversationEntity>() {
-            override fun areItemsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean {
+        val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback<ConversationViewData>() {
+            override fun areItemsTheSame(oldItem: ConversationViewData, newItem: ConversationViewData): Boolean {
                 return oldItem.id == newItem.id
             }
 
-            override fun areContentsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean {
+            override fun areContentsTheSame(oldItem: ConversationViewData, newItem: ConversationViewData): Boolean {
                 return oldItem == newItem
             }
         }
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt
index 88c9dbad..f585b4ea 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt
@@ -15,7 +15,6 @@
 
 package com.keylesspalace.tusky.components.conversation
 
-import android.text.Spanned
 import androidx.room.Embedded
 import androidx.room.Entity
 import androidx.room.TypeConverters
@@ -27,7 +26,7 @@ import com.keylesspalace.tusky.entity.HashTag
 import com.keylesspalace.tusky.entity.Poll
 import com.keylesspalace.tusky.entity.Status
 import com.keylesspalace.tusky.entity.TimelineAccount
-import com.keylesspalace.tusky.util.shouldTrimStatus
+import com.keylesspalace.tusky.viewdata.StatusViewData
 import java.util.Date
 
 @Entity(primaryKeys = ["id", "accountId"])
@@ -38,7 +37,16 @@ data class ConversationEntity(
     val accounts: List<ConversationAccountEntity>,
     val unread: Boolean,
     @Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity
-)
+) {
+    fun toViewData(): ConversationViewData {
+        return ConversationViewData(
+            id = id,
+            accounts = accounts,
+            unread = unread,
+            lastStatus = lastStatus.toViewData()
+        )
+    }
+}
 
 data class ConversationAccountEntity(
     val id: String,
@@ -67,7 +75,7 @@ data class ConversationStatusEntity(
     val inReplyToId: String?,
     val inReplyToAccountId: String?,
     val account: ConversationAccountEntity,
-    val content: Spanned,
+    val content: String,
     val createdAt: Date,
     val emojis: List<Emoji>,
     val favouritesCount: Int,
@@ -80,95 +88,43 @@ data class ConversationStatusEntity(
     val tags: List<HashTag>?,
     val showingHiddenContent: Boolean,
     val expanded: Boolean,
-    val collapsible: Boolean,
     val collapsed: Boolean,
     val muted: Boolean,
     val poll: Poll?
 ) {
-    /** its necessary to override this because Spanned.equals does not work as expected  */
-    override fun equals(other: Any?): Boolean {
-        if (this === other) return true
-        if (javaClass != other?.javaClass) return false
 
-        other as ConversationStatusEntity
-
-        if (id != other.id) return false
-        if (url != other.url) return false
-        if (inReplyToId != other.inReplyToId) return false
-        if (inReplyToAccountId != other.inReplyToAccountId) return false
-        if (account != other.account) return false
-        if (content.toString() != other.content.toString()) return false
-        if (createdAt != other.createdAt) return false
-        if (emojis != other.emojis) return false
-        if (favouritesCount != other.favouritesCount) return false
-        if (favourited != other.favourited) return false
-        if (sensitive != other.sensitive) return false
-        if (spoilerText != other.spoilerText) return false
-        if (attachments != other.attachments) return false
-        if (mentions != other.mentions) return false
-        if (tags != other.tags) return false
-        if (showingHiddenContent != other.showingHiddenContent) return false
-        if (expanded != other.expanded) return false
-        if (collapsible != other.collapsible) return false
-        if (collapsed != other.collapsed) return false
-        if (muted != other.muted) return false
-        if (poll != other.poll) return false
-
-        return true
-    }
-
-    override fun hashCode(): Int {
-        var result = id.hashCode()
-        result = 31 * result + (url?.hashCode() ?: 0)
-        result = 31 * result + (inReplyToId?.hashCode() ?: 0)
-        result = 31 * result + (inReplyToAccountId?.hashCode() ?: 0)
-        result = 31 * result + account.hashCode()
-        result = 31 * result + content.toString().hashCode()
-        result = 31 * result + createdAt.hashCode()
-        result = 31 * result + emojis.hashCode()
-        result = 31 * result + favouritesCount
-        result = 31 * result + favourited.hashCode()
-        result = 31 * result + sensitive.hashCode()
-        result = 31 * result + spoilerText.hashCode()
-        result = 31 * result + attachments.hashCode()
-        result = 31 * result + mentions.hashCode()
-        result = 31 * result + tags.hashCode()
-        result = 31 * result + showingHiddenContent.hashCode()
-        result = 31 * result + expanded.hashCode()
-        result = 31 * result + collapsible.hashCode()
-        result = 31 * result + collapsed.hashCode()
-        result = 31 * result + muted.hashCode()
-        result = 31 * result + poll.hashCode()
-        return result
-    }
-
-    fun toStatus(): Status {
-        return Status(
-            id = id,
-            url = url,
-            account = account.toAccount(),
-            inReplyToId = inReplyToId,
-            inReplyToAccountId = inReplyToAccountId,
-            content = content,
-            reblog = null,
-            createdAt = createdAt,
-            emojis = emojis,
-            reblogsCount = 0,
-            favouritesCount = favouritesCount,
-            reblogged = false,
-            favourited = favourited,
-            bookmarked = bookmarked,
-            sensitive = sensitive,
-            spoilerText = spoilerText,
-            visibility = Status.Visibility.DIRECT,
-            attachments = attachments,
-            mentions = mentions,
-            tags = tags,
-            application = null,
-            pinned = false,
-            muted = muted,
-            poll = poll,
-            card = null
+    fun toViewData(): StatusViewData.Concrete {
+        return StatusViewData.Concrete(
+            status = Status(
+                id = id,
+                url = url,
+                account = account.toAccount(),
+                inReplyToId = inReplyToId,
+                inReplyToAccountId = inReplyToAccountId,
+                content = content,
+                reblog = null,
+                createdAt = createdAt,
+                emojis = emojis,
+                reblogsCount = 0,
+                favouritesCount = favouritesCount,
+                reblogged = false,
+                favourited = favourited,
+                bookmarked = bookmarked,
+                sensitive = sensitive,
+                spoilerText = spoilerText,
+                visibility = Status.Visibility.DIRECT,
+                attachments = attachments,
+                mentions = mentions,
+                tags = tags,
+                application = null,
+                pinned = false,
+                muted = muted,
+                poll = poll,
+                card = null
+            ),
+            isExpanded = expanded,
+            isShowingContent = showingHiddenContent,
+            isCollapsed = collapsed
         )
     }
 }
@@ -202,7 +158,6 @@ fun Status.toEntity() =
         tags = tags,
         showingHiddenContent = false,
         expanded = false,
-        collapsible = shouldTrimStatus(content),
         collapsed = true,
         muted = muted ?: false,
         poll = poll
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt
new file mode 100644
index 00000000..470675d1
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt
@@ -0,0 +1,87 @@
+/* Copyright 2022 Tusky Contributors
+ *
+ * This file is a part of Tusky.
+ *
+ * This program is free software; you can redistribute it and/or modify it under the terms of the
+ * GNU General Public License as published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
+ * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
+ * Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with Tusky; if not,
+ * see <http://www.gnu.org/licenses>. */
+
+package com.keylesspalace.tusky.components.conversation
+
+import com.keylesspalace.tusky.entity.Poll
+import com.keylesspalace.tusky.viewdata.StatusViewData
+
+data class ConversationViewData(
+    val id: String,
+    val accounts: List<ConversationAccountEntity>,
+    val unread: Boolean,
+    val lastStatus: StatusViewData.Concrete
+) {
+    fun toEntity(
+        accountId: Long,
+        favourited: Boolean = lastStatus.status.favourited,
+        bookmarked: Boolean = lastStatus.status.bookmarked,
+        muted: Boolean = lastStatus.status.muted ?: false,
+        poll: Poll? = lastStatus.status.poll,
+        expanded: Boolean = lastStatus.isExpanded,
+        collapsed: Boolean = lastStatus.isCollapsed,
+        showingHiddenContent: Boolean = lastStatus.isShowingContent
+    ): ConversationEntity {
+        return ConversationEntity(
+            accountId = accountId,
+            id = id,
+            accounts = accounts,
+            unread = unread,
+            lastStatus = lastStatus.toConversationStatusEntity(
+                favourited = favourited,
+                bookmarked = bookmarked,
+                muted = muted,
+                poll = poll,
+                expanded = expanded,
+                collapsed = collapsed,
+                showingHiddenContent = showingHiddenContent
+            )
+        )
+    }
+}
+
+fun StatusViewData.Concrete.toConversationStatusEntity(
+    favourited: Boolean = status.favourited,
+    bookmarked: Boolean = status.bookmarked,
+    muted: Boolean = status.muted ?: false,
+    poll: Poll? = status.poll,
+    expanded: Boolean = isExpanded,
+    collapsed: Boolean = isCollapsed,
+    showingHiddenContent: Boolean = isShowingContent
+): ConversationStatusEntity {
+    return ConversationStatusEntity(
+        id = id,
+        url = status.url,
+        inReplyToId = status.inReplyToId,
+        inReplyToAccountId = status.inReplyToAccountId,
+        account = status.account.toEntity(),
+        content = status.content,
+        createdAt = status.createdAt,
+        emojis = status.emojis,
+        favouritesCount = status.favouritesCount,
+        favourited = favourited,
+        bookmarked = bookmarked,
+        sensitive = status.sensitive,
+        spoilerText = status.spoilerText,
+        attachments = status.attachments,
+        mentions = status.mentions,
+        tags = status.tags,
+        showingHiddenContent = showingHiddenContent,
+        expanded = expanded,
+        collapsed = collapsed,
+        muted = muted,
+        poll = poll
+    )
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java
index 436ba84e..ffb88a94 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java
+++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java
@@ -28,11 +28,14 @@ import androidx.recyclerview.widget.RecyclerView;
 import com.keylesspalace.tusky.R;
 import com.keylesspalace.tusky.adapter.StatusBaseViewHolder;
 import com.keylesspalace.tusky.entity.Attachment;
+import com.keylesspalace.tusky.entity.Status;
+import com.keylesspalace.tusky.entity.TimelineAccount;
 import com.keylesspalace.tusky.interfaces.StatusActionListener;
 import com.keylesspalace.tusky.util.ImageLoadingHelper;
 import com.keylesspalace.tusky.util.SmartLengthInputFilter;
 import com.keylesspalace.tusky.util.StatusDisplayOptions;
 import com.keylesspalace.tusky.viewdata.PollViewDataKt;
+import com.keylesspalace.tusky.viewdata.StatusViewData;
 
 import java.util.List;
 
@@ -69,11 +72,12 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
         return context.getResources().getDimensionPixelSize(R.dimen.status_media_preview_height);
     }
 
-    void setupWithConversation(ConversationEntity conversation) {
-        ConversationStatusEntity status = conversation.getLastStatus();
-        ConversationAccountEntity account = status.getAccount();
+    void setupWithConversation(ConversationViewData conversation) {
+        StatusViewData.Concrete statusViewData = conversation.getLastStatus();
+        Status status = statusViewData.getStatus();
+        TimelineAccount account = status.getAccount();
 
-        setupCollapsedState(status.getCollapsible(), status.getCollapsed(), status.getExpanded(), status.getSpoilerText(), listener);
+        setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), statusViewData.getSpoilerText(), listener);
 
         setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions);
         setUsername(account.getUsername());
@@ -84,7 +88,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
         List<Attachment> attachments = status.getAttachments();
         boolean sensitive = status.getSensitive();
         if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
-            setMediaPreviews(attachments, sensitive, listener, status.getShowingHiddenContent(),
+            setMediaPreviews(attachments, sensitive, listener, statusViewData.isShowingContent(),
                     statusDisplayOptions.useBlurhash());
 
             if (attachments.size() == 0) {
@@ -95,7 +99,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
                 mediaLabel.setVisibility(View.GONE);
             }
         } else {
-            setMediaLabel(attachments, sensitive, listener, status.getShowingHiddenContent());
+            setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent());
             // Hide all unused views.
             mediaPreviews[0].setVisibility(View.GONE);
             mediaPreviews[1].setVisibility(View.GONE);
@@ -104,10 +108,10 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
             hideSensitiveMediaWarning();
         }
 
-        setupButtons(listener, account.getId(), status.getContent().toString(),
+        setupButtons(listener, account.getId(), statusViewData.getContent().toString(),
                 statusDisplayOptions);
 
-        setSpoilerAndContent(status.getExpanded(), status.getContent(), status.getSpoilerText(),
+        setSpoilerAndContent(statusViewData.isExpanded(), statusViewData.getContent(), status.getSpoilerText(),
                 status.getMentions(), status.getTags(), status.getEmojis(),
                 PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener);
 
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt
index a09026c2..243c3744 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt
@@ -153,24 +153,24 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
     }
 
     override fun onFavourite(favourite: Boolean, position: Int) {
-        adapter.item(position)?.let { conversation ->
+        adapter.peek(position)?.let { conversation ->
             viewModel.favourite(favourite, conversation)
         }
     }
 
     override fun onBookmark(favourite: Boolean, position: Int) {
-        adapter.item(position)?.let { conversation ->
+        adapter.peek(position)?.let { conversation ->
             viewModel.bookmark(favourite, conversation)
         }
     }
 
     override fun onMore(view: View, position: Int) {
-        adapter.item(position)?.let { conversation ->
+        adapter.peek(position)?.let { conversation ->
 
             val popup = PopupMenu(requireContext(), view)
             popup.inflate(R.menu.conversation_more)
 
-            if (conversation.lastStatus.muted) {
+            if (conversation.lastStatus.status.muted == true) {
                 popup.menu.removeItem(R.id.status_mute_conversation)
             } else {
                 popup.menu.removeItem(R.id.status_unmute_conversation)
@@ -189,14 +189,14 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
     }
 
     override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
-        adapter.item(position)?.let { conversation ->
-            viewMedia(attachmentIndex, AttachmentViewData.list(conversation.lastStatus.toStatus()), view)
+        adapter.peek(position)?.let { conversation ->
+            viewMedia(attachmentIndex, AttachmentViewData.list(conversation.lastStatus.status), view)
         }
     }
 
     override fun onViewThread(position: Int) {
-        adapter.item(position)?.let { conversation ->
-            viewThread(conversation.lastStatus.id, conversation.lastStatus.url)
+        adapter.peek(position)?.let { conversation ->
+            viewThread(conversation.lastStatus.id, conversation.lastStatus.status.url)
         }
     }
 
@@ -205,13 +205,13 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
     }
 
     override fun onExpandedChange(expanded: Boolean, position: Int) {
-        adapter.item(position)?.let { conversation ->
+        adapter.peek(position)?.let { conversation ->
             viewModel.expandHiddenStatus(expanded, conversation)
         }
     }
 
     override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
-        adapter.item(position)?.let { conversation ->
+        adapter.peek(position)?.let { conversation ->
             viewModel.showContent(isShowing, conversation)
         }
     }
@@ -221,7 +221,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
     }
 
     override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
-        adapter.item(position)?.let { conversation ->
+        adapter.peek(position)?.let { conversation ->
             viewModel.collapseLongStatus(isCollapsed, conversation)
         }
     }
@@ -241,12 +241,12 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
     }
 
     override fun onReply(position: Int) {
-        adapter.item(position)?.let { conversation ->
-            reply(conversation.lastStatus.toStatus())
+        adapter.peek(position)?.let { conversation ->
+            reply(conversation.lastStatus.status)
         }
     }
 
-    private fun deleteConversation(conversation: ConversationEntity) {
+    private fun deleteConversation(conversation: ConversationViewData) {
         AlertDialog.Builder(requireContext())
             .setMessage(R.string.dialog_delete_conversation_warning)
             .setNegativeButton(android.R.string.cancel, null)
@@ -268,7 +268,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
     }
 
     override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
-        adapter.item(position)?.let { conversation ->
+        adapter.peek(position)?.let { conversation ->
             viewModel.voteInPoll(choices, conversation)
         }
     }
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt
index 396f8e48..9326a05c 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt
@@ -16,16 +16,18 @@
 package com.keylesspalace.tusky.components.conversation
 
 import android.util.Log
+import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
 import androidx.paging.ExperimentalPagingApi
 import androidx.paging.Pager
 import androidx.paging.PagingConfig
 import androidx.paging.cachedIn
+import androidx.paging.map
 import com.keylesspalace.tusky.db.AccountManager
 import com.keylesspalace.tusky.db.AppDatabase
 import com.keylesspalace.tusky.network.MastodonApi
 import com.keylesspalace.tusky.network.TimelineCases
-import com.keylesspalace.tusky.util.RxAwareViewModel
+import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.rx3.await
 import javax.inject.Inject
@@ -35,7 +37,7 @@ class ConversationsViewModel @Inject constructor(
     private val database: AppDatabase,
     private val accountManager: AccountManager,
     private val api: MastodonApi
-) : RxAwareViewModel() {
+) : ViewModel() {
 
     @OptIn(ExperimentalPagingApi::class)
     val conversationFlow = Pager(
@@ -44,104 +46,117 @@ class ConversationsViewModel @Inject constructor(
         pagingSourceFactory = { database.conversationDao().conversationsForAccount(accountManager.activeAccount!!.id) }
     )
         .flow
+        .map { pagingData ->
+            pagingData.map { conversation -> conversation.toViewData() }
+        }
         .cachedIn(viewModelScope)
 
-    fun favourite(favourite: Boolean, conversation: ConversationEntity) {
+    fun favourite(favourite: Boolean, conversation: ConversationViewData) {
         viewModelScope.launch {
             try {
                 timelineCases.favourite(conversation.lastStatus.id, favourite).await()
 
-                val newConversation = conversation.copy(
-                    lastStatus = conversation.lastStatus.copy(favourited = favourite)
+                val newConversation = conversation.toEntity(
+                    accountId = accountManager.activeAccount!!.id,
+                    favourited = favourite
                 )
 
-                database.conversationDao().insert(newConversation)
+                saveConversationToDb(newConversation)
             } catch (e: Exception) {
                 Log.w(TAG, "failed to favourite status", e)
             }
         }
     }
 
-    fun bookmark(bookmark: Boolean, conversation: ConversationEntity) {
+    fun bookmark(bookmark: Boolean, conversation: ConversationViewData) {
         viewModelScope.launch {
             try {
                 timelineCases.bookmark(conversation.lastStatus.id, bookmark).await()
 
-                val newConversation = conversation.copy(
-                    lastStatus = conversation.lastStatus.copy(bookmarked = bookmark)
+                val newConversation = conversation.toEntity(
+                    accountId = accountManager.activeAccount!!.id,
+                    bookmarked = bookmark
                 )
 
-                database.conversationDao().insert(newConversation)
+                saveConversationToDb(newConversation)
             } catch (e: Exception) {
                 Log.w(TAG, "failed to bookmark status", e)
             }
         }
     }
 
-    fun voteInPoll(choices: List<Int>, conversation: ConversationEntity) {
+    fun voteInPoll(choices: List<Int>, conversation: ConversationViewData) {
         viewModelScope.launch {
             try {
-                val poll = timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.poll?.id!!, choices).await()
-                val newConversation = conversation.copy(
-                    lastStatus = conversation.lastStatus.copy(poll = poll)
+                val poll = timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.status.poll?.id!!, choices).await()
+                val newConversation = conversation.toEntity(
+                    accountId = accountManager.activeAccount!!.id,
+                    poll = poll
                 )
 
-                database.conversationDao().insert(newConversation)
+                saveConversationToDb(newConversation)
             } catch (e: Exception) {
                 Log.w(TAG, "failed to vote in poll", e)
             }
         }
     }
 
-    fun expandHiddenStatus(expanded: Boolean, conversation: ConversationEntity) {
+    fun expandHiddenStatus(expanded: Boolean, conversation: ConversationViewData) {
         viewModelScope.launch {
-            val newConversation = conversation.copy(
-                lastStatus = conversation.lastStatus.copy(expanded = expanded)
+            val newConversation = conversation.toEntity(
+                accountId = accountManager.activeAccount!!.id,
+                expanded = expanded
             )
             saveConversationToDb(newConversation)
         }
     }
 
-    fun collapseLongStatus(collapsed: Boolean, conversation: ConversationEntity) {
+    fun collapseLongStatus(collapsed: Boolean, conversation: ConversationViewData) {
         viewModelScope.launch {
-            val newConversation = conversation.copy(
-                lastStatus = conversation.lastStatus.copy(collapsed = collapsed)
+            val newConversation = conversation.toEntity(
+                accountId = accountManager.activeAccount!!.id,
+                collapsed = collapsed
             )
             saveConversationToDb(newConversation)
         }
     }
 
-    fun showContent(showing: Boolean, conversation: ConversationEntity) {
+    fun showContent(showing: Boolean, conversation: ConversationViewData) {
         viewModelScope.launch {
-            val newConversation = conversation.copy(
-                lastStatus = conversation.lastStatus.copy(showingHiddenContent = showing)
+            val newConversation = conversation.toEntity(
+                accountId = accountManager.activeAccount!!.id,
+                showingHiddenContent = showing
             )
             saveConversationToDb(newConversation)
         }
     }
 
-    fun remove(conversation: ConversationEntity) {
+    fun remove(conversation: ConversationViewData) {
         viewModelScope.launch {
             try {
                 api.deleteConversation(conversationId = conversation.id)
 
-                database.conversationDao().delete(conversation)
+                database.conversationDao().delete(
+                    id = conversation.id,
+                    accountId = accountManager.activeAccount!!.id
+                )
             } catch (e: Exception) {
                 Log.w(TAG, "failed to delete conversation", e)
             }
         }
     }
 
-    fun muteConversation(conversation: ConversationEntity) {
+    fun muteConversation(conversation: ConversationViewData) {
         viewModelScope.launch {
             try {
-                val newStatus = timelineCases.muteConversation(
+                timelineCases.muteConversation(
                     conversation.lastStatus.id,
-                    !conversation.lastStatus.muted
+                    !(conversation.lastStatus.status.muted ?: false)
                 ).await()
 
-                val newConversation = conversation.copy(
-                    lastStatus = newStatus.toEntity()
+                val newConversation = conversation.toEntity(
+                    accountId = accountManager.activeAccount!!.id,
+                    muted = !(conversation.lastStatus.status.muted ?: false)
                 )
 
                 database.conversationDao().insert(newConversation)
@@ -151,7 +166,7 @@ class ConversationsViewModel @Inject constructor(
         }
     }
 
-    suspend fun saveConversationToDb(conversation: ConversationEntity) {
+    private suspend fun saveConversationToDb(conversation: ConversationEntity) {
         database.conversationDao().insert(conversation)
     }
 
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt
index f8991282..9f99da53 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt
@@ -21,6 +21,7 @@ import androidx.lifecycle.viewModelScope
 import androidx.paging.Pager
 import androidx.paging.PagingConfig
 import androidx.paging.cachedIn
+import androidx.paging.map
 import com.keylesspalace.tusky.appstore.BlockEvent
 import com.keylesspalace.tusky.appstore.EventHub
 import com.keylesspalace.tusky.appstore.MuteEvent
@@ -34,11 +35,13 @@ import com.keylesspalace.tusky.util.Loading
 import com.keylesspalace.tusky.util.Resource
 import com.keylesspalace.tusky.util.RxAwareViewModel
 import com.keylesspalace.tusky.util.Success
+import com.keylesspalace.tusky.util.toViewData
 import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
 import io.reactivex.rxjava3.schedulers.Schedulers
 import kotlinx.coroutines.channels.BufferOverflow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.launch
 import javax.inject.Inject
 
@@ -74,6 +77,11 @@ class ReportViewModel @Inject constructor(
             pagingSourceFactory = { StatusesPagingSource(accountId, mastodonApi) }
         ).flow
     }
+        .map { pagingData ->
+            /* TODO: refactor reports to use the isShowingContent / isExpanded / isCollapsed attributes from StatusViewData.Concrete
+             instead of StatusViewState */
+            pagingData.map { status -> status.toViewData(false, false, false) }
+        }
         .cachedIn(viewModelScope)
 
     private val selectedIds = HashSet<String>()
@@ -155,7 +163,7 @@ class ReportViewModel @Inject constructor(
             .observeOn(AndroidSchedulers.mainThread())
             .subscribe(
                 { relationship ->
-                    val muting = relationship?.muting == true
+                    val muting = relationship.muting
                     muteStateMutable.value = Success(muting)
                     if (muting) {
                         eventHub.dispatch(MuteEvent(accountId))
@@ -180,7 +188,7 @@ class ReportViewModel @Inject constructor(
             .observeOn(AndroidSchedulers.mainThread())
             .subscribe(
                 { relationship ->
-                    val blocking = relationship?.blocking == true
+                    val blocking = relationship.blocking
                     blockStateMutable.value = Success(blocking)
                     if (blocking) {
                         eventHub.dispatch(BlockEvent(accountId))
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt
index 1b3b0de6..9dceddec 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt
@@ -37,6 +37,7 @@ import com.keylesspalace.tusky.util.setClickableMentions
 import com.keylesspalace.tusky.util.setClickableText
 import com.keylesspalace.tusky.util.shouldTrimStatus
 import com.keylesspalace.tusky.util.show
+import com.keylesspalace.tusky.viewdata.StatusViewData
 import com.keylesspalace.tusky.viewdata.toViewData
 import java.util.Date
 
@@ -45,20 +46,21 @@ class StatusViewHolder(
     private val statusDisplayOptions: StatusDisplayOptions,
     private val viewState: StatusViewState,
     private val adapterHandler: AdapterHandler,
-    private val getStatusForPosition: (Int) -> Status?
+    private val getStatusForPosition: (Int) -> StatusViewData.Concrete?
 ) : RecyclerView.ViewHolder(binding.root) {
+
     private val mediaViewHeight = itemView.context.resources.getDimensionPixelSize(R.dimen.status_media_preview_height)
     private val statusViewHelper = StatusViewHelper(itemView)
 
     private val previewListener = object : StatusViewHelper.MediaPreviewListener {
         override fun onViewMedia(v: View?, idx: Int) {
-            status()?.let { status ->
-                adapterHandler.showMedia(v, status, idx)
+            viewdata()?.let { viewdata ->
+                adapterHandler.showMedia(v, viewdata.status, idx)
             }
         }
 
         override fun onContentHiddenChange(isShowing: Boolean) {
-            status()?.id?.let { id ->
+            viewdata()?.id?.let { id ->
                 viewState.setMediaShow(id, isShowing)
             }
         }
@@ -66,57 +68,57 @@ class StatusViewHolder(
 
     init {
         binding.statusSelection.setOnCheckedChangeListener { _, isChecked ->
-            status()?.let { status ->
-                adapterHandler.setStatusChecked(status, isChecked)
+            viewdata()?.let { viewdata ->
+                adapterHandler.setStatusChecked(viewdata.status, isChecked)
             }
         }
         binding.statusMediaPreviewContainer.clipToOutline = true
     }
 
-    fun bind(status: Status) {
-        binding.statusSelection.isChecked = adapterHandler.isStatusChecked(status.id)
+    fun bind(viewData: StatusViewData.Concrete) {
+        binding.statusSelection.isChecked = adapterHandler.isStatusChecked(viewData.id)
 
         updateTextView()
 
-        val sensitive = status.sensitive
+        val sensitive = viewData.status.sensitive
 
         statusViewHelper.setMediasPreview(
-            statusDisplayOptions, status.attachments,
-            sensitive, previewListener, viewState.isMediaShow(status.id, status.sensitive),
+            statusDisplayOptions, viewData.status.attachments,
+            sensitive, previewListener, viewState.isMediaShow(viewData.id, viewData.status.sensitive),
             mediaViewHeight
         )
 
-        statusViewHelper.setupPollReadonly(status.poll.toViewData(), status.emojis, statusDisplayOptions)
-        setCreatedAt(status.createdAt)
+        statusViewHelper.setupPollReadonly(viewData.status.poll.toViewData(), viewData.status.emojis, statusDisplayOptions)
+        setCreatedAt(viewData.status.createdAt)
     }
 
     private fun updateTextView() {
-        status()?.let { status ->
+        viewdata()?.let { viewdata ->
             setupCollapsedState(
-                shouldTrimStatus(status.content), viewState.isCollapsed(status.id, true),
-                viewState.isContentShow(status.id, status.sensitive), status.spoilerText
+                shouldTrimStatus(viewdata.content), viewState.isCollapsed(viewdata.id, true),
+                viewState.isContentShow(viewdata.id, viewdata.status.sensitive), viewdata.spoilerText
             )
 
-            if (status.spoilerText.isBlank()) {
-                setTextVisible(true, status.content, status.mentions, status.tags, status.emojis, adapterHandler)
+            if (viewdata.spoilerText.isBlank()) {
+                setTextVisible(true, viewdata.content, viewdata.status.mentions, viewdata.status.tags, viewdata.status.emojis, adapterHandler)
                 binding.statusContentWarningButton.hide()
                 binding.statusContentWarningDescription.hide()
             } else {
-                val emojiSpoiler = status.spoilerText.emojify(status.emojis, binding.statusContentWarningDescription, statusDisplayOptions.animateEmojis)
+                val emojiSpoiler = viewdata.spoilerText.emojify(viewdata.status.emojis, binding.statusContentWarningDescription, statusDisplayOptions.animateEmojis)
                 binding.statusContentWarningDescription.text = emojiSpoiler
                 binding.statusContentWarningDescription.show()
                 binding.statusContentWarningButton.show()
-                setContentWarningButtonText(viewState.isContentShow(status.id, true))
+                setContentWarningButtonText(viewState.isContentShow(viewdata.id, true))
                 binding.statusContentWarningButton.setOnClickListener {
-                    status()?.let { status ->
-                        val contentShown = viewState.isContentShow(status.id, true)
+                    viewdata()?.let { viewdata ->
+                        val contentShown = viewState.isContentShow(viewdata.id, true)
                         binding.statusContentWarningDescription.invalidate()
-                        viewState.setContentShow(status.id, !contentShown)
-                        setTextVisible(!contentShown, status.content, status.mentions, status.tags, status.emojis, adapterHandler)
+                        viewState.setContentShow(viewdata.id, !contentShown)
+                        setTextVisible(!contentShown, viewdata.content, viewdata.status.mentions, viewdata.status.tags, viewdata.status.emojis, adapterHandler)
                         setContentWarningButtonText(!contentShown)
                     }
                 }
-                setTextVisible(viewState.isContentShow(status.id, true), status.content, status.mentions, status.tags, status.emojis, adapterHandler)
+                setTextVisible(viewState.isContentShow(viewdata.id, true), viewdata.content, viewdata.status.mentions, viewdata.status.tags, viewdata.status.emojis, adapterHandler)
             }
         }
     }
@@ -169,8 +171,8 @@ class StatusViewHolder(
         /* input filter for TextViews have to be set before text */
         if (collapsible && (expanded || TextUtils.isEmpty(spoilerText))) {
             binding.buttonToggleContent.setOnClickListener {
-                status()?.let { status ->
-                    viewState.setCollapsed(status.id, !collapsed)
+                viewdata()?.let { viewdata ->
+                    viewState.setCollapsed(viewdata.id, !collapsed)
                     updateTextView()
                 }
             }
@@ -189,5 +191,5 @@ class StatusViewHolder(
         }
     }
 
-    private fun status() = getStatusForPosition(bindingAdapterPosition)
+    private fun viewdata() = getStatusForPosition(bindingAdapterPosition)
 }
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt
index 76ed2ebe..314513eb 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt
@@ -22,16 +22,16 @@ import androidx.recyclerview.widget.DiffUtil
 import androidx.recyclerview.widget.RecyclerView
 import com.keylesspalace.tusky.components.report.model.StatusViewState
 import com.keylesspalace.tusky.databinding.ItemReportStatusBinding
-import com.keylesspalace.tusky.entity.Status
 import com.keylesspalace.tusky.util.StatusDisplayOptions
+import com.keylesspalace.tusky.viewdata.StatusViewData
 
 class StatusesAdapter(
     private val statusDisplayOptions: StatusDisplayOptions,
     private val statusViewState: StatusViewState,
     private val adapterHandler: AdapterHandler
-) : PagingDataAdapter<Status, StatusViewHolder>(STATUS_COMPARATOR) {
+) : PagingDataAdapter<StatusViewData.Concrete, StatusViewHolder>(STATUS_COMPARATOR) {
 
-    private val statusForPosition: (Int) -> Status? = { position: Int ->
+    private val statusForPosition: (Int) -> StatusViewData.Concrete? = { position: Int ->
         if (position != RecyclerView.NO_POSITION) getItem(position) else null
     }
 
@@ -50,11 +50,11 @@ class StatusesAdapter(
     }
 
     companion object {
-        val STATUS_COMPARATOR = object : DiffUtil.ItemCallback<Status>() {
-            override fun areContentsTheSame(oldItem: Status, newItem: Status): Boolean =
+        val STATUS_COMPARATOR = object : DiffUtil.ItemCallback<StatusViewData.Concrete>() {
+            override fun areContentsTheSame(oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete): Boolean =
                 oldItem == newItem
 
-            override fun areItemsTheSame(oldItem: Status, newItem: Status): Boolean =
+            override fun areItemsTheSame(oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete): Boolean =
                 oldItem.id == newItem.id
         }
     }
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt
index 252b9880..6ec95423 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt
@@ -15,9 +15,6 @@
 
 package com.keylesspalace.tusky.components.timeline
 
-import android.text.SpannedString
-import androidx.core.text.parseAsHtml
-import androidx.core.text.toHtml
 import com.google.gson.Gson
 import com.google.gson.reflect.TypeToken
 import com.keylesspalace.tusky.db.TimelineAccountEntity
@@ -29,8 +26,6 @@ import com.keylesspalace.tusky.entity.HashTag
 import com.keylesspalace.tusky.entity.Poll
 import com.keylesspalace.tusky.entity.Status
 import com.keylesspalace.tusky.entity.TimelineAccount
-import com.keylesspalace.tusky.util.shouldTrimStatus
-import com.keylesspalace.tusky.util.trimTrailingWhitespace
 import com.keylesspalace.tusky.viewdata.StatusViewData
 import java.util.Date
 
@@ -119,7 +114,7 @@ fun Status.toEntity(
         authorServerId = actionableStatus.account.id,
         inReplyToId = actionableStatus.inReplyToId,
         inReplyToAccountId = actionableStatus.inReplyToAccountId,
-        content = actionableStatus.content.toHtml(),
+        content = actionableStatus.content,
         createdAt = actionableStatus.createdAt.time,
         emojis = actionableStatus.emojis.let(gson::toJson),
         reblogsCount = actionableStatus.reblogsCount,
@@ -165,8 +160,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
             inReplyToId = status.inReplyToId,
             inReplyToAccountId = status.inReplyToAccountId,
             reblog = null,
-            content = status.content?.parseAsHtml()?.trimTrailingWhitespace()
-                ?: SpannedString(""),
+            content = status.content.orEmpty(),
             createdAt = Date(status.createdAt),
             emojis = emojis,
             reblogsCount = status.reblogsCount,
@@ -195,7 +189,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
             inReplyToId = null,
             inReplyToAccountId = null,
             reblog = reblog,
-            content = SpannedString(""),
+            content = "",
             createdAt = Date(status.createdAt), // lie but whatever?
             emojis = listOf(),
             reblogsCount = 0,
@@ -223,8 +217,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
             inReplyToId = status.inReplyToId,
             inReplyToAccountId = status.inReplyToAccountId,
             reblog = null,
-            content = status.content?.parseAsHtml()?.trimTrailingWhitespace()
-                ?: SpannedString(""),
+            content = status.content.orEmpty(),
             createdAt = Date(status.createdAt),
             emojis = emojis,
             reblogsCount = status.reblogsCount,
@@ -249,7 +242,6 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
         status = status,
         isExpanded = this.status.expanded,
         isShowingContent = this.status.contentShowing,
-        isCollapsible = shouldTrimStatus(status.content),
         isCollapsed = this.status.contentCollapsed
     )
 }
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt
index 304b4e5a..7158a7b3 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt
@@ -42,7 +42,10 @@ import com.keylesspalace.tusky.network.FilterModel
 import com.keylesspalace.tusky.network.MastodonApi
 import com.keylesspalace.tusky.network.TimelineCases
 import com.keylesspalace.tusky.viewdata.StatusViewData
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.asExecutor
 import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.flowOn
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.rx3.await
@@ -79,15 +82,13 @@ class CachedTimelineViewModel @Inject constructor(
         }
     ).flow
         .map { pagingData ->
-            pagingData.map { timelineStatus ->
+            pagingData.map(Dispatchers.Default.asExecutor()) { timelineStatus ->
                 timelineStatus.toViewData(gson)
-            }
-        }
-        .map { pagingData ->
-            pagingData.filter { statusViewData ->
+            }.filter(Dispatchers.Default.asExecutor()) { statusViewData ->
                 !shouldFilterStatus(statusViewData)
             }
         }
+        .flowOn(Dispatchers.Default)
         .cachedIn(viewModelScope)
 
     init {
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt
index f70fdcc8..ca7988bb 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt
@@ -40,6 +40,9 @@ import com.keylesspalace.tusky.util.isLessThan
 import com.keylesspalace.tusky.util.isLessThanOrEqual
 import com.keylesspalace.tusky.util.toViewData
 import com.keylesspalace.tusky.viewdata.StatusViewData
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.asExecutor
+import kotlinx.coroutines.flow.flowOn
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.rx3.await
@@ -79,10 +82,11 @@ class NetworkTimelineViewModel @Inject constructor(
         remoteMediator = NetworkTimelineRemoteMediator(accountManager, this)
     ).flow
         .map { pagingData ->
-            pagingData.filter { statusViewData ->
+            pagingData.filter(Dispatchers.Default.asExecutor()) { statusViewData ->
                 !shouldFilterStatus(statusViewData)
             }
         }
+        .flowOn(Dispatchers.Default)
         .cachedIn(viewModelScope)
 
     override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) {
diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java
index 2131300c..c541958a 100644
--- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java
+++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java
@@ -31,7 +31,7 @@ import java.io.File;
  */
 @Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
                 TimelineAccountEntity.class,  ConversationEntity.class
-        }, version = 32)
+        }, version = 33)
 public abstract class AppDatabase extends RoomDatabase {
 
     public abstract AccountDao accountDao();
@@ -490,4 +490,41 @@ public abstract class AppDatabase extends RoomDatabase {
             database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsSignUps` INTEGER NOT NULL DEFAULT 1");
         }
     };
+
+    public static final Migration MIGRATION_32_33 = new Migration(32, 33) {
+        @Override
+        public void migrate(@NonNull SupportSQLiteDatabase database) {
+
+            // ConversationEntity lost the s_collapsible column
+            // since SQLite does not support removing columns and it is just a cache table, we recreate the whole table.
+            database.execSQL("DROP TABLE `ConversationEntity`");
+            database.execSQL("CREATE TABLE IF NOT EXISTS `ConversationEntity` (" +
+                    "`accountId` INTEGER NOT NULL," +
+                    "`id` TEXT NOT NULL," +
+                    "`accounts` TEXT NOT NULL," +
+                    "`unread` INTEGER NOT NULL," +
+                    "`s_id` TEXT NOT NULL," +
+                    "`s_url` TEXT," +
+                    "`s_inReplyToId` TEXT," +
+                    "`s_inReplyToAccountId` TEXT," +
+                    "`s_account` TEXT NOT NULL," +
+                    "`s_content` TEXT NOT NULL," +
+                    "`s_createdAt` INTEGER NOT NULL," +
+                    "`s_emojis` TEXT NOT NULL," +
+                    "`s_favouritesCount` INTEGER NOT NULL," +
+                    "`s_favourited` INTEGER NOT NULL," +
+                    "`s_bookmarked` INTEGER NOT NULL," +
+                    "`s_sensitive` INTEGER NOT NULL," +
+                    "`s_spoilerText` TEXT NOT NULL," +
+                    "`s_attachments` TEXT NOT NULL," +
+                    "`s_mentions` TEXT NOT NULL," +
+                    "`s_tags` TEXT," +
+                    "`s_showingHiddenContent` INTEGER NOT NULL," +
+                    "`s_expanded` INTEGER NOT NULL," +
+                    "`s_collapsed` INTEGER NOT NULL," +
+                    "`s_muted` INTEGER NOT NULL," +
+                    "`s_poll` TEXT," +
+                    "PRIMARY KEY(`id`, `accountId`))");
+        }
+    };
 }
diff --git a/app/src/main/java/com/keylesspalace/tusky/db/ConversationsDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/ConversationsDao.kt
index 393a2392..fe093bd0 100644
--- a/app/src/main/java/com/keylesspalace/tusky/db/ConversationsDao.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/db/ConversationsDao.kt
@@ -17,7 +17,6 @@ package com.keylesspalace.tusky.db
 
 import androidx.paging.PagingSource
 import androidx.room.Dao
-import androidx.room.Delete
 import androidx.room.Insert
 import androidx.room.OnConflictStrategy
 import androidx.room.Query
@@ -31,8 +30,8 @@ interface ConversationsDao {
     @Insert(onConflict = OnConflictStrategy.REPLACE)
     suspend fun insert(conversation: ConversationEntity): Long
 
-    @Delete
-    suspend fun delete(conversation: ConversationEntity): Int
+    @Query("DELETE FROM ConversationEntity WHERE id = :id AND accountId = :accountId")
+    suspend fun delete(id: String, accountId: Long): Int
 
     @Query("SELECT * FROM ConversationEntity WHERE accountId = :accountId ORDER BY s_createdAt DESC")
     fun conversationsForAccount(accountId: Long): PagingSource<Int, ConversationEntity>
diff --git a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt
index c9daec0a..34ff6474 100644
--- a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt
@@ -15,9 +15,6 @@
 
 package com.keylesspalace.tusky.db
 
-import android.text.Spanned
-import androidx.core.text.parseAsHtml
-import androidx.core.text.toHtml
 import androidx.room.ProvidedTypeConverter
 import androidx.room.TypeConverter
 import com.google.gson.Gson
@@ -31,10 +28,8 @@ import com.keylesspalace.tusky.entity.HashTag
 import com.keylesspalace.tusky.entity.NewPoll
 import com.keylesspalace.tusky.entity.Poll
 import com.keylesspalace.tusky.entity.Status
-import com.keylesspalace.tusky.util.trimTrailingWhitespace
 import java.net.URLDecoder
 import java.net.URLEncoder
-import java.util.ArrayList
 import java.util.Date
 import javax.inject.Inject
 import javax.inject.Singleton
@@ -140,22 +135,6 @@ class Converters @Inject constructor (
         return Date(date)
     }
 
-    @TypeConverter
-    fun spannedToString(spanned: Spanned?): String? {
-        if (spanned == null) {
-            return null
-        }
-        return spanned.toHtml()
-    }
-
-    @TypeConverter
-    fun stringToSpanned(spannedString: String?): Spanned? {
-        if (spannedString == null) {
-            return null
-        }
-        return spannedString.parseAsHtml().trimTrailingWhitespace()
-    }
-
     @TypeConverter
     fun pollToJson(poll: Poll?): String? {
         return gson.toJson(poll)
diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt
index 677f8167..7f0fbd01 100644
--- a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt
@@ -63,6 +63,7 @@ class AppModule {
                 AppDatabase.Migration25_26(appContext.getExternalFilesDir("Tusky")),
                 AppDatabase.MIGRATION_26_27, AppDatabase.MIGRATION_27_28, AppDatabase.MIGRATION_28_29,
                 AppDatabase.MIGRATION_29_30, AppDatabase.MIGRATION_30_31, AppDatabase.MIGRATION_31_32,
+                AppDatabase.MIGRATION_32_33
             )
             .build()
     }
diff --git a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt
index d927c299..d8b52ca3 100644
--- a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt
@@ -18,13 +18,10 @@ package com.keylesspalace.tusky.di
 import android.content.Context
 import android.content.SharedPreferences
 import android.os.Build
-import android.text.Spanned
 import at.connyduck.calladapter.kotlinresult.KotlinResultCallAdapterFactory
 import com.google.gson.Gson
-import com.google.gson.GsonBuilder
 import com.keylesspalace.tusky.BuildConfig
 import com.keylesspalace.tusky.db.AccountManager
-import com.keylesspalace.tusky.json.SpannedTypeAdapter
 import com.keylesspalace.tusky.network.InstanceSwitchAuthInterceptor
 import com.keylesspalace.tusky.network.MastodonApi
 import com.keylesspalace.tusky.util.getNonNullString
@@ -52,11 +49,7 @@ class NetworkModule {
 
     @Provides
     @Singleton
-    fun providesGson(): Gson {
-        return GsonBuilder()
-            .registerTypeAdapter(Spanned::class.java, SpannedTypeAdapter())
-            .create()
-    }
+    fun providesGson() = Gson()
 
     @Provides
     @Singleton
diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt
index 672bd5aa..bf5431ee 100644
--- a/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt
@@ -15,7 +15,6 @@
 
 package com.keylesspalace.tusky.entity
 
-import android.text.Spanned
 import com.google.gson.annotations.SerializedName
 import java.util.Date
 
@@ -24,7 +23,7 @@ data class Account(
     @SerializedName("username") val localUsername: String,
     @SerializedName("acct") val username: String,
     @SerializedName("display_name") val displayName: String?, // should never be null per Api definition, but some servers break the contract
-    val note: Spanned,
+    val note: String,
     val url: String,
     val avatar: String,
     val header: String,
@@ -46,56 +45,6 @@ data class Account(
         } else displayName
 
     fun isRemote(): Boolean = this.username != this.localUsername
-
-    /**
-     * overriding equals & hashcode because Spanned does not always compare correctly otherwise
-     */
-    override fun equals(other: Any?): Boolean {
-        if (this === other) return true
-        if (javaClass != other?.javaClass) return false
-        other as Account
-
-        if (id != other.id) return false
-        if (localUsername != other.localUsername) return false
-        if (username != other.username) return false
-        if (displayName != other.displayName) return false
-        if (note.toString() != other.note.toString()) return false
-        if (url != other.url) return false
-        if (avatar != other.avatar) return false
-        if (header != other.header) return false
-        if (locked != other.locked) return false
-        if (followersCount != other.followersCount) return false
-        if (followingCount != other.followingCount) return false
-        if (statusesCount != other.statusesCount) return false
-        if (source != other.source) return false
-        if (bot != other.bot) return false
-        if (emojis != other.emojis) return false
-        if (fields != other.fields) return false
-        if (moved != other.moved) return false
-
-        return true
-    }
-
-    override fun hashCode(): Int {
-        var result = id.hashCode()
-        result = 31 * result + localUsername.hashCode()
-        result = 31 * result + username.hashCode()
-        result = 31 * result + (displayName?.hashCode() ?: 0)
-        result = 31 * result + note.toString().hashCode()
-        result = 31 * result + url.hashCode()
-        result = 31 * result + avatar.hashCode()
-        result = 31 * result + header.hashCode()
-        result = 31 * result + locked.hashCode()
-        result = 31 * result + followersCount
-        result = 31 * result + followingCount
-        result = 31 * result + statusesCount
-        result = 31 * result + (source?.hashCode() ?: 0)
-        result = 31 * result + bot.hashCode()
-        result = 31 * result + (emojis?.hashCode() ?: 0)
-        result = 31 * result + (fields?.hashCode() ?: 0)
-        result = 31 * result + (moved?.hashCode() ?: 0)
-        return result
-    }
 }
 
 data class AccountSource(
@@ -107,7 +56,7 @@ data class AccountSource(
 
 data class Field(
     val name: String,
-    val value: Spanned,
+    val value: String,
     @SerializedName("verified_at") val verifiedAt: Date?
 )
 
diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt
index 400e9764..00d5659d 100644
--- a/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt
@@ -15,13 +15,12 @@
 
 package com.keylesspalace.tusky.entity
 
-import android.text.Spanned
 import com.google.gson.annotations.SerializedName
 import java.util.Date
 
 data class Announcement(
     val id: String,
-    val content: Spanned,
+    val content: String,
     @SerializedName("starts_at") val startsAt: Date?,
     @SerializedName("ends_at") val endsAt: Date?,
     @SerializedName("all_day") val allDay: Boolean,
diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt
index 52011f3d..29fe7f8e 100644
--- a/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt
@@ -15,13 +15,12 @@
 
 package com.keylesspalace.tusky.entity
 
-import android.text.Spanned
 import com.google.gson.annotations.SerializedName
 
 data class Card(
     val url: String,
-    val title: Spanned,
-    val description: Spanned,
+    val title: String,
+    val description: String,
     @SerializedName("author_name") val authorName: String,
     val image: String,
     val type: String,
@@ -31,9 +30,7 @@ data class Card(
     val embed_url: String?
 ) {
 
-    override fun hashCode(): Int {
-        return url.hashCode()
-    }
+    override fun hashCode() = url.hashCode()
 
     override fun equals(other: Any?): Boolean {
         if (other !is Card) {
diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt
index f75ce4e7..19cb7aa6 100644
--- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt
@@ -16,9 +16,9 @@
 package com.keylesspalace.tusky.entity
 
 import android.text.SpannableStringBuilder
-import android.text.Spanned
 import android.text.style.URLSpan
 import com.google.gson.annotations.SerializedName
+import com.keylesspalace.tusky.util.parseAsMastodonHtml
 import java.util.ArrayList
 import java.util.Date
 
@@ -29,7 +29,7 @@ data class Status(
     @SerializedName("in_reply_to_id") var inReplyToId: String?,
     @SerializedName("in_reply_to_account_id") val inReplyToAccountId: String?,
     val reblog: Status?,
-    val content: Spanned,
+    val content: String,
     @SerializedName("created_at") val createdAt: Date,
     val emojis: List<Emoji>,
     @SerializedName("reblogs_count") val reblogsCount: Int,
@@ -134,8 +134,9 @@ data class Status(
     }
 
     private fun getEditableText(): String {
-        val builder = SpannableStringBuilder(content)
-        for (span in content.getSpans(0, content.length, URLSpan::class.java)) {
+        val contentSpanned = content.parseAsMastodonHtml()
+        val builder = SpannableStringBuilder(content.parseAsMastodonHtml())
+        for (span in contentSpanned.getSpans(0, content.length, URLSpan::class.java)) {
             val url = span.url
             for ((_, url1, username) in mentions) {
                 if (url == url1) {
@@ -149,71 +150,6 @@ data class Status(
         return builder.toString()
     }
 
-    /**
-     * overriding equals & hashcode because Spanned does not always compare correctly otherwise
-     */
-    override fun equals(other: Any?): Boolean {
-        if (this === other) return true
-        if (javaClass != other?.javaClass) return false
-        other as Status
-
-        if (id != other.id) return false
-        if (url != other.url) return false
-        if (account != other.account) return false
-        if (inReplyToId != other.inReplyToId) return false
-        if (inReplyToAccountId != other.inReplyToAccountId) return false
-        if (reblog != other.reblog) return false
-        if (content.toString() != other.content.toString()) return false
-        if (createdAt != other.createdAt) return false
-        if (emojis != other.emojis) return false
-        if (reblogsCount != other.reblogsCount) return false
-        if (favouritesCount != other.favouritesCount) return false
-        if (reblogged != other.reblogged) return false
-        if (favourited != other.favourited) return false
-        if (bookmarked != other.bookmarked) return false
-        if (sensitive != other.sensitive) return false
-        if (spoilerText != other.spoilerText) return false
-        if (visibility != other.visibility) return false
-        if (attachments != other.attachments) return false
-        if (mentions != other.mentions) return false
-        if (tags != other.tags) return false
-        if (application != other.application) return false
-        if (pinned != other.pinned) return false
-        if (muted != other.muted) return false
-        if (poll != other.poll) return false
-        if (card != other.card) return false
-        return true
-    }
-
-    override fun hashCode(): Int {
-        var result = id.hashCode()
-        result = 31 * result + (url?.hashCode() ?: 0)
-        result = 31 * result + account.hashCode()
-        result = 31 * result + (inReplyToId?.hashCode() ?: 0)
-        result = 31 * result + (inReplyToAccountId?.hashCode() ?: 0)
-        result = 31 * result + (reblog?.hashCode() ?: 0)
-        result = 31 * result + content.toString().hashCode()
-        result = 31 * result + createdAt.hashCode()
-        result = 31 * result + emojis.hashCode()
-        result = 31 * result + reblogsCount
-        result = 31 * result + favouritesCount
-        result = 31 * result + reblogged.hashCode()
-        result = 31 * result + favourited.hashCode()
-        result = 31 * result + bookmarked.hashCode()
-        result = 31 * result + sensitive.hashCode()
-        result = 31 * result + spoilerText.hashCode()
-        result = 31 * result + visibility.hashCode()
-        result = 31 * result + attachments.hashCode()
-        result = 31 * result + mentions.hashCode()
-        result = 31 * result + (tags?.hashCode() ?: 0)
-        result = 31 * result + (application?.hashCode() ?: 0)
-        result = 31 * result + (pinned?.hashCode() ?: 0)
-        result = 31 * result + (muted?.hashCode() ?: 0)
-        result = 31 * result + (poll?.hashCode() ?: 0)
-        result = 31 * result + (card?.hashCode() ?: 0)
-        return result
-    }
-
     data class Mention(
         val id: String,
         val url: String,
diff --git a/app/src/main/java/com/keylesspalace/tusky/json/SpannedTypeAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/json/SpannedTypeAdapter.kt
deleted file mode 100644
index 60af6134..00000000
--- a/app/src/main/java/com/keylesspalace/tusky/json/SpannedTypeAdapter.kt
+++ /dev/null
@@ -1,54 +0,0 @@
-/* Copyright 2020 Tusky Contributors
- *
- * This file is a part of Tusky.
- *
- * This program is free software; you can redistribute it and/or modify it under the terms of the
- * GNU General Public License as published by the Free Software Foundation; either version 3 of the
- * License, or (at your option) any later version.
- *
- * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
- * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
- * Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with Tusky; if not,
- * see <http://www.gnu.org/licenses>. */
-
-package com.keylesspalace.tusky.json
-
-import android.text.Spanned
-import android.text.SpannedString
-import androidx.core.text.HtmlCompat
-import androidx.core.text.parseAsHtml
-import androidx.core.text.toHtml
-import com.google.gson.JsonDeserializationContext
-import com.google.gson.JsonDeserializer
-import com.google.gson.JsonElement
-import com.google.gson.JsonParseException
-import com.google.gson.JsonPrimitive
-import com.google.gson.JsonSerializationContext
-import com.google.gson.JsonSerializer
-import com.keylesspalace.tusky.util.trimTrailingWhitespace
-import java.lang.reflect.Type
-
-class SpannedTypeAdapter : JsonDeserializer<Spanned>, JsonSerializer<Spanned?> {
-    @Throws(JsonParseException::class)
-    override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Spanned {
-        return json.asString
-            /* Mastodon uses 'white-space: pre-wrap;' so spaces are displayed as returned by the Api.
-             * We can't use CSS so we replace spaces with non-breaking-spaces to emulate the behavior.
-             */
-            ?.replace("<br> ", "<br>&nbsp;")
-            ?.replace("<br /> ", "<br />&nbsp;")
-            ?.replace("<br/> ", "<br/>&nbsp;")
-            ?.replace("  ", "&nbsp;&nbsp;")
-            ?.parseAsHtml()
-            /* Html.fromHtml returns trailing whitespace if the html ends in a </p> tag, which
-             * most status contents do, so it should be trimmed. */
-            ?.trimTrailingWhitespace()
-            ?: SpannedString("")
-    }
-
-    override fun serialize(src: Spanned?, typeOfSrc: Type, context: JsonSerializationContext): JsonElement {
-        return JsonPrimitive(src!!.toHtml(HtmlCompat.TO_HTML_PARAGRAPH_LINES_INDIVIDUAL))
-    }
-}
diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt
new file mode 100644
index 00000000..fc62c78d
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt
@@ -0,0 +1,62 @@
+/* Copyright 2022 Tusky Contributors
+ *
+ * This file is a part of Tusky.
+ *
+ * This program is free software; you can redistribute it and/or modify it under the terms of the
+ * GNU General Public License as published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
+ * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
+ * Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with Tusky; if not,
+ * see <http://www.gnu.org/licenses>. */
+
+package com.keylesspalace.tusky.util
+
+import android.text.SpannableStringBuilder
+import android.text.Spanned
+import androidx.core.text.parseAsHtml
+
+/**
+ * parse a String containing html from the Mastodon api to Spanned
+ */
+fun String.parseAsMastodonHtml(): Spanned {
+    return this.replace("<br> ", "<br>&nbsp;")
+        .replace("<br /> ", "<br />&nbsp;")
+        .replace("<br/> ", "<br/>&nbsp;")
+        .replace("  ", "&nbsp;&nbsp;")
+        .parseAsHtml()
+        /* Html.fromHtml returns trailing whitespace if the html ends in a </p> tag, which
+         * most status contents do, so it should be trimmed. */
+        .trimTrailingWhitespace()
+}
+
+fun replaceCrashingCharacters(content: Spanned): Spanned {
+    return replaceCrashingCharacters(content as CharSequence) as Spanned
+}
+
+fun replaceCrashingCharacters(content: CharSequence): CharSequence? {
+    var replacing = false
+    var builder: SpannableStringBuilder? = null
+    val length = content.length
+    for (index in 0 until length) {
+        val character = content[index]
+
+        // If there are more than one or two, switch to a map
+        if (character == SOFT_HYPHEN) {
+            if (!replacing) {
+                replacing = true
+                builder = SpannableStringBuilder(content, 0, index)
+            }
+            builder!!.append(ASCII_HYPHEN)
+        } else if (replacing) {
+            builder!!.append(character)
+        }
+    }
+    return if (replacing) builder else content
+}
+
+private const val SOFT_HYPHEN = '\u00ad'
+private const val ASCII_HYPHEN = '-'
diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt
index 52d9713f..fef9c0bb 100644
--- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt
@@ -27,12 +27,9 @@ fun Status.toViewData(
     isExpanded: Boolean,
     isCollapsed: Boolean
 ): StatusViewData.Concrete {
-    val visibleStatus = this.reblog ?: this
-
     return StatusViewData.Concrete(
         status = this,
         isShowingContent = isShowingContent,
-        isCollapsible = shouldTrimStatus(visibleStatus.content),
         isCollapsed = isCollapsed,
         isExpanded = isExpanded,
     )
diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt
index d8f27157..8ac212d9 100644
--- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt
@@ -15,9 +15,11 @@
 package com.keylesspalace.tusky.viewdata
 
 import android.os.Build
-import android.text.SpannableStringBuilder
 import android.text.Spanned
 import com.keylesspalace.tusky.entity.Status
+import com.keylesspalace.tusky.util.parseAsMastodonHtml
+import com.keylesspalace.tusky.util.replaceCrashingCharacters
+import com.keylesspalace.tusky.util.shouldTrimStatus
 
 /**
  * Created by charlag on 11/07/2017.
@@ -32,13 +34,6 @@ sealed class StatusViewData {
         val status: Status,
         val isExpanded: Boolean,
         val isShowingContent: Boolean,
-        /**
-         * Specifies whether the content of this post is allowed to be collapsed or if it should show
-         * all content regardless.
-         *
-         * @return Whether the post is collapsible or never collapsed.
-         */
-        val isCollapsible: Boolean,
         /**
          * Specifies whether the content of this post is currently limited in visibility to the first
          * 500 characters or not.
@@ -51,6 +46,14 @@ sealed class StatusViewData {
         override val id: String
             get() = status.id
 
+        /**
+         * Specifies whether the content of this post is allowed to be collapsed or if it should show
+         * all content regardless.
+         *
+         * @return Whether the post is collapsible or never collapsed.
+         */
+        val isCollapsible: Boolean
+
         val content: Spanned
         val spoilerText: String
         val username: String
@@ -74,45 +77,17 @@ sealed class StatusViewData {
         init {
             if (Build.VERSION.SDK_INT == 23) {
                 // https://github.com/tuskyapp/Tusky/issues/563
-                this.content = replaceCrashingCharacters(status.actionableStatus.content)
+                this.content = replaceCrashingCharacters(status.actionableStatus.content.parseAsMastodonHtml())
                 this.spoilerText =
                     replaceCrashingCharacters(status.actionableStatus.spoilerText).toString()
                 this.username =
                     replaceCrashingCharacters(status.actionableStatus.account.username).toString()
             } else {
-                this.content = status.actionableStatus.content
+                this.content = status.actionableStatus.content.parseAsMastodonHtml()
                 this.spoilerText = status.actionableStatus.spoilerText
                 this.username = status.actionableStatus.account.username
             }
-        }
-
-        companion object {
-            private const val SOFT_HYPHEN = '\u00ad'
-            private const val ASCII_HYPHEN = '-'
-            fun replaceCrashingCharacters(content: Spanned): Spanned {
-                return replaceCrashingCharacters(content as CharSequence) as Spanned
-            }
-
-            fun replaceCrashingCharacters(content: CharSequence): CharSequence? {
-                var replacing = false
-                var builder: SpannableStringBuilder? = null
-                val length = content.length
-                for (index in 0 until length) {
-                    val character = content[index]
-
-                    // If there are more than one or two, switch to a map
-                    if (character == SOFT_HYPHEN) {
-                        if (!replacing) {
-                            replacing = true
-                            builder = SpannableStringBuilder(content, 0, index)
-                        }
-                        builder!!.append(ASCII_HYPHEN)
-                    } else if (replacing) {
-                        builder!!.append(character)
-                    }
-                }
-                return if (replacing) builder else content
-            }
+            this.isCollapsible = shouldTrimStatus(this.content)
         }
 
         /** Helper for Java */
diff --git a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt
index ff208823..beb6af9b 100644
--- a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt
+++ b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt
@@ -15,7 +15,6 @@
 
 package com.keylesspalace.tusky
 
-import android.text.SpannedString
 import androidx.arch.core.executor.testing.InstantTaskExecutorRule
 import com.keylesspalace.tusky.entity.SearchResult
 import com.keylesspalace.tusky.entity.Status
@@ -70,7 +69,7 @@ class BottomSheetActivityTest {
         inReplyToId = null,
         inReplyToAccountId = null,
         reblog = null,
-        content = SpannedString("omgwat"),
+        content = "omgwat",
         createdAt = Date(),
         emojis = emptyList(),
         reblogsCount = 0,
diff --git a/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt
index dc4a412f..5396a21e 100644
--- a/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt
+++ b/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt
@@ -17,7 +17,6 @@ package com.keylesspalace.tusky
 
 import android.content.Intent
 import android.os.Looper.getMainLooper
-import android.text.SpannedString
 import android.widget.EditText
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import com.keylesspalace.tusky.components.compose.ComposeActivity
@@ -469,7 +468,7 @@ class ComposeActivityTest {
                 "admin",
                 "admin",
                 "admin",
-                SpannedString(""),
+                "",
                 "https://example.token",
                 "",
                 "",
diff --git a/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt b/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt
index d5063943..91ea38d3 100644
--- a/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt
+++ b/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt
@@ -1,6 +1,5 @@
 package com.keylesspalace.tusky
 
-import android.text.SpannedString
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import com.keylesspalace.tusky.entity.Attachment
 import com.keylesspalace.tusky.entity.Filter
@@ -162,7 +161,7 @@ class FilterTest {
             inReplyToId = null,
             inReplyToAccountId = null,
             reblog = null,
-            content = SpannedString(content),
+            content = content,
             createdAt = Date(),
             emojis = emptyList(),
             reblogsCount = 0,
diff --git a/app/src/test/java/com/keylesspalace/tusky/StatusComparisonTest.kt b/app/src/test/java/com/keylesspalace/tusky/StatusComparisonTest.kt
index ed06e27c..3086036a 100644
--- a/app/src/test/java/com/keylesspalace/tusky/StatusComparisonTest.kt
+++ b/app/src/test/java/com/keylesspalace/tusky/StatusComparisonTest.kt
@@ -1,10 +1,8 @@
 package com.keylesspalace.tusky
 
-import android.text.Spanned
 import androidx.test.ext.junit.runners.AndroidJUnit4
-import com.google.gson.GsonBuilder
+import com.google.gson.Gson
 import com.keylesspalace.tusky.entity.Status
-import com.keylesspalace.tusky.json.SpannedTypeAdapter
 import com.keylesspalace.tusky.viewdata.StatusViewData
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertNotEquals
@@ -39,9 +37,7 @@ class StatusComparisonTest {
         assertEquals(createStatus(note = "Test"), createStatus(note = "Test 123456"))
     }
 
-    private val gson = GsonBuilder().registerTypeAdapter(
-        Spanned::class.java, SpannedTypeAdapter()
-    ).create()
+    private val gson = Gson()
 
     @Test
     fun `two equal status view data - should be equal`() {
@@ -49,14 +45,12 @@ class StatusComparisonTest {
             status = createStatus(),
             isExpanded = false,
             isShowingContent = false,
-            isCollapsible = false,
             isCollapsed = false
         )
         val viewdata2 = StatusViewData.Concrete(
             status = createStatus(),
             isExpanded = false,
             isShowingContent = false,
-            isCollapsible = false,
             isCollapsed = false
         )
         assertEquals(viewdata1, viewdata2)
@@ -68,14 +62,12 @@ class StatusComparisonTest {
             status = createStatus(),
             isExpanded = true,
             isShowingContent = false,
-            isCollapsible = false,
             isCollapsed = false
         )
         val viewdata2 = StatusViewData.Concrete(
             status = createStatus(),
             isExpanded = false,
             isShowingContent = false,
-            isCollapsible = false,
             isCollapsed = false
         )
         assertNotEquals(viewdata1, viewdata2)
@@ -87,14 +79,12 @@ class StatusComparisonTest {
             status = createStatus(content = "whatever"),
             isExpanded = true,
             isShowingContent = false,
-            isCollapsible = false,
             isCollapsed = false
         )
         val viewdata2 = StatusViewData.Concrete(
             status = createStatus(),
             isExpanded = false,
             isShowingContent = false,
-            isCollapsible = false,
             isCollapsed = false
         )
         assertNotEquals(viewdata1, viewdata2)
diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt
index 60dda419..33215e67 100644
--- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt
+++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt
@@ -1,14 +1,19 @@
 package com.keylesspalace.tusky.components.timeline
 
 import androidx.paging.PagingSource
+import androidx.test.ext.junit.runners.AndroidJUnit4
 import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelinePagingSource
 import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel
 import kotlinx.coroutines.runBlocking
 import org.junit.Assert.assertEquals
 import org.junit.Test
+import org.junit.runner.RunWith
 import org.mockito.kotlin.doReturn
 import org.mockito.kotlin.mock
+import org.robolectric.annotation.Config
 
+@Config(sdk = [28])
+@RunWith(AndroidJUnit4::class)
 class NetworkTimelinePagingSourceTest {
 
     private val status = mockStatusViewData()
diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt
index f7c998b5..cc6a90bd 100644
--- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt
+++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt
@@ -1,6 +1,5 @@
 package com.keylesspalace.tusky.components.timeline
 
-import android.text.SpannedString
 import com.google.gson.Gson
 import com.keylesspalace.tusky.db.TimelineStatusWithAccount
 import com.keylesspalace.tusky.entity.Status
@@ -25,7 +24,7 @@ fun mockStatus(id: String = "100") = Status(
     inReplyToId = null,
     inReplyToAccountId = null,
     reblog = null,
-    content = SpannedString("Test"),
+    content = "Test",
     createdAt = fixedDate,
     emojis = emptyList(),
     reblogsCount = 1,
@@ -50,7 +49,6 @@ fun mockStatusViewData(id: String = "100") = StatusViewData.Concrete(
     status = mockStatus(id),
     isExpanded = false,
     isShowingContent = false,
-    isCollapsible = false,
     isCollapsed = true,
 )