Fix status diffing and improve timeline performance (#2386)

* fix status & account diffing

* introduce TimelineAccount

* use TimelineAccount where possible

* improve tests

* improve ConversationEntity equals/hashcode

* fix mistake in ConversationEntity

* improve StatusViewData comparison

* improve tests

* fix typo in comment
This commit is contained in:
Konrad Pozniak 2022-03-15 21:34:57 +01:00 committed by GitHub
parent 6e4a9fb0e6
commit e05fdc6d7b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 463 additions and 147 deletions

View File

@ -34,7 +34,7 @@ import com.keylesspalace.tusky.databinding.FragmentAccountsInListBinding
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.Either
@ -49,7 +49,7 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import java.io.IOException
import javax.inject.Inject
private typealias AccountInfo = Pair<Account, Boolean>
private typealias AccountInfo = Pair<TimelineAccount, Boolean>
class AccountsInListFragment : DialogFragment(), Injectable {
@ -168,21 +168,21 @@ class AccountsInListFragment : DialogFragment(), Injectable {
viewModel.deleteAccountFromList(listId, accountId)
}
private fun onAddToList(account: Account) {
private fun onAddToList(account: TimelineAccount) {
viewModel.addAccountToList(listId, account)
}
private object AccountDiffer : DiffUtil.ItemCallback<Account>() {
override fun areItemsTheSame(oldItem: Account, newItem: Account): Boolean {
return oldItem == newItem
private object AccountDiffer : DiffUtil.ItemCallback<TimelineAccount>() {
override fun areItemsTheSame(oldItem: TimelineAccount, newItem: TimelineAccount): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: Account, newItem: Account): Boolean {
return oldItem.deepEquals(newItem)
override fun areContentsTheSame(oldItem: TimelineAccount, newItem: TimelineAccount): Boolean {
return oldItem == newItem
}
}
inner class Adapter : ListAdapter<Account, BindingHolder<ItemFollowRequestBinding>>(AccountDiffer) {
inner class Adapter : ListAdapter<TimelineAccount, BindingHolder<ItemFollowRequestBinding>>(AccountDiffer) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemFollowRequestBinding> {
val binding = ItemFollowRequestBinding.inflate(LayoutInflater.from(parent.context), parent, false)
@ -209,12 +209,11 @@ class AccountsInListFragment : DialogFragment(), Injectable {
private object SearchDiffer : DiffUtil.ItemCallback<AccountInfo>() {
override fun areItemsTheSame(oldItem: AccountInfo, newItem: AccountInfo): Boolean {
return oldItem == newItem
return oldItem.first.id == newItem.first.id
}
override fun areContentsTheSame(oldItem: AccountInfo, newItem: AccountInfo): Boolean {
return oldItem.second == newItem.second &&
oldItem.first.deepEquals(newItem.first)
return oldItem == newItem
}
}

View File

@ -18,7 +18,7 @@ import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.util.removeDuplicates
@ -28,7 +28,7 @@ abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructo
protected val animateAvatar: Boolean,
protected val animateEmojis: Boolean
) : RecyclerView.Adapter<RecyclerView.ViewHolder?>() {
var accountList = mutableListOf<Account>()
var accountList = mutableListOf<TimelineAccount>()
private var bottomLoading: Boolean = false
override fun getItemCount(): Int {
@ -73,12 +73,12 @@ abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructo
}
}
fun update(newAccounts: List<Account>) {
fun update(newAccounts: List<TimelineAccount>) {
accountList = removeDuplicates(newAccounts)
notifyDataSetChanged()
}
fun addItems(newAccounts: List<Account>) {
fun addItems(newAccounts: List<TimelineAccount>) {
val end = accountList.size
val last = accountList[end - 1]
if (newAccounts.none { it.id == last.id }) {
@ -100,7 +100,7 @@ abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructo
}
}
fun removeItem(position: Int): Account? {
fun removeItem(position: Int): TimelineAccount? {
if (position < 0 || position >= accountList.size) {
return null
}
@ -109,7 +109,7 @@ abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructo
return account
}
fun addItem(account: Account, position: Int) {
fun addItem(account: TimelineAccount, position: Int) {
if (position < 0 || position > accountList.size) {
return
}

View File

@ -9,7 +9,7 @@ import androidx.preference.PreferenceManager;
import androidx.recyclerview.widget.RecyclerView;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.entity.TimelineAccount;
import com.keylesspalace.tusky.interfaces.AccountActionListener;
import com.keylesspalace.tusky.interfaces.LinkListener;
import com.keylesspalace.tusky.util.CustomEmojiHelper;
@ -33,7 +33,7 @@ public class AccountViewHolder extends RecyclerView.ViewHolder {
showBotOverlay = sharedPrefs.getBoolean("showBotOverlay", true);
}
public void setupWithAccount(Account account, boolean animateAvatar, boolean animateEmojis) {
public void setupWithAccount(TimelineAccount account, boolean animateAvatar, boolean animateEmojis) {
accountId = account.getId();
String format = username.getContext().getString(R.string.status_username_format);
String formattedUsername = String.format(format, account.getUsername());

View File

@ -22,7 +22,7 @@ import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.loadAvatar
@ -55,7 +55,7 @@ class BlocksAdapter(
private val unblock: ImageButton = itemView.findViewById(R.id.blocked_user_unblock)
private var id: String? = null
fun setupWithAccount(account: Account, animateAvatar: Boolean, animateEmojis: Boolean) {
fun setupWithAccount(account: TimelineAccount, animateAvatar: Boolean, animateEmojis: Boolean) {
id = account.id
val emojifiedName = account.name.emojify(account.emojis, displayName, animateEmojis)
displayName.text = emojifiedName

View File

@ -22,7 +22,7 @@ import android.text.style.StyleSpan
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.loadAvatar
@ -34,7 +34,7 @@ class FollowRequestViewHolder(
private val showHeader: Boolean
) : RecyclerView.ViewHolder(binding.root) {
fun setupWithAccount(account: Account, animateAvatar: Boolean, animateEmojis: Boolean) {
fun setupWithAccount(account: TimelineAccount, animateAvatar: Boolean, animateEmojis: Boolean) {
val wrappedName = account.name.unicodeWrap()
val emojifiedName: CharSequence = wrappedName.emojify(account.emojis, itemView, animateEmojis)
binding.displayNameTextView.text = emojifiedName

View File

@ -9,7 +9,7 @@ import android.widget.TextView
import androidx.core.view.ViewCompat
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.loadAvatar
@ -69,7 +69,7 @@ class MutesAdapter(
private var notifications = false
fun setupWithAccount(
account: Account,
account: TimelineAccount,
mutingNotifications: Boolean?,
animateAvatar: Boolean,
animateEmojis: Boolean

View File

@ -40,10 +40,10 @@ import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding;
import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.entity.TimelineAccount;
import com.keylesspalace.tusky.interfaces.AccountActionListener;
import com.keylesspalace.tusky.interfaces.LinkListener;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
@ -335,7 +335,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
this.statusDisplayOptions = statusDisplayOptions;
}
void setMessage(Account account) {
void setMessage(TimelineAccount account) {
Context context = message.getContext();
String format = context.getString(R.string.notification_follow_format);

View File

@ -16,7 +16,6 @@
package com.keylesspalace.tusky.components.compose;
import android.content.Context;
import android.preference.PreferenceManager;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@ -28,9 +27,9 @@ import android.widget.TextView;
import com.bumptech.glide.Glide;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.HashTag;
import com.keylesspalace.tusky.entity.TimelineAccount;
import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.ImageLoadingHelper;
@ -144,7 +143,7 @@ public class ComposeAutoCompleteAdapter extends BaseAdapter
AccountResult accountResult = ((AccountResult) getItem(position));
if (accountResult != null) {
Account account = accountResult.account;
TimelineAccount account = accountResult.account;
String formattedUsername = context.getString(
R.string.status_username_format,
account.getUsername()
@ -268,9 +267,9 @@ public class ComposeAutoCompleteAdapter extends BaseAdapter
}
public final static class AccountResult extends AutocompleteResult {
private final Account account;
private final TimelineAccount account;
public AccountResult(Account account) {
public AccountResult(TimelineAccount account) {
this.account = account;
}
}

View File

@ -16,18 +16,17 @@
package com.keylesspalace.tusky.components.conversation
import android.text.Spanned
import android.text.SpannedString
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.TypeConverters
import com.keylesspalace.tusky.db.Converters
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Conversation
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.util.shouldTrimStatus
import java.util.Date
@ -48,17 +47,15 @@ data class ConversationAccountEntity(
val avatar: String,
val emojis: List<Emoji>
) {
fun toAccount(): Account {
return Account(
fun toAccount(): TimelineAccount {
return TimelineAccount(
id = id,
username = username,
displayName = displayName,
url = "",
avatar = avatar,
emojis = emojis,
url = "",
localUsername = "",
note = SpannedString(""),
header = ""
)
}
}
@ -100,7 +97,7 @@ data class ConversationStatusEntity(
if (inReplyToId != other.inReplyToId) return false
if (inReplyToAccountId != other.inReplyToAccountId) return false
if (account != other.account) return false
if (content.toString() != other.content.toString()) return false // TODO find a better method to compare two spanned strings
if (content.toString() != other.content.toString()) return false
if (createdAt != other.createdAt) return false
if (emojis != other.emojis) return false
if (favouritesCount != other.favouritesCount) return false
@ -126,7 +123,7 @@ data class ConversationStatusEntity(
result = 31 * result + (inReplyToId?.hashCode() ?: 0)
result = 31 * result + (inReplyToAccountId?.hashCode() ?: 0)
result = 31 * result + account.hashCode()
result = 31 * result + content.hashCode()
result = 31 * result + content.toString().hashCode()
result = 31 * result + createdAt.hashCode()
result = 31 * result + emojis.hashCode()
result = 31 * result + favouritesCount
@ -176,7 +173,7 @@ data class ConversationStatusEntity(
}
}
fun Account.toEntity() =
fun TimelineAccount.toEntity() =
ConversationAccountEntity(
id = id,
username = username,

View File

@ -21,11 +21,11 @@ import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.AccountViewHolder
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.interfaces.LinkListener
class SearchAccountsAdapter(private val linkListener: LinkListener, private val animateAvatars: Boolean, private val animateEmojis: Boolean) :
PagingDataAdapter<Account, AccountViewHolder>(ACCOUNT_COMPARATOR) {
PagingDataAdapter<TimelineAccount, AccountViewHolder>(ACCOUNT_COMPARATOR) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountViewHolder {
val view = LayoutInflater.from(parent.context)
@ -44,11 +44,11 @@ class SearchAccountsAdapter(private val linkListener: LinkListener, private val
companion object {
val ACCOUNT_COMPARATOR = object : DiffUtil.ItemCallback<Account>() {
override fun areContentsTheSame(oldItem: Account, newItem: Account): Boolean =
oldItem.deepEquals(newItem)
val ACCOUNT_COMPARATOR = object : DiffUtil.ItemCallback<TimelineAccount>() {
override fun areContentsTheSame(oldItem: TimelineAccount, newItem: TimelineAccount): Boolean =
oldItem == newItem
override fun areItemsTheSame(oldItem: Account, newItem: Account): Boolean =
override fun areItemsTheSame(oldItem: TimelineAccount, newItem: TimelineAccount): Boolean =
oldItem.id == newItem.id
}
}

View File

@ -19,12 +19,12 @@ import androidx.paging.PagingData
import androidx.paging.PagingDataAdapter
import androidx.preference.PreferenceManager
import com.keylesspalace.tusky.components.search.adapter.SearchAccountsAdapter
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.settings.PrefKeys
import kotlinx.coroutines.flow.Flow
class SearchAccountsFragment : SearchFragment<Account>() {
override fun createAdapter(): PagingDataAdapter<Account, *> {
class SearchAccountsFragment : SearchFragment<TimelineAccount>() {
override fun createAdapter(): PagingDataAdapter<TimelineAccount, *> {
val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context)
return SearchAccountsAdapter(
@ -34,7 +34,7 @@ class SearchAccountsFragment : SearchFragment<Account>() {
)
}
override val data: Flow<PagingData<Account>>
override val data: Flow<PagingData<TimelineAccount>>
get() = viewModel.accountsFlow
companion object {

View File

@ -114,7 +114,7 @@ class TimelinePagingAdapter(
oldItem: StatusViewData,
newItem: StatusViewData
): Boolean {
return oldItem.viewDataId == newItem.viewDataId
return oldItem.id == newItem.id
}
override fun areContentsTheSame(
@ -128,7 +128,7 @@ class TimelinePagingAdapter(
oldItem: StatusViewData,
newItem: StatusViewData
): Any? {
return if (oldItem === newItem) {
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

View File

@ -23,12 +23,12 @@ import com.google.gson.reflect.TypeToken
import com.keylesspalace.tusky.db.TimelineAccountEntity
import com.keylesspalace.tusky.db.TimelineStatusEntity
import com.keylesspalace.tusky.db.TimelineStatusWithAccount
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.util.shouldTrimStatus
import com.keylesspalace.tusky.util.trimTrailingWhitespace
import com.keylesspalace.tusky.viewdata.StatusViewData
@ -44,7 +44,7 @@ private val emojisListType = object : TypeToken<List<Emoji>>() {}.type
private val mentionListType = object : TypeToken<List<Status.Mention>>() {}.type
private val tagListType = object : TypeToken<List<HashTag>>() {}.type
fun Account.toEntity(accountId: Long, gson: Gson): TimelineAccountEntity {
fun TimelineAccount.toEntity(accountId: Long, gson: Gson): TimelineAccountEntity {
return TimelineAccountEntity(
serverId = id,
timelineUserId = accountId,
@ -58,25 +58,16 @@ fun Account.toEntity(accountId: Long, gson: Gson): TimelineAccountEntity {
)
}
fun TimelineAccountEntity.toAccount(gson: Gson): Account {
return Account(
fun TimelineAccountEntity.toAccount(gson: Gson): TimelineAccount {
return TimelineAccount(
id = serverId,
localUsername = localUsername,
username = username,
displayName = displayName,
note = SpannedString(""),
url = url,
avatar = avatar,
header = "",
locked = false,
followingCount = 0,
followersCount = 0,
statusesCount = 0,
source = null,
bot = bot,
emojis = gson.fromJson(emojis, emojisListType),
fields = null,
moved = null
emojis = gson.fromJson(emojis, emojisListType)
)
}

View File

@ -45,37 +45,57 @@ data class Account(
localUsername
} else displayName
override fun hashCode(): Int {
return id.hashCode()
}
override fun equals(other: Any?): Boolean {
if (other !is Account) {
return false
}
return other.id == this.id
}
fun deepEquals(other: Account): Boolean {
return id == other.id &&
localUsername == other.localUsername &&
displayName == other.displayName &&
note == other.note &&
url == other.url &&
avatar == other.avatar &&
header == other.header &&
locked == other.locked &&
followersCount == other.followersCount &&
followingCount == other.followingCount &&
statusesCount == other.statusesCount &&
source == other.source &&
bot == other.bot &&
emojis == other.emojis &&
fields == other.fields &&
moved == other.moved
}
fun isRemote(): Boolean = this.username != this.localUsername
/**
* overriding equals & hashcode because Spanned does not always compare correctly otherwise
*/
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Account
if (id != other.id) return false
if (localUsername != other.localUsername) return false
if (username != other.username) return false
if (displayName != other.displayName) return false
if (note.toString() != other.note.toString()) return false
if (url != other.url) return false
if (avatar != other.avatar) return false
if (header != other.header) return false
if (locked != other.locked) return false
if (followersCount != other.followersCount) return false
if (followingCount != other.followingCount) return false
if (statusesCount != other.statusesCount) return false
if (source != other.source) return false
if (bot != other.bot) return false
if (emojis != other.emojis) return false
if (fields != other.fields) return false
if (moved != other.moved) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + localUsername.hashCode()
result = 31 * result + username.hashCode()
result = 31 * result + (displayName?.hashCode() ?: 0)
result = 31 * result + note.toString().hashCode()
result = 31 * result + url.hashCode()
result = 31 * result + avatar.hashCode()
result = 31 * result + header.hashCode()
result = 31 * result + locked.hashCode()
result = 31 * result + followersCount
result = 31 * result + followingCount
result = 31 * result + statusesCount
result = 31 * result + (source?.hashCode() ?: 0)
result = 31 * result + bot.hashCode()
result = 31 * result + (emojis?.hashCode() ?: 0)
result = 31 * result + (fields?.hashCode() ?: 0)
result = 31 * result + (moved?.hashCode() ?: 0)
return result
}
}
data class AccountSource(

View File

@ -19,7 +19,7 @@ import com.google.gson.annotations.SerializedName
data class Conversation(
val id: String,
val accounts: List<Account>,
val accounts: List<TimelineAccount>,
@SerializedName("last_status") val lastStatus: Status?, // should never be null, but apparently its possible https://github.com/tuskyapp/Tusky/issues/1038
val unread: Boolean
)

View File

@ -24,7 +24,7 @@ import com.google.gson.annotations.JsonAdapter
data class Notification(
val type: Type,
val id: String,
val account: Account,
val account: TimelineAccount,
val status: Status?
) {

View File

@ -16,7 +16,7 @@
package com.keylesspalace.tusky.entity
data class SearchResult(
val accounts: List<Account>,
val accounts: List<TimelineAccount>,
val statuses: List<Status>,
val hashtags: List<HashTag>
)

View File

@ -25,7 +25,7 @@ import java.util.Date
data class Status(
val id: String,
val url: String?, // not present if it's reblog
val account: Account,
val account: TimelineAccount,
@SerializedName("in_reply_to_id") var inReplyToId: String?,
@SerializedName("in_reply_to_account_id") val inReplyToAccountId: String?,
val reblog: Status?,
@ -149,6 +149,71 @@ data class Status(
return builder.toString()
}
/**
* overriding equals & hashcode because Spanned does not always compare correctly otherwise
*/
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Status
if (id != other.id) return false
if (url != other.url) return false
if (account != other.account) return false
if (inReplyToId != other.inReplyToId) return false
if (inReplyToAccountId != other.inReplyToAccountId) return false
if (reblog != other.reblog) return false
if (content.toString() != other.content.toString()) return false
if (createdAt != other.createdAt) return false
if (emojis != other.emojis) return false
if (reblogsCount != other.reblogsCount) return false
if (favouritesCount != other.favouritesCount) return false
if (reblogged != other.reblogged) return false
if (favourited != other.favourited) return false
if (bookmarked != other.bookmarked) return false
if (sensitive != other.sensitive) return false
if (spoilerText != other.spoilerText) return false
if (visibility != other.visibility) return false
if (attachments != other.attachments) return false
if (mentions != other.mentions) return false
if (tags != other.tags) return false
if (application != other.application) return false
if (pinned != other.pinned) return false
if (muted != other.muted) return false
if (poll != other.poll) return false
if (card != other.card) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + (url?.hashCode() ?: 0)
result = 31 * result + account.hashCode()
result = 31 * result + (inReplyToId?.hashCode() ?: 0)
result = 31 * result + (inReplyToAccountId?.hashCode() ?: 0)
result = 31 * result + (reblog?.hashCode() ?: 0)
result = 31 * result + content.toString().hashCode()
result = 31 * result + createdAt.hashCode()
result = 31 * result + emojis.hashCode()
result = 31 * result + reblogsCount
result = 31 * result + favouritesCount
result = 31 * result + reblogged.hashCode()
result = 31 * result + favourited.hashCode()
result = 31 * result + bookmarked.hashCode()
result = 31 * result + sensitive.hashCode()
result = 31 * result + spoilerText.hashCode()
result = 31 * result + visibility.hashCode()
result = 31 * result + attachments.hashCode()
result = 31 * result + mentions.hashCode()
result = 31 * result + (tags?.hashCode() ?: 0)
result = 31 * result + (application?.hashCode() ?: 0)
result = 31 * result + (pinned?.hashCode() ?: 0)
result = 31 * result + (muted?.hashCode() ?: 0)
result = 31 * result + (poll?.hashCode() ?: 0)
result = 31 * result + (card?.hashCode() ?: 0)
return result
}
data class Mention(
val id: String,
val url: String,

View File

@ -0,0 +1,39 @@
/* Copyright 2017 Andrew Dawson
*
* 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.entity
import com.google.gson.annotations.SerializedName
/**
* Same as [Account], but only with the attributes required in timelines.
* Prefer this class over [Account] because it uses way less memory & deserializes faster from json.
*/
data class TimelineAccount(
val id: String,
@SerializedName("username") val localUsername: String,
@SerializedName("acct") val username: String,
@SerializedName("display_name") val displayName: String?, // should never be null per Api definition, but some servers break the contract
val url: String,
val avatar: String,
val bot: Boolean = false,
val emojis: List<Emoji>? = emptyList(), // nullable for backward compatibility
) {
val name: String
get() = if (displayName.isNullOrEmpty()) {
localUsername
} else displayName
}

View File

@ -42,8 +42,8 @@ import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.databinding.FragmentAccountListBinding
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.settings.PrefKeys
@ -255,7 +255,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
followRequestsAdapter.removeItem(position)
}
private fun getFetchCallByListType(fromId: String?): Single<Response<List<Account>>> {
private fun getFetchCallByListType(fromId: String?): Single<Response<List<TimelineAccount>>> {
return when (type) {
Type.FOLLOWS -> {
val accountId = requireId(type, id)
@ -313,7 +313,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
)
}
private fun onFetchAccountsSuccess(accounts: List<Account>, linkHeader: String?) {
private fun onFetchAccountsSuccess(accounts: List<TimelineAccount>, linkHeader: String?) {
adapter.setBottomLoading(false)
val links = HttpHeaderLink.parse(linkHeader)

View File

@ -37,6 +37,7 @@ import com.keylesspalace.tusky.entity.ScheduledStatus
import com.keylesspalace.tusky.entity.SearchResult
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.StatusContext
import com.keylesspalace.tusky.entity.TimelineAccount
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Single
import okhttp3.MultipartBody
@ -178,13 +179,13 @@ interface MastodonApi {
fun statusRebloggedBy(
@Path("id") statusId: String,
@Query("max_id") maxId: String?
): Single<Response<List<Account>>>
): Single<Response<List<TimelineAccount>>>
@GET("api/v1/statuses/{id}/favourited_by")
fun statusFavouritedBy(
@Path("id") statusId: String,
@Query("max_id") maxId: String?
): Single<Response<List<Account>>>
): Single<Response<List<TimelineAccount>>>
@DELETE("api/v1/statuses/{id}")
fun deleteStatus(
@ -286,7 +287,7 @@ interface MastodonApi {
@Query("resolve") resolve: Boolean? = null,
@Query("limit") limit: Int? = null,
@Query("following") following: Boolean? = null
): Single<List<Account>>
): Single<List<TimelineAccount>>
@GET("api/v1/accounts/{id}")
fun account(
@ -317,13 +318,13 @@ interface MastodonApi {
fun accountFollowers(
@Path("id") accountId: String,
@Query("max_id") maxId: String?
): Single<Response<List<Account>>>
): Single<Response<List<TimelineAccount>>>
@GET("api/v1/accounts/{id}/following")
fun accountFollowing(
@Path("id") accountId: String,
@Query("max_id") maxId: String?
): Single<Response<List<Account>>>
): Single<Response<List<TimelineAccount>>>
@FormUrlEncoded
@POST("api/v1/accounts/{id}/follow")
@ -384,12 +385,12 @@ interface MastodonApi {
@GET("api/v1/blocks")
fun blocks(
@Query("max_id") maxId: String?
): Single<Response<List<Account>>>
): Single<Response<List<TimelineAccount>>>
@GET("api/v1/mutes")
fun mutes(
@Query("max_id") maxId: String?
): Single<Response<List<Account>>>
): Single<Response<List<TimelineAccount>>>
@GET("api/v1/domain_blocks")
fun domainBlocks(
@ -426,7 +427,7 @@ interface MastodonApi {
@GET("api/v1/follow_requests")
fun followRequests(
@Query("max_id") maxId: String?
): Single<Response<List<Account>>>
): Single<Response<List<TimelineAccount>>>
@POST("api/v1/follow_requests/{id}/authorize")
fun authorizeFollowRequest(
@ -481,7 +482,7 @@ interface MastodonApi {
fun getAccountsInList(
@Path("listId") listId: String,
@Query("limit") limit: Int
): Single<List<Account>>
): Single<List<TimelineAccount>>
@FormUrlEncoded
// @DELETE doesn't support fields

View File

@ -19,6 +19,7 @@ import androidx.annotation.Nullable;
import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.TimelineAccount;
import java.util.Objects;
@ -44,11 +45,11 @@ public abstract class NotificationViewData {
public static final class Concrete extends NotificationViewData {
private final Notification.Type type;
private final String id;
private final Account account;
private final TimelineAccount account;
@Nullable
private final StatusViewData.Concrete statusViewData;
public Concrete(Notification.Type type, String id, Account account,
public Concrete(Notification.Type type, String id, TimelineAccount account,
@Nullable StatusViewData.Concrete statusViewData) {
this.type = type;
this.id = id;
@ -64,7 +65,7 @@ public abstract class NotificationViewData {
return id;
}
public Account getAccount() {
public TimelineAccount getAccount() {
return account;
}

View File

@ -22,12 +22,11 @@ import com.keylesspalace.tusky.entity.Status
/**
* Created by charlag on 11/07/2017.
*
*
* Class to represent data required to display either a notification or a placeholder.
* It is either a [StatusViewData.Concrete] or a [StatusViewData.Placeholder].
*/
sealed class StatusViewData private constructor() {
abstract val viewDataId: Long
sealed class StatusViewData {
abstract val id: String
data class Concrete(
val status: Status,
@ -49,8 +48,8 @@ sealed class StatusViewData private constructor() {
/** Whether the status meets the requirement to be collapse */
val isCollapsed: Boolean,
) : StatusViewData() {
override val viewDataId: Long
get() = status.id.hashCode().toLong()
override val id: String
get() = status.id
val content: Spanned
val spoilerText: String
@ -116,9 +115,6 @@ sealed class StatusViewData private constructor() {
}
}
val id: String
get() = status.id
/** Helper for Java */
fun copyWithStatus(status: Status): Concrete {
return copy(status = status)
@ -140,10 +136,10 @@ sealed class StatusViewData private constructor() {
}
}
data class Placeholder(val id: String, val isLoading: Boolean) : StatusViewData() {
override val viewDataId: Long
get() = id.hashCode().toLong()
}
data class Placeholder(
override val id: String,
val isLoading: Boolean
) : StatusViewData()
fun asStatusOrNull() = this as? Concrete

View File

@ -17,7 +17,7 @@
package com.keylesspalace.tusky.viewmodel
import android.util.Log
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.Either.Left
@ -28,7 +28,7 @@ import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.subjects.BehaviorSubject
import javax.inject.Inject
data class State(val accounts: Either<Throwable, List<Account>>, val searchResult: List<Account>?)
data class State(val accounts: Either<Throwable, List<TimelineAccount>>, val searchResult: List<TimelineAccount>?)
class AccountsInListViewModel @Inject constructor(private val api: MastodonApi) : RxAwareViewModel() {
@ -49,7 +49,7 @@ class AccountsInListViewModel @Inject constructor(private val api: MastodonApi)
}
}
fun addAccountToList(listId: String, account: Account) {
fun addAccountToList(listId: String, account: TimelineAccount) {
api.addCountToList(listId, listOf(account.id))
.subscribe(
{

View File

@ -19,9 +19,9 @@ import android.text.SpannedString
import android.widget.LinearLayout
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.SearchResult
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.network.MastodonApi
import com.nhaarman.mockitokotlin2.doReturn
import com.nhaarman.mockitokotlin2.mock
@ -57,19 +57,13 @@ class BottomSheetActivityTest {
private val emptyCallback = Single.just(SearchResult(emptyList(), emptyList(), emptyList()))
private val testScheduler = TestScheduler()
private val account = Account(
private val account = TimelineAccount(
id = "1",
localUsername = "admin",
username = "admin",
displayName = "Ad Min",
note = SpannedString(""),
url = "http://mastodon.foo.bar",
avatar = "",
header = "",
locked = false,
followersCount = 0,
followingCount = 0,
statusesCount = 0
avatar = ""
)
private val accountSingle = Single.just(SearchResult(listOf(account), emptyList(), emptyList()))

View File

@ -0,0 +1,216 @@
package com.keylesspalace.tusky
import android.text.Spanned
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.gson.GsonBuilder
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.json.SpannedTypeAdapter
import com.keylesspalace.tusky.viewdata.StatusViewData
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@Config(sdk = [28])
@RunWith(AndroidJUnit4::class)
class StatusComparisonTest {
@Test
fun `two equal statuses - should be equal`() {
assertEquals(createStatus(), createStatus())
}
@Test
fun `status with different id - should not be equal`() {
assertNotEquals(createStatus(), createStatus(id = "987654321"))
}
@Test
fun `status with different content - should not be equal`() {
val content: String = """
\u003cp\u003e\u003cspan class=\"h-card\"\u003e\u003ca href=\"https://mastodon.social/@ConnyDuck\" class=\"u-url mention\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"\u003e@\u003cspan\u003eConnyDuck@mastodon.social\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e 123\u003c/p\u003e
""".trimIndent()
assertNotEquals(createStatus(), createStatus(content = content))
}
@Test
fun `accounts with different notes in json - should be equal because notes are not relevant for timelines`() {
assertEquals(createStatus(note = "Test"), createStatus(note = "Test 123456"))
}
private val gson = GsonBuilder().registerTypeAdapter(
Spanned::class.java, SpannedTypeAdapter()
).create()
@Test
fun `two equal status view data - should be equal`() {
val viewdata1 = StatusViewData.Concrete(
status = createStatus(),
isExpanded = false,
isShowingContent = false,
isCollapsible = false,
isCollapsed = false
)
val viewdata2 = StatusViewData.Concrete(
status = createStatus(),
isExpanded = false,
isShowingContent = false,
isCollapsible = false,
isCollapsed = false
)
assertEquals(viewdata1, viewdata2)
}
@Test
fun `status view data with different isExpanded - should not be equal`() {
val viewdata1 = StatusViewData.Concrete(
status = createStatus(),
isExpanded = true,
isShowingContent = false,
isCollapsible = false,
isCollapsed = false
)
val viewdata2 = StatusViewData.Concrete(
status = createStatus(),
isExpanded = false,
isShowingContent = false,
isCollapsible = false,
isCollapsed = false
)
assertNotEquals(viewdata1, viewdata2)
}
@Test
fun `status view data with different statuses- should not be equal`() {
val viewdata1 = StatusViewData.Concrete(
status = createStatus(content = "whatever"),
isExpanded = true,
isShowingContent = false,
isCollapsible = false,
isCollapsed = false
)
val viewdata2 = StatusViewData.Concrete(
status = createStatus(),
isExpanded = false,
isShowingContent = false,
isCollapsible = false,
isCollapsed = false
)
assertNotEquals(viewdata1, viewdata2)
}
private fun createStatus(
id: String = "123456",
content: String = """
\u003cp\u003e\u003cspan class=\"h-card\"\u003e\u003ca href=\"https://mastodon.social/@ConnyDuck\" class=\"u-url mention\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"\u003e@\u003cspan\u003eConnyDuck@mastodon.social\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e Hi\u003c/p\u003e
""".trimIndent(),
note: String = ""
): Status {
val statusJson = """
{
"id": "$id",
"created_at": "2022-02-26T09:54:45.000Z",
"in_reply_to_id": null,
"in_reply_to_account_id": null,
"sensitive": false,
"spoiler_text": "",
"visibility": "public",
"language": null,
"uri": "https://pixelfed.social/p/connyduck/403124983655733325",
"url": "https://pixelfed.social/p/connyduck/403124983655733325",
"replies_count": 3,
"reblogs_count": 28,
"favourites_count": 6,
"edited_at": null,
"favourited": true,
"reblogged": false,
"muted": false,
"bookmarked": false,
"content": "$content",
"reblog": null,
"account": {
"id": "419352",
"username": "connyduck",
"acct": "connyduck@pixelfed.social",
"display_name": "Conny Duck",
"locked": false,
"bot": false,
"discoverable": false,
"group": false,
"created_at": "2018-08-14T00:00:00.000Z",
"note": "$note",
"url": "https://pixelfed.social/connyduck",
"avatar": "https://files.mastodon.social/cache/accounts/avatars/000/419/352/original/31ce660c53962e0c.jpeg",
"avatar_static": "https://files.mastodon.social/cache/accounts/avatars/000/419/352/original/31ce660c53962e0c.jpeg",
"header": "https://mastodon.social/headers/original/missing.png",
"header_static": "https://mastodon.social/headers/original/missing.png",
"followers_count": 2,
"following_count": 0,
"statuses_count": 70,
"last_status_at": "2022-03-07",
"emojis": [],
"fields": []
},
"media_attachments": [
{
"id": "107863694400783337",
"type": "image",
"url": "https://files.mastodon.social/cache/media_attachments/files/107/863/694/400/783/337/original/71c5bad1756bbc8f.jpg",
"preview_url": "https://files.mastodon.social/cache/media_attachments/files/107/863/694/400/783/337/small/71c5bad1756bbc8f.jpg",
"remote_url": "https://pixelfed-prod.nyc3.cdn.digitaloceanspaces.com/public/m/_v2/1138/affc38a2b-1c5f41/JRKoMNoj6dKa/9mXs0Fetvj4KwRbKypt8C1PZNVd7d3dQqod4roLZ.jpg",
"preview_remote_url": null,
"text_url": null,
"meta": {
"original": {
"width": 1371,
"height": 1080,
"size": "1371x1080",
"aspect": 1.2694444444444444
},
"small": {
"width": 451,
"height": 355,
"size": "451x355",
"aspect": 1.2704225352112677
}
},
"description": "Oilpainting of a kingfisher, photographed on my easel",
"blurhash": "UUG91|?wxHV@WTkDs.V?xZa_I:WBNFR*WBRk"
},
{
"id": "107863694727565058",
"type": "image",
"url": "https://files.mastodon.social/cache/media_attachments/files/107/863/694/727/565/058/original/68daef05be7ac6b6.jpg",
"preview_url": "https://files.mastodon.social/cache/media_attachments/files/107/863/694/727/565/058/small/68daef05be7ac6b6.jpg",
"remote_url": "https://pixelfed-prod.nyc3.cdn.digitaloceanspaces.com/public/m/_v2/1138/affc38a2b-1c5f41/nBVJUnrEIjfO/M6i8GSP44Iv230KWXnMpvVobOqASXY3EkImyxySS.jpg",
"preview_remote_url": null,
"text_url": null,
"meta": {
"original": {
"width": 1087,
"height": 1080,
"size": "1087x1080",
"aspect": 1.0064814814814815
},
"small": {
"width": 401,
"height": 398,
"size": "401x398",
"aspect": 1.0075376884422111
}
},
"description": "Oilpainting of a kingfisher",
"blurhash": "U89u4pPJ4:SoJ6NNnkoxoBtSx0Von-RiNgt8"
}
],
"mentions": [],
"tags": [],
"emojis": [],
"card": null,
"poll": null
}
""".trimIndent()
return gson.fromJson(statusJson, Status::class.java)
}
}

View File

@ -3,8 +3,8 @@ package com.keylesspalace.tusky.components.timeline
import android.text.SpannedString
import com.google.gson.Gson
import com.keylesspalace.tusky.db.TimelineStatusWithAccount
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.viewdata.StatusViewData
import java.util.ArrayList
import java.util.Date
@ -14,15 +14,13 @@ private val fixedDate = Date(1638889052000)
fun mockStatus(id: String = "100") = Status(
id = id,
url = "https://mastodon.example/@ConnyDuck/$id",
account = Account(
account = TimelineAccount(
id = "1",
localUsername = "connyduck",
username = "connyduck@mastodon.example",
displayName = "Conny Duck",
note = SpannedString(""),
url = "https://mastodon.example/@ConnyDuck",
avatar = "https://mastodon.example/system/accounts/avatars/000/150/486/original/ab27d7ddd18a10ea.jpg",
header = "https://mastodon.example/system/accounts/header/000/106/476/original/e590545d7eb4da39.jpg"
avatar = "https://mastodon.example/system/accounts/avatars/000/150/486/original/ab27d7ddd18a10ea.jpg"
),
inReplyToId = null,
inReplyToAccountId = null,