Timeline paging (#2238)

* first setup

* network timeline paging / improvements

* rename classes / move to correct package

* remove unused class TimelineAdapter

* some code cleanup

* remove TimelineRepository, put mapper functions in TimelineTypeMappers.kt

* add db migration

* cleanup unused code

* bugfix

* make default timeline settings work again

* fix pinning statuses from timeline

* fix network timeline

* respect account settings in NetworkTimelineRemoteMediator

* respect account settings in NetworkTimelineRemoteMediator

* update license headers

* show error view when an error occurs

* cleanup some todos

* fix db migration

* fix changing mediaPreviewEnabled setting

* fix "load more" button appearing on top of timeline

* fix filtering and other bugs

* cleanup cache after 14 days

* fix TimelineDAOTest

* fix code formatting

* add NetworkTimeline unit tests

* add CachedTimeline unit tests

* fix code formatting

* move TimelineDaoTest to unit tests

* implement removeAllByInstance for CachedTimelineViewModel

* fix code formatting

* fix bug in TimelineDao.deleteAllFromInstance

* improve loading more statuses in NetworkTimelineViewModel

* improve loading more statuses in NetworkTimelineViewModel

* fix bug where empty state was shown too soon

* reload top of cached timeline on app start

* improve CachedTimelineRemoteMediator and Tests

* improve cached timeline tests

* fix some more todos

* implement TimelineFragment.removeItem

* fix ListStatusAccessibilityDelegate

* fix crash in NetworkTimelineViewModel.loadMore

* fix default state of collapsible statuses

* fix default state of collapsible statuses -tests

* fix showing/hiding media in the timeline

* get rid of some not-null assertion operators in TimelineTypeMappers

* fix tests

* error handling in CachedTimelineViewModel.loadMore

* keep local status state when refreshing cached statuses

* keep local status state when refreshing network timeline statuses

* show placeholder loading state in cached timeline

* better comments, some code cleanup

* add TimelineViewModelTest, improve code, fix bug

* fix ktlint

* fix voting in boosted polls

* code improvement
This commit is contained in:
Konrad Pozniak 2022-01-11 19:00:29 +01:00 committed by GitHub
commit 643e012b11
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 4019 additions and 3146 deletions

View file

@ -1,138 +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.components.timeline;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.adapter.PlaceholderViewHolder;
import com.keylesspalace.tusky.adapter.StatusViewHolder;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.StatusDisplayOptions;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.util.List;
public final class TimelineAdapter extends RecyclerView.Adapter {
public interface AdapterDataSource<T> {
int getItemCount();
T getItemAt(int pos);
}
private static final int VIEW_TYPE_STATUS = 0;
private static final int VIEW_TYPE_PLACEHOLDER = 2;
private final AdapterDataSource<StatusViewData> dataSource;
private StatusDisplayOptions statusDisplayOptions;
private final StatusActionListener statusListener;
public TimelineAdapter(AdapterDataSource<StatusViewData> dataSource,
StatusDisplayOptions statusDisplayOptions,
StatusActionListener statusListener) {
this.dataSource = dataSource;
this.statusDisplayOptions = statusDisplayOptions;
this.statusListener = statusListener;
}
public boolean getMediaPreviewEnabled() {
return statusDisplayOptions.mediaPreviewEnabled();
}
public void setMediaPreviewEnabled(boolean mediaPreviewEnabled) {
this.statusDisplayOptions = statusDisplayOptions.copy(
statusDisplayOptions.animateAvatars(),
mediaPreviewEnabled,
statusDisplayOptions.useAbsoluteTime(),
statusDisplayOptions.showBotOverlay(),
statusDisplayOptions.useBlurhash(),
statusDisplayOptions.cardViewMode(),
statusDisplayOptions.confirmReblogs(),
statusDisplayOptions.confirmFavourites(),
statusDisplayOptions.hideStats(),
statusDisplayOptions.animateEmojis()
);
}
@NonNull
@Override
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int viewType) {
switch (viewType) {
default:
case VIEW_TYPE_STATUS: {
View view = LayoutInflater.from(viewGroup.getContext())
.inflate(R.layout.item_status, viewGroup, false);
return new StatusViewHolder(view);
}
case VIEW_TYPE_PLACEHOLDER: {
View view = LayoutInflater.from(viewGroup.getContext())
.inflate(R.layout.item_status_placeholder, viewGroup, false);
return new PlaceholderViewHolder(view);
}
}
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
bindViewHolder(viewHolder, position, null);
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @NonNull List payloads) {
bindViewHolder(viewHolder, position, payloads);
}
private void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @Nullable List payloads) {
StatusViewData status = dataSource.getItemAt(position);
if (status instanceof StatusViewData.Placeholder) {
PlaceholderViewHolder holder = (PlaceholderViewHolder) viewHolder;
holder.setup(statusListener, ((StatusViewData.Placeholder) status).isLoading());
} else if (status instanceof StatusViewData.Concrete) {
StatusViewHolder holder = (StatusViewHolder) viewHolder;
holder.setupWithStatus((StatusViewData.Concrete) status,
statusListener,
statusDisplayOptions,
payloads != null && !payloads.isEmpty() ? payloads.get(0) : null);
}
}
@Override
public int getItemCount() {
return dataSource.getItemCount();
}
@Override
public int getItemViewType(int position) {
if (dataSource.getItemAt(position) instanceof StatusViewData.Placeholder) {
return VIEW_TYPE_PLACEHOLDER;
} else {
return VIEW_TYPE_STATUS;
}
}
@Override
public long getItemId(int position) {
return dataSource.getItemAt(position).getViewDataId();
}
}

View file

@ -22,15 +22,13 @@ 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.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.paging.LoadState
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListUpdateCallback
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
@ -40,13 +38,17 @@ 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.appstore.StatusComposedEvent
import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel
import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
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.entity.Status
import com.keylesspalace.tusky.fragment.SFragment
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import com.keylesspalace.tusky.interfaces.RefreshableFragment
@ -59,12 +61,12 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.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 kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.io.IOException
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@ -85,25 +87,33 @@ class TimelineFragment :
@Inject
lateinit var accountManager: AccountManager
private val viewModel: TimelineViewModel by viewModels { viewModelFactory }
private val viewModel: TimelineViewModel by lazy {
if (kind == TimelineViewModel.Kind.HOME) {
ViewModelProvider(this, viewModelFactory).get(CachedTimelineViewModel::class.java)
} else {
ViewModelProvider(this, viewModelFactory).get(NetworkTimelineViewModel::class.java)
}
}
private val binding by viewBinding(FragmentTimelineBinding::bind)
private lateinit var adapter: TimelineAdapter
private lateinit var kind: TimelineViewModel.Kind
private lateinit var adapter: TimelinePagingAdapter
private var isSwipeToRefreshEnabled = true
private var eventRegistered = false
private var layoutManager: LinearLayoutManager? = null
private var scrollListener: EndlessOnScrollListener? = null
private var scrollListener: RecyclerView.OnScrollListener? = 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)!!)
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 ||
@ -125,11 +135,6 @@ class TimelineFragment :
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)
@ -149,8 +154,7 @@ class TimelineFragment :
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
)
adapter = TimelineAdapter(
dataSource,
adapter = TimelinePagingAdapter(
statusDisplayOptions,
this
)
@ -167,8 +171,56 @@ class TimelineFragment :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
setupSwipeRefreshLayout()
setupRecyclerView()
updateViews()
viewModel.loadInitial()
adapter.addLoadStateListener { loadState ->
if (loadState.refresh != LoadState.Loading) {
binding.swipeRefreshLayout.isRefreshing = false
}
binding.statusView.hide()
binding.progressBar.hide()
if (adapter.itemCount == 0) {
when (loadState.refresh) {
is LoadState.NotLoading -> {
if (loadState.append is LoadState.NotLoading) {
binding.statusView.show()
binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null)
}
}
is LoadState.Error -> {
binding.statusView.show()
if ((loadState.refresh as LoadState.Error).error is IOException) {
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, null)
} else {
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, null)
}
}
is LoadState.Loading -> {
binding.progressBar.show()
}
}
}
}
adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
if (positionStart == 0 && adapter.itemCount != itemCount) {
binding.recyclerView.post {
if (isSwipeToRefreshEnabled) {
binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30))
} else binding.recyclerView.scrollToPosition(0)
}
}
}
})
lifecycleScope.launch {
viewModel.statuses.collectLatest { pagingData ->
adapter.submitData(pagingData)
}
}
}
private fun setupSwipeRefreshLayout() {
@ -179,7 +231,9 @@ class TimelineFragment :
private fun setupRecyclerView() {
binding.recyclerView.setAccessibilityDelegateCompat(
ListStatusAccessibilityDelegate(binding.recyclerView, this) { pos -> viewModel.statuses.getOrNull(pos) }
ListStatusAccessibilityDelegate(binding.recyclerView, this) { pos ->
adapter.peek(pos)
}
)
binding.recyclerView.setHasFixedSize(true)
layoutManager = LinearLayoutManager(context)
@ -192,24 +246,16 @@ class TimelineFragment :
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. */
if (actionButtonPresent()) {
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
hideFab = preferences.getBoolean("fabHide", false)
object : EndlessOnScrollListener(layoutManager) {
scrollListener = object : RecyclerView.OnScrollListener() {
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) {
@ -223,20 +269,9 @@ class TimelineFragment :
}
}
}
override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) {
this@TimelineFragment.onLoadMore()
}
}.also {
binding.recyclerView.addOnScrollListener(it)
}
} 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) {
@ -248,6 +283,10 @@ class TimelineFragment :
is PreferenceChangedEvent -> {
onPreferenceChanged(event.preferenceKey)
}
is StatusComposedEvent -> {
val status = event.status
handleStatusComposeEvent(status)
}
}
}
eventRegistered = true
@ -255,75 +294,80 @@ class TimelineFragment :
}
override fun onRefresh() {
binding.swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled
binding.statusView.hide()
viewModel.refresh()
adapter.refresh()
}
override fun onReply(position: Int) {
val status = viewModel.statuses[position].asStatusOrNull() ?: return
val status = adapter.peek(position)?.asStatusOrNull() ?: return
super.reply(status.status)
}
override fun onReblog(reblog: Boolean, position: Int) {
viewModel.reblog(reblog, position)
val status = adapter.peek(position)?.asStatusOrNull() ?: return
viewModel.reblog(reblog, status)
}
override fun onFavourite(favourite: Boolean, position: Int) {
viewModel.favorite(favourite, position)
val status = adapter.peek(position)?.asStatusOrNull() ?: return
viewModel.favorite(favourite, status)
}
override fun onBookmark(bookmark: Boolean, position: Int) {
viewModel.bookmark(bookmark, position)
val status = adapter.peek(position)?.asStatusOrNull() ?: return
viewModel.bookmark(bookmark, status)
}
override fun onVoteInPoll(position: Int, choices: List<Int>) {
viewModel.voteInPoll(position, choices)
val status = adapter.peek(position)?.asStatusOrNull() ?: return
viewModel.voteInPoll(choices, status)
}
override fun onMore(view: View, position: Int) {
val status = viewModel.statuses[position].asStatusOrNull()?.status ?: return
super.more(status, view, position)
val status = adapter.peek(position)?.asStatusOrNull() ?: return
super.more(status.status, view, position)
}
override fun onOpenReblog(position: Int) {
val status = viewModel.statuses[position].asStatusOrNull()?.status ?: return
super.openReblog(status)
val status = adapter.peek(position)?.asStatusOrNull() ?: return
super.openReblog(status.status)
}
override fun onExpandedChange(expanded: Boolean, position: Int) {
viewModel.changeExpanded(expanded, position)
updateViews()
val status = adapter.peek(position)?.asStatusOrNull() ?: return
viewModel.changeExpanded(expanded, status)
}
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
viewModel.changeContentHidden(isShowing, position)
updateViews()
val status = adapter.peek(position)?.asStatusOrNull() ?: return
viewModel.changeContentShowing(isShowing, status)
}
override fun onShowReblogs(position: Int) {
val statusId = viewModel.statuses[position].asStatusOrNull()?.id ?: return
val statusId = adapter.peek(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 statusId = adapter.peek(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)
val placeholder = adapter.peek(position)?.asPlaceholderOrNull() ?: return
viewModel.loadMore(placeholder.id)
}
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
viewModel.changeContentCollapsed(isCollapsed, position)
val status = adapter.peek(position)?.asStatusOrNull() ?: return
viewModel.changeContentCollapsed(isCollapsed, status)
}
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
val status = viewModel.statuses[position].asStatusOrNull() ?: return
val status = adapter.peek(position)?.asStatusOrNull() ?: return
super.viewMedia(
attachmentIndex,
AttachmentViewData.list(status.actionable),
@ -332,7 +376,7 @@ class TimelineFragment :
}
override fun onViewThread(position: Int) {
val status = viewModel.statuses[position].asStatusOrNull() ?: return
val status = adapter.peek(position)?.asStatusOrNull() ?: return
super.viewThread(status.actionable.id, status.actionable.url)
}
@ -371,19 +415,32 @@ class TimelineFragment :
val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled
if (enabled != oldMediaPreviewEnabled) {
adapter.mediaPreviewEnabled = enabled
updateViews()
adapter.notifyDataSetChanged()
}
}
}
}
public override fun removeItem(position: Int) {
viewModel.statuses.removeAt(position)
updateViews()
private fun handleStatusComposeEvent(status: Status) {
when (kind) {
TimelineViewModel.Kind.HOME,
TimelineViewModel.Kind.PUBLIC_FEDERATED,
TimelineViewModel.Kind.PUBLIC_LOCAL -> adapter.refresh()
TimelineViewModel.Kind.USER,
TimelineViewModel.Kind.USER_WITH_REPLIES -> if (status.account.id == viewModel.id) {
adapter.refresh()
}
TimelineViewModel.Kind.TAG,
TimelineViewModel.Kind.FAVOURITES,
TimelineViewModel.Kind.LIST,
TimelineViewModel.Kind.BOOKMARKS,
TimelineViewModel.Kind.USER_PINNED -> return
}
}
private fun onLoadMore() {
viewModel.loadMore()
public override fun removeItem(position: Int) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
viewModel.removeStatusWithId(status.id)
}
private fun actionButtonPresent(): Boolean {
@ -393,86 +450,6 @@ class TimelineFragment :
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() {
@ -501,7 +478,9 @@ class TimelineFragment :
Observable.interval(1, TimeUnit.MINUTES)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_PAUSE)
.subscribe { updateViews() }
.subscribe {
adapter.notifyDataSetChanged()
}
}
}
@ -509,7 +488,6 @@ class TimelineFragment :
if (isAdded) {
layoutManager!!.scrollToPosition(0)
binding.recyclerView.stopScroll()
scrollListener!!.reset()
}
}
@ -548,33 +526,5 @@ class TimelineFragment :
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
}
}
}
}

View file

@ -0,0 +1,135 @@
/* 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.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.PlaceholderViewHolder
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
import com.keylesspalace.tusky.adapter.StatusViewHolder
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.viewdata.StatusViewData
class TimelinePagingAdapter(
private var statusDisplayOptions: StatusDisplayOptions,
private val statusListener: StatusActionListener
) : PagingDataAdapter<StatusViewData, RecyclerView.ViewHolder>(TimelineDifferCallback) {
var mediaPreviewEnabled: Boolean
get() = statusDisplayOptions.mediaPreviewEnabled
set(mediaPreviewEnabled) {
statusDisplayOptions = statusDisplayOptions.copy(
mediaPreviewEnabled = mediaPreviewEnabled
)
}
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
VIEW_TYPE_STATUS -> {
val view = LayoutInflater.from(viewGroup.context)
.inflate(R.layout.item_status, viewGroup, false)
StatusViewHolder(view)
}
VIEW_TYPE_PLACEHOLDER -> {
val view = LayoutInflater.from(viewGroup.context)
.inflate(R.layout.item_status_placeholder, viewGroup, false)
PlaceholderViewHolder(view)
}
else -> {
val view = LayoutInflater.from(viewGroup.context)
.inflate(R.layout.item_status, viewGroup, false)
StatusViewHolder(view)
}
}
}
override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) {
bindViewHolder(viewHolder, position, null)
}
override fun onBindViewHolder(
viewHolder: RecyclerView.ViewHolder,
position: Int,
payloads: List<*>
) {
bindViewHolder(viewHolder, position, payloads)
}
private fun bindViewHolder(
viewHolder: RecyclerView.ViewHolder,
position: Int,
payloads: List<*>?
) {
val status = getItem(position)
if (status is StatusViewData.Placeholder) {
val holder = viewHolder as PlaceholderViewHolder
holder.setup(statusListener, status.isLoading)
} else if (status is StatusViewData.Concrete) {
val holder = viewHolder as StatusViewHolder
holder.setupWithStatus(
status,
statusListener,
statusDisplayOptions,
if (payloads != null && payloads.isNotEmpty()) payloads[0] else null
)
}
}
override fun getItemViewType(position: Int): Int {
return if (getItem(position) is StatusViewData.Placeholder) {
VIEW_TYPE_PLACEHOLDER
} else {
VIEW_TYPE_STATUS
}
}
companion object {
private const val VIEW_TYPE_STATUS = 0
private const val VIEW_TYPE_PLACEHOLDER = 2
val TimelineDifferCallback = 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
}
}
}
}

View file

@ -1,435 +0,0 @@
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.components.timeline.TimelineRequestMode.DISK
import com.keylesspalace.tusky.components.timeline.TimelineRequestMode.NETWORK
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.TimelineAccountEntity
import com.keylesspalace.tusky.db.TimelineDao
import com.keylesspalace.tusky.db.TimelineStatusEntity
import com.keylesspalace.tusky.db.TimelineStatusWithAccount
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
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.Date
import java.util.concurrent.TimeUnit
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)

View file

@ -0,0 +1,256 @@
/* 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.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.TimelineAccountEntity
import com.keylesspalace.tusky.db.TimelineStatusEntity
import com.keylesspalace.tusky.db.TimelineStatusWithAccount
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.shouldTrimStatus
import com.keylesspalace.tusky.util.trimTrailingWhitespace
import com.keylesspalace.tusky.viewdata.StatusViewData
import java.util.Date
data class Placeholder(
val id: String,
val loading: Boolean
)
private val attachmentArrayListType = object : TypeToken<ArrayList<Attachment>>() {}.type
private val emojisListType = object : TypeToken<List<Emoji>>() {}.type
private val mentionListType = object : TypeToken<List<Status.Mention>>() {}.type
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(emojis, emojisListType),
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 = "",
visibility = Status.Visibility.UNKNOWN,
attachments = null,
mentions = null,
application = null,
reblogServerId = null,
reblogAccountId = null,
poll = null,
muted = false,
expanded = loading,
contentCollapsed = false,
contentShowing = false,
pinned = false
)
}
fun Status.toEntity(
timelineUserId: Long,
gson: Gson,
expanded: Boolean,
contentShowing: Boolean,
contentCollapsed: Boolean
): TimelineStatusEntity {
return TimelineStatusEntity(
serverId = this.id,
url = actionableStatus.url,
timelineUserId = timelineUserId,
authorServerId = actionableStatus.account.id,
inReplyToId = actionableStatus.inReplyToId,
inReplyToAccountId = actionableStatus.inReplyToAccountId,
content = actionableStatus.content.toHtml(),
createdAt = actionableStatus.createdAt.time,
emojis = actionableStatus.emojis.let(gson::toJson),
reblogsCount = actionableStatus.reblogsCount,
favouritesCount = actionableStatus.favouritesCount,
reblogged = actionableStatus.reblogged,
favourited = actionableStatus.favourited,
bookmarked = actionableStatus.bookmarked,
sensitive = actionableStatus.sensitive,
spoilerText = actionableStatus.spoilerText,
visibility = actionableStatus.visibility,
attachments = actionableStatus.attachments.let(gson::toJson),
mentions = actionableStatus.mentions.let(gson::toJson),
application = actionableStatus.application.let(gson::toJson),
reblogServerId = reblog?.id,
reblogAccountId = reblog?.let { this.account.id },
poll = actionableStatus.poll.let(gson::toJson),
muted = actionableStatus.muted,
expanded = expanded,
contentShowing = contentShowing,
contentCollapsed = contentCollapsed,
pinned = actionableStatus.pinned == true
)
}
fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
if (this.status.authorServerId == null) {
return StatusViewData.Placeholder(this.status.serverId, this.status.expanded)
}
val attachments: ArrayList<Attachment> = gson.fromJson(status.attachments, attachmentArrayListType) ?: arrayListOf()
val mentions: List<Status.Mention> = gson.fromJson(status.mentions, mentionListType) ?: emptyList()
val application = gson.fromJson(status.application, Status.Application::class.java)
val emojis: List<Emoji> = gson.fromJson(status.emojis, emojisListType) ?: emptyList()
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 = status.pinned,
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 = status.pinned,
muted = status.muted,
poll = poll,
card = null
)
}
return StatusViewData.Concrete(
status = status,
isExpanded = this.status.expanded,
isShowingContent = this.status.contentShowing,
isCollapsible = shouldTrimStatus(status.content),
isCollapsed = this.status.contentCollapsed
)
}

View file

@ -1,940 +0,0 @@
package com.keylesspalace.tusky.components.timeline
import android.content.SharedPreferences
import android.util.Log
import androidx.lifecycle.viewModelScope
import com.keylesspalace.tusky.appstore.BlockEvent
import com.keylesspalace.tusky.appstore.BookmarkEvent
import com.keylesspalace.tusky.appstore.DomainMuteEvent
import com.keylesspalace.tusky.appstore.Event
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.FavoriteEvent
import com.keylesspalace.tusky.appstore.MuteConversationEvent
import com.keylesspalace.tusky.appstore.MuteEvent
import com.keylesspalace.tusky.appstore.PinEvent
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.appstore.ReblogEvent
import com.keylesspalace.tusky.appstore.StatusComposedEvent
import com.keylesspalace.tusky.appstore.StatusDeletedEvent
import com.keylesspalace.tusky.appstore.UnfollowEvent
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.Either
import com.keylesspalace.tusky.util.HttpHeaderLink
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.RxAwareViewModel
import com.keylesspalace.tusky.util.dec
import com.keylesspalace.tusky.util.firstIsInstanceOrNull
import com.keylesspalace.tusky.util.inc
import com.keylesspalace.tusky.util.isLessThan
import com.keylesspalace.tusky.util.toViewData
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.actionableId, reblog).await()
} catch (t: Exception) {
ifExpected(t) {
Log.d(TAG, "Failed to reblog status " + status.actionableId, t)
}
}
}
fun favorite(favorite: Boolean, position: Int): Job = viewModelScope.launch {
val status = statuses[position].asStatusOrNull() ?: return@launch
try {
timelineCases.favourite(status.actionableId, favorite).await()
} catch (t: Exception) {
ifExpected(t) {
Log.d(TAG, "Failed to favourite status " + status.actionableId, t)
}
}
}
fun bookmark(bookmark: Boolean, position: Int): Job = viewModelScope.launch {
val status = statuses[position].asStatusOrNull() ?: return@launch
try {
timelineCases.bookmark(status.actionableId, bookmark).await()
} catch (t: Exception) {
ifExpected(t) {
Log.d(TAG, "Failed to favourite status " + status.actionableId, 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.actionableId, poll.id, choices).await()
} catch (t: Exception) {
ifExpected(t) {
Log.d(TAG, "Failed to vote in poll: " + status.actionableId, 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 HttpException(response)
}
}.toMutableList()
filterStatuses(statuses)
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(mutableStatusResponse.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) {
updateActionableStatusById(favEvent.statusId) {
it.copy(favourited = favEvent.favourite)
}
}
private fun handleBookmarkEvent(bookmarkEvent: BookmarkEvent) {
updateActionableStatusById(bookmarkEvent.statusId) {
it.copy(bookmarked = bookmarkEvent.bookmark)
}
}
private fun handlePinEvent(pinEvent: PinEvent) {
updateActionableStatusById(pinEvent.statusId) {
it.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 updateActionableStatusById(
id: String,
updater: (Status) -> Status
) {
val pos = statuses.indexOfFirst { it.asStatusOrNull()?.id == id }
if (pos == -1) return
updateStatusAt(pos) {
if (it.status.reblog != null) {
it.copy(status = it.status.copy(reblog = updater(it.status.reblog)))
} else {
it.copy(status = updater(it.status))
}
}
}
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
}
}

View file

@ -0,0 +1,154 @@
/* 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.viewmodel
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import androidx.room.withTransaction
import com.google.gson.Gson
import com.keylesspalace.tusky.components.timeline.Placeholder
import com.keylesspalace.tusky.components.timeline.toEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.TimelineStatusEntity
import com.keylesspalace.tusky.db.TimelineStatusWithAccount
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.dec
import kotlinx.coroutines.rx3.await
import retrofit2.HttpException
@ExperimentalPagingApi
class CachedTimelineRemoteMediator(
accountManager: AccountManager,
private val api: MastodonApi,
private val db: AppDatabase,
private val gson: Gson
) : RemoteMediator<Int, TimelineStatusWithAccount>() {
private var initialRefresh = false
private val timelineDao = db.timelineDao()
private val activeAccount = accountManager.activeAccount!!
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, TimelineStatusWithAccount>
): MediatorResult {
try {
var dbEmpty = false
if (!initialRefresh && loadType == LoadType.REFRESH) {
val topId = timelineDao.getTopId(activeAccount.id)
topId?.let { cachedTopId ->
val statusResponse = api.homeTimeline(
maxId = cachedTopId,
limit = state.config.pageSize
).await()
val statuses = statusResponse.body()
if (statusResponse.isSuccessful && statuses != null) {
db.withTransaction {
replaceStatusRange(statuses, state)
}
}
}
initialRefresh = true
dbEmpty = topId == null
}
val statusResponse = when (loadType) {
LoadType.REFRESH -> {
api.homeTimeline(limit = state.config.pageSize).await()
}
LoadType.PREPEND -> {
return MediatorResult.Success(endOfPaginationReached = true)
}
LoadType.APPEND -> {
val maxId = state.pages.findLast { it.data.isNotEmpty() }?.data?.lastOrNull()?.status?.serverId
api.homeTimeline(maxId = maxId, limit = state.config.pageSize).await()
}
}
val statuses = statusResponse.body()
if (!statusResponse.isSuccessful || statuses == null) {
return MediatorResult.Error(HttpException(statusResponse))
}
db.withTransaction {
val overlappedStatuses = replaceStatusRange(statuses, state)
if (loadType == LoadType.REFRESH && overlappedStatuses == 0 && statuses.isNotEmpty() && !dbEmpty) {
timelineDao.insertStatus(
Placeholder(statuses.last().id.dec(), loading = false).toEntity(activeAccount.id)
)
}
}
return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty())
} catch (e: Exception) {
return MediatorResult.Error(e)
}
}
/**
* Deletes all statuses in a given range and inserts new statuses.
* This is necessary so statuses that have been deleted on the server are cleaned up.
* Should be run in a transaction as it executes multiple db updates
* @param statuses the new statuses
* @return the number of old statuses that have been cleared from the database
*/
private suspend fun replaceStatusRange(statuses: List<Status>, state: PagingState<Int, TimelineStatusWithAccount>): Int {
val overlappedStatuses = if (statuses.isNotEmpty()) {
timelineDao.deleteRange(activeAccount.id, statuses.last().id, statuses.first().id)
} else {
0
}
for (status in statuses) {
timelineDao.insertAccount(status.account.toEntity(activeAccount.id, gson))
status.reblog?.account?.toEntity(activeAccount.id, gson)?.let { rebloggedAccount ->
timelineDao.insertAccount(rebloggedAccount)
}
// check if we already have one of the newly loaded statuses cached locally
// in case we do, copy the local state (expanded, contentShowing, contentCollapsed) over so it doesn't get lost
var oldStatus: TimelineStatusEntity? = null
for (page in state.pages) {
oldStatus = page.data.find { s ->
s.status.serverId == status.id
}?.status
if (oldStatus != null) break
}
val expanded = oldStatus?.expanded ?: activeAccount.alwaysOpenSpoiler
val contentShowing = oldStatus?.contentShowing ?: activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive
val contentCollapsed = oldStatus?.contentCollapsed ?: true
timelineDao.insertStatus(
status.toEntity(
timelineUserId = activeAccount.id,
gson = gson,
expanded = expanded,
contentShowing = contentShowing,
contentCollapsed = contentCollapsed
)
)
}
return overlappedStatuses
}
}

View file

@ -0,0 +1,208 @@
/* 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.viewmodel
import android.content.SharedPreferences
import android.util.Log
import androidx.lifecycle.viewModelScope
import androidx.paging.ExperimentalPagingApi
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import androidx.paging.filter
import androidx.paging.map
import androidx.room.withTransaction
import com.google.gson.Gson
import com.keylesspalace.tusky.appstore.BookmarkEvent
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.FavoriteEvent
import com.keylesspalace.tusky.appstore.PinEvent
import com.keylesspalace.tusky.appstore.ReblogEvent
import com.keylesspalace.tusky.components.timeline.Placeholder
import com.keylesspalace.tusky.components.timeline.toEntity
import com.keylesspalace.tusky.components.timeline.toViewData
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases
import com.keylesspalace.tusky.util.dec
import com.keylesspalace.tusky.util.inc
import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
import retrofit2.HttpException
import javax.inject.Inject
/**
* TimelineViewModel that caches all statuses in a local database
*/
class CachedTimelineViewModel @Inject constructor(
timelineCases: TimelineCases,
private val api: MastodonApi,
eventHub: EventHub,
accountManager: AccountManager,
sharedPreferences: SharedPreferences,
filterModel: FilterModel,
private val db: AppDatabase,
private val gson: Gson
) : TimelineViewModel(timelineCases, api, eventHub, accountManager, sharedPreferences, filterModel) {
@ExperimentalPagingApi
override val statuses = Pager(
config = PagingConfig(pageSize = LOAD_AT_ONCE),
remoteMediator = CachedTimelineRemoteMediator(accountManager, api, db, gson),
pagingSourceFactory = { db.timelineDao().getStatusesForAccount(accountManager.activeAccount!!.id) }
).flow
.map { pagingData ->
pagingData.map { timelineStatus ->
timelineStatus.toViewData(gson)
}
}
.map { pagingData ->
pagingData.filter { statusViewData ->
!shouldFilterStatus(statusViewData)
}
}
.cachedIn(viewModelScope)
override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) {
// handled by CacheUpdater
}
override fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) {
viewModelScope.launch {
db.timelineDao().setExpanded(accountManager.activeAccount!!.id, status.id, expanded)
}
}
override fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) {
viewModelScope.launch {
db.timelineDao().setContentShowing(accountManager.activeAccount!!.id, status.id, isShowing)
}
}
override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) {
viewModelScope.launch {
db.timelineDao().setContentCollapsed(accountManager.activeAccount!!.id, status.id, isCollapsed)
}
}
override fun removeAllByAccountId(accountId: String) {
viewModelScope.launch {
db.timelineDao().removeAllByUser(accountManager.activeAccount!!.id, accountId)
}
}
override fun removeAllByInstance(instance: String) {
viewModelScope.launch {
db.timelineDao().deleteAllFromInstance(accountManager.activeAccount!!.id, instance)
}
}
override fun removeStatusWithId(id: String) {
// handled by CacheUpdater
}
override fun loadMore(placeholderId: String) {
viewModelScope.launch {
try {
val timelineDao = db.timelineDao()
val activeAccount = accountManager.activeAccount!!
timelineDao.insertStatus(Placeholder(placeholderId, loading = true).toEntity(activeAccount.id))
val response = api.homeTimeline(maxId = placeholderId.inc(), limit = 20).await()
val statuses = response.body()
if (!response.isSuccessful || statuses == null) {
loadMoreFailed(placeholderId, HttpException(response))
return@launch
}
db.withTransaction {
timelineDao.delete(activeAccount.id, placeholderId)
val overlappedStatuses = if (statuses.isNotEmpty()) {
timelineDao.deleteRange(activeAccount.id, statuses.last().id, statuses.first().id)
} else {
0
}
for (status in statuses) {
timelineDao.insertAccount(status.account.toEntity(activeAccount.id, gson))
status.reblog?.account?.toEntity(activeAccount.id, gson)?.let { rebloggedAccount ->
timelineDao.insertAccount(rebloggedAccount)
}
timelineDao.insertStatus(
status.toEntity(
timelineUserId = activeAccount.id,
gson = gson,
expanded = activeAccount.alwaysOpenSpoiler,
contentShowing = activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive,
contentCollapsed = true
)
)
}
if (overlappedStatuses == 0) {
timelineDao.insertStatus(
Placeholder(statuses.last().id.dec(), loading = false).toEntity(activeAccount.id)
)
}
}
} catch (e: java.lang.Exception) {
loadMoreFailed(placeholderId, e)
}
}
}
private suspend fun loadMoreFailed(placeholderId: String, e: Exception) {
Log.w("CachedTimelineVM", "failed loading statuses", e)
val activeAccount = accountManager.activeAccount!!
db.timelineDao().insertStatus(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id))
}
override fun handleReblogEvent(reblogEvent: ReblogEvent) {
// handled by CacheUpdater
}
override fun handleFavEvent(favEvent: FavoriteEvent) {
// handled by CacheUpdater
}
override fun handleBookmarkEvent(bookmarkEvent: BookmarkEvent) {
// handled by CacheUpdater
}
override fun handlePinEvent(pinEvent: PinEvent) {
// handled by CacheUpdater
}
override fun fullReload() {
viewModelScope.launch {
val activeAccount = accountManager.activeAccount!!
db.runInTransaction {
db.timelineDao().removeAllForAccount(activeAccount.id)
db.timelineDao().removeAllUsersForAccount(activeAccount.id)
}
}
}
}

View file

@ -0,0 +1,37 @@
/* 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.viewmodel
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.keylesspalace.tusky.viewdata.StatusViewData
class NetworkTimelinePagingSource(
private val viewModel: NetworkTimelineViewModel
) : PagingSource<String, StatusViewData>() {
override fun getRefreshKey(state: PagingState<String, StatusViewData>): String? = null
override suspend fun load(params: LoadParams<String>): LoadResult<String, StatusViewData> {
return if (params is LoadParams.Refresh) {
val list = viewModel.statusData.toList()
LoadResult.Page(list, null, viewModel.nextKey)
} else {
LoadResult.Page(emptyList(), null, null)
}
}
}

View file

@ -0,0 +1,109 @@
/* 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.viewmodel
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.util.HttpHeaderLink
import com.keylesspalace.tusky.util.dec
import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import retrofit2.HttpException
@ExperimentalPagingApi
class NetworkTimelineRemoteMediator(
private val accountManager: AccountManager,
private val viewModel: NetworkTimelineViewModel
) : RemoteMediator<String, StatusViewData>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<String, StatusViewData>
): MediatorResult {
try {
val statusResponse = when (loadType) {
LoadType.REFRESH -> {
viewModel.fetchStatusesForKind(null, null, limit = state.config.pageSize)
}
LoadType.PREPEND -> {
return MediatorResult.Success(endOfPaginationReached = true)
}
LoadType.APPEND -> {
val maxId = viewModel.nextKey
viewModel.fetchStatusesForKind(maxId, null, limit = state.config.pageSize)
}
}
val statuses = statusResponse.body()
if (!statusResponse.isSuccessful || statuses == null) {
return MediatorResult.Error(HttpException(statusResponse))
}
val activeAccount = accountManager.activeAccount!!
val data = statuses.map { status ->
val oldStatus = viewModel.statusData.find { s ->
s.asStatusOrNull()?.id == status.id
}?.asStatusOrNull()
val contentShowing = oldStatus?.isShowingContent ?: activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive
val expanded = oldStatus?.isExpanded ?: activeAccount.alwaysOpenSpoiler
val contentCollapsed = oldStatus?.isCollapsed ?: true
status.toViewData(
isShowingContent = contentShowing,
isExpanded = expanded,
isCollapsed = contentCollapsed
)
}
if (loadType == LoadType.REFRESH && viewModel.statusData.isNotEmpty()) {
val insertPlaceholder = if (statuses.isNotEmpty()) {
!viewModel.statusData.removeAll { statusViewData ->
statuses.any { status -> status.id == statusViewData.asStatusOrNull()?.id }
}
} else {
false
}
viewModel.statusData.addAll(0, data)
if (insertPlaceholder) {
viewModel.statusData.add(statuses.size, StatusViewData.Placeholder(statuses.last().id.dec(), false))
}
} else {
val linkHeader = statusResponse.headers()["Link"]
val links = HttpHeaderLink.parse(linkHeader)
val nextId = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id")
viewModel.nextKey = nextId
viewModel.statusData.addAll(data)
}
viewModel.currentSource?.invalidate()
return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty())
} catch (e: Exception) {
return MediatorResult.Error(e)
}
}
}

View file

@ -0,0 +1,302 @@
/* 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.viewmodel
import android.content.SharedPreferences
import android.util.Log
import androidx.lifecycle.viewModelScope
import androidx.paging.ExperimentalPagingApi
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import androidx.paging.filter
import com.keylesspalace.tusky.appstore.BookmarkEvent
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.FavoriteEvent
import com.keylesspalace.tusky.appstore.PinEvent
import com.keylesspalace.tusky.appstore.ReblogEvent
import com.keylesspalace.tusky.db.AccountManager
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.util.LinkHelper
import com.keylesspalace.tusky.util.inc
import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
import retrofit2.HttpException
import retrofit2.Response
import javax.inject.Inject
/**
* TimelineViewModel that caches all statuses in an in-memory list
*/
class NetworkTimelineViewModel @Inject constructor(
timelineCases: TimelineCases,
private val api: MastodonApi,
eventHub: EventHub,
accountManager: AccountManager,
sharedPreferences: SharedPreferences,
filterModel: FilterModel
) : TimelineViewModel(timelineCases, api, eventHub, accountManager, sharedPreferences, filterModel) {
var currentSource: NetworkTimelinePagingSource? = null
val statusData: MutableList<StatusViewData> = mutableListOf()
var nextKey: String? = null
@ExperimentalPagingApi
override val statuses = Pager(
config = PagingConfig(pageSize = LOAD_AT_ONCE),
pagingSourceFactory = {
NetworkTimelinePagingSource(
viewModel = this
).also { source ->
currentSource = source
}
},
remoteMediator = NetworkTimelineRemoteMediator(accountManager, this)
).flow
.map { pagingData ->
pagingData.filter { statusViewData ->
!shouldFilterStatus(statusViewData)
}
}
.cachedIn(viewModelScope)
override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) {
status.copy(
status = status.status.copy(poll = newPoll)
).update()
}
override fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) {
status.copy(
isExpanded = expanded
).update()
}
override fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) {
status.copy(
isShowingContent = isShowing
).update()
}
override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) {
status.copy(
isCollapsed = isCollapsed
).update()
}
override fun removeAllByAccountId(accountId: String) {
statusData.removeAll { vd ->
val status = vd.asStatusOrNull()?.status ?: return@removeAll false
status.account.id == accountId || status.actionableStatus.account.id == accountId
}
currentSource?.invalidate()
}
override fun removeAllByInstance(instance: String) {
statusData.removeAll { vd ->
val status = vd.asStatusOrNull()?.status ?: return@removeAll false
LinkHelper.getDomain(status.account.url) == instance
}
currentSource?.invalidate()
}
override fun removeStatusWithId(id: String) {
statusData.removeAll { vd ->
val status = vd.asStatusOrNull()?.status ?: return@removeAll false
status.id == id || status.reblog?.id == id
}
currentSource?.invalidate()
}
override fun loadMore(placeholderId: String) {
viewModelScope.launch {
try {
val statusResponse = fetchStatusesForKind(
fromId = placeholderId.inc(),
uptoId = null,
limit = 20
)
val statuses = statusResponse.body()
if (!statusResponse.isSuccessful || statuses == null) {
loadMoreFailed(placeholderId, HttpException(statusResponse))
return@launch
}
val activeAccount = accountManager.activeAccount!!
val data = statuses.map { status ->
val oldStatus = statusData.find { s ->
s.asStatusOrNull()?.id == status.id
}?.asStatusOrNull()
val contentShowing = oldStatus?.isShowingContent ?: activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive
val expanded = oldStatus?.isExpanded ?: activeAccount.alwaysOpenSpoiler
val contentCollapsed = oldStatus?.isCollapsed ?: true
status.toViewData(
isShowingContent = contentShowing,
isExpanded = expanded,
isCollapsed = contentCollapsed
)
}
val index =
statusData.indexOfFirst { it is StatusViewData.Placeholder && it.id == placeholderId }
statusData.removeAt(index)
statusData.addAll(index, data)
currentSource?.invalidate()
} catch (e: Exception) {
loadMoreFailed(placeholderId, e)
}
}
}
private fun loadMoreFailed(placeholderId: String, e: Exception) {
Log.w("NetworkTimelineVM", "failed loading statuses", e)
val index =
statusData.indexOfFirst { it is StatusViewData.Placeholder && it.id == placeholderId }
statusData[index] = StatusViewData.Placeholder(placeholderId, isLoading = false)
currentSource?.invalidate()
}
override fun handleReblogEvent(reblogEvent: ReblogEvent) {
updateStatusById(reblogEvent.statusId) {
it.copy(status = it.status.copy(reblogged = reblogEvent.reblog))
}
}
override fun handleFavEvent(favEvent: FavoriteEvent) {
updateActionableStatusById(favEvent.statusId) {
it.copy(favourited = favEvent.favourite)
}
}
override fun handleBookmarkEvent(bookmarkEvent: BookmarkEvent) {
updateActionableStatusById(bookmarkEvent.statusId) {
it.copy(bookmarked = bookmarkEvent.bookmark)
}
}
override fun handlePinEvent(pinEvent: PinEvent) {
updateActionableStatusById(pinEvent.statusId) {
it.copy(pinned = pinEvent.pinned)
}
}
override fun fullReload() {
statusData.clear()
currentSource?.invalidate()
}
suspend fun fetchStatusesForKind(
fromId: String?,
uptoId: String?,
limit: Int
): 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)
}.await()
}
private fun StatusViewData.Concrete.update() {
val position = statusData.indexOfFirst { viewData -> viewData.asStatusOrNull()?.id == this.id }
statusData[position] = this
currentSource?.invalidate()
}
private inline fun updateStatusById(
id: String,
updater: (StatusViewData.Concrete) -> StatusViewData.Concrete
) {
val pos = statusData.indexOfFirst { it.asStatusOrNull()?.id == id }
if (pos == -1) return
updateViewDataAt(pos, updater)
}
private inline fun updateActionableStatusById(
id: String,
updater: (Status) -> Status
) {
val pos = statusData.indexOfFirst { it.asStatusOrNull()?.id == id }
if (pos == -1) return
updateViewDataAt(pos) { vd ->
if (vd.status.reblog != null) {
vd.copy(status = vd.status.copy(reblog = updater(vd.status.reblog)))
} else {
vd.copy(status = updater(vd.status))
}
}
}
private inline fun updateViewDataAt(
position: Int,
updater: (StatusViewData.Concrete) -> StatusViewData.Concrete
) {
val status = statusData.getOrNull(position)?.asStatusOrNull() ?: return
statusData[position] = updater(status)
currentSource?.invalidate()
}
}

View file

@ -0,0 +1,316 @@
/* 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.viewmodel
import android.content.SharedPreferences
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import com.keylesspalace.tusky.appstore.BlockEvent
import com.keylesspalace.tusky.appstore.BookmarkEvent
import com.keylesspalace.tusky.appstore.DomainMuteEvent
import com.keylesspalace.tusky.appstore.Event
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.FavoriteEvent
import com.keylesspalace.tusky.appstore.MuteConversationEvent
import com.keylesspalace.tusky.appstore.MuteEvent
import com.keylesspalace.tusky.appstore.PinEvent
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.appstore.ReblogEvent
import com.keylesspalace.tusky.appstore.StatusDeletedEvent
import com.keylesspalace.tusky.appstore.UnfollowEvent
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.Poll
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.viewdata.StatusViewData
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.asFlow
import kotlinx.coroutines.rx3.await
import retrofit2.HttpException
import java.io.IOException
abstract class TimelineViewModel(
private val timelineCases: TimelineCases,
private val api: MastodonApi,
private val eventHub: EventHub,
protected val accountManager: AccountManager,
private val sharedPreferences: SharedPreferences,
private val filterModel: FilterModel
) : ViewModel() {
abstract val statuses: Flow<PagingData<StatusViewData>>
var kind: Kind = Kind.HOME
private set
var id: String? = null
private set
var tags: List<String> = emptyList()
private set
protected var alwaysShowSensitiveMedia = false
protected var alwaysOpenSpoilers = false
private var filterRemoveReplies = false
private var filterRemoveReblogs = false
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()
}
fun reblog(reblog: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch {
try {
timelineCases.reblog(status.actionableId, reblog).await()
} catch (t: Exception) {
ifExpected(t) {
Log.d(TAG, "Failed to reblog status " + status.actionableId, t)
}
}
}
fun favorite(favorite: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch {
try {
timelineCases.favourite(status.actionableId, favorite).await()
} catch (t: Exception) {
ifExpected(t) {
Log.d(TAG, "Failed to favourite status " + status.actionableId, t)
}
}
}
fun bookmark(bookmark: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch {
try {
timelineCases.bookmark(status.actionableId, bookmark).await()
} catch (t: Exception) {
ifExpected(t) {
Log.d(TAG, "Failed to favourite status " + status.actionableId, t)
}
}
}
fun voteInPoll(choices: List<Int>, status: StatusViewData.Concrete): Job = viewModelScope.launch {
val poll = status.status.actionableStatus.poll ?: run {
Log.w(TAG, "No poll on status ${status.id}")
return@launch
}
val votedPoll = poll.votedCopy(choices)
updatePoll(votedPoll, status)
try {
timelineCases.voteInPoll(status.actionableId, poll.id, choices).await()
} catch (t: Exception) {
ifExpected(t) {
Log.d(TAG, "Failed to vote in poll: " + status.actionableId, t)
}
}
}
abstract fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete)
abstract fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete)
abstract fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete)
abstract fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete)
abstract fun removeAllByAccountId(accountId: String)
abstract fun removeAllByInstance(instance: String)
abstract fun removeStatusWithId(id: String)
abstract fun loadMore(placeholderId: String)
abstract fun handleReblogEvent(reblogEvent: ReblogEvent)
abstract fun handleFavEvent(favEvent: FavoriteEvent)
abstract fun handleBookmarkEvent(bookmarkEvent: BookmarkEvent)
abstract fun handlePinEvent(pinEvent: PinEvent)
abstract fun fullReload()
protected fun shouldFilterStatus(statusViewData: StatusViewData): Boolean {
val status = statusViewData.asStatusOrNull()?.status ?: return false
return status.inReplyToId != null && filterRemoveReplies ||
status.reblog != null && filterRemoveReblogs ||
filterModel.shouldFilterStatus(status.actionableStatus)
}
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 (oldRemoveReplies != filterRemoveReplies) {
fullReload()
}
}
PrefKeys.TAB_FILTER_HOME_BOOSTS -> {
val filter = sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true)
val oldRemoveReblogs = filterRemoveReblogs
filterRemoveReblogs = kind == Kind.HOME && !filter
if (oldRemoveReblogs != filterRemoveReblogs) {
fullReload()
}
}
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
}
}
}
private 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 -> fullReload()
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) {
removeStatusWithId(event.statusId)
}
}
is PreferenceChangedEvent -> {
onPreferenceChanged(event.preferenceKey)
}
}
}
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)
}
)
}
}
private fun isExpectedRequestException(t: Exception) = t is IOException || t is HttpException
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
}
}