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 {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-rx3:1.5.0'
implementation "androidx.core:core-ktx:1.5.0"
implementation "androidx.appcompat:appcompat:1.3.0"
implementation "androidx.fragment:fragment-ktx:1.3.4"
@ -114,13 +117,11 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$lifecycleVersion"
implementation "androidx.constraintlayout:constraintlayout:2.0.4"
implementation "androidx.paging:paging-runtime-ktx:2.1.2"
implementation "androidx.paging:paging-runtime-ktx:3.0.0"
implementation "androidx.viewpager2:viewpager2:1.0.0"
implementation "androidx.work:work-runtime:2.5.0"
implementation "androidx.room:room-runtime:$roomVersion"
implementation "androidx.room:room-ktx:$roomVersion"
implementation "androidx.room:room-rxjava3:$roomVersion"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-rx3:1.5.0'
kapt "androidx.room:room-compiler:$roomVersion"
implementation "com.google.android.material:material:1.3.0"

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

View file

@ -17,114 +17,39 @@ package com.keylesspalace.tusky.components.conversation
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.AsyncPagedListDiffer
import androidx.paging.PagedList
import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListUpdateCallback
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.NetworkStateViewHolder
import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.NetworkState
import com.keylesspalace.tusky.util.StatusDisplayOptions
class ConversationAdapter(
private val statusDisplayOptions: StatusDisplayOptions,
private val listener: StatusActionListener,
private val topLoadedCallback: () -> Unit,
private val retryCallback: () -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private val statusDisplayOptions: StatusDisplayOptions,
private val listener: StatusActionListener
) : PagingDataAdapter<ConversationEntity, ConversationViewHolder>(CONVERSATION_COMPARATOR) {
private var networkState: NetworkState? = null
private val differ: AsyncPagedListDiffer<ConversationEntity> = AsyncPagedListDiffer(object : ListUpdateCallback {
override fun onInserted(position: Int, count: Int) {
notifyItemRangeInserted(position, count)
if (position == 0) {
topLoadedCallback()
}
}
override fun onRemoved(position: Int, count: Int) {
notifyItemRangeRemoved(position, count)
}
override fun onMoved(fromPosition: Int, toPosition: Int) {
notifyItemMoved(fromPosition, toPosition)
}
override fun onChanged(position: Int, count: Int, payload: Any?) {
notifyItemRangeChanged(position, count, payload)
}
}, AsyncDifferConfig.Builder(CONVERSATION_COMPARATOR).build())
fun submitList(list: PagedList<ConversationEntity>) {
differ.submitList(list)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_conversation, parent, false)
return ConversationViewHolder(view, statusDisplayOptions, listener)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
R.layout.item_network_state -> {
val binding = ItemNetworkStateBinding.inflate(LayoutInflater.from(parent.context), parent, false)
NetworkStateViewHolder(binding, retryCallback)
}
R.layout.item_conversation -> {
val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
ConversationViewHolder(view, statusDisplayOptions, listener)
}
else -> throw IllegalArgumentException("unknown view type $viewType")
}
override fun onBindViewHolder(holder: ConversationViewHolder, position: Int) {
holder.setupWithConversation(getItem(position))
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (getItemViewType(position)) {
R.layout.item_network_state -> (holder as NetworkStateViewHolder).setUpWithNetworkState(networkState, differ.itemCount == 0)
R.layout.item_conversation -> (holder as ConversationViewHolder).setupWithConversation(differ.getItem(position))
}
}
private fun hasExtraRow() = networkState != null && networkState != NetworkState.LOADED
override fun getItemViewType(position: Int): Int {
return if (hasExtraRow() && position == itemCount - 1) {
R.layout.item_network_state
} else {
R.layout.item_conversation
}
}
override fun getItemCount(): Int {
return differ.itemCount + if (hasExtraRow()) 1 else 0
}
fun setNetworkState(newNetworkState: NetworkState?) {
val previousState = this.networkState
val hadExtraRow = hasExtraRow()
this.networkState = newNetworkState
val hasExtraRow = hasExtraRow()
if (hadExtraRow != hasExtraRow) {
if (hadExtraRow) {
notifyItemRemoved(differ.itemCount)
} else {
notifyItemInserted(differ.itemCount)
}
} else if (hasExtraRow && previousState != newNetworkState) {
notifyItemChanged(itemCount - 1)
}
fun item(position: Int): ConversationEntity? {
return getItem(position)
}
companion object {
val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback<ConversationEntity>() {
override fun areContentsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean =
oldItem == newItem
override fun areItemsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean {
return oldItem.id == newItem.id
}
override fun areItemsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean =
oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean {
return oldItem == newItem
}
}
}
}
}

View file

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

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.
*
@ -20,7 +20,12 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.PopupMenu
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadState
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
@ -35,8 +40,11 @@ import com.keylesspalace.tusky.fragment.SFragment
import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.*
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.io.IOException
import com.keylesspalace.tusky.util.CardViewMode
import com.keylesspalace.tusky.util.NetworkState
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.viewBinding
@ -53,34 +61,39 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
private val binding by viewBinding(FragmentTimelineBinding::bind)
private lateinit var adapter: ConversationAdapter
private lateinit var loadStateAdapter: ConversationLoadStateAdapter
private var layoutManager: LinearLayoutManager? = null
private var initialRefreshDone: Boolean = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_timeline, container, false)
}
@ExperimentalPagingApi
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val preferences = PreferenceManager.getDefaultSharedPreferences(view.context)
val statusDisplayOptions = StatusDisplayOptions(
animateAvatars = preferences.getBoolean("animateGifAvatars", false),
mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true,
useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false),
showBotOverlay = preferences.getBoolean("showBotOverlay", true),
useBlurhash = preferences.getBoolean("useBlurhash", true),
cardViewMode = CardViewMode.NONE,
confirmReblogs = preferences.getBoolean("confirmReblogs", true),
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
animateAvatars = preferences.getBoolean("animateGifAvatars", false),
mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true,
useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false),
showBotOverlay = preferences.getBoolean("showBotOverlay", true),
useBlurhash = preferences.getBoolean("useBlurhash", true),
cardViewMode = CardViewMode.NONE,
confirmReblogs = preferences.getBoolean("confirmReblogs", true),
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
)
adapter = ConversationAdapter(statusDisplayOptions, this, ::onTopLoaded, viewModel::retry)
adapter = ConversationAdapter(statusDisplayOptions, this)
loadStateAdapter = ConversationLoadStateAdapter(adapter::retry)
binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
layoutManager = LinearLayoutManager(view.context)
binding.recyclerView.layoutManager = layoutManager
binding.recyclerView.adapter = adapter
binding.recyclerView.adapter = adapter.withLoadStateFooter(loadStateAdapter)
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
binding.progressBar.hide()
@ -88,59 +101,101 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
initSwipeToRefresh()
viewModel.conversations.observe(viewLifecycleOwner) {
adapter.submitList(it)
}
viewModel.networkState.observe(viewLifecycleOwner) {
adapter.setNetworkState(it)
lifecycleScope.launch {
viewModel.conversationFlow.collectLatest { pagingData ->
adapter.submitData(pagingData)
}
}
viewModel.load()
adapter.addLoadStateListener { loadStates ->
loadStates.refresh.let { refreshState ->
if (refreshState is LoadState.Error) {
binding.statusView.show()
if (refreshState.error is IOException) {
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) {
adapter.refresh()
}
} else {
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) {
adapter.refresh()
}
}
} else {
binding.statusView.hide()
}
binding.progressBar.visible(refreshState == LoadState.Loading && adapter.itemCount == 0)
if (refreshState is LoadState.NotLoading && !initialRefreshDone) {
// jump to top after the initial refresh finished
binding.recyclerView.scrollToPosition(0)
initialRefreshDone = true
}
if (refreshState != LoadState.Loading) {
binding.swipeRefreshLayout.isRefreshing = false
}
}
}
}
private fun initSwipeToRefresh() {
viewModel.refreshState.observe(viewLifecycleOwner) {
binding.swipeRefreshLayout.isRefreshing = it == NetworkState.LOADING
}
binding.swipeRefreshLayout.setOnRefreshListener {
viewModel.refresh()
adapter.refresh()
}
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
}
private fun onTopLoaded() {
binding.recyclerView.scrollToPosition(0)
}
override fun onReblog(reblog: Boolean, position: Int) {
// its impossible to reblog private messages
}
override fun onFavourite(favourite: Boolean, position: Int) {
viewModel.favourite(favourite, position)
adapter.item(position)?.let { conversation ->
viewModel.favourite(favourite, conversation)
}
}
override fun onBookmark(favourite: Boolean, position: Int) {
viewModel.bookmark(favourite, position)
adapter.item(position)?.let { conversation ->
viewModel.bookmark(favourite, conversation)
}
}
override fun onMore(view: View, position: Int) {
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let {
more(it.toStatus(), view, position)
adapter.item(position)?.let { conversation ->
val popup = PopupMenu(requireContext(), view)
popup.inflate(R.menu.conversation_more)
if (conversation.lastStatus.muted) {
popup.menu.removeItem(R.id.status_mute_conversation)
} else {
popup.menu.removeItem(R.id.status_unmute_conversation)
}
popup.setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.status_mute_conversation -> viewModel.muteConversation(conversation)
R.id.status_unmute_conversation -> viewModel.muteConversation(conversation)
R.id.conversation_delete -> deleteConversation(conversation)
}
true
}
popup.show()
}
}
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let {
viewMedia(attachmentIndex, AttachmentViewData.list(it.toStatus()), view)
adapter.item(position)?.let { conversation ->
viewMedia(attachmentIndex, AttachmentViewData.list(conversation.lastStatus.toStatus()), view)
}
}
override fun onViewThread(position: Int) {
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let {
val status = it.toStatus()
viewThread(status.actionableId, status.actionableStatus.url)
adapter.item(position)?.let { conversation ->
viewThread(conversation.lastStatus.id, conversation.lastStatus.url)
}
}
@ -149,11 +204,15 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
}
override fun onExpandedChange(expanded: Boolean, position: Int) {
viewModel.expandHiddenStatus(expanded, position)
adapter.item(position)?.let { conversation ->
viewModel.expandHiddenStatus(expanded, conversation)
}
}
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
viewModel.showContent(isShowing, position)
adapter.item(position)?.let { conversation ->
viewModel.showContent(isShowing, conversation)
}
}
override fun onLoadMore(position: Int) {
@ -161,7 +220,9 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
}
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
viewModel.collapseLongStatus(isCollapsed, position)
adapter.item(position)?.let { conversation ->
viewModel.collapseLongStatus(isCollapsed, conversation)
}
}
override fun onViewAccount(id: String) {
@ -176,15 +237,25 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
}
override fun removeItem(position: Int) {
viewModel.remove(position)
// not needed
}
override fun onReply(position: Int) {
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let {
reply(it.toStatus())
adapter.item(position)?.let { conversation ->
reply(conversation.lastStatus.toStatus())
}
}
private fun deleteConversation(conversation: ConversationEntity) {
AlertDialog.Builder(requireContext())
.setMessage(R.string.dialog_delete_conversation_warning)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(android.R.string.ok) { _, _ ->
viewModel.remove(conversation)
}
.show()
}
private fun jumpToTop() {
if (isAdded) {
layoutManager?.scrollToPosition(0)
@ -197,7 +268,9 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
}
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
viewModel.voteInPoll(position, choices)
adapter.item(position)?.let { conversation ->
viewModel.voteInPoll(choices, conversation)
}
}
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
import androidx.annotation.MainThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.paging.Config
import androidx.paging.toLiveData
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.entity.Conversation
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.Listing
import com.keylesspalace.tusky.util.NetworkState
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.util.concurrent.Executors
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ConversationsRepository @Inject constructor(val mastodonApi: MastodonApi, val db: AppDatabase) {
private val ioExecutor = Executors.newSingleThreadExecutor()
companion object {
private const val DEFAULT_PAGE_SIZE = 20
}
@MainThread
fun refresh(accountId: Long, showLoadingIndicator: Boolean): LiveData<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
)
}
class ConversationsRepository @Inject constructor(
val mastodonApi: MastodonApi,
val db: AppDatabase
) {
fun deleteCacheForAccount(accountId: Long) {
Single.fromCallable {
@ -102,10 +35,4 @@ class ConversationsRepository @Inject constructor(val mastodonApi: MastodonApi,
.subscribe()
}
private fun insertResultIntoDb(accountId: Long, result: List<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
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.paging.PagedList
import androidx.lifecycle.viewModelScope
import androidx.paging.ExperimentalPagingApi
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases
import com.keylesspalace.tusky.util.Listing
import com.keylesspalace.tusky.util.NetworkState
import com.keylesspalace.tusky.util.RxAwareViewModel
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
import javax.inject.Inject
class ConversationsViewModel @Inject constructor(
private val repository: ConversationsRepository,
private val timelineCases: TimelineCases,
private val database: AppDatabase,
private val accountManager: AccountManager
private val accountManager: AccountManager,
private val api: MastodonApi
) : RxAwareViewModel() {
private val repoResult = MutableLiveData<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>> =
Transformations.switchMap(repoResult) { it.pagedList }
val networkState: LiveData<NetworkState> =
Transformations.switchMap(repoResult) { it.networkState }
val refreshState: LiveData<NetworkState> =
Transformations.switchMap(repoResult) { it.refreshState }
fun favourite(favourite: Boolean, conversation: ConversationEntity) {
viewModelScope.launch {
try {
timelineCases.favourite(conversation.lastStatus.id, favourite).await()
fun load() {
val accountId = accountManager.activeAccount?.id ?: return
if (repoResult.value == null) {
repository.refresh(accountId, false)
val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(favourited = favourite)
)
database.conversationDao().insert(newConversation)
} catch (e: Exception) {
Log.w(TAG, "failed to favourite status", e)
}
}
repoResult.value = repository.conversations(accountId)
}
fun refresh() {
repoResult.value?.refresh?.invoke()
}
fun bookmark(bookmark: Boolean, conversation: ConversationEntity) {
viewModelScope.launch {
try {
timelineCases.bookmark(conversation.lastStatus.id, bookmark).await()
fun retry() {
repoResult.value?.retry?.invoke()
}
val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(bookmarked = bookmark)
)
fun favourite(favourite: Boolean, position: Int) {
conversations.value?.getOrNull(position)?.let { conversation ->
timelineCases.favourite(conversation.lastStatus.id, favourite)
.flatMap {
val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(favourited = favourite)
)
database.conversationDao().insert(newConversation)
}
.subscribeOn(Schedulers.io())
.doOnError { t ->
Log.w(
"ConversationViewModel",
"Failed to favourite conversation",
t
)
}
.onErrorReturnItem(0)
.subscribe()
.autoDispose()
database.conversationDao().insert(newConversation)
} catch (e: Exception) {
Log.w(TAG, "failed to bookmark status", e)
}
}
}
fun bookmark(bookmark: Boolean, position: Int) {
conversations.value?.getOrNull(position)?.let { conversation ->
timelineCases.bookmark(conversation.lastStatus.id, bookmark)
.flatMap {
val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(bookmarked = bookmark)
)
fun voteInPoll(choices: List<Int>, conversation: ConversationEntity) {
viewModelScope.launch {
try {
val poll = timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.poll?.id!!, choices).await()
val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(poll = poll)
)
database.conversationDao().insert(newConversation)
}
.subscribeOn(Schedulers.io())
.doOnError { t ->
Log.w(
"ConversationViewModel",
"Failed to bookmark conversation",
t
)
}
.onErrorReturnItem(0)
.subscribe()
.autoDispose()
database.conversationDao().insert(newConversation)
} catch (e: Exception) {
Log.w(TAG, "failed to vote in poll", e)
}
}
}
fun voteInPoll(position: Int, choices: MutableList<Int>) {
conversations.value?.getOrNull(position)?.let { conversation ->
val poll = conversation.lastStatus.poll ?: return
timelineCases.voteInPoll(conversation.lastStatus.id, poll.id, choices)
.flatMap { newPoll ->
val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(poll = newPoll)
)
database.conversationDao().insert(newConversation)
}
.subscribeOn(Schedulers.io())
.doOnError { t ->
Log.w(
"ConversationViewModel",
"Failed to favourite conversation",
t
)
}
.onErrorReturnItem(0)
.subscribe()
.autoDispose()
}
}
fun expandHiddenStatus(expanded: Boolean, position: Int) {
conversations.value?.getOrNull(position)?.let { conversation ->
fun expandHiddenStatus(expanded: Boolean, conversation: ConversationEntity) {
viewModelScope.launch {
val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(expanded = expanded)
)
@ -131,8 +102,8 @@ class ConversationsViewModel @Inject constructor(
}
}
fun collapseLongStatus(collapsed: Boolean, position: Int) {
conversations.value?.getOrNull(position)?.let { conversation ->
fun collapseLongStatus(collapsed: Boolean, conversation: ConversationEntity) {
viewModelScope.launch {
val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(collapsed = collapsed)
)
@ -140,8 +111,8 @@ class ConversationsViewModel @Inject constructor(
}
}
fun showContent(showing: Boolean, position: Int) {
conversations.value?.getOrNull(position)?.let { conversation ->
fun showContent(showing: Boolean, conversation: ConversationEntity) {
viewModelScope.launch {
val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(showingHiddenContent = showing)
)
@ -149,16 +120,42 @@ class ConversationsViewModel @Inject constructor(
}
}
fun remove(position: Int) {
conversations.value?.getOrNull(position)?.let {
refresh()
fun remove(conversation: ConversationEntity) {
viewModelScope.launch {
try {
api.deleteConversation(conversationId = conversation.id)
database.conversationDao().delete(conversation)
} catch (e: Exception) {
Log.w(TAG, "failed to delete conversation", e)
}
}
}
private fun saveConversationToDb(conversation: ConversationEntity) {
database.conversationDao().insert(conversation)
.subscribeOn(Schedulers.io())
.subscribe()
fun muteConversation(conversation: ConversationEntity) {
viewModelScope.launch {
try {
val newStatus = timelineCases.muteConversation(
conversation.lastStatus.id,
!conversation.lastStatus.muted
).await()
val newConversation = conversation.copy(
lastStatus = newStatus.toEntity()
)
database.conversationDao().insert(newConversation)
} catch (e: Exception) {
Log.w(TAG, "failed to mute conversation", e)
}
}
}
suspend fun saveConversationToDb(conversation: ConversationEntity) {
database.conversationDao().insert(conversation)
}
companion object {
private const val TAG = "ConversationsViewModel"
}
}

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

View file

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

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.
*
@ -17,7 +17,7 @@ package com.keylesspalace.tusky.components.search.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.PagedListAdapter
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import com.keylesspalace.tusky.databinding.ItemHashtagBinding
import com.keylesspalace.tusky.entity.HashTag
@ -25,7 +25,7 @@ import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.BindingHolder
class SearchHashtagsAdapter(private val linkListener: LinkListener)
: PagedListAdapter<HashTag, BindingHolder<ItemHashtagBinding>>(HASHTAG_COMPARATOR) {
: PagingDataAdapter<HashTag, BindingHolder<ItemHashtagBinding>>(HASHTAG_COMPARATOR) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemHashtagBinding> {
val binding = ItemHashtagBinding.inflate(LayoutInflater.from(parent.context), parent, false)
@ -48,7 +48,5 @@ class SearchHashtagsAdapter(private val linkListener: LinkListener)
override fun areItemsTheSame(oldItem: HashTag, newItem: HashTag): Boolean =
oldItem.name == newItem.name
}
}
}
}

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.
*
@ -15,30 +15,39 @@
package com.keylesspalace.tusky.components.search.adapter
import androidx.lifecycle.MutableLiveData
import androidx.paging.DataSource
import com.keylesspalace.tusky.components.search.SearchType
import com.keylesspalace.tusky.entity.SearchResult
import com.keylesspalace.tusky.network.MastodonApi
import io.reactivex.rxjava3.disposables.CompositeDisposable
import java.util.concurrent.Executor
class SearchDataSourceFactory<T>(
private val mastodonApi: MastodonApi,
private val searchType: SearchType,
private val searchRequest: String,
private val disposables: CompositeDisposable,
private val retryExecutor: Executor,
private val cacheData: List<T>? = null,
private val parser: (SearchResult?) -> List<T>) : DataSource.Factory<Int, T>() {
class SearchPagingSourceFactory<T : Any>(
private val mastodonApi: MastodonApi,
private val searchType: SearchType,
private val initialItems: List<T>? = null,
private val parser: (SearchResult) -> List<T>
) : () -> SearchPagingSource<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> {
val source = SearchDataSource(mastodonApi, searchType, searchRequest, disposables, retryExecutor, cacheData, parser, this)
sourceLiveData.postValue(source)
return source
override fun invoke(): SearchPagingSource<T> {
return SearchPagingSource(
mastodonApi = mastodonApi,
searchType = searchType,
searchRequest = searchRequest,
initialItems = initialItems,
parser = parser
).also { source ->
currentSource = source
}
}
fun newSearch(newSearchRequest: String) {
this.searchRequest = newSearchRequest
currentSource?.invalidate()
}
fun invalidate() {
currentSource?.invalidate()
}
}

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.
*
@ -17,9 +17,8 @@ package com.keylesspalace.tusky.components.search.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.PagedListAdapter
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.StatusViewHolder
import com.keylesspalace.tusky.entity.Status
@ -28,36 +27,34 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.viewdata.StatusViewData
class SearchStatusesAdapter(
private val statusDisplayOptions: StatusDisplayOptions,
private val statusListener: StatusActionListener
) : PagedListAdapter<Pair<Status, StatusViewData.Concrete>, RecyclerView.ViewHolder>(STATUS_COMPARATOR) {
private val statusDisplayOptions: StatusDisplayOptions,
private val statusListener: StatusActionListener
) : 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)
.inflate(R.layout.item_status, parent, false)
.inflate(R.layout.item_status, parent, false)
return StatusViewHolder(view)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
override fun onBindViewHolder(holder: StatusViewHolder, position: Int) {
getItem(position)?.let { item ->
(holder as StatusViewHolder).setupWithStatus(item.second, statusListener, statusDisplayOptions)
holder.setupWithStatus(item.second, statusListener, statusDisplayOptions)
}
}
public override fun getItem(position: Int): Pair<Status, StatusViewData.Concrete>? {
return super.getItem(position)
fun item(position: Int): Pair<Status, StatusViewData.Concrete>? {
return getItem(position)
}
companion object {
val STATUS_COMPARATOR = object : DiffUtil.ItemCallback<Pair<Status, StatusViewData.Concrete>>() {
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 =
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.
*
@ -15,17 +15,16 @@
package com.keylesspalace.tusky.components.search.fragments
import androidx.lifecycle.LiveData
import androidx.paging.PagedList
import androidx.paging.PagedListAdapter
import androidx.paging.PagingData
import androidx.paging.PagingDataAdapter
import androidx.preference.PreferenceManager
import com.keylesspalace.tusky.components.search.adapter.SearchAccountsAdapter
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.NetworkState
import kotlinx.coroutines.flow.Flow
class SearchAccountsFragment : SearchFragment<Account>() {
override fun createAdapter(): PagedListAdapter<Account, *> {
override fun createAdapter(): PagingDataAdapter<Account, *> {
val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context)
return SearchAccountsAdapter(
@ -35,12 +34,8 @@ class SearchAccountsFragment : SearchFragment<Account>() {
)
}
override val networkStateRefresh: LiveData<NetworkState>
get() = viewModel.networkStateAccountRefresh
override val networkState: LiveData<NetworkState>
get() = viewModel.networkStateAccount
override val data: LiveData<PagedList<Account>>
get() = viewModel.accounts
override val data: Flow<PagingData<Account>>
get() = viewModel.accountsFlow
companion object {
fun newInstance() = SearchAccountsFragment()

View file

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

View file

@ -1,4 +1,4 @@
/* Copyright 2019 Joel Pyska
/* Copyright 2021 Tusky Contributors
*
* This file is a part of Tusky.
*
@ -15,22 +15,18 @@
package com.keylesspalace.tusky.components.search.fragments
import androidx.lifecycle.LiveData
import androidx.paging.PagedList
import androidx.paging.PagedListAdapter
import androidx.paging.PagingData
import androidx.paging.PagingDataAdapter
import com.keylesspalace.tusky.components.search.adapter.SearchHashtagsAdapter
import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.util.NetworkState
import kotlinx.coroutines.flow.Flow
class SearchHashtagsFragment : SearchFragment<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 {
fun newInstance() = SearchHashtagsFragment()

View file

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

View file

@ -32,7 +32,7 @@ import java.io.File;
@Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
TimelineAccountEntity.class, ConversationEntity.class
}, version = 26)
}, version = 27)
public abstract class AppDatabase extends RoomDatabase {
public abstract AccountDao accountDao();
@ -393,4 +393,11 @@ public abstract class AppDatabase extends RoomDatabase {
}
}
}
public static final Migration MIGRATION_26_27 = new Migration(26, 27) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_muted` INTEGER NOT NULL DEFAULT 0");
}
};
}

View file

@ -15,27 +15,29 @@
package com.keylesspalace.tusky.db
import androidx.paging.DataSource
import androidx.room.*
import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.keylesspalace.tusky.components.conversation.ConversationEntity
import io.reactivex.rxjava3.core.Single
@Dao
interface ConversationsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(conversations: List<ConversationEntity>)
suspend fun insert(conversations: List<ConversationEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(conversation: ConversationEntity): Single<Long>
suspend fun insert(conversation: ConversationEntity): Long
@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")
fun conversationsForAccount(accountId: Long) : DataSource.Factory<Int, ConversationEntity>
fun conversationsForAccount(accountId: Long) : PagingSource<Int, ConversationEntity>
@Query("DELETE FROM ConversationEntity WHERE accountId = :accountId")
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_19_20, AppDatabase.MIGRATION_20_21, AppDatabase.MIGRATION_21_22,
AppDatabase.MIGRATION_22_23, AppDatabase.MIGRATION_23_24, AppDatabase.MIGRATION_24_25,
AppDatabase.MIGRATION_26_27,
AppDatabase.Migration25_26(appContext.getExternalFilesDir("Tusky"))
)
.build()

View file

@ -15,7 +15,27 @@
package com.keylesspalace.tusky.network
import com.keylesspalace.tusky.entity.*
import com.keylesspalace.tusky.entity.AccessToken
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Announcement
import com.keylesspalace.tusky.entity.AppCredentials
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Conversation
import com.keylesspalace.tusky.entity.DeletedStatus
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.IdentityProof
import com.keylesspalace.tusky.entity.Instance
import com.keylesspalace.tusky.entity.Marker
import com.keylesspalace.tusky.entity.MastoList
import com.keylesspalace.tusky.entity.NewStatus
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.entity.ScheduledStatus
import com.keylesspalace.tusky.entity.SearchResult
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.StatusContext
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Single
import okhttp3.MultipartBody
@ -23,8 +43,20 @@ import okhttp3.RequestBody
import okhttp3.ResponseBody
import retrofit2.Call
import retrofit2.Response
import retrofit2.http.*
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET
import retrofit2.http.HTTP
import retrofit2.http.Header
import retrofit2.http.Multipart
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
/**
* for documentation of the Mastodon REST API see https://docs.joinmastodon.org/api/
@ -466,10 +498,15 @@ interface MastodonApi {
): Completable
@GET("/api/v1/conversations")
fun getConversations(
suspend fun getConversations(
@Query("max_id") maxId: String? = null,
@Query("limit") limit: Int
): Call<List<Conversation>>
): List<Conversation>
@DELETE("/api/v1/conversations/{id}")
suspend fun deleteConversation(
@Path("id") conversationId: String
)
@FormUrlEncoded
@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 BiListing<T>(
data class BiListing<T: Any>(
// the LiveData of paged lists for the UI to observe
val pagedList: LiveData<PagedList<T>>,
// 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_edit">Edit</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_send">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_delete_toot_warning">Delete 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_dialog_ok">Hide entire domain</string>
<string name="dialog_block_warning">Block @%s?</string>