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

View file

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

View file

@ -9,7 +9,7 @@ import androidx.preference.PreferenceManager;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import com.keylesspalace.tusky.R; 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.AccountActionListener;
import com.keylesspalace.tusky.interfaces.LinkListener; import com.keylesspalace.tusky.interfaces.LinkListener;
import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.CustomEmojiHelper;
@ -33,7 +33,7 @@ public class AccountViewHolder extends RecyclerView.ViewHolder {
showBotOverlay = sharedPrefs.getBoolean("showBotOverlay", true); 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(); accountId = account.getId();
String format = username.getContext().getString(R.string.status_username_format); String format = username.getContext().getString(R.string.status_username_format);
String formattedUsername = String.format(format, account.getUsername()); String formattedUsername = String.format(format, account.getUsername());

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -114,7 +114,7 @@ class TimelinePagingAdapter(
oldItem: StatusViewData, oldItem: StatusViewData,
newItem: StatusViewData newItem: StatusViewData
): Boolean { ): Boolean {
return oldItem.viewDataId == newItem.viewDataId return oldItem.id == newItem.id
} }
override fun areContentsTheSame( override fun areContentsTheSame(
@ -128,7 +128,7 @@ class TimelinePagingAdapter(
oldItem: StatusViewData, oldItem: StatusViewData,
newItem: StatusViewData newItem: StatusViewData
): Any? { ): Any? {
return if (oldItem === newItem) { return if (oldItem == newItem) {
// If items are equal - update timestamp only // If items are equal - update timestamp only
listOf(StatusBaseViewHolder.Key.KEY_CREATED) listOf(StatusBaseViewHolder.Key.KEY_CREATED)
} else // If items are different - update the whole view holder } 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.TimelineAccountEntity
import com.keylesspalace.tusky.db.TimelineStatusEntity import com.keylesspalace.tusky.db.TimelineStatusEntity
import com.keylesspalace.tusky.db.TimelineStatusWithAccount import com.keylesspalace.tusky.db.TimelineStatusWithAccount
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.util.shouldTrimStatus import com.keylesspalace.tusky.util.shouldTrimStatus
import com.keylesspalace.tusky.util.trimTrailingWhitespace import com.keylesspalace.tusky.util.trimTrailingWhitespace
import com.keylesspalace.tusky.viewdata.StatusViewData 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 mentionListType = object : TypeToken<List<Status.Mention>>() {}.type
private val tagListType = object : TypeToken<List<HashTag>>() {}.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( return TimelineAccountEntity(
serverId = id, serverId = id,
timelineUserId = accountId, timelineUserId = accountId,
@ -58,25 +58,16 @@ fun Account.toEntity(accountId: Long, gson: Gson): TimelineAccountEntity {
) )
} }
fun TimelineAccountEntity.toAccount(gson: Gson): Account { fun TimelineAccountEntity.toAccount(gson: Gson): TimelineAccount {
return Account( return TimelineAccount(
id = serverId, id = serverId,
localUsername = localUsername, localUsername = localUsername,
username = username, username = username,
displayName = displayName, displayName = displayName,
note = SpannedString(""),
url = url, url = url,
avatar = avatar, avatar = avatar,
header = "",
locked = false,
followingCount = 0,
followersCount = 0,
statusesCount = 0,
source = null,
bot = bot, bot = bot,
emojis = gson.fromJson(emojis, emojisListType), emojis = gson.fromJson(emojis, emojisListType)
fields = null,
moved = null
) )
} }

View file

@ -45,37 +45,57 @@ data class Account(
localUsername localUsername
} else displayName } 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 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( data class AccountSource(

View file

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

View file

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

View file

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

View file

@ -25,7 +25,7 @@ import java.util.Date
data class Status( data class Status(
val id: String, val id: String,
val url: String?, // not present if it's reblog 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_id") var inReplyToId: String?,
@SerializedName("in_reply_to_account_id") val inReplyToAccountId: String?, @SerializedName("in_reply_to_account_id") val inReplyToAccountId: String?,
val reblog: Status?, val reblog: Status?,
@ -149,6 +149,71 @@ data class Status(
return builder.toString() 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( data class Mention(
val id: String, val id: String,
val url: 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.databinding.FragmentAccountListBinding
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys
@ -255,7 +255,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
followRequestsAdapter.removeItem(position) followRequestsAdapter.removeItem(position)
} }
private fun getFetchCallByListType(fromId: String?): Single<Response<List<Account>>> { private fun getFetchCallByListType(fromId: String?): Single<Response<List<TimelineAccount>>> {
return when (type) { return when (type) {
Type.FOLLOWS -> { Type.FOLLOWS -> {
val accountId = requireId(type, id) 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) adapter.setBottomLoading(false)
val links = HttpHeaderLink.parse(linkHeader) 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.SearchResult
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.StatusContext import com.keylesspalace.tusky.entity.StatusContext
import com.keylesspalace.tusky.entity.TimelineAccount
import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.core.Single
import okhttp3.MultipartBody import okhttp3.MultipartBody
@ -178,13 +179,13 @@ interface MastodonApi {
fun statusRebloggedBy( fun statusRebloggedBy(
@Path("id") statusId: String, @Path("id") statusId: String,
@Query("max_id") maxId: String? @Query("max_id") maxId: String?
): Single<Response<List<Account>>> ): Single<Response<List<TimelineAccount>>>
@GET("api/v1/statuses/{id}/favourited_by") @GET("api/v1/statuses/{id}/favourited_by")
fun statusFavouritedBy( fun statusFavouritedBy(
@Path("id") statusId: String, @Path("id") statusId: String,
@Query("max_id") maxId: String? @Query("max_id") maxId: String?
): Single<Response<List<Account>>> ): Single<Response<List<TimelineAccount>>>
@DELETE("api/v1/statuses/{id}") @DELETE("api/v1/statuses/{id}")
fun deleteStatus( fun deleteStatus(
@ -286,7 +287,7 @@ interface MastodonApi {
@Query("resolve") resolve: Boolean? = null, @Query("resolve") resolve: Boolean? = null,
@Query("limit") limit: Int? = null, @Query("limit") limit: Int? = null,
@Query("following") following: Boolean? = null @Query("following") following: Boolean? = null
): Single<List<Account>> ): Single<List<TimelineAccount>>
@GET("api/v1/accounts/{id}") @GET("api/v1/accounts/{id}")
fun account( fun account(
@ -317,13 +318,13 @@ interface MastodonApi {
fun accountFollowers( fun accountFollowers(
@Path("id") accountId: String, @Path("id") accountId: String,
@Query("max_id") maxId: String? @Query("max_id") maxId: String?
): Single<Response<List<Account>>> ): Single<Response<List<TimelineAccount>>>
@GET("api/v1/accounts/{id}/following") @GET("api/v1/accounts/{id}/following")
fun accountFollowing( fun accountFollowing(
@Path("id") accountId: String, @Path("id") accountId: String,
@Query("max_id") maxId: String? @Query("max_id") maxId: String?
): Single<Response<List<Account>>> ): Single<Response<List<TimelineAccount>>>
@FormUrlEncoded @FormUrlEncoded
@POST("api/v1/accounts/{id}/follow") @POST("api/v1/accounts/{id}/follow")
@ -384,12 +385,12 @@ interface MastodonApi {
@GET("api/v1/blocks") @GET("api/v1/blocks")
fun blocks( fun blocks(
@Query("max_id") maxId: String? @Query("max_id") maxId: String?
): Single<Response<List<Account>>> ): Single<Response<List<TimelineAccount>>>
@GET("api/v1/mutes") @GET("api/v1/mutes")
fun mutes( fun mutes(
@Query("max_id") maxId: String? @Query("max_id") maxId: String?
): Single<Response<List<Account>>> ): Single<Response<List<TimelineAccount>>>
@GET("api/v1/domain_blocks") @GET("api/v1/domain_blocks")
fun domainBlocks( fun domainBlocks(
@ -426,7 +427,7 @@ interface MastodonApi {
@GET("api/v1/follow_requests") @GET("api/v1/follow_requests")
fun followRequests( fun followRequests(
@Query("max_id") maxId: String? @Query("max_id") maxId: String?
): Single<Response<List<Account>>> ): Single<Response<List<TimelineAccount>>>
@POST("api/v1/follow_requests/{id}/authorize") @POST("api/v1/follow_requests/{id}/authorize")
fun authorizeFollowRequest( fun authorizeFollowRequest(
@ -481,7 +482,7 @@ interface MastodonApi {
fun getAccountsInList( fun getAccountsInList(
@Path("listId") listId: String, @Path("listId") listId: String,
@Query("limit") limit: Int @Query("limit") limit: Int
): Single<List<Account>> ): Single<List<TimelineAccount>>
@FormUrlEncoded @FormUrlEncoded
// @DELETE doesn't support fields // @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.Account;
import com.keylesspalace.tusky.entity.Notification; import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.TimelineAccount;
import java.util.Objects; import java.util.Objects;
@ -44,11 +45,11 @@ public abstract class NotificationViewData {
public static final class Concrete extends NotificationViewData { public static final class Concrete extends NotificationViewData {
private final Notification.Type type; private final Notification.Type type;
private final String id; private final String id;
private final Account account; private final TimelineAccount account;
@Nullable @Nullable
private final StatusViewData.Concrete statusViewData; 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) { @Nullable StatusViewData.Concrete statusViewData) {
this.type = type; this.type = type;
this.id = id; this.id = id;
@ -64,7 +65,7 @@ public abstract class NotificationViewData {
return id; return id;
} }
public Account getAccount() { public TimelineAccount getAccount() {
return account; return account;
} }

View file

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

View file

@ -17,7 +17,7 @@
package com.keylesspalace.tusky.viewmodel package com.keylesspalace.tusky.viewmodel
import android.util.Log 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.network.MastodonApi
import com.keylesspalace.tusky.util.Either import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.Either.Left import com.keylesspalace.tusky.util.Either.Left
@ -28,7 +28,7 @@ import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.subjects.BehaviorSubject import io.reactivex.rxjava3.subjects.BehaviorSubject
import javax.inject.Inject 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() { 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)) api.addCountToList(listId, listOf(account.id))
.subscribe( .subscribe(
{ {

View file

@ -19,9 +19,9 @@ import android.text.SpannedString
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.SearchResult import com.keylesspalace.tusky.entity.SearchResult
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.nhaarman.mockitokotlin2.doReturn import com.nhaarman.mockitokotlin2.doReturn
import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.mock
@ -57,19 +57,13 @@ class BottomSheetActivityTest {
private val emptyCallback = Single.just(SearchResult(emptyList(), emptyList(), emptyList())) private val emptyCallback = Single.just(SearchResult(emptyList(), emptyList(), emptyList()))
private val testScheduler = TestScheduler() private val testScheduler = TestScheduler()
private val account = Account( private val account = TimelineAccount(
id = "1", id = "1",
localUsername = "admin", localUsername = "admin",
username = "admin", username = "admin",
displayName = "Ad Min", displayName = "Ad Min",
note = SpannedString(""),
url = "http://mastodon.foo.bar", url = "http://mastodon.foo.bar",
avatar = "", avatar = ""
header = "",
locked = false,
followersCount = 0,
followingCount = 0,
statusesCount = 0
) )
private val accountSingle = Single.just(SearchResult(listOf(account), emptyList(), emptyList())) 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 android.text.SpannedString
import com.google.gson.Gson import com.google.gson.Gson
import com.keylesspalace.tusky.db.TimelineStatusWithAccount import com.keylesspalace.tusky.db.TimelineStatusWithAccount
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
import java.util.ArrayList import java.util.ArrayList
import java.util.Date import java.util.Date
@ -14,15 +14,13 @@ private val fixedDate = Date(1638889052000)
fun mockStatus(id: String = "100") = Status( fun mockStatus(id: String = "100") = Status(
id = id, id = id,
url = "https://mastodon.example/@ConnyDuck/$id", url = "https://mastodon.example/@ConnyDuck/$id",
account = Account( account = TimelineAccount(
id = "1", id = "1",
localUsername = "connyduck", localUsername = "connyduck",
username = "connyduck@mastodon.example", username = "connyduck@mastodon.example",
displayName = "Conny Duck", displayName = "Conny Duck",
note = SpannedString(""),
url = "https://mastodon.example/@ConnyDuck", url = "https://mastodon.example/@ConnyDuck",
avatar = "https://mastodon.example/system/accounts/avatars/000/150/486/original/ab27d7ddd18a10ea.jpg", 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"
), ),
inReplyToId = null, inReplyToId = null,
inReplyToAccountId = null, inReplyToAccountId = null,