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,
"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')"
]
}
}

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.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<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 {
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<Any>
) {
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
}
}
}
}

View file

@ -34,6 +34,7 @@ import java.util.Date
data class ConversationEntity(
val accountId: Long,
val id: String,
val order: Int,
val accounts: List<ConversationAccountEntity>,
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()

View file

@ -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<NetworkStateViewHolder>() {
) : LoadStateAdapter<BindingHolder<ItemNetworkStateBinding>>() {
override fun onBindViewHolder(holder: NetworkStateViewHolder, loadState: LoadState) {
holder.setUpWithNetworkState(loadState)
override fun onBindViewHolder(holder: BindingHolder<ItemNetworkStateBinding>, 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<ItemNetworkStateBinding> {
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(
val id: String,
val order: Int,
val accounts: List<ConversationAccountEntity>,
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(

View file

@ -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<Attachment> 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<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();
}
// 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<ConversationAccountEntity> accounts) {
@ -169,4 +185,4 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
content.setFilters(NO_INPUT_FILTER);
}
}
}
}

View file

@ -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<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) {
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<Int>) {
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)
}
}
}
}

View file

@ -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<Int, ConversationEntity>() {
private var nextKey: String? = null
private var order: Int = 0
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, ConversationEntity>
): 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
}

View file

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

View file

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

View file

@ -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`");
}
};
}

View file

@ -28,14 +28,14 @@ interface ConversationsDao {
suspend fun insert(conversations: List<ConversationEntity>)
@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<Int, ConversationEntity>
@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")
suspend fun getConversations(
@Query("max_id") maxId: String? = null,
@Query("limit") limit: Int
): List<Conversation>
@Query("limit") limit: Int? = null
): Response<List<Conversation>>
@DELETE("/api/v1/conversations/{id}")
suspend fun deleteConversation(