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
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

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