Fix conversations (#2556)

* fix conversations

* cleanup ConversationsRemoteMediator

* update conversation timestamps regularly

* improve loadStateListener

* add db migration

* make deleting from conversation db suspending

* reorganize code in ConversationsFragment

* delete NetworkStateViewHolder

* cleanup imports

* add 38.json

* honor fabHide setting in ConversationsFragment

* set page size to 30
This commit is contained in:
Konrad Pozniak 2022-05-30 19:06:14 +02:00 committed by GitHub
parent 2983c3f48e
commit 131309e99c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 314 additions and 200 deletions

View file

@ -2,7 +2,7 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 38, "version": 38,
"identityHash": "11033751d382aa8a1c6fc68833097d35", "identityHash": "798fc8d34064eb671c079689d4650ea5",
"entities": [ "entities": [
{ {
"tableName": "DraftEntity", "tableName": "DraftEntity",
@ -690,7 +690,7 @@
}, },
{ {
"tableName": "ConversationEntity", "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_repliesCount` 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_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER 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_repliesCount` 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_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))",
"fields": [ "fields": [
{ {
"fieldPath": "accountId", "fieldPath": "accountId",
@ -704,6 +704,12 @@
"affinity": "TEXT", "affinity": "TEXT",
"notNull": true "notNull": true
}, },
{
"fieldPath": "order",
"columnName": "order",
"affinity": "INTEGER",
"notNull": true
},
{ {
"fieldPath": "accounts", "fieldPath": "accounts",
"columnName": "accounts", "columnName": "accounts",
@ -863,7 +869,7 @@
"views": [], "views": [],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "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, '11033751d382aa8a1c6fc68833097d35')" "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '798fc8d34064eb671c079689d4650ea5')"
] ]
} }
} }

View file

@ -1,42 +0,0 @@
/* Copyright 2019 Conny Duck
*
* 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.adapter
import androidx.paging.LoadState
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding
import com.keylesspalace.tusky.util.visible
class NetworkStateViewHolder(
private val binding: ItemNetworkStateBinding,
private val retryCallback: () -> Unit
) : RecyclerView.ViewHolder(binding.root) {
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()
}
}
}

View file

@ -20,21 +20,40 @@ import android.view.ViewGroup
import androidx.paging.PagingDataAdapter import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.StatusDisplayOptions
class ConversationAdapter( class ConversationAdapter(
private val statusDisplayOptions: StatusDisplayOptions, private var statusDisplayOptions: StatusDisplayOptions,
private val listener: StatusActionListener private val listener: StatusActionListener
) : PagingDataAdapter<ConversationViewData, ConversationViewHolder>(CONVERSATION_COMPARATOR) { ) : PagingDataAdapter<ConversationViewData, ConversationViewHolder>(CONVERSATION_COMPARATOR) {
var mediaPreviewEnabled: Boolean
get() = statusDisplayOptions.mediaPreviewEnabled
set(mediaPreviewEnabled) {
statusDisplayOptions = statusDisplayOptions.copy(
mediaPreviewEnabled = mediaPreviewEnabled
)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_conversation, parent, false) val view = LayoutInflater.from(parent.context).inflate(R.layout.item_conversation, parent, false)
return ConversationViewHolder(view, statusDisplayOptions, listener) return ConversationViewHolder(view, statusDisplayOptions, listener)
} }
override fun onBindViewHolder(holder: ConversationViewHolder, position: Int) { override fun onBindViewHolder(holder: ConversationViewHolder, position: Int) {
holder.setupWithConversation(getItem(position)) onBindViewHolder(holder, position, emptyList())
}
override fun onBindViewHolder(
holder: ConversationViewHolder,
position: Int,
payloads: List<Any>
) {
getItem(position)?.let { conversationViewData ->
holder.setupWithConversation(conversationViewData, payloads.firstOrNull())
}
} }
companion object { companion object {
@ -44,7 +63,17 @@ class ConversationAdapter(
} }
override fun areContentsTheSame(oldItem: ConversationViewData, newItem: ConversationViewData): Boolean { override fun areContentsTheSame(oldItem: ConversationViewData, newItem: ConversationViewData): Boolean {
return oldItem == newItem return false // Items are different always. It allows to refresh timestamp on every view holder update
}
override fun getChangePayload(oldItem: ConversationViewData, newItem: ConversationViewData): Any? {
return if (oldItem == newItem) {
// If items are equal - update timestamp only
listOf(StatusBaseViewHolder.Key.KEY_CREATED)
} else {
// If items are different - update the whole view holder
null
}
} }
} }
} }

View file

@ -34,6 +34,7 @@ import java.util.Date
data class ConversationEntity( data class ConversationEntity(
val accountId: Long, val accountId: Long,
val id: String, val id: String,
val order: Int,
val accounts: List<ConversationAccountEntity>, val accounts: List<ConversationAccountEntity>,
val unread: Boolean, val unread: Boolean,
@Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity @Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity
@ -41,6 +42,7 @@ data class ConversationEntity(
fun toViewData(): ConversationViewData { fun toViewData(): ConversationViewData {
return ConversationViewData( return ConversationViewData(
id = id, id = id,
order = order,
accounts = accounts, accounts = accounts,
unread = unread, unread = unread,
lastStatus = lastStatus.toViewData() lastStatus = lastStatus.toViewData()
@ -50,6 +52,7 @@ data class ConversationEntity(
data class ConversationAccountEntity( data class ConversationAccountEntity(
val id: String, val id: String,
val localUsername: String,
val username: String, val username: String,
val displayName: String, val displayName: String,
val avatar: String, val avatar: String,
@ -58,12 +61,12 @@ data class ConversationAccountEntity(
fun toAccount(): TimelineAccount { fun toAccount(): TimelineAccount {
return TimelineAccount( return TimelineAccount(
id = id, id = id,
localUsername = localUsername,
username = username, username = username,
displayName = displayName, displayName = displayName,
url = "", url = "",
avatar = avatar, avatar = avatar,
emojis = emojis, emojis = emojis,
localUsername = "",
) )
} }
} }
@ -134,6 +137,7 @@ data class ConversationStatusEntity(
fun TimelineAccount.toEntity() = fun TimelineAccount.toEntity() =
ConversationAccountEntity( ConversationAccountEntity(
id = id, id = id,
localUsername = localUsername,
username = username, username = username,
displayName = name, displayName = name,
avatar = avatar, avatar = avatar,
@ -166,10 +170,11 @@ fun Status.toEntity() =
poll = poll poll = poll
) )
fun Conversation.toEntity(accountId: Long) = fun Conversation.toEntity(accountId: Long, order: Int) =
ConversationEntity( ConversationEntity(
accountId = accountId, accountId = accountId,
id = id, id = id,
order = order,
accounts = accounts.map { it.toEntity() }, accounts = accounts.map { it.toEntity() },
unread = unread, unread = unread,
lastStatus = lastStatus!!.toEntity() lastStatus = lastStatus!!.toEntity()

View file

@ -19,22 +19,35 @@ import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.paging.LoadState import androidx.paging.LoadState
import androidx.paging.LoadStateAdapter import androidx.paging.LoadStateAdapter
import com.keylesspalace.tusky.adapter.NetworkStateViewHolder
import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.visible
class ConversationLoadStateAdapter( class ConversationLoadStateAdapter(
private val retryCallback: () -> Unit private val retryCallback: () -> Unit
) : LoadStateAdapter<NetworkStateViewHolder>() { ) : LoadStateAdapter<BindingHolder<ItemNetworkStateBinding>>() {
override fun onBindViewHolder(holder: NetworkStateViewHolder, loadState: LoadState) { override fun onBindViewHolder(holder: BindingHolder<ItemNetworkStateBinding>, loadState: LoadState) {
holder.setUpWithNetworkState(loadState) val binding = holder.binding
binding.progressBar.visible(loadState == LoadState.Loading)
binding.retryButton.visible(loadState is LoadState.Error)
val msg = if (loadState is LoadState.Error) {
loadState.error.message
} else {
null
}
binding.errorMsg.visible(msg != null)
binding.errorMsg.text = msg
binding.retryButton.setOnClickListener {
retryCallback()
}
} }
override fun onCreateViewHolder( override fun onCreateViewHolder(
parent: ViewGroup, parent: ViewGroup,
loadState: LoadState loadState: LoadState
): NetworkStateViewHolder { ): BindingHolder<ItemNetworkStateBinding> {
val binding = ItemNetworkStateBinding.inflate(LayoutInflater.from(parent.context), parent, false) val binding = ItemNetworkStateBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return NetworkStateViewHolder(binding, retryCallback) return BindingHolder(binding)
} }
} }

View file

@ -20,6 +20,7 @@ import com.keylesspalace.tusky.viewdata.StatusViewData
data class ConversationViewData( data class ConversationViewData(
val id: String, val id: String,
val order: Int,
val accounts: List<ConversationAccountEntity>, val accounts: List<ConversationAccountEntity>,
val unread: Boolean, val unread: Boolean,
val lastStatus: StatusViewData.Concrete val lastStatus: StatusViewData.Concrete
@ -37,6 +38,7 @@ data class ConversationViewData(
return ConversationEntity( return ConversationEntity(
accountId = accountId, accountId = accountId,
id = id, id = id,
order = order,
accounts = accounts, accounts = accounts,
unread = unread, unread = unread,
lastStatus = lastStatus.toConversationStatusEntity( lastStatus = lastStatus.toConversationStatusEntity(

View file

@ -23,6 +23,8 @@ import android.widget.Button;
import android.widget.ImageView; import android.widget.ImageView;
import android.widget.TextView; import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.R;
@ -43,12 +45,12 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE}; private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE};
private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0]; private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0];
private TextView conversationNameTextView; private final TextView conversationNameTextView;
private Button contentCollapseButton; private final Button contentCollapseButton;
private ImageView[] avatars; private final ImageView[] avatars;
private StatusDisplayOptions statusDisplayOptions; private final StatusDisplayOptions statusDisplayOptions;
private StatusActionListener listener; private final StatusActionListener listener;
ConversationViewHolder(View itemView, ConversationViewHolder(View itemView,
StatusDisplayOptions statusDisplayOptions, StatusDisplayOptions statusDisplayOptions,
@ -64,7 +66,6 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
this.statusDisplayOptions = statusDisplayOptions; this.statusDisplayOptions = statusDisplayOptions;
this.listener = listener; this.listener = listener;
} }
@Override @Override
@ -72,52 +73,67 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
return context.getResources().getDimensionPixelSize(R.dimen.status_media_preview_height); return context.getResources().getDimensionPixelSize(R.dimen.status_media_preview_height);
} }
void setupWithConversation(ConversationViewData conversation) { void setupWithConversation(
@NonNull ConversationViewData conversation,
@Nullable Object payloads
) {
StatusViewData.Concrete statusViewData = conversation.getLastStatus(); StatusViewData.Concrete statusViewData = conversation.getLastStatus();
Status status = statusViewData.getStatus(); Status status = statusViewData.getStatus();
TimelineAccount account = status.getAccount();
setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), statusViewData.getSpoilerText(), listener); if (payloads == null) {
TimelineAccount account = status.getAccount();
setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions); setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), statusViewData.getSpoilerText(), listener);
setUsername(account.getUsername());
setCreatedAt(status.getCreatedAt(), statusDisplayOptions);
setIsReply(status.getInReplyToId() != null);
setFavourited(status.getFavourited());
setBookmarked(status.getBookmarked());
List<Attachment> attachments = status.getAttachments();
boolean sensitive = status.getSensitive();
if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
setMediaPreviews(attachments, sensitive, listener, statusViewData.isShowingContent(),
statusDisplayOptions.useBlurhash());
if (attachments.size() == 0) { setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions);
setUsername(account.getUsername());
setCreatedAt(status.getCreatedAt(), statusDisplayOptions);
setIsReply(status.getInReplyToId() != null);
setFavourited(status.getFavourited());
setBookmarked(status.getBookmarked());
List<Attachment> attachments = status.getAttachments();
boolean sensitive = status.getSensitive();
if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
setMediaPreviews(attachments, sensitive, listener, statusViewData.isShowingContent(),
statusDisplayOptions.useBlurhash());
if (attachments.size() == 0) {
hideSensitiveMediaWarning();
}
// Hide the unused label.
for (TextView mediaLabel : mediaLabels) {
mediaLabel.setVisibility(View.GONE);
}
} else {
setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent());
// Hide all unused views.
mediaPreviews[0].setVisibility(View.GONE);
mediaPreviews[1].setVisibility(View.GONE);
mediaPreviews[2].setVisibility(View.GONE);
mediaPreviews[3].setVisibility(View.GONE);
hideSensitiveMediaWarning(); hideSensitiveMediaWarning();
} }
// Hide the unused label.
for (TextView mediaLabel : mediaLabels) { setupButtons(listener, account.getId(), statusViewData.getContent().toString(),
mediaLabel.setVisibility(View.GONE); statusDisplayOptions);
}
setSpoilerAndContent(statusViewData.isExpanded(), statusViewData.getContent(), status.getSpoilerText(),
status.getMentions(), status.getTags(), status.getEmojis(),
PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener);
setConversationName(conversation.getAccounts());
setAvatars(conversation.getAccounts());
} else { } else {
setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent()); if (payloads instanceof List) {
// Hide all unused views. for (Object item : (List<?>) payloads) {
mediaPreviews[0].setVisibility(View.GONE); if (Key.KEY_CREATED.equals(item)) {
mediaPreviews[1].setVisibility(View.GONE); setCreatedAt(status.getCreatedAt(), statusDisplayOptions);
mediaPreviews[2].setVisibility(View.GONE); }
mediaPreviews[3].setVisibility(View.GONE); }
hideSensitiveMediaWarning(); }
} }
setupButtons(listener, account.getId(), statusViewData.getContent().toString(),
statusDisplayOptions);
setSpoilerAndContent(statusViewData.isExpanded(), statusViewData.getContent(), status.getSpoilerText(),
status.getMentions(), status.getTags(), status.getEmojis(),
PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener);
setConversationName(conversation.getAccounts());
setAvatars(conversation.getAccounts());
} }
private void setConversationName(List<ConversationAccountEntity> accounts) { private void setConversationName(List<ConversationAccountEntity> accounts) {
@ -169,4 +185,4 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
content.setFilters(NO_INPUT_FILTER); content.setFilters(NO_INPUT_FILTER);
} }
} }
} }

View file

@ -22,20 +22,27 @@ import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadState import androidx.paging.LoadState
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator import androidx.recyclerview.widget.SimpleItemAnimator
import at.connyduck.sparkbutton.helpers.Utils
import autodispose2.androidx.lifecycle.autoDispose
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.databinding.FragmentTimelineBinding import com.keylesspalace.tusky.databinding.FragmentTimelineBinding
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.fragment.SFragment import com.keylesspalace.tusky.fragment.SFragment
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys
@ -44,29 +51,31 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.AttachmentViewData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.IOException import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.DurationUnit
import kotlin.time.toDuration
@OptIn(ExperimentalPagingApi::class)
class ConversationsFragment : SFragment(), StatusActionListener, Injectable, ReselectableFragment { class ConversationsFragment : SFragment(), StatusActionListener, Injectable, ReselectableFragment {
@Inject @Inject
lateinit var viewModelFactory: ViewModelFactory lateinit var viewModelFactory: ViewModelFactory
@Inject
lateinit var eventHub: EventHub
private val viewModel: ConversationsViewModel by viewModels { viewModelFactory } private val viewModel: ConversationsViewModel by viewModels { viewModelFactory }
private val binding by viewBinding(FragmentTimelineBinding::bind) private val binding by viewBinding(FragmentTimelineBinding::bind)
private lateinit var adapter: ConversationAdapter private lateinit var adapter: ConversationAdapter
private lateinit var loadStateAdapter: ConversationLoadStateAdapter
private var layoutManager: LinearLayoutManager? = null private var hideFab = false
private var initialRefreshDone: Boolean = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_timeline, container, false) return inflater.inflate(R.layout.fragment_timeline, container, false)
@ -89,56 +98,106 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
) )
adapter = ConversationAdapter(statusDisplayOptions, this) adapter = ConversationAdapter(statusDisplayOptions, this)
loadStateAdapter = ConversationLoadStateAdapter(adapter::retry)
binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) setupRecyclerView()
layoutManager = LinearLayoutManager(view.context)
binding.recyclerView.layoutManager = layoutManager
binding.recyclerView.adapter = adapter.withLoadStateFooter(loadStateAdapter)
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
binding.progressBar.hide()
binding.statusView.hide()
initSwipeToRefresh() initSwipeToRefresh()
adapter.addLoadStateListener { loadState ->
if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) {
binding.swipeRefreshLayout.isRefreshing = false
}
binding.statusView.hide()
binding.progressBar.hide()
if (adapter.itemCount == 0) {
when (loadState.refresh) {
is LoadState.NotLoading -> {
if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) {
binding.statusView.show()
binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null)
}
}
is LoadState.Error -> {
binding.statusView.show()
if ((loadState.refresh as LoadState.Error).error is IOException) {
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, null)
} else {
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, null)
}
}
is LoadState.Loading -> {
binding.progressBar.show()
}
}
}
}
adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
if (positionStart == 0 && adapter.itemCount != itemCount) {
binding.recyclerView.post {
if (getView() != null) {
binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30))
}
}
}
}
})
hideFab = preferences.getBoolean(PrefKeys.FAB_HIDE, false)
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
val composeButton = (activity as ActionButtonActivity).actionButton
if (composeButton != null) {
if (hideFab) {
if (dy > 0 && composeButton.isShown) {
composeButton.hide() // hides the button if we're scrolling down
} else if (dy < 0 && !composeButton.isShown) {
composeButton.show() // shows it if we are scrolling up
}
} else if (!composeButton.isShown) {
composeButton.show()
}
}
}
})
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
viewModel.conversationFlow.collectLatest { pagingData -> viewModel.conversationFlow.collectLatest { pagingData ->
adapter.submitData(pagingData) adapter.submitData(pagingData)
} }
} }
adapter.addLoadStateListener { loadStates -> lifecycleScope.launchWhenResumed {
val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false)
loadStates.refresh.let { refreshState -> while (!useAbsoluteTime) {
if (refreshState is LoadState.Error) { adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED))
binding.statusView.show() delay(1.toDuration(DurationUnit.MINUTES))
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
}
} }
} }
eventHub.events
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe { event ->
if (event is PreferenceChangedEvent) {
onPreferenceChanged(event.preferenceKey)
}
}
}
private fun setupRecyclerView() {
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(context)
binding.recyclerView.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
binding.recyclerView.adapter = adapter.withLoadStateFooter(ConversationLoadStateAdapter(adapter::retry))
} }
private fun initSwipeToRefresh() { private fun initSwipeToRefresh() {
@ -201,7 +260,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
} }
override fun onOpenReblog(position: Int) { override fun onOpenReblog(position: Int) {
// there are no reblogs in search results // there are no reblogs in conversations
} }
override fun onExpandedChange(expanded: Boolean, position: Int) { override fun onExpandedChange(expanded: Boolean, position: Int) {
@ -246,6 +305,19 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
} }
} }
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
adapter.peek(position)?.let { conversation ->
viewModel.voteInPoll(choices, conversation)
}
}
override fun onReselect() {
if (isAdded) {
binding.recyclerView.layoutManager?.scrollToPosition(0)
binding.recyclerView.stopScroll()
}
}
private fun deleteConversation(conversation: ConversationViewData) { private fun deleteConversation(conversation: ConversationViewData) {
AlertDialog.Builder(requireContext()) AlertDialog.Builder(requireContext())
.setMessage(R.string.dialog_delete_conversation_warning) .setMessage(R.string.dialog_delete_conversation_warning)
@ -256,20 +328,20 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
.show() .show()
} }
private fun jumpToTop() { private fun onPreferenceChanged(key: String) {
if (isAdded) { val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
layoutManager?.scrollToPosition(0) when (key) {
binding.recyclerView.stopScroll() PrefKeys.FAB_HIDE -> {
} hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false)
} }
PrefKeys.MEDIA_PREVIEW_ENABLED -> {
override fun onReselect() { val enabled = accountManager.activeAccount!!.mediaPreviewEnabled
jumpToTop() val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled
} if (enabled != oldMediaPreviewEnabled) {
adapter.mediaPreviewEnabled = enabled
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) { adapter.notifyItemRangeChanged(0, adapter.itemCount)
adapter.peek(position)?.let { conversation -> }
viewModel.voteInPoll(choices, conversation) }
} }
} }

View file

@ -4,8 +4,11 @@ import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType import androidx.paging.LoadType
import androidx.paging.PagingState import androidx.paging.PagingState
import androidx.paging.RemoteMediator import androidx.paging.RemoteMediator
import androidx.room.withTransaction
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.HttpHeaderLink
import retrofit2.HttpException
@OptIn(ExperimentalPagingApi::class) @OptIn(ExperimentalPagingApi::class)
class ConversationsRemoteMediator( class ConversationsRemoteMediator(
@ -14,38 +17,53 @@ class ConversationsRemoteMediator(
private val db: AppDatabase private val db: AppDatabase
) : RemoteMediator<Int, ConversationEntity>() { ) : RemoteMediator<Int, ConversationEntity>() {
private var nextKey: String? = null
private var order: Int = 0
override suspend fun load( override suspend fun load(
loadType: LoadType, loadType: LoadType,
state: PagingState<Int, ConversationEntity> state: PagingState<Int, ConversationEntity>
): MediatorResult { ): MediatorResult {
if (loadType == LoadType.PREPEND) {
return MediatorResult.Success(endOfPaginationReached = true)
}
if (loadType == LoadType.REFRESH) {
nextKey = null
order = 0
}
try { try {
val conversationsResult = when (loadType) { val conversationsResponse = api.getConversations(maxId = nextKey, limit = state.config.pageSize)
LoadType.REFRESH -> {
api.getConversations(limit = state.config.initialLoadSize) val conversations = conversationsResponse.body()
} if (!conversationsResponse.isSuccessful || conversations == null) {
LoadType.PREPEND -> { return MediatorResult.Error(HttpException(conversationsResponse))
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.withTransaction {
db.conversationDao().deleteForAccount(accountId)
if (loadType == LoadType.REFRESH) {
db.conversationDao().deleteForAccount(accountId)
}
val linkHeader = conversationsResponse.headers()["Link"]
val links = HttpHeaderLink.parse(linkHeader)
nextKey = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id")
db.conversationDao().insert(
conversations
.filterNot { it.lastStatus == null }
.map {
it.toEntity(accountId, order++)
}
)
} }
db.conversationDao().insert( return MediatorResult.Success(endOfPaginationReached = nextKey == null)
conversationsResult
.filterNot { it.lastStatus == null }
.map { it.toEntity(accountId) }
)
return MediatorResult.Success(endOfPaginationReached = conversationsResult.isEmpty())
} catch (e: Exception) { } catch (e: Exception) {
return MediatorResult.Error(e) return MediatorResult.Error(e)
} }
} }
override suspend fun initialize() = InitializeAction.LAUNCH_INITIAL_REFRESH
} }

View file

@ -16,22 +16,15 @@
package com.keylesspalace.tusky.components.conversation package com.keylesspalace.tusky.components.conversation
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class ConversationsRepository @Inject constructor( class ConversationsRepository @Inject constructor(
val mastodonApi: MastodonApi,
val db: AppDatabase val db: AppDatabase
) { ) {
fun deleteCacheForAccount(accountId: Long) { suspend fun deleteCacheForAccount(accountId: Long) {
Single.fromCallable { db.conversationDao().deleteForAccount(accountId)
db.conversationDao().deleteForAccount(accountId)
}.subscribeOn(Schedulers.io())
.subscribe()
} }
} }

View file

@ -41,7 +41,7 @@ class ConversationsViewModel @Inject constructor(
@OptIn(ExperimentalPagingApi::class) @OptIn(ExperimentalPagingApi::class)
val conversationFlow = Pager( val conversationFlow = Pager(
config = PagingConfig(pageSize = 10, enablePlaceholders = false, initialLoadSize = 20), config = PagingConfig(pageSize = 30),
remoteMediator = ConversationsRemoteMediator(accountManager.activeAccount!!.id, api, database), remoteMediator = ConversationsRemoteMediator(accountManager.activeAccount!!.id, api, database),
pagingSourceFactory = { database.conversationDao().conversationsForAccount(accountManager.activeAccount!!.id) } pagingSourceFactory = { database.conversationDao().conversationsForAccount(accountManager.activeAccount!!.id) }
) )

View file

@ -565,10 +565,12 @@ public abstract class AppDatabase extends RoomDatabase {
public static final Migration MIGRATION_37_38 = new Migration(37, 38) { public static final Migration MIGRATION_37_38 = new Migration(37, 38) {
@Override @Override
public void migrate(@NonNull SupportSQLiteDatabase database) { public void migrate(@NonNull SupportSQLiteDatabase database) {
// database needs to be cleaned because the ConversationAccountEntity got a new attribute
// no actual scheme change, but timestamps are now serialized differently so all cache tables that contain them need to be cleaned
database.execSQL("DELETE FROM `TimelineStatusEntity`");
database.execSQL("DELETE FROM `ConversationEntity`"); database.execSQL("DELETE FROM `ConversationEntity`");
database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `order` INTEGER NOT NULL DEFAULT 0");
// timestamps are now serialized differently so all cache tables that contain them need to be cleaned
database.execSQL("DELETE FROM `TimelineStatusEntity`");
} }
}; };
} }

View file

@ -28,14 +28,14 @@ interface ConversationsDao {
suspend fun insert(conversations: List<ConversationEntity>) suspend fun insert(conversations: List<ConversationEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(conversation: ConversationEntity): Long suspend fun insert(conversation: ConversationEntity)
@Query("DELETE FROM ConversationEntity WHERE id = :id AND accountId = :accountId") @Query("DELETE FROM ConversationEntity WHERE id = :id AND accountId = :accountId")
suspend fun delete(id: String, accountId: Long): Int suspend fun delete(id: String, accountId: Long)
@Query("SELECT * FROM ConversationEntity WHERE accountId = :accountId ORDER BY s_createdAt DESC") @Query("SELECT * FROM ConversationEntity WHERE accountId = :accountId ORDER BY `order` ASC")
fun conversationsForAccount(accountId: Long): PagingSource<Int, ConversationEntity> fun conversationsForAccount(accountId: Long): PagingSource<Int, ConversationEntity>
@Query("DELETE FROM ConversationEntity WHERE accountId = :accountId") @Query("DELETE FROM ConversationEntity WHERE accountId = :accountId")
fun deleteForAccount(accountId: Long) suspend fun deleteForAccount(accountId: Long)
} }

View file

@ -503,8 +503,8 @@ interface MastodonApi {
@GET("/api/v1/conversations") @GET("/api/v1/conversations")
suspend fun getConversations( suspend fun getConversations(
@Query("max_id") maxId: String? = null, @Query("max_id") maxId: String? = null,
@Query("limit") limit: Int @Query("limit") limit: Int? = null
): List<Conversation> ): Response<List<Conversation>>
@DELETE("/api/v1/conversations/{id}") @DELETE("/api/v1/conversations/{id}")
suspend fun deleteConversation( suspend fun deleteConversation(