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:
parent
31da851f28
commit
6d4f5ad027
32 changed files with 1612 additions and 1022 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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) }
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue