migrate to paging 3 (#2182)

* migrate conversations and search to paging 3

* delete SearchRepository

* remove unneeded executor from search

* fix bugs in conversations

* update license headers

* fix conversations refreshing

* fix search refresh indicators

* show fullscreen loading while conversations are empty

* search bugfixes

* error handling

* error handling

* remove mastodon bug workaround

* update ConversationsFragment

* fix conversations more menu and deleting conversations

* delete unused class

* catch exceptions in ConversationsViewModel

* fix bug where items are not diffed correctly / cleanup code

* fix search progressbar display conditions
This commit is contained in:
Konrad Pozniak 2021-06-17 18:54:56 +02:00 committed by GitHub
parent 31da851f28
commit 6d4f5ad027
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1612 additions and 1022 deletions

View File

@ -97,6 +97,9 @@ ext.materialdrawerVersion = '8.4.1'
dependencies { dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" 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.core:core-ktx:1.5.0"
implementation "androidx.appcompat:appcompat:1.3.0" implementation "androidx.appcompat:appcompat:1.3.0"
implementation "androidx.fragment:fragment-ktx:1.3.4" implementation "androidx.fragment:fragment-ktx:1.3.4"
@ -114,13 +117,11 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$lifecycleVersion"
implementation "androidx.constraintlayout:constraintlayout:2.0.4" 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.viewpager2:viewpager2:1.0.0"
implementation "androidx.work:work-runtime:2.5.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 "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" kapt "androidx.room:room-compiler:$roomVersion"
implementation "com.google.android.material:material:1.3.0" implementation "com.google.android.material:material:1.3.0"

View File

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

View File

@ -15,30 +15,28 @@
package com.keylesspalace.tusky.adapter package com.keylesspalace.tusky.adapter
import androidx.paging.LoadState
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import android.view.ViewGroup
import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding
import com.keylesspalace.tusky.util.NetworkState
import com.keylesspalace.tusky.util.Status
import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.util.visible
class NetworkStateViewHolder(private val binding: ItemNetworkStateBinding, class NetworkStateViewHolder(private val binding: ItemNetworkStateBinding,
private val retryCallback: () -> Unit) private val retryCallback: () -> Unit)
: RecyclerView.ViewHolder(binding.root) { : RecyclerView.ViewHolder(binding.root) {
fun setUpWithNetworkState(state: NetworkState?, fullScreen: Boolean) { fun setUpWithNetworkState(state: LoadState) {
binding.progressBar.visible(state?.status == Status.RUNNING) binding.progressBar.visible(state == LoadState.Loading)
binding.retryButton.visible(state?.status == Status.FAILED) binding.retryButton.visible(state is LoadState.Error)
binding.errorMsg.visible(state?.msg != null) val msg = if (state is LoadState.Error) {
binding.errorMsg.text = state?.msg state.error.message
} else {
null
}
binding.errorMsg.visible(msg != null)
binding.errorMsg.text = msg
binding.retryButton.setOnClickListener { binding.retryButton.setOnClickListener {
retryCallback() retryCallback()
} }
if(fullScreen) {
binding.root.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
} else {
binding.root.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT
}
} }
} }

View File

@ -17,114 +17,39 @@ package com.keylesspalace.tusky.components.conversation
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.paging.AsyncPagedListDiffer import androidx.paging.PagingDataAdapter
import androidx.paging.PagedList
import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListUpdateCallback
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R 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.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.NetworkState
import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.StatusDisplayOptions
class ConversationAdapter( class ConversationAdapter(
private val statusDisplayOptions: StatusDisplayOptions, private val statusDisplayOptions: StatusDisplayOptions,
private val listener: StatusActionListener, private val listener: StatusActionListener
private val topLoadedCallback: () -> Unit, ) : PagingDataAdapter<ConversationEntity, ConversationViewHolder>(CONVERSATION_COMPARATOR) {
private val retryCallback: () -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var networkState: NetworkState? = null override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_conversation, parent, false)
private val differ: AsyncPagedListDiffer<ConversationEntity> = AsyncPagedListDiffer(object : ListUpdateCallback { return ConversationViewHolder(view, statusDisplayOptions, listener)
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<ConversationEntity>) {
differ.submitList(list)
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun onBindViewHolder(holder: ConversationViewHolder, position: Int) {
return when (viewType) { holder.setupWithConversation(getItem(position))
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: RecyclerView.ViewHolder, position: Int) { fun item(position: Int): ConversationEntity? {
when (getItemViewType(position)) { return getItem(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)
}
} }
companion object { companion object {
val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback<ConversationEntity>() { val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback<ConversationEntity>() {
override fun areContentsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean = override fun areItemsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean {
oldItem == newItem return oldItem.id == newItem.id
}
override fun areItemsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean = override fun areContentsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean {
oldItem.id == newItem.id return oldItem == newItem
}
} }
} }
}
}

View File

@ -1,4 +1,4 @@
/* Copyright 2019 Conny Duck /* Copyright 2021 Tusky Contributors
* *
* This file is a part of Tusky. * This file is a part of Tusky.
* *
@ -21,65 +21,70 @@ import androidx.room.Embedded
import androidx.room.Entity import androidx.room.Entity
import androidx.room.TypeConverters import androidx.room.TypeConverters
import com.keylesspalace.tusky.db.Converters 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 com.keylesspalace.tusky.util.shouldTrimStatus
import java.util.* import java.util.Date
@Entity(primaryKeys = ["id","accountId"]) @Entity(primaryKeys = ["id","accountId"])
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
data class ConversationEntity( data class ConversationEntity(
val accountId: Long, val accountId: Long,
val id: String, val id: String,
val accounts: List<ConversationAccountEntity>, val accounts: List<ConversationAccountEntity>,
val unread: Boolean, val unread: Boolean,
@Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity @Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity
) )
data class ConversationAccountEntity( data class ConversationAccountEntity(
val id: String, val id: String,
val username: String, val username: String,
val displayName: String, val displayName: String,
val avatar: String, val avatar: String,
val emojis: List<Emoji> val emojis: List<Emoji>
) { ) {
fun toAccount(): Account { fun toAccount(): Account {
return Account( return Account(
id = id, id = id,
username = username, username = username,
displayName = displayName, displayName = displayName,
avatar = avatar, avatar = avatar,
emojis = emojis, emojis = emojis,
url = "", url = "",
localUsername = "", localUsername = "",
note = SpannedString(""), note = SpannedString(""),
header = "" header = ""
) )
} }
} }
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
data class ConversationStatusEntity( data class ConversationStatusEntity(
val id: String, val id: String,
val url: String?, val url: String?,
val inReplyToId: String?, val inReplyToId: String?,
val inReplyToAccountId: String?, val inReplyToAccountId: String?,
val account: ConversationAccountEntity, val account: ConversationAccountEntity,
val content: Spanned, val content: Spanned,
val createdAt: Date, val createdAt: Date,
val emojis: List<Emoji>, val emojis: List<Emoji>,
val favouritesCount: Int, val favouritesCount: Int,
val favourited: Boolean, val favourited: Boolean,
val bookmarked: Boolean, val bookmarked: Boolean,
val sensitive: Boolean, val sensitive: Boolean,
val spoilerText: String, val spoilerText: String,
val attachments: ArrayList<Attachment>, val attachments: ArrayList<Attachment>,
val mentions: List<Status.Mention>, val mentions: List<Status.Mention>,
val showingHiddenContent: Boolean, val showingHiddenContent: Boolean,
val expanded: Boolean, val expanded: Boolean,
val collapsible: Boolean, val collapsible: Boolean,
val collapsed: Boolean, val collapsed: Boolean,
val poll: Poll? val muted: Boolean,
val poll: Poll?
) { ) {
/** its necessary to override this because Spanned.equals does not work as expected */ /** its necessary to override this because Spanned.equals does not work as expected */
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
@ -106,6 +111,7 @@ data class ConversationStatusEntity(
if (expanded != other.expanded) return false if (expanded != other.expanded) return false
if (collapsible != other.collapsible) return false if (collapsible != other.collapsible) return false
if (collapsed != other.collapsed) return false if (collapsed != other.collapsed) return false
if (muted != other.muted) return false
if (poll != other.poll) return false if (poll != other.poll) return false
return true return true
@ -130,66 +136,79 @@ data class ConversationStatusEntity(
result = 31 * result + expanded.hashCode() result = 31 * result + expanded.hashCode()
result = 31 * result + collapsible.hashCode() result = 31 * result + collapsible.hashCode()
result = 31 * result + collapsed.hashCode() result = 31 * result + collapsed.hashCode()
result = 31 * result + muted.hashCode()
result = 31 * result + poll.hashCode() result = 31 * result + poll.hashCode()
return result return result
} }
fun toStatus(): Status { fun toStatus(): Status {
return Status( return Status(
id = id, id = id,
url = url, url = url,
account = account.toAccount(), account = account.toAccount(),
inReplyToId = inReplyToId, inReplyToId = inReplyToId,
inReplyToAccountId = inReplyToAccountId, inReplyToAccountId = inReplyToAccountId,
content = content, content = content,
reblog = null, reblog = null,
createdAt = createdAt, createdAt = createdAt,
emojis = emojis, emojis = emojis,
reblogsCount = 0, reblogsCount = 0,
favouritesCount = favouritesCount, favouritesCount = favouritesCount,
reblogged = false, reblogged = false,
favourited = favourited, favourited = favourited,
bookmarked = bookmarked, bookmarked = bookmarked,
sensitive= sensitive, sensitive= sensitive,
spoilerText = spoilerText, spoilerText = spoilerText,
visibility = Status.Visibility.DIRECT, visibility = Status.Visibility.DIRECT,
attachments = attachments, attachments = attachments,
mentions = mentions, mentions = mentions,
application = null, application = null,
pinned = false, pinned = false,
muted = false, muted = muted,
poll = poll, poll = poll,
card = null) card = null)
} }
} }
fun Account.toEntity() = fun Account.toEntity() =
ConversationAccountEntity( ConversationAccountEntity(
id, id = id,
username, username = username,
name, displayName = name,
avatar, avatar = avatar,
emojis ?: emptyList() emojis = emojis ?: emptyList()
) )
fun Status.toEntity() = fun Status.toEntity() =
ConversationStatusEntity( ConversationStatusEntity(
id, url, inReplyToId, inReplyToAccountId, account.toEntity(), content, id = id,
createdAt, emojis, favouritesCount, favourited, bookmarked, sensitive, url = url,
spoilerText, attachments, mentions, inReplyToId = inReplyToId,
false, inReplyToAccountId = inReplyToAccountId,
false, account = account.toEntity(),
shouldTrimStatus(content), content = content,
true, createdAt = createdAt,
poll 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) = fun Conversation.toEntity(accountId: Long) =
ConversationEntity( ConversationEntity(
accountId, accountId = accountId,
id, id = id,
accounts.map { it.toEntity() }, accounts = accounts.map { it.toEntity() },
unread, unread = unread,
lastStatus!!.toEntity() lastStatus = lastStatus!!.toEntity()
) )

View File

@ -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 <http://www.gnu.org/licenses>. */
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<NetworkStateViewHolder>() {
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)
}
}

View File

@ -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.
* <p>
* 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<Conversation>?) -> Unit,
private val ioExecutor: Executor,
private val networkPageSize: Int)
: PagedList.BoundaryCallback<ConversationEntity>() {
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<List<Conversation>>,
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<List<Conversation>> {
return object : Callback<List<Conversation>> {
override fun onFailure(call: Call<List<Conversation>>, t: Throwable) {
it.recordFailure(t)
}
override fun onResponse(call: Call<List<Conversation>>, response: Response<List<Conversation>>) {
insertItemsIntoDb(response, it)
}
}
}
}

View File

@ -1,4 +1,4 @@
/* Copyright 2019 Conny Duck /* Copyright 2021 Tusky Contributors
* *
* This file is a part of Tusky. * This file is a part of Tusky.
* *
@ -20,7 +20,12 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.PopupMenu
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadState
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager 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.ReselectableFragment
import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.settings.PrefKeys 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.CardViewMode
import com.keylesspalace.tusky.util.NetworkState
import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
@ -53,34 +61,39 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
private val binding by viewBinding(FragmentTimelineBinding::bind) private val binding by viewBinding(FragmentTimelineBinding::bind)
private lateinit var adapter: ConversationAdapter private lateinit var adapter: ConversationAdapter
private lateinit var loadStateAdapter: ConversationLoadStateAdapter
private var layoutManager: LinearLayoutManager? = null private var layoutManager: LinearLayoutManager? = null
private var initialRefreshDone: Boolean = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_timeline, container, false) return inflater.inflate(R.layout.fragment_timeline, container, false)
} }
@ExperimentalPagingApi
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val preferences = PreferenceManager.getDefaultSharedPreferences(view.context) val preferences = PreferenceManager.getDefaultSharedPreferences(view.context)
val statusDisplayOptions = StatusDisplayOptions( val statusDisplayOptions = StatusDisplayOptions(
animateAvatars = preferences.getBoolean("animateGifAvatars", false), animateAvatars = preferences.getBoolean("animateGifAvatars", false),
mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true, mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true,
useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false), useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false),
showBotOverlay = preferences.getBoolean("showBotOverlay", true), showBotOverlay = preferences.getBoolean("showBotOverlay", true),
useBlurhash = preferences.getBoolean("useBlurhash", true), useBlurhash = preferences.getBoolean("useBlurhash", true),
cardViewMode = CardViewMode.NONE, cardViewMode = CardViewMode.NONE,
confirmReblogs = preferences.getBoolean("confirmReblogs", true), confirmReblogs = preferences.getBoolean("confirmReblogs", true),
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, 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)) binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
layoutManager = LinearLayoutManager(view.context) layoutManager = LinearLayoutManager(view.context)
binding.recyclerView.layoutManager = layoutManager binding.recyclerView.layoutManager = layoutManager
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter.withLoadStateFooter(loadStateAdapter)
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
binding.progressBar.hide() binding.progressBar.hide()
@ -88,59 +101,101 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
initSwipeToRefresh() initSwipeToRefresh()
viewModel.conversations.observe(viewLifecycleOwner) { lifecycleScope.launch {
adapter.submitList(it) viewModel.conversationFlow.collectLatest { pagingData ->
} adapter.submitData(pagingData)
viewModel.networkState.observe(viewLifecycleOwner) { }
adapter.setNetworkState(it)
} }
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() { private fun initSwipeToRefresh() {
viewModel.refreshState.observe(viewLifecycleOwner) {
binding.swipeRefreshLayout.isRefreshing = it == NetworkState.LOADING
}
binding.swipeRefreshLayout.setOnRefreshListener { binding.swipeRefreshLayout.setOnRefreshListener {
viewModel.refresh() adapter.refresh()
} }
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
} }
private fun onTopLoaded() {
binding.recyclerView.scrollToPosition(0)
}
override fun onReblog(reblog: Boolean, position: Int) { override fun onReblog(reblog: Boolean, position: Int) {
// its impossible to reblog private messages // its impossible to reblog private messages
} }
override fun onFavourite(favourite: Boolean, position: Int) { 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) { 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) { override fun onMore(view: View, position: Int) {
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { adapter.item(position)?.let { conversation ->
more(it.toStatus(), view, position)
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?) { override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { adapter.item(position)?.let { conversation ->
viewMedia(attachmentIndex, AttachmentViewData.list(it.toStatus()), view) viewMedia(attachmentIndex, AttachmentViewData.list(conversation.lastStatus.toStatus()), view)
} }
} }
override fun onViewThread(position: Int) { override fun onViewThread(position: Int) {
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { adapter.item(position)?.let { conversation ->
val status = it.toStatus() viewThread(conversation.lastStatus.id, conversation.lastStatus.url)
viewThread(status.actionableId, status.actionableStatus.url)
} }
} }
@ -149,11 +204,15 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
} }
override fun onExpandedChange(expanded: Boolean, position: Int) { 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) { 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) { override fun onLoadMore(position: Int) {
@ -161,7 +220,9 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
} }
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { 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) { override fun onViewAccount(id: String) {
@ -176,15 +237,25 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
} }
override fun removeItem(position: Int) { override fun removeItem(position: Int) {
viewModel.remove(position) // not needed
} }
override fun onReply(position: Int) { override fun onReply(position: Int) {
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { adapter.item(position)?.let { conversation ->
reply(it.toStatus()) 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() { private fun jumpToTop() {
if (isAdded) { if (isAdded) {
layoutManager?.scrollToPosition(0) layoutManager?.scrollToPosition(0)
@ -197,7 +268,9 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
} }
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) { override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
viewModel.voteInPoll(position, choices) adapter.item(position)?.let { conversation ->
viewModel.voteInPoll(choices, conversation)
}
} }
companion object { companion object {

View File

@ -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<Int, ConversationEntity>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, ConversationEntity>
): 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
}

View File

@ -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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.conversation 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.db.AppDatabase
import com.keylesspalace.tusky.entity.Conversation
import com.keylesspalace.tusky.network.MastodonApi 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.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers 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.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class ConversationsRepository @Inject constructor(val mastodonApi: MastodonApi, val db: AppDatabase) { class ConversationsRepository @Inject constructor(
val mastodonApi: MastodonApi,
private val ioExecutor = Executors.newSingleThreadExecutor() val db: AppDatabase
) {
companion object {
private const val DEFAULT_PAGE_SIZE = 20
}
@MainThread
fun refresh(accountId: Long, showLoadingIndicator: Boolean): LiveData<NetworkState> {
val networkState = MutableLiveData<NetworkState>()
if(showLoadingIndicator) {
networkState.value = NetworkState.LOADING
}
mastodonApi.getConversations(limit = DEFAULT_PAGE_SIZE).enqueue(
object : Callback<List<Conversation>> {
override fun onFailure(call: Call<List<Conversation>>, 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<List<Conversation>>, response: Response<List<Conversation>>) {
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<ConversationEntity> {
// 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<Unit?>()
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
)
}
fun deleteCacheForAccount(accountId: Long) { fun deleteCacheForAccount(accountId: Long) {
Single.fromCallable { Single.fromCallable {
@ -102,10 +35,4 @@ class ConversationsRepository @Inject constructor(val mastodonApi: MastodonApi,
.subscribe() .subscribe()
} }
private fun insertResultIntoDb(accountId: Long, result: List<Conversation>?) {
result?.filter { it.lastStatus != null }
?.map{ it.toEntity(accountId) }
?.let { db.conversationDao().insert(it) }
}
} }

View File

@ -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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.conversation package com.keylesspalace.tusky.components.conversation
import android.util.Log import android.util.Log
import androidx.lifecycle.LiveData import androidx.lifecycle.viewModelScope
import androidx.lifecycle.MutableLiveData import androidx.paging.ExperimentalPagingApi
import androidx.lifecycle.Transformations import androidx.paging.Pager
import androidx.paging.PagedList import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases 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 com.keylesspalace.tusky.util.RxAwareViewModel
import io.reactivex.rxjava3.schedulers.Schedulers import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
import javax.inject.Inject import javax.inject.Inject
class ConversationsViewModel @Inject constructor( class ConversationsViewModel @Inject constructor(
private val repository: ConversationsRepository,
private val timelineCases: TimelineCases, private val timelineCases: TimelineCases,
private val database: AppDatabase, private val database: AppDatabase,
private val accountManager: AccountManager private val accountManager: AccountManager,
private val api: MastodonApi
) : RxAwareViewModel() { ) : RxAwareViewModel() {
private val repoResult = MutableLiveData<Listing<ConversationEntity>>() @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<PagedList<ConversationEntity>> = fun favourite(favourite: Boolean, conversation: ConversationEntity) {
Transformations.switchMap(repoResult) { it.pagedList } viewModelScope.launch {
val networkState: LiveData<NetworkState> = try {
Transformations.switchMap(repoResult) { it.networkState } timelineCases.favourite(conversation.lastStatus.id, favourite).await()
val refreshState: LiveData<NetworkState> =
Transformations.switchMap(repoResult) { it.refreshState }
fun load() { val newConversation = conversation.copy(
val accountId = accountManager.activeAccount?.id ?: return lastStatus = conversation.lastStatus.copy(favourited = favourite)
if (repoResult.value == null) { )
repository.refresh(accountId, false)
database.conversationDao().insert(newConversation)
} catch (e: Exception) {
Log.w(TAG, "failed to favourite status", e)
}
} }
repoResult.value = repository.conversations(accountId)
} }
fun refresh() { fun bookmark(bookmark: Boolean, conversation: ConversationEntity) {
repoResult.value?.refresh?.invoke() viewModelScope.launch {
} try {
timelineCases.bookmark(conversation.lastStatus.id, bookmark).await()
fun retry() { val newConversation = conversation.copy(
repoResult.value?.retry?.invoke() lastStatus = conversation.lastStatus.copy(bookmarked = bookmark)
} )
fun favourite(favourite: Boolean, position: Int) { database.conversationDao().insert(newConversation)
conversations.value?.getOrNull(position)?.let { conversation -> } catch (e: Exception) {
timelineCases.favourite(conversation.lastStatus.id, favourite) Log.w(TAG, "failed to bookmark status", e)
.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()
} }
} }
fun bookmark(bookmark: Boolean, position: Int) { fun voteInPoll(choices: List<Int>, conversation: ConversationEntity) {
conversations.value?.getOrNull(position)?.let { conversation -> viewModelScope.launch {
timelineCases.bookmark(conversation.lastStatus.id, bookmark) try {
.flatMap { val poll = timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.poll?.id!!, choices).await()
val newConversation = conversation.copy( val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(bookmarked = bookmark) lastStatus = conversation.lastStatus.copy(poll = poll)
) )
database.conversationDao().insert(newConversation) database.conversationDao().insert(newConversation)
} } catch (e: Exception) {
.subscribeOn(Schedulers.io()) Log.w(TAG, "failed to vote in poll", e)
.doOnError { t -> }
Log.w(
"ConversationViewModel",
"Failed to bookmark conversation",
t
)
}
.onErrorReturnItem(0)
.subscribe()
.autoDispose()
} }
} }
fun voteInPoll(position: Int, choices: MutableList<Int>) { fun expandHiddenStatus(expanded: Boolean, conversation: ConversationEntity) {
conversations.value?.getOrNull(position)?.let { conversation -> viewModelScope.launch {
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 ->
val newConversation = conversation.copy( val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(expanded = expanded) lastStatus = conversation.lastStatus.copy(expanded = expanded)
) )
@ -131,8 +102,8 @@ class ConversationsViewModel @Inject constructor(
} }
} }
fun collapseLongStatus(collapsed: Boolean, position: Int) { fun collapseLongStatus(collapsed: Boolean, conversation: ConversationEntity) {
conversations.value?.getOrNull(position)?.let { conversation -> viewModelScope.launch {
val newConversation = conversation.copy( val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(collapsed = collapsed) lastStatus = conversation.lastStatus.copy(collapsed = collapsed)
) )
@ -140,8 +111,8 @@ class ConversationsViewModel @Inject constructor(
} }
} }
fun showContent(showing: Boolean, position: Int) { fun showContent(showing: Boolean, conversation: ConversationEntity) {
conversations.value?.getOrNull(position)?.let { conversation -> viewModelScope.launch {
val newConversation = conversation.copy( val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(showingHiddenContent = showing) lastStatus = conversation.lastStatus.copy(showingHiddenContent = showing)
) )
@ -149,16 +120,42 @@ class ConversationsViewModel @Inject constructor(
} }
} }
fun remove(position: Int) { fun remove(conversation: ConversationEntity) {
conversations.value?.getOrNull(position)?.let { viewModelScope.launch {
refresh() 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) { fun muteConversation(conversation: ConversationEntity) {
database.conversationDao().insert(conversation) viewModelScope.launch {
.subscribeOn(Schedulers.io()) try {
.subscribe() 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"
}
} }

View File

@ -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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.search package com.keylesspalace.tusky.components.search
enum class SearchType(val apiParameter: String) { enum class SearchType(val apiParameter: String) {

View File

@ -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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.search package com.keylesspalace.tusky.components.search
import android.util.Log import android.util.Log
import androidx.lifecycle.LiveData import androidx.lifecycle.viewModelScope
import androidx.lifecycle.MutableLiveData import androidx.paging.Pager
import androidx.paging.PagedList import androidx.paging.PagingConfig
import com.keylesspalace.tusky.components.search.adapter.SearchRepository import androidx.paging.cachedIn
import com.keylesspalace.tusky.components.search.adapter.SearchPagingSourceFactory
import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager 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.entity.Status
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases import com.keylesspalace.tusky.network.TimelineCases
import com.keylesspalace.tusky.util.* import com.keylesspalace.tusky.util.RxAwareViewModel
import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.core.Single
@ -35,82 +53,62 @@ class SearchViewModel @Inject constructor(
val alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false val alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false
val alwaysOpenSpoiler = activeAccount?.alwaysOpenSpoiler ?: false val alwaysOpenSpoiler = activeAccount?.alwaysOpenSpoiler ?: false
private val statusesRepository = private val loadedStatuses: MutableList<Pair<Status, StatusViewData.Concrete>> = mutableListOf()
SearchRepository<Pair<Status, StatusViewData.Concrete>>(mastodonApi)
private val accountsRepository = SearchRepository<Account>(mastodonApi)
private val hashtagsRepository = SearchRepository<HashTag>(mastodonApi)
private val repoResultStatus = MutableLiveData<Listing<Pair<Status, StatusViewData.Concrete>>>() private val statusesPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Status, loadedStatuses) {
val statuses: LiveData<PagedList<Pair<Status, StatusViewData.Concrete>>> = it.statuses.map { status -> Pair(status, status.toViewData(alwaysShowSensitiveMedia, alwaysOpenSpoiler)) }
repoResultStatus.switchMap { it.pagedList } .apply {
val networkStateStatus: LiveData<NetworkState> = repoResultStatus.switchMap { it.networkState } loadedStatuses.addAll(this)
val networkStateStatusRefresh: LiveData<NetworkState> = }
repoResultStatus.switchMap { it.refreshState } }
private val accountsPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Account) {
it.accounts
}
private val hashtagsPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Hashtag) {
it.hashtags
}
private val repoResultAccount = MutableLiveData<Listing<Account>>() val statusesFlow = Pager(
val accounts: LiveData<PagedList<Account>> = repoResultAccount.switchMap { it.pagedList } config = PagingConfig(pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE),
val networkStateAccount: LiveData<NetworkState> = pagingSourceFactory = statusesPagingSourceFactory
repoResultAccount.switchMap { it.networkState } ).flow
val networkStateAccountRefresh: LiveData<NetworkState> = .cachedIn(viewModelScope)
repoResultAccount.switchMap { it.refreshState }
private val repoResultHashTag = MutableLiveData<Listing<HashTag>>() val accountsFlow = Pager(
val hashtags: LiveData<PagedList<HashTag>> = repoResultHashTag.switchMap { it.pagedList } config = PagingConfig(pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE),
val networkStateHashTag: LiveData<NetworkState> = pagingSourceFactory = accountsPagingSourceFactory
repoResultHashTag.switchMap { it.networkState } ).flow
val networkStateHashTagRefresh: LiveData<NetworkState> = .cachedIn(viewModelScope)
repoResultHashTag.switchMap { it.refreshState }
val hashtagsFlow = Pager(
config = PagingConfig(pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE),
pagingSourceFactory = hashtagsPagingSourceFactory
).flow
.cachedIn(viewModelScope)
private val loadedStatuses = ArrayList<Pair<Status, StatusViewData.Concrete>>()
fun search(query: String) { fun search(query: String) {
loadedStatuses.clear() loadedStatuses.clear()
repoResultStatus.value = statusesRepository.getSearchData( statusesPagingSourceFactory.newSearch(query)
SearchType.Status, accountsPagingSourceFactory.newSearch(query)
query, hashtagsPagingSourceFactory.newSearch(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()
}
} }
fun removeItem(status: Pair<Status, StatusViewData.Concrete>) { fun removeItem(status: Pair<Status, StatusViewData.Concrete>) {
timelineCases.delete(status.first.id) timelineCases.delete(status.first.id)
.subscribe({ .subscribe({
if (loadedStatuses.remove(status)) if (loadedStatuses.remove(status))
repoResultStatus.value?.refresh?.invoke() statusesPagingSourceFactory.invalidate()
}, { err -> }, {
Log.d(TAG, "Failed to delete status", err) err -> Log.d(TAG, "Failed to delete status", err)
}) })
.autoDispose() .autoDispose()
} }
fun expandedChange(status: Pair<Status, StatusViewData.Concrete>, expanded: Boolean) { fun expandedChange(status: Pair<Status, StatusViewData.Concrete>, expanded: Boolean) {
val idx = loadedStatuses.indexOf(status) val idx = loadedStatuses.indexOf(status)
if (idx >= 0) { if (idx >= 0) {
val newPair = Pair(status.first, status.second.copy(isExpanded = expanded)) loadedStatuses[idx] = Pair(status.first, status.second.copy(isExpanded = expanded))
loadedStatuses[idx] = newPair statusesPagingSourceFactory.invalidate()
repoResultStatus.value?.refresh?.invoke()
} }
} }
@ -119,36 +117,30 @@ class SearchViewModel @Inject constructor(
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe( .subscribe(
{ setRebloggedForStatus(status, reblog) }, { 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() .autoDispose()
} }
private fun setRebloggedForStatus( private fun setRebloggedForStatus(status: Pair<Status, StatusViewData.Concrete>, reblog: Boolean) {
status: Pair<Status, StatusViewData.Concrete>,
reblog: Boolean
) {
status.first.reblogged = reblog status.first.reblogged = reblog
status.first.reblog?.reblogged = reblog status.first.reblog?.reblogged = reblog
statusesPagingSourceFactory.invalidate()
repoResultStatus.value?.refresh?.invoke()
} }
fun contentHiddenChange(status: Pair<Status, StatusViewData.Concrete>, isShowing: Boolean) { fun contentHiddenChange(status: Pair<Status, StatusViewData.Concrete>, isShowing: Boolean) {
val idx = loadedStatuses.indexOf(status) val idx = loadedStatuses.indexOf(status)
if (idx >= 0) { if (idx >= 0) {
val newPair = Pair(status.first, status.second.copy(isShowingContent = isShowing)) loadedStatuses[idx] = Pair(status.first, status.second.copy(isShowingContent = isShowing))
loadedStatuses[idx] = newPair statusesPagingSourceFactory.invalidate()
repoResultStatus.value?.refresh?.invoke()
} }
} }
fun collapsedChange(status: Pair<Status, StatusViewData.Concrete>, collapsed: Boolean) { fun collapsedChange(status: Pair<Status, StatusViewData.Concrete>, collapsed: Boolean) {
val idx = loadedStatuses.indexOf(status) val idx = loadedStatuses.indexOf(status)
if (idx >= 0) { if (idx >= 0) {
val newPair = Pair(status.first, status.second.copy(isCollapsed = collapsed)) loadedStatuses[idx] = Pair(status.first, status.second.copy(isCollapsed = collapsed))
loadedStatuses[idx] = newPair statusesPagingSourceFactory.invalidate()
repoResultStatus.value?.refresh?.invoke()
} }
} }
@ -159,12 +151,7 @@ class SearchViewModel @Inject constructor(
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe( .subscribe(
{ newPoll -> updateStatus(status, newPoll) }, { newPoll -> updateStatus(status, newPoll) },
{ t -> { t -> Log.d(TAG, "Failed to vote in poll: ${status.first.id}", t) }
Log.d(
TAG,
"Failed to vote in poll: ${status.first.id}", t
)
}
) )
.autoDispose() .autoDispose()
} }
@ -175,13 +162,13 @@ class SearchViewModel @Inject constructor(
val newStatus = status.first.copy(poll = newPoll) val newStatus = status.first.copy(poll = newPoll)
val newViewData = status.second.copy(status = newStatus) val newViewData = status.second.copy(status = newStatus)
loadedStatuses[idx] = Pair(newStatus, newViewData) loadedStatuses[idx] = Pair(newStatus, newViewData)
repoResultStatus.value?.refresh?.invoke() statusesPagingSourceFactory.invalidate()
} }
} }
fun favorite(status: Pair<Status, StatusViewData.Concrete>, isFavorited: Boolean) { fun favorite(status: Pair<Status, StatusViewData.Concrete>, isFavorited: Boolean) {
status.first.favourited = isFavorited status.first.favourited = isFavorited
repoResultStatus.value?.refresh?.invoke() statusesPagingSourceFactory.invalidate()
timelineCases.favourite(status.first.id, isFavorited) timelineCases.favourite(status.first.id, isFavorited)
.onErrorReturnItem(status.first) .onErrorReturnItem(status.first)
.subscribe() .subscribe()
@ -190,7 +177,7 @@ class SearchViewModel @Inject constructor(
fun bookmark(status: Pair<Status, StatusViewData.Concrete>, isBookmarked: Boolean) { fun bookmark(status: Pair<Status, StatusViewData.Concrete>, isBookmarked: Boolean) {
status.first.bookmarked = isBookmarked status.first.bookmarked = isBookmarked
repoResultStatus.value?.refresh?.invoke() statusesPagingSourceFactory.invalidate()
timelineCases.bookmark(status.first.id, isBookmarked) timelineCases.bookmark(status.first.id, isBookmarked)
.onErrorReturnItem(status.first) .onErrorReturnItem(status.first)
.subscribe() .subscribe()
@ -217,10 +204,6 @@ class SearchViewModel @Inject constructor(
return timelineCases.delete(id) return timelineCases.delete(id)
} }
fun retryAllSearches() {
search(currentQuery)
}
fun muteConversation(status: Pair<Status, StatusViewData.Concrete>, mute: Boolean) { fun muteConversation(status: Pair<Status, StatusViewData.Concrete>, mute: Boolean) {
val idx = loadedStatuses.indexOf(status) val idx = loadedStatuses.indexOf(status)
if (idx >= 0) { if (idx >= 0) {
@ -230,7 +213,7 @@ class SearchViewModel @Inject constructor(
status.second.copy(status = newStatus) status.second.copy(status = newStatus)
) )
loadedStatuses[idx] = newPair loadedStatuses[idx] = newPair
repoResultStatus.value?.refresh?.invoke() statusesPagingSourceFactory.invalidate()
} }
timelineCases.muteConversation(status.first.id, mute) timelineCases.muteConversation(status.first.id, mute)
.onErrorReturnItem(status.first) .onErrorReturnItem(status.first)
@ -240,5 +223,6 @@ class SearchViewModel @Inject constructor(
companion object { companion object {
private const val TAG = "SearchViewModel" private const val TAG = "SearchViewModel"
private const val DEFAULT_LOAD_SIZE = 20
} }
} }

View File

@ -1,4 +1,4 @@
/* Copyright 2019 Joel Pyska /* Copyright 2021 Tusky Contributors
* *
* This file is a part of Tusky. * 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.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.paging.PagedListAdapter import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.AccountViewHolder import com.keylesspalace.tusky.adapter.AccountViewHolder
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.interfaces.LinkListener
class SearchAccountsAdapter(private val linkListener: LinkListener, private val animateAvatars: Boolean, private val animateEmojis: Boolean) class SearchAccountsAdapter(private val linkListener: LinkListener, private val animateAvatars: Boolean, private val animateEmojis: Boolean)
: PagedListAdapter<Account, RecyclerView.ViewHolder>(ACCOUNT_COMPARATOR) { : PagingDataAdapter<Account, AccountViewHolder>(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) val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_account, parent, false) .inflate(R.layout.item_account, parent, false)
return AccountViewHolder(view) return AccountViewHolder(view)
} }
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onBindViewHolder(holder: AccountViewHolder, position: Int) {
getItem(position)?.let { item -> getItem(position)?.let { item ->
(holder as AccountViewHolder).apply { holder.apply {
setupWithAccount(item, animateAvatars, animateEmojis) setupWithAccount(item, animateAvatars, animateEmojis)
setupLinkListener(linkListener) setupLinkListener(linkListener)
} }
@ -52,7 +51,5 @@ class SearchAccountsAdapter(private val linkListener: LinkListener, private val
override fun areItemsTheSame(oldItem: Account, newItem: Account): Boolean = override fun areItemsTheSame(oldItem: Account, newItem: Account): Boolean =
oldItem.id == newItem.id oldItem.id == newItem.id
} }
} }
}
}

View File

@ -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 <http://www.gnu.org/licenses>. */
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<T>(
private val mastodonApi: MastodonApi,
private val searchType: SearchType,
private val searchRequest: String,
private val disposables: CompositeDisposable,
private val retryExecutor: Executor,
private val initialItems: List<T>? = null,
private val parser: (SearchResult?) -> List<T>,
private val source: SearchDataSourceFactory<T>) : PositionalDataSource<T>() {
val networkState = MutableLiveData<NetworkState>()
private var retry: (() -> Any)? = null
val initialLoad = MutableLiveData<NetworkState>()
fun retry() {
retry?.let {
retryExecutor.execute {
it.invoke()
}
}
}
override fun loadInitial(params: LoadInitialParams, callback: LoadInitialCallback<T>) {
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<T>) {
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)
}
}

View File

@ -1,4 +1,4 @@
/* Copyright 2019 Joel Pyska /* Copyright 2021 Tusky Contributors
* *
* This file is a part of Tusky. * 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.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.paging.PagedListAdapter import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import com.keylesspalace.tusky.databinding.ItemHashtagBinding import com.keylesspalace.tusky.databinding.ItemHashtagBinding
import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.HashTag
@ -25,7 +25,7 @@ import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.BindingHolder
class SearchHashtagsAdapter(private val linkListener: LinkListener) class SearchHashtagsAdapter(private val linkListener: LinkListener)
: PagedListAdapter<HashTag, BindingHolder<ItemHashtagBinding>>(HASHTAG_COMPARATOR) { : PagingDataAdapter<HashTag, BindingHolder<ItemHashtagBinding>>(HASHTAG_COMPARATOR) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemHashtagBinding> { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemHashtagBinding> {
val binding = ItemHashtagBinding.inflate(LayoutInflater.from(parent.context), parent, false) 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 = override fun areItemsTheSame(oldItem: HashTag, newItem: HashTag): Boolean =
oldItem.name == newItem.name oldItem.name == newItem.name
} }
} }
}
}

View File

@ -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 <http://www.gnu.org/licenses>. */
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<T: Any>(
private val mastodonApi: MastodonApi,
private val searchType: SearchType,
private val searchRequest: String,
private val initialItems: List<T>?,
private val parser: (SearchResult) -> List<T>) : PagingSource<Int, T>() {
override fun getRefreshKey(state: PagingState<Int, T>): Int? {
return null
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, T> {
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)
}
}
}

View File

@ -1,4 +1,4 @@
/* Copyright 2019 Joel Pyska /* Copyright 2021 Tusky Contributors
* *
* This file is a part of Tusky. * This file is a part of Tusky.
* *
@ -15,30 +15,39 @@
package com.keylesspalace.tusky.components.search.adapter 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.components.search.SearchType
import com.keylesspalace.tusky.entity.SearchResult import com.keylesspalace.tusky.entity.SearchResult
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import io.reactivex.rxjava3.disposables.CompositeDisposable
import java.util.concurrent.Executor
class SearchDataSourceFactory<T>( class SearchPagingSourceFactory<T : Any>(
private val mastodonApi: MastodonApi, private val mastodonApi: MastodonApi,
private val searchType: SearchType, private val searchType: SearchType,
private val searchRequest: String, private val initialItems: List<T>? = null,
private val disposables: CompositeDisposable, private val parser: (SearchResult) -> List<T>
private val retryExecutor: Executor, ) : () -> SearchPagingSource<T> {
private val cacheData: List<T>? = null,
private val parser: (SearchResult?) -> List<T>) : DataSource.Factory<Int, T>() {
val sourceLiveData = MutableLiveData<SearchDataSource<T>>() private var searchRequest: String = ""
var exhausted = false private var currentSource: SearchPagingSource<T>? = null
override fun create(): DataSource<Int, T> { override fun invoke(): SearchPagingSource<T> {
val source = SearchDataSource(mastodonApi, searchType, searchRequest, disposables, retryExecutor, cacheData, parser, this) return SearchPagingSource(
sourceLiveData.postValue(source) mastodonApi = mastodonApi,
return source 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()
} }
} }

View File

@ -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 <http://www.gnu.org/licenses>. */
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<T>(private val mastodonApi: MastodonApi) {
private val executor = Executors.newSingleThreadExecutor()
fun getSearchData(searchType: SearchType, searchRequest: String, disposables: CompositeDisposable, pageSize: Int = 20,
initialItems: List<T>? = null, parser: (SearchResult?) -> List<T>): Listing<T> {
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
}
)
}
}

View File

@ -1,4 +1,4 @@
/* Copyright 2019 Joel Pyska /* Copyright 2021 Tusky Contributors
* *
* This file is a part of Tusky. * 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.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.paging.PagedListAdapter import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.StatusViewHolder import com.keylesspalace.tusky.adapter.StatusViewHolder
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
@ -28,36 +27,34 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
class SearchStatusesAdapter( class SearchStatusesAdapter(
private val statusDisplayOptions: StatusDisplayOptions, private val statusDisplayOptions: StatusDisplayOptions,
private val statusListener: StatusActionListener private val statusListener: StatusActionListener
) : PagedListAdapter<Pair<Status, StatusViewData.Concrete>, RecyclerView.ViewHolder>(STATUS_COMPARATOR) { ) : PagingDataAdapter<Pair<Status, StatusViewData.Concrete>, 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) val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_status, parent, false) .inflate(R.layout.item_status, parent, false)
return StatusViewHolder(view) return StatusViewHolder(view)
} }
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onBindViewHolder(holder: StatusViewHolder, position: Int) {
getItem(position)?.let { item -> 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<Status, StatusViewData.Concrete>? { fun item(position: Int): Pair<Status, StatusViewData.Concrete>? {
return super.getItem(position) return getItem(position)
} }
companion object { companion object {
val STATUS_COMPARATOR = object : DiffUtil.ItemCallback<Pair<Status, StatusViewData.Concrete>>() { val STATUS_COMPARATOR = object : DiffUtil.ItemCallback<Pair<Status, StatusViewData.Concrete>>() {
override fun areContentsTheSame(oldItem: Pair<Status, StatusViewData.Concrete>, newItem: Pair<Status, StatusViewData.Concrete>): Boolean = override fun areContentsTheSame(oldItem: Pair<Status, StatusViewData.Concrete>, newItem: Pair<Status, StatusViewData.Concrete>): Boolean =
oldItem.second == newItem.second oldItem == newItem
override fun areItemsTheSame(oldItem: Pair<Status, StatusViewData.Concrete>, newItem: Pair<Status, StatusViewData.Concrete>): Boolean = override fun areItemsTheSame(oldItem: Pair<Status, StatusViewData.Concrete>, newItem: Pair<Status, StatusViewData.Concrete>): Boolean =
oldItem.second.id == newItem.second.id oldItem.second.id == newItem.second.id
} }
} }
}
}

View File

@ -1,4 +1,4 @@
/* Copyright 2019 Joel Pyska /* Copyright 2021 Tusky Contributors
* *
* This file is a part of Tusky. * This file is a part of Tusky.
* *
@ -15,17 +15,16 @@
package com.keylesspalace.tusky.components.search.fragments package com.keylesspalace.tusky.components.search.fragments
import androidx.lifecycle.LiveData import androidx.paging.PagingData
import androidx.paging.PagedList import androidx.paging.PagingDataAdapter
import androidx.paging.PagedListAdapter
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.keylesspalace.tusky.components.search.adapter.SearchAccountsAdapter import com.keylesspalace.tusky.components.search.adapter.SearchAccountsAdapter
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.NetworkState import kotlinx.coroutines.flow.Flow
class SearchAccountsFragment : SearchFragment<Account>() { class SearchAccountsFragment : SearchFragment<Account>() {
override fun createAdapter(): PagedListAdapter<Account, *> { override fun createAdapter(): PagingDataAdapter<Account, *> {
val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context) val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context)
return SearchAccountsAdapter( return SearchAccountsAdapter(
@ -35,12 +34,8 @@ class SearchAccountsFragment : SearchFragment<Account>() {
) )
} }
override val networkStateRefresh: LiveData<NetworkState> override val data: Flow<PagingData<Account>>
get() = viewModel.networkStateAccountRefresh get() = viewModel.accountsFlow
override val networkState: LiveData<NetworkState>
get() = viewModel.networkStateAccount
override val data: LiveData<PagedList<Account>>
get() = viewModel.accounts
companion object { companion object {
fun newInstance() = SearchAccountsFragment() fun newInstance() = SearchAccountsFragment()

View File

@ -4,9 +4,10 @@ import android.os.Bundle
import android.view.View import android.view.View
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.LiveData import androidx.lifecycle.lifecycleScope
import androidx.paging.PagedList import androidx.paging.LoadState
import androidx.paging.PagedListAdapter import androidx.paging.PagingData
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.SimpleItemAnimator 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.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.interfaces.LinkListener 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 import javax.inject.Inject
abstract class SearchFragment<T> : Fragment(R.layout.fragment_search), abstract class SearchFragment<T: Any> : Fragment(R.layout.fragment_search),
LinkListener, Injectable, SwipeRefreshLayout.OnRefreshListener { LinkListener, Injectable, SwipeRefreshLayout.OnRefreshListener {
@Inject @Inject
@ -36,12 +41,12 @@ abstract class SearchFragment<T> : Fragment(R.layout.fragment_search),
private var snackbarErrorRetry: Snackbar? = null private var snackbarErrorRetry: Snackbar? = null
abstract fun createAdapter(): PagedListAdapter<T, *> abstract fun createAdapter(): PagingDataAdapter<T, *>
abstract val networkStateRefresh: LiveData<NetworkState> abstract val data: Flow<PagingData<T>>
abstract val networkState: LiveData<NetworkState> protected lateinit var adapter: PagingDataAdapter<T, *>
abstract val data: LiveData<PagedList<T>>
protected lateinit var adapter: PagedListAdapter<T, *> private var currentQuery: String = ""
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
initAdapter() initAdapter()
@ -55,32 +60,32 @@ abstract class SearchFragment<T> : Fragment(R.layout.fragment_search),
} }
private fun subscribeObservables() { private fun subscribeObservables() {
data.observe(viewLifecycleOwner) { viewLifecycleOwner.lifecycleScope.launch {
adapter.submitList(it) data.collectLatest { pagingData ->
} adapter.submitData(pagingData)
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()
} }
} }
}
private fun checkNoData() { adapter.addLoadStateListener { loadState ->
showNoData(adapter.itemCount == 0)
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() { private fun initAdapter() {
@ -92,20 +97,12 @@ abstract class SearchFragment<T> : Fragment(R.layout.fragment_search),
(binding.searchRecyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false (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() { private fun showError() {
if (snackbarErrorRetry?.isShown != true) { if (snackbarErrorRetry?.isShown != true) {
snackbarErrorRetry = Snackbar.make(binding.root, R.string.failed_search, Snackbar.LENGTH_INDEFINITE) snackbarErrorRetry = Snackbar.make(binding.root, R.string.failed_search, Snackbar.LENGTH_INDEFINITE)
snackbarErrorRetry?.setAction(R.string.action_retry) { snackbarErrorRetry?.setAction(R.string.action_retry) {
snackbarErrorRetry = null snackbarErrorRetry = null
viewModel.retryAllSearches() adapter.retry()
} }
snackbarErrorRetry?.show() snackbarErrorRetry?.show()
} }
@ -123,11 +120,6 @@ abstract class SearchFragment<T> : Fragment(R.layout.fragment_search),
get() = (activity as? BottomSheetActivity) get() = (activity as? BottomSheetActivity)
override fun onRefresh() { override fun onRefresh() {
adapter.refresh()
// Dismissed here because the RecyclerView bottomProgressBar is shown as soon as the retry begins.
binding.swipeRefreshLayout.post {
binding.swipeRefreshLayout.isRefreshing = false
}
viewModel.retryAllSearches()
} }
} }

View File

@ -1,4 +1,4 @@
/* Copyright 2019 Joel Pyska /* Copyright 2021 Tusky Contributors
* *
* This file is a part of Tusky. * This file is a part of Tusky.
* *
@ -15,22 +15,18 @@
package com.keylesspalace.tusky.components.search.fragments package com.keylesspalace.tusky.components.search.fragments
import androidx.lifecycle.LiveData import androidx.paging.PagingData
import androidx.paging.PagedList import androidx.paging.PagingDataAdapter
import androidx.paging.PagedListAdapter
import com.keylesspalace.tusky.components.search.adapter.SearchHashtagsAdapter import com.keylesspalace.tusky.components.search.adapter.SearchHashtagsAdapter
import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.util.NetworkState import kotlinx.coroutines.flow.Flow
class SearchHashtagsFragment : SearchFragment<HashTag>() { class SearchHashtagsFragment : SearchFragment<HashTag>() {
override val networkStateRefresh: LiveData<NetworkState>
get() = viewModel.networkStateHashTagRefresh
override val networkState: LiveData<NetworkState>
get() = viewModel.networkStateHashTag
override val data: LiveData<PagedList<HashTag>>
get() = viewModel.hashtags
override fun createAdapter(): PagedListAdapter<HashTag, *> = SearchHashtagsAdapter(this) override val data: Flow<PagingData<HashTag>>
get() = viewModel.hashtagsFlow
override fun createAdapter(): PagingDataAdapter<HashTag, *> = SearchHashtagsAdapter(this)
companion object { companion object {
fun newInstance() = SearchHashtagsFragment() fun newInstance() = SearchHashtagsFragment()

View File

@ -1,4 +1,4 @@
/* Copyright 2019 Joel Pyska /* Copyright 2021 Tusky Contributors
* *
* This file is a part of Tusky. * This file is a part of Tusky.
* *
@ -32,9 +32,8 @@ import androidx.appcompat.widget.PopupMenu
import androidx.core.app.ActivityOptionsCompat import androidx.core.app.ActivityOptionsCompat
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LiveData import androidx.paging.PagingData
import androidx.paging.PagedList import androidx.paging.PagingDataAdapter
import androidx.paging.PagedListAdapter
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager 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.settings.PrefKeys
import com.keylesspalace.tusky.util.CardViewMode import com.keylesspalace.tusky.util.CardViewMode
import com.keylesspalace.tusky.util.LinkHelper import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.NetworkState
import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.view.showMuteAccountDialog import com.keylesspalace.tusky.view.showMuteAccountDialog
import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.AttachmentViewData
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import kotlinx.coroutines.flow.Flow
class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concrete>>(), StatusActionListener { class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concrete>>(), StatusActionListener {
override val networkStateRefresh: LiveData<NetworkState> override val data: Flow<PagingData<Pair<Status, StatusViewData.Concrete>>>
get() = viewModel.networkStateStatusRefresh get() = viewModel.statusesFlow
override val networkState: LiveData<NetworkState>
get() = viewModel.networkStateStatus
override val data: LiveData<PagedList<Pair<Status, StatusViewData.Concrete>>>
get() = viewModel.statuses
private val searchAdapter private val searchAdapter
get() = super.adapter as SearchStatusesAdapter get() = super.adapter as SearchStatusesAdapter
override fun createAdapter(): PagedListAdapter<Pair<Status, StatusViewData.Concrete>, *> { override fun createAdapter(): PagingDataAdapter<Pair<Status, StatusViewData.Concrete>, *> {
val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context) val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context)
val statusDisplayOptions = StatusDisplayOptions( val statusDisplayOptions = StatusDisplayOptions(
animateAvatars = preferences.getBoolean("animateGifAvatars", false), animateAvatars = preferences.getBoolean("animateGifAvatars", false),
@ -96,37 +91,37 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
} }
override fun onContentHiddenChange(isShowing: Boolean, position: Int) { override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
searchAdapter.getItem(position)?.let { searchAdapter.item(position)?.let {
viewModel.contentHiddenChange(it, isShowing) viewModel.contentHiddenChange(it, isShowing)
} }
} }
override fun onReply(position: Int) { override fun onReply(position: Int) {
searchAdapter.getItem(position)?.first?.let { status -> searchAdapter.item(position)?.first?.let { status ->
reply(status) reply(status)
} }
} }
override fun onFavourite(favourite: Boolean, position: Int) { override fun onFavourite(favourite: Boolean, position: Int) {
searchAdapter.getItem(position)?.let { status -> searchAdapter.item(position)?.let { status ->
viewModel.favorite(status, favourite) viewModel.favorite(status, favourite)
} }
} }
override fun onBookmark(bookmark: Boolean, position: Int) { override fun onBookmark(bookmark: Boolean, position: Int) {
searchAdapter.getItem(position)?.let { status -> searchAdapter.item(position)?.let { status ->
viewModel.bookmark(status, bookmark) viewModel.bookmark(status, bookmark)
} }
} }
override fun onMore(view: View, position: Int) { override fun onMore(view: View, position: Int) {
searchAdapter.getItem(position)?.first?.let { searchAdapter.item(position)?.first?.let {
more(it, view, position) more(it, view, position)
} }
} }
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { 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) { when (actionable.attachments[attachmentIndex].type) {
Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> { Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> {
val attachments = AttachmentViewData.list(actionable) val attachments = AttachmentViewData.list(actionable)
@ -146,26 +141,24 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
LinkHelper.openLink(actionable.attachments[attachmentIndex].url, context) LinkHelper.openLink(actionable.attachments[attachmentIndex].url, context)
} }
} }
} }
} }
override fun onViewThread(position: Int) { override fun onViewThread(position: Int) {
searchAdapter.getItem(position)?.first?.let { status -> searchAdapter.item(position)?.first?.let { status ->
val actionableStatus = status.actionableStatus val actionableStatus = status.actionableStatus
bottomSheetActivity?.viewThread(actionableStatus.id, actionableStatus.url) bottomSheetActivity?.viewThread(actionableStatus.id, actionableStatus.url)
} }
} }
override fun onOpenReblog(position: Int) { override fun onOpenReblog(position: Int) {
searchAdapter.getItem(position)?.first?.let { status -> searchAdapter.item(position)?.first?.let { status ->
bottomSheetActivity?.viewAccount(status.account.id) bottomSheetActivity?.viewAccount(status.account.id)
} }
} }
override fun onExpandedChange(expanded: Boolean, position: Int) { override fun onExpandedChange(expanded: Boolean, position: Int) {
searchAdapter.getItem(position)?.let { searchAdapter.item(position)?.let {
viewModel.expandedChange(it, expanded) viewModel.expandedChange(it, expanded)
} }
} }
@ -175,25 +168,25 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
} }
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
searchAdapter.getItem(position)?.let { searchAdapter.item(position)?.let {
viewModel.collapsedChange(it, isCollapsed) viewModel.collapsedChange(it, isCollapsed)
} }
} }
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) { override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
searchAdapter.getItem(position)?.let { searchAdapter.item(position)?.let {
viewModel.voteInPoll(it, choices) viewModel.voteInPoll(it, choices)
} }
} }
private fun removeItem(position: Int) { private fun removeItem(position: Int) {
searchAdapter.getItem(position)?.let { searchAdapter.item(position)?.let {
viewModel.removeItem(it) viewModel.removeItem(it)
} }
} }
override fun onReblog(reblog: Boolean, position: Int) { override fun onReblog(reblog: Boolean, position: Int) {
searchAdapter.getItem(position)?.let { status -> searchAdapter.item(position)?.let { status ->
viewModel.reblog(status, reblog) viewModel.reblog(status, reblog)
} }
} }
@ -323,7 +316,7 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
return@setOnMenuItemClickListener true return@setOnMenuItemClickListener true
} }
R.id.status_mute_conversation -> { R.id.status_mute_conversation -> {
searchAdapter.getItem(position)?.let { foundStatus -> searchAdapter.item(position)?.let { foundStatus ->
viewModel.muteConversation(foundStatus, status.muted != true) viewModel.muteConversation(foundStatus, status.muted != true)
} }
return@setOnMenuItemClickListener true return@setOnMenuItemClickListener true

View File

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

View File

@ -15,27 +15,29 @@
package com.keylesspalace.tusky.db package com.keylesspalace.tusky.db
import androidx.paging.DataSource import androidx.paging.PagingSource
import androidx.room.* 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 com.keylesspalace.tusky.components.conversation.ConversationEntity
import io.reactivex.rxjava3.core.Single
@Dao @Dao
interface ConversationsDao { interface ConversationsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(conversations: List<ConversationEntity>) suspend fun insert(conversations: List<ConversationEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(conversation: ConversationEntity): Single<Long> suspend fun insert(conversation: ConversationEntity): Long
@Delete @Delete
fun delete(conversation: ConversationEntity): Single<Int> suspend fun delete(conversation: ConversationEntity): Int
@Query("SELECT * FROM ConversationEntity WHERE accountId = :accountId ORDER BY s_createdAt DESC") @Query("SELECT * FROM ConversationEntity WHERE accountId = :accountId ORDER BY s_createdAt DESC")
fun conversationsForAccount(accountId: Long) : DataSource.Factory<Int, ConversationEntity> fun conversationsForAccount(accountId: Long) : PagingSource<Int, ConversationEntity>
@Query("DELETE FROM ConversationEntity WHERE accountId = :accountId") @Query("DELETE FROM ConversationEntity WHERE accountId = :accountId")
fun deleteForAccount(accountId: Long) fun deleteForAccount(accountId: Long)
} }

View File

@ -83,6 +83,7 @@ class AppModule {
AppDatabase.MIGRATION_16_17, AppDatabase.MIGRATION_17_18, AppDatabase.MIGRATION_18_19, 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_19_20, AppDatabase.MIGRATION_20_21, AppDatabase.MIGRATION_21_22,
AppDatabase.MIGRATION_22_23, AppDatabase.MIGRATION_23_24, AppDatabase.MIGRATION_24_25, AppDatabase.MIGRATION_22_23, AppDatabase.MIGRATION_23_24, AppDatabase.MIGRATION_24_25,
AppDatabase.MIGRATION_26_27,
AppDatabase.Migration25_26(appContext.getExternalFilesDir("Tusky")) AppDatabase.Migration25_26(appContext.getExternalFilesDir("Tusky"))
) )
.build() .build()

View File

@ -15,7 +15,27 @@
package com.keylesspalace.tusky.network 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.Completable
import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.core.Single
import okhttp3.MultipartBody import okhttp3.MultipartBody
@ -23,8 +43,20 @@ import okhttp3.RequestBody
import okhttp3.ResponseBody import okhttp3.ResponseBody
import retrofit2.Call import retrofit2.Call
import retrofit2.Response import retrofit2.Response
import retrofit2.http.* import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.Field 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/ * for documentation of the Mastodon REST API see https://docs.joinmastodon.org/api/
@ -466,10 +498,15 @@ interface MastodonApi {
): Completable ): Completable
@GET("/api/v1/conversations") @GET("/api/v1/conversations")
fun getConversations( suspend fun getConversations(
@Query("max_id") maxId: String? = null, @Query("max_id") maxId: String? = null,
@Query("limit") limit: Int @Query("limit") limit: Int
): Call<List<Conversation>> ): List<Conversation>
@DELETE("/api/v1/conversations/{id}")
suspend fun deleteConversation(
@Path("id") conversationId: String
)
@FormUrlEncoded @FormUrlEncoded
@POST("api/v1/filters") @POST("api/v1/filters")

View File

@ -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 that is necessary for a UI to show a listing and interact w/ the rest of the system
*/ */
data class BiListing<T>( data class BiListing<T: Any>(
// the LiveData of paged lists for the UI to observe // the LiveData of paged lists for the UI to observe
val pagedList: LiveData<PagedList<T>>, val pagedList: LiveData<PagedList<T>>,
// represents the network request status for load data before first to show to the user // represents the network request status for load data before first to show to the user

View File

@ -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<T>(
// the LiveData of paged lists for the UI to observe
val pagedList: LiveData<PagedList<T>>,
// represents the network request status to show to the user
val networkState: LiveData<NetworkState>,
// represents the refresh status to show to the user. Separate from networkState, this
// value is importantly only when refresh is requested.
val refreshState: LiveData<NetworkState>,
// refreshes the whole data and fetches it from scratch.
val refresh: () -> Unit,
// retries any failed requests.
val retry: () -> Unit)

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/status_mute_conversation"
android:title="@string/action_mute_conversation" />
<item
android:id="@+id/status_unmute_conversation"
android:title="@string/action_unmute_conversation" />
<item
android:id="@+id/conversation_delete"
android:title="@string/action_delete_conversation" />
</menu>

View File

@ -88,6 +88,7 @@
<string name="action_report">Report</string> <string name="action_report">Report</string>
<string name="action_edit">Edit</string> <string name="action_edit">Edit</string>
<string name="action_delete">Delete</string> <string name="action_delete">Delete</string>
<string name="action_delete_conversation">Delete conversation</string>
<string name="action_delete_and_redraft">Delete and re-draft</string> <string name="action_delete_and_redraft">Delete and re-draft</string>
<string name="action_send">TOOT</string> <string name="action_send">TOOT</string>
<string name="action_send_public">TOOT!</string> <string name="action_send_public">TOOT!</string>
@ -200,6 +201,7 @@
<string name="dialog_unfollow_warning">Unfollow this account?</string> <string name="dialog_unfollow_warning">Unfollow this account?</string>
<string name="dialog_delete_toot_warning">Delete this toot?</string> <string name="dialog_delete_toot_warning">Delete this toot?</string>
<string name="dialog_redraft_toot_warning">Delete and re-draft this toot?</string> <string name="dialog_redraft_toot_warning">Delete and re-draft this toot?</string>
<string name="dialog_delete_conversation_warning">Delete this conversation?</string>
<string name="mute_domain_warning">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.</string> <string name="mute_domain_warning">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.</string>
<string name="mute_domain_warning_dialog_ok">Hide entire domain</string> <string name="mute_domain_warning_dialog_ok">Hide entire domain</string>
<string name="dialog_block_warning">Block @%s?</string> <string name="dialog_block_warning">Block @%s?</string>