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,
|
"formatVersion": 1,
|
||||||
"database": {
|
"database": {
|
||||||
"version": 38,
|
"version": 38,
|
||||||
"identityHash": "11033751d382aa8a1c6fc68833097d35",
|
"identityHash": "798fc8d34064eb671c079689d4650ea5",
|
||||||
"entities": [
|
"entities": [
|
||||||
{
|
{
|
||||||
"tableName": "DraftEntity",
|
"tableName": "DraftEntity",
|
||||||
|
@ -690,7 +690,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"tableName": "ConversationEntity",
|
"tableName": "ConversationEntity",
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))",
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))",
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
"fieldPath": "accountId",
|
"fieldPath": "accountId",
|
||||||
|
@ -704,6 +704,12 @@
|
||||||
"affinity": "TEXT",
|
"affinity": "TEXT",
|
||||||
"notNull": true
|
"notNull": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "order",
|
||||||
|
"columnName": "order",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"fieldPath": "accounts",
|
"fieldPath": "accounts",
|
||||||
"columnName": "accounts",
|
"columnName": "accounts",
|
||||||
|
@ -863,7 +869,7 @@
|
||||||
"views": [],
|
"views": [],
|
||||||
"setupQueries": [
|
"setupQueries": [
|
||||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '11033751d382aa8a1c6fc68833097d35')"
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '798fc8d34064eb671c079689d4650ea5')"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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.paging.PagingDataAdapter
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
|
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
|
||||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||||
|
|
||||||
class ConversationAdapter(
|
class ConversationAdapter(
|
||||||
private val statusDisplayOptions: StatusDisplayOptions,
|
private var statusDisplayOptions: StatusDisplayOptions,
|
||||||
private val listener: StatusActionListener
|
private val listener: StatusActionListener
|
||||||
) : PagingDataAdapter<ConversationViewData, ConversationViewHolder>(CONVERSATION_COMPARATOR) {
|
) : PagingDataAdapter<ConversationViewData, ConversationViewHolder>(CONVERSATION_COMPARATOR) {
|
||||||
|
|
||||||
|
var mediaPreviewEnabled: Boolean
|
||||||
|
get() = statusDisplayOptions.mediaPreviewEnabled
|
||||||
|
set(mediaPreviewEnabled) {
|
||||||
|
statusDisplayOptions = statusDisplayOptions.copy(
|
||||||
|
mediaPreviewEnabled = mediaPreviewEnabled
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder {
|
||||||
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_conversation, parent, false)
|
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_conversation, parent, false)
|
||||||
return ConversationViewHolder(view, statusDisplayOptions, listener)
|
return ConversationViewHolder(view, statusDisplayOptions, listener)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: ConversationViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: ConversationViewHolder, position: Int) {
|
||||||
holder.setupWithConversation(getItem(position))
|
onBindViewHolder(holder, position, emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(
|
||||||
|
holder: ConversationViewHolder,
|
||||||
|
position: Int,
|
||||||
|
payloads: List<Any>
|
||||||
|
) {
|
||||||
|
getItem(position)?.let { conversationViewData ->
|
||||||
|
holder.setupWithConversation(conversationViewData, payloads.firstOrNull())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
@ -44,7 +63,17 @@ class ConversationAdapter(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun areContentsTheSame(oldItem: ConversationViewData, newItem: ConversationViewData): Boolean {
|
override fun areContentsTheSame(oldItem: ConversationViewData, newItem: ConversationViewData): Boolean {
|
||||||
return oldItem == newItem
|
return false // Items are different always. It allows to refresh timestamp on every view holder update
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getChangePayload(oldItem: ConversationViewData, newItem: ConversationViewData): Any? {
|
||||||
|
return if (oldItem == newItem) {
|
||||||
|
// If items are equal - update timestamp only
|
||||||
|
listOf(StatusBaseViewHolder.Key.KEY_CREATED)
|
||||||
|
} else {
|
||||||
|
// If items are different - update the whole view holder
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,6 +34,7 @@ import java.util.Date
|
||||||
data class ConversationEntity(
|
data class ConversationEntity(
|
||||||
val accountId: Long,
|
val accountId: Long,
|
||||||
val id: String,
|
val id: String,
|
||||||
|
val order: Int,
|
||||||
val accounts: List<ConversationAccountEntity>,
|
val accounts: List<ConversationAccountEntity>,
|
||||||
val unread: Boolean,
|
val unread: Boolean,
|
||||||
@Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity
|
@Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity
|
||||||
|
@ -41,6 +42,7 @@ data class ConversationEntity(
|
||||||
fun toViewData(): ConversationViewData {
|
fun toViewData(): ConversationViewData {
|
||||||
return ConversationViewData(
|
return ConversationViewData(
|
||||||
id = id,
|
id = id,
|
||||||
|
order = order,
|
||||||
accounts = accounts,
|
accounts = accounts,
|
||||||
unread = unread,
|
unread = unread,
|
||||||
lastStatus = lastStatus.toViewData()
|
lastStatus = lastStatus.toViewData()
|
||||||
|
@ -50,6 +52,7 @@ data class ConversationEntity(
|
||||||
|
|
||||||
data class ConversationAccountEntity(
|
data class ConversationAccountEntity(
|
||||||
val id: String,
|
val id: String,
|
||||||
|
val localUsername: String,
|
||||||
val username: String,
|
val username: String,
|
||||||
val displayName: String,
|
val displayName: String,
|
||||||
val avatar: String,
|
val avatar: String,
|
||||||
|
@ -58,12 +61,12 @@ data class ConversationAccountEntity(
|
||||||
fun toAccount(): TimelineAccount {
|
fun toAccount(): TimelineAccount {
|
||||||
return TimelineAccount(
|
return TimelineAccount(
|
||||||
id = id,
|
id = id,
|
||||||
|
localUsername = localUsername,
|
||||||
username = username,
|
username = username,
|
||||||
displayName = displayName,
|
displayName = displayName,
|
||||||
url = "",
|
url = "",
|
||||||
avatar = avatar,
|
avatar = avatar,
|
||||||
emojis = emojis,
|
emojis = emojis,
|
||||||
localUsername = "",
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -134,6 +137,7 @@ data class ConversationStatusEntity(
|
||||||
fun TimelineAccount.toEntity() =
|
fun TimelineAccount.toEntity() =
|
||||||
ConversationAccountEntity(
|
ConversationAccountEntity(
|
||||||
id = id,
|
id = id,
|
||||||
|
localUsername = localUsername,
|
||||||
username = username,
|
username = username,
|
||||||
displayName = name,
|
displayName = name,
|
||||||
avatar = avatar,
|
avatar = avatar,
|
||||||
|
@ -166,10 +170,11 @@ fun Status.toEntity() =
|
||||||
poll = poll
|
poll = poll
|
||||||
)
|
)
|
||||||
|
|
||||||
fun Conversation.toEntity(accountId: Long) =
|
fun Conversation.toEntity(accountId: Long, order: Int) =
|
||||||
ConversationEntity(
|
ConversationEntity(
|
||||||
accountId = accountId,
|
accountId = accountId,
|
||||||
id = id,
|
id = id,
|
||||||
|
order = order,
|
||||||
accounts = accounts.map { it.toEntity() },
|
accounts = accounts.map { it.toEntity() },
|
||||||
unread = unread,
|
unread = unread,
|
||||||
lastStatus = lastStatus!!.toEntity()
|
lastStatus = lastStatus!!.toEntity()
|
||||||
|
|
|
@ -19,22 +19,35 @@ import android.view.LayoutInflater
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.paging.LoadState
|
import androidx.paging.LoadState
|
||||||
import androidx.paging.LoadStateAdapter
|
import androidx.paging.LoadStateAdapter
|
||||||
import com.keylesspalace.tusky.adapter.NetworkStateViewHolder
|
|
||||||
import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding
|
import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding
|
||||||
|
import com.keylesspalace.tusky.util.BindingHolder
|
||||||
|
import com.keylesspalace.tusky.util.visible
|
||||||
|
|
||||||
class ConversationLoadStateAdapter(
|
class ConversationLoadStateAdapter(
|
||||||
private val retryCallback: () -> Unit
|
private val retryCallback: () -> Unit
|
||||||
) : LoadStateAdapter<NetworkStateViewHolder>() {
|
) : LoadStateAdapter<BindingHolder<ItemNetworkStateBinding>>() {
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: NetworkStateViewHolder, loadState: LoadState) {
|
override fun onBindViewHolder(holder: BindingHolder<ItemNetworkStateBinding>, loadState: LoadState) {
|
||||||
holder.setUpWithNetworkState(loadState)
|
val binding = holder.binding
|
||||||
|
binding.progressBar.visible(loadState == LoadState.Loading)
|
||||||
|
binding.retryButton.visible(loadState is LoadState.Error)
|
||||||
|
val msg = if (loadState is LoadState.Error) {
|
||||||
|
loadState.error.message
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
binding.errorMsg.visible(msg != null)
|
||||||
|
binding.errorMsg.text = msg
|
||||||
|
binding.retryButton.setOnClickListener {
|
||||||
|
retryCallback()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateViewHolder(
|
override fun onCreateViewHolder(
|
||||||
parent: ViewGroup,
|
parent: ViewGroup,
|
||||||
loadState: LoadState
|
loadState: LoadState
|
||||||
): NetworkStateViewHolder {
|
): BindingHolder<ItemNetworkStateBinding> {
|
||||||
val binding = ItemNetworkStateBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
val binding = ItemNetworkStateBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||||
return NetworkStateViewHolder(binding, retryCallback)
|
return BindingHolder(binding)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,6 +20,7 @@ import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||||
|
|
||||||
data class ConversationViewData(
|
data class ConversationViewData(
|
||||||
val id: String,
|
val id: String,
|
||||||
|
val order: Int,
|
||||||
val accounts: List<ConversationAccountEntity>,
|
val accounts: List<ConversationAccountEntity>,
|
||||||
val unread: Boolean,
|
val unread: Boolean,
|
||||||
val lastStatus: StatusViewData.Concrete
|
val lastStatus: StatusViewData.Concrete
|
||||||
|
@ -37,6 +38,7 @@ data class ConversationViewData(
|
||||||
return ConversationEntity(
|
return ConversationEntity(
|
||||||
accountId = accountId,
|
accountId = accountId,
|
||||||
id = id,
|
id = id,
|
||||||
|
order = order,
|
||||||
accounts = accounts,
|
accounts = accounts,
|
||||||
unread = unread,
|
unread = unread,
|
||||||
lastStatus = lastStatus.toConversationStatusEntity(
|
lastStatus = lastStatus.toConversationStatusEntity(
|
||||||
|
|
|
@ -23,6 +23,8 @@ import android.widget.Button;
|
||||||
import android.widget.ImageView;
|
import android.widget.ImageView;
|
||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
import androidx.recyclerview.widget.RecyclerView;
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
|
||||||
import com.keylesspalace.tusky.R;
|
import com.keylesspalace.tusky.R;
|
||||||
|
@ -43,12 +45,12 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
|
||||||
private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE};
|
private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE};
|
||||||
private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0];
|
private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0];
|
||||||
|
|
||||||
private TextView conversationNameTextView;
|
private final TextView conversationNameTextView;
|
||||||
private Button contentCollapseButton;
|
private final Button contentCollapseButton;
|
||||||
private ImageView[] avatars;
|
private final ImageView[] avatars;
|
||||||
|
|
||||||
private StatusDisplayOptions statusDisplayOptions;
|
private final StatusDisplayOptions statusDisplayOptions;
|
||||||
private StatusActionListener listener;
|
private final StatusActionListener listener;
|
||||||
|
|
||||||
ConversationViewHolder(View itemView,
|
ConversationViewHolder(View itemView,
|
||||||
StatusDisplayOptions statusDisplayOptions,
|
StatusDisplayOptions statusDisplayOptions,
|
||||||
|
@ -64,7 +66,6 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
|
||||||
this.statusDisplayOptions = statusDisplayOptions;
|
this.statusDisplayOptions = statusDisplayOptions;
|
||||||
|
|
||||||
this.listener = listener;
|
this.listener = listener;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -72,52 +73,67 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
|
||||||
return context.getResources().getDimensionPixelSize(R.dimen.status_media_preview_height);
|
return context.getResources().getDimensionPixelSize(R.dimen.status_media_preview_height);
|
||||||
}
|
}
|
||||||
|
|
||||||
void setupWithConversation(ConversationViewData conversation) {
|
void setupWithConversation(
|
||||||
|
@NonNull ConversationViewData conversation,
|
||||||
|
@Nullable Object payloads
|
||||||
|
) {
|
||||||
|
|
||||||
StatusViewData.Concrete statusViewData = conversation.getLastStatus();
|
StatusViewData.Concrete statusViewData = conversation.getLastStatus();
|
||||||
Status status = statusViewData.getStatus();
|
Status status = statusViewData.getStatus();
|
||||||
TimelineAccount account = status.getAccount();
|
|
||||||
|
|
||||||
setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), statusViewData.getSpoilerText(), listener);
|
if (payloads == null) {
|
||||||
|
TimelineAccount account = status.getAccount();
|
||||||
|
|
||||||
setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions);
|
setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), statusViewData.getSpoilerText(), listener);
|
||||||
setUsername(account.getUsername());
|
|
||||||
setCreatedAt(status.getCreatedAt(), statusDisplayOptions);
|
|
||||||
setIsReply(status.getInReplyToId() != null);
|
|
||||||
setFavourited(status.getFavourited());
|
|
||||||
setBookmarked(status.getBookmarked());
|
|
||||||
List<Attachment> attachments = status.getAttachments();
|
|
||||||
boolean sensitive = status.getSensitive();
|
|
||||||
if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
|
|
||||||
setMediaPreviews(attachments, sensitive, listener, statusViewData.isShowingContent(),
|
|
||||||
statusDisplayOptions.useBlurhash());
|
|
||||||
|
|
||||||
if (attachments.size() == 0) {
|
setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions);
|
||||||
|
setUsername(account.getUsername());
|
||||||
|
setCreatedAt(status.getCreatedAt(), statusDisplayOptions);
|
||||||
|
setIsReply(status.getInReplyToId() != null);
|
||||||
|
setFavourited(status.getFavourited());
|
||||||
|
setBookmarked(status.getBookmarked());
|
||||||
|
List<Attachment> attachments = status.getAttachments();
|
||||||
|
boolean sensitive = status.getSensitive();
|
||||||
|
if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
|
||||||
|
setMediaPreviews(attachments, sensitive, listener, statusViewData.isShowingContent(),
|
||||||
|
statusDisplayOptions.useBlurhash());
|
||||||
|
|
||||||
|
if (attachments.size() == 0) {
|
||||||
|
hideSensitiveMediaWarning();
|
||||||
|
}
|
||||||
|
// Hide the unused label.
|
||||||
|
for (TextView mediaLabel : mediaLabels) {
|
||||||
|
mediaLabel.setVisibility(View.GONE);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent());
|
||||||
|
// Hide all unused views.
|
||||||
|
mediaPreviews[0].setVisibility(View.GONE);
|
||||||
|
mediaPreviews[1].setVisibility(View.GONE);
|
||||||
|
mediaPreviews[2].setVisibility(View.GONE);
|
||||||
|
mediaPreviews[3].setVisibility(View.GONE);
|
||||||
hideSensitiveMediaWarning();
|
hideSensitiveMediaWarning();
|
||||||
}
|
}
|
||||||
// Hide the unused label.
|
|
||||||
for (TextView mediaLabel : mediaLabels) {
|
setupButtons(listener, account.getId(), statusViewData.getContent().toString(),
|
||||||
mediaLabel.setVisibility(View.GONE);
|
statusDisplayOptions);
|
||||||
}
|
|
||||||
|
setSpoilerAndContent(statusViewData.isExpanded(), statusViewData.getContent(), status.getSpoilerText(),
|
||||||
|
status.getMentions(), status.getTags(), status.getEmojis(),
|
||||||
|
PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener);
|
||||||
|
|
||||||
|
setConversationName(conversation.getAccounts());
|
||||||
|
|
||||||
|
setAvatars(conversation.getAccounts());
|
||||||
} else {
|
} else {
|
||||||
setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent());
|
if (payloads instanceof List) {
|
||||||
// Hide all unused views.
|
for (Object item : (List<?>) payloads) {
|
||||||
mediaPreviews[0].setVisibility(View.GONE);
|
if (Key.KEY_CREATED.equals(item)) {
|
||||||
mediaPreviews[1].setVisibility(View.GONE);
|
setCreatedAt(status.getCreatedAt(), statusDisplayOptions);
|
||||||
mediaPreviews[2].setVisibility(View.GONE);
|
}
|
||||||
mediaPreviews[3].setVisibility(View.GONE);
|
}
|
||||||
hideSensitiveMediaWarning();
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setupButtons(listener, account.getId(), statusViewData.getContent().toString(),
|
|
||||||
statusDisplayOptions);
|
|
||||||
|
|
||||||
setSpoilerAndContent(statusViewData.isExpanded(), statusViewData.getContent(), status.getSpoilerText(),
|
|
||||||
status.getMentions(), status.getTags(), status.getEmojis(),
|
|
||||||
PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener);
|
|
||||||
|
|
||||||
setConversationName(conversation.getAccounts());
|
|
||||||
|
|
||||||
setAvatars(conversation.getAccounts());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setConversationName(List<ConversationAccountEntity> accounts) {
|
private void setConversationName(List<ConversationAccountEntity> accounts) {
|
||||||
|
@ -169,4 +185,4 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
|
||||||
content.setFilters(NO_INPUT_FILTER);
|
content.setFilters(NO_INPUT_FILTER);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,20 +22,27 @@ import android.view.ViewGroup
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.appcompat.widget.PopupMenu
|
import androidx.appcompat.widget.PopupMenu
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.paging.ExperimentalPagingApi
|
|
||||||
import androidx.paging.LoadState
|
import androidx.paging.LoadState
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import androidx.recyclerview.widget.DividerItemDecoration
|
import androidx.recyclerview.widget.DividerItemDecoration
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import androidx.recyclerview.widget.SimpleItemAnimator
|
import androidx.recyclerview.widget.SimpleItemAnimator
|
||||||
|
import at.connyduck.sparkbutton.helpers.Utils
|
||||||
|
import autodispose2.androidx.lifecycle.autoDispose
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
import com.keylesspalace.tusky.StatusListActivity
|
import com.keylesspalace.tusky.StatusListActivity
|
||||||
|
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
|
||||||
|
import com.keylesspalace.tusky.appstore.EventHub
|
||||||
|
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||||
import com.keylesspalace.tusky.components.account.AccountActivity
|
import com.keylesspalace.tusky.components.account.AccountActivity
|
||||||
import com.keylesspalace.tusky.databinding.FragmentTimelineBinding
|
import com.keylesspalace.tusky.databinding.FragmentTimelineBinding
|
||||||
import com.keylesspalace.tusky.di.Injectable
|
import com.keylesspalace.tusky.di.Injectable
|
||||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||||
import com.keylesspalace.tusky.fragment.SFragment
|
import com.keylesspalace.tusky.fragment.SFragment
|
||||||
|
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
|
||||||
import com.keylesspalace.tusky.interfaces.ReselectableFragment
|
import com.keylesspalace.tusky.interfaces.ReselectableFragment
|
||||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||||
import com.keylesspalace.tusky.settings.PrefKeys
|
import com.keylesspalace.tusky.settings.PrefKeys
|
||||||
|
@ -44,29 +51,31 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||||
import com.keylesspalace.tusky.util.hide
|
import com.keylesspalace.tusky.util.hide
|
||||||
import com.keylesspalace.tusky.util.show
|
import com.keylesspalace.tusky.util.show
|
||||||
import com.keylesspalace.tusky.util.viewBinding
|
import com.keylesspalace.tusky.util.viewBinding
|
||||||
import com.keylesspalace.tusky.util.visible
|
|
||||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||||
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlin.time.DurationUnit
|
||||||
|
import kotlin.time.toDuration
|
||||||
|
|
||||||
@OptIn(ExperimentalPagingApi::class)
|
|
||||||
class ConversationsFragment : SFragment(), StatusActionListener, Injectable, ReselectableFragment {
|
class ConversationsFragment : SFragment(), StatusActionListener, Injectable, ReselectableFragment {
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var viewModelFactory: ViewModelFactory
|
lateinit var viewModelFactory: ViewModelFactory
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var eventHub: EventHub
|
||||||
|
|
||||||
private val viewModel: ConversationsViewModel by viewModels { viewModelFactory }
|
private val viewModel: ConversationsViewModel by viewModels { viewModelFactory }
|
||||||
|
|
||||||
private val binding by viewBinding(FragmentTimelineBinding::bind)
|
private val binding by viewBinding(FragmentTimelineBinding::bind)
|
||||||
|
|
||||||
private lateinit var adapter: ConversationAdapter
|
private lateinit var adapter: ConversationAdapter
|
||||||
private lateinit var loadStateAdapter: ConversationLoadStateAdapter
|
|
||||||
|
|
||||||
private var layoutManager: LinearLayoutManager? = null
|
private var hideFab = false
|
||||||
|
|
||||||
private var initialRefreshDone: Boolean = false
|
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||||
return inflater.inflate(R.layout.fragment_timeline, container, false)
|
return inflater.inflate(R.layout.fragment_timeline, container, false)
|
||||||
|
@ -89,56 +98,106 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
||||||
)
|
)
|
||||||
|
|
||||||
adapter = ConversationAdapter(statusDisplayOptions, this)
|
adapter = ConversationAdapter(statusDisplayOptions, this)
|
||||||
loadStateAdapter = ConversationLoadStateAdapter(adapter::retry)
|
|
||||||
|
|
||||||
binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
|
setupRecyclerView()
|
||||||
layoutManager = LinearLayoutManager(view.context)
|
|
||||||
binding.recyclerView.layoutManager = layoutManager
|
|
||||||
binding.recyclerView.adapter = adapter.withLoadStateFooter(loadStateAdapter)
|
|
||||||
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
|
||||||
|
|
||||||
binding.progressBar.hide()
|
|
||||||
binding.statusView.hide()
|
|
||||||
|
|
||||||
initSwipeToRefresh()
|
initSwipeToRefresh()
|
||||||
|
|
||||||
|
adapter.addLoadStateListener { loadState ->
|
||||||
|
if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) {
|
||||||
|
binding.swipeRefreshLayout.isRefreshing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.statusView.hide()
|
||||||
|
binding.progressBar.hide()
|
||||||
|
|
||||||
|
if (adapter.itemCount == 0) {
|
||||||
|
when (loadState.refresh) {
|
||||||
|
is LoadState.NotLoading -> {
|
||||||
|
if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) {
|
||||||
|
binding.statusView.show()
|
||||||
|
binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is LoadState.Error -> {
|
||||||
|
binding.statusView.show()
|
||||||
|
|
||||||
|
if ((loadState.refresh as LoadState.Error).error is IOException) {
|
||||||
|
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, null)
|
||||||
|
} else {
|
||||||
|
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is LoadState.Loading -> {
|
||||||
|
binding.progressBar.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
|
||||||
|
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
|
||||||
|
if (positionStart == 0 && adapter.itemCount != itemCount) {
|
||||||
|
binding.recyclerView.post {
|
||||||
|
if (getView() != null) {
|
||||||
|
binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
hideFab = preferences.getBoolean(PrefKeys.FAB_HIDE, false)
|
||||||
|
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||||
|
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
|
||||||
|
val composeButton = (activity as ActionButtonActivity).actionButton
|
||||||
|
if (composeButton != null) {
|
||||||
|
if (hideFab) {
|
||||||
|
if (dy > 0 && composeButton.isShown) {
|
||||||
|
composeButton.hide() // hides the button if we're scrolling down
|
||||||
|
} else if (dy < 0 && !composeButton.isShown) {
|
||||||
|
composeButton.show() // shows it if we are scrolling up
|
||||||
|
}
|
||||||
|
} else if (!composeButton.isShown) {
|
||||||
|
composeButton.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
viewModel.conversationFlow.collectLatest { pagingData ->
|
viewModel.conversationFlow.collectLatest { pagingData ->
|
||||||
adapter.submitData(pagingData)
|
adapter.submitData(pagingData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
adapter.addLoadStateListener { loadStates ->
|
lifecycleScope.launchWhenResumed {
|
||||||
|
val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false)
|
||||||
loadStates.refresh.let { refreshState ->
|
while (!useAbsoluteTime) {
|
||||||
if (refreshState is LoadState.Error) {
|
adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED))
|
||||||
binding.statusView.show()
|
delay(1.toDuration(DurationUnit.MINUTES))
|
||||||
if (refreshState.error is IOException) {
|
|
||||||
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) {
|
|
||||||
adapter.refresh()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) {
|
|
||||||
adapter.refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
binding.statusView.hide()
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.progressBar.visible(refreshState == LoadState.Loading && adapter.itemCount == 0)
|
|
||||||
|
|
||||||
if (refreshState is LoadState.NotLoading && !initialRefreshDone) {
|
|
||||||
// jump to top after the initial refresh finished
|
|
||||||
binding.recyclerView.scrollToPosition(0)
|
|
||||||
initialRefreshDone = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (refreshState != LoadState.Loading) {
|
|
||||||
binding.swipeRefreshLayout.isRefreshing = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
eventHub.events
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
|
||||||
|
.subscribe { event ->
|
||||||
|
if (event is PreferenceChangedEvent) {
|
||||||
|
onPreferenceChanged(event.preferenceKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupRecyclerView() {
|
||||||
|
binding.recyclerView.setHasFixedSize(true)
|
||||||
|
binding.recyclerView.layoutManager = LinearLayoutManager(context)
|
||||||
|
|
||||||
|
binding.recyclerView.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
|
||||||
|
|
||||||
|
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
||||||
|
|
||||||
|
binding.recyclerView.adapter = adapter.withLoadStateFooter(ConversationLoadStateAdapter(adapter::retry))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initSwipeToRefresh() {
|
private fun initSwipeToRefresh() {
|
||||||
|
@ -201,7 +260,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOpenReblog(position: Int) {
|
override fun onOpenReblog(position: Int) {
|
||||||
// there are no reblogs in search results
|
// there are no reblogs in conversations
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onExpandedChange(expanded: Boolean, position: Int) {
|
override fun onExpandedChange(expanded: Boolean, position: Int) {
|
||||||
|
@ -246,6 +305,19 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
|
||||||
|
adapter.peek(position)?.let { conversation ->
|
||||||
|
viewModel.voteInPoll(choices, conversation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReselect() {
|
||||||
|
if (isAdded) {
|
||||||
|
binding.recyclerView.layoutManager?.scrollToPosition(0)
|
||||||
|
binding.recyclerView.stopScroll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun deleteConversation(conversation: ConversationViewData) {
|
private fun deleteConversation(conversation: ConversationViewData) {
|
||||||
AlertDialog.Builder(requireContext())
|
AlertDialog.Builder(requireContext())
|
||||||
.setMessage(R.string.dialog_delete_conversation_warning)
|
.setMessage(R.string.dialog_delete_conversation_warning)
|
||||||
|
@ -256,20 +328,20 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun jumpToTop() {
|
private fun onPreferenceChanged(key: String) {
|
||||||
if (isAdded) {
|
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||||
layoutManager?.scrollToPosition(0)
|
when (key) {
|
||||||
binding.recyclerView.stopScroll()
|
PrefKeys.FAB_HIDE -> {
|
||||||
}
|
hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false)
|
||||||
}
|
}
|
||||||
|
PrefKeys.MEDIA_PREVIEW_ENABLED -> {
|
||||||
override fun onReselect() {
|
val enabled = accountManager.activeAccount!!.mediaPreviewEnabled
|
||||||
jumpToTop()
|
val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled
|
||||||
}
|
if (enabled != oldMediaPreviewEnabled) {
|
||||||
|
adapter.mediaPreviewEnabled = enabled
|
||||||
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
|
adapter.notifyItemRangeChanged(0, adapter.itemCount)
|
||||||
adapter.peek(position)?.let { conversation ->
|
}
|
||||||
viewModel.voteInPoll(choices, conversation)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,8 +4,11 @@ import androidx.paging.ExperimentalPagingApi
|
||||||
import androidx.paging.LoadType
|
import androidx.paging.LoadType
|
||||||
import androidx.paging.PagingState
|
import androidx.paging.PagingState
|
||||||
import androidx.paging.RemoteMediator
|
import androidx.paging.RemoteMediator
|
||||||
|
import androidx.room.withTransaction
|
||||||
import com.keylesspalace.tusky.db.AppDatabase
|
import com.keylesspalace.tusky.db.AppDatabase
|
||||||
import com.keylesspalace.tusky.network.MastodonApi
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
|
import com.keylesspalace.tusky.util.HttpHeaderLink
|
||||||
|
import retrofit2.HttpException
|
||||||
|
|
||||||
@OptIn(ExperimentalPagingApi::class)
|
@OptIn(ExperimentalPagingApi::class)
|
||||||
class ConversationsRemoteMediator(
|
class ConversationsRemoteMediator(
|
||||||
|
@ -14,38 +17,53 @@ class ConversationsRemoteMediator(
|
||||||
private val db: AppDatabase
|
private val db: AppDatabase
|
||||||
) : RemoteMediator<Int, ConversationEntity>() {
|
) : RemoteMediator<Int, ConversationEntity>() {
|
||||||
|
|
||||||
|
private var nextKey: String? = null
|
||||||
|
|
||||||
|
private var order: Int = 0
|
||||||
|
|
||||||
override suspend fun load(
|
override suspend fun load(
|
||||||
loadType: LoadType,
|
loadType: LoadType,
|
||||||
state: PagingState<Int, ConversationEntity>
|
state: PagingState<Int, ConversationEntity>
|
||||||
): MediatorResult {
|
): MediatorResult {
|
||||||
|
|
||||||
|
if (loadType == LoadType.PREPEND) {
|
||||||
|
return MediatorResult.Success(endOfPaginationReached = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadType == LoadType.REFRESH) {
|
||||||
|
nextKey = null
|
||||||
|
order = 0
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val conversationsResult = when (loadType) {
|
val conversationsResponse = api.getConversations(maxId = nextKey, limit = state.config.pageSize)
|
||||||
LoadType.REFRESH -> {
|
|
||||||
api.getConversations(limit = state.config.initialLoadSize)
|
val conversations = conversationsResponse.body()
|
||||||
}
|
if (!conversationsResponse.isSuccessful || conversations == null) {
|
||||||
LoadType.PREPEND -> {
|
return MediatorResult.Error(HttpException(conversationsResponse))
|
||||||
return MediatorResult.Success(endOfPaginationReached = true)
|
|
||||||
}
|
|
||||||
LoadType.APPEND -> {
|
|
||||||
val maxId = state.pages.findLast { it.data.isNotEmpty() }?.data?.lastOrNull()?.lastStatus?.id
|
|
||||||
api.getConversations(maxId = maxId, limit = state.config.pageSize)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loadType == LoadType.REFRESH) {
|
db.withTransaction {
|
||||||
db.conversationDao().deleteForAccount(accountId)
|
|
||||||
|
if (loadType == LoadType.REFRESH) {
|
||||||
|
db.conversationDao().deleteForAccount(accountId)
|
||||||
|
}
|
||||||
|
|
||||||
|
val linkHeader = conversationsResponse.headers()["Link"]
|
||||||
|
val links = HttpHeaderLink.parse(linkHeader)
|
||||||
|
nextKey = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id")
|
||||||
|
|
||||||
|
db.conversationDao().insert(
|
||||||
|
conversations
|
||||||
|
.filterNot { it.lastStatus == null }
|
||||||
|
.map {
|
||||||
|
it.toEntity(accountId, order++)
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
db.conversationDao().insert(
|
return MediatorResult.Success(endOfPaginationReached = nextKey == null)
|
||||||
conversationsResult
|
|
||||||
.filterNot { it.lastStatus == null }
|
|
||||||
.map { it.toEntity(accountId) }
|
|
||||||
)
|
|
||||||
return MediatorResult.Success(endOfPaginationReached = conversationsResult.isEmpty())
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
return MediatorResult.Error(e)
|
return MediatorResult.Error(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun initialize() = InitializeAction.LAUNCH_INITIAL_REFRESH
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,22 +16,15 @@
|
||||||
package com.keylesspalace.tusky.components.conversation
|
package com.keylesspalace.tusky.components.conversation
|
||||||
|
|
||||||
import com.keylesspalace.tusky.db.AppDatabase
|
import com.keylesspalace.tusky.db.AppDatabase
|
||||||
import com.keylesspalace.tusky.network.MastodonApi
|
|
||||||
import io.reactivex.rxjava3.core.Single
|
|
||||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class ConversationsRepository @Inject constructor(
|
class ConversationsRepository @Inject constructor(
|
||||||
val mastodonApi: MastodonApi,
|
|
||||||
val db: AppDatabase
|
val db: AppDatabase
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun deleteCacheForAccount(accountId: Long) {
|
suspend fun deleteCacheForAccount(accountId: Long) {
|
||||||
Single.fromCallable {
|
db.conversationDao().deleteForAccount(accountId)
|
||||||
db.conversationDao().deleteForAccount(accountId)
|
|
||||||
}.subscribeOn(Schedulers.io())
|
|
||||||
.subscribe()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,7 +41,7 @@ class ConversationsViewModel @Inject constructor(
|
||||||
|
|
||||||
@OptIn(ExperimentalPagingApi::class)
|
@OptIn(ExperimentalPagingApi::class)
|
||||||
val conversationFlow = Pager(
|
val conversationFlow = Pager(
|
||||||
config = PagingConfig(pageSize = 10, enablePlaceholders = false, initialLoadSize = 20),
|
config = PagingConfig(pageSize = 30),
|
||||||
remoteMediator = ConversationsRemoteMediator(accountManager.activeAccount!!.id, api, database),
|
remoteMediator = ConversationsRemoteMediator(accountManager.activeAccount!!.id, api, database),
|
||||||
pagingSourceFactory = { database.conversationDao().conversationsForAccount(accountManager.activeAccount!!.id) }
|
pagingSourceFactory = { database.conversationDao().conversationsForAccount(accountManager.activeAccount!!.id) }
|
||||||
)
|
)
|
||||||
|
|
|
@ -565,10 +565,12 @@ public abstract class AppDatabase extends RoomDatabase {
|
||||||
public static final Migration MIGRATION_37_38 = new Migration(37, 38) {
|
public static final Migration MIGRATION_37_38 = new Migration(37, 38) {
|
||||||
@Override
|
@Override
|
||||||
public void migrate(@NonNull SupportSQLiteDatabase database) {
|
public void migrate(@NonNull SupportSQLiteDatabase database) {
|
||||||
|
// database needs to be cleaned because the ConversationAccountEntity got a new attribute
|
||||||
// no actual scheme change, but timestamps are now serialized differently so all cache tables that contain them need to be cleaned
|
|
||||||
database.execSQL("DELETE FROM `TimelineStatusEntity`");
|
|
||||||
database.execSQL("DELETE FROM `ConversationEntity`");
|
database.execSQL("DELETE FROM `ConversationEntity`");
|
||||||
|
database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `order` INTEGER NOT NULL DEFAULT 0");
|
||||||
|
|
||||||
|
// timestamps are now serialized differently so all cache tables that contain them need to be cleaned
|
||||||
|
database.execSQL("DELETE FROM `TimelineStatusEntity`");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,14 +28,14 @@ interface ConversationsDao {
|
||||||
suspend fun insert(conversations: List<ConversationEntity>)
|
suspend fun insert(conversations: List<ConversationEntity>)
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun insert(conversation: ConversationEntity): Long
|
suspend fun insert(conversation: ConversationEntity)
|
||||||
|
|
||||||
@Query("DELETE FROM ConversationEntity WHERE id = :id AND accountId = :accountId")
|
@Query("DELETE FROM ConversationEntity WHERE id = :id AND accountId = :accountId")
|
||||||
suspend fun delete(id: String, accountId: Long): Int
|
suspend fun delete(id: String, accountId: Long)
|
||||||
|
|
||||||
@Query("SELECT * FROM ConversationEntity WHERE accountId = :accountId ORDER BY s_createdAt DESC")
|
@Query("SELECT * FROM ConversationEntity WHERE accountId = :accountId ORDER BY `order` ASC")
|
||||||
fun conversationsForAccount(accountId: Long): PagingSource<Int, ConversationEntity>
|
fun conversationsForAccount(accountId: Long): PagingSource<Int, ConversationEntity>
|
||||||
|
|
||||||
@Query("DELETE FROM ConversationEntity WHERE accountId = :accountId")
|
@Query("DELETE FROM ConversationEntity WHERE accountId = :accountId")
|
||||||
fun deleteForAccount(accountId: Long)
|
suspend fun deleteForAccount(accountId: Long)
|
||||||
}
|
}
|
||||||
|
|
|
@ -503,8 +503,8 @@ interface MastodonApi {
|
||||||
@GET("/api/v1/conversations")
|
@GET("/api/v1/conversations")
|
||||||
suspend fun getConversations(
|
suspend fun getConversations(
|
||||||
@Query("max_id") maxId: String? = null,
|
@Query("max_id") maxId: String? = null,
|
||||||
@Query("limit") limit: Int
|
@Query("limit") limit: Int? = null
|
||||||
): List<Conversation>
|
): Response<List<Conversation>>
|
||||||
|
|
||||||
@DELETE("/api/v1/conversations/{id}")
|
@DELETE("/api/v1/conversations/{id}")
|
||||||
suspend fun deleteConversation(
|
suspend fun deleteConversation(
|
||||||
|
|
Loading…
Reference in a new issue