mirror of https://github.com/tuskyapp/Tusky.git
Browse Source
* Move Timeline files into their own package * Introduce TimelineViewModel, add coroutines * Simplify StatusViewData * Handle timeilne fetch errors * Rework filters, fix ViewThreadFragment * Fix NotificationsFragment * Simplify Notifications and Thread, handle pin * Redo loading in TimelineViewModel * Improve error handling in TimelineViewModel * Rewrite actions in TimelineViewModel * Apply feedback after timeline factoring review * Handle initial failure in timeline correctlypull/2197/head
58 changed files with 3959 additions and 3621 deletions
@ -0,0 +1,563 @@
|
||||
/* Copyright 2021 Tusky Contributors |
||||
* |
||||
* 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.components.timeline |
||||
|
||||
import android.os.Bundle |
||||
import android.util.Log |
||||
import android.view.LayoutInflater |
||||
import android.view.View |
||||
import android.view.ViewGroup |
||||
import android.view.accessibility.AccessibilityManager |
||||
import androidx.core.content.ContextCompat |
||||
import androidx.fragment.app.viewModels |
||||
import androidx.lifecycle.Lifecycle |
||||
import androidx.preference.PreferenceManager |
||||
import androidx.recyclerview.widget.* |
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener |
||||
import at.connyduck.sparkbutton.helpers.Utils |
||||
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.* |
||||
import autodispose2.androidx.lifecycle.autoDispose |
||||
import com.keylesspalace.tusky.AccountListActivity |
||||
import com.keylesspalace.tusky.AccountListActivity.Companion.newIntent |
||||
import com.keylesspalace.tusky.BaseActivity |
||||
import com.keylesspalace.tusky.R |
||||
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder |
||||
import com.keylesspalace.tusky.appstore.EventHub |
||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent |
||||
import com.keylesspalace.tusky.databinding.FragmentTimelineBinding |
||||
import com.keylesspalace.tusky.db.AccountManager |
||||
import com.keylesspalace.tusky.di.Injectable |
||||
import com.keylesspalace.tusky.di.ViewModelFactory |
||||
import com.keylesspalace.tusky.fragment.SFragment |
||||
import com.keylesspalace.tusky.interfaces.ActionButtonActivity |
||||
import com.keylesspalace.tusky.interfaces.RefreshableFragment |
||||
import com.keylesspalace.tusky.interfaces.ReselectableFragment |
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener |
||||
import com.keylesspalace.tusky.settings.PrefKeys |
||||
import com.keylesspalace.tusky.util.* |
||||
import com.keylesspalace.tusky.view.EndlessOnScrollListener |
||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData |
||||
import com.keylesspalace.tusky.viewdata.StatusViewData |
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers |
||||
import io.reactivex.rxjava3.core.Observable |
||||
import java.util.concurrent.TimeUnit |
||||
import javax.inject.Inject |
||||
|
||||
class TimelineFragment : SFragment(), OnRefreshListener, StatusActionListener, Injectable, |
||||
ReselectableFragment, RefreshableFragment { |
||||
|
||||
@Inject |
||||
lateinit var viewModelFactory: ViewModelFactory |
||||
|
||||
@Inject |
||||
lateinit var eventHub: EventHub |
||||
|
||||
@Inject |
||||
lateinit var accountManager: AccountManager |
||||
|
||||
private val viewModel: TimelineViewModel by viewModels { viewModelFactory } |
||||
|
||||
private val binding by viewBinding(FragmentTimelineBinding::bind) |
||||
|
||||
private lateinit var adapter: TimelineAdapter |
||||
|
||||
private var isSwipeToRefreshEnabled = true |
||||
|
||||
private var eventRegistered = false |
||||
|
||||
private var layoutManager: LinearLayoutManager? = null |
||||
private var scrollListener: EndlessOnScrollListener? = null |
||||
private var hideFab = false |
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) { |
||||
super.onCreate(savedInstanceState) |
||||
|
||||
val arguments = requireArguments() |
||||
val kind = TimelineViewModel.Kind.valueOf(arguments.getString(KIND_ARG)!!) |
||||
val id: String? = if (kind == TimelineViewModel.Kind.USER || |
||||
kind == TimelineViewModel.Kind.USER_PINNED || |
||||
kind == TimelineViewModel.Kind.USER_WITH_REPLIES || |
||||
kind == TimelineViewModel.Kind.LIST |
||||
) { |
||||
arguments.getString(ID_ARG)!! |
||||
} else { |
||||
null |
||||
} |
||||
|
||||
val tags = if (kind == TimelineViewModel.Kind.TAG) { |
||||
arguments.getStringArrayList(HASHTAGS_ARG)!! |
||||
} else { |
||||
listOf() |
||||
} |
||||
viewModel.init( |
||||
kind, |
||||
id, |
||||
tags, |
||||
) |
||||
|
||||
viewModel.viewUpdates |
||||
.observeOn(AndroidSchedulers.mainThread()) |
||||
.autoDispose(this) |
||||
.subscribe { this.updateViews() } |
||||
|
||||
isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) |
||||
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(activity) |
||||
val statusDisplayOptions = StatusDisplayOptions( |
||||
animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), |
||||
mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled, |
||||
useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false), |
||||
showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true), |
||||
useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), |
||||
cardViewMode = if (preferences.getBoolean( |
||||
PrefKeys.SHOW_CARDS_IN_TIMELINES, |
||||
false |
||||
) |
||||
) CardViewMode.INDENTED else CardViewMode.NONE, |
||||
confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), |
||||
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), |
||||
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) |
||||
) |
||||
adapter = TimelineAdapter( |
||||
dataSource, |
||||
statusDisplayOptions, |
||||
this |
||||
) |
||||
} |
||||
|
||||
override fun onCreateView( |
||||
inflater: LayoutInflater, |
||||
container: ViewGroup?, |
||||
savedInstanceState: Bundle? |
||||
): View? { |
||||
return inflater.inflate(R.layout.fragment_timeline, container, false) |
||||
} |
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { |
||||
setupSwipeRefreshLayout() |
||||
setupRecyclerView() |
||||
updateViews() |
||||
viewModel.loadInitial() |
||||
} |
||||
|
||||
private fun setupSwipeRefreshLayout() { |
||||
binding.swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled |
||||
binding.swipeRefreshLayout.setOnRefreshListener(this) |
||||
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) |
||||
} |
||||
|
||||
private fun setupRecyclerView() { |
||||
binding.recyclerView.setAccessibilityDelegateCompat( |
||||
ListStatusAccessibilityDelegate(binding.recyclerView, this) |
||||
{ pos -> viewModel.statuses.getOrNull(pos) } |
||||
) |
||||
binding.recyclerView.setHasFixedSize(true) |
||||
layoutManager = LinearLayoutManager(context) |
||||
binding.recyclerView.layoutManager = layoutManager |
||||
val divider = DividerItemDecoration(context, RecyclerView.VERTICAL) |
||||
binding.recyclerView.addItemDecoration(divider) |
||||
|
||||
// CWs are expanded without animation, buttons animate itself, we don't need it basically |
||||
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false |
||||
binding.recyclerView.adapter = adapter |
||||
} |
||||
|
||||
private fun showEmptyView() { |
||||
binding.statusView.show() |
||||
binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null) |
||||
} |
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) { |
||||
super.onActivityCreated(savedInstanceState) |
||||
|
||||
/* This is delayed until onActivityCreated solely because MainActivity.composeButton isn't |
||||
* guaranteed to be set until then. */ |
||||
scrollListener = if (actionButtonPresent()) { |
||||
/* Use a modified scroll listener that both loads more statuses as it goes, and hides |
||||
* the follow button on down-scroll. */ |
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(context) |
||||
hideFab = preferences.getBoolean("fabHide", false) |
||||
object : EndlessOnScrollListener(layoutManager) { |
||||
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { |
||||
super.onScrolled(view, dx, dy) |
||||
val composeButton = (activity as ActionButtonActivity).actionButton |
||||
if (composeButton != null) { |
||||
if (hideFab) { |
||||
if (dy > 0 && composeButton.isShown) { |
||||
composeButton.hide() // hides the button if we're scrolling down |
||||
} else if (dy < 0 && !composeButton.isShown) { |
||||
composeButton.show() // shows it if we are scrolling up |
||||
} |
||||
} else if (!composeButton.isShown) { |
||||
composeButton.show() |
||||
} |
||||
} |
||||
} |
||||
|
||||
override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) { |
||||
this@TimelineFragment.onLoadMore() |
||||
} |
||||
} |
||||
} else { |
||||
// Just use the basic scroll listener to load more statuses. |
||||
object : EndlessOnScrollListener(layoutManager) { |
||||
override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) { |
||||
this@TimelineFragment.onLoadMore() |
||||
} |
||||
} |
||||
}.also { |
||||
binding.recyclerView.addOnScrollListener(it) |
||||
} |
||||
|
||||
if (!eventRegistered) { |
||||
eventHub.events |
||||
.observeOn(AndroidSchedulers.mainThread()) |
||||
.autoDispose(this, Lifecycle.Event.ON_DESTROY) |
||||
.subscribe { event -> |
||||
when (event) { |
||||
is PreferenceChangedEvent -> { |
||||
onPreferenceChanged(event.preferenceKey) |
||||
} |
||||
} |
||||
} |
||||
eventRegistered = true |
||||
} |
||||
} |
||||
|
||||
override fun onRefresh() { |
||||
binding.swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled |
||||
binding.statusView.hide() |
||||
|
||||
viewModel.refresh() |
||||
} |
||||
|
||||
override fun onReply(position: Int) { |
||||
val status = viewModel.statuses[position].asStatusOrNull() ?: return |
||||
super.reply(status.status) |
||||
} |
||||
|
||||
override fun onReblog(reblog: Boolean, position: Int) { |
||||
viewModel.reblog(reblog, position) |
||||
} |
||||
|
||||
override fun onFavourite(favourite: Boolean, position: Int) { |
||||
viewModel.favorite(favourite, position) |
||||
} |
||||
|
||||
override fun onBookmark(bookmark: Boolean, position: Int) { |
||||
viewModel.bookmark(bookmark, position) |
||||
} |
||||
|
||||
override fun onVoteInPoll(position: Int, choices: List<Int>) { |
||||
viewModel.voteInPoll(position, choices) |
||||
} |
||||
|
||||
override fun onMore(view: View, position: Int) { |
||||
val status = viewModel.statuses[position].asStatusOrNull()?.status ?: return |
||||
super.more(status, view, position) |
||||
} |
||||
|
||||
override fun onOpenReblog(position: Int) { |
||||
val status = viewModel.statuses[position].asStatusOrNull()?.status ?: return |
||||
super.openReblog(status) |
||||
} |
||||
|
||||
override fun onExpandedChange(expanded: Boolean, position: Int) { |
||||
viewModel.changeExpanded(expanded, position) |
||||
updateViews() |
||||
} |
||||
|
||||
override fun onContentHiddenChange(isShowing: Boolean, position: Int) { |
||||
viewModel.changeContentHidden(isShowing, position) |
||||
updateViews() |
||||
} |
||||
|
||||
override fun onShowReblogs(position: Int) { |
||||
val statusId = viewModel.statuses[position].asStatusOrNull()?.id ?: return |
||||
val intent = newIntent(requireContext(), AccountListActivity.Type.REBLOGGED, statusId) |
||||
(activity as BaseActivity).startActivityWithSlideInAnimation(intent) |
||||
} |
||||
|
||||
override fun onShowFavs(position: Int) { |
||||
val statusId = viewModel.statuses[position].asStatusOrNull()?.id ?: return |
||||
val intent = newIntent(requireContext(), AccountListActivity.Type.FAVOURITED, statusId) |
||||
(activity as BaseActivity).startActivityWithSlideInAnimation(intent) |
||||
} |
||||
|
||||
override fun onLoadMore(position: Int) { |
||||
viewModel.loadGap(position) |
||||
} |
||||
|
||||
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { |
||||
viewModel.changeContentCollapsed(isCollapsed, position) |
||||
} |
||||
|
||||
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { |
||||
val status = viewModel.statuses[position].asStatusOrNull() ?: return |
||||
super.viewMedia( |
||||
attachmentIndex, |
||||
AttachmentViewData.list(status.actionable), |
||||
view |
||||
) |
||||
} |
||||
|
||||
override fun onViewThread(position: Int) { |
||||
val status = viewModel.statuses[position].asStatusOrNull() ?: return |
||||
super.viewThread(status.actionable.id, status.actionable.url) |
||||
} |
||||
|
||||
override fun onViewTag(tag: String) { |
||||
if (viewModel.kind == TimelineViewModel.Kind.TAG && viewModel.tags.size == 1 && |
||||
viewModel.tags.contains(tag) |
||||
) { |
||||
// If already viewing a tag page, then ignore any request to view that tag again. |
||||
return |
||||
} |
||||
super.viewTag(tag) |
||||
} |
||||
|
||||
override fun onViewAccount(id: String) { |
||||
if ((viewModel.kind == TimelineViewModel.Kind.USER || |
||||
viewModel.kind == TimelineViewModel.Kind.USER_WITH_REPLIES) && |
||||
viewModel.id == id |
||||
) { |
||||
/* If already viewing an account page, then any requests to view that account page |
||||
* should be ignored. */ |
||||
return |
||||
} |
||||
super.viewAccount(id) |
||||
} |
||||
|
||||
private fun onPreferenceChanged(key: String) { |
||||
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) |
||||
when (key) { |
||||
PrefKeys.FAB_HIDE -> { |
||||
hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false) |
||||
} |
||||
PrefKeys.MEDIA_PREVIEW_ENABLED -> { |
||||
val enabled = accountManager.activeAccount!!.mediaPreviewEnabled |
||||
val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled |
||||
if (enabled != oldMediaPreviewEnabled) { |
||||
adapter.mediaPreviewEnabled = enabled |
||||
updateViews() |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
public override fun removeItem(position: Int) { |
||||
viewModel.statuses.removeAt(position) |
||||
updateViews() |
||||
} |
||||
|
||||
private fun onLoadMore() { |
||||
viewModel.loadMore() |
||||
} |
||||
|
||||
private fun actionButtonPresent(): Boolean { |
||||
return viewModel.kind != TimelineViewModel.Kind.TAG && |
||||
viewModel.kind != TimelineViewModel.Kind.FAVOURITES && |
||||
viewModel.kind != TimelineViewModel.Kind.BOOKMARKS && |
||||
activity is ActionButtonActivity |
||||
} |
||||
|
||||
private fun updateViews() { |
||||
differ.submitList(viewModel.statuses.toList()) |
||||
binding.swipeRefreshLayout.isEnabled = viewModel.failure == null |
||||
|
||||
if (isAdded) { |
||||
binding.swipeRefreshLayout.isRefreshing = viewModel.isRefreshing |
||||
binding.progressBar.visible(viewModel.isLoadingInitially) |
||||
if (viewModel.failure == null && viewModel.statuses.isEmpty() && !viewModel.isLoadingInitially) { |
||||
showEmptyView() |
||||
} else { |
||||
when (viewModel.failure) { |
||||
TimelineViewModel.FailureReason.NETWORK -> { |
||||
binding.statusView.show() |
||||
binding.statusView.setup( |
||||
R.drawable.elephant_offline, |
||||
R.string.error_network |
||||
) { |
||||
binding.statusView.hide() |
||||
viewModel.loadInitial() |
||||
} |
||||
} |
||||
TimelineViewModel.FailureReason.OTHER -> { |
||||
binding.statusView.show() |
||||
binding.statusView.setup( |
||||
R.drawable.elephant_error, |
||||
R.string.error_generic |
||||
) { |
||||
binding.statusView.hide() |
||||
viewModel.loadInitial() |
||||
} |
||||
} |
||||
null -> binding.statusView.hide() |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
private val listUpdateCallback: ListUpdateCallback = object : ListUpdateCallback { |
||||
override fun onInserted(position: Int, count: Int) { |
||||
if (isAdded) { |
||||
adapter.notifyItemRangeInserted(position, count) |
||||
val context = context |
||||
// scroll up when new items at the top are loaded while being in the first position |
||||
// https://github.com/tuskyapp/Tusky/pull/1905#issuecomment-677819724 |
||||
if (position == 0 && context != null && adapter.itemCount != count) { |
||||
if (isSwipeToRefreshEnabled) { |
||||
binding.recyclerView.scrollBy(0, Utils.dpToPx(context, -30)) |
||||
} else binding.recyclerView.scrollToPosition(0) |
||||
} |
||||
} |
||||
} |
||||
|
||||
override fun onRemoved(position: Int, count: Int) { |
||||
adapter.notifyItemRangeRemoved(position, count) |
||||
} |
||||
|
||||
override fun onMoved(fromPosition: Int, toPosition: Int) { |
||||
adapter.notifyItemMoved(fromPosition, toPosition) |
||||
} |
||||
|
||||
override fun onChanged(position: Int, count: Int, payload: Any?) { |
||||
adapter.notifyItemRangeChanged(position, count, payload) |
||||
} |
||||
} |
||||
private val differ = AsyncListDiffer( |
||||
listUpdateCallback, |
||||
AsyncDifferConfig.Builder(diffCallback).build() |
||||
) |
||||
|
||||
private val dataSource: TimelineAdapter.AdapterDataSource<StatusViewData> = |
||||
object : TimelineAdapter.AdapterDataSource<StatusViewData> { |
||||
override fun getItemCount(): Int { |
||||
return differ.currentList.size |
||||
} |
||||
|
||||
override fun getItemAt(pos: Int): StatusViewData { |
||||
return differ.currentList[pos] |
||||
} |
||||
} |
||||
|
||||
private var talkBackWasEnabled = false |
||||
|
||||
override fun onResume() { |
||||
super.onResume() |
||||
val a11yManager = |
||||
ContextCompat.getSystemService(requireContext(), AccessibilityManager::class.java) |
||||
|
||||
val wasEnabled = talkBackWasEnabled |
||||
talkBackWasEnabled = a11yManager?.isEnabled == true |
||||
Log.d(TAG, "talkback was enabled: $wasEnabled, now $talkBackWasEnabled") |
||||
if (talkBackWasEnabled && !wasEnabled) { |
||||
adapter.notifyDataSetChanged() |
||||
} |
||||
startUpdateTimestamp() |
||||
} |
||||
|
||||
/** |
||||
* Start to update adapter every minute to refresh timestamp |
||||
* If setting absoluteTimeView is false |
||||
* Auto dispose observable on pause |
||||
*/ |
||||
private fun startUpdateTimestamp() { |
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(activity) |
||||
val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false) |
||||
if (!useAbsoluteTime) { |
||||
Observable.interval(1, TimeUnit.MINUTES) |
||||
.observeOn(AndroidSchedulers.mainThread()) |
||||
.autoDispose(this, Lifecycle.Event.ON_PAUSE) |
||||
.subscribe { updateViews() } |
||||
} |
||||
} |
||||
|
||||
override fun onReselect() { |
||||
if (isAdded) { |
||||
layoutManager!!.scrollToPosition(0) |
||||
binding.recyclerView.stopScroll() |
||||
scrollListener!!.reset() |
||||
} |
||||
} |
||||
|
||||
override fun refreshContent() { |
||||
onRefresh() |
||||
} |
||||
|
||||
companion object { |
||||
private const val TAG = "TimelineF" // logging tag |
||||
private const val KIND_ARG = "kind" |
||||
private const val ID_ARG = "id" |
||||
private const val HASHTAGS_ARG = "hashtags" |
||||
private const val ARG_ENABLE_SWIPE_TO_REFRESH = "enableSwipeToRefresh" |
||||
|
||||
|
||||
fun newInstance( |
||||
kind: TimelineViewModel.Kind, |
||||
hashtagOrId: String? = null, |
||||
enableSwipeToRefresh: Boolean = true |
||||
): TimelineFragment { |
||||
val fragment = TimelineFragment() |
||||
val arguments = Bundle(3) |
||||
arguments.putString(KIND_ARG, kind.name) |
||||
arguments.putString(ID_ARG, hashtagOrId) |
||||
arguments.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, enableSwipeToRefresh) |
||||
fragment.arguments = arguments |
||||
return fragment |
||||
} |
||||
|
||||
@JvmStatic |
||||
fun newHashtagInstance(hashtags: List<String>): TimelineFragment { |
||||
val fragment = TimelineFragment() |
||||
val arguments = Bundle(3) |
||||
arguments.putString(KIND_ARG, TimelineViewModel.Kind.TAG.name) |
||||
arguments.putStringArrayList(HASHTAGS_ARG, ArrayList(hashtags)) |
||||
arguments.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) |
||||
fragment.arguments = arguments |
||||
return fragment |
||||
} |
||||
|
||||
|
||||
private val diffCallback: DiffUtil.ItemCallback<StatusViewData> = |
||||
object : DiffUtil.ItemCallback<StatusViewData>() { |
||||
override fun areItemsTheSame( |
||||
oldItem: StatusViewData, |
||||
newItem: StatusViewData |
||||
): Boolean { |
||||
return oldItem.viewDataId == newItem.viewDataId |
||||
} |
||||
|
||||
override fun areContentsTheSame( |
||||
oldItem: StatusViewData, |
||||
newItem: StatusViewData |
||||
): Boolean { |
||||
return false // Items are different always. It allows to refresh timestamp on every view holder update |
||||
} |
||||
|
||||
override fun getChangePayload( |
||||
oldItem: StatusViewData, |
||||
newItem: StatusViewData |
||||
): Any? { |
||||
return if (oldItem === newItem) { |
||||
// If items are equal - update timestamp only |
||||
listOf(StatusBaseViewHolder.Key.KEY_CREATED) |
||||
} else // If items are different - update the whole view holder |
||||
null |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,413 @@
|
||||
package com.keylesspalace.tusky.components.timeline |
||||
|
||||
import android.text.SpannedString |
||||
import androidx.core.text.parseAsHtml |
||||
import androidx.core.text.toHtml |
||||
import com.google.gson.Gson |
||||
import com.google.gson.reflect.TypeToken |
||||
import com.keylesspalace.tusky.db.* |
||||
import com.keylesspalace.tusky.entity.* |
||||
import com.keylesspalace.tusky.network.MastodonApi |
||||
import com.keylesspalace.tusky.components.timeline.TimelineRequestMode.DISK |
||||
import com.keylesspalace.tusky.components.timeline.TimelineRequestMode.NETWORK |
||||
import com.keylesspalace.tusky.util.Either |
||||
import com.keylesspalace.tusky.util.dec |
||||
import com.keylesspalace.tusky.util.inc |
||||
import com.keylesspalace.tusky.util.trimTrailingWhitespace |
||||
import io.reactivex.rxjava3.core.Single |
||||
import io.reactivex.rxjava3.schedulers.Schedulers |
||||
import java.io.IOException |
||||
import java.util.* |
||||
import java.util.concurrent.TimeUnit |
||||
import kotlin.collections.ArrayList |
||||
|
||||
data class Placeholder(val id: String) |
||||
|
||||
typealias TimelineStatus = Either<Placeholder, Status> |
||||
|
||||
enum class TimelineRequestMode { |
||||
DISK, NETWORK, ANY |
||||
} |
||||
|
||||
interface TimelineRepository { |
||||
fun getStatuses( |
||||
maxId: String?, sinceId: String?, sincedIdMinusOne: String?, limit: Int, |
||||
requestMode: TimelineRequestMode |
||||
): Single<out List<TimelineStatus>> |
||||
|
||||
companion object { |
||||
val CLEANUP_INTERVAL = TimeUnit.DAYS.toMillis(14) |
||||
} |
||||
} |
||||
|
||||
class TimelineRepositoryImpl( |
||||
private val timelineDao: TimelineDao, |
||||
private val mastodonApi: MastodonApi, |
||||
private val accountManager: AccountManager, |
||||
private val gson: Gson |
||||
) : TimelineRepository { |
||||
|
||||
init { |
||||
this.cleanup() |
||||
} |
||||
|
||||
override fun getStatuses( |
||||
maxId: String?, sinceId: String?, sincedIdMinusOne: String?, |
||||
limit: Int, requestMode: TimelineRequestMode |
||||
): Single<out List<TimelineStatus>> { |
||||
val acc = accountManager.activeAccount ?: throw IllegalStateException() |
||||
val accountId = acc.id |
||||
|
||||
return if (requestMode == DISK) { |
||||
this.getStatusesFromDb(accountId, maxId, sinceId, limit) |
||||
} else { |
||||
getStatusesFromNetwork(maxId, sinceId, sincedIdMinusOne, limit, accountId, requestMode) |
||||
} |
||||
} |
||||
|
||||
private fun getStatusesFromNetwork( |
||||
maxId: String?, sinceId: String?, |
||||
sinceIdMinusOne: String?, limit: Int, |
||||
accountId: Long, requestMode: TimelineRequestMode |
||||
): Single<out List<TimelineStatus>> { |
||||
return mastodonApi.homeTimeline(maxId, sinceIdMinusOne, limit + 1) |
||||
.map { response -> |
||||
this.saveStatusesToDb(accountId, response.body().orEmpty(), maxId, sinceId) |
||||
} |
||||
.flatMap { statuses -> |
||||
this.addFromDbIfNeeded(accountId, statuses, maxId, sinceId, limit, requestMode) |
||||
} |
||||
.onErrorResumeNext { error -> |
||||
if (error is IOException && requestMode != NETWORK) { |
||||
this.getStatusesFromDb(accountId, maxId, sinceId, limit) |
||||
} else { |
||||
Single.error(error) |
||||
} |
||||
} |
||||
} |
||||
|
||||
private fun addFromDbIfNeeded( |
||||
accountId: Long, statuses: List<Either<Placeholder, Status>>, |
||||
maxId: String?, sinceId: String?, limit: Int, |
||||
requestMode: TimelineRequestMode |
||||
): Single<List<TimelineStatus>> { |
||||
return if (requestMode != NETWORK && statuses.size < 2) { |
||||
val newMaxID = if (statuses.isEmpty()) { |
||||
maxId |
||||
} else { |
||||
statuses.last { it.isRight() }.asRight().id |
||||
} |
||||
this.getStatusesFromDb(accountId, newMaxID, sinceId, limit) |
||||
.map { fromDb -> |
||||
// If it's just placeholders and less than limit (so we exhausted both |
||||
// db and server at this point) |
||||
if (fromDb.size < limit && fromDb.all { !it.isRight() }) { |
||||
statuses |
||||
} else { |
||||
statuses + fromDb |
||||
} |
||||
} |
||||
} else { |
||||
Single.just(statuses) |
||||
} |
||||
} |
||||
|
||||
private fun getStatusesFromDb( |
||||
accountId: Long, maxId: String?, sinceId: String?, |
||||
limit: Int |
||||
): Single<out List<TimelineStatus>> { |
||||
return timelineDao.getStatusesForAccount(accountId, maxId, sinceId, limit) |
||||
.subscribeOn(Schedulers.io()) |
||||
.map { statuses -> |
||||
statuses.map { it.toStatus() } |
||||
} |
||||
} |
||||
|
||||
private fun saveStatusesToDb( |
||||
accountId: Long, statuses: List<Status>, |
||||
maxId: String?, sinceId: String? |
||||
): List<Either<Placeholder, Status>> { |
||||
var placeholderToInsert: Placeholder? = null |
||||
|
||||
// Look for overlap |
||||
val resultStatuses = if (statuses.isNotEmpty() && sinceId != null) { |
||||
val indexOfSince = statuses.indexOfLast { it.id == sinceId } |
||||
if (indexOfSince == -1) { |
||||
// We didn't find the status which must be there. Add a placeholder |
||||
placeholderToInsert = Placeholder(sinceId.inc()) |
||||
statuses.mapTo(mutableListOf(), Status::lift) |
||||
.apply { |
||||
add(Either.Left(placeholderToInsert)) |
||||
} |
||||
} else { |
||||
// There was an overlap. Remove all overlapped statuses. No need for a placeholder. |
||||
statuses.mapTo(mutableListOf(), Status::lift) |
||||
.apply { |
||||
subList(indexOfSince, size).clear() |
||||
} |
||||
} |
||||
} else { |
||||
// Just a normal case. |
||||
statuses.map(Status::lift) |
||||
} |
||||
|
||||
Single.fromCallable { |
||||
|
||||
if (statuses.isNotEmpty()) { |
||||
timelineDao.deleteRange(accountId, statuses.last().id, statuses.first().id) |
||||
} |
||||
|
||||
for (status in statuses) { |
||||
timelineDao.insertInTransaction( |
||||
status.toEntity(accountId, gson), |
||||
status.account.toEntity(accountId, gson), |
||||
status.reblog?.account?.toEntity(accountId, gson) |
||||
) |
||||
} |
||||
|
||||
placeholderToInsert?.let { |
||||
timelineDao.insertStatusIfNotThere(placeholderToInsert.toEntity(accountId)) |
||||
} |
||||
|
||||
// If we're loading in the bottom insert placeholder after every load |
||||
// (for requests on next launches) but not return it. |
||||
if (sinceId == null && statuses.isNotEmpty()) { |
||||
timelineDao.insertStatusIfNotThere( |
||||
Placeholder(statuses.last().id.dec()).toEntity(accountId) |
||||
) |
||||
} |
||||
|
||||
// There may be placeholders which we thought could be from our TL but they are not |
||||
if (statuses.size > 2) { |
||||
timelineDao.removeAllPlaceholdersBetween( |
||||
accountId, statuses.first().id, |
||||
statuses.last().id |
||||
) |
||||
} else if (placeholderToInsert == null && maxId != null && sinceId != null) { |
||||
timelineDao.removeAllPlaceholdersBetween(accountId, maxId, sinceId) |
||||
} |
||||
} |
||||
.subscribeOn(Schedulers.io()) |
||||
.subscribe() |
||||
|
||||
return resultStatuses |
||||
} |
||||
|
||||
private fun cleanup() { |
||||
Schedulers.io().scheduleDirect { |
||||
val olderThan = System.currentTimeMillis() - TimelineRepository.CLEANUP_INTERVAL |
||||
timelineDao.cleanup(olderThan) |
||||
} |
||||
} |
||||
|
||||
private fun TimelineStatusWithAccount.toStatus(): TimelineStatus { |
||||
if (this.status.authorServerId == null) { |
||||
return Either.Left(Placeholder(this.status.serverId)) |
||||
} |
||||
|
||||
val attachments: ArrayList<Attachment> = gson.fromJson( |
||||
status.attachments, |
||||
object : TypeToken<List<Attachment>>() {}.type |
||||
) ?: ArrayList() |
||||
val mentions: List<Status.Mention> = gson.fromJson( |
||||
status.mentions, |
||||
object : TypeToken<List<Status.Mention>>() {}.type |
||||
) ?: listOf() |
||||
val application = gson.fromJson(status.application, Status.Application::class.java) |
||||
val emojis: List<Emoji> = gson.fromJson( |
||||
status.emojis, |
||||
object : TypeToken<List<Emoji>>() {}.type |
||||
) ?: listOf() |
||||
val poll: Poll? = gson.fromJson(status.poll, Poll::class.java) |
||||
|
||||
val reblog = status.reblogServerId?.let { id -> |
||||
Status( |
||||
id = id, |
||||
url = status.url, |
||||
account = account.toAccount(gson), |
||||
inReplyToId = status.inReplyToId, |
||||
inReplyToAccountId = status.inReplyToAccountId, |
||||
reblog = null, |
||||
content = status.content?.parseAsHtml()?.trimTrailingWhitespace() |
||||
?: SpannedString(""), |
||||
createdAt = Date(status.createdAt), |
||||
emojis = emojis, |
||||
reblogsCount = status.reblogsCount, |
||||
favouritesCount = status.favouritesCount, |
||||
reblogged = status.reblogged, |
||||
favourited = status.favourited, |
||||
bookmarked = status.bookmarked, |
||||
sensitive = status.sensitive, |
||||
spoilerText = status.spoilerText!!, |
||||
visibility = status.visibility!!, |
||||
attachments = attachments, |
||||
mentions = mentions, |
||||
application = application, |
||||
pinned = false, |
||||
muted = status.muted, |
||||
poll = poll, |
||||
card = null |
||||
) |
||||
} |
||||
val status = if (reblog != null) { |
||||
Status( |
||||
id = status.serverId, |
||||
url = null, // no url for reblogs |
||||
account = this.reblogAccount!!.toAccount(gson), |
||||
inReplyToId = null, |
||||
inReplyToAccountId = null, |
||||
reblog = reblog, |
||||
content = SpannedString(""), |
||||
createdAt = Date(status.createdAt), // lie but whatever? |
||||
emojis = listOf(), |
||||
reblogsCount = 0, |
||||
favouritesCount = 0, |
||||
reblogged = false, |
||||
favourited = false, |
||||
bookmarked = false, |
||||
sensitive = false, |
||||
spoilerText = "", |
||||
visibility = status.visibility!!, |
||||
attachments = ArrayList(), |
||||
mentions = listOf(), |
||||
application = null, |
||||
pinned = false, |
||||
muted = status.muted, |
||||
poll = null, |
||||
card = null |
||||
) |
||||
} else { |
||||
Status( |
||||
id = status.serverId, |
||||
url = status.url, |
||||
account = account.toAccount(gson), |
||||
inReplyToId = status.inReplyToId, |
||||
inReplyToAccountId = status.inReplyToAccountId, |
||||
reblog = null, |
||||
content = status.content?.parseAsHtml()?.trimTrailingWhitespace() |
||||
?: SpannedString(""), |
||||
createdAt = Date(status.createdAt), |
||||
emojis = emojis, |
||||
reblogsCount = status.reblogsCount, |
||||
favouritesCount = status.favouritesCount, |
||||
reblogged = status.reblogged, |
||||
favourited = status.favourited, |
||||
bookmarked = status.bookmarked, |
||||
sensitive = status.sensitive, |
||||
spoilerText = status.spoilerText!!, |
||||
visibility = status.visibility!!, |
||||
attachments = attachments, |
||||
mentions = mentions, |
||||
application = application, |
||||
pinned = false, |
||||
muted = status.muted, |
||||
poll = poll, |
||||
card = null |
||||
) |
||||
} |
||||
return Either.Right(status) |
||||
} |
||||
} |
||||
|
||||
private val emojisListTypeToken = object : TypeToken<List<Emoji>>() {} |
||||
|
||||
fun Account.toEntity(accountId: Long, gson: Gson): TimelineAccountEntity { |
||||
return TimelineAccountEntity( |
||||
serverId = id, |
||||
timelineUserId = accountId, |
||||
localUsername = localUsername, |
||||
username = username, |
||||
displayName = name, |
||||
url = url, |
||||
avatar = avatar, |
||||
emojis = gson.toJson(emojis), |
||||
bot = bot |
||||
) |
||||
} |
||||
|
||||
fun TimelineAccountEntity.toAccount(gson: Gson): Account { |
||||
return Account( |
||||
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(this.emojis, emojisListTypeToken.type), |
||||
fields = null, |
||||
moved = null |
||||
) |
||||
} |
||||
|
||||
|
||||
fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity { |
||||
return TimelineStatusEntity( |
||||
serverId = this.id, |
||||
url = null, |
||||
timelineUserId = timelineUserId, |
||||
authorServerId = null, |
||||
inReplyToId = null, |
||||
inReplyToAccountId = null, |
||||
content = null, |
||||
createdAt = 0L, |
||||
emojis = null, |
||||
reblogsCount = 0, |
||||
favouritesCount = 0, |
||||
reblogged = false, |
||||
favourited = false, |
||||
bookmarked = false, |
||||
sensitive = false, |
||||
spoilerText = null, |
||||
visibility = null, |
||||
attachments = null, |
||||
mentions = null, |
||||
application = null, |
||||
reblogServerId = null, |
||||
reblogAccountId = null, |
||||
poll = null, |
||||
muted = false |
||||
) |
||||
} |
||||
|
||||
fun Status.toEntity( |
||||
timelineUserId: Long, |
||||
gson: Gson |
||||
): TimelineStatusEntity { |
||||
val actionable = actionableStatus |
||||
return TimelineStatusEntity( |
||||
serverId = this.id, |
||||
url = actionable.url!!, |
||||
timelineUserId = timelineUserId, |
||||
authorServerId = actionable.account.id, |
||||
inReplyToId = actionable.inReplyToId, |
||||
inReplyToAccountId = actionable.inReplyToAccountId, |
||||
content = actionable.content.toHtml(), |
||||
createdAt = actionable.createdAt.time, |
||||
emojis = actionable.emojis.let(gson::toJson), |
||||
reblogsCount = actionable.reblogsCount, |
||||
favouritesCount = actionable.favouritesCount, |
||||
reblogged = actionable.reblogged, |
||||
favourited = actionable.favourited, |
||||
bookmarked = actionable.bookmarked, |
||||
sensitive = actionable.sensitive, |
||||
spoilerText = actionable.spoilerText, |
||||
visibility = actionable.visibility, |
||||
attachments = actionable.attachments.let(gson::toJson), |
||||
mentions = actionable.mentions.let(gson::toJson), |
||||
application = actionable.application.let(gson::toJson), |
||||
reblogServerId = reblog?.id, |
||||
reblogAccountId = reblog?.let { this.account.id }, |
||||
poll = actionable.poll.let(gson::toJson), |
||||
muted = actionable.muted |
||||
) |
||||
} |
||||
|
||||
fun Status.lift(): Either<Placeholder, Status> = Either.Right(this) |
||||
@ -0,0 +1,903 @@
|
||||
package com.keylesspalace.tusky.components.timeline |
||||
|
||||
import android.content.SharedPreferences |
||||
import android.util.Log |
||||
import androidx.lifecycle.viewModelScope |
||||
import com.keylesspalace.tusky.appstore.* |
||||
import com.keylesspalace.tusky.db.AccountManager |
||||
import com.keylesspalace.tusky.entity.Filter |
||||
import com.keylesspalace.tusky.entity.Poll |
||||
import com.keylesspalace.tusky.entity.Status |
||||
import com.keylesspalace.tusky.network.FilterModel |
||||
import com.keylesspalace.tusky.network.MastodonApi |
||||
import com.keylesspalace.tusky.network.TimelineCases |
||||
import com.keylesspalace.tusky.settings.PrefKeys |
||||
import com.keylesspalace.tusky.util.* |
||||
import com.keylesspalace.tusky.viewdata.StatusViewData |
||||
import io.reactivex.rxjava3.core.Observable |
||||
import io.reactivex.rxjava3.core.Single |
||||
import io.reactivex.rxjava3.subjects.PublishSubject |
||||
import kotlinx.coroutines.Job |
||||
import kotlinx.coroutines.flow.collect |
||||
import kotlinx.coroutines.launch |
||||
import kotlinx.coroutines.rx3.asFlow |
||||
import kotlinx.coroutines.rx3.await |
||||
import retrofit2.HttpException |
||||
import retrofit2.Response |
||||
import java.io.IOException |
||||
import javax.inject.Inject |
||||
|
||||
class TimelineViewModel @Inject constructor( |
||||
private val timelineRepo: TimelineRepository, |
||||
private val timelineCases: TimelineCases, |
||||
private val api: MastodonApi, |
||||
private val eventHub: EventHub, |
||||
private val accountManager: AccountManager, |
||||
private val sharedPreferences: SharedPreferences, |
||||
private val filterModel: FilterModel, |
||||
) : RxAwareViewModel() { |
||||
|
||||
enum class FailureReason { |
||||
NETWORK, |
||||
OTHER, |
||||
} |
||||
|
||||
val viewUpdates: Observable<Unit> |
||||
get() = updateViewSubject |
||||
|
||||
var kind: Kind = Kind.HOME |
||||
private set |
||||
|
||||
var isLoadingInitially = false |
||||
private set |
||||
var isRefreshing = false |
||||
private set |
||||
var bottomLoading = false |
||||
private set |
||||
var initialUpdateFailed = false |
||||
private set |
||||
var failure: FailureReason? = null |
||||
private set |
||||
var id: String? = null |
||||
private set |
||||
var tags: List<String> = emptyList() |
||||
private set |
||||
|
||||
private var alwaysShowSensitiveMedia = false |
||||
private var alwaysOpenSpoilers = false |
||||
private var filterRemoveReplies = false |
||||
private var filterRemoveReblogs = false |
||||
private var didLoadEverythingBottom = false |
||||
|
||||
private var updateViewSubject = PublishSubject.create<Unit>() |
||||
|
||||
/** |
||||
* For some timeline kinds we must use LINK headers and not just status ids. |
||||
*/ |
||||
private var nextId: String? = null |
||||
|
||||
val statuses = mutableListOf<StatusViewData>() |
||||
|
||||
fun init( |
||||
kind: Kind, |
||||
id: String?, |
||||
tags: List<String> |
||||
) { |
||||
this.kind = kind |
||||
this.id = id |
||||
this.tags = tags |
||||
|
||||
if (kind == Kind.HOME) { |
||||
filterRemoveReplies = |
||||
!sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true) |
||||
filterRemoveReblogs = |
||||
!sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true) |
||||
} |
||||
this.alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia |
||||
this.alwaysOpenSpoilers = accountManager.activeAccount!!.alwaysOpenSpoiler |
||||
|
||||
viewModelScope.launch { |
||||
eventHub.events |
||||
.asFlow() |
||||
.collect { event -> handleEvent(event) } |
||||
} |
||||
|
||||
reloadFilters() |
||||
} |
||||
|
||||
private suspend fun updateCurrent() { |
||||
val topId = statuses.firstIsInstanceOrNull<StatusViewData.Concrete>()?.id ?: return |
||||
// Request statuses including current top to refresh all of them |
||||
val topIdMinusOne = topId.inc() |
||||
val statuses = try { |
||||
loadStatuses( |
||||
maxId = topIdMinusOne, |
||||
sinceId = null, |
||||
sinceIdMinusOne = null, |
||||
TimelineRequestMode.NETWORK, |
||||
) |
||||
} catch (t: Exception) { |
||||
initialUpdateFailed = true |
||||
if (isExpectedRequestException(t)) { |
||||
Log.d(TAG, "Failed updating timeline", t) |
||||
triggerViewUpdate() |
||||
return |
||||
} else { |
||||
throw t |
||||
} |
||||
} |
||||
|
||||
initialUpdateFailed = false |
||||
|
||||
// When cached timeline is too old, we would replace it with nothing |
||||
if (statuses.isNotEmpty()) { |
||||
val mutableStatuses = statuses.toMutableList() |
||||
filterStatuses(mutableStatuses) |
||||
this.statuses.removeAll { item -> |
||||
val id = when (item) { |
||||
is StatusViewData.Concrete -> item.id |
||||
is StatusViewData.Placeholder -> item.id |
||||
} |
||||
|
||||
id == topId || id.isLessThan(topId) |
||||
} |
||||
this.statuses.addAll(mutableStatuses.toViewData()) |
||||
} |
||||
triggerViewUpdate() |
||||
} |
||||
|
||||
private fun isExpectedRequestException(t: Exception) = t is IOException || t is HttpException |
||||
|
||||
fun refresh(): Job { |
||||
return viewModelScope.launch { |
||||
isRefreshing = true |
||||
failure = null |
||||
triggerViewUpdate() |
||||
|
||||
try { |
||||
if (initialUpdateFailed) updateCurrent() |
||||
loadAbove() |
||||
} catch (e: Exception) { |
||||
if (isExpectedRequestException(e)) { |
||||
Log.e(TAG, "Failed to refresh", e) |
||||
} else { |
||||
throw e |
||||
} |
||||
} finally { |
||||
isRefreshing = false |
||||
triggerViewUpdate() |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** When reaching the end of list. WIll optionally show spinner in the end of list. */ |
||||
fun loadMore(): Job { |
||||
return viewModelScope.launch { |
||||
if (didLoadEverythingBottom || bottomLoading) { |
||||
return@launch |
||||
} |
||||
if (statuses.isEmpty()) { |
||||
loadInitial().join() |
||||
return@launch |
||||
} |
||||
setLoadingPlaceholderBelow() |
||||
|
||||
val bottomId: String? = |
||||
if (kind == Kind.FAVOURITES || kind == Kind.BOOKMARKS) { |
||||
nextId |
||||
} else { |
||||
statuses.lastOrNull { it is StatusViewData.Concrete } |
||||
?.let { (it as StatusViewData.Concrete).id } |
||||
} |
||||
try { |
||||
loadBelow(bottomId) |
||||
} catch (e: Exception) { |
||||
if (isExpectedRequestException(e)) { |
||||
if (statuses.lastOrNull() is StatusViewData.Placeholder) { |
||||
statuses.removeAt(statuses.lastIndex) |
||||
} |
||||
} else { |
||||
throw e |
||||
} |
||||
} finally { |
||||
triggerViewUpdate() |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** Load and insert statuses below the [bottomId]. Does not indicate progress. */ |
||||
private suspend fun loadBelow(bottomId: String?) { |
||||
this.bottomLoading = true |
||||
try { |
||||
val statuses = loadStatuses( |
||||
bottomId, |
||||
null, |
||||
null, |
||||
TimelineRequestMode.ANY |
||||
) |
||||
addStatusesBelow(statuses.toMutableList()) |
||||
} finally { |
||||
this.bottomLoading = false |
||||
} |
||||
} |
||||
|
||||
private fun setLoadingPlaceholderBelow() { |
||||
val last = statuses.last() |
||||
val placeholder: StatusViewData.Placeholder |
||||
if (last is StatusViewData.Concrete) { |
||||
val placeholderId = last.id.dec() |
||||
placeholder = StatusViewData.Placeholder(placeholderId, true) |
||||
statuses.add(placeholder) |
||||
} else { |
||||
placeholder = last as StatusViewData.Placeholder |
||||
} |
||||
statuses[statuses.lastIndex] = placeholder |
||||
triggerViewUpdate() |
||||
} |
||||
|
||||
private fun addStatusesBelow(statuses: MutableList<Either<Placeholder, Status>>) { |
||||
val fullFetch = isFullFetch(statuses) |
||||
// Remove placeholder in the bottom if it's there |
||||
if (this.statuses.isNotEmpty() |
||||
&& this.statuses.last() !is StatusViewData.Concrete |
||||
) { |
||||
this.statuses.removeAt(this.statuses.lastIndex) |
||||
} |
||||
|
||||
// Removing placeholder if it's the last one from the cache |
||||
if (statuses.isNotEmpty() && !statuses[statuses.size - 1].isRight()) { |
||||
statuses.removeAt(statuses.size - 1) |
||||
} |
||||
|
||||
val oldSize = this.statuses.size |
||||
if (this.statuses.isNotEmpty()) { |
||||
addItems(statuses) |
||||
} else { |
||||
updateStatuses(statuses, fullFetch) |
||||
} |
||||
if (this.statuses.size == oldSize) { |
||||
// This may be a brittle check but seems like it works |
||||
// Can we check it using headers somehow? Do all server support them? |
||||
didLoadEverythingBottom = true |
||||
} |
||||
} |
||||
|
||||
fun loadGap(position: Int): Job { |
||||
return viewModelScope.launch { |
||||
//check bounds before accessing list, |
||||
if (statuses.size < position || position <= 0) { |
||||
Log.e(TAG, "Wrong gap position: $position") |
||||
return@launch |
||||
} |
||||
|
||||
val fromStatus = statuses[position - 1].asStatusOrNull() |
||||
val toStatus = statuses[position + 1].asStatusOrNull() |
||||
val toMinusOne = statuses.getOrNull(position + 2)?.asStatusOrNull()?.id |
||||
if (fromStatus == null || toStatus == null) { |
||||
Log.e(TAG, "Failed to load more at $position, wrong placeholder position") |
||||
return@launch |
||||
} |
||||
val placeholder = statuses[position].asPlaceholderOrNull() ?: run { |
||||
Log.e(TAG, "Not a placeholder at $position") |
||||
return@launch |
||||
} |
||||
|
||||
val newViewData: StatusViewData = StatusViewData.Placeholder(placeholder.id, true) |
||||
statuses[position] = newViewData |
||||
triggerViewUpdate() |
||||
|
||||
try { |
||||
val statuses = loadStatuses( |
||||
fromStatus.id, |
||||
toStatus.id, |
||||
toMinusOne, |
||||
TimelineRequestMode.NETWORK |
||||
) |
||||
replacePlaceholderWithStatuses( |
||||
statuses.toMutableList(), |
||||
isFullFetch(statuses), |
||||
position |
||||
) |
||||
} catch (t: Exception) { |
||||
if (isExpectedRequestException(t)) { |
||||
Log.e(TAG, "Failed to load gap", t) |
||||
if (statuses[position] is StatusViewData.Placeholder) { |
||||
statuses[position] = StatusViewData.Placeholder(placeholder.id, false) |
||||
} |
||||
} else { |
||||
throw t |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
fun reblog(reblog: Boolean, position: Int): Job = viewModelScope.launch { |
||||
val status = statuses[position].asStatusOrNull() ?: return@launch |
||||
try { |
||||
timelineCases.reblog(status.id, reblog).await() |
||||
} catch (t: Exception) { |
||||
ifExpected(t) { |
||||
Log.d(TAG, "Failed to reblog status " + status.id, t) |
||||
|
||||
} |
||||
} |
||||
} |
||||
|
||||
fun favorite(favorite: Boolean, position: Int): Job = viewModelScope.launch { |
||||
val status = statuses[position].asStatusOrNull() ?: return@launch |
||||
|
||||
try { |
||||
timelineCases.favourite(status.id, favorite).await() |
||||
} catch (t: Exception) { |
||||
ifExpected(t) { |
||||
Log.d(TAG, "Failed to favourite status " + status.id, t) |
||||
} |
||||
} |
||||
} |
||||
|
||||
fun bookmark(bookmark: Boolean, position: Int): Job = viewModelScope.launch { |
||||
val status = statuses[position].asStatusOrNull() ?: return@launch |
||||
try { |
||||
timelineCases.bookmark(status.id, bookmark).await() |
||||
} catch (t: Exception) { |
||||
ifExpected(t) { |
||||
Log.d(TAG, "Failed to favourite status " + status.id, t) |
||||
} |
||||
} |
||||
} |
||||
|
||||
fun voteInPoll(position: Int, choices: List<Int>): Job = viewModelScope.launch { |
||||
val status = statuses[position].asStatusOrNull() ?: return@launch |
||||
|
||||
val poll = status.status.poll ?: run { |
||||
Log.w(TAG, "No poll on status ${status.id}") |
||||
return@launch |
||||
} |
||||
|
||||
val votedPoll = poll.votedCopy(choices) |
||||
updatePoll(status, votedPoll) |
||||
|
||||
try { |
||||
timelineCases.voteInPoll(status.id, poll.id, choices).await() |
||||
} catch (t: Exception) { |
||||
ifExpected(t) { |
||||
Log.d(TAG, "Failed to vote in poll: " + status.id, t) |
||||
} |
||||
} |
||||
} |
||||
|
||||
private fun updatePoll( |
||||
status: StatusViewData.Concrete, |
||||
newPoll: Poll |
||||
) { |
||||
updateStatusById(status.id) { |
||||
it.copy(status = it.status.copy(poll = newPoll)) |
||||
} |
||||
} |
||||
|
||||
fun changeExpanded(expanded: Boolean, position: Int) { |
||||
updateStatusAt(position) { it.copy(isExpanded = expanded) } |
||||
triggerViewUpdate() |
||||
} |
||||
|
||||
fun changeContentHidden(isShowing: Boolean, position: Int) { |
||||
updateStatusAt(position) { it.copy(isShowingContent = isShowing) } |
||||
triggerViewUpdate() |
||||
} |
||||
|
||||
fun changeContentCollapsed(isCollapsed: Boolean, position: Int) { |
||||
updateStatusAt(position) { it.copy(isCollapsed = isCollapsed) } |
||||
triggerViewUpdate() |
||||
} |
||||
|
||||
private fun removeAllByAccountId(accountId: String) { |
||||
statuses.removeAll { vm -> |
||||
val status = vm.asStatusOrNull()?.status ?: return@removeAll false |
||||
status.account.id == accountId || status.actionableStatus.account.id == accountId |
||||
} |
||||
} |
||||
|
||||
private fun removeAllByInstance(instance: String) { |
||||
statuses.removeAll { vd -> |
||||
val status = vd.asStatusOrNull()?.status ?: return@removeAll false |
||||
LinkHelper.getDomain(status.account.url) == instance |
||||
} |
||||
} |
||||
|
||||
private fun triggerViewUpdate() { |
||||
this.updateViewSubject.onNext(Unit) |
||||
} |
||||
|
||||
private suspend fun loadStatuses( |
||||
maxId: String?, |
||||
sinceId: String?, |
||||
sinceIdMinusOne: String?, |
||||
homeMode: TimelineRequestMode, |
||||
): List<TimelineStatus> { |
||||
val statuses = if (kind == Kind.HOME) { |
||||
timelineRepo.getStatuses(maxId, sinceId, sinceIdMinusOne, LOAD_AT_ONCE, homeMode) |
||||
.await() |
||||
} else { |
||||
val response = fetchStatusesForKind(maxId, sinceId, LOAD_AT_ONCE).await() |
||||
if (response.isSuccessful) { |
||||
val newNextId = extractNextId(response) |
||||
if (newNextId != null) { |
||||
// when we reach the bottom of the list, we won't have a new link. If |
||||
// we blindly write `null` here we will start loading from the top |
||||
// again. |
||||
nextId = newNextId |
||||
} |
||||
response.body()?.map { Either.Right(it) } ?: listOf() |
||||
} else { |
||||
throw Exception(response.message()) |
||||
} |
||||
} |
||||
|
||||
filterStatuses(statuses.toMutableList()) |
||||
|
||||
return statuses |
||||
} |
||||
|
||||
private fun updateStatuses( |
||||
newStatuses: MutableList<Either<Placeholder, Status>>, |
||||
fullFetch: Boolean |
||||
) { |
||||
if (statuses.isEmpty()) { |
||||
statuses.addAll(newStatuses.toViewData()) |
||||
} else { |
||||
val lastOfNew = newStatuses.lastOrNull() |
||||
val index = if (lastOfNew == null) -1 |
||||
else statuses.indexOfLast { it.asStatusOrNull()?.id === lastOfNew.asRightOrNull()?.id } |
||||
if (index >= 0) { |
||||
statuses.subList(0, index).clear() |
||||
} |
||||
|
||||
val newIndex = |
||||
newStatuses.indexOfFirst { |
||||
it.isRight() && it.asRight().id == (statuses[0] as? StatusViewData.Concrete)?.id |
||||
} |
||||
if (newIndex == -1) { |
||||
if (index == -1 && fullFetch) { |
||||
val placeholderId = |
||||
newStatuses.last { status -> status.isRight() }.asRight().id.inc() |
||||
newStatuses.add(Either.Left(Placeholder(placeholderId))) |
||||
} |
||||
statuses.addAll(0, newStatuses.toViewData()) |
||||
} else { |
||||
statuses.addAll(0, newStatuses.subList(0, newIndex).toViewData()) |
||||
} |
||||
} |
||||
// Remove all consecutive placeholders |
||||
removeConsecutivePlaceholders() |
||||
this.triggerViewUpdate() |
||||
} |
||||
|
||||
private fun filterViewData(viewData: MutableList<StatusViewData>) { |
||||
viewData.removeAll { vd -> |
||||
vd.asStatusOrNull()?.status?.let { shouldFilterStatus(it) } ?: false |
||||
} |
||||
} |
||||
|
||||
private fun filterStatuses(statuses: MutableList<Either<Placeholder, Status>>) { |
||||
statuses.removeAll { status -> |
||||
status.asRightOrNull()?.let { shouldFilterStatus(it) } ?: false |
||||
} |
||||
} |
||||
|
||||
private fun shouldFilterStatus(status: Status): Boolean { |
||||
return status.inReplyToId != null && filterRemoveReplies |
||||
|| status.reblog != null && filterRemoveReblogs |
||||
|| filterModel.shouldFilterStatus(status.actionableStatus) |
||||
} |
||||
|
||||
private fun extractNextId(response: Response<*>): String? { |
||||
val linkHeader = response.headers()["Link"] ?: return null |
||||
val links = HttpHeaderLink.parse(linkHeader) |
||||
val nextHeader = HttpHeaderLink.findByRelationType(links, "next") ?: return null |
||||
val nextLink = nextHeader.uri ?: return null |
||||
return nextLink.getQueryParameter("max_id") |
||||
} |
||||
|
||||
private suspend fun tryCache() { |
||||
// Request timeline from disk to make it quick, then replace it with timeline from |
||||
// the server to update it |
||||
val statuses = |
||||
timelineRepo.getStatuses(null, null, null, LOAD_AT_ONCE, TimelineRequestMode.DISK) |
||||
.await() |
||||
|
||||
val mutableStatusResponse = statuses.toMutableList() |
||||
filterStatuses(mutableStatusResponse) |
||||
if (statuses.size > 1) { |
||||
clearPlaceholdersForResponse(mutableStatusResponse) |
||||
this.statuses.clear() |
||||
this.statuses.addAll(statuses.toViewData()) |
||||
} |
||||
} |
||||
|
||||
fun loadInitial(): Job { |
||||
return viewModelScope.launch { |
||||
if (statuses.isNotEmpty() || initialUpdateFailed || isLoadingInitially) { |
||||
return@launch |
||||
} |
||||
isLoadingInitially = true |
||||
failure = null |
||||
triggerViewUpdate() |
||||
|
||||
if (kind == Kind.HOME) { |
||||
tryCache() |
||||
isLoadingInitially = statuses.isEmpty() |
||||
updateCurrent() |
||||
try { |
||||
loadAbove() |
||||
} catch (e: Exception) { |
||||
Log.e(TAG, "Loading above failed", e) |
||||
if (!isExpectedRequestException(e)) { |
||||
throw e |
||||
} else if (statuses.isEmpty()) { |
||||
failure = |
||||
if (e is IOException) FailureReason.NETWORK |
||||
else FailureReason.OTHER |
||||
} |
||||
} finally { |
||||
isLoadingInitially = false |
||||
triggerViewUpdate() |
||||
} |
||||
} else { |
||||
try { |
||||
loadBelow(null) |
||||
} catch (e: IOException) { |
||||
failure = FailureReason.NETWORK |
||||
} catch (e: HttpException) { |
||||
failure = FailureReason.OTHER |
||||
} finally { |
||||
isLoadingInitially = false |
||||
triggerViewUpdate() |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
private suspend fun loadAbove() { |
||||
var firstOrNull: String? = null |
||||
var secondOrNull: String? = null |
||||
for (i in statuses.indices) { |
||||
val status = statuses[i].asStatusOrNull() ?: continue |
||||
firstOrNull = status.id |
||||
secondOrNull = statuses.getOrNull(i + 1)?.asStatusOrNull()?.id |
||||
break |
||||
} |
||||
|
||||
try { |
||||
if (firstOrNull != null) { |
||||
triggerViewUpdate() |
||||
|
||||
val statuses = loadStatuses( |
||||
maxId = null, |
||||
sinceId = firstOrNull, |
||||
sinceIdMinusOne = secondOrNull, |
||||
homeMode = TimelineRequestMode.NETWORK |
||||
) |
||||
|
||||
val fullFetch = isFullFetch(statuses) |
||||
updateStatuses(statuses.toMutableList(), fullFetch) |
||||
} else { |
||||
loadBelow(null) |
||||
} |
||||
} finally { |
||||
triggerViewUpdate() |
||||
} |
||||
} |
||||
|
||||
private fun isFullFetch(statuses: List<TimelineStatus>) = statuses.size >= LOAD_AT_ONCE |
||||
|
||||
private fun fullyRefresh(): Job { |
||||
this.statuses.clear() |
||||
return loadInitial() |
||||
} |
||||
|
||||
private fun fetchStatusesForKind( |
||||
fromId: String?, |
||||
uptoId: String?, |
||||
limit: Int |
||||
): Single<Response<List<Status>>> { |
||||
return when (kind) { |
||||
Kind.HOME -> api.homeTimeline(fromId, uptoId, limit) |
||||
Kind.PUBLIC_FEDERATED -> api.publicTimeline(null, fromId, uptoId, limit) |
||||
Kind.PUBLIC_LOCAL -> api.publicTimeline(true, fromId, uptoId, limit) |
||||
Kind.TAG -> { |
||||
val firstHashtag = tags[0] |
||||
val additionalHashtags = tags.subList(1, tags.size) |
||||
api.hashtagTimeline(firstHashtag, additionalHashtags, null, fromId, uptoId, limit) |
||||
} |
||||
Kind.USER -> api.accountStatuses( |
||||
id!!, |
||||
fromId, |
||||
uptoId, |
||||
limit, |
||||
excludeReplies = true, |
||||
onlyMedia = null, |
||||
pinned = null |
||||
) |
||||
Kind.USER_PINNED -> api.accountStatuses( |
||||
id!!, |
||||
fromId, |
||||
uptoId, |
||||
limit, |
||||
excludeReplies = null, |
||||
onlyMedia = null, |
||||
pinned = true |
||||
) |
||||
Kind.USER_WITH_REPLIES -> api.accountStatuses( |
||||
id!!, |
||||
fromId, |
||||
uptoId, |
||||
limit, |
||||
excludeReplies = null, |
||||
onlyMedia = null, |
||||
pinned = null |
||||
) |
||||
Kind.FAVOURITES -> api.favourites(fromId, uptoId, limit) |
||||
Kind.BOOKMARKS -> api.bookmarks(fromId, uptoId, limit) |
||||
Kind.LIST -> api.listTimeline(id!!, fromId, uptoId, limit) |
||||
} |
||||
} |
||||
|
||||
private fun replacePlaceholderWithStatuses( |
||||
newStatuses: MutableList<Either<Placeholder, Status>>, |
||||
fullFetch: Boolean, pos: Int |
||||
) { |
||||
val placeholder = statuses[pos] |
||||
if (placeholder is StatusViewData.Placeholder) { |
||||
statuses.removeAt(pos) |
||||
} |
||||
if (newStatuses.isEmpty()) { |
||||
return |
||||
} |
||||
val newViewData = newStatuses |
||||
.toViewData() |
||||
.toMutableList() |
||||
|
||||
if (fullFetch) { |
||||
newViewData.add(placeholder) |
||||
} |
||||
statuses.addAll(pos, newViewData) |
||||
removeConsecutivePlaceholders() |
||||
triggerViewUpdate() |
||||
} |
||||
|
||||
private fun removeConsecutivePlaceholders() { |
||||
for (i in 0 until statuses.size - 1) { |
||||
if (statuses[i] is StatusViewData.Placeholder && |
||||
statuses[i + 1] is StatusViewData.Placeholder |
||||
) { |
||||
statuses.removeAt(i) |
||||
} |
||||
} |
||||
} |
||||
|
||||
private fun addItems(newStatuses: List<Either<Placeholder, Status>>) { |
||||
if (newStatuses.isEmpty()) { |
||||
return |
||||
} |
||||
statuses.addAll(newStatuses.toViewData()) |
||||
removeConsecutivePlaceholders() |
||||
} |
||||
|
||||
/** |
||||
* For certain requests we don't want to see placeholders, they will be removed some other way |
||||
*/ |
||||
private fun clearPlaceholdersForResponse(statuses: MutableList<Either<Placeholder, Status>>) { |
||||
statuses.removeAll { status -> status.isLeft() } |
||||
} |
||||
|
||||
private fun handleReblogEvent(reblogEvent: ReblogEvent) { |
||||
updateStatusById(reblogEvent.statusId) { |
||||
it.copy(status = it.status.copy(reblogged = reblogEvent.reblog)) |
||||
} |
||||
} |
||||
|
||||
private fun handleFavEvent(favEvent: FavoriteEvent) { |
||||
updateStatusById(favEvent.statusId) { |
||||
it.copy(status = it.status.copy(favourited = favEvent.favourite)) |
||||
} |
||||
} |
||||
|
||||
private fun handleBookmarkEvent(bookmarkEvent: BookmarkEvent) { |
||||
updateStatusById(bookmarkEvent.statusId) { |
||||
it.copy(status = it.status.copy(bookmarked = bookmarkEvent.bookmark)) |
||||
} |
||||
} |
||||
|
||||
private fun handlePinEvent(pinEvent: PinEvent) { |
||||
updateStatusById(pinEvent.statusId) { |
||||
it.copy(status = it.status.copy(pinned = pinEvent.pinned)) |
||||
} |
||||
} |
||||
|
||||
private fun handleStatusComposeEvent(status: Status) { |
||||
when (kind) { |
||||
Kind.HOME, Kind.PUBLIC_FEDERATED, Kind.PUBLIC_LOCAL -> refresh() |
||||
Kind.USER, Kind.USER_WITH_REPLIES -> if (status.account.id == id) { |
||||
refresh() |
||||
} else { |
||||
return |
||||
} |
||||
Kind.TAG, Kind.FAVOURITES, Kind.LIST, Kind.BOOKMARKS, Kind.USER_PINNED -> return |
||||
} |
||||
} |
||||
|
||||
private fun deleteStatusById(id: String) { |
||||
for (i in statuses.indices) { |
||||
val either = statuses[i] |
||||
if (either.asStatusOrNull()?.id == id) { |
||||
statuses.removeAt(i) |
||||
break |
||||
} |
||||
} |
||||
} |
||||
|
||||
private fun onPreferenceChanged(key: String) { |
||||
when (key) { |
||||
PrefKeys.TAB_FILTER_HOME_REPLIES -> { |
||||
val filter = sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true) |
||||
val oldRemoveReplies = filterRemoveReplies |
||||
filterRemoveReplies = kind == Kind.HOME && !filter |
||||
if (statuses.isNotEmpty() && oldRemoveReplies != filterRemoveReplies) { |
||||
fullyRefresh() |
||||
} |
||||
} |
||||
PrefKeys.TAB_FILTER_HOME_BOOSTS -> { |
||||
val filter = sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true) |
||||
val oldRemoveReblogs = filterRemoveReblogs |
||||
filterRemoveReblogs = kind == Kind.HOME && !filter |
||||
if (statuses.isNotEmpty() && oldRemoveReblogs != filterRemoveReblogs) { |
||||
fullyRefresh() |
||||
} |
||||
} |
||||
Filter.HOME, Filter.NOTIFICATIONS, Filter.THREAD, Filter.PUBLIC, Filter.ACCOUNT -> { |
||||
if (filterContextMatchesKind(kind, listOf(key))) { |
||||
reloadFilters() |
||||
} |
||||
} |
||||
PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> { |
||||
// it is ok if only newly loaded statuses are affected, no need to fully refresh |
||||
alwaysShowSensitiveMedia = |
||||
accountManager.activeAccount!!.alwaysShowSensitiveMedia |
||||
} |
||||
} |
||||
} |
||||
|
||||
// public for now |
||||
fun filterContextMatchesKind( |
||||
kind: Kind, |
||||
filterContext: List<String> |
||||
): Boolean { |
||||
// home, notifications, public, thread |
||||
return when (kind) { |
||||
Kind.HOME, Kind.LIST -> filterContext.contains( |
||||
Filter.HOME |
||||
) |
||||
Kind.PUBLIC_FEDERATED, Kind.PUBLIC_LOCAL, Kind.TAG -> filterContext.contains( |
||||
Filter.PUBLIC |
||||
) |
||||
Kind.FAVOURITES -> filterContext.contains(Filter.PUBLIC) || filterContext.contains( |
||||
Filter.NOTIFICATIONS |
||||
) |
||||
Kind.USER, Kind.USER_WITH_REPLIES, Kind.USER_PINNED -> filterContext.contains( |
||||
Filter.ACCOUNT |
||||
) |
||||
else -> false |
||||
} |
||||
} |
||||
|
||||
private fun handleEvent(event: Event) { |
||||
when (event) { |
||||
is FavoriteEvent -> handleFavEvent(event) |
||||
is ReblogEvent -> handleReblogEvent(event) |
||||
is BookmarkEvent -> handleBookmarkEvent(event) |
||||
is PinEvent -> handlePinEvent(event) |
||||
is MuteConversationEvent -> fullyRefresh() |
||||
is UnfollowEvent -> { |
||||
if (kind == Kind.HOME) { |
||||
val id = event.accountId |
||||
removeAllByAccountId(id) |
||||
} |
||||
} |
||||
is BlockEvent -> { |
||||
if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { |
||||
val id = event.accountId |
||||
removeAllByAccountId(id) |
||||
} |
||||
} |
||||
is MuteEvent -> { |
||||
if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { |
||||
val id = event.accountId |
||||
removeAllByAccountId(id) |
||||
} |
||||
} |
||||
is DomainMuteEvent -> { |
||||
if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { |
||||
val instance = event.instance |
||||
removeAllByInstance(instance) |
||||
} |
||||
} |
||||
is StatusDeletedEvent -> { |
||||
if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { |
||||
val id = event.statusId |
||||
deleteStatusById(id) |
||||
} |
||||
} |
||||
is StatusComposedEvent -> { |
||||
val status = event.status |
||||
handleStatusComposeEvent(status) |
||||
} |
||||
is PreferenceChangedEvent -> { |
||||
onPreferenceChanged(event.preferenceKey) |
||||
} |
||||
} |
||||
} |
||||
|
||||
private inline fun updateStatusById( |
||||
id: String, |
||||
updater: (StatusViewData.Concrete) -> StatusViewData.Concrete |
||||
) { |
||||
val pos = statuses.indexOfFirst { it.asStatusOrNull()?.id == id } |
||||
if (pos == -1) return |
||||
updateStatusAt(pos, updater) |
||||
} |
||||
|
||||
private inline fun updateStatusAt( |
||||
position: Int, |
||||
updater: (StatusViewData.Concrete) -> StatusViewData.Concrete |
||||
) { |
||||
val status = statuses.getOrNull(position)?.asStatusOrNull() ?: return |
||||
statuses[position] = updater(status) |
||||
triggerViewUpdate() |
||||
} |
||||
|
||||
private fun List<TimelineStatus>.toViewData(): List<StatusViewData> = this.map { |
||||
when (it) { |
||||
is Either.Right -> it.value.toViewData( |
||||
alwaysShowSensitiveMedia, |
||||
alwaysOpenSpoilers |
||||
) |
||||
is Either.Left -> StatusViewData.Placeholder(it.value.id, false) |
||||
} |
||||
} |
||||
|
||||
private fun reloadFilters() { |
||||
viewModelScope.launch { |
||||
val filters = try { |
||||
api.getFilters().await() |
||||
} catch (t: Exception) { |
||||
Log.e(TAG, "Failed to fetch filters", t) |
||||
return@launch |
||||
} |
||||
filterModel.initWithFilters(filters.filter { |
||||
filterContextMatchesKind(kind, it.context) |
||||
}) |
||||
filterViewData(this@TimelineViewModel.statuses) |
||||
} |
||||
} |
||||
|
||||
private inline fun ifExpected( |
||||
t: Exception, |
||||
cb: () -> Unit |
||||
) { |
||||
if (isExpectedRequestException(t)) { |
||||
cb() |
||||
} else { |
||||
throw t |
||||
} |
||||
} |
||||
|
||||
|
||||
companion object { |
||||
private const val TAG = "TimelineVM" |
||||
internal const val LOAD_AT_ONCE = 30 |
||||
} |
||||
|
||||
enum class Kind { |
||||
HOME, PUBLIC_LOCAL, PUBLIC_FEDERATED, TAG, USER, USER_PINNED, USER_WITH_REPLIES, FAVOURITES, LIST, BOOKMARKS |
||||
} |
||||
} |
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,56 @@
|
||||
package com.keylesspalace.tusky.network |
||||
|
||||
import android.text.TextUtils |
||||
import com.keylesspalace.tusky.entity.Filter |
||||
import com.keylesspalace.tusky.entity.Status |
||||
import java.util.regex.Pattern |
||||
import javax.inject.Inject |
||||
|
||||
/** |
||||
* One-stop for status filtering logic using Mastodon's filters. |
||||
* |
||||
* 1. You init with [initWithFilters], this compiles regex pattern. |
||||
* 2. You call [shouldFilterStatus] to figure out what to display when you load statuses. |
||||
*/ |
||||
class FilterModel @Inject constructor() { |
||||
private var pattern: Pattern? = null |
||||
|
||||
fun initWithFilters(filters: List<Filter>) { |
||||
this.pattern = makeFilter(filters) |
||||
} |
||||
|
||||
fun shouldFilterStatus(status: Status): Boolean { |
||||
// Patterns are expensive and thread-safe, matchers are neither. |
||||
val matcher = pattern?.matcher("") ?: return false |
||||
|
||||
if (status.poll != null) { |
||||
val pollMatches = status.poll.options.any { matcher.reset(it.title).find() } |
||||
if (pollMatches) return true |
||||
} |
||||
|
||||
val spoilerText = status.actionableStatus.spoilerText |
||||
return (matcher.reset(status.actionableStatus.content).find() || |
||||
spoilerText.isNotEmpty() && matcher.reset(spoilerText).find()) |
||||
} |
||||
|
||||
private fun filterToRegexToken(filter: Filter): String? { |
||||
val phrase = filter.phrase |
||||
val quotedPhrase = Pattern.quote(phrase) |
||||
return if (filter.wholeWord && ALPHANUMERIC.matcher(phrase).matches()) { |
||||
String.format("(^|\\W)%s($|\\W)", quotedPhrase) |
||||
} else { |
||||
quotedPhrase |
||||
} |
||||
} |
||||
|
||||
private fun makeFilter(filters: List<Filter>): Pattern? { |
||||
if (filters.isEmpty()) return null |
||||
val tokens = filters.map { filterToRegexToken(it) } |
||||
|
||||
return Pattern.compile(TextUtils.join("|", tokens), Pattern.CASE_INSENSITIVE); |
||||
} |
||||
|
||||
companion object { |
||||
private val ALPHANUMERIC = Pattern.compile("^\\w+$") |
||||
} |
||||
} |
||||
@ -1,392 +0,0 @@
|
||||
package com.keylesspalace.tusky.repository |
||||
|
||||
import android.text.SpannedString |
||||
import androidx.core.text.parseAsHtml |
||||
import androidx.core.text.toHtml |
||||
import com.google.gson.Gson |
||||
import com.google.gson.reflect.TypeToken |
||||
import com.keylesspalace.tusky.db.* |
||||
import com.keylesspalace.tusky.entity.* |
||||
import com.keylesspalace.tusky.network.MastodonApi |
||||
import com.keylesspalace.tusky.repository.TimelineRequestMode.DISK |
||||
import com.keylesspalace.tusky.repository.TimelineRequestMode.NETWORK |
||||
import com.keylesspalace.tusky.util.Either |
||||
import com.keylesspalace.tusky.util.dec |
||||
import com.keylesspalace.tusky.util.inc |
||||
import com.keylesspalace.tusky.util.trimTrailingWhitespace |
||||
import io.reactivex.rxjava3.core.Single |
||||
import io.reactivex.rxjava3.schedulers.Schedulers |
||||
import java.io.IOException |
||||
import java.util.* |
||||
import java.util.concurrent.TimeUnit |
||||
import kotlin.collections.ArrayList |
||||
|
||||
data class Placeholder(val id: String) |
||||
|
||||
typealias TimelineStatus = Either<Placeholder, Status> |
||||
|
||||
enum class TimelineRequestMode { |
||||
DISK, NETWORK, ANY |
||||
} |
||||
|
||||
interface TimelineRepository { |
||||
fun getStatuses(maxId: String?, sinceId: String?, sincedIdMinusOne: String?, limit: Int, |
||||
requestMode: TimelineRequestMode): Single<out List<TimelineStatus>> |
||||
|
||||
companion object { |
||||
val CLEANUP_INTERVAL = TimeUnit.DAYS.toMillis(14) |
||||
} |
||||
} |
||||
|
||||
class TimelineRepositoryImpl( |
||||
private val timelineDao: TimelineDao, |
||||
private val mastodonApi: MastodonApi, |
||||
private val accountManager: AccountManager, |
||||
private val gson: Gson |
||||
) : TimelineRepository { |
||||
|
||||
init { |
||||
this.cleanup() |
||||
} |
||||
|
||||
override fun getStatuses(maxId: String?, sinceId: String?, sincedIdMinusOne: String?, |
||||
limit: Int, requestMode: TimelineRequestMode |
||||
): Single<out List<TimelineStatus>> { |
||||
val acc = accountManager.activeAccount ?: throw IllegalStateException() |
||||
val accountId = acc.id |
||||
|
||||
return if (requestMode == DISK) { |
||||
this.getStatusesFromDb(accountId, maxId, sinceId, limit) |
||||
} else { |
||||
getStatusesFromNetwork(maxId, sinceId, sincedIdMinusOne, limit, accountId, requestMode) |
||||
} |
||||
} |
||||
|
||||
private fun getStatusesFromNetwork(maxId: String?, sinceId: String?, |
||||
sinceIdMinusOne: String?, limit: Int, |
||||
accountId: Long, requestMode: TimelineRequestMode |
||||
): Single<out List<TimelineStatus>> { |
||||
return mastodonApi.homeTimeline(maxId, sinceIdMinusOne, limit + 1) |
||||
.map { response -> |
||||
this.saveStatusesToDb(accountId, response.body().orEmpty(), maxId, sinceId) |
||||
} |
||||
.flatMap { statuses -> |
||||
this.addFromDbIfNeeded(accountId, statuses, maxId, sinceId, limit, requestMode) |
||||
} |
||||
.onErrorResumeNext { error -> |
||||
if (error is IOException && requestMode != NETWORK) { |
||||
this.getStatusesFromDb(accountId, maxId, sinceId, limit) |
||||
} else { |
||||
Single.error(error) |
||||
} |
||||
} |
||||
} |
||||
|
||||
private fun addFromDbIfNeeded(accountId: Long, statuses: List<Either<Placeholder, Status>>, |
||||
maxId: String?, sinceId: String?, limit: Int, |
||||
requestMode: TimelineRequestMode |
||||
): Single<List<TimelineStatus>> { |
||||
return if (requestMode != NETWORK && statuses.size < 2) { |
||||
val newMaxID = if (statuses.isEmpty()) { |
||||
maxId |
||||
} else { |
||||
statuses.last { it.isRight() }.asRight().id |
||||
} |
||||
this.getStatusesFromDb(accountId, newMaxID, sinceId, limit) |
||||
.map { fromDb -> |
||||
// If it's just placeholders and less than limit (so we exhausted both |
||||
// db and server at this point) |
||||
if (fromDb.size < limit && fromDb.all { !it.isRight() }) { |
||||
statuses |
||||
} else { |
||||
statuses + fromDb |
||||
} |
||||
} |
||||
} else { |
||||
Single.just(statuses) |
||||
} |
||||
} |
||||
|
||||
private fun getStatusesFromDb(accountId: Long, maxId: String?, sinceId: String?, |
||||
limit: Int): Single<out List<TimelineStatus>> { |
||||
return timelineDao.getStatusesForAccount(accountId, maxId, sinceId, limit) |
||||
.subscribeOn(Schedulers.io()) |
||||
.map { statuses -> |
||||
statuses.map { it.toStatus() } |
||||
} |
||||
} |
||||
|
||||
private fun saveStatusesToDb(accountId: Long, statuses: List<Status>, |
||||
maxId: String?, sinceId: String? |
||||
): List<Either<Placeholder, Status>> { |
||||
var placeholderToInsert: Placeholder? = null |
||||
|
||||
// Look for overlap |
||||
val resultStatuses = if (statuses.isNotEmpty() && sinceId != null) { |
||||
val indexOfSince = statuses.indexOfLast { it.id == sinceId } |
||||
if (indexOfSince == -1) { |
||||
// We didn't find the status which must be there. Add a placeholder |
||||
placeholderToInsert = Placeholder(sinceId.inc()) |
||||
statuses.mapTo(mutableListOf(), Status::lift) |
||||
.apply { |
||||
add(Either.Left(placeholderToInsert)) |
||||
} |
||||
} else { |
||||
// There was an overlap. Remove all overlapped statuses. No need for a placeholder. |
||||
statuses.mapTo(mutableListOf(), Status::lift) |
||||
.apply { |
||||
subList(indexOfSince, size).clear() |
||||
} |
||||
} |
||||
} else { |
||||
// Just a normal case. |
||||
statuses.map(Status::lift) |
||||
} |
||||
|
||||
Single.fromCallable { |
||||
|
||||
if(statuses.isNotEmpty()) { |
||||
timelineDao.deleteRange(accountId, statuses.last().id, statuses.first().id) |
||||
} |
||||
|
||||
for (status in statuses) { |
||||
timelineDao.insertInTransaction( |
||||
status.toEntity(accountId, gson), |
||||
status.account.toEntity(accountId, gson), |
||||
status.reblog?.account?.toEntity(accountId, gson) |
||||
) |
||||
} |
||||
|
||||
placeholderToInsert?.let { |
||||
timelineDao.insertStatusIfNotThere(placeholderToInsert.toEntity(accountId)) |
||||
} |
||||
|
||||
// If we're loading in the bottom insert placeholder after every load |
||||
// (for requests on next launches) but not return it. |
||||
if (sinceId == null && statuses.isNotEmpty()) { |
||||
timelineDao.insertStatusIfNotThere( |
||||
Placeholder(statuses.last().id.dec()).toEntity(accountId)) |
||||
} |
||||
|
||||
// There may be placeholders which we thought could be from our TL but they are not |
||||
if (statuses.size > 2) { |
||||
timelineDao.removeAllPlaceholdersBetween(accountId, statuses.first().id, |
||||
statuses.last().id) |
||||
} else if (placeholderToInsert == null && maxId != null && sinceId != null) { |
||||
timelineDao.removeAllPlaceholdersBetween(accountId, maxId, sinceId) |
||||
} |
||||
} |
||||
.subscribeOn(Schedulers.io()) |
||||
.subscribe() |
||||
|
||||
return resultStatuses |
||||
} |
||||
|
||||
private fun cleanup() { |
||||
Schedulers.io().scheduleDirect { |
||||
val olderThan = System.currentTimeMillis() - TimelineRepository.CLEANUP_INTERVAL |
||||
timelineDao.cleanup(olderThan) |
||||
} |
||||
} |
||||
|
||||
private fun TimelineStatusWithAccount.toStatus(): TimelineStatus { |
||||
if (this.status.authorServerId == null) { |
||||
return Either.Left(Placeholder(this.status.serverId)) |
||||
} |
||||
|
||||
val attachments: ArrayList<Attachment> = gson.fromJson(status.attachments, |
||||
object : TypeToken<List<Attachment>>() {}.type) ?: ArrayList() |
||||
val mentions: Array<Status.Mention> = gson.fromJson(status.mentions, |
||||
Array<Status.Mention>::class.java) ?: arrayOf() |
||||
val application = gson.fromJson(status.application, Status.Application::class.java) |
||||
val emojis: List<Emoji> = gson.fromJson(status.emojis, |
||||
object : TypeToken<List<Emoji>>() {}.type) ?: listOf() |
||||
val poll: Poll? = gson.fromJson(status.poll, Poll::class.java) |
||||
|
||||
val reblog = status.reblogServerId?.let { id -> |
||||
Status( |
||||
id = id, |
||||
url = status.url, |
||||
account = account.toAccount(gson), |
||||
inReplyToId = status.inReplyToId, |
||||
inReplyToAccountId = status.inReplyToAccountId, |
||||
reblog = null, |
||||
content = status.content?.parseAsHtml()?.trimTrailingWhitespace() ?: SpannedString(""), |
||||
createdAt = Date(status.createdAt), |
||||
emojis = emojis, |
||||
reblogsCount = status.reblogsCount, |
||||
favouritesCount = status.favouritesCount, |
||||
reblogged = status.reblogged, |
||||
favourited = status.favourited, |
||||
bookmarked = status.bookmarked, |
||||
sensitive = status.sensitive, |
||||
spoilerText = status.spoilerText!!, |
||||
visibility = status.visibility!!, |
||||
attachments = attachments, |
||||
mentions = mentions, |
||||
application = application, |
||||
pinned = false, |
||||
muted = status.muted, |
||||
poll = poll, |
||||
card = null |
||||
) |
||||
} |
||||
val status = if (reblog != null) { |
||||
Status( |
||||
id = status.serverId, |
||||
url = null, // no url for reblogs |
||||
account = this.reblogAccount!!.toAccount(gson), |
||||
inReplyToId = null, |
||||
inReplyToAccountId = null, |
||||
reblog = reblog, |
||||
content = SpannedString(""), |
||||
createdAt = Date(status.createdAt), // lie but whatever? |
||||
emojis = listOf(), |
||||
reblogsCount = 0, |
||||
favouritesCount = 0, |
||||
reblogged = false, |
||||
favourited = false, |
||||
bookmarked = false, |
||||
sensitive = false, |
||||
spoilerText = "", |
||||
visibility = status.visibility!!, |
||||
attachments = ArrayList(), |
||||
mentions = arrayOf(), |
||||
application = null, |
||||
pinned = false, |
||||
muted = status.muted, |
||||
poll = null, |
||||
card = null |
||||
) |
||||
} else { |
||||
Status( |
||||
id = status.serverId, |
||||
url = status.url, |
||||
account = account.toAccount(gson), |
||||
inReplyToId = status.inReplyToId, |
||||
inReplyToAccountId = status.inReplyToAccountId, |
||||
reblog = null, |
||||
content = status.content?.parseAsHtml()?.trimTrailingWhitespace() ?: SpannedString(""), |
||||
createdAt = Date(status.createdAt), |
||||
emojis = emojis, |
||||
reblogsCount = status.reblogsCount, |
||||
favouritesCount = status.favouritesCount, |
||||
reblogged = status.reblogged, |
||||
favourited = status.favourited, |
||||
bookmarked = status.bookmarked, |
||||
sensitive = status.sensitive, |
||||
spoilerText = status.spoilerText!!, |
||||
visibility = status.visibility!!, |
||||
attachments = attachments, |
||||
mentions = mentions, |
||||
application = application, |
||||
pinned = false, |
||||
muted = status.muted, |
||||
poll = poll, |
||||
card = null |
||||
) |
||||
} |
||||
return Either.Right(status) |
||||
} |
||||
} |
||||
|
||||
private val emojisListTypeToken = object : TypeToken<List<Emoji>>() {} |
||||
|
||||
fun Account.toEntity(accountId: Long, gson: Gson): TimelineAccountEntity { |
||||
return TimelineAccountEntity( |
||||
serverId = id, |
||||
timelineUserId = accountId, |
||||
localUsername = localUsername, |
||||
username = username, |
||||
displayName = name, |
||||
url = url, |
||||
avatar = avatar, |
||||
emojis = gson.toJson(emojis), |
||||
bot = bot |
||||
) |
||||
} |
||||
|
||||
fun TimelineAccountEntity.toAccount(gson: Gson): Account { |
||||
return Account( |
||||
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(this.emojis, emojisListTypeToken.type), |
||||
fields = null, |
||||
moved = null |
||||
) |
||||
} |
||||
|
||||
|
||||
fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity { |
||||
return TimelineStatusEntity( |
||||
serverId = this.id, |
||||
url = null, |
||||
timelineUserId = timelineUserId, |
||||
authorServerId = null, |
||||
inReplyToId = null, |
||||
inReplyToAccountId = null, |
||||
content = null, |
||||
createdAt = 0L, |
||||
emojis = null, |
||||
reblogsCount = 0, |
||||
favouritesCount = 0, |
||||
reblogged = false, |
||||
favourited = false, |
||||
bookmarked = false, |
||||
sensitive = false, |
||||
spoilerText = null, |
||||
visibility = null, |
||||
attachments = null, |
||||
mentions = null, |
||||
application = null, |
||||
reblogServerId = null, |
||||
reblogAccountId = null, |
||||
poll = null, |
||||
muted = false |
||||
) |
||||
} |
||||
|
||||
fun Status.toEntity(timelineUserId: Long, |
||||
gson: Gson): TimelineStatusEntity { |
||||
val actionable = actionableStatus |
||||
return TimelineStatusEntity( |
||||
serverId = this.id, |
||||
url = actionable.url!!, |
||||
timelineUserId = timelineUserId, |
||||
authorServerId = actionable.account.id, |
||||
inReplyToId = actionable.inReplyToId, |
||||
inReplyToAccountId = actionable.inReplyToAccountId, |
||||
content = actionable.content.toHtml(), |
||||
createdAt = actionable.createdAt.time, |
||||
emojis = actionable.emojis.let(gson::toJson), |
||||
reblogsCount = actionable.reblogsCount, |
||||
favouritesCount = actionable.favouritesCount, |
||||
reblogged = actionable.reblogged, |
||||
favourited = actionable.favourited, |
||||
bookmarked = actionable.bookmarked, |
||||
sensitive = actionable.sensitive, |
||||
spoilerText = actionable.spoilerText, |
||||
visibility = actionable.visibility, |
||||
attachments = actionable.attachments.let(gson::toJson), |
||||
mentions = actionable.mentions.let(gson::toJson), |
||||
application = actionable.application.let(gson::toJson), |
||||
reblogServerId = reblog?.id, |
||||
reblogAccountId = reblog?.let { this.account.id }, |
||||
poll = actionable.poll.let(gson::toJson), |
||||
muted = actionable.muted |
||||
) |
||||
} |
||||
|
||||
fun Status.lift(): Either<Placeholder, Status> = Either.Right(this) |
||||
@ -1,86 +0,0 @@
|
||||
/* 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.util; |
||||
|
||||
import androidx.annotation.Nullable; |
||||
|
||||
import com.keylesspalace.tusky.entity.Notification; |
||||
import com.keylesspalace.tusky.entity.Status; |
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData; |
||||
import com.keylesspalace.tusky.viewdata.StatusViewData; |
||||
|
||||
/** |
||||
* Created by charlag on 12/07/2017. |
||||
*/ |
||||
|
||||
public final class ViewDataUtils { |
||||
@Nullable |
||||
public static StatusViewData.Concrete statusToViewData(@Nullable Status status, |
||||
boolean alwaysShowSensitiveMedia, |
||||
boolean alwaysOpenSpoiler) { |
||||
if (status == null) return null; |
||||
Status visibleStatus = status.getReblog() == null ? status : status.getReblog(); |
||||
return new StatusViewData.Builder().setId(status.getId()) |
||||
.setAttachments(visibleStatus.getAttachments()) |
||||
.setAvatar(visibleStatus.getAccount().getAvatar()) |
||||
.setContent(visibleStatus.getContent()) |
||||
.setCreatedAt(visibleStatus.getCreatedAt()) |
||||
.setReblogsCount(visibleStatus.getReblogsCount()) |
||||
.setFavouritesCount(visibleStatus.getFavouritesCount()) |
||||
.setInReplyToId(visibleStatus.getInReplyToId()) |
||||
.setFavourited(visibleStatus.getFavourited()) |
||||
.setBookmarked(visibleStatus.getBookmarked()) |
||||
.setReblogged(visibleStatus.getReblogged()) |
||||
.setIsExpanded(alwaysOpenSpoiler) |
||||
.setIsShowingSensitiveContent(false) |
||||
.setMentions(visibleStatus.getMentions()) |
||||
.setNickname(visibleStatus.getAccount().getUsername()) |
||||
.setRebloggedAvatar(status.getReblog() == null ? null : status.getAccount().getAvatar()) |
||||
.setSensitive(visibleStatus.getSensitive()) |
||||
.setIsShowingSensitiveContent(alwaysShowSensitiveMedia || !visibleStatus.getSensitive()) |
||||
.setSpoilerText(visibleStatus.getSpoilerText()) |
||||
.setRebloggedByUsername(status.getReblog() == null ? null : status.getAccount().getName()) |
||||
.setUserFullName(visibleStatus.getAccount().getName()) |
||||
.setVisibility(visibleStatus.getVisibility()) |
||||
.setSenderId(visibleStatus.getAccount().getId()) |
||||
.setRebloggingEnabled(visibleStatus.rebloggingAllowed()) |
||||
.setApplication(visibleStatus.getApplication()) |
||||
.setStatusEmojis(visibleStatus.getEmojis()) |
||||
.setAccountEmojis(visibleStatus.getAccount().getEmojis()) |
||||
.setRebloggedByEmojis(status.getReblog() == null ? null : status.getAccount().getEmojis()) |
||||
.setCollapsible(SmartLengthInputFilterKt.shouldTrimStatus(visibleStatus.getContent())) |
||||
.setCollapsed(true) |
||||
.setPoll(visibleStatus.getPoll()) |
||||
.setCard(visibleStatus.getCard()) |
||||
.setIsBot(visibleStatus.getAccount().getBot()) |
||||
.createStatusViewData(); |
||||
} |
||||
|
||||
public static NotificationViewData.Concrete notificationToViewData(Notification notification, |
||||
boolean alwaysShowSensitiveData, |
||||
boolean alwaysOpenSpoiler) { |
||||
return new NotificationViewData.Concrete( |
||||
notification.getType(), |
||||
notification.getId(), |
||||
notification.getAccount(), |
||||
statusToViewData( |
||||
notification.getStatus(), |
||||
alwaysShowSensitiveData, |
||||
alwaysOpenSpoiler |
||||
) |
||||
); |
||||
} |
||||
} |
||||
@ -0,0 +1,53 @@
|
||||
@file:JvmName("ViewDataUtils") |
||||
|
||||
/* 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.util |
||||
|
||||
import com.keylesspalace.tusky.entity.Notification |
||||
import com.keylesspalace.tusky.entity.Status |
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData |
||||
import com.keylesspalace.tusky.viewdata.StatusViewData |
||||
import com.keylesspalace.tusky.viewdata.toViewData |
||||
import java.util.* |
||||
|
||||
@JvmName("statusToViewData") |
||||
fun Status.toViewData( |
||||
alwaysShowSensitiveMedia: Boolean, |
||||
alwaysOpenSpoiler: Boolean |
||||
): StatusViewData.Concrete { |
||||
val visibleStatus = this.reblog ?: this |
||||
|
||||
return StatusViewData.Concrete( |
||||
status = this, |
||||
isShowingContent = alwaysShowSensitiveMedia || !visibleStatus.sensitive, |
||||
isCollapsible = shouldTrimStatus(visibleStatus.content), |
||||
isCollapsed = false, |
||||
isExpanded = alwaysOpenSpoiler, |
||||
) |
||||
} |
||||
|
||||
@JvmName("notificationToViewData") |
||||
fun Notification.toViewData( |
||||
alwaysShowSensitiveData: Boolean, |
||||
alwaysOpenSpoiler: Boolean |
||||
): NotificationViewData.Concrete { |
||||
return NotificationViewData.Concrete( |
||||
this.type, |
||||
this.id, |
||||
this.account, |
||||
this.status?.toViewData(alwaysShowSensitiveData, alwaysOpenSpoiler) |
||||
) |
||||
} |
||||
@ -1,677 +0,0 @@
|
||||
/* 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.viewdata; |
||||
|
||||
import android.os.Build; |
||||
import android.text.SpannableStringBuilder; |
||||
import android.text.Spanned; |
||||
|
||||
import androidx.annotation.Nullable; |
||||
|
||||
import com.keylesspalace.tusky.entity.Attachment; |
||||
import com.keylesspalace.tusky.entity.Card; |
||||
import com.keylesspalace.tusky.entity.Emoji; |
||||
import com.keylesspalace.tusky.entity.Poll; |
||||
import com.keylesspalace.tusky.entity.Status; |
||||
|
||||
import java.util.ArrayList; |
||||
import java.util.Arrays; |
||||
import java.util.Collections; |
||||
import java.util.Date; |
||||
import java.util.List; |
||||
import java.util.Objects; |
||||
|
||||
/** |
||||
* Created by charlag on 11/07/2017. |
||||
* <p> |
||||
* Class to represent data required to display either a notification or a placeholder. |
||||
* It is either a {@link StatusViewData.Concrete} or a {@link StatusViewData.Placeholder}. |
||||
*/ |
||||
|
||||
public abstract class StatusViewData { |
||||
|
||||
private StatusViewData() { } |
||||
|
||||
public abstract long getViewDataId(); |
||||
|
||||
public abstract boolean deepEquals(StatusViewData other); |
||||
|
||||
public static final class Concrete extends StatusViewData { |
||||
private static final char SOFT_HYPHEN = '\u00ad'; |
||||
private static final char ASCII_HYPHEN = '-'; |
||||
|
||||
private final String id; |
||||
private final Spanned content; |
||||
final boolean reblogged; |
||||
final boolean favourited; |
||||
final boolean bookmarked; |
||||
private final boolean muted; |
||||
@Nullable |
||||
private final String spoilerText; |
||||
private final Status.Visibility visibility; |
||||
private final List<Attachment> attachments; |
||||
@Nullable |
||||
private final String rebloggedByUsername; |
||||
@Nullable |
||||
private final String rebloggedAvatar; |
||||
private final boolean isSensitive; |
||||
final boolean isExpanded; |
||||
private final boolean isShowingContent; |
||||
private final String userFullName; |
||||
private final String nickname; |
||||
private final String avatar; |
||||
private final Date createdAt; |
||||
private final int reblogsCount; |
||||
private final int favouritesCount; |
||||
@Nullable |
||||
private final String inReplyToId; |
||||
// I would rather have something else but it would be too much of a rewrite
|
||||
@Nullable |
||||
private final Status.Mention[] mentions; |
||||
private final String senderId; |
||||
private final boolean rebloggingEnabled; |
||||
private final Status.Application application; |
||||
private final List<Emoji> statusEmojis; |
||||
private final List<Emoji> accountEmojis; |
||||
private final List<Emoji> rebloggedByAccountEmojis; |
||||
@Nullable |
||||
private final Card card; |
||||
private final boolean isCollapsible; /** Whether the status meets the requirement to be collapse */ |
||||
final boolean isCollapsed; /** Whether the status is shown partially or fully */ |
||||
@Nullable |
||||
private final PollViewData poll; |
||||
private final boolean isBot; |
||||
|
||||
public Concrete(String id, Spanned content, boolean reblogged, boolean favourited, boolean bookmarked, boolean muted, |
||||
@Nullable String spoilerText, Status.Visibility visibility, List<Attachment> attachments, |
||||
@Nullable String rebloggedByUsername, @Nullable String rebloggedAvatar, boolean sensitive, boolean isExpanded, |
||||
boolean isShowingContent, String userFullName, String nickname, String avatar, |
||||
Date createdAt, int reblogsCount, int favouritesCount, @Nullable String inReplyToId, |
||||
@Nullable Status.Mention[] mentions, String senderId, boolean rebloggingEnabled, |
||||
Status.Application application, List<Emoji> statusEmojis, List<Emoji> accountEmojis, List<Emoji> rebloggedByAccountEmojis, @Nullable Card card, |
||||
boolean isCollapsible, boolean isCollapsed, @Nullable PollViewData poll, boolean isBot) { |
||||
|
||||
this.id = id; |
||||
if (Build.VERSION.SDK_INT == 23) { |
||||
// https://github.com/tuskyapp/Tusky/issues/563
|
||||
this.content = replaceCrashingCharacters(content); |
||||
this.spoilerText = spoilerText == null ? null : replaceCrashingCharacters(spoilerText).toString(); |
||||
this.nickname = replaceCrashingCharacters(nickname).toString(); |
||||
} else { |
||||
this.content = content; |
||||
this.spoilerText = spoilerText; |
||||
this.nickname = nickname; |
||||
} |
||||
this.reblogged = reblogged; |
||||
this.favourited = favourited; |
||||
this.bookmarked = bookmarked; |
||||
this.muted = muted; |
||||
this.visibility = visibility; |
||||
this.attachments = attachments; |
||||
this.rebloggedByUsername = rebloggedByUsername; |
||||
this.rebloggedAvatar = rebloggedAvatar; |
||||
this.isSensitive = sensitive; |
||||
this.isExpanded = isExpanded; |
||||
this.isShowingContent = isShowingContent; |
||||
this.userFullName = userFullName; |
||||
this.avatar = avatar; |
||||
this.createdAt = createdAt; |
||||
this.reblogsCount = reblogsCount; |
||||
this.favouritesCount = favouritesCount; |
||||
this.inReplyToId = inReplyToId; |
||||
this.mentions = mentions; |
||||
this.senderId = senderId; |
||||
this.rebloggingEnabled = rebloggingEnabled; |
||||
this.application = application; |
||||
this.statusEmojis = statusEmojis; |
||||
this.accountEmojis = accountEmojis; |
||||
this.rebloggedByAccountEmojis = rebloggedByAccountEmojis; |
||||
this.card = card; |
||||
this.isCollapsible = isCollapsible; |
||||
this.isCollapsed = isCollapsed; |
||||
this.poll = poll; |
||||
this.isBot = isBot; |
||||
} |
||||
|
||||
public String getId() { |
||||
return id; |
||||
} |
||||
|
||||
public Spanned getContent() { |
||||
return content; |
||||
} |
||||
|
||||
public boolean isReblogged() { |
||||
return reblogged; |
||||
} |
||||
|
||||
public boolean isFavourited() { |
||||
return favourited; |
||||
} |
||||
|
||||
public boolean isBookmarked() { |
||||
return bookmarked; |
||||
} |
||||
|
||||
public boolean isMuted() { |
||||
return muted; |
||||
} |
||||
|
||||
@Nullable |
||||
public String getSpoilerText() { |
||||
return spoilerText; |
||||
} |
||||
|
||||
public Status.Visibility getVisibility() { |
||||
return visibility; |
||||
} |
||||
|
||||
public List<Attachment> getAttachments() { |
||||
return attachments; |
||||
} |
||||
|
||||
@Nullable |
||||
public String getRebloggedByUsername() { |
||||
return rebloggedByUsername; |
||||
} |
||||
|
||||
public boolean isSensitive() { |
||||
return isSensitive; |
||||
} |
||||
|
||||
public boolean isExpanded() { |
||||
return isExpanded; |
||||
} |
||||
|
||||
public boolean isShowingContent() { |
||||
return isShowingContent; |
||||
} |
||||
|
||||
public boolean isBot(){ return isBot; } |
||||
|
||||
@Nullable |
||||
public String getRebloggedAvatar() { |
||||
return rebloggedAvatar; |
||||
} |
||||
|
||||
public String getUserFullName() { |
||||
return userFullName; |
||||
} |
||||
|
||||
public String getNickname() { |
||||
return nickname; |
||||
} |
||||
|
||||
public String getAvatar() { |
||||
return avatar; |
||||
} |
||||
|
||||
public Date getCreatedAt() { |
||||
return createdAt; |
||||
} |
||||
|
||||
public int getReblogsCount() { |
||||
return reblogsCount; |
||||
} |
||||
|
||||
public int getFavouritesCount() { |
||||
return favouritesCount; |
||||
} |
||||
|
||||
@Nullable |
||||
public String getInReplyToId() { |
||||
return inReplyToId; |
||||
} |
||||
|
||||
public String getSenderId() { |
||||
return senderId; |
||||
} |
||||
|
||||
public Boolean getRebloggingEnabled() { |
||||
return rebloggingEnabled; |
||||
} |
||||
|
||||
@Nullable |
||||
public Status.Mention[] getMentions() { |
||||
return mentions; |
||||
} |
||||
|
||||
public Status.Application getApplication() { |
||||
return application; |
||||
} |
||||
|
||||
public List<Emoji> getStatusEmojis() { |
||||
return statusEmojis; |
||||
} |
||||
|
||||
public List<Emoji> getAccountEmojis() { |
||||
return accountEmojis; |
||||
} |
||||
|
||||
public List<Emoji> getRebloggedByAccountEmojis() { |
||||
return rebloggedByAccountEmojis; |
||||
} |
||||
|
||||
@Nullable |
||||
public Card getCard() { |
||||
return card; |
||||
} |
||||
|
||||
/** |
||||
* Specifies whether the content of this post is allowed to be collapsed or if it should show |
||||
* all content regardless. |
||||
* |
||||
* @return Whether the post is collapsible or never collapsed. |
||||
*/ |
||||
public boolean isCollapsible() { |
||||
return isCollapsible; |
||||
} |
||||
|
||||
/** |
||||
* Specifies whether the content of this post is currently limited in visibility to the first |
||||
* 500 characters or not. |
||||
* |
||||
* @return Whether the post is collapsed or fully expanded. |
||||
*/ |
||||
public boolean isCollapsed() { |
||||
return isCollapsed; |
||||
} |
||||
|
||||
@Nullable |
||||
public PollViewData getPoll() { |
||||
return poll; |
||||
} |
||||
|
||||
@Override public long getViewDataId() { |
||||
// Chance of collision is super low and impact of mistake is low as well
|
||||
return id.hashCode(); |
||||
} |
||||
|
||||
public boolean deepEquals(StatusViewData o) { |
||||
if (this == o) return true; |
||||
if (o == null || getClass() != o.getClass()) return false; |
||||
Concrete concrete = (Concrete) o; |
||||
return reblogged == concrete.reblogged && |
||||
favourited == concrete.favourited && |
||||
bookmarked == concrete.bookmarked && |
||||
isSensitive == concrete.isSensitive && |
||||
isExpanded == concrete.isExpanded && |
||||
isShowingContent == concrete.isShowingContent && |
||||
isBot == concrete.isBot && |
||||
reblogsCount == concrete.reblogsCount && |
||||
favouritesCount == concrete.favouritesCount && |
||||
rebloggingEnabled == concrete.rebloggingEnabled && |
||||
Objects.equals(id, concrete.id) && |
||||
Objects.equals(content, concrete.content) && |
||||
Objects.equals(spoilerText, concrete.spoilerText) && |
||||
visibility == concrete.visibility && |
||||
Objects.equals(attachments, concrete.attachments) && |
||||
Objects.equals(rebloggedByUsername, concrete.rebloggedByUsername) && |
||||
Objects.equals(rebloggedAvatar, concrete.rebloggedAvatar) && |
||||
Objects.equals(userFullName, concrete.userFullName) && |
||||
Objects.equals(nickname, concrete.nickname) && |
||||
Objects.equals(avatar, concrete.avatar) && |
||||
Objects.equals(createdAt, concrete.createdAt) && |
||||
Objects.equals(inReplyToId, concrete.inReplyToId) && |
||||
Arrays.equals(mentions, concrete.mentions) && |
||||
Objects.equals(senderId, concrete.senderId) && |
||||
Objects.equals(application, concrete.application) && |
||||
Objects.equals(statusEmojis, concrete.statusEmojis) && |
||||
Objects.equals(accountEmojis, concrete.accountEmojis) && |
||||
Objects.equals(rebloggedByAccountEmojis, concrete.rebloggedByAccountEmojis) && |
||||
Objects.equals(card, concrete.card) && |
||||
Objects.equals(poll, concrete.poll) |
||||
&& isCollapsed == concrete.isCollapsed; |
||||
} |
||||
|
||||
static Spanned replaceCrashingCharacters(Spanned content) { |
||||
return (Spanned) replaceCrashingCharacters((CharSequence) content); |
||||
} |
||||
|
||||
static CharSequence replaceCrashingCharacters(CharSequence content) { |
||||
boolean replacing = false; |
||||
SpannableStringBuilder builder = null; |
||||
int length = content.length(); |
||||
|
||||
for (int index = 0; index < length; ++index) { |
||||
char character = content.charAt(index); |
||||
|
||||
// If there are more than one or two, switch to a map
|
||||
if (character == SOFT_HYPHEN) { |
||||
if (!replacing) { |
||||
replacing = true; |
||||
builder = new SpannableStringBuilder(content, 0, index); |
||||
} |
||||
builder.append(ASCII_HYPHEN); |
||||
} else if (replacing) { |
||||
builder.append(character); |
||||
} |
||||
} |
||||
|
||||
return replacing ? builder : content; |
||||
} |
||||
} |
||||
|
||||
public static final class Placeholder extends StatusViewData { |
||||
private final boolean isLoading; |
||||
private final String id; |
||||
|
||||
public Placeholder(String id, boolean isLoading) { |
||||
this.id = id; |
||||
this.isLoading = isLoading; |
||||
} |
||||
|
||||
public boolean isLoading() { |
||||
return isLoading; |
||||
} |
||||
|
||||
public String getId() { |
||||
return id; |
||||
} |
||||
|
||||
@Override public long getViewDataId() { |
||||
return id.hashCode(); |
||||
} |
||||
|
||||
@Override public boolean deepEquals(StatusViewData other) { |
||||
if (!(other instanceof Placeholder)) return false; |
||||
Placeholder that = (Placeholder) other; |
||||
return isLoading == that.isLoading && id.equals(that.id); |
||||
} |
||||
|
||||
@Override public boolean equals(Object o) { |
||||
if (this == o) return true; |
||||
if (o == null || getClass() != o.getClass()) return false; |
||||
|
||||
Placeholder that = (Placeholder) o; |
||||
|
||||
return deepEquals(that); |
||||
} |
||||
|
||||
@Override |
||||
public int hashCode() { |
||||
int result = (isLoading ? 1 : 0); |
||||
result = 31 * result + id.hashCode(); |
||||
return result; |
||||
} |
||||
} |
||||
|
||||
public static class Builder { |
||||
private String id; |
||||
private Spanned content; |
||||
private boolean reblogged; |
||||
private boolean favourited; |
||||
private boolean bookmarked; |
||||
private boolean muted; |
||||
private String spoilerText; |
||||
private Status.Visibility visibility; |
||||
private List<Attachment> attachments; |
||||
private String rebloggedByUsername; |
||||
private String rebloggedAvatar; |
||||
private boolean isSensitive; |
||||
private boolean isExpanded; |
||||
private boolean isShowingContent; |
||||
private String userFullName; |
||||
private String nickname; |
||||
private String avatar; |
||||
private Date createdAt; |
||||
private int reblogsCount; |
||||
private int favouritesCount; |
||||
private String inReplyToId; |
||||
private Status.Mention[] mentions; |
||||
private String senderId; |
||||
private boolean rebloggingEnabled; |
||||
private Status.Application application; |
||||
private List<Emoji> statusEmojis; |
||||
private List<Emoji> accountEmojis; |
||||
private List<Emoji> rebloggedByAccountEmojis; |
||||
private Card card; |
||||
private boolean isCollapsible; /** Whether the status meets the requirement to be collapsed */ |
||||
private boolean isCollapsed; /** Whether the status is shown partially or fully */ |
||||
private PollViewData poll; |
||||
private boolean isBot; |
||||
|
||||
public Builder() { |
||||
} |
||||
|
||||
public Builder(final StatusViewData.Concrete viewData) { |
||||
id = viewData.id; |
||||
content = viewData.content; |
||||
reblogged = viewData.reblogged; |
||||
favourited = viewData.favourited; |
||||
bookmarked = viewData.bookmarked; |
||||
muted = viewData.muted; |
||||
spoilerText = viewData.spoilerText; |
||||
visibility = viewData.visibility; |
||||
attachments = viewData.attachments == null ? null : new ArrayList<>(viewData.attachments); |
||||
rebloggedByUsername = viewData.rebloggedByUsername; |
||||
rebloggedAvatar = viewData.rebloggedAvatar; |
||||
isSensitive = viewData.isSensitive; |
||||
isExpanded = viewData.isExpanded; |
||||
isShowingContent = viewData.isShowingContent; |
||||
userFullName = viewData.userFullName; |
||||
nickname = viewData.nickname; |
||||
avatar = viewData.avatar; |
||||
createdAt = new Date(viewData.createdAt.getTime()); |
||||
reblogsCount = viewData.reblogsCount; |
||||
favouritesCount = viewData.favouritesCount; |
||||
inReplyToId = viewData.inReplyToId; |
||||
mentions = viewData.mentions == null ? null : viewData.mentions.clone(); |
||||
senderId = viewData.senderId; |
||||
rebloggingEnabled = viewData.rebloggingEnabled; |
||||
application = viewData.application; |
||||
statusEmojis = viewData.getStatusEmojis(); |
||||
accountEmojis = viewData.getAccountEmojis(); |
||||
rebloggedByAccountEmojis = viewData.getRebloggedByAccountEmojis(); |
||||
card = viewData.getCard(); |
||||
isCollapsible = viewData.isCollapsible(); |
||||
isCollapsed = viewData.isCollapsed(); |
||||
poll = viewData.poll; |
||||
isBot = viewData.isBot(); |
||||
} |
||||
|
||||
public Builder setId(String id) { |
||||
this.id = id; |
||||
return this; |
||||
} |
||||
|
||||
public Builder setContent(Spanned content) { |
||||
this.content = content; |
||||
return this; |
||||
} |
||||
|
||||
public Builder setReblogged(boolean reblogged) { |
||||
this.reblogged = reblogged; |
||||
return this; |
||||
} |
||||
|
||||
public Builder setFavourited(boolean favourited) { |
||||
this.favourited = favourited; |
||||
return this; |
||||
} |
||||
|
||||
public Builder setBookmarked(boolean bookmarked) { |
||||
this.bookmarked = bookmarked; |
||||
return this; |
||||
} |
||||
|
||||
public Builder setMuted(boolean muted) { |
||||
this.muted = muted; |
||||
return this; |
||||
} |
||||
|
||||
public Builder setSpoilerText(String spoilerText) { |
||||
this.spoilerText = spoilerText; |
||||
return this; |
||||
} |
||||
|
||||
public Builder setVisibility(Status.Visibility visibility) { |
||||
this.visibility = visibility; |
||||
return this; |
||||
} |
||||
|
||||
public Builder setAttachments(List<Attachment> attachments) { |
||||
this.attachments = attachments; |
||||
return this; |
||||
} |
||||
|
||||
public Builder setRebloggedByUsername(String rebloggedByUsername) { |
||||
this.rebloggedByUsername = rebloggedByUsername; |
||||
return this; |
||||
} |
||||
|
||||
public Builder setRebloggedAvatar(String rebloggedAvatar) { |
||||
this.rebloggedAvatar = rebloggedAvatar; |
||||
return this; |
||||
} |
||||
|
||||
public Builder setSensitive(boolean sensitive) { |
||||
this.isSensitive = sensitive; |
||||
return this; |
||||
} |
||||
|
||||
public Builder setIsExpanded(boolean isExpanded) { |
||||
this.isExpanded = isExpanded; |
||||
return this; |
||||
} |
||||
|
||||
public Builder setIsShowingSensitiveContent(boolean isShowingSensitiveContent) { |
||||
this.isShowingContent = isShowingSensitiveContent; |
||||
return this; |
||||
} |
||||
|
||||
public Builder setIsBot(boolean isBot) { |
||||
this.isBot = isBot; |
||||
return this; |
||||
} |
||||
|
||||
public Builder setUserFullName(String userFullName) { |
||||
this.userFullName = userFullName; |
||||
return this; |
||||
} |
||||
|
||||
public Builder setNickname(String nickname) { |
||||
this.nickname = nickname; |
||||
return this; |
||||
} |
||||
|
||||
public Builder setAvatar(String avatar) { |
||||
this.avatar = avatar; |
||||
return this; |
||||
} |
||||
|
||||
public Builder setCreatedAt(Date createdAt) { |
||||
this.createdAt = createdAt; |
||||
return this; |
||||
} |
||||
|
||||
public Builder setReblogsCount(int reblogsCount) { |
||||
this.reblogsCount = reblogsCount; |
||||
return this; |
||||
} |
||||
|
||||
public Builder setFavouritesCount(int favouritesCount) { |
||||
this.favouritesCount = favouritesCount; |
||||
return this; |
||||
} |
||||
|
||||
public Builder setInReplyToId(String inReplyToId) { |
||||
this.inReplyToId = inReplyToId; |
||||
return this; |
||||
} |
||||
|
||||
public Builder setMentions(Status.Mention[] mentions) { |
||||
this.mentions = mentions; |
||||
return this; |
||||
} |
||||
|
||||
public Builder setSenderId(String senderId) { |
||||
this.senderId = senderId; |
||||
return this; |
||||
} |
||||
|
||||
public Builder setRebloggingEnabled(boolean rebloggingEnabled) { |
||||
this.rebloggingEnabled = rebloggingEnabled; |
||||
return this; |
||||
} |
||||
|
||||
public Builder setApplication(Status.Application application) { |
||||
this.application = application; |
||||
return this; |
||||
} |
||||
|
||||
public Builder setStatusEmojis(List<Emoji> emojis) { |
||||
this.statusEmojis = emojis; |
||||
return this; |
||||
} |
||||
|
||||
public Builder setAccountEmojis(List<Emoji> emojis) { |
||||
this.accountEmojis = emojis; |
||||
return this; |
||||
} |
||||
|
||||
public Builder setRebloggedByEmojis(List<Emoji> emojis) { |
||||
this.rebloggedByAccountEmojis = emojis; |
||||
return this; |
||||
} |
||||
|
||||
public Builder setCard(Card card) { |
||||
this.card = card; |
||||
return this; |
||||
} |
||||
|
||||
/** |
||||
* Configure the {@link com.keylesspalace.tusky.viewdata.StatusViewData} to support collapsing |
||||
* its content limiting the visible length when collapsed at 500 characters, |
||||
* |
||||
* @param collapsible Whether the status should support being collapsed or not. |
||||
* @return This {@link com.keylesspalace.tusky.viewdata.StatusViewData.Builder} instance. |
||||
*/ |
||||
public Builder setCollapsible(boolean collapsible) { |
||||
isCollapsible = collapsible; |
||||
return this; |
||||
} |
||||
|
||||
/** |
||||
* Configure the {@link com.keylesspalace.tusky.viewdata.StatusViewData} to start in a collapsed |
||||
* state, hiding partially the content of the post if it exceeds a certain amount of characters. |
||||
* |
||||
* @param collapsed Whether to show the full content of the status or not. |
||||
* @return This {@link com.keylesspalace.tusky.viewdata.StatusViewData.Builder} instance. |
||||
*/ |
||||
public Builder setCollapsed(boolean collapsed) { |
||||
isCollapsed = collapsed; |
||||
return this; |
||||
} |
||||
|
||||
public Builder setPoll(Poll poll) { |
||||
this.poll = PollViewDataKt.toViewData(poll); |
||||
return this; |
||||
} |
||||
|
||||
public StatusViewData.Concrete createStatusViewData() { |
||||
if (this.statusEmojis == null) statusEmojis = Collections.emptyList(); |
||||
if (this.accountEmojis == null) accountEmojis = Collections.emptyList(); |
||||
if (this.createdAt == null) createdAt = new Date(); |
||||
|
||||
return new StatusViewData.Concrete(id, content, reblogged, favourited, bookmarked, muted, spoilerText, |
||||
visibility, attachments, rebloggedByUsername, rebloggedAvatar, isSensitive, isExpanded, |
||||
isShowingContent, userFullName, nickname, avatar, createdAt, reblogsCount, |
||||
favouritesCount, inReplyToId, mentions, senderId, rebloggingEnabled, application, |
||||
statusEmojis, accountEmojis, rebloggedByAccountEmojis, card, isCollapsible, isCollapsed, poll, isBot); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,144 @@
|
||||
/* 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.viewdata |
||||
|
||||
import android.os.Build |
||||
import android.text.SpannableStringBuilder |
||||
import android.text.Spanned |
||||
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 |
||||
|
||||
data class Concrete( |
||||
val status: Status, |
||||
val isExpanded: Boolean, |
||||
val isShowingContent: Boolean, |
||||
/** |
||||
* Specifies whether the content of this post is allowed to be collapsed or if it should show |
||||
* all content regardless. |
||||
* |
||||
* @return Whether the post is collapsible or never collapsed. |
||||
*/ |
||||
val isCollapsible: Boolean, |
||||
/** |
||||
* Specifies whether the content of this post is currently limited in visibility to the first |
||||
* 500 characters or not. |
||||
* |
||||
* @return Whether the post is collapsed or fully expanded. |
||||
*/ |
||||
/** Whether the status meets the requirement to be collapse */ |
||||
val isCollapsed: Boolean, |
||||
) : StatusViewData() { |
||||
override val viewDataId: Long |
||||
get() = status.id.hashCode().toLong() |
||||
|
||||
val content: Spanned |
||||
val spoilerText: String |
||||
val username: String |
||||
|
||||
val actionable: Status |
||||
get() = status.actionableStatus |
||||
|
||||
val rebloggedAvatar: String? |
||||
get() = status.reblog?.account?.avatar |
||||
|
||||
val rebloggingStatus: Status? |
||||
get() = if (status.reblog != null) status else null |
||||
|
||||
init { |
||||
if (Build.VERSION.SDK_INT == 23) { |
||||
// https://github.com/tuskyapp/Tusky/issues/563 |
||||
this.content = replaceCrashingCharacters(status.actionableStatus.content) |
||||
this.spoilerText = |
||||
replaceCrashingCharacters(status.actionableStatus.spoilerText).toString() |
||||
this.username = |
||||
replaceCrashingCharacters(status.actionableStatus.account.username).toString() |
||||
} else { |
||||
this.content = status.actionableStatus.content |
||||
this.spoilerText = status.actionableStatus.spoilerText |
||||
this.username = status.actionableStatus.account.username |
||||
} |
||||
} |
||||
|
||||
companion object { |
||||
private const val SOFT_HYPHEN = '\u00ad' |
||||
private const val ASCII_HYPHEN = '-' |
||||
fun replaceCrashingCharacters(content: Spanned): Spanned { |
||||
return replaceCrashingCharacters(content as CharSequence) as Spanned |
||||
} |
||||
|
||||
fun replaceCrashingCharacters(content: CharSequence?): CharSequence? { |
||||
var replacing = false |
||||
var builder: SpannableStringBuilder? = null |
||||
val length = content!!.length |
||||
for (index in 0 until length) { |
||||
val character = content[index] |
||||
|
||||
// If there are more than one or two, switch to a map |
||||
if (character == SOFT_HYPHEN) { |
||||
if (!replacing) { |
||||
replacing = true |
||||
builder = SpannableStringBuilder(content, 0, index) |
||||
} |
||||
builder!!.append(ASCII_HYPHEN) |
||||
} else if (replacing) { |
||||
builder!!.append(character) |
||||
} |
||||
} |
||||
return if (replacing) builder else content |
||||
} |
||||
} |
||||
|
||||
val id: String |
||||
get() = status.id |
||||
|
||||
/** Helper for Java */ |
||||
fun copyWithStatus(status: Status): Concrete { |
||||
return copy(status = status) |
||||
} |
||||
|
||||
/** Helper for Java */ |
||||
fun copyWithExpanded(isExpanded: Boolean): Concrete { |
||||
return copy(isExpanded = isExpanded) |
||||
} |
||||
|
||||
/** Helper for Java */ |
||||
fun copyWithShowingContent(isShowingContent: Boolean): Concrete { |
||||
return copy(isShowingContent = isShowingContent) |
||||
} |
||||
|
||||
/** Helper for Java */ |
||||
fun copyWIthCollapsed(isCollapsed: Boolean): Concrete { |
||||
return copy(isCollapsed = isCollapsed) |
||||
} |
||||
} |
||||
|
||||
data class Placeholder(val id: String, val isLoading: Boolean) : StatusViewData() { |
||||
override val viewDataId: Long |
||||
get() = id.hashCode().toLong() |
||||
} |
||||
|
||||
fun asStatusOrNull() = this as? Concrete |
||||
|
||||
fun asPlaceholderOrNull() = this as? Placeholder |
||||
} |
||||
@ -1,260 +1,186 @@
|
||||
package com.keylesspalace.tusky |
||||
|
||||
import android.os.Bundle |
||||
import android.text.SpannedString |
||||
import androidx.test.ext.junit.runners.AndroidJUnit4 |
||||
import com.keylesspalace.tusky.entity.Filter |
||||
import com.keylesspalace.tusky.entity.Poll |
||||
import com.keylesspalace.tusky.entity.PollOption |
||||
import com.keylesspalace.tusky.entity.Status |
||||
import com.keylesspalace.tusky.fragment.SFragment |
||||
import com.keylesspalace.tusky.network.FilterModel |
||||
import com.keylesspalace.tusky.network.MastodonApi |
||||
import com.nhaarman.mockitokotlin2.doReturn |
||||
import com.nhaarman.mockitokotlin2.mock |
||||
import okhttp3.Request |
||||
import okio.Timeout |
||||
import io.reactivex.rxjava3.core.Single |
||||
import org.junit.Assert.assertFalse |
||||
import org.junit.Assert.assertTrue |
||||
import org.junit.Before |
||||
import org.junit.Test |
||||
import org.junit.runner.RunWith |
||||
import org.mockito.Mockito |
||||
import org.robolectric.Robolectric |
||||
import org.robolectric.annotation.Config |
||||
import retrofit2.Call |
||||
import retrofit2.Callback |
||||
import retrofit2.Response |
||||
import java.util.* |
||||
|
||||
@Config(sdk = [28]) |
||||
@RunWith(AndroidJUnit4::class) |
||||
class FilterTest { |
||||
|
||||
private val fragment = FakeFragment() |
||||
lateinit var filterModel: FilterModel |
||||
|
||||
@Before |
||||
fun setup() { |
||||
filterModel = FilterModel() |
||||
val filters = listOf( |
||||
Filter( |
||||
id = "123", |
||||
phrase = "badWord", |
||||
context = listOf(Filter.HOME), |
||||
expiresAt = null, |
||||
irreversible = false, |
||||
wholeWord = false |
||||
), |
||||
Filter( |
||||
id = "123", |
||||
phrase = "badWholeWord", |
||||
context = listOf(Filter.HOME, Filter.PUBLIC), |
||||
expiresAt = null, |
||||
irreversible = false, |
||||
wholeWord = true |
||||
), |
||||
Filter( |
||||
id = "123", |
||||
phrase = "@twitter.com", |
||||
context = listOf(Filter.HOME), |
||||
expiresAt = null, |
||||
irreversible = false, |
||||
wholeWord = true |
||||
) |
||||
) |
||||
|
||||
val controller = Robolectric.buildActivity(FakeActivity::class.java) |
||||
val activity = controller.get() |
||||
|
||||
activity.accountManager = mock() |
||||
val apiMock = Mockito.mock(MastodonApi::class.java) |
||||
Mockito.`when`(apiMock.getFilters()).thenReturn(object: Call<List<Filter>> { |
||||
override fun isExecuted(): Boolean { |
||||
return false |
||||
} |
||||
override fun clone(): Call<List<Filter>> { |
||||
throw Error("not implemented") |
||||
} |
||||
override fun isCanceled(): Boolean { |
||||
throw Error("not implemented") |
||||
} |
||||
override fun cancel() { |
||||
throw Error("not implemented") |
||||
} |
||||
override fun execute(): Response<List<Filter>> { |
||||
throw Error("not implemented") |
||||
} |
||||
override fun request(): Request { |
||||
throw Error("not implemented") |
||||
} |
||||
|
||||
override fun enqueue(callback: Callback<List<Filter>>) { |
||||
callback.onResponse( |
||||
this, |
||||
Response.success( |
||||
listOf( |
||||
Filter( |
||||
id = "123", |
||||
phrase = "badWord", |
||||
context = listOf(Filter.HOME), |
||||
expiresAt = null, |
||||
irreversible = false, |
||||
wholeWord = false |
||||
), |
||||
Filter( |
||||
id = "123", |
||||
phrase = "badWholeWord", |
||||
context = listOf(Filter.HOME, Filter.PUBLIC), |
||||
expiresAt = null, |
||||
irreversible = false, |
||||
wholeWord = true |
||||
), |
||||
Filter( |
||||
id = "123", |
||||
phrase = "wrongContext", |
||||
context = listOf(Filter.PUBLIC), |
||||
expiresAt = null, |
||||
irreversible = false, |
||||
wholeWord = true |
||||
), |
||||
Filter( |
||||
id = "123", |
||||
phrase = "@twitter.com", |
||||
context = listOf(Filter.HOME), |
||||
expiresAt = null, |
||||
irreversible = false, |
||||
wholeWord = true |
||||
) |
||||
) |
||||
) |
||||
) |
||||
} |
||||
|
||||
override fun timeout(): Timeout { |
||||
throw Error("not implemented") |
||||
} |
||||
}) |
||||
|
||||
activity.mastodonApi = apiMock |
||||
|
||||
|
||||
controller.create().start() |
||||
|
||||
fragment.mastodonApi = apiMock |
||||
|
||||
|
||||
activity.supportFragmentManager.beginTransaction() |
||||
.replace(R.id.mainDrawerLayout, fragment, "fragment") |
||||
.commit() |
||||
|
||||
fragment.reloadFilters(false) |
||||
|
||||
filterModel.initWithFilters(filters) |
||||
} |
||||
|
||||
@Test |
||||
fun shouldNotFilter() { |
||||
assertFalse(fragment.shouldFilterStatus( |
||||
assertFalse( |
||||
filterModel.shouldFilterStatus( |
||||
mockStatus(content = "should not be filtered") |
||||
)) |
||||
} |
||||
|
||||
@Test |
||||
fun shouldNotFilter_whenContextDoesNotMatch() { |
||||
assertFalse(fragment.shouldFilterStatus( |
||||
mockStatus(content = "one two wrongContext three") |
||||
)) |
||||
) |
||||
) |
||||
} |
||||
|
||||
@Test |
||||
fun shouldFilter_whenContentMatchesBadWord() { |
||||
assertTrue(fragment.shouldFilterStatus( |
||||
assertTrue( |
||||
filterModel.shouldFilterStatus( |
||||
mockStatus(content = "one two badWord three") |
||||
)) |
||||
) |
||||
) |
||||
} |
||||
|
||||
@Test |
||||
fun shouldFilter_whenContentMatchesBadWordPart() { |
||||
assertTrue(fragment.shouldFilterStatus( |
||||
assertTrue( |
||||
filterModel.shouldFilterStatus( |
||||
mockStatus(content = "one two badWordPart three") |
||||
)) |
||||
) |
||||
) |
||||
} |
||||
|
||||
@Test |
||||
fun shouldFilter_whenContentMatchesBadWholeWord() { |
||||
assertTrue(fragment.shouldFilterStatus( |
||||
assertTrue( |
||||
filterModel.shouldFilterStatus( |
||||
mockStatus(content = "one two badWholeWord three") |
||||
)) |
||||
) |
||||
) |
||||
} |
||||
|
||||
@Test |
||||
fun shouldNotFilter_whenContentDoesNotMatchWholeWord() { |
||||
assertFalse(fragment.shouldFilterStatus( |
||||
assertFalse( |
||||
filterModel.shouldFilterStatus( |
||||
mockStatus(content = "one two badWholeWordTest three") |
||||
)) |
||||
) |
||||
) |
||||
} |
||||
|
||||
@Test |
||||
fun shouldFilter_whenSpoilerTextDoesMatch() { |
||||
assertTrue(fragment.shouldFilterStatus( |
||||
assertTrue( |
||||
filterModel.shouldFilterStatus( |
||||
mockStatus( |
||||
content = "should not be filtered", |
||||
spoilerText = "badWord should be filtered" |
||||
content = "should not be filtered", |
||||
spoilerText = "badWord should be filtered" |
||||
) |
||||
)) |
||||
) |
||||
) |
||||
} |
||||
|
||||
@Test |
||||
fun shouldFilter_whenPollTextDoesMatch() { |
||||
assertTrue(fragment.shouldFilterStatus( |
||||
assertTrue( |
||||
filterModel.shouldFilterStatus( |
||||
mockStatus( |
||||
content = "should not be filtered", |
||||
spoilerText = "should not be filtered", |
||||
pollOptions = listOf("should not be filtered", "badWord") |
||||
content = "should not be filtered", |
||||
spoilerText = "should not be filtered", |
||||
pollOptions = listOf("should not be filtered", "badWord") |
||||
) |
||||
)) |
||||
) |
||||
) |
||||
} |
||||
|
||||
@Test |
||||
fun shouldFilterPartialWord_whenWholeWordFilterContainsNonAlphanumericCharacters() { |
||||
assertTrue(fragment.shouldFilterStatus( |
||||
assertTrue( |
||||
filterModel.shouldFilterStatus( |
||||
mockStatus(content = "one two someone@twitter.com three") |
||||
)) |
||||
) |
||||
) |
||||
} |
||||
|
||||
private fun mockStatus( |
||||
content: String = "", |
||||
spoilerText: String = "", |
||||
pollOptions: List<String>? = null |
||||
content: String = "", |
||||
spoilerText: String = "", |
||||
pollOptions: List<String>? = null |
||||
): Status { |
||||
return Status( |
||||
id = "123", |
||||
url = "https://mastodon.social/@Tusky/100571663297225812", |
||||
account = mock(), |
||||
inReplyToId = null, |
||||
inReplyToAccountId = null, |
||||
reblog = null, |
||||
content = SpannedString(content), |
||||
createdAt = Date(), |
||||
emojis = emptyList(), |
||||
reblogsCount = 0, |
||||
favouritesCount = 0, |
||||
reblogged = false, |
||||
favourited = false, |
||||
bookmarked = false, |
||||
sensitive = false, |
||||
spoilerText = spoilerText, |
||||
visibility = Status.Visibility.PUBLIC, |
||||
attachments = arrayListOf(), |
||||
mentions = emptyArray(), |
||||
application = null, |
||||
pinned = false, |
||||
muted = false, |
||||
poll = if (pollOptions != null) { |
||||
Poll( |
||||
id = "1234", |
||||
expiresAt = null, |
||||
expired = false, |
||||
multiple = false, |
||||
votesCount = 0, |
||||
votersCount = 0, |
||||
options = pollOptions.map { |
||||
PollOption(it, 0) |
||||
}, |
||||
voted = false |
||||
) |
||||
} else null, |
||||
card = null |
||||
id = "123", |
||||
url = "https://mastodon.social/@Tusky/100571663297225812", |
||||
account = mock(), |
||||
inReplyToId = null, |
||||
inReplyToAccountId = null, |
||||
reblog = null, |
||||
content = SpannedString(content), |
||||
createdAt = Date(), |
||||
emojis = emptyList(), |
||||
reblogsCount = 0, |
||||
favouritesCount = 0, |
||||
reblogged = false, |
||||
favourited = false, |
||||
bookmarked = false, |
||||
sensitive = false, |
||||
spoilerText = spoilerText, |
||||
visibility = Status.Visibility.PUBLIC, |
||||
attachments = arrayListOf(), |
||||
mentions = listOf(), |
||||
application = null, |
||||
pinned = false, |
||||
muted = false, |
||||
poll = if (pollOptions != null) { |
||||
Poll( |
||||
id = "1234", |
||||
expiresAt = null, |
||||
expired = false, |
||||
multiple = false, |
||||
votesCount = 0, |
||||
votersCount = 0, |
||||
options = pollOptions.map { |
||||
PollOption(it, 0) |
||||
}, |
||||
voted = false |
||||
) |
||||
} else null, |
||||
card = null |
||||
) |
||||
} |
||||
|
||||
} |
||||
|
||||
class FakeActivity: BottomSheetActivity() { |
||||
override fun onCreate(savedInstanceState: Bundle?) { |
||||
super.onCreate(savedInstanceState) |
||||
setContentView(R.layout.activity_main) |
||||
} |
||||
} |
||||
|
||||
class FakeFragment: SFragment() { |
||||
override fun removeItem(position: Int) { |
||||
} |
||||
|
||||
override fun onReblog(reblog: Boolean, position: Int) { |
||||
} |
||||
|
||||
override fun filterIsRelevant(filter: Filter): Boolean { |
||||
return filter.context.contains(Filter.HOME) |
||||
} |
||||
} |
||||
@ -0,0 +1,783 @@
|
||||
package com.keylesspalace.tusky.components.timeline |
||||
|
||||
import android.content.SharedPreferences |
||||
import com.keylesspalace.tusky.appstore.EventHub |
||||
import com.keylesspalace.tusky.components.timeline.TimelineViewModel.Companion.LOAD_AT_ONCE |
||||
import com.keylesspalace.tusky.db.AccountEntity |
||||
import com.keylesspalace.tusky.db.AccountManager |
||||
import com.keylesspalace.tusky.entity.Poll |
||||
import com.keylesspalace.tusky.entity.PollOption |
||||
import com.keylesspalace.tusky.entity.Status |
||||
import com.keylesspalace.tusky.network.FilterModel |
||||
import com.keylesspalace.tusky.network.MastodonApi |
||||
import com.keylesspalace.tusky.network.TimelineCases |
||||
import com.keylesspalace.tusky.util.Either |
||||
import com.keylesspalace.tusky.util.toViewData |
||||
import com.keylesspalace.tusky.viewdata.StatusViewData |
||||
import com.nhaarman.mockitokotlin2.* |
||||
import io.reactivex.rxjava3.annotations.NonNull |
||||
import io.reactivex.rxjava3.core.Observable |
||||
import io.reactivex.rxjava3.core.Single |
||||
import io.reactivex.rxjava3.observers.TestObserver |
||||
import io.reactivex.rxjava3.subjects.PublishSubject |
||||
import kotlinx.coroutines.runBlocking |
||||
import org.junit.Assert.* |
||||
import org.junit.Before |
||||
import org.junit.Test |
||||
import org.robolectric.annotation.Config |
||||
import org.robolectric.shadows.ShadowLog |
||||
import retrofit2.Response |
||||
import java.io.IOException |
||||
|
||||
|
||||
@Config(sdk = [29]) |
||||
class TimelineViewModelTest { |
||||
lateinit var timelineRepository: TimelineRepository |
||||
lateinit var timelineCases: TimelineCases |
||||
lateinit var mastodonApi: MastodonApi |
||||
lateinit var eventHub: EventHub |
||||
lateinit var viewModel: TimelineViewModel |
||||
lateinit var accountManager: AccountManager |
||||
lateinit var sharedPreference: SharedPreferences |
||||
|
||||
@Before |
||||
fun setup() { |
||||
ShadowLog.stream = System.out |
||||
timelineRepository = mock() |
||||
timelineCases = mock() |
||||
mastodonApi = mock() |
||||
eventHub = mock { |
||||
on { events } doReturn Observable.never() |
||||
} |
||||
val account = AccountEntity( |
||||
0, |
||||
"domain", |
||||
"accessToken", |
||||
isActive = true, |
||||
) |
||||
|
||||
accountManager = mock { |
||||
on { activeAccount } doReturn account |
||||
} |
||||
sharedPreference = mock() |
||||
viewModel = TimelineViewModel( |
||||
timelineRepository, |
||||
timelineCases, |
||||
mastodonApi, |
||||
eventHub, |
||||
accountManager, |
||||
sharedPreference, |
||||
FilterModel() |
||||
) |
||||
} |
||||
|
||||
@Test |
||||
fun `loadInitial, home, without cache, empty response`() { |
||||
val initialResponse = listOf<Status>() |
||||
setCachedResponse(initialResponse) |
||||
|
||||
// loadAbove -> loadBelow |
||||
whenever( |
||||
timelineRepository.getStatuses( |
||||
maxId = null, |
||||
sinceId = null, |
||||
sincedIdMinusOne = null, |
||||
requestMode = TimelineRequestMode.ANY, |
||||
limit = LOAD_AT_ONCE |
||||
) |
||||
).thenReturn(Single.just(listOf())) |
||||
|
||||
runBlocking { |
||||
viewModel.loadInitial() |
||||
} |
||||
|
||||
verify(timelineRepository).getStatuses( |
||||
null, |
||||
null, |
||||
null, |
||||
LOAD_AT_ONCE, |
||||
TimelineRequestMode.ANY |
||||
) |
||||
} |
||||
|
||||
@Test |
||||
fun `loadInitial, home, without cache, single item in response`() { |
||||
setCachedResponse(listOf()) |
||||
|
||||
val status = makeStatus("1") |
||||
whenever( |
||||
timelineRepository.getStatuses( |
||||
isNull(), |
||||
isNull(), |
||||
isNull(), |
||||
eq(LOAD_AT_ONCE), |
||||
eq(TimelineRequestMode.ANY) |
||||
) |
||||
).thenReturn( |
||||
Single.just( |
||||
listOf( |
||||
Either.Right(status) |
||||
) |
||||
) |
||||
) |
||||
|
||||
val updates = viewModel.viewUpdates.test() |
||||
|
||||
runBlocking { |
||||
viewModel.loadInitial() |
||||
} |
||||
|
||||
verify(timelineRepository).getStatuses( |
||||
isNull(), |
||||
isNull(), |
||||
isNull(), |
||||
eq(LOAD_AT_ONCE), |
||||
eq(TimelineRequestMode.ANY) |
||||
) |
||||
|
||||
assertViewUpdated(updates) |
||||
|
||||
assertHasList(listOf(status).toViewData()) |
||||
} |
||||
|
||||
@Test |
||||
fun `loadInitial, list`() { |
||||
val listId = "listId" |
||||
viewModel.init(TimelineViewModel.Kind.LIST, listId, listOf()) |
||||
val status = makeStatus("1") |
||||
|
||||
whenever( |
||||
mastodonApi.listTimeline( |
||||
listId, |
||||
null, |
||||
null, |
||||
LOAD_AT_ONCE, |
||||
) |
||||
).thenReturn( |
||||
Single.just( |
||||
Response.success( |
||||
listOf( |
||||
status |
||||
) |
||||
) |
||||
) |
||||
) |
||||
|
||||
val updates = viewModel.viewUpdates.test() |
||||
|
||||
runBlocking { |
||||
viewModel.loadInitial().join() |
||||
} |
||||
assertViewUpdated(updates) |
||||
|
||||
assertHasList(listOf(status).toViewData()) |
||||
assertFalse("loading", viewModel.isLoadingInitially) |
||||
} |
||||
|
||||
@Test |
||||
fun `loadInitial, home, without cache, error on load`() { |
||||
setCachedResponse(listOf()) |
||||
|
||||
whenever( |
||||
timelineRepository.getStatuses( |
||||
maxId = null, |
||||
sinceId = null, |
||||
sincedIdMinusOne = null, |
||||
limit = LOAD_AT_ONCE, |
||||
TimelineRequestMode.ANY, |
||||
) |
||||
).thenReturn(Single.error(IOException("test"))) |
||||
|
||||
val updates = viewModel.viewUpdates.test() |
||||
|
||||
runBlocking { |
||||
viewModel.loadInitial() |
||||
} |
||||
|
||||
verify(timelineRepository).getStatuses( |
||||
isNull(), |
||||
isNull(), |
||||
isNull(), |
||||
eq(LOAD_AT_ONCE), |
||||
eq(TimelineRequestMode.ANY) |
||||
) |
||||
|
||||
assertViewUpdated(updates) |
||||
|
||||
assertHasList(listOf()) |
||||
assertEquals(TimelineViewModel.FailureReason.NETWORK, viewModel.failure) |
||||
} |
||||
|
||||
@Test |
||||
fun `loadInitial, home, with cache, error on load above`() { |
||||
val statuses = (5 downTo 1).map { makeStatus(it.toString()) } |
||||
setCachedResponse(statuses) |
||||
setInitialRefresh("6", statuses) |
||||
|
||||
whenever( |
||||
timelineRepository.getStatuses( |
||||
maxId = null, |
||||
sinceId = "5", |
||||
sincedIdMinusOne = "4", |
||||
limit = LOAD_AT_ONCE, |
||||
TimelineRequestMode.NETWORK, |
||||
) |
||||
).thenReturn(Single.error(IOException("test"))) |
||||
|
||||
val updates = viewModel.viewUpdates.test() |
||||
|
||||
runBlocking { |
||||
viewModel.loadInitial() |
||||
} |
||||
|
||||
assertViewUpdated(updates) |
||||
|
||||
assertHasList(statuses.toViewData()) |
||||
// No failure set since we had statuses |
||||
assertNull(viewModel.failure) |
||||
} |
||||
|
||||
@Test |
||||
fun `loadInitial, home, with cache, error on refresh`() { |
||||
val statuses = (5 downTo 2).map { makeStatus(it.toString()) } |
||||
setCachedResponse(statuses) |
||||
|
||||
// Error on refreshing cached |
||||
whenever( |
||||
timelineRepository.getStatuses( |
||||
maxId = "6", |
||||
sinceId = null, |
||||
sincedIdMinusOne = null, |
||||
limit = LOAD_AT_ONCE, |
||||
TimelineRequestMode.NETWORK, |
||||
) |
||||
).thenReturn(Single.error(IOException("test"))) |
||||
|
||||
// Empty on loading above |
||||
setLoadAbove("5", "4", listOf()) |
||||
|
||||
val updates = viewModel.viewUpdates.test() |
||||
|
||||
runBlocking { |
||||
viewModel.loadInitial() |
||||
} |
||||
|
||||
assertViewUpdated(updates) |
||||
|
||||
assertHasList(statuses.toViewData()) |
||||
assertNull(viewModel.failure) |
||||
} |
||||
|
||||
@Test |
||||
fun `loads above cached`() { |
||||
val cachedStatuses = (5 downTo 1).map { makeStatus(it.toString()) } |
||||
setCachedResponse(cachedStatuses) |
||||
setInitialRefresh("6", cachedStatuses) |
||||
|
||||
val additionalStatuses = (10 downTo 6) |
||||
.map { makeStatus(it.toString()) } |
||||
|
||||
whenever( |
||||
timelineRepository.getStatuses( |
||||
null, |
||||
"5", |
||||
"4", |
||||
LOAD_AT_ONCE, |
||||
TimelineRequestMode.NETWORK |
||||
) |
||||
).thenReturn(Single.just(additionalStatuses.toEitherList())) |
||||
|
||||
runBlocking { |
||||
viewModel.loadInitial() |
||||
} |
||||
|
||||
// We could also check refresh progress here but it's a bit cumbersome |
||||
|
||||
assertHasList(additionalStatuses.plus(cachedStatuses).toViewData()) |
||||
} |
||||
|
||||
@Test |
||||
fun refresh() { |
||||
val cachedStatuses = (5 downTo 1).map { makeStatus(it.toString()) } |
||||
setCachedResponse(cachedStatuses) |
||||
setInitialRefresh("6", cachedStatuses) |
||||
|
||||
val additionalStatuses = listOf(makeStatus("6")) |
||||
|
||||
whenever( |
||||
timelineRepository.getStatuses( |
||||
null, |
||||
"5", |
||||
"4", |
||||
LOAD_AT_ONCE, |
||||
TimelineRequestMode.NETWORK |
||||
) |
||||
).thenReturn(Single.just(additionalStatuses.toEitherList())) |
||||
|
||||
runBlocking { |
||||
viewModel.loadInitial() |
||||
} |
||||
|
||||
clearInvocations(timelineRepository) |
||||
|
||||
val newStatuses = (8 downTo 7).map { makeStatus(it.toString()) } |
||||
|
||||
// Loading above the cached manually |
||||
whenever( |
||||
timelineRepository.getStatuses( |
||||
null, |
||||
"6", |
||||
"5", |
||||
LOAD_AT_ONCE, |
||||
TimelineRequestMode.NETWORK |
||||
) |
||||
).thenReturn(Single.just(newStatuses.toEitherList())) |
||||
|
||||
runBlocking { |
||||
viewModel.refresh() |
||||
} |
||||
|
||||
val allStatuses = newStatuses + additionalStatuses + cachedStatuses |
||||
assertHasList(allStatuses.toViewData()) |
||||
} |
||||
|
||||
@Test |
||||
fun `refresh failed`() { |
||||
val cachedStatuses = (5 downTo 1).map { makeStatus(it.toString()) } |
||||
setCachedResponse(cachedStatuses) |
||||
setInitialRefresh("6", cachedStatuses) |
||||
setLoadAbove("5", "4", listOf()) |
||||
|
||||
runBlocking { |
||||
viewModel.loadInitial() |
||||
} |
||||
|
||||
clearInvocations(timelineRepository) |
||||
|
||||
// Loading above the cached manually |
||||
whenever( |
||||
timelineRepository.getStatuses( |
||||
null, |
||||
"6", |
||||
"5", |
||||
LOAD_AT_ONCE, |
||||
TimelineRequestMode.NETWORK |
||||
) |
||||
).thenReturn(Single.error(IOException("test"))) |
||||
|
||||
runBlocking { |
||||
viewModel.refresh().join() |
||||
} |
||||
|
||||
assertHasList(cachedStatuses.map { it.toViewData(false, false) }) |
||||
assertFalse("refreshing", viewModel.isRefreshing) |
||||
assertNull("failure is not set", viewModel.failure) |
||||
} |
||||
|
||||
@Test |
||||
fun loadMore() { |
||||
val cachedStatuses = (10 downTo 5).map { makeStatus(it.toString()) } |
||||
setCachedResponse(cachedStatuses) |
||||
setInitialRefresh("11", cachedStatuses) |
||||
|
||||
// Nothing above |
||||
setLoadAbove("10", "9", listOf()) |
||||
|
||||
runBlocking { |
||||
viewModel.loadInitial().join() |
||||
} |
||||
|
||||
clearInvocations(timelineRepository) |
||||
|
||||
val oldStatuses = (4 downTo 1).map { makeStatus(it.toString()) } |
||||
|
||||
// Loading below the cached |
||||
whenever( |
||||
timelineRepository.getStatuses( |
||||
"5", |
||||
null, |
||||
null, |
||||
LOAD_AT_ONCE, |
||||
TimelineRequestMode.ANY |
||||
) |
||||
).thenReturn(Single.just(oldStatuses.toEitherList())) |
||||
|
||||
runBlocking { |
||||
viewModel.loadMore().join() |
||||
} |
||||
|
||||
val allStatuses = cachedStatuses + oldStatuses |
||||
assertHasList(allStatuses.toViewData()) |
||||
} |
||||
|
||||
@Test |
||||
fun `loadMore parallel`() { |
||||
val cachedStatuses = (10 downTo 5).map { makeStatus(it.toString()) } |
||||
setCachedResponse(cachedStatuses) |
||||
setInitialRefresh("11", cachedStatuses) |
||||
|
||||
// Nothing above |
||||
setLoadAbove("10", "9", listOf()) |
||||
|
||||
runBlocking { |
||||
viewModel.loadInitial().join() |
||||
} |
||||
|
||||
clearInvocations(timelineRepository) |
||||
|
||||
val oldStatuses = (4 downTo 1).map { makeStatus(it.toString()) } |
||||
|
||||
val responseSubject = PublishSubject.create<List<TimelineStatus>>() |
||||
// Loading below the cached |
||||
whenever( |
||||
timelineRepository.getStatuses( |
||||
"5", |
||||
null, |
||||
null, |
||||
LOAD_AT_ONCE, |
||||
TimelineRequestMode.ANY |
||||
) |
||||
).thenReturn(responseSubject.firstOrError()) |
||||
|
||||
clearInvocations(timelineRepository) |
||||
|
||||
runBlocking { |
||||
// Trigger them in parallel |
||||
val job1 = viewModel.loadMore() |
||||
val job2 = viewModel.loadMore() |
||||
// Send the response |
||||
responseSubject.onNext(oldStatuses.toEitherList()) |
||||
// Wait for both |
||||
job1.join() |
||||
job2.join() |
||||
} |
||||
|
||||
val allStatuses = cachedStatuses + oldStatuses |
||||
assertHasList(allStatuses.toViewData()) |
||||
|
||||
verify(timelineRepository, times(1)).getStatuses( |
||||
"5", |
||||
null, |
||||
null, |
||||
LOAD_AT_ONCE, |
||||
TimelineRequestMode.ANY |
||||
) |
||||
} |
||||
|
||||
@Test |
||||
fun `loadMore failed`() { |
||||
val cachedStatuses = (10 downTo 5).map { makeStatus(it.toString()) } |
||||
setCachedResponse(cachedStatuses) |
||||
setInitialRefresh("11", cachedStatuses) |
||||
|
||||
// Nothing above |
||||
setLoadAbove("10", "9", listOf()) |
||||
|
||||
runBlocking { |
||||
viewModel.loadInitial().join() |
||||
} |
||||
|
||||
clearInvocations(timelineRepository) |
||||
|
||||
// Loading below the cached |
||||
whenever( |
||||
timelineRepository.getStatuses( |
||||
"5", |
||||
null, |
||||
null, |
||||
LOAD_AT_ONCE, |
||||
TimelineRequestMode.ANY |
||||
) |
||||
).thenReturn(Single.error(IOException("test"))) |
||||
|
||||
runBlocking { |
||||
viewModel.loadMore().join() |
||||
} |
||||
|
||||
assertHasList(cachedStatuses.toViewData()) |
||||
|
||||
// Check that we can still load after that |
||||
|
||||
val oldStatuses = listOf(makeStatus("4")) |
||||
whenever( |
||||
timelineRepository.getStatuses( |
||||
"5", |
||||
null, |
||||
null, |
||||
LOAD_AT_ONCE, |
||||
TimelineRequestMode.ANY |
||||
) |
||||
).thenReturn(Single.just(oldStatuses.toEitherList())) |
||||
|
||||
runBlocking { |
||||
viewModel.loadMore().join() |
||||
} |
||||
assertHasList((cachedStatuses + oldStatuses).toViewData()) |
||||
} |
||||
|
||||
@Test |
||||
fun loadGap() { |
||||
val status5 = makeStatus("5") |
||||
val status4 = makeStatus("4") |
||||
val status3 = makeStatus("3") |
||||
val status1 = makeStatus("1") |
||||
|
||||
val cachedStatuses: List<TimelineStatus> = listOf( |
||||
Either.Right(status5), |
||||
Either.Left(Placeholder("4")), |
||||
Either.Right(status1) |
||||
) |
||||
val laterFetchedStatuses = listOf<TimelineStatus>( |
||||
Either.Right(status4), |
||||
Either.Right(status3), |
||||
) |
||||
|
||||
setCachedResponseWithGaps(cachedStatuses) |
||||
setInitialRefreshWithGaps("6", cachedStatuses) |
||||
|
||||
// Nothing above |
||||
setLoadAbove("5", items = listOf()) |
||||
|
||||
whenever( |
||||
timelineRepository.getStatuses( |
||||
"5", |
||||
"1", |
||||
null, |
||||
LOAD_AT_ONCE, |
||||
TimelineRequestMode.NETWORK |
||||
) |
||||
).thenReturn(Single.just(laterFetchedStatuses)) |
||||
|
||||
runBlocking { |
||||
viewModel.loadInitial().join() |
||||
|
||||
viewModel.loadGap(1).join() |
||||
} |
||||
|
||||
assertHasList( |
||||
listOf( |
||||
status5, |
||||
status4, |
||||
status3, |
||||
status1 |
||||
).toViewData() |
||||
) |
||||
} |
||||
|
||||
@Test |
||||
fun `loadGap failed`() { |
||||
val status5 = makeStatus("5") |
||||
val status1 = makeStatus("1") |
||||
|
||||
val cachedStatuses: List<TimelineStatus> = listOf( |
||||
Either.Right(status5), |
||||
Either.Left(Placeholder("4")), |
||||
Either.Right(status1) |
||||
) |
||||
setCachedResponseWithGaps(cachedStatuses) |
||||
setInitialRefreshWithGaps("6", cachedStatuses) |
||||
|
||||
setLoadAbove("5", items = listOf()) |
||||
|
||||
whenever( |
||||
timelineRepository.getStatuses( |
||||
"5", |
||||
"1", |
||||
null, |
||||
LOAD_AT_ONCE, |
||||
TimelineRequestMode.NETWORK |
||||
) |
||||
).thenReturn(Single.error(IOException("test"))) |
||||
|
||||
runBlocking { |
||||
viewModel.loadInitial().join() |
||||
|
||||
viewModel.loadGap(1).join() |
||||
} |
||||
|
||||
assertHasList( |
||||
listOf( |
||||
status5.toViewData(false, false), |
||||
StatusViewData.Placeholder("4", false), |
||||
status1.toViewData(false, false), |
||||
) |
||||
) |
||||
} |
||||
|
||||
@Test |
||||
fun favorite() { |
||||
val status5 = makeStatus("5") |
||||
val status4 = makeStatus("4") |
||||
val status3 = makeStatus("3") |
||||
val statuses = listOf(status5, status4, status3) |
||||
setCachedResponse(statuses) |
||||
setInitialRefresh("6", statuses) |
||||
setLoadAbove("5", "4", listOf()) |
||||
|
||||
runBlocking { viewModel.loadInitial() } |
||||
|
||||
whenever(timelineCases.favourite("4", true)) |
||||
.thenReturn(Single.just(status4.copy(favourited = true))) |
||||
|
||||
runBlocking { |
||||
viewModel.favorite(true, 1).join() |
||||
} |
||||
|
||||
verify(timelineCases).favourite("4", true) |
||||
|
||||
assertHasList(listOf(status5, status4.copy(favourited = true), status3).toViewData()) |
||||
} |
||||
|
||||
@Test |
||||
fun reblog() { |
||||
val status5 = makeStatus("5") |
||||
val status4 = makeStatus("4") |
||||
val status3 = makeStatus("3") |
||||
val statuses = listOf(status5, status4, status3) |
||||
setCachedResponse(statuses) |
||||
setInitialRefresh("6", statuses) |
||||
setLoadAbove("5", "4", listOf()) |
||||
|
||||
runBlocking { viewModel.loadInitial() } |
||||
|
||||
whenever(timelineCases.reblog("4", true)) |
||||
.thenReturn(Single.just(status4.copy(reblogged = true))) |
||||
|
||||
runBlocking { |
||||
viewModel.reblog(true, 1).join() |
||||
} |
||||
|
||||
verify(timelineCases).reblog("4", true) |
||||
|
||||
assertHasList(listOf(status5, status4.copy(reblogged = true), status3).toViewData()) |
||||
} |
||||
|
||||
@Test |
||||
fun bookmark() { |
||||
val status5 = makeStatus("5") |
||||
val status4 = makeStatus("4") |
||||
val status3 = makeStatus("3") |
||||
val statuses = listOf(status5, status4, status3) |
||||
setCachedResponse(statuses) |
||||
setInitialRefresh("6", statuses) |
||||
setLoadAbove("5", "4", listOf()) |
||||
|
||||
runBlocking { viewModel.loadInitial() } |
||||
|
||||
whenever(timelineCases.bookmark("4", true)) |
||||
.thenReturn(Single.just(status4.copy(bookmarked = true))) |
||||
|
||||
runBlocking { |
||||
viewModel.bookmark(true, 1).join() |
||||
} |
||||
|
||||
verify(timelineCases).bookmark("4", true) |
||||
|
||||
assertHasList(listOf(status5, status4.copy(bookmarked = true), status3).toViewData()) |
||||
} |
||||
|
||||
@Test |
||||
fun voteInPoll() { |
||||
val status5 = makeStatus("5") |
||||
val poll = Poll( |
||||
"1", |
||||
expiresAt = null, |
||||
expired = false, |
||||
multiple = false, |
||||
votersCount = 1, |
||||
votesCount = 1, |
||||
voted = false, |
||||
options = listOf(PollOption("1", 1), PollOption("2", 2)), |
||||
) |
||||
val status4 = makeStatus("4").copy(poll = poll) |
||||
val status3 = makeStatus("3") |
||||
val statuses = listOf(status5, status4, status3) |
||||
setCachedResponse(statuses) |
||||
setInitialRefresh("6", statuses) |
||||
setLoadAbove("5", "4", listOf()) |
||||
|
||||
runBlocking { viewModel.loadInitial() } |
||||
|
||||
val votedPoll = poll.votedCopy(listOf(0)) |
||||
whenever(timelineCases.voteInPoll("4", poll.id, listOf(0))) |
||||
.thenReturn(Single.just(votedPoll)) |
||||
|
||||
runBlocking { |
||||
viewModel.voteInPoll(1, listOf(0)).join() |
||||
} |
||||
|
||||
verify(timelineCases).voteInPoll("4", poll.id, listOf(0)) |
||||
|
||||
assertHasList(listOf(status5, status4.copy(poll = votedPoll), status3).toViewData()) |
||||
} |
||||
|
||||
private fun setLoadAbove( |
||||
above: String, |
||||
aboveMinusOne: String? = null, |
||||
items: List<TimelineStatus> |
||||
) { |
||||
whenever( |
||||
timelineRepository.getStatuses( |
||||
null, |
||||
above, |
||||
aboveMinusOne, |
||||
LOAD_AT_ONCE, |
||||
TimelineRequestMode.NETWORK |
||||
) |
||||
).thenReturn(Single.just(items)) |
||||
} |
||||
|
||||
|
||||
private fun assertHasList(aList: List<StatusViewData>) { |
||||
assertEquals( |
||||
aList, |
||||
viewModel.statuses.toList() |
||||
) |
||||
} |
||||
|
||||
private fun assertViewUpdated(updates: @NonNull TestObserver<Unit>) { |
||||
assertTrue("There were view updates", updates.values().isNotEmpty()) |
||||
} |
||||
|
||||
private fun setInitialRefresh(maxId: String?, statuses: List<Status>) { |
||||
setInitialRefreshWithGaps(maxId, statuses.toEitherList()) |
||||
} |
||||
|
||||
private fun setCachedResponse(initialResponse: List<Status>) { |
||||
setCachedResponseWithGaps(initialResponse.toEitherList()) |
||||
} |
||||
|
||||
private fun setCachedResponseWithGaps(initialResponse: List<TimelineStatus>) { |
||||
whenever( |
||||
timelineRepository.getStatuses( |
||||
isNull(), |
||||
isNull(), |
||||
isNull(), |
||||
eq(LOAD_AT_ONCE), |
||||
eq(TimelineRequestMode.DISK) |
||||
) |
||||
) |
||||
.thenReturn(Single.just(initialResponse)) |
||||
} |
||||
|
||||
private fun setInitialRefreshWithGaps(maxId: String?, statuses: List<TimelineStatus>) { |
||||
whenever( |
||||
timelineRepository.getStatuses( |
||||
maxId, |
||||
null, |
||||
null, |
||||
LOAD_AT_ONCE, |
||||
TimelineRequestMode.NETWORK |
||||
) |
||||
).thenReturn(Single.just(statuses)) |
||||
} |
||||
|
||||
private fun List<Status>.toViewData(): List<StatusViewData> = map { |
||||
it.toViewData( |
||||
alwaysShowSensitiveMedia = false, |
||||
alwaysOpenSpoiler = false |
||||
) |
||||
} |
||||
|
||||
private fun List<Status>.toEitherList() = map { Either.Right<Placeholder, Status>(it) } |
||||
} |
||||
Loading…
Reference in new issue