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:
		
					parent
					
						
							
								2983c3f48e
							
						
					
				
			
			
				commit
				
					
						131309e99c
					
				
			
		
					 14 changed files with 314 additions and 200 deletions
				
			
		|  | @ -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')" | ||||
|     ] | ||||
|   } | ||||
| } | ||||
|  | @ -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() | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -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 | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  |  | |||
|  | @ -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() | ||||
|  |  | |||
|  | @ -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) | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -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( | ||||
|  |  | |||
|  | @ -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); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -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) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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 | ||||
| } | ||||
|  |  | |||
|  | @ -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) | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -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) } | ||||
|     ) | ||||
|  |  | |||
|  | @ -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`"); | ||||
|         } | ||||
|     }; | ||||
| } | ||||
|  |  | |||
|  | @ -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) | ||||
| } | ||||
|  |  | |||
|  | @ -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( | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue