diff --git a/app/build.gradle b/app/build.gradle index 84ca536ac..7f2dcc4ef 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -97,6 +97,9 @@ ext.materialdrawerVersion = '8.4.1' dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-rx3:1.5.0' + implementation "androidx.core:core-ktx:1.5.0" implementation "androidx.appcompat:appcompat:1.3.0" implementation "androidx.fragment:fragment-ktx:1.3.4" @@ -114,13 +117,11 @@ dependencies { implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$lifecycleVersion" implementation "androidx.constraintlayout:constraintlayout:2.0.4" - implementation "androidx.paging:paging-runtime-ktx:2.1.2" + implementation "androidx.paging:paging-runtime-ktx:3.0.0" implementation "androidx.viewpager2:viewpager2:1.0.0" implementation "androidx.work:work-runtime:2.5.0" - implementation "androidx.room:room-runtime:$roomVersion" + implementation "androidx.room:room-ktx:$roomVersion" implementation "androidx.room:room-rxjava3:$roomVersion" - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0' - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-rx3:1.5.0' kapt "androidx.room:room-compiler:$roomVersion" implementation "com.google.android.material:material:1.3.0" diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/27.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/27.json new file mode 100644 index 000000000..c83963093 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/27.json @@ -0,0 +1,753 @@ +{ + "formatVersion": 1, + "database": { + "version": 27, + "identityHash": "be914d4eb3f406b6970fef53a925afa1", + "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, `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": "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" + ], + "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, `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": "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, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, 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": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "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 + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "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_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` 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.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsible", + "columnName": "s_collapsible", + "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, 'be914d4eb3f406b6970fef53a925afa1')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NetworkStateViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/NetworkStateViewHolder.kt index b45ca95f7..b991def5e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NetworkStateViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NetworkStateViewHolder.kt @@ -15,30 +15,28 @@ package com.keylesspalace.tusky.adapter +import androidx.paging.LoadState import androidx.recyclerview.widget.RecyclerView -import android.view.ViewGroup import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding -import com.keylesspalace.tusky.util.NetworkState -import com.keylesspalace.tusky.util.Status import com.keylesspalace.tusky.util.visible class NetworkStateViewHolder(private val binding: ItemNetworkStateBinding, private val retryCallback: () -> Unit) : RecyclerView.ViewHolder(binding.root) { - fun setUpWithNetworkState(state: NetworkState?, fullScreen: Boolean) { - binding.progressBar.visible(state?.status == Status.RUNNING) - binding.retryButton.visible(state?.status == Status.FAILED) - binding.errorMsg.visible(state?.msg != null) - binding.errorMsg.text = state?.msg + fun setUpWithNetworkState(state: LoadState) { + binding.progressBar.visible(state == LoadState.Loading) + binding.retryButton.visible(state is LoadState.Error) + val msg = if (state is LoadState.Error) { + state.error.message + } else { + null + } + binding.errorMsg.visible(msg != null) + binding.errorMsg.text = msg binding.retryButton.setOnClickListener { retryCallback() } - if(fullScreen) { - binding.root.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT - } else { - binding.root.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT - } } } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt index 376d3cd56..89c1ad0f1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt @@ -17,114 +17,39 @@ package com.keylesspalace.tusky.components.conversation import android.view.LayoutInflater import android.view.ViewGroup -import androidx.paging.AsyncPagedListDiffer -import androidx.paging.PagedList -import androidx.recyclerview.widget.AsyncDifferConfig +import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListUpdateCallback -import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.adapter.NetworkStateViewHolder -import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding import com.keylesspalace.tusky.interfaces.StatusActionListener -import com.keylesspalace.tusky.util.NetworkState import com.keylesspalace.tusky.util.StatusDisplayOptions class ConversationAdapter( - private val statusDisplayOptions: StatusDisplayOptions, - private val listener: StatusActionListener, - private val topLoadedCallback: () -> Unit, - private val retryCallback: () -> Unit -) : RecyclerView.Adapter() { + private val statusDisplayOptions: StatusDisplayOptions, + private val listener: StatusActionListener +) : PagingDataAdapter(CONVERSATION_COMPARATOR) { - private var networkState: NetworkState? = null - - private val differ: AsyncPagedListDiffer = AsyncPagedListDiffer(object : ListUpdateCallback { - override fun onInserted(position: Int, count: Int) { - notifyItemRangeInserted(position, count) - if (position == 0) { - topLoadedCallback() - } - } - - override fun onRemoved(position: Int, count: Int) { - notifyItemRangeRemoved(position, count) - } - - override fun onMoved(fromPosition: Int, toPosition: Int) { - notifyItemMoved(fromPosition, toPosition) - } - - override fun onChanged(position: Int, count: Int, payload: Any?) { - notifyItemRangeChanged(position, count, payload) - } - }, AsyncDifferConfig.Builder(CONVERSATION_COMPARATOR).build()) - - fun submitList(list: PagedList) { - differ.submitList(list) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder { + val view = LayoutInflater.from(parent.context).inflate(R.layout.item_conversation, parent, false) + return ConversationViewHolder(view, statusDisplayOptions, listener) } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return when (viewType) { - R.layout.item_network_state -> { - val binding = ItemNetworkStateBinding.inflate(LayoutInflater.from(parent.context), parent, false) - NetworkStateViewHolder(binding, retryCallback) - } - R.layout.item_conversation -> { - val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false) - ConversationViewHolder(view, statusDisplayOptions, listener) - } - else -> throw IllegalArgumentException("unknown view type $viewType") - } + override fun onBindViewHolder(holder: ConversationViewHolder, position: Int) { + holder.setupWithConversation(getItem(position)) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (getItemViewType(position)) { - R.layout.item_network_state -> (holder as NetworkStateViewHolder).setUpWithNetworkState(networkState, differ.itemCount == 0) - R.layout.item_conversation -> (holder as ConversationViewHolder).setupWithConversation(differ.getItem(position)) - } - } - - private fun hasExtraRow() = networkState != null && networkState != NetworkState.LOADED - - override fun getItemViewType(position: Int): Int { - return if (hasExtraRow() && position == itemCount - 1) { - R.layout.item_network_state - } else { - R.layout.item_conversation - } - } - - override fun getItemCount(): Int { - return differ.itemCount + if (hasExtraRow()) 1 else 0 - } - - fun setNetworkState(newNetworkState: NetworkState?) { - val previousState = this.networkState - val hadExtraRow = hasExtraRow() - this.networkState = newNetworkState - val hasExtraRow = hasExtraRow() - if (hadExtraRow != hasExtraRow) { - if (hadExtraRow) { - notifyItemRemoved(differ.itemCount) - } else { - notifyItemInserted(differ.itemCount) - } - } else if (hasExtraRow && previousState != newNetworkState) { - notifyItemChanged(itemCount - 1) - } + fun item(position: Int): ConversationEntity? { + return getItem(position) } companion object { - val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback() { - override fun areContentsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean = - oldItem == newItem + override fun areItemsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean { + return oldItem.id == newItem.id + } - override fun areItemsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean = - oldItem.id == newItem.id + override fun areContentsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean { + return oldItem == newItem + } } - } - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt index 0ecfe3b5e..7caa91144 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt @@ -1,4 +1,4 @@ -/* Copyright 2019 Conny Duck +/* Copyright 2021 Tusky Contributors * * This file is a part of Tusky. * @@ -21,65 +21,70 @@ import androidx.room.Embedded import androidx.room.Entity import androidx.room.TypeConverters import com.keylesspalace.tusky.db.Converters -import com.keylesspalace.tusky.entity.* +import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.Conversation +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.util.shouldTrimStatus -import java.util.* +import java.util.Date @Entity(primaryKeys = ["id","accountId"]) @TypeConverters(Converters::class) data class ConversationEntity( - val accountId: Long, - val id: String, - val accounts: List, - val unread: Boolean, - @Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity + val accountId: Long, + val id: String, + val accounts: List, + val unread: Boolean, + @Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity ) data class ConversationAccountEntity( - val id: String, - val username: String, - val displayName: String, - val avatar: String, - val emojis: List + val id: String, + val username: String, + val displayName: String, + val avatar: String, + val emojis: List ) { fun toAccount(): Account { return Account( - id = id, - username = username, - displayName = displayName, - avatar = avatar, - emojis = emojis, - url = "", - localUsername = "", - note = SpannedString(""), - header = "" + id = id, + username = username, + displayName = displayName, + avatar = avatar, + emojis = emojis, + url = "", + localUsername = "", + note = SpannedString(""), + header = "" ) } } @TypeConverters(Converters::class) data class ConversationStatusEntity( - val id: String, - val url: String?, - val inReplyToId: String?, - val inReplyToAccountId: String?, - val account: ConversationAccountEntity, - val content: Spanned, - val createdAt: Date, - val emojis: List, - val favouritesCount: Int, - val favourited: Boolean, - val bookmarked: Boolean, - val sensitive: Boolean, - val spoilerText: String, - val attachments: ArrayList, - val mentions: List, - val showingHiddenContent: Boolean, - val expanded: Boolean, - val collapsible: Boolean, - val collapsed: Boolean, - val poll: Poll? - + val id: String, + val url: String?, + val inReplyToId: String?, + val inReplyToAccountId: String?, + val account: ConversationAccountEntity, + val content: Spanned, + val createdAt: Date, + val emojis: List, + val favouritesCount: Int, + val favourited: Boolean, + val bookmarked: Boolean, + val sensitive: Boolean, + val spoilerText: String, + val attachments: ArrayList, + val mentions: List, + val showingHiddenContent: Boolean, + val expanded: Boolean, + val collapsible: Boolean, + val collapsed: Boolean, + val muted: Boolean, + val poll: Poll? ) { /** its necessary to override this because Spanned.equals does not work as expected */ override fun equals(other: Any?): Boolean { @@ -106,6 +111,7 @@ data class ConversationStatusEntity( if (expanded != other.expanded) return false if (collapsible != other.collapsible) return false if (collapsed != other.collapsed) return false + if (muted != other.muted) return false if (poll != other.poll) return false return true @@ -130,66 +136,79 @@ data class ConversationStatusEntity( 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, - application = null, - pinned = false, - muted = false, - poll = poll, - card = null) + 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, + application = null, + pinned = false, + muted = muted, + poll = poll, + card = null) } } fun Account.toEntity() = - ConversationAccountEntity( - id, - username, - name, - avatar, - emojis ?: emptyList() - ) + ConversationAccountEntity( + id = id, + username = username, + displayName = name, + avatar = avatar, + emojis = emojis ?: emptyList() + ) fun Status.toEntity() = - ConversationStatusEntity( - id, url, inReplyToId, inReplyToAccountId, account.toEntity(), content, - createdAt, emojis, favouritesCount, favourited, bookmarked, sensitive, - spoilerText, attachments, mentions, - false, - false, - shouldTrimStatus(content), - true, - poll - ) - + ConversationStatusEntity( + id = id, + url = url, + inReplyToId = inReplyToId, + inReplyToAccountId = inReplyToAccountId, + account = account.toEntity(), + content = content, + createdAt = createdAt, + emojis = emojis, + favouritesCount = favouritesCount, + favourited = favourited, + bookmarked = bookmarked, + sensitive = sensitive, + spoilerText = spoilerText, + attachments = attachments, + mentions = mentions, + showingHiddenContent = false, + expanded = false, + collapsible = shouldTrimStatus(content), + collapsed = true, + muted = muted ?: false, + poll = poll + ) fun Conversation.toEntity(accountId: Long) = - ConversationEntity( - accountId, - id, - accounts.map { it.toEntity() }, - unread, - lastStatus!!.toEntity() - ) + ConversationEntity( + accountId = accountId, + id = id, + accounts = accounts.map { it.toEntity() }, + unread = unread, + lastStatus = lastStatus!!.toEntity() + ) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationLoadStateAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationLoadStateAdapter.kt new file mode 100644 index 000000000..d5c0983a0 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationLoadStateAdapter.kt @@ -0,0 +1,41 @@ +/* Copyright 2021 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 . */ + +package com.keylesspalace.tusky.components.conversation + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.paging.LoadState +import androidx.paging.LoadStateAdapter +import com.keylesspalace.tusky.adapter.NetworkStateViewHolder +import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding + +class ConversationLoadStateAdapter( + private val retryCallback: () -> Unit +) : LoadStateAdapter() { + + override fun onBindViewHolder(holder: NetworkStateViewHolder, loadState: LoadState) { + holder.setUpWithNetworkState(loadState) + } + + override fun onCreateViewHolder( + parent: ViewGroup, + loadState: LoadState + ): NetworkStateViewHolder { + val binding = ItemNetworkStateBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return NetworkStateViewHolder(binding, retryCallback) + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsBoundaryCallback.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsBoundaryCallback.kt deleted file mode 100644 index 5d3590157..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsBoundaryCallback.kt +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright (C) 2017 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.keylesspalace.tusky.components.conversation - -import androidx.annotation.MainThread -import androidx.paging.PagedList -import com.keylesspalace.tusky.entity.Conversation -import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.PagingRequestHelper -import com.keylesspalace.tusky.util.createStatusLiveData -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response -import java.util.concurrent.Executor - -/** - * This boundary callback gets notified when user reaches to the edges of the list such that the - * database cannot provide any more data. - *

- * The boundary callback might be called multiple times for the same direction so it does its own - * rate limiting using the PagingRequestHelper class. - */ -class ConversationsBoundaryCallback( - private val accountId: Long, - private val mastodonApi: MastodonApi, - private val handleResponse: (Long, List?) -> Unit, - private val ioExecutor: Executor, - private val networkPageSize: Int) - : PagedList.BoundaryCallback() { - - val helper = PagingRequestHelper(ioExecutor) - val networkState = helper.createStatusLiveData() - - /** - * Database returned 0 items. We should query the backend for more items. - */ - @MainThread - override fun onZeroItemsLoaded() { - helper.runIfNotRunning(PagingRequestHelper.RequestType.INITIAL) { - mastodonApi.getConversations(null, networkPageSize) - .enqueue(createWebserviceCallback(it)) - } - } - - /** - * User reached to the end of the list. - */ - @MainThread - override fun onItemAtEndLoaded(itemAtEnd: ConversationEntity) { - helper.runIfNotRunning(PagingRequestHelper.RequestType.AFTER) { - mastodonApi.getConversations(itemAtEnd.lastStatus.id, networkPageSize) - .enqueue(createWebserviceCallback(it)) - } - } - - /** - * every time it gets new items, boundary callback simply inserts them into the database and - * paging library takes care of refreshing the list if necessary. - */ - private fun insertItemsIntoDb( - response: Response>, - it: PagingRequestHelper.Request.Callback) { - ioExecutor.execute { - handleResponse(accountId, response.body()) - it.recordSuccess() - } - } - - override fun onItemAtFrontLoaded(itemAtFront: ConversationEntity) { - // ignored, since we only ever append to what's in the DB - } - - private fun createWebserviceCallback(it: PagingRequestHelper.Request.Callback): Callback> { - return object : Callback> { - override fun onFailure(call: Call>, t: Throwable) { - it.recordFailure(t) - } - - override fun onResponse(call: Call>, response: Response>) { - insertItemsIntoDb(response, it) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt index 43f250c79..a484c6d06 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt @@ -1,4 +1,4 @@ -/* Copyright 2019 Conny Duck +/* Copyright 2021 Tusky Contributors * * This file is a part of Tusky. * @@ -20,7 +20,12 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.PopupMenu import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.paging.ExperimentalPagingApi +import androidx.paging.LoadState import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager @@ -35,8 +40,11 @@ import com.keylesspalace.tusky.fragment.SFragment import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.* +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import java.io.IOException import com.keylesspalace.tusky.util.CardViewMode -import com.keylesspalace.tusky.util.NetworkState import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.viewBinding @@ -53,34 +61,39 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res private val binding by viewBinding(FragmentTimelineBinding::bind) private lateinit var adapter: ConversationAdapter + private lateinit var loadStateAdapter: ConversationLoadStateAdapter private var layoutManager: LinearLayoutManager? = null + private var initialRefreshDone: Boolean = false + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { return inflater.inflate(R.layout.fragment_timeline, container, false) } + @ExperimentalPagingApi override fun onViewCreated(view: View, savedInstanceState: Bundle?) { val preferences = PreferenceManager.getDefaultSharedPreferences(view.context) val statusDisplayOptions = StatusDisplayOptions( - animateAvatars = preferences.getBoolean("animateGifAvatars", false), - mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true, - useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false), - showBotOverlay = preferences.getBoolean("showBotOverlay", true), - useBlurhash = preferences.getBoolean("useBlurhash", true), - cardViewMode = CardViewMode.NONE, - confirmReblogs = preferences.getBoolean("confirmReblogs", true), - hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), - animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) + animateAvatars = preferences.getBoolean("animateGifAvatars", false), + mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true, + useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false), + showBotOverlay = preferences.getBoolean("showBotOverlay", true), + useBlurhash = preferences.getBoolean("useBlurhash", true), + cardViewMode = CardViewMode.NONE, + confirmReblogs = preferences.getBoolean("confirmReblogs", true), + hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) ) - adapter = ConversationAdapter(statusDisplayOptions, this, ::onTopLoaded, viewModel::retry) + adapter = ConversationAdapter(statusDisplayOptions, this) + loadStateAdapter = ConversationLoadStateAdapter(adapter::retry) binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) layoutManager = LinearLayoutManager(view.context) binding.recyclerView.layoutManager = layoutManager - binding.recyclerView.adapter = adapter + binding.recyclerView.adapter = adapter.withLoadStateFooter(loadStateAdapter) (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false binding.progressBar.hide() @@ -88,59 +101,101 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res initSwipeToRefresh() - viewModel.conversations.observe(viewLifecycleOwner) { - adapter.submitList(it) - } - viewModel.networkState.observe(viewLifecycleOwner) { - adapter.setNetworkState(it) + lifecycleScope.launch { + viewModel.conversationFlow.collectLatest { pagingData -> + adapter.submitData(pagingData) + } } - viewModel.load() + adapter.addLoadStateListener { loadStates -> + loadStates.refresh.let { refreshState -> + if (refreshState is LoadState.Error) { + binding.statusView.show() + if (refreshState.error is IOException) { + binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) { + adapter.refresh() + } + } else { + binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) { + adapter.refresh() + } + } + } else { + binding.statusView.hide() + } + + binding.progressBar.visible(refreshState == LoadState.Loading && adapter.itemCount == 0) + + if (refreshState is LoadState.NotLoading && !initialRefreshDone) { + // jump to top after the initial refresh finished + binding.recyclerView.scrollToPosition(0) + initialRefreshDone = true + } + + if (refreshState != LoadState.Loading) { + binding.swipeRefreshLayout.isRefreshing = false + } + } + } } private fun initSwipeToRefresh() { - viewModel.refreshState.observe(viewLifecycleOwner) { - binding.swipeRefreshLayout.isRefreshing = it == NetworkState.LOADING - } binding.swipeRefreshLayout.setOnRefreshListener { - viewModel.refresh() + adapter.refresh() } binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) } - private fun onTopLoaded() { - binding.recyclerView.scrollToPosition(0) - } - override fun onReblog(reblog: Boolean, position: Int) { // its impossible to reblog private messages } override fun onFavourite(favourite: Boolean, position: Int) { - viewModel.favourite(favourite, position) + adapter.item(position)?.let { conversation -> + viewModel.favourite(favourite, conversation) + } } override fun onBookmark(favourite: Boolean, position: Int) { - viewModel.bookmark(favourite, position) + adapter.item(position)?.let { conversation -> + viewModel.bookmark(favourite, conversation) + } } override fun onMore(view: View, position: Int) { - viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { - more(it.toStatus(), view, position) + adapter.item(position)?.let { conversation -> + + val popup = PopupMenu(requireContext(), view) + popup.inflate(R.menu.conversation_more) + + if (conversation.lastStatus.muted) { + popup.menu.removeItem(R.id.status_mute_conversation) + } else { + popup.menu.removeItem(R.id.status_unmute_conversation) + } + + popup.setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.status_mute_conversation -> viewModel.muteConversation(conversation) + R.id.status_unmute_conversation -> viewModel.muteConversation(conversation) + R.id.conversation_delete -> deleteConversation(conversation) + } + true + } + popup.show() } } override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { - viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { - viewMedia(attachmentIndex, AttachmentViewData.list(it.toStatus()), view) + adapter.item(position)?.let { conversation -> + viewMedia(attachmentIndex, AttachmentViewData.list(conversation.lastStatus.toStatus()), view) } } override fun onViewThread(position: Int) { - viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { - val status = it.toStatus() - viewThread(status.actionableId, status.actionableStatus.url) + adapter.item(position)?.let { conversation -> + viewThread(conversation.lastStatus.id, conversation.lastStatus.url) } } @@ -149,11 +204,15 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } override fun onExpandedChange(expanded: Boolean, position: Int) { - viewModel.expandHiddenStatus(expanded, position) + adapter.item(position)?.let { conversation -> + viewModel.expandHiddenStatus(expanded, conversation) + } } override fun onContentHiddenChange(isShowing: Boolean, position: Int) { - viewModel.showContent(isShowing, position) + adapter.item(position)?.let { conversation -> + viewModel.showContent(isShowing, conversation) + } } override fun onLoadMore(position: Int) { @@ -161,7 +220,9 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { - viewModel.collapseLongStatus(isCollapsed, position) + adapter.item(position)?.let { conversation -> + viewModel.collapseLongStatus(isCollapsed, conversation) + } } override fun onViewAccount(id: String) { @@ -176,15 +237,25 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } override fun removeItem(position: Int) { - viewModel.remove(position) + // not needed } override fun onReply(position: Int) { - viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { - reply(it.toStatus()) + adapter.item(position)?.let { conversation -> + reply(conversation.lastStatus.toStatus()) } } + private fun deleteConversation(conversation: ConversationEntity) { + AlertDialog.Builder(requireContext()) + .setMessage(R.string.dialog_delete_conversation_warning) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok) { _, _ -> + viewModel.remove(conversation) + } + .show() + } + private fun jumpToTop() { if (isAdded) { layoutManager?.scrollToPosition(0) @@ -197,7 +268,9 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } override fun onVoteInPoll(position: Int, choices: MutableList) { - viewModel.voteInPoll(position, choices) + adapter.item(position)?.let { conversation -> + viewModel.voteInPoll(choices, conversation) + } } companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt new file mode 100644 index 000000000..7418c3b08 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt @@ -0,0 +1,51 @@ +package com.keylesspalace.tusky.components.conversation + +import androidx.paging.ExperimentalPagingApi +import androidx.paging.LoadType +import androidx.paging.PagingState +import androidx.paging.RemoteMediator +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.network.MastodonApi + +@ExperimentalPagingApi +class ConversationsRemoteMediator( + private val accountId: Long, + private val api: MastodonApi, + private val db: AppDatabase +) : RemoteMediator() { + + override suspend fun load( + loadType: LoadType, + state: PagingState + ): MediatorResult { + + try { + val conversationsResult = when (loadType) { + LoadType.REFRESH -> { + api.getConversations(limit = state.config.initialLoadSize) + } + LoadType.PREPEND -> { + return MediatorResult.Success(endOfPaginationReached = true) + } + LoadType.APPEND -> { + val maxId = state.pages.findLast { it.data.isNotEmpty() }?.data?.lastOrNull()?.lastStatus?.id + api.getConversations(maxId = maxId, limit = state.config.pageSize) + } + } + + if (loadType == LoadType.REFRESH) { + db.conversationDao().deleteForAccount(accountId) + } + db.conversationDao().insert( + conversationsResult + .filterNot { it.lastStatus == null } + .map { it.toEntity(accountId) } + ) + return MediatorResult.Success(endOfPaginationReached = conversationsResult.isEmpty()) + } catch (e: Exception) { + return MediatorResult.Error(e) + } + } + + override suspend fun initialize() = InitializeAction.LAUNCH_INITIAL_REFRESH +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRepository.kt index e3703cbfe..2156b0189 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRepository.kt @@ -1,99 +1,32 @@ +/* Copyright 2021 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 . */ + package com.keylesspalace.tusky.components.conversation -import androidx.annotation.MainThread -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Transformations -import androidx.paging.Config -import androidx.paging.toLiveData import com.keylesspalace.tusky.db.AppDatabase -import com.keylesspalace.tusky.entity.Conversation import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.Listing -import com.keylesspalace.tusky.util.NetworkState import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response -import java.util.concurrent.Executors import javax.inject.Inject import javax.inject.Singleton @Singleton -class ConversationsRepository @Inject constructor(val mastodonApi: MastodonApi, val db: AppDatabase) { - - private val ioExecutor = Executors.newSingleThreadExecutor() - - companion object { - private const val DEFAULT_PAGE_SIZE = 20 - } - - @MainThread - fun refresh(accountId: Long, showLoadingIndicator: Boolean): LiveData { - val networkState = MutableLiveData() - if(showLoadingIndicator) { - networkState.value = NetworkState.LOADING - } - - mastodonApi.getConversations(limit = DEFAULT_PAGE_SIZE).enqueue( - object : Callback> { - override fun onFailure(call: Call>, t: Throwable) { - // retrofit calls this on main thread so safe to call set value - networkState.value = NetworkState.error(t.message) - } - - override fun onResponse(call: Call>, response: Response>) { - ioExecutor.execute { - db.runInTransaction { - db.conversationDao().deleteForAccount(accountId) - insertResultIntoDb(accountId, response.body()) - } - // since we are in bg thread now, post the result. - networkState.postValue(NetworkState.LOADED) - } - } - } - ) - return networkState - } - - @MainThread - fun conversations(accountId: Long): Listing { - // create a boundary callback which will observe when the user reaches to the edges of - // the list and update the database with extra data. - val boundaryCallback = ConversationsBoundaryCallback( - accountId = accountId, - mastodonApi = mastodonApi, - handleResponse = this::insertResultIntoDb, - ioExecutor = ioExecutor, - networkPageSize = DEFAULT_PAGE_SIZE) - // we are using a mutable live data to trigger refresh requests which eventually calls - // refresh method and gets a new live data. Each refresh request by the user becomes a newly - // dispatched data in refreshTrigger - val refreshTrigger = MutableLiveData() - val refreshState = Transformations.switchMap(refreshTrigger) { - refresh(accountId, true) - } - - // We use toLiveData Kotlin extension function here, you could also use LivePagedListBuilder - val livePagedList = db.conversationDao().conversationsForAccount(accountId).toLiveData( - config = Config(pageSize = DEFAULT_PAGE_SIZE, prefetchDistance = DEFAULT_PAGE_SIZE / 2, enablePlaceholders = false), - boundaryCallback = boundaryCallback - ) - - return Listing( - pagedList = livePagedList, - networkState = boundaryCallback.networkState, - retry = { - boundaryCallback.helper.retryAllFailed() - }, - refresh = { - refreshTrigger.value = null - }, - refreshState = refreshState - ) - } +class ConversationsRepository @Inject constructor( + val mastodonApi: MastodonApi, + val db: AppDatabase +) { fun deleteCacheForAccount(accountId: Long) { Single.fromCallable { @@ -102,10 +35,4 @@ class ConversationsRepository @Inject constructor(val mastodonApi: MastodonApi, .subscribe() } - private fun insertResultIntoDb(accountId: Long, result: List?) { - result?.filter { it.lastStatus != null } - ?.map{ it.toEntity(accountId) } - ?.let { db.conversationDao().insert(it) } - - } } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt index 5f2b9cdb8..eafdbdf27 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt @@ -1,129 +1,100 @@ +/* Copyright 2021 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 . */ + package com.keylesspalace.tusky.components.conversation import android.util.Log -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Transformations -import androidx.paging.PagedList +import androidx.lifecycle.viewModelScope +import androidx.paging.ExperimentalPagingApi +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.cachedIn import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.TimelineCases -import com.keylesspalace.tusky.util.Listing -import com.keylesspalace.tusky.util.NetworkState import com.keylesspalace.tusky.util.RxAwareViewModel -import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.launch +import kotlinx.coroutines.rx3.await import javax.inject.Inject class ConversationsViewModel @Inject constructor( - private val repository: ConversationsRepository, private val timelineCases: TimelineCases, private val database: AppDatabase, - private val accountManager: AccountManager + private val accountManager: AccountManager, + private val api: MastodonApi ) : RxAwareViewModel() { - private val repoResult = MutableLiveData>() + @ExperimentalPagingApi + val conversationFlow = Pager( + config = PagingConfig(pageSize = 10, enablePlaceholders = false, initialLoadSize = 20), + remoteMediator = ConversationsRemoteMediator(accountManager.activeAccount!!.id, api, database), + pagingSourceFactory = { database.conversationDao().conversationsForAccount(accountManager.activeAccount!!.id) } + ) + .flow + .cachedIn(viewModelScope) - val conversations: LiveData> = - Transformations.switchMap(repoResult) { it.pagedList } - val networkState: LiveData = - Transformations.switchMap(repoResult) { it.networkState } - val refreshState: LiveData = - Transformations.switchMap(repoResult) { it.refreshState } + fun favourite(favourite: Boolean, conversation: ConversationEntity) { + viewModelScope.launch { + try { + timelineCases.favourite(conversation.lastStatus.id, favourite).await() - fun load() { - val accountId = accountManager.activeAccount?.id ?: return - if (repoResult.value == null) { - repository.refresh(accountId, false) + val newConversation = conversation.copy( + lastStatus = conversation.lastStatus.copy(favourited = favourite) + ) + + database.conversationDao().insert(newConversation) + } catch (e: Exception) { + Log.w(TAG, "failed to favourite status", e) + } } - repoResult.value = repository.conversations(accountId) } - fun refresh() { - repoResult.value?.refresh?.invoke() - } + fun bookmark(bookmark: Boolean, conversation: ConversationEntity) { + viewModelScope.launch { + try { + timelineCases.bookmark(conversation.lastStatus.id, bookmark).await() - fun retry() { - repoResult.value?.retry?.invoke() - } + val newConversation = conversation.copy( + lastStatus = conversation.lastStatus.copy(bookmarked = bookmark) + ) - fun favourite(favourite: Boolean, position: Int) { - conversations.value?.getOrNull(position)?.let { conversation -> - timelineCases.favourite(conversation.lastStatus.id, favourite) - .flatMap { - val newConversation = conversation.copy( - lastStatus = conversation.lastStatus.copy(favourited = favourite) - ) - - database.conversationDao().insert(newConversation) - } - .subscribeOn(Schedulers.io()) - .doOnError { t -> - Log.w( - "ConversationViewModel", - "Failed to favourite conversation", - t - ) - } - .onErrorReturnItem(0) - .subscribe() - .autoDispose() + database.conversationDao().insert(newConversation) + } catch (e: Exception) { + Log.w(TAG, "failed to bookmark status", e) + } } - } - fun bookmark(bookmark: Boolean, position: Int) { - conversations.value?.getOrNull(position)?.let { conversation -> - timelineCases.bookmark(conversation.lastStatus.id, bookmark) - .flatMap { - val newConversation = conversation.copy( - lastStatus = conversation.lastStatus.copy(bookmarked = bookmark) - ) + fun voteInPoll(choices: List, conversation: ConversationEntity) { + viewModelScope.launch { + try { + val poll = timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.poll?.id!!, choices).await() + val newConversation = conversation.copy( + lastStatus = conversation.lastStatus.copy(poll = poll) + ) - database.conversationDao().insert(newConversation) - } - .subscribeOn(Schedulers.io()) - .doOnError { t -> - Log.w( - "ConversationViewModel", - "Failed to bookmark conversation", - t - ) - } - .onErrorReturnItem(0) - .subscribe() - .autoDispose() + database.conversationDao().insert(newConversation) + } catch (e: Exception) { + Log.w(TAG, "failed to vote in poll", e) + } } - } - fun voteInPoll(position: Int, choices: MutableList) { - conversations.value?.getOrNull(position)?.let { conversation -> - val poll = conversation.lastStatus.poll ?: return - timelineCases.voteInPoll(conversation.lastStatus.id, poll.id, choices) - .flatMap { newPoll -> - val newConversation = conversation.copy( - lastStatus = conversation.lastStatus.copy(poll = newPoll) - ) - - database.conversationDao().insert(newConversation) - } - .subscribeOn(Schedulers.io()) - .doOnError { t -> - Log.w( - "ConversationViewModel", - "Failed to favourite conversation", - t - ) - } - .onErrorReturnItem(0) - .subscribe() - .autoDispose() - } - - } - - fun expandHiddenStatus(expanded: Boolean, position: Int) { - conversations.value?.getOrNull(position)?.let { conversation -> + fun expandHiddenStatus(expanded: Boolean, conversation: ConversationEntity) { + viewModelScope.launch { val newConversation = conversation.copy( lastStatus = conversation.lastStatus.copy(expanded = expanded) ) @@ -131,8 +102,8 @@ class ConversationsViewModel @Inject constructor( } } - fun collapseLongStatus(collapsed: Boolean, position: Int) { - conversations.value?.getOrNull(position)?.let { conversation -> + fun collapseLongStatus(collapsed: Boolean, conversation: ConversationEntity) { + viewModelScope.launch { val newConversation = conversation.copy( lastStatus = conversation.lastStatus.copy(collapsed = collapsed) ) @@ -140,8 +111,8 @@ class ConversationsViewModel @Inject constructor( } } - fun showContent(showing: Boolean, position: Int) { - conversations.value?.getOrNull(position)?.let { conversation -> + fun showContent(showing: Boolean, conversation: ConversationEntity) { + viewModelScope.launch { val newConversation = conversation.copy( lastStatus = conversation.lastStatus.copy(showingHiddenContent = showing) ) @@ -149,16 +120,42 @@ class ConversationsViewModel @Inject constructor( } } - fun remove(position: Int) { - conversations.value?.getOrNull(position)?.let { - refresh() + fun remove(conversation: ConversationEntity) { + viewModelScope.launch { + try { + api.deleteConversation(conversationId = conversation.id) + + database.conversationDao().delete(conversation) + } catch (e: Exception) { + Log.w(TAG, "failed to delete conversation", e) + } } } - private fun saveConversationToDb(conversation: ConversationEntity) { - database.conversationDao().insert(conversation) - .subscribeOn(Schedulers.io()) - .subscribe() + fun muteConversation(conversation: ConversationEntity) { + viewModelScope.launch { + try { + val newStatus = timelineCases.muteConversation( + conversation.lastStatus.id, + !conversation.lastStatus.muted + ).await() + + val newConversation = conversation.copy( + lastStatus = newStatus.toEntity() + ) + + database.conversationDao().insert(newConversation) + } catch (e: Exception) { + Log.w(TAG, "failed to mute conversation", e) + } + } } + suspend fun saveConversationToDb(conversation: ConversationEntity) { + database.conversationDao().insert(conversation) + } + + companion object { + private const val TAG = "ConversationsViewModel" + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchType.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchType.kt index 5df657449..df98b9ab0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchType.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchType.kt @@ -1,3 +1,18 @@ +/* Copyright 2021 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 . */ + package com.keylesspalace.tusky.components.search enum class SearchType(val apiParameter: String) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt index 2fdb76293..4ec51413b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt @@ -1,17 +1,35 @@ +/* Copyright 2021 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 . */ + package com.keylesspalace.tusky.components.search import android.util.Log -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.paging.PagedList -import com.keylesspalace.tusky.components.search.adapter.SearchRepository +import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.cachedIn +import com.keylesspalace.tusky.components.search.adapter.SearchPagingSourceFactory import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.entity.* +import com.keylesspalace.tusky.entity.DeletedStatus +import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.TimelineCases -import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.util.RxAwareViewModel +import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Single @@ -35,82 +53,62 @@ class SearchViewModel @Inject constructor( val alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false val alwaysOpenSpoiler = activeAccount?.alwaysOpenSpoiler ?: false - private val statusesRepository = - SearchRepository>(mastodonApi) - private val accountsRepository = SearchRepository(mastodonApi) - private val hashtagsRepository = SearchRepository(mastodonApi) + private val loadedStatuses: MutableList> = mutableListOf() - private val repoResultStatus = MutableLiveData>>() - val statuses: LiveData>> = - repoResultStatus.switchMap { it.pagedList } - val networkStateStatus: LiveData = repoResultStatus.switchMap { it.networkState } - val networkStateStatusRefresh: LiveData = - repoResultStatus.switchMap { it.refreshState } + private val statusesPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Status, loadedStatuses) { + it.statuses.map { status -> Pair(status, status.toViewData(alwaysShowSensitiveMedia, alwaysOpenSpoiler)) } + .apply { + loadedStatuses.addAll(this) + } + } + private val accountsPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Account) { + it.accounts + } + private val hashtagsPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Hashtag) { + it.hashtags + } - private val repoResultAccount = MutableLiveData>() - val accounts: LiveData> = repoResultAccount.switchMap { it.pagedList } - val networkStateAccount: LiveData = - repoResultAccount.switchMap { it.networkState } - val networkStateAccountRefresh: LiveData = - repoResultAccount.switchMap { it.refreshState } + val statusesFlow = Pager( + config = PagingConfig(pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE), + pagingSourceFactory = statusesPagingSourceFactory + ).flow + .cachedIn(viewModelScope) - private val repoResultHashTag = MutableLiveData>() - val hashtags: LiveData> = repoResultHashTag.switchMap { it.pagedList } - val networkStateHashTag: LiveData = - repoResultHashTag.switchMap { it.networkState } - val networkStateHashTagRefresh: LiveData = - repoResultHashTag.switchMap { it.refreshState } + val accountsFlow = Pager( + config = PagingConfig(pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE), + pagingSourceFactory = accountsPagingSourceFactory + ).flow + .cachedIn(viewModelScope) + + val hashtagsFlow = Pager( + config = PagingConfig(pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE), + pagingSourceFactory = hashtagsPagingSourceFactory + ).flow + .cachedIn(viewModelScope) - private val loadedStatuses = ArrayList>() fun search(query: String) { loadedStatuses.clear() - repoResultStatus.value = statusesRepository.getSearchData( - SearchType.Status, - query, - disposables, - initialItems = loadedStatuses - ) { - it?.statuses?.map { status -> - Pair( - status, - status.toViewData(alwaysShowSensitiveMedia, alwaysOpenSpoiler) - ) - } - .orEmpty() - .apply { - loadedStatuses.addAll(this) - } - } - repoResultAccount.value = - accountsRepository.getSearchData(SearchType.Account, query, disposables) { - it?.accounts.orEmpty() - } - val hashtagQuery = if (query.startsWith("#")) query else "#$query" - repoResultHashTag.value = - hashtagsRepository.getSearchData(SearchType.Hashtag, hashtagQuery, disposables) { - it?.hashtags.orEmpty() - } - + statusesPagingSourceFactory.newSearch(query) + accountsPagingSourceFactory.newSearch(query) + hashtagsPagingSourceFactory.newSearch(query) } fun removeItem(status: Pair) { timelineCases.delete(status.first.id) - .subscribe({ - if (loadedStatuses.remove(status)) - repoResultStatus.value?.refresh?.invoke() - }, { err -> - Log.d(TAG, "Failed to delete status", err) - }) - .autoDispose() - + .subscribe({ + if (loadedStatuses.remove(status)) + statusesPagingSourceFactory.invalidate() + }, { + err -> Log.d(TAG, "Failed to delete status", err) + }) + .autoDispose() } fun expandedChange(status: Pair, expanded: Boolean) { val idx = loadedStatuses.indexOf(status) if (idx >= 0) { - val newPair = Pair(status.first, status.second.copy(isExpanded = expanded)) - loadedStatuses[idx] = newPair - repoResultStatus.value?.refresh?.invoke() + loadedStatuses[idx] = Pair(status.first, status.second.copy(isExpanded = expanded)) + statusesPagingSourceFactory.invalidate() } } @@ -119,36 +117,30 @@ class SearchViewModel @Inject constructor( .observeOn(AndroidSchedulers.mainThread()) .subscribe( { setRebloggedForStatus(status, reblog) }, - { err -> Log.d(TAG, "Failed to reblog status ${status.first.id}", err) } + { t -> Log.d(TAG, "Failed to reblog status ${status.first.id}", t) } ) .autoDispose() } - private fun setRebloggedForStatus( - status: Pair, - reblog: Boolean - ) { + private fun setRebloggedForStatus(status: Pair, reblog: Boolean) { status.first.reblogged = reblog status.first.reblog?.reblogged = reblog - - repoResultStatus.value?.refresh?.invoke() + statusesPagingSourceFactory.invalidate() } fun contentHiddenChange(status: Pair, isShowing: Boolean) { val idx = loadedStatuses.indexOf(status) if (idx >= 0) { - val newPair = Pair(status.first, status.second.copy(isShowingContent = isShowing)) - loadedStatuses[idx] = newPair - repoResultStatus.value?.refresh?.invoke() + loadedStatuses[idx] = Pair(status.first, status.second.copy(isShowingContent = isShowing)) + statusesPagingSourceFactory.invalidate() } } fun collapsedChange(status: Pair, collapsed: Boolean) { val idx = loadedStatuses.indexOf(status) if (idx >= 0) { - val newPair = Pair(status.first, status.second.copy(isCollapsed = collapsed)) - loadedStatuses[idx] = newPair - repoResultStatus.value?.refresh?.invoke() + loadedStatuses[idx] = Pair(status.first, status.second.copy(isCollapsed = collapsed)) + statusesPagingSourceFactory.invalidate() } } @@ -159,12 +151,7 @@ class SearchViewModel @Inject constructor( .observeOn(AndroidSchedulers.mainThread()) .subscribe( { newPoll -> updateStatus(status, newPoll) }, - { t -> - Log.d( - TAG, - "Failed to vote in poll: ${status.first.id}", t - ) - } + { t -> Log.d(TAG, "Failed to vote in poll: ${status.first.id}", t) } ) .autoDispose() } @@ -175,13 +162,13 @@ class SearchViewModel @Inject constructor( val newStatus = status.first.copy(poll = newPoll) val newViewData = status.second.copy(status = newStatus) loadedStatuses[idx] = Pair(newStatus, newViewData) - repoResultStatus.value?.refresh?.invoke() + statusesPagingSourceFactory.invalidate() } } fun favorite(status: Pair, isFavorited: Boolean) { status.first.favourited = isFavorited - repoResultStatus.value?.refresh?.invoke() + statusesPagingSourceFactory.invalidate() timelineCases.favourite(status.first.id, isFavorited) .onErrorReturnItem(status.first) .subscribe() @@ -190,7 +177,7 @@ class SearchViewModel @Inject constructor( fun bookmark(status: Pair, isBookmarked: Boolean) { status.first.bookmarked = isBookmarked - repoResultStatus.value?.refresh?.invoke() + statusesPagingSourceFactory.invalidate() timelineCases.bookmark(status.first.id, isBookmarked) .onErrorReturnItem(status.first) .subscribe() @@ -217,10 +204,6 @@ class SearchViewModel @Inject constructor( return timelineCases.delete(id) } - fun retryAllSearches() { - search(currentQuery) - } - fun muteConversation(status: Pair, mute: Boolean) { val idx = loadedStatuses.indexOf(status) if (idx >= 0) { @@ -230,7 +213,7 @@ class SearchViewModel @Inject constructor( status.second.copy(status = newStatus) ) loadedStatuses[idx] = newPair - repoResultStatus.value?.refresh?.invoke() + statusesPagingSourceFactory.invalidate() } timelineCases.muteConversation(status.first.id, mute) .onErrorReturnItem(status.first) @@ -240,5 +223,6 @@ class SearchViewModel @Inject constructor( companion object { private const val TAG = "SearchViewModel" + private const val DEFAULT_LOAD_SIZE = 20 } } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchAccountsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchAccountsAdapter.kt index b6bc95681..7056d5e29 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchAccountsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchAccountsAdapter.kt @@ -1,4 +1,4 @@ -/* Copyright 2019 Joel Pyska +/* Copyright 2021 Tusky Contributors * * This file is a part of Tusky. * @@ -17,26 +17,25 @@ package com.keylesspalace.tusky.components.search.adapter import android.view.LayoutInflater import android.view.ViewGroup -import androidx.paging.PagedListAdapter +import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.AccountViewHolder import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.interfaces.LinkListener class SearchAccountsAdapter(private val linkListener: LinkListener, private val animateAvatars: Boolean, private val animateEmojis: Boolean) - : PagedListAdapter(ACCOUNT_COMPARATOR) { + : PagingDataAdapter(ACCOUNT_COMPARATOR) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountViewHolder { val view = LayoutInflater.from(parent.context) .inflate(R.layout.item_account, parent, false) return AccountViewHolder(view) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + override fun onBindViewHolder(holder: AccountViewHolder, position: Int) { getItem(position)?.let { item -> - (holder as AccountViewHolder).apply { + holder.apply { setupWithAccount(item, animateAvatars, animateEmojis) setupLinkListener(linkListener) } @@ -52,7 +51,5 @@ class SearchAccountsAdapter(private val linkListener: LinkListener, private val override fun areItemsTheSame(oldItem: Account, newItem: Account): Boolean = oldItem.id == newItem.id } - } - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchDataSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchDataSource.kt deleted file mode 100644 index a1ed9454a..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchDataSource.kt +++ /dev/null @@ -1,126 +0,0 @@ -/* Copyright 2019 Joel Pyska - * - * 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 . */ - -package com.keylesspalace.tusky.components.search.adapter - -import androidx.lifecycle.MutableLiveData -import androidx.paging.PositionalDataSource -import com.keylesspalace.tusky.components.search.SearchType -import com.keylesspalace.tusky.entity.SearchResult -import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.NetworkState -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.kotlin.addTo -import java.util.concurrent.Executor - -class SearchDataSource( - private val mastodonApi: MastodonApi, - private val searchType: SearchType, - private val searchRequest: String, - private val disposables: CompositeDisposable, - private val retryExecutor: Executor, - private val initialItems: List? = null, - private val parser: (SearchResult?) -> List, - private val source: SearchDataSourceFactory) : PositionalDataSource() { - - val networkState = MutableLiveData() - - private var retry: (() -> Any)? = null - - val initialLoad = MutableLiveData() - - fun retry() { - retry?.let { - retryExecutor.execute { - it.invoke() - } - } - } - - override fun loadInitial(params: LoadInitialParams, callback: LoadInitialCallback) { - if (!initialItems.isNullOrEmpty()) { - callback.onResult(initialItems.toList(), 0) - } else { - networkState.postValue(NetworkState.LOADED) - retry = null - initialLoad.postValue(NetworkState.LOADING) - mastodonApi.searchObservable( - query = searchRequest, - type = searchType.apiParameter, - resolve = true, - limit = params.requestedLoadSize, - offset = 0, - following = false) - .subscribe( - { data -> - val res = parser(data) - callback.onResult(res, params.requestedStartPosition) - initialLoad.postValue(NetworkState.LOADED) - - }, - { error -> - retry = { - loadInitial(params, callback) - } - initialLoad.postValue(NetworkState.error(error.message)) - } - ).addTo(disposables) - } - - } - - override fun loadRange(params: LoadRangeParams, callback: LoadRangeCallback) { - networkState.postValue(NetworkState.LOADING) - retry = null - if (source.exhausted) { - return callback.onResult(emptyList()) - } - mastodonApi.searchObservable( - query = searchRequest, - type = searchType.apiParameter, - resolve = true, - limit = params.loadSize, - offset = params.startPosition, - following = false) - .subscribe( - { data -> - // Working around Mastodon bug where exact match is returned no matter - // which offset is requested (so if we search for a full username, it's - // infinite) - // see https://github.com/tootsuite/mastodon/issues/11365 - // see https://github.com/tootsuite/mastodon/issues/13083 - val res = if ((data.accounts.size == 1 && data.accounts[0].username.equals(searchRequest, ignoreCase = true)) - || (data.statuses.size == 1 && data.statuses[0].url.equals(searchRequest))) { - listOf() - } else { - parser(data) - } - if (res.isEmpty()) { - source.exhausted = true - } - callback.onResult(res) - networkState.postValue(NetworkState.LOADED) - }, - { error -> - retry = { - loadRange(params, callback) - } - networkState.postValue(NetworkState.error(error.message)) - } - ).addTo(disposables) - - - } -} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchHashtagsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchHashtagsAdapter.kt index ebc021602..cf7e7c7c5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchHashtagsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchHashtagsAdapter.kt @@ -1,4 +1,4 @@ -/* Copyright 2019 Joel Pyska +/* Copyright 2021 Tusky Contributors * * This file is a part of Tusky. * @@ -17,7 +17,7 @@ package com.keylesspalace.tusky.components.search.adapter import android.view.LayoutInflater import android.view.ViewGroup -import androidx.paging.PagedListAdapter +import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import com.keylesspalace.tusky.databinding.ItemHashtagBinding import com.keylesspalace.tusky.entity.HashTag @@ -25,7 +25,7 @@ import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.util.BindingHolder class SearchHashtagsAdapter(private val linkListener: LinkListener) - : PagedListAdapter>(HASHTAG_COMPARATOR) { + : PagingDataAdapter>(HASHTAG_COMPARATOR) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { val binding = ItemHashtagBinding.inflate(LayoutInflater.from(parent.context), parent, false) @@ -48,7 +48,5 @@ class SearchHashtagsAdapter(private val linkListener: LinkListener) override fun areItemsTheSame(oldItem: HashTag, newItem: HashTag): Boolean = oldItem.name == newItem.name } - } - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagingSource.kt new file mode 100644 index 000000000..315edba69 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagingSource.kt @@ -0,0 +1,83 @@ +/* Copyright 2021 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 . */ + +package com.keylesspalace.tusky.components.search.adapter + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.keylesspalace.tusky.components.search.SearchType +import com.keylesspalace.tusky.entity.SearchResult +import com.keylesspalace.tusky.network.MastodonApi +import kotlinx.coroutines.rx3.await + +class SearchPagingSource( + private val mastodonApi: MastodonApi, + private val searchType: SearchType, + private val searchRequest: String, + private val initialItems: List?, + private val parser: (SearchResult) -> List) : PagingSource() { + + override fun getRefreshKey(state: PagingState): Int? { + return null + } + + override suspend fun load(params: LoadParams): LoadResult { + if (searchRequest.isEmpty()) { + return LoadResult.Page( + data = emptyList(), + prevKey = null, + nextKey = null + ) + } + + if (params.key == null && !initialItems.isNullOrEmpty()) { + return LoadResult.Page( + data = initialItems.toList(), + prevKey = null, + nextKey = initialItems.size + ) + } + + val currentKey = params.key ?: 0 + + try { + + val data = mastodonApi.searchObservable( + query = searchRequest, + type = searchType.apiParameter, + resolve = true, + limit = params.loadSize, + offset = currentKey, + following = false + ).await() + + val res = parser(data) + + val nextKey = if (res.isEmpty()) { + null + } else { + currentKey + res.size + } + + return LoadResult.Page( + data = res, + prevKey = null, + nextKey = nextKey + ) + } catch (e: Exception) { + return LoadResult.Error(e) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchDataSourceFactory.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagingSourceFactory.kt similarity index 50% rename from app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchDataSourceFactory.kt rename to app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagingSourceFactory.kt index b19976706..fb3760ca4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchDataSourceFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagingSourceFactory.kt @@ -1,4 +1,4 @@ -/* Copyright 2019 Joel Pyska +/* Copyright 2021 Tusky Contributors * * This file is a part of Tusky. * @@ -15,30 +15,39 @@ package com.keylesspalace.tusky.components.search.adapter -import androidx.lifecycle.MutableLiveData -import androidx.paging.DataSource import com.keylesspalace.tusky.components.search.SearchType import com.keylesspalace.tusky.entity.SearchResult import com.keylesspalace.tusky.network.MastodonApi -import io.reactivex.rxjava3.disposables.CompositeDisposable -import java.util.concurrent.Executor -class SearchDataSourceFactory( - private val mastodonApi: MastodonApi, - private val searchType: SearchType, - private val searchRequest: String, - private val disposables: CompositeDisposable, - private val retryExecutor: Executor, - private val cacheData: List? = null, - private val parser: (SearchResult?) -> List) : DataSource.Factory() { +class SearchPagingSourceFactory( + private val mastodonApi: MastodonApi, + private val searchType: SearchType, + private val initialItems: List? = null, + private val parser: (SearchResult) -> List +) : () -> SearchPagingSource { - val sourceLiveData = MutableLiveData>() + private var searchRequest: String = "" - var exhausted = false + private var currentSource: SearchPagingSource? = null - override fun create(): DataSource { - val source = SearchDataSource(mastodonApi, searchType, searchRequest, disposables, retryExecutor, cacheData, parser, this) - sourceLiveData.postValue(source) - return source + override fun invoke(): SearchPagingSource { + return SearchPagingSource( + mastodonApi = mastodonApi, + searchType = searchType, + searchRequest = searchRequest, + initialItems = initialItems, + parser = parser + ).also { source -> + currentSource = source + } + } + + fun newSearch(newSearchRequest: String) { + this.searchRequest = newSearchRequest + currentSource?.invalidate() + } + + fun invalidate() { + currentSource?.invalidate() } } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchRepository.kt deleted file mode 100644 index 4425542e6..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchRepository.kt +++ /dev/null @@ -1,56 +0,0 @@ -/* Copyright 2019 Joel Pyska - * - * 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 . */ - -package com.keylesspalace.tusky.components.search.adapter - -import androidx.lifecycle.Transformations -import androidx.paging.Config -import androidx.paging.toLiveData -import com.keylesspalace.tusky.components.search.SearchType -import com.keylesspalace.tusky.entity.SearchResult -import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.Listing -import io.reactivex.rxjava3.disposables.CompositeDisposable -import java.util.concurrent.Executors - -class SearchRepository(private val mastodonApi: MastodonApi) { - - private val executor = Executors.newSingleThreadExecutor() - - fun getSearchData(searchType: SearchType, searchRequest: String, disposables: CompositeDisposable, pageSize: Int = 20, - initialItems: List? = null, parser: (SearchResult?) -> List): Listing { - val sourceFactory = SearchDataSourceFactory(mastodonApi, searchType, searchRequest, disposables, executor, initialItems, parser) - val livePagedList = sourceFactory.toLiveData( - config = Config(pageSize = pageSize, enablePlaceholders = false, initialLoadSizeHint = pageSize * 2), - fetchExecutor = executor - ) - return Listing( - pagedList = livePagedList, - networkState = Transformations.switchMap(sourceFactory.sourceLiveData) { - it.networkState - }, - retry = { - sourceFactory.sourceLiveData.value?.retry() - }, - refresh = { - sourceFactory.sourceLiveData.value?.invalidate() - }, - refreshState = Transformations.switchMap(sourceFactory.sourceLiveData) { - it.initialLoad - } - - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt index a40414f93..d1ad35864 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt @@ -1,4 +1,4 @@ -/* Copyright 2019 Joel Pyska +/* Copyright 2021 Tusky Contributors * * This file is a part of Tusky. * @@ -17,9 +17,8 @@ package com.keylesspalace.tusky.components.search.adapter import android.view.LayoutInflater import android.view.ViewGroup -import androidx.paging.PagedListAdapter +import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.StatusViewHolder import com.keylesspalace.tusky.entity.Status @@ -28,36 +27,34 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.viewdata.StatusViewData class SearchStatusesAdapter( - private val statusDisplayOptions: StatusDisplayOptions, - private val statusListener: StatusActionListener -) : PagedListAdapter, RecyclerView.ViewHolder>(STATUS_COMPARATOR) { + private val statusDisplayOptions: StatusDisplayOptions, + private val statusListener: StatusActionListener +) : PagingDataAdapter, StatusViewHolder>(STATUS_COMPARATOR) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusViewHolder { val view = LayoutInflater.from(parent.context) - .inflate(R.layout.item_status, parent, false) + .inflate(R.layout.item_status, parent, false) return StatusViewHolder(view) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + override fun onBindViewHolder(holder: StatusViewHolder, position: Int) { getItem(position)?.let { item -> - (holder as StatusViewHolder).setupWithStatus(item.second, statusListener, statusDisplayOptions) + holder.setupWithStatus(item.second, statusListener, statusDisplayOptions) } } - public override fun getItem(position: Int): Pair? { - return super.getItem(position) + fun item(position: Int): Pair? { + return getItem(position) } companion object { val STATUS_COMPARATOR = object : DiffUtil.ItemCallback>() { override fun areContentsTheSame(oldItem: Pair, newItem: Pair): Boolean = - oldItem.second == newItem.second + oldItem == newItem override fun areItemsTheSame(oldItem: Pair, newItem: Pair): Boolean = - oldItem.second.id == newItem.second.id + oldItem.second.id == newItem.second.id } - } - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt index 8715e1ab2..8a0d54162 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt @@ -1,4 +1,4 @@ -/* Copyright 2019 Joel Pyska +/* Copyright 2021 Tusky Contributors * * This file is a part of Tusky. * @@ -15,17 +15,16 @@ package com.keylesspalace.tusky.components.search.fragments -import androidx.lifecycle.LiveData -import androidx.paging.PagedList -import androidx.paging.PagedListAdapter +import androidx.paging.PagingData +import androidx.paging.PagingDataAdapter import androidx.preference.PreferenceManager import com.keylesspalace.tusky.components.search.adapter.SearchAccountsAdapter import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.settings.PrefKeys -import com.keylesspalace.tusky.util.NetworkState +import kotlinx.coroutines.flow.Flow class SearchAccountsFragment : SearchFragment() { - override fun createAdapter(): PagedListAdapter { + override fun createAdapter(): PagingDataAdapter { val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context) return SearchAccountsAdapter( @@ -35,12 +34,8 @@ class SearchAccountsFragment : SearchFragment() { ) } - override val networkStateRefresh: LiveData - get() = viewModel.networkStateAccountRefresh - override val networkState: LiveData - get() = viewModel.networkStateAccount - override val data: LiveData> - get() = viewModel.accounts + override val data: Flow> + get() = viewModel.accountsFlow companion object { fun newInstance() = SearchAccountsFragment() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt index 32475c78c..e18cd5cb1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt @@ -4,9 +4,10 @@ import android.os.Bundle import android.view.View import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels -import androidx.lifecycle.LiveData -import androidx.paging.PagedList -import androidx.paging.PagedListAdapter +import androidx.lifecycle.lifecycleScope +import androidx.paging.LoadState +import androidx.paging.PagingData +import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.SimpleItemAnimator @@ -21,10 +22,14 @@ import com.keylesspalace.tusky.databinding.FragmentSearchBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.interfaces.LinkListener -import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.util.viewBinding +import com.keylesspalace.tusky.util.visible +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import javax.inject.Inject -abstract class SearchFragment : Fragment(R.layout.fragment_search), +abstract class SearchFragment : Fragment(R.layout.fragment_search), LinkListener, Injectable, SwipeRefreshLayout.OnRefreshListener { @Inject @@ -36,12 +41,12 @@ abstract class SearchFragment : Fragment(R.layout.fragment_search), private var snackbarErrorRetry: Snackbar? = null - abstract fun createAdapter(): PagedListAdapter + abstract fun createAdapter(): PagingDataAdapter - abstract val networkStateRefresh: LiveData - abstract val networkState: LiveData - abstract val data: LiveData> - protected lateinit var adapter: PagedListAdapter + abstract val data: Flow> + protected lateinit var adapter: PagingDataAdapter + + private var currentQuery: String = "" override fun onViewCreated(view: View, savedInstanceState: Bundle?) { initAdapter() @@ -55,32 +60,32 @@ abstract class SearchFragment : Fragment(R.layout.fragment_search), } private fun subscribeObservables() { - data.observe(viewLifecycleOwner) { - adapter.submitList(it) - } - - networkStateRefresh.observe(viewLifecycleOwner) { - - binding.searchProgressBar.visible(it == NetworkState.LOADING) - - if (it.status == Status.FAILED) { - showError() - } - checkNoData() - } - - networkState.observe(viewLifecycleOwner) { - - binding.progressBarBottom.visible(it == NetworkState.LOADING) - - if (it.status == Status.FAILED) { - showError() + viewLifecycleOwner.lifecycleScope.launch { + data.collectLatest { pagingData -> + adapter.submitData(pagingData) } } - } - private fun checkNoData() { - showNoData(adapter.itemCount == 0) + adapter.addLoadStateListener { loadState -> + + if (loadState.refresh is LoadState.Error) { + showError() + } + + val isNewSearch = currentQuery != viewModel.currentQuery + + binding.searchProgressBar.visible(loadState.refresh == LoadState.Loading && isNewSearch && !binding.swipeRefreshLayout.isRefreshing) + binding.searchRecyclerView.visible(loadState.refresh is LoadState.NotLoading || !isNewSearch || binding.swipeRefreshLayout.isRefreshing) + + if (loadState.refresh != LoadState.Loading) { + binding.swipeRefreshLayout.isRefreshing = false + currentQuery = viewModel.currentQuery + } + + binding.progressBarBottom.visible(loadState.append == LoadState.Loading) + + binding.searchNoResultsText.visible(loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0 && viewModel.currentQuery.isNotEmpty()) + } } private fun initAdapter() { @@ -92,20 +97,12 @@ abstract class SearchFragment : Fragment(R.layout.fragment_search), (binding.searchRecyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false } - private fun showNoData(isEmpty: Boolean) { - if (isEmpty && networkStateRefresh.value == NetworkState.LOADED) { - binding.searchNoResultsText.show() - } else { - binding.searchNoResultsText.hide() - } - } - private fun showError() { if (snackbarErrorRetry?.isShown != true) { snackbarErrorRetry = Snackbar.make(binding.root, R.string.failed_search, Snackbar.LENGTH_INDEFINITE) snackbarErrorRetry?.setAction(R.string.action_retry) { snackbarErrorRetry = null - viewModel.retryAllSearches() + adapter.retry() } snackbarErrorRetry?.show() } @@ -123,11 +120,6 @@ abstract class SearchFragment : Fragment(R.layout.fragment_search), get() = (activity as? BottomSheetActivity) override fun onRefresh() { - - // Dismissed here because the RecyclerView bottomProgressBar is shown as soon as the retry begins. - binding.swipeRefreshLayout.post { - binding.swipeRefreshLayout.isRefreshing = false - } - viewModel.retryAllSearches() + adapter.refresh() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchHashtagsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchHashtagsFragment.kt index 15310d3c1..d0b7e8fa9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchHashtagsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchHashtagsFragment.kt @@ -1,4 +1,4 @@ -/* Copyright 2019 Joel Pyska +/* Copyright 2021 Tusky Contributors * * This file is a part of Tusky. * @@ -15,22 +15,18 @@ package com.keylesspalace.tusky.components.search.fragments -import androidx.lifecycle.LiveData -import androidx.paging.PagedList -import androidx.paging.PagedListAdapter +import androidx.paging.PagingData +import androidx.paging.PagingDataAdapter import com.keylesspalace.tusky.components.search.adapter.SearchHashtagsAdapter import com.keylesspalace.tusky.entity.HashTag -import com.keylesspalace.tusky.util.NetworkState +import kotlinx.coroutines.flow.Flow class SearchHashtagsFragment : SearchFragment() { - override val networkStateRefresh: LiveData - get() = viewModel.networkStateHashTagRefresh - override val networkState: LiveData - get() = viewModel.networkStateHashTag - override val data: LiveData> - get() = viewModel.hashtags - override fun createAdapter(): PagedListAdapter = SearchHashtagsAdapter(this) + override val data: Flow> + get() = viewModel.hashtagsFlow + + override fun createAdapter(): PagingDataAdapter = SearchHashtagsAdapter(this) companion object { fun newInstance() = SearchHashtagsFragment() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt index 06013ce42..c6fe2c4e0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt @@ -1,4 +1,4 @@ -/* Copyright 2019 Joel Pyska +/* Copyright 2021 Tusky Contributors * * This file is a part of Tusky. * @@ -32,9 +32,8 @@ import androidx.appcompat.widget.PopupMenu import androidx.core.app.ActivityOptionsCompat import androidx.core.view.ViewCompat import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LiveData -import androidx.paging.PagedList -import androidx.paging.PagedListAdapter +import androidx.paging.PagingData +import androidx.paging.PagingDataAdapter import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager @@ -57,26 +56,22 @@ import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.CardViewMode import com.keylesspalace.tusky.util.LinkHelper -import com.keylesspalace.tusky.util.NetworkState import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.view.showMuteAccountDialog import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.StatusViewData import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import kotlinx.coroutines.flow.Flow class SearchStatusesFragment : SearchFragment>(), StatusActionListener { - override val networkStateRefresh: LiveData - get() = viewModel.networkStateStatusRefresh - override val networkState: LiveData - get() = viewModel.networkStateStatus - override val data: LiveData>> - get() = viewModel.statuses + override val data: Flow>> + get() = viewModel.statusesFlow private val searchAdapter get() = super.adapter as SearchStatusesAdapter - override fun createAdapter(): PagedListAdapter, *> { + override fun createAdapter(): PagingDataAdapter, *> { val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context) val statusDisplayOptions = StatusDisplayOptions( animateAvatars = preferences.getBoolean("animateGifAvatars", false), @@ -96,37 +91,37 @@ class SearchStatusesFragment : SearchFragment + searchAdapter.item(position)?.first?.let { status -> reply(status) } } override fun onFavourite(favourite: Boolean, position: Int) { - searchAdapter.getItem(position)?.let { status -> + searchAdapter.item(position)?.let { status -> viewModel.favorite(status, favourite) } } override fun onBookmark(bookmark: Boolean, position: Int) { - searchAdapter.getItem(position)?.let { status -> + searchAdapter.item(position)?.let { status -> viewModel.bookmark(status, bookmark) } } override fun onMore(view: View, position: Int) { - searchAdapter.getItem(position)?.first?.let { + searchAdapter.item(position)?.first?.let { more(it, view, position) } } override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { - searchAdapter.getItem(position)?.first?.actionableStatus?.let { actionable -> + searchAdapter.item(position)?.first?.actionableStatus?.let { actionable -> when (actionable.attachments[attachmentIndex].type) { Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> { val attachments = AttachmentViewData.list(actionable) @@ -146,26 +141,24 @@ class SearchStatusesFragment : SearchFragment + searchAdapter.item(position)?.first?.let { status -> val actionableStatus = status.actionableStatus bottomSheetActivity?.viewThread(actionableStatus.id, actionableStatus.url) } } override fun onOpenReblog(position: Int) { - searchAdapter.getItem(position)?.first?.let { status -> + searchAdapter.item(position)?.first?.let { status -> bottomSheetActivity?.viewAccount(status.account.id) } } override fun onExpandedChange(expanded: Boolean, position: Int) { - searchAdapter.getItem(position)?.let { + searchAdapter.item(position)?.let { viewModel.expandedChange(it, expanded) } } @@ -175,25 +168,25 @@ class SearchStatusesFragment : SearchFragment) { - searchAdapter.getItem(position)?.let { + searchAdapter.item(position)?.let { viewModel.voteInPoll(it, choices) } } private fun removeItem(position: Int) { - searchAdapter.getItem(position)?.let { + searchAdapter.item(position)?.let { viewModel.removeItem(it) } } override fun onReblog(reblog: Boolean, position: Int) { - searchAdapter.getItem(position)?.let { status -> + searchAdapter.item(position)?.let { status -> viewModel.reblog(status, reblog) } } @@ -323,7 +316,7 @@ class SearchStatusesFragment : SearchFragment { - searchAdapter.getItem(position)?.let { foundStatus -> + searchAdapter.item(position)?.let { foundStatus -> viewModel.muteConversation(foundStatus, status.muted != true) } return@setOnMenuItemClickListener true diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java index 1a950feec..624c15ac1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -32,7 +32,7 @@ import java.io.File; @Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, TimelineAccountEntity.class, ConversationEntity.class - }, version = 26) + }, version = 27) public abstract class AppDatabase extends RoomDatabase { public abstract AccountDao accountDao(); @@ -393,4 +393,11 @@ public abstract class AppDatabase extends RoomDatabase { } } } + + public static final Migration MIGRATION_26_27 = new Migration(26, 27) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_muted` INTEGER NOT NULL DEFAULT 0"); + } + }; } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/ConversationsDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/ConversationsDao.kt index aae30826f..2d54e6746 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/ConversationsDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/ConversationsDao.kt @@ -15,27 +15,29 @@ package com.keylesspalace.tusky.db -import androidx.paging.DataSource -import androidx.room.* +import androidx.paging.PagingSource +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query import com.keylesspalace.tusky.components.conversation.ConversationEntity -import io.reactivex.rxjava3.core.Single @Dao interface ConversationsDao { @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insert(conversations: List) + suspend fun insert(conversations: List) @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insert(conversation: ConversationEntity): Single + suspend fun insert(conversation: ConversationEntity): Long @Delete - fun delete(conversation: ConversationEntity): Single + suspend fun delete(conversation: ConversationEntity): Int @Query("SELECT * FROM ConversationEntity WHERE accountId = :accountId ORDER BY s_createdAt DESC") - fun conversationsForAccount(accountId: Long) : DataSource.Factory + fun conversationsForAccount(accountId: Long) : PagingSource @Query("DELETE FROM ConversationEntity WHERE accountId = :accountId") fun deleteForAccount(accountId: Long) - } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt index 2a699ee33..faf0a3863 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt @@ -83,6 +83,7 @@ class AppModule { AppDatabase.MIGRATION_16_17, AppDatabase.MIGRATION_17_18, AppDatabase.MIGRATION_18_19, AppDatabase.MIGRATION_19_20, AppDatabase.MIGRATION_20_21, AppDatabase.MIGRATION_21_22, AppDatabase.MIGRATION_22_23, AppDatabase.MIGRATION_23_24, AppDatabase.MIGRATION_24_25, + AppDatabase.MIGRATION_26_27, AppDatabase.Migration25_26(appContext.getExternalFilesDir("Tusky")) ) .build() diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index dfa681f57..6aadc9b76 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -15,7 +15,27 @@ package com.keylesspalace.tusky.network -import com.keylesspalace.tusky.entity.* +import com.keylesspalace.tusky.entity.AccessToken +import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.Announcement +import com.keylesspalace.tusky.entity.AppCredentials +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.Conversation +import com.keylesspalace.tusky.entity.DeletedStatus +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.entity.IdentityProof +import com.keylesspalace.tusky.entity.Instance +import com.keylesspalace.tusky.entity.Marker +import com.keylesspalace.tusky.entity.MastoList +import com.keylesspalace.tusky.entity.NewStatus +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.entity.Relationship +import com.keylesspalace.tusky.entity.ScheduledStatus +import com.keylesspalace.tusky.entity.SearchResult +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.entity.StatusContext import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Single import okhttp3.MultipartBody @@ -23,8 +43,20 @@ import okhttp3.RequestBody import okhttp3.ResponseBody import retrofit2.Call import retrofit2.Response -import retrofit2.http.* +import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.Field +import retrofit2.http.FormUrlEncoded +import retrofit2.http.GET +import retrofit2.http.HTTP +import retrofit2.http.Header +import retrofit2.http.Multipart +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Part +import retrofit2.http.Path +import retrofit2.http.Query /** * for documentation of the Mastodon REST API see https://docs.joinmastodon.org/api/ @@ -466,10 +498,15 @@ interface MastodonApi { ): Completable @GET("/api/v1/conversations") - fun getConversations( + suspend fun getConversations( @Query("max_id") maxId: String? = null, @Query("limit") limit: Int - ): Call> + ): List + + @DELETE("/api/v1/conversations/{id}") + suspend fun deleteConversation( + @Path("id") conversationId: String + ) @FormUrlEncoded @POST("api/v1/filters") diff --git a/app/src/main/java/com/keylesspalace/tusky/util/BiListing.kt b/app/src/main/java/com/keylesspalace/tusky/util/BiListing.kt index dad6d552d..268631cb7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/BiListing.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/BiListing.kt @@ -22,7 +22,7 @@ import androidx.paging.PagedList /** * Data class that is necessary for a UI to show a listing and interact w/ the rest of the system */ -data class BiListing( +data class BiListing( // the LiveData of paged lists for the UI to observe val pagedList: LiveData>, // represents the network request status for load data before first to show to the user diff --git a/app/src/main/java/com/keylesspalace/tusky/util/Listing.kt b/app/src/main/java/com/keylesspalace/tusky/util/Listing.kt deleted file mode 100644 index 3d4234c59..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/Listing.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright (C) 2017 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.keylesspalace.tusky.util - -import androidx.lifecycle.LiveData -import androidx.paging.PagedList - -/** - * Data class that is necessary for a UI to show a listing and interact w/ the rest of the system - */ -data class Listing( - // the LiveData of paged lists for the UI to observe - val pagedList: LiveData>, - // represents the network request status to show to the user - val networkState: LiveData, - // represents the refresh status to show to the user. Separate from networkState, this - // value is importantly only when refresh is requested. - val refreshState: LiveData, - // refreshes the whole data and fetches it from scratch. - val refresh: () -> Unit, - // retries any failed requests. - val retry: () -> Unit) \ No newline at end of file diff --git a/app/src/main/res/menu/conversation_more.xml b/app/src/main/res/menu/conversation_more.xml new file mode 100644 index 000000000..2f5dedd93 --- /dev/null +++ b/app/src/main/res/menu/conversation_more.xml @@ -0,0 +1,13 @@ + +

+ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6dd78e760..d9fefae27 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -88,6 +88,7 @@ Report Edit Delete + Delete conversation Delete and re-draft TOOT TOOT! @@ -200,6 +201,7 @@ Unfollow this account? Delete this toot? Delete and re-draft this toot? + Delete this conversation? Are you sure you want to block all of %s? You will not see content from that domain in any public timelines or in your notifications. Your followers from that domain will be removed. Hide entire domain Block @%s?