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
This commit is contained in:
Konrad Pozniak 2022-04-15 13:20:27 +02:00 committed by GitHub
parent ffbc4b6403
commit 3e849244f9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1232 additions and 500 deletions

View File

@ -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')"
]
}
}

View File

@ -78,7 +78,7 @@ import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.getDomain import com.keylesspalace.tusky.util.getDomain
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.loadAvatar 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.setClickableText
import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
@ -375,12 +375,11 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
} }
} }
viewModel.accountFieldData.observe( viewModel.accountFieldData.observe(
this, this
{ ) {
accountFieldAdapter.fields = it accountFieldAdapter.fields = it
accountFieldAdapter.notifyDataSetChanged() accountFieldAdapter.notifyDataSetChanged()
} }
)
viewModel.noteSaved.observe(this) { viewModel.noteSaved.observe(this) {
binding.saveNoteInfo.visible(it, View.INVISIBLE) binding.saveNoteInfo.visible(it, View.INVISIBLE)
} }
@ -395,11 +394,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
adapter.refreshContent() adapter.refreshContent()
} }
viewModel.isRefreshing.observe( viewModel.isRefreshing.observe(
this, this
{ isRefreshing -> ) { isRefreshing ->
binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true
} }
)
binding.swipeToRefreshLayout.setColorSchemeResources(R.color.tusky_blue) binding.swipeToRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
} }
@ -410,7 +408,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
binding.accountUsernameTextView.text = usernameFormatted binding.accountUsernameTextView.text = usernameFormatted
binding.accountDisplayNameTextView.text = account.name.emojify(account.emojis, binding.accountDisplayNameTextView, animateEmojis) 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) setClickableText(binding.accountNoteTextView, emojifiedNote, emptyList(), null, this)
// accountFieldAdapter.fields = account.fields ?: emptyList() // accountFieldAdapter.fields = account.fields ?: emptyList()

View File

@ -29,6 +29,7 @@ import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.Either import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.createClickableText import com.keylesspalace.tusky.util.createClickableText
import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.setClickableText import com.keylesspalace.tusky.util.setClickableText
class AccountFieldAdapter( class AccountFieldAdapter(
@ -65,7 +66,7 @@ class AccountFieldAdapter(
val emojifiedName = field.name.emojify(emojis, nameTextView, animateEmojis) val emojifiedName = field.name.emojify(emojis, nameTextView, animateEmojis)
nameTextView.text = emojifiedName 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) setClickableText(valueTextView, emojifiedValue, emptyList(), null, linkListener)
if (field.verifiedAt != null) { if (field.verifiedAt != null) {

View File

@ -26,7 +26,7 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions
class ConversationAdapter( class ConversationAdapter(
private val statusDisplayOptions: StatusDisplayOptions, private val statusDisplayOptions: StatusDisplayOptions,
private val listener: StatusActionListener private val listener: StatusActionListener
) : PagingDataAdapter<ConversationEntity, ConversationViewHolder>(CONVERSATION_COMPARATOR) { ) : PagingDataAdapter<ConversationViewData, ConversationViewHolder>(CONVERSATION_COMPARATOR) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_conversation, parent, false) val view = LayoutInflater.from(parent.context).inflate(R.layout.item_conversation, parent, false)
@ -37,17 +37,13 @@ class ConversationAdapter(
holder.setupWithConversation(getItem(position)) holder.setupWithConversation(getItem(position))
} }
fun item(position: Int): ConversationEntity? {
return getItem(position)
}
companion object { companion object {
val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback<ConversationEntity>() { val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback<ConversationViewData>() {
override fun areItemsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean { override fun areItemsTheSame(oldItem: ConversationViewData, newItem: ConversationViewData): Boolean {
return oldItem.id == newItem.id return oldItem.id == newItem.id
} }
override fun areContentsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean { override fun areContentsTheSame(oldItem: ConversationViewData, newItem: ConversationViewData): Boolean {
return oldItem == newItem return oldItem == newItem
} }
} }

View File

@ -15,7 +15,6 @@
package com.keylesspalace.tusky.components.conversation package com.keylesspalace.tusky.components.conversation
import android.text.Spanned
import androidx.room.Embedded import androidx.room.Embedded
import androidx.room.Entity import androidx.room.Entity
import androidx.room.TypeConverters 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.Poll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.util.shouldTrimStatus import com.keylesspalace.tusky.viewdata.StatusViewData
import java.util.Date import java.util.Date
@Entity(primaryKeys = ["id", "accountId"]) @Entity(primaryKeys = ["id", "accountId"])
@ -38,7 +37,16 @@ data class ConversationEntity(
val accounts: List<ConversationAccountEntity>, val accounts: List<ConversationAccountEntity>,
val unread: Boolean, val unread: Boolean,
@Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity @Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity
) ) {
fun toViewData(): ConversationViewData {
return ConversationViewData(
id = id,
accounts = accounts,
unread = unread,
lastStatus = lastStatus.toViewData()
)
}
}
data class ConversationAccountEntity( data class ConversationAccountEntity(
val id: String, val id: String,
@ -67,7 +75,7 @@ data class ConversationStatusEntity(
val inReplyToId: String?, val inReplyToId: String?,
val inReplyToAccountId: String?, val inReplyToAccountId: String?,
val account: ConversationAccountEntity, val account: ConversationAccountEntity,
val content: Spanned, val content: String,
val createdAt: Date, val createdAt: Date,
val emojis: List<Emoji>, val emojis: List<Emoji>,
val favouritesCount: Int, val favouritesCount: Int,
@ -80,95 +88,43 @@ data class ConversationStatusEntity(
val tags: List<HashTag>?, val tags: List<HashTag>?,
val showingHiddenContent: Boolean, val showingHiddenContent: Boolean,
val expanded: Boolean, val expanded: Boolean,
val collapsible: Boolean,
val collapsed: Boolean, val collapsed: Boolean,
val muted: Boolean, val muted: Boolean,
val poll: Poll? 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 fun toViewData(): StatusViewData.Concrete {
return StatusViewData.Concrete(
if (id != other.id) return false status = Status(
if (url != other.url) return false id = id,
if (inReplyToId != other.inReplyToId) return false url = url,
if (inReplyToAccountId != other.inReplyToAccountId) return false account = account.toAccount(),
if (account != other.account) return false inReplyToId = inReplyToId,
if (content.toString() != other.content.toString()) return false inReplyToAccountId = inReplyToAccountId,
if (createdAt != other.createdAt) return false content = content,
if (emojis != other.emojis) return false reblog = null,
if (favouritesCount != other.favouritesCount) return false createdAt = createdAt,
if (favourited != other.favourited) return false emojis = emojis,
if (sensitive != other.sensitive) return false reblogsCount = 0,
if (spoilerText != other.spoilerText) return false favouritesCount = favouritesCount,
if (attachments != other.attachments) return false reblogged = false,
if (mentions != other.mentions) return false favourited = favourited,
if (tags != other.tags) return false bookmarked = bookmarked,
if (showingHiddenContent != other.showingHiddenContent) return false sensitive = sensitive,
if (expanded != other.expanded) return false spoilerText = spoilerText,
if (collapsible != other.collapsible) return false visibility = Status.Visibility.DIRECT,
if (collapsed != other.collapsed) return false attachments = attachments,
if (muted != other.muted) return false mentions = mentions,
if (poll != other.poll) return false tags = tags,
application = null,
return true pinned = false,
} muted = muted,
poll = poll,
override fun hashCode(): Int { card = null
var result = id.hashCode() ),
result = 31 * result + (url?.hashCode() ?: 0) isExpanded = expanded,
result = 31 * result + (inReplyToId?.hashCode() ?: 0) isShowingContent = showingHiddenContent,
result = 31 * result + (inReplyToAccountId?.hashCode() ?: 0) isCollapsed = collapsed
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
) )
} }
} }
@ -202,7 +158,6 @@ fun Status.toEntity() =
tags = tags, tags = tags,
showingHiddenContent = false, showingHiddenContent = false,
expanded = false, expanded = false,
collapsible = shouldTrimStatus(content),
collapsed = true, collapsed = true,
muted = muted ?: false, muted = muted ?: false,
poll = poll poll = poll

View File

@ -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
)
}

View File

@ -28,11 +28,14 @@ import androidx.recyclerview.widget.RecyclerView;
import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder; import com.keylesspalace.tusky.adapter.StatusBaseViewHolder;
import com.keylesspalace.tusky.entity.Attachment; 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.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.ImageLoadingHelper; import com.keylesspalace.tusky.util.ImageLoadingHelper;
import com.keylesspalace.tusky.util.SmartLengthInputFilter; import com.keylesspalace.tusky.util.SmartLengthInputFilter;
import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.util.StatusDisplayOptions;
import com.keylesspalace.tusky.viewdata.PollViewDataKt; import com.keylesspalace.tusky.viewdata.PollViewDataKt;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.util.List; import java.util.List;
@ -69,11 +72,12 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
return context.getResources().getDimensionPixelSize(R.dimen.status_media_preview_height); return context.getResources().getDimensionPixelSize(R.dimen.status_media_preview_height);
} }
void setupWithConversation(ConversationEntity conversation) { void setupWithConversation(ConversationViewData conversation) {
ConversationStatusEntity status = conversation.getLastStatus(); StatusViewData.Concrete statusViewData = conversation.getLastStatus();
ConversationAccountEntity account = status.getAccount(); 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); setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions);
setUsername(account.getUsername()); setUsername(account.getUsername());
@ -84,7 +88,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
List<Attachment> attachments = status.getAttachments(); List<Attachment> attachments = status.getAttachments();
boolean sensitive = status.getSensitive(); boolean sensitive = status.getSensitive();
if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) { if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
setMediaPreviews(attachments, sensitive, listener, status.getShowingHiddenContent(), setMediaPreviews(attachments, sensitive, listener, statusViewData.isShowingContent(),
statusDisplayOptions.useBlurhash()); statusDisplayOptions.useBlurhash());
if (attachments.size() == 0) { if (attachments.size() == 0) {
@ -95,7 +99,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
mediaLabel.setVisibility(View.GONE); mediaLabel.setVisibility(View.GONE);
} }
} else { } else {
setMediaLabel(attachments, sensitive, listener, status.getShowingHiddenContent()); setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent());
// Hide all unused views. // Hide all unused views.
mediaPreviews[0].setVisibility(View.GONE); mediaPreviews[0].setVisibility(View.GONE);
mediaPreviews[1].setVisibility(View.GONE); mediaPreviews[1].setVisibility(View.GONE);
@ -104,10 +108,10 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
hideSensitiveMediaWarning(); hideSensitiveMediaWarning();
} }
setupButtons(listener, account.getId(), status.getContent().toString(), setupButtons(listener, account.getId(), statusViewData.getContent().toString(),
statusDisplayOptions); statusDisplayOptions);
setSpoilerAndContent(status.getExpanded(), status.getContent(), status.getSpoilerText(), setSpoilerAndContent(statusViewData.isExpanded(), statusViewData.getContent(), status.getSpoilerText(),
status.getMentions(), status.getTags(), status.getEmojis(), status.getMentions(), status.getTags(), status.getEmojis(),
PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener); PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener);

View File

@ -153,24 +153,24 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
} }
override fun onFavourite(favourite: Boolean, position: Int) { override fun onFavourite(favourite: Boolean, position: Int) {
adapter.item(position)?.let { conversation -> adapter.peek(position)?.let { conversation ->
viewModel.favourite(favourite, conversation) viewModel.favourite(favourite, conversation)
} }
} }
override fun onBookmark(favourite: Boolean, position: Int) { override fun onBookmark(favourite: Boolean, position: Int) {
adapter.item(position)?.let { conversation -> adapter.peek(position)?.let { conversation ->
viewModel.bookmark(favourite, conversation) viewModel.bookmark(favourite, conversation)
} }
} }
override fun onMore(view: View, position: Int) { override fun onMore(view: View, position: Int) {
adapter.item(position)?.let { conversation -> adapter.peek(position)?.let { conversation ->
val popup = PopupMenu(requireContext(), view) val popup = PopupMenu(requireContext(), view)
popup.inflate(R.menu.conversation_more) popup.inflate(R.menu.conversation_more)
if (conversation.lastStatus.muted) { if (conversation.lastStatus.status.muted == true) {
popup.menu.removeItem(R.id.status_mute_conversation) popup.menu.removeItem(R.id.status_mute_conversation)
} else { } else {
popup.menu.removeItem(R.id.status_unmute_conversation) 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?) { override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
adapter.item(position)?.let { conversation -> adapter.peek(position)?.let { conversation ->
viewMedia(attachmentIndex, AttachmentViewData.list(conversation.lastStatus.toStatus()), view) viewMedia(attachmentIndex, AttachmentViewData.list(conversation.lastStatus.status), view)
} }
} }
override fun onViewThread(position: Int) { override fun onViewThread(position: Int) {
adapter.item(position)?.let { conversation -> adapter.peek(position)?.let { conversation ->
viewThread(conversation.lastStatus.id, conversation.lastStatus.url) 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) { override fun onExpandedChange(expanded: Boolean, position: Int) {
adapter.item(position)?.let { conversation -> adapter.peek(position)?.let { conversation ->
viewModel.expandHiddenStatus(expanded, conversation) viewModel.expandHiddenStatus(expanded, conversation)
} }
} }
override fun onContentHiddenChange(isShowing: Boolean, position: Int) { override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
adapter.item(position)?.let { conversation -> adapter.peek(position)?.let { conversation ->
viewModel.showContent(isShowing, conversation) viewModel.showContent(isShowing, conversation)
} }
} }
@ -221,7 +221,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
} }
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
adapter.item(position)?.let { conversation -> adapter.peek(position)?.let { conversation ->
viewModel.collapseLongStatus(isCollapsed, conversation) viewModel.collapseLongStatus(isCollapsed, conversation)
} }
} }
@ -241,12 +241,12 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
} }
override fun onReply(position: Int) { override fun onReply(position: Int) {
adapter.item(position)?.let { conversation -> adapter.peek(position)?.let { conversation ->
reply(conversation.lastStatus.toStatus()) reply(conversation.lastStatus.status)
} }
} }
private fun deleteConversation(conversation: ConversationEntity) { private fun deleteConversation(conversation: ConversationViewData) {
AlertDialog.Builder(requireContext()) AlertDialog.Builder(requireContext())
.setMessage(R.string.dialog_delete_conversation_warning) .setMessage(R.string.dialog_delete_conversation_warning)
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
@ -268,7 +268,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
} }
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) { override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
adapter.item(position)?.let { conversation -> adapter.peek(position)?.let { conversation ->
viewModel.voteInPoll(choices, conversation) viewModel.voteInPoll(choices, conversation)
} }
} }

View File

@ -16,16 +16,18 @@
package com.keylesspalace.tusky.components.conversation package com.keylesspalace.tusky.components.conversation
import android.util.Log import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.paging.ExperimentalPagingApi import androidx.paging.ExperimentalPagingApi
import androidx.paging.Pager import androidx.paging.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
import androidx.paging.cachedIn import androidx.paging.cachedIn
import androidx.paging.map
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases import com.keylesspalace.tusky.network.TimelineCases
import com.keylesspalace.tusky.util.RxAwareViewModel import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await import kotlinx.coroutines.rx3.await
import javax.inject.Inject import javax.inject.Inject
@ -35,7 +37,7 @@ class ConversationsViewModel @Inject constructor(
private val database: AppDatabase, private val database: AppDatabase,
private val accountManager: AccountManager, private val accountManager: AccountManager,
private val api: MastodonApi private val api: MastodonApi
) : RxAwareViewModel() { ) : ViewModel() {
@OptIn(ExperimentalPagingApi::class) @OptIn(ExperimentalPagingApi::class)
val conversationFlow = Pager( val conversationFlow = Pager(
@ -44,104 +46,117 @@ class ConversationsViewModel @Inject constructor(
pagingSourceFactory = { database.conversationDao().conversationsForAccount(accountManager.activeAccount!!.id) } pagingSourceFactory = { database.conversationDao().conversationsForAccount(accountManager.activeAccount!!.id) }
) )
.flow .flow
.map { pagingData ->
pagingData.map { conversation -> conversation.toViewData() }
}
.cachedIn(viewModelScope) .cachedIn(viewModelScope)
fun favourite(favourite: Boolean, conversation: ConversationEntity) { fun favourite(favourite: Boolean, conversation: ConversationViewData) {
viewModelScope.launch { viewModelScope.launch {
try { try {
timelineCases.favourite(conversation.lastStatus.id, favourite).await() timelineCases.favourite(conversation.lastStatus.id, favourite).await()
val newConversation = conversation.copy( val newConversation = conversation.toEntity(
lastStatus = conversation.lastStatus.copy(favourited = favourite) accountId = accountManager.activeAccount!!.id,
favourited = favourite
) )
database.conversationDao().insert(newConversation) saveConversationToDb(newConversation)
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "failed to favourite status", e) Log.w(TAG, "failed to favourite status", e)
} }
} }
} }
fun bookmark(bookmark: Boolean, conversation: ConversationEntity) { fun bookmark(bookmark: Boolean, conversation: ConversationViewData) {
viewModelScope.launch { viewModelScope.launch {
try { try {
timelineCases.bookmark(conversation.lastStatus.id, bookmark).await() timelineCases.bookmark(conversation.lastStatus.id, bookmark).await()
val newConversation = conversation.copy( val newConversation = conversation.toEntity(
lastStatus = conversation.lastStatus.copy(bookmarked = bookmark) accountId = accountManager.activeAccount!!.id,
bookmarked = bookmark
) )
database.conversationDao().insert(newConversation) saveConversationToDb(newConversation)
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "failed to bookmark status", e) Log.w(TAG, "failed to bookmark status", e)
} }
} }
} }
fun voteInPoll(choices: List<Int>, conversation: ConversationEntity) { fun voteInPoll(choices: List<Int>, conversation: ConversationViewData) {
viewModelScope.launch { viewModelScope.launch {
try { try {
val poll = timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.poll?.id!!, choices).await() val poll = timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.status.poll?.id!!, choices).await()
val newConversation = conversation.copy( val newConversation = conversation.toEntity(
lastStatus = conversation.lastStatus.copy(poll = poll) accountId = accountManager.activeAccount!!.id,
poll = poll
) )
database.conversationDao().insert(newConversation) saveConversationToDb(newConversation)
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "failed to vote in poll", e) Log.w(TAG, "failed to vote in poll", e)
} }
} }
} }
fun expandHiddenStatus(expanded: Boolean, conversation: ConversationEntity) { fun expandHiddenStatus(expanded: Boolean, conversation: ConversationViewData) {
viewModelScope.launch { viewModelScope.launch {
val newConversation = conversation.copy( val newConversation = conversation.toEntity(
lastStatus = conversation.lastStatus.copy(expanded = expanded) accountId = accountManager.activeAccount!!.id,
expanded = expanded
) )
saveConversationToDb(newConversation) saveConversationToDb(newConversation)
} }
} }
fun collapseLongStatus(collapsed: Boolean, conversation: ConversationEntity) { fun collapseLongStatus(collapsed: Boolean, conversation: ConversationViewData) {
viewModelScope.launch { viewModelScope.launch {
val newConversation = conversation.copy( val newConversation = conversation.toEntity(
lastStatus = conversation.lastStatus.copy(collapsed = collapsed) accountId = accountManager.activeAccount!!.id,
collapsed = collapsed
) )
saveConversationToDb(newConversation) saveConversationToDb(newConversation)
} }
} }
fun showContent(showing: Boolean, conversation: ConversationEntity) { fun showContent(showing: Boolean, conversation: ConversationViewData) {
viewModelScope.launch { viewModelScope.launch {
val newConversation = conversation.copy( val newConversation = conversation.toEntity(
lastStatus = conversation.lastStatus.copy(showingHiddenContent = showing) accountId = accountManager.activeAccount!!.id,
showingHiddenContent = showing
) )
saveConversationToDb(newConversation) saveConversationToDb(newConversation)
} }
} }
fun remove(conversation: ConversationEntity) { fun remove(conversation: ConversationViewData) {
viewModelScope.launch { viewModelScope.launch {
try { try {
api.deleteConversation(conversationId = conversation.id) api.deleteConversation(conversationId = conversation.id)
database.conversationDao().delete(conversation) database.conversationDao().delete(
id = conversation.id,
accountId = accountManager.activeAccount!!.id
)
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "failed to delete conversation", e) Log.w(TAG, "failed to delete conversation", e)
} }
} }
} }
fun muteConversation(conversation: ConversationEntity) { fun muteConversation(conversation: ConversationViewData) {
viewModelScope.launch { viewModelScope.launch {
try { try {
val newStatus = timelineCases.muteConversation( timelineCases.muteConversation(
conversation.lastStatus.id, conversation.lastStatus.id,
!conversation.lastStatus.muted !(conversation.lastStatus.status.muted ?: false)
).await() ).await()
val newConversation = conversation.copy( val newConversation = conversation.toEntity(
lastStatus = newStatus.toEntity() accountId = accountManager.activeAccount!!.id,
muted = !(conversation.lastStatus.status.muted ?: false)
) )
database.conversationDao().insert(newConversation) 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) database.conversationDao().insert(conversation)
} }

View File

@ -21,6 +21,7 @@ import androidx.lifecycle.viewModelScope
import androidx.paging.Pager import androidx.paging.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
import androidx.paging.cachedIn import androidx.paging.cachedIn
import androidx.paging.map
import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.BlockEvent
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.MuteEvent 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.Resource
import com.keylesspalace.tusky.util.RxAwareViewModel import com.keylesspalace.tusky.util.RxAwareViewModel
import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.toViewData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -74,6 +77,11 @@ class ReportViewModel @Inject constructor(
pagingSourceFactory = { StatusesPagingSource(accountId, mastodonApi) } pagingSourceFactory = { StatusesPagingSource(accountId, mastodonApi) }
).flow ).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) .cachedIn(viewModelScope)
private val selectedIds = HashSet<String>() private val selectedIds = HashSet<String>()
@ -155,7 +163,7 @@ class ReportViewModel @Inject constructor(
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe( .subscribe(
{ relationship -> { relationship ->
val muting = relationship?.muting == true val muting = relationship.muting
muteStateMutable.value = Success(muting) muteStateMutable.value = Success(muting)
if (muting) { if (muting) {
eventHub.dispatch(MuteEvent(accountId)) eventHub.dispatch(MuteEvent(accountId))
@ -180,7 +188,7 @@ class ReportViewModel @Inject constructor(
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe( .subscribe(
{ relationship -> { relationship ->
val blocking = relationship?.blocking == true val blocking = relationship.blocking
blockStateMutable.value = Success(blocking) blockStateMutable.value = Success(blocking)
if (blocking) { if (blocking) {
eventHub.dispatch(BlockEvent(accountId)) eventHub.dispatch(BlockEvent(accountId))

View File

@ -37,6 +37,7 @@ import com.keylesspalace.tusky.util.setClickableMentions
import com.keylesspalace.tusky.util.setClickableText import com.keylesspalace.tusky.util.setClickableText
import com.keylesspalace.tusky.util.shouldTrimStatus import com.keylesspalace.tusky.util.shouldTrimStatus
import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.viewdata.StatusViewData
import com.keylesspalace.tusky.viewdata.toViewData import com.keylesspalace.tusky.viewdata.toViewData
import java.util.Date import java.util.Date
@ -45,20 +46,21 @@ class StatusViewHolder(
private val statusDisplayOptions: StatusDisplayOptions, private val statusDisplayOptions: StatusDisplayOptions,
private val viewState: StatusViewState, private val viewState: StatusViewState,
private val adapterHandler: AdapterHandler, private val adapterHandler: AdapterHandler,
private val getStatusForPosition: (Int) -> Status? private val getStatusForPosition: (Int) -> StatusViewData.Concrete?
) : RecyclerView.ViewHolder(binding.root) { ) : RecyclerView.ViewHolder(binding.root) {
private val mediaViewHeight = itemView.context.resources.getDimensionPixelSize(R.dimen.status_media_preview_height) private val mediaViewHeight = itemView.context.resources.getDimensionPixelSize(R.dimen.status_media_preview_height)
private val statusViewHelper = StatusViewHelper(itemView) private val statusViewHelper = StatusViewHelper(itemView)
private val previewListener = object : StatusViewHelper.MediaPreviewListener { private val previewListener = object : StatusViewHelper.MediaPreviewListener {
override fun onViewMedia(v: View?, idx: Int) { override fun onViewMedia(v: View?, idx: Int) {
status()?.let { status -> viewdata()?.let { viewdata ->
adapterHandler.showMedia(v, status, idx) adapterHandler.showMedia(v, viewdata.status, idx)
} }
} }
override fun onContentHiddenChange(isShowing: Boolean) { override fun onContentHiddenChange(isShowing: Boolean) {
status()?.id?.let { id -> viewdata()?.id?.let { id ->
viewState.setMediaShow(id, isShowing) viewState.setMediaShow(id, isShowing)
} }
} }
@ -66,57 +68,57 @@ class StatusViewHolder(
init { init {
binding.statusSelection.setOnCheckedChangeListener { _, isChecked -> binding.statusSelection.setOnCheckedChangeListener { _, isChecked ->
status()?.let { status -> viewdata()?.let { viewdata ->
adapterHandler.setStatusChecked(status, isChecked) adapterHandler.setStatusChecked(viewdata.status, isChecked)
} }
} }
binding.statusMediaPreviewContainer.clipToOutline = true binding.statusMediaPreviewContainer.clipToOutline = true
} }
fun bind(status: Status) { fun bind(viewData: StatusViewData.Concrete) {
binding.statusSelection.isChecked = adapterHandler.isStatusChecked(status.id) binding.statusSelection.isChecked = adapterHandler.isStatusChecked(viewData.id)
updateTextView() updateTextView()
val sensitive = status.sensitive val sensitive = viewData.status.sensitive
statusViewHelper.setMediasPreview( statusViewHelper.setMediasPreview(
statusDisplayOptions, status.attachments, statusDisplayOptions, viewData.status.attachments,
sensitive, previewListener, viewState.isMediaShow(status.id, status.sensitive), sensitive, previewListener, viewState.isMediaShow(viewData.id, viewData.status.sensitive),
mediaViewHeight mediaViewHeight
) )
statusViewHelper.setupPollReadonly(status.poll.toViewData(), status.emojis, statusDisplayOptions) statusViewHelper.setupPollReadonly(viewData.status.poll.toViewData(), viewData.status.emojis, statusDisplayOptions)
setCreatedAt(status.createdAt) setCreatedAt(viewData.status.createdAt)
} }
private fun updateTextView() { private fun updateTextView() {
status()?.let { status -> viewdata()?.let { viewdata ->
setupCollapsedState( setupCollapsedState(
shouldTrimStatus(status.content), viewState.isCollapsed(status.id, true), shouldTrimStatus(viewdata.content), viewState.isCollapsed(viewdata.id, true),
viewState.isContentShow(status.id, status.sensitive), status.spoilerText viewState.isContentShow(viewdata.id, viewdata.status.sensitive), viewdata.spoilerText
) )
if (status.spoilerText.isBlank()) { if (viewdata.spoilerText.isBlank()) {
setTextVisible(true, status.content, status.mentions, status.tags, status.emojis, adapterHandler) setTextVisible(true, viewdata.content, viewdata.status.mentions, viewdata.status.tags, viewdata.status.emojis, adapterHandler)
binding.statusContentWarningButton.hide() binding.statusContentWarningButton.hide()
binding.statusContentWarningDescription.hide() binding.statusContentWarningDescription.hide()
} else { } 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.text = emojiSpoiler
binding.statusContentWarningDescription.show() binding.statusContentWarningDescription.show()
binding.statusContentWarningButton.show() binding.statusContentWarningButton.show()
setContentWarningButtonText(viewState.isContentShow(status.id, true)) setContentWarningButtonText(viewState.isContentShow(viewdata.id, true))
binding.statusContentWarningButton.setOnClickListener { binding.statusContentWarningButton.setOnClickListener {
status()?.let { status -> viewdata()?.let { viewdata ->
val contentShown = viewState.isContentShow(status.id, true) val contentShown = viewState.isContentShow(viewdata.id, true)
binding.statusContentWarningDescription.invalidate() binding.statusContentWarningDescription.invalidate()
viewState.setContentShow(status.id, !contentShown) viewState.setContentShow(viewdata.id, !contentShown)
setTextVisible(!contentShown, status.content, status.mentions, status.tags, status.emojis, adapterHandler) setTextVisible(!contentShown, viewdata.content, viewdata.status.mentions, viewdata.status.tags, viewdata.status.emojis, adapterHandler)
setContentWarningButtonText(!contentShown) 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 */ /* input filter for TextViews have to be set before text */
if (collapsible && (expanded || TextUtils.isEmpty(spoilerText))) { if (collapsible && (expanded || TextUtils.isEmpty(spoilerText))) {
binding.buttonToggleContent.setOnClickListener { binding.buttonToggleContent.setOnClickListener {
status()?.let { status -> viewdata()?.let { viewdata ->
viewState.setCollapsed(status.id, !collapsed) viewState.setCollapsed(viewdata.id, !collapsed)
updateTextView() updateTextView()
} }
} }
@ -189,5 +191,5 @@ class StatusViewHolder(
} }
} }
private fun status() = getStatusForPosition(bindingAdapterPosition) private fun viewdata() = getStatusForPosition(bindingAdapterPosition)
} }

View File

@ -22,16 +22,16 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.components.report.model.StatusViewState import com.keylesspalace.tusky.components.report.model.StatusViewState
import com.keylesspalace.tusky.databinding.ItemReportStatusBinding import com.keylesspalace.tusky.databinding.ItemReportStatusBinding
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.viewdata.StatusViewData
class StatusesAdapter( class StatusesAdapter(
private val statusDisplayOptions: StatusDisplayOptions, private val statusDisplayOptions: StatusDisplayOptions,
private val statusViewState: StatusViewState, private val statusViewState: StatusViewState,
private val adapterHandler: AdapterHandler 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 if (position != RecyclerView.NO_POSITION) getItem(position) else null
} }
@ -50,11 +50,11 @@ class StatusesAdapter(
} }
companion object { companion object {
val STATUS_COMPARATOR = object : DiffUtil.ItemCallback<Status>() { val STATUS_COMPARATOR = object : DiffUtil.ItemCallback<StatusViewData.Concrete>() {
override fun areContentsTheSame(oldItem: Status, newItem: Status): Boolean = override fun areContentsTheSame(oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete): Boolean =
oldItem == newItem oldItem == newItem
override fun areItemsTheSame(oldItem: Status, newItem: Status): Boolean = override fun areItemsTheSame(oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete): Boolean =
oldItem.id == newItem.id oldItem.id == newItem.id
} }
} }

View File

@ -15,9 +15,6 @@
package com.keylesspalace.tusky.components.timeline 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.Gson
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import com.keylesspalace.tusky.db.TimelineAccountEntity 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.Poll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.TimelineAccount 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 com.keylesspalace.tusky.viewdata.StatusViewData
import java.util.Date import java.util.Date
@ -119,7 +114,7 @@ fun Status.toEntity(
authorServerId = actionableStatus.account.id, authorServerId = actionableStatus.account.id,
inReplyToId = actionableStatus.inReplyToId, inReplyToId = actionableStatus.inReplyToId,
inReplyToAccountId = actionableStatus.inReplyToAccountId, inReplyToAccountId = actionableStatus.inReplyToAccountId,
content = actionableStatus.content.toHtml(), content = actionableStatus.content,
createdAt = actionableStatus.createdAt.time, createdAt = actionableStatus.createdAt.time,
emojis = actionableStatus.emojis.let(gson::toJson), emojis = actionableStatus.emojis.let(gson::toJson),
reblogsCount = actionableStatus.reblogsCount, reblogsCount = actionableStatus.reblogsCount,
@ -165,8 +160,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
inReplyToId = status.inReplyToId, inReplyToId = status.inReplyToId,
inReplyToAccountId = status.inReplyToAccountId, inReplyToAccountId = status.inReplyToAccountId,
reblog = null, reblog = null,
content = status.content?.parseAsHtml()?.trimTrailingWhitespace() content = status.content.orEmpty(),
?: SpannedString(""),
createdAt = Date(status.createdAt), createdAt = Date(status.createdAt),
emojis = emojis, emojis = emojis,
reblogsCount = status.reblogsCount, reblogsCount = status.reblogsCount,
@ -195,7 +189,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
inReplyToId = null, inReplyToId = null,
inReplyToAccountId = null, inReplyToAccountId = null,
reblog = reblog, reblog = reblog,
content = SpannedString(""), content = "",
createdAt = Date(status.createdAt), // lie but whatever? createdAt = Date(status.createdAt), // lie but whatever?
emojis = listOf(), emojis = listOf(),
reblogsCount = 0, reblogsCount = 0,
@ -223,8 +217,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
inReplyToId = status.inReplyToId, inReplyToId = status.inReplyToId,
inReplyToAccountId = status.inReplyToAccountId, inReplyToAccountId = status.inReplyToAccountId,
reblog = null, reblog = null,
content = status.content?.parseAsHtml()?.trimTrailingWhitespace() content = status.content.orEmpty(),
?: SpannedString(""),
createdAt = Date(status.createdAt), createdAt = Date(status.createdAt),
emojis = emojis, emojis = emojis,
reblogsCount = status.reblogsCount, reblogsCount = status.reblogsCount,
@ -249,7 +242,6 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
status = status, status = status,
isExpanded = this.status.expanded, isExpanded = this.status.expanded,
isShowingContent = this.status.contentShowing, isShowingContent = this.status.contentShowing,
isCollapsible = shouldTrimStatus(status.content),
isCollapsed = this.status.contentCollapsed isCollapsed = this.status.contentCollapsed
) )
} }

View File

@ -42,7 +42,10 @@ import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases import com.keylesspalace.tusky.network.TimelineCases
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await import kotlinx.coroutines.rx3.await
@ -79,15 +82,13 @@ class CachedTimelineViewModel @Inject constructor(
} }
).flow ).flow
.map { pagingData -> .map { pagingData ->
pagingData.map { timelineStatus -> pagingData.map(Dispatchers.Default.asExecutor()) { timelineStatus ->
timelineStatus.toViewData(gson) timelineStatus.toViewData(gson)
} }.filter(Dispatchers.Default.asExecutor()) { statusViewData ->
}
.map { pagingData ->
pagingData.filter { statusViewData ->
!shouldFilterStatus(statusViewData) !shouldFilterStatus(statusViewData)
} }
} }
.flowOn(Dispatchers.Default)
.cachedIn(viewModelScope) .cachedIn(viewModelScope)
init { init {

View File

@ -40,6 +40,9 @@ import com.keylesspalace.tusky.util.isLessThan
import com.keylesspalace.tusky.util.isLessThanOrEqual import com.keylesspalace.tusky.util.isLessThanOrEqual
import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.StatusViewData 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.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await import kotlinx.coroutines.rx3.await
@ -79,10 +82,11 @@ class NetworkTimelineViewModel @Inject constructor(
remoteMediator = NetworkTimelineRemoteMediator(accountManager, this) remoteMediator = NetworkTimelineRemoteMediator(accountManager, this)
).flow ).flow
.map { pagingData -> .map { pagingData ->
pagingData.filter { statusViewData -> pagingData.filter(Dispatchers.Default.asExecutor()) { statusViewData ->
!shouldFilterStatus(statusViewData) !shouldFilterStatus(statusViewData)
} }
} }
.flowOn(Dispatchers.Default)
.cachedIn(viewModelScope) .cachedIn(viewModelScope)
override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) { override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) {

View File

@ -31,7 +31,7 @@ import java.io.File;
*/ */
@Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, @Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
TimelineAccountEntity.class, ConversationEntity.class TimelineAccountEntity.class, ConversationEntity.class
}, version = 32) }, version = 33)
public abstract class AppDatabase extends RoomDatabase { public abstract class AppDatabase extends RoomDatabase {
public abstract AccountDao accountDao(); 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"); 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`))");
}
};
} }

View File

@ -17,7 +17,6 @@ package com.keylesspalace.tusky.db
import androidx.paging.PagingSource import androidx.paging.PagingSource
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
@ -31,8 +30,8 @@ interface ConversationsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(conversation: ConversationEntity): Long suspend fun insert(conversation: ConversationEntity): Long
@Delete @Query("DELETE FROM ConversationEntity WHERE id = :id AND accountId = :accountId")
suspend fun delete(conversation: ConversationEntity): Int suspend fun delete(id: String, accountId: Long): Int
@Query("SELECT * FROM ConversationEntity WHERE accountId = :accountId ORDER BY s_createdAt DESC") @Query("SELECT * FROM ConversationEntity WHERE accountId = :accountId ORDER BY s_createdAt DESC")
fun conversationsForAccount(accountId: Long): PagingSource<Int, ConversationEntity> fun conversationsForAccount(accountId: Long): PagingSource<Int, ConversationEntity>

View File

@ -15,9 +15,6 @@
package com.keylesspalace.tusky.db 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.ProvidedTypeConverter
import androidx.room.TypeConverter import androidx.room.TypeConverter
import com.google.gson.Gson 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.NewPoll
import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.trimTrailingWhitespace
import java.net.URLDecoder import java.net.URLDecoder
import java.net.URLEncoder import java.net.URLEncoder
import java.util.ArrayList
import java.util.Date import java.util.Date
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -140,22 +135,6 @@ class Converters @Inject constructor (
return Date(date) 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 @TypeConverter
fun pollToJson(poll: Poll?): String? { fun pollToJson(poll: Poll?): String? {
return gson.toJson(poll) return gson.toJson(poll)

View File

@ -63,6 +63,7 @@ class AppModule {
AppDatabase.Migration25_26(appContext.getExternalFilesDir("Tusky")), AppDatabase.Migration25_26(appContext.getExternalFilesDir("Tusky")),
AppDatabase.MIGRATION_26_27, AppDatabase.MIGRATION_27_28, AppDatabase.MIGRATION_28_29, 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_29_30, AppDatabase.MIGRATION_30_31, AppDatabase.MIGRATION_31_32,
AppDatabase.MIGRATION_32_33
) )
.build() .build()
} }

View File

@ -18,13 +18,10 @@ package com.keylesspalace.tusky.di
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Build import android.os.Build
import android.text.Spanned
import at.connyduck.calladapter.kotlinresult.KotlinResultCallAdapterFactory import at.connyduck.calladapter.kotlinresult.KotlinResultCallAdapterFactory
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.json.SpannedTypeAdapter
import com.keylesspalace.tusky.network.InstanceSwitchAuthInterceptor import com.keylesspalace.tusky.network.InstanceSwitchAuthInterceptor
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.getNonNullString import com.keylesspalace.tusky.util.getNonNullString
@ -52,11 +49,7 @@ class NetworkModule {
@Provides @Provides
@Singleton @Singleton
fun providesGson(): Gson { fun providesGson() = Gson()
return GsonBuilder()
.registerTypeAdapter(Spanned::class.java, SpannedTypeAdapter())
.create()
}
@Provides @Provides
@Singleton @Singleton

View File

@ -15,7 +15,6 @@
package com.keylesspalace.tusky.entity package com.keylesspalace.tusky.entity
import android.text.Spanned
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import java.util.Date import java.util.Date
@ -24,7 +23,7 @@ data class Account(
@SerializedName("username") val localUsername: String, @SerializedName("username") val localUsername: String,
@SerializedName("acct") val username: 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 @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 url: String,
val avatar: String, val avatar: String,
val header: String, val header: String,
@ -46,56 +45,6 @@ data class Account(
} else displayName } else displayName
fun isRemote(): Boolean = this.username != this.localUsername 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( data class AccountSource(
@ -107,7 +56,7 @@ data class AccountSource(
data class Field( data class Field(
val name: String, val name: String,
val value: Spanned, val value: String,
@SerializedName("verified_at") val verifiedAt: Date? @SerializedName("verified_at") val verifiedAt: Date?
) )

View File

@ -15,13 +15,12 @@
package com.keylesspalace.tusky.entity package com.keylesspalace.tusky.entity
import android.text.Spanned
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import java.util.Date import java.util.Date
data class Announcement( data class Announcement(
val id: String, val id: String,
val content: Spanned, val content: String,
@SerializedName("starts_at") val startsAt: Date?, @SerializedName("starts_at") val startsAt: Date?,
@SerializedName("ends_at") val endsAt: Date?, @SerializedName("ends_at") val endsAt: Date?,
@SerializedName("all_day") val allDay: Boolean, @SerializedName("all_day") val allDay: Boolean,

View File

@ -15,13 +15,12 @@
package com.keylesspalace.tusky.entity package com.keylesspalace.tusky.entity
import android.text.Spanned
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
data class Card( data class Card(
val url: String, val url: String,
val title: Spanned, val title: String,
val description: Spanned, val description: String,
@SerializedName("author_name") val authorName: String, @SerializedName("author_name") val authorName: String,
val image: String, val image: String,
val type: String, val type: String,
@ -31,9 +30,7 @@ data class Card(
val embed_url: String? val embed_url: String?
) { ) {
override fun hashCode(): Int { override fun hashCode() = url.hashCode()
return url.hashCode()
}
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (other !is Card) { if (other !is Card) {

View File

@ -16,9 +16,9 @@
package com.keylesspalace.tusky.entity package com.keylesspalace.tusky.entity
import android.text.SpannableStringBuilder import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.URLSpan import android.text.style.URLSpan
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import java.util.ArrayList import java.util.ArrayList
import java.util.Date import java.util.Date
@ -29,7 +29,7 @@ data class Status(
@SerializedName("in_reply_to_id") var inReplyToId: String?, @SerializedName("in_reply_to_id") var inReplyToId: String?,
@SerializedName("in_reply_to_account_id") val inReplyToAccountId: String?, @SerializedName("in_reply_to_account_id") val inReplyToAccountId: String?,
val reblog: Status?, val reblog: Status?,
val content: Spanned, val content: String,
@SerializedName("created_at") val createdAt: Date, @SerializedName("created_at") val createdAt: Date,
val emojis: List<Emoji>, val emojis: List<Emoji>,
@SerializedName("reblogs_count") val reblogsCount: Int, @SerializedName("reblogs_count") val reblogsCount: Int,
@ -134,8 +134,9 @@ data class Status(
} }
private fun getEditableText(): String { private fun getEditableText(): String {
val builder = SpannableStringBuilder(content) val contentSpanned = content.parseAsMastodonHtml()
for (span in content.getSpans(0, content.length, URLSpan::class.java)) { val builder = SpannableStringBuilder(content.parseAsMastodonHtml())
for (span in contentSpanned.getSpans(0, content.length, URLSpan::class.java)) {
val url = span.url val url = span.url
for ((_, url1, username) in mentions) { for ((_, url1, username) in mentions) {
if (url == url1) { if (url == url1) {
@ -149,71 +150,6 @@ data class Status(
return builder.toString() 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( data class Mention(
val id: String, val id: String,
val url: String, val url: String,

View File

@ -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))
}
}

View File

@ -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 = '-'

View File

@ -27,12 +27,9 @@ fun Status.toViewData(
isExpanded: Boolean, isExpanded: Boolean,
isCollapsed: Boolean isCollapsed: Boolean
): StatusViewData.Concrete { ): StatusViewData.Concrete {
val visibleStatus = this.reblog ?: this
return StatusViewData.Concrete( return StatusViewData.Concrete(
status = this, status = this,
isShowingContent = isShowingContent, isShowingContent = isShowingContent,
isCollapsible = shouldTrimStatus(visibleStatus.content),
isCollapsed = isCollapsed, isCollapsed = isCollapsed,
isExpanded = isExpanded, isExpanded = isExpanded,
) )

View File

@ -15,9 +15,11 @@
package com.keylesspalace.tusky.viewdata package com.keylesspalace.tusky.viewdata
import android.os.Build import android.os.Build
import android.text.SpannableStringBuilder
import android.text.Spanned import android.text.Spanned
import com.keylesspalace.tusky.entity.Status 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. * Created by charlag on 11/07/2017.
@ -32,13 +34,6 @@ sealed class StatusViewData {
val status: Status, val status: Status,
val isExpanded: Boolean, val isExpanded: Boolean,
val isShowingContent: 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 * Specifies whether the content of this post is currently limited in visibility to the first
* 500 characters or not. * 500 characters or not.
@ -51,6 +46,14 @@ sealed class StatusViewData {
override val id: String override val id: String
get() = status.id 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 content: Spanned
val spoilerText: String val spoilerText: String
val username: String val username: String
@ -74,45 +77,17 @@ sealed class StatusViewData {
init { init {
if (Build.VERSION.SDK_INT == 23) { if (Build.VERSION.SDK_INT == 23) {
// https://github.com/tuskyapp/Tusky/issues/563 // https://github.com/tuskyapp/Tusky/issues/563
this.content = replaceCrashingCharacters(status.actionableStatus.content) this.content = replaceCrashingCharacters(status.actionableStatus.content.parseAsMastodonHtml())
this.spoilerText = this.spoilerText =
replaceCrashingCharacters(status.actionableStatus.spoilerText).toString() replaceCrashingCharacters(status.actionableStatus.spoilerText).toString()
this.username = this.username =
replaceCrashingCharacters(status.actionableStatus.account.username).toString() replaceCrashingCharacters(status.actionableStatus.account.username).toString()
} else { } else {
this.content = status.actionableStatus.content this.content = status.actionableStatus.content.parseAsMastodonHtml()
this.spoilerText = status.actionableStatus.spoilerText this.spoilerText = status.actionableStatus.spoilerText
this.username = status.actionableStatus.account.username this.username = status.actionableStatus.account.username
} }
} this.isCollapsible = shouldTrimStatus(this.content)
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
}
} }
/** Helper for Java */ /** Helper for Java */

View File

@ -15,7 +15,6 @@
package com.keylesspalace.tusky package com.keylesspalace.tusky
import android.text.SpannedString
import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.keylesspalace.tusky.entity.SearchResult import com.keylesspalace.tusky.entity.SearchResult
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
@ -70,7 +69,7 @@ class BottomSheetActivityTest {
inReplyToId = null, inReplyToId = null,
inReplyToAccountId = null, inReplyToAccountId = null,
reblog = null, reblog = null,
content = SpannedString("omgwat"), content = "omgwat",
createdAt = Date(), createdAt = Date(),
emojis = emptyList(), emojis = emptyList(),
reblogsCount = 0, reblogsCount = 0,

View File

@ -17,7 +17,6 @@ package com.keylesspalace.tusky
import android.content.Intent import android.content.Intent
import android.os.Looper.getMainLooper import android.os.Looper.getMainLooper
import android.text.SpannedString
import android.widget.EditText import android.widget.EditText
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity
@ -469,7 +468,7 @@ class ComposeActivityTest {
"admin", "admin",
"admin", "admin",
"admin", "admin",
SpannedString(""), "",
"https://example.token", "https://example.token",
"", "",
"", "",

View File

@ -1,6 +1,5 @@
package com.keylesspalace.tusky package com.keylesspalace.tusky
import android.text.SpannedString
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Filter
@ -162,7 +161,7 @@ class FilterTest {
inReplyToId = null, inReplyToId = null,
inReplyToAccountId = null, inReplyToAccountId = null,
reblog = null, reblog = null,
content = SpannedString(content), content = content,
createdAt = Date(), createdAt = Date(),
emojis = emptyList(), emojis = emptyList(),
reblogsCount = 0, reblogsCount = 0,

View File

@ -1,10 +1,8 @@
package com.keylesspalace.tusky package com.keylesspalace.tusky
import android.text.Spanned
import androidx.test.ext.junit.runners.AndroidJUnit4 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.entity.Status
import com.keylesspalace.tusky.json.SpannedTypeAdapter
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotEquals
@ -39,9 +37,7 @@ class StatusComparisonTest {
assertEquals(createStatus(note = "Test"), createStatus(note = "Test 123456")) assertEquals(createStatus(note = "Test"), createStatus(note = "Test 123456"))
} }
private val gson = GsonBuilder().registerTypeAdapter( private val gson = Gson()
Spanned::class.java, SpannedTypeAdapter()
).create()
@Test @Test
fun `two equal status view data - should be equal`() { fun `two equal status view data - should be equal`() {
@ -49,14 +45,12 @@ class StatusComparisonTest {
status = createStatus(), status = createStatus(),
isExpanded = false, isExpanded = false,
isShowingContent = false, isShowingContent = false,
isCollapsible = false,
isCollapsed = false isCollapsed = false
) )
val viewdata2 = StatusViewData.Concrete( val viewdata2 = StatusViewData.Concrete(
status = createStatus(), status = createStatus(),
isExpanded = false, isExpanded = false,
isShowingContent = false, isShowingContent = false,
isCollapsible = false,
isCollapsed = false isCollapsed = false
) )
assertEquals(viewdata1, viewdata2) assertEquals(viewdata1, viewdata2)
@ -68,14 +62,12 @@ class StatusComparisonTest {
status = createStatus(), status = createStatus(),
isExpanded = true, isExpanded = true,
isShowingContent = false, isShowingContent = false,
isCollapsible = false,
isCollapsed = false isCollapsed = false
) )
val viewdata2 = StatusViewData.Concrete( val viewdata2 = StatusViewData.Concrete(
status = createStatus(), status = createStatus(),
isExpanded = false, isExpanded = false,
isShowingContent = false, isShowingContent = false,
isCollapsible = false,
isCollapsed = false isCollapsed = false
) )
assertNotEquals(viewdata1, viewdata2) assertNotEquals(viewdata1, viewdata2)
@ -87,14 +79,12 @@ class StatusComparisonTest {
status = createStatus(content = "whatever"), status = createStatus(content = "whatever"),
isExpanded = true, isExpanded = true,
isShowingContent = false, isShowingContent = false,
isCollapsible = false,
isCollapsed = false isCollapsed = false
) )
val viewdata2 = StatusViewData.Concrete( val viewdata2 = StatusViewData.Concrete(
status = createStatus(), status = createStatus(),
isExpanded = false, isExpanded = false,
isShowingContent = false, isShowingContent = false,
isCollapsible = false,
isCollapsed = false isCollapsed = false
) )
assertNotEquals(viewdata1, viewdata2) assertNotEquals(viewdata1, viewdata2)

View File

@ -1,14 +1,19 @@
package com.keylesspalace.tusky.components.timeline package com.keylesspalace.tusky.components.timeline
import androidx.paging.PagingSource 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.NetworkTimelinePagingSource
import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.doReturn import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock import org.mockito.kotlin.mock
import org.robolectric.annotation.Config
@Config(sdk = [28])
@RunWith(AndroidJUnit4::class)
class NetworkTimelinePagingSourceTest { class NetworkTimelinePagingSourceTest {
private val status = mockStatusViewData() private val status = mockStatusViewData()

View File

@ -1,6 +1,5 @@
package com.keylesspalace.tusky.components.timeline package com.keylesspalace.tusky.components.timeline
import android.text.SpannedString
import com.google.gson.Gson import com.google.gson.Gson
import com.keylesspalace.tusky.db.TimelineStatusWithAccount import com.keylesspalace.tusky.db.TimelineStatusWithAccount
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
@ -25,7 +24,7 @@ fun mockStatus(id: String = "100") = Status(
inReplyToId = null, inReplyToId = null,
inReplyToAccountId = null, inReplyToAccountId = null,
reblog = null, reblog = null,
content = SpannedString("Test"), content = "Test",
createdAt = fixedDate, createdAt = fixedDate,
emojis = emptyList(), emojis = emptyList(),
reblogsCount = 1, reblogsCount = 1,
@ -50,7 +49,6 @@ fun mockStatusViewData(id: String = "100") = StatusViewData.Concrete(
status = mockStatus(id), status = mockStatus(id),
isExpanded = false, isExpanded = false,
isShowingContent = false, isShowingContent = false,
isCollapsible = false,
isCollapsed = true, isCollapsed = true,
) )