From 131309e99cfe261f3fef3e94984a3ee69b35cf1d Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Mon, 30 May 2022 19:06:14 +0200 Subject: [PATCH] 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 --- .../38.json | 12 +- .../tusky/adapter/NetworkStateViewHolder.kt | 42 ---- .../conversation/ConversationAdapter.kt | 35 +++- .../conversation/ConversationEntity.kt | 9 +- .../ConversationLoadStateAdapter.kt | 25 ++- .../conversation/ConversationViewData.kt | 2 + .../conversation/ConversationViewHolder.java | 104 ++++++---- .../conversation/ConversationsFragment.kt | 192 ++++++++++++------ .../ConversationsRemoteMediator.kt | 60 ++++-- .../conversation/ConversationsRepository.kt | 11 +- .../conversation/ConversationsViewModel.kt | 2 +- .../keylesspalace/tusky/db/AppDatabase.java | 8 +- .../tusky/db/ConversationsDao.kt | 8 +- .../tusky/network/MastodonApi.kt | 4 +- 14 files changed, 314 insertions(+), 200 deletions(-) delete mode 100644 app/src/main/java/com/keylesspalace/tusky/adapter/NetworkStateViewHolder.kt diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/38.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/38.json index dacfb708..391d6b86 100644 --- a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/38.json +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/38.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 38, - "identityHash": "11033751d382aa8a1c6fc68833097d35", + "identityHash": "798fc8d34064eb671c079689d4650ea5", "entities": [ { "tableName": "DraftEntity", @@ -690,7 +690,7 @@ }, { "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": [ { "fieldPath": "accountId", @@ -704,6 +704,12 @@ "affinity": "TEXT", "notNull": true }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, { "fieldPath": "accounts", "columnName": "accounts", @@ -863,7 +869,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '11033751d382aa8a1c6fc68833097d35')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '798fc8d34064eb671c079689d4650ea5')" ] } } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NetworkStateViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/NetworkStateViewHolder.kt deleted file mode 100644 index cf755990..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NetworkStateViewHolder.kt +++ /dev/null @@ -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 . */ - -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() - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt index 0c946514..a5a8ed27 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt @@ -20,21 +20,40 @@ import android.view.ViewGroup import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.util.StatusDisplayOptions class ConversationAdapter( - private val statusDisplayOptions: StatusDisplayOptions, + private var statusDisplayOptions: StatusDisplayOptions, private val listener: StatusActionListener ) : PagingDataAdapter(CONVERSATION_COMPARATOR) { + var mediaPreviewEnabled: Boolean + get() = statusDisplayOptions.mediaPreviewEnabled + set(mediaPreviewEnabled) { + statusDisplayOptions = statusDisplayOptions.copy( + mediaPreviewEnabled = mediaPreviewEnabled + ) + } + 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 onBindViewHolder(holder: ConversationViewHolder, position: Int) { - holder.setupWithConversation(getItem(position)) + onBindViewHolder(holder, position, emptyList()) + } + + override fun onBindViewHolder( + holder: ConversationViewHolder, + position: Int, + payloads: List + ) { + getItem(position)?.let { conversationViewData -> + holder.setupWithConversation(conversationViewData, payloads.firstOrNull()) + } } companion object { @@ -44,7 +63,17 @@ class ConversationAdapter( } 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 + } } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt index 5462ea7b..401d6146 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt @@ -34,6 +34,7 @@ import java.util.Date data class ConversationEntity( val accountId: Long, val id: String, + val order: Int, val accounts: List, val unread: Boolean, @Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity @@ -41,6 +42,7 @@ data class ConversationEntity( fun toViewData(): ConversationViewData { return ConversationViewData( id = id, + order = order, accounts = accounts, unread = unread, lastStatus = lastStatus.toViewData() @@ -50,6 +52,7 @@ data class ConversationEntity( data class ConversationAccountEntity( val id: String, + val localUsername: String, val username: String, val displayName: String, val avatar: String, @@ -58,12 +61,12 @@ data class ConversationAccountEntity( fun toAccount(): TimelineAccount { return TimelineAccount( id = id, + localUsername = localUsername, username = username, displayName = displayName, url = "", avatar = avatar, emojis = emojis, - localUsername = "", ) } } @@ -134,6 +137,7 @@ data class ConversationStatusEntity( fun TimelineAccount.toEntity() = ConversationAccountEntity( id = id, + localUsername = localUsername, username = username, displayName = name, avatar = avatar, @@ -166,10 +170,11 @@ fun Status.toEntity() = poll = poll ) -fun Conversation.toEntity(accountId: Long) = +fun Conversation.toEntity(accountId: Long, order: Int) = ConversationEntity( accountId = accountId, id = id, + order = order, accounts = accounts.map { it.toEntity() }, unread = unread, lastStatus = lastStatus!!.toEntity() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationLoadStateAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationLoadStateAdapter.kt index c7224c4d..7ff4daa7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationLoadStateAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationLoadStateAdapter.kt @@ -19,22 +19,35 @@ 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 +import com.keylesspalace.tusky.util.BindingHolder +import com.keylesspalace.tusky.util.visible class ConversationLoadStateAdapter( private val retryCallback: () -> Unit -) : LoadStateAdapter() { +) : LoadStateAdapter>() { - override fun onBindViewHolder(holder: NetworkStateViewHolder, loadState: LoadState) { - holder.setUpWithNetworkState(loadState) + override fun onBindViewHolder(holder: BindingHolder, loadState: 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( parent: ViewGroup, loadState: LoadState - ): NetworkStateViewHolder { + ): BindingHolder { val binding = ItemNetworkStateBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return NetworkStateViewHolder(binding, retryCallback) + return BindingHolder(binding) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt index d63fce6c..fae55f0b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt @@ -20,6 +20,7 @@ import com.keylesspalace.tusky.viewdata.StatusViewData data class ConversationViewData( val id: String, + val order: Int, val accounts: List, val unread: Boolean, val lastStatus: StatusViewData.Concrete @@ -37,6 +38,7 @@ data class ConversationViewData( return ConversationEntity( accountId = accountId, id = id, + order = order, accounts = accounts, unread = unread, lastStatus = lastStatus.toConversationStatusEntity( diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java index ffb88a94..19280441 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java @@ -23,6 +23,8 @@ import android.widget.Button; import android.widget.ImageView; import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; 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[] NO_INPUT_FILTER = new InputFilter[0]; - private TextView conversationNameTextView; - private Button contentCollapseButton; - private ImageView[] avatars; + private final TextView conversationNameTextView; + private final Button contentCollapseButton; + private final ImageView[] avatars; - private StatusDisplayOptions statusDisplayOptions; - private StatusActionListener listener; + private final StatusDisplayOptions statusDisplayOptions; + private final StatusActionListener listener; ConversationViewHolder(View itemView, StatusDisplayOptions statusDisplayOptions, @@ -64,7 +66,6 @@ public class ConversationViewHolder extends StatusBaseViewHolder { this.statusDisplayOptions = statusDisplayOptions; this.listener = listener; - } @Override @@ -72,52 +73,67 @@ public class ConversationViewHolder extends StatusBaseViewHolder { 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(); 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); - setUsername(account.getUsername()); - setCreatedAt(status.getCreatedAt(), statusDisplayOptions); - setIsReply(status.getInReplyToId() != null); - setFavourited(status.getFavourited()); - setBookmarked(status.getBookmarked()); - List attachments = status.getAttachments(); - boolean sensitive = status.getSensitive(); - if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) { - setMediaPreviews(attachments, sensitive, listener, statusViewData.isShowingContent(), - statusDisplayOptions.useBlurhash()); + setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), statusViewData.getSpoilerText(), listener); - 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 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(); } - // Hide the unused label. - for (TextView mediaLabel : mediaLabels) { - mediaLabel.setVisibility(View.GONE); - } + + 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()); } 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(); + if (payloads instanceof List) { + for (Object item : (List) payloads) { + if (Key.KEY_CREATED.equals(item)) { + setCreatedAt(status.getCreatedAt(), statusDisplayOptions); + } + } + } } - - 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 accounts) { @@ -169,4 +185,4 @@ public class ConversationViewHolder extends StatusBaseViewHolder { content.setFilters(NO_INPUT_FILTER); } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt index 243c3744..b05df2f8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt @@ -22,20 +22,27 @@ import android.view.ViewGroup import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.PopupMenu import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle 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 +import androidx.recyclerview.widget.RecyclerView 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.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.databinding.FragmentTimelineBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.fragment.SFragment +import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.interfaces.StatusActionListener 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.show import com.keylesspalace.tusky.util.viewBinding -import com.keylesspalace.tusky.util.visible 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.launch import java.io.IOException import javax.inject.Inject +import kotlin.time.DurationUnit +import kotlin.time.toDuration -@OptIn(ExperimentalPagingApi::class) class ConversationsFragment : SFragment(), StatusActionListener, Injectable, ReselectableFragment { @Inject lateinit var viewModelFactory: ViewModelFactory + @Inject + lateinit var eventHub: EventHub + private val viewModel: ConversationsViewModel by viewModels { viewModelFactory } 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 + private var hideFab = false override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { return inflater.inflate(R.layout.fragment_timeline, container, false) @@ -89,56 +98,106 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res ) 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.withLoadStateFooter(loadStateAdapter) - (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false - - binding.progressBar.hide() - binding.statusView.hide() + setupRecyclerView() 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 { viewModel.conversationFlow.collectLatest { pagingData -> adapter.submitData(pagingData) } } - 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 - } + lifecycleScope.launchWhenResumed { + val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false) + while (!useAbsoluteTime) { + adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED)) + delay(1.toDuration(DurationUnit.MINUTES)) } } + + 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() { @@ -201,7 +260,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } 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) { @@ -246,6 +305,19 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } } + override fun onVoteInPoll(position: Int, choices: MutableList) { + 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) { AlertDialog.Builder(requireContext()) .setMessage(R.string.dialog_delete_conversation_warning) @@ -256,20 +328,20 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res .show() } - private fun jumpToTop() { - if (isAdded) { - layoutManager?.scrollToPosition(0) - binding.recyclerView.stopScroll() - } - } - - override fun onReselect() { - jumpToTop() - } - - override fun onVoteInPoll(position: Int, choices: MutableList) { - adapter.peek(position)?.let { conversation -> - viewModel.voteInPoll(choices, conversation) + private fun onPreferenceChanged(key: String) { + val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) + when (key) { + PrefKeys.FAB_HIDE -> { + hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false) + } + PrefKeys.MEDIA_PREVIEW_ENABLED -> { + val enabled = accountManager.activeAccount!!.mediaPreviewEnabled + val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled + if (enabled != oldMediaPreviewEnabled) { + adapter.mediaPreviewEnabled = enabled + adapter.notifyItemRangeChanged(0, adapter.itemCount) + } + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt index 26984c8e..02a44f95 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt @@ -4,8 +4,11 @@ import androidx.paging.ExperimentalPagingApi import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator +import androidx.room.withTransaction import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.HttpHeaderLink +import retrofit2.HttpException @OptIn(ExperimentalPagingApi::class) class ConversationsRemoteMediator( @@ -14,38 +17,53 @@ class ConversationsRemoteMediator( private val db: AppDatabase ) : RemoteMediator() { + private var nextKey: String? = null + + private var order: Int = 0 + override suspend fun load( loadType: LoadType, state: PagingState ): MediatorResult { + if (loadType == LoadType.PREPEND) { + return MediatorResult.Success(endOfPaginationReached = true) + } + + if (loadType == LoadType.REFRESH) { + nextKey = null + order = 0 + } + 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) - } + val conversationsResponse = api.getConversations(maxId = nextKey, limit = state.config.pageSize) + + val conversations = conversationsResponse.body() + if (!conversationsResponse.isSuccessful || conversations == null) { + return MediatorResult.Error(HttpException(conversationsResponse)) } - if (loadType == LoadType.REFRESH) { - db.conversationDao().deleteForAccount(accountId) + db.withTransaction { + + 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( - conversationsResult - .filterNot { it.lastStatus == null } - .map { it.toEntity(accountId) } - ) - return MediatorResult.Success(endOfPaginationReached = conversationsResult.isEmpty()) + return MediatorResult.Success(endOfPaginationReached = nextKey == null) } catch (e: Exception) { return MediatorResult.Error(e) } } - - override suspend fun initialize() = InitializeAction.LAUNCH_INITIAL_REFRESH } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRepository.kt index 12c5eb0b..3f074be5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRepository.kt @@ -16,22 +16,15 @@ package com.keylesspalace.tusky.components.conversation 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.Singleton @Singleton class ConversationsRepository @Inject constructor( - val mastodonApi: MastodonApi, val db: AppDatabase ) { - fun deleteCacheForAccount(accountId: Long) { - Single.fromCallable { - db.conversationDao().deleteForAccount(accountId) - }.subscribeOn(Schedulers.io()) - .subscribe() + suspend fun deleteCacheForAccount(accountId: Long) { + db.conversationDao().deleteForAccount(accountId) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt index 9326a05c..684c6f01 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt @@ -41,7 +41,7 @@ class ConversationsViewModel @Inject constructor( @OptIn(ExperimentalPagingApi::class) val conversationFlow = Pager( - config = PagingConfig(pageSize = 10, enablePlaceholders = false, initialLoadSize = 20), + config = PagingConfig(pageSize = 30), remoteMediator = ConversationsRemoteMediator(accountManager.activeAccount!!.id, api, database), pagingSourceFactory = { database.conversationDao().conversationsForAccount(accountManager.activeAccount!!.id) } ) diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java index bdbfb09c..5bce5334 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -565,10 +565,12 @@ public abstract class AppDatabase extends RoomDatabase { public static final Migration MIGRATION_37_38 = new Migration(37, 38) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { - - // 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 needs to be cleaned because the ConversationAccountEntity got a new attribute 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`"); } }; } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/ConversationsDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/ConversationsDao.kt index fe093bd0..001dbbe5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/ConversationsDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/ConversationsDao.kt @@ -28,14 +28,14 @@ interface ConversationsDao { suspend fun insert(conversations: List) @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") - 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 @Query("DELETE FROM ConversationEntity WHERE accountId = :accountId") - fun deleteForAccount(accountId: Long) + suspend fun deleteForAccount(accountId: Long) } diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index 0d9a1945..abd3c124 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -503,8 +503,8 @@ interface MastodonApi { @GET("/api/v1/conversations") suspend fun getConversations( @Query("max_id") maxId: String? = null, - @Query("limit") limit: Int - ): List + @Query("limit") limit: Int? = null + ): Response> @DELETE("/api/v1/conversations/{id}") suspend fun deleteConversation(