Timeline refactor (#2175)
* Move Timeline files into their own package * Introduce TimelineViewModel, add coroutines * Simplify StatusViewData * Handle timeilne fetch errors * Rework filters, fix ViewThreadFragment * Fix NotificationsFragment * Simplify Notifications and Thread, handle pin * Redo loading in TimelineViewModel * Improve error handling in TimelineViewModel * Rewrite actions in TimelineViewModel * Apply feedback after timeline factoring review * Handle initial failure in timeline correctly
This commit is contained in:
parent
0a992480c2
commit
44a5b42cac
58 changed files with 3956 additions and 3618 deletions
|
@ -0,0 +1,137 @@
|
|||
/* 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.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();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,563 @@
|
|||
/* Copyright 2021 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.timeline
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.accessibility.AccessibilityManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.*
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.*
|
||||
import autodispose2.androidx.lifecycle.autoDispose
|
||||
import com.keylesspalace.tusky.AccountListActivity
|
||||
import com.keylesspalace.tusky.AccountListActivity.Companion.newIntent
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||
import com.keylesspalace.tusky.databinding.FragmentTimelineBinding
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.fragment.SFragment
|
||||
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
|
||||
import com.keylesspalace.tusky.interfaces.RefreshableFragment
|
||||
import com.keylesspalace.tusky.interfaces.ReselectableFragment
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.util.*
|
||||
import com.keylesspalace.tusky.view.EndlessOnScrollListener
|
||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
|
||||
class TimelineFragment : SFragment(), OnRefreshListener, StatusActionListener, Injectable,
|
||||
ReselectableFragment, RefreshableFragment {
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
@Inject
|
||||
lateinit var eventHub: EventHub
|
||||
|
||||
@Inject
|
||||
lateinit var accountManager: AccountManager
|
||||
|
||||
private val viewModel: TimelineViewModel by viewModels { viewModelFactory }
|
||||
|
||||
private val binding by viewBinding(FragmentTimelineBinding::bind)
|
||||
|
||||
private lateinit var adapter: TimelineAdapter
|
||||
|
||||
private var isSwipeToRefreshEnabled = true
|
||||
|
||||
private var eventRegistered = false
|
||||
|
||||
private var layoutManager: LinearLayoutManager? = null
|
||||
private var scrollListener: EndlessOnScrollListener? = null
|
||||
private var hideFab = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val arguments = requireArguments()
|
||||
val kind = TimelineViewModel.Kind.valueOf(arguments.getString(KIND_ARG)!!)
|
||||
val id: String? = if (kind == TimelineViewModel.Kind.USER ||
|
||||
kind == TimelineViewModel.Kind.USER_PINNED ||
|
||||
kind == TimelineViewModel.Kind.USER_WITH_REPLIES ||
|
||||
kind == TimelineViewModel.Kind.LIST
|
||||
) {
|
||||
arguments.getString(ID_ARG)!!
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val tags = if (kind == TimelineViewModel.Kind.TAG) {
|
||||
arguments.getStringArrayList(HASHTAGS_ARG)!!
|
||||
} else {
|
||||
listOf()
|
||||
}
|
||||
viewModel.init(
|
||||
kind,
|
||||
id,
|
||||
tags,
|
||||
)
|
||||
|
||||
viewModel.viewUpdates
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(this)
|
||||
.subscribe { this.updateViews() }
|
||||
|
||||
isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true)
|
||||
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(activity)
|
||||
val statusDisplayOptions = StatusDisplayOptions(
|
||||
animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
|
||||
mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled,
|
||||
useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false),
|
||||
showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true),
|
||||
useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true),
|
||||
cardViewMode = if (preferences.getBoolean(
|
||||
PrefKeys.SHOW_CARDS_IN_TIMELINES,
|
||||
false
|
||||
)
|
||||
) CardViewMode.INDENTED else CardViewMode.NONE,
|
||||
confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true),
|
||||
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
|
||||
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
|
||||
)
|
||||
adapter = TimelineAdapter(
|
||||
dataSource,
|
||||
statusDisplayOptions,
|
||||
this
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return inflater.inflate(R.layout.fragment_timeline, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
setupSwipeRefreshLayout()
|
||||
setupRecyclerView()
|
||||
updateViews()
|
||||
viewModel.loadInitial()
|
||||
}
|
||||
|
||||
private fun setupSwipeRefreshLayout() {
|
||||
binding.swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled
|
||||
binding.swipeRefreshLayout.setOnRefreshListener(this)
|
||||
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
|
||||
}
|
||||
|
||||
private fun setupRecyclerView() {
|
||||
binding.recyclerView.setAccessibilityDelegateCompat(
|
||||
ListStatusAccessibilityDelegate(binding.recyclerView, this)
|
||||
{ pos -> viewModel.statuses.getOrNull(pos) }
|
||||
)
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
binding.recyclerView.layoutManager = layoutManager
|
||||
val divider = DividerItemDecoration(context, RecyclerView.VERTICAL)
|
||||
binding.recyclerView.addItemDecoration(divider)
|
||||
|
||||
// CWs are expanded without animation, buttons animate itself, we don't need it basically
|
||||
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
||||
binding.recyclerView.adapter = adapter
|
||||
}
|
||||
|
||||
private fun showEmptyView() {
|
||||
binding.statusView.show()
|
||||
binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null)
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
|
||||
/* This is delayed until onActivityCreated solely because MainActivity.composeButton isn't
|
||||
* guaranteed to be set until then. */
|
||||
scrollListener = if (actionButtonPresent()) {
|
||||
/* Use a modified scroll listener that both loads more statuses as it goes, and hides
|
||||
* the follow button on down-scroll. */
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
hideFab = preferences.getBoolean("fabHide", false)
|
||||
object : EndlessOnScrollListener(layoutManager) {
|
||||
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
|
||||
super.onScrolled(view, dx, dy)
|
||||
val composeButton = (activity as ActionButtonActivity).actionButton
|
||||
if (composeButton != null) {
|
||||
if (hideFab) {
|
||||
if (dy > 0 && composeButton.isShown) {
|
||||
composeButton.hide() // hides the button if we're scrolling down
|
||||
} else if (dy < 0 && !composeButton.isShown) {
|
||||
composeButton.show() // shows it if we are scrolling up
|
||||
}
|
||||
} else if (!composeButton.isShown) {
|
||||
composeButton.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) {
|
||||
this@TimelineFragment.onLoadMore()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Just use the basic scroll listener to load more statuses.
|
||||
object : EndlessOnScrollListener(layoutManager) {
|
||||
override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) {
|
||||
this@TimelineFragment.onLoadMore()
|
||||
}
|
||||
}
|
||||
}.also {
|
||||
binding.recyclerView.addOnScrollListener(it)
|
||||
}
|
||||
|
||||
if (!eventRegistered) {
|
||||
eventHub.events
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
|
||||
.subscribe { event ->
|
||||
when (event) {
|
||||
is PreferenceChangedEvent -> {
|
||||
onPreferenceChanged(event.preferenceKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
eventRegistered = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRefresh() {
|
||||
binding.swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled
|
||||
binding.statusView.hide()
|
||||
|
||||
viewModel.refresh()
|
||||
}
|
||||
|
||||
override fun onReply(position: Int) {
|
||||
val status = viewModel.statuses[position].asStatusOrNull() ?: return
|
||||
super.reply(status.status)
|
||||
}
|
||||
|
||||
override fun onReblog(reblog: Boolean, position: Int) {
|
||||
viewModel.reblog(reblog, position)
|
||||
}
|
||||
|
||||
override fun onFavourite(favourite: Boolean, position: Int) {
|
||||
viewModel.favorite(favourite, position)
|
||||
}
|
||||
|
||||
override fun onBookmark(bookmark: Boolean, position: Int) {
|
||||
viewModel.bookmark(bookmark, position)
|
||||
}
|
||||
|
||||
override fun onVoteInPoll(position: Int, choices: List<Int>) {
|
||||
viewModel.voteInPoll(position, choices)
|
||||
}
|
||||
|
||||
override fun onMore(view: View, position: Int) {
|
||||
val status = viewModel.statuses[position].asStatusOrNull()?.status ?: return
|
||||
super.more(status, view, position)
|
||||
}
|
||||
|
||||
override fun onOpenReblog(position: Int) {
|
||||
val status = viewModel.statuses[position].asStatusOrNull()?.status ?: return
|
||||
super.openReblog(status)
|
||||
}
|
||||
|
||||
override fun onExpandedChange(expanded: Boolean, position: Int) {
|
||||
viewModel.changeExpanded(expanded, position)
|
||||
updateViews()
|
||||
}
|
||||
|
||||
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
|
||||
viewModel.changeContentHidden(isShowing, position)
|
||||
updateViews()
|
||||
}
|
||||
|
||||
override fun onShowReblogs(position: Int) {
|
||||
val statusId = viewModel.statuses[position].asStatusOrNull()?.id ?: return
|
||||
val intent = newIntent(requireContext(), AccountListActivity.Type.REBLOGGED, statusId)
|
||||
(activity as BaseActivity).startActivityWithSlideInAnimation(intent)
|
||||
}
|
||||
|
||||
override fun onShowFavs(position: Int) {
|
||||
val statusId = viewModel.statuses[position].asStatusOrNull()?.id ?: return
|
||||
val intent = newIntent(requireContext(), AccountListActivity.Type.FAVOURITED, statusId)
|
||||
(activity as BaseActivity).startActivityWithSlideInAnimation(intent)
|
||||
}
|
||||
|
||||
override fun onLoadMore(position: Int) {
|
||||
viewModel.loadGap(position)
|
||||
}
|
||||
|
||||
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
|
||||
viewModel.changeContentCollapsed(isCollapsed, position)
|
||||
}
|
||||
|
||||
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
|
||||
val status = viewModel.statuses[position].asStatusOrNull() ?: return
|
||||
super.viewMedia(
|
||||
attachmentIndex,
|
||||
AttachmentViewData.list(status.actionable),
|
||||
view
|
||||
)
|
||||
}
|
||||
|
||||
override fun onViewThread(position: Int) {
|
||||
val status = viewModel.statuses[position].asStatusOrNull() ?: return
|
||||
super.viewThread(status.actionable.id, status.actionable.url)
|
||||
}
|
||||
|
||||
override fun onViewTag(tag: String) {
|
||||
if (viewModel.kind == TimelineViewModel.Kind.TAG && viewModel.tags.size == 1 &&
|
||||
viewModel.tags.contains(tag)
|
||||
) {
|
||||
// If already viewing a tag page, then ignore any request to view that tag again.
|
||||
return
|
||||
}
|
||||
super.viewTag(tag)
|
||||
}
|
||||
|
||||
override fun onViewAccount(id: String) {
|
||||
if ((viewModel.kind == TimelineViewModel.Kind.USER ||
|
||||
viewModel.kind == TimelineViewModel.Kind.USER_WITH_REPLIES) &&
|
||||
viewModel.id == id
|
||||
) {
|
||||
/* If already viewing an account page, then any requests to view that account page
|
||||
* should be ignored. */
|
||||
return
|
||||
}
|
||||
super.viewAccount(id)
|
||||
}
|
||||
|
||||
private fun onPreferenceChanged(key: String) {
|
||||
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
when (key) {
|
||||
PrefKeys.FAB_HIDE -> {
|
||||
hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false)
|
||||
}
|
||||
PrefKeys.MEDIA_PREVIEW_ENABLED -> {
|
||||
val enabled = accountManager.activeAccount!!.mediaPreviewEnabled
|
||||
val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled
|
||||
if (enabled != oldMediaPreviewEnabled) {
|
||||
adapter.mediaPreviewEnabled = enabled
|
||||
updateViews()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override fun removeItem(position: Int) {
|
||||
viewModel.statuses.removeAt(position)
|
||||
updateViews()
|
||||
}
|
||||
|
||||
private fun onLoadMore() {
|
||||
viewModel.loadMore()
|
||||
}
|
||||
|
||||
private fun actionButtonPresent(): Boolean {
|
||||
return viewModel.kind != TimelineViewModel.Kind.TAG &&
|
||||
viewModel.kind != TimelineViewModel.Kind.FAVOURITES &&
|
||||
viewModel.kind != TimelineViewModel.Kind.BOOKMARKS &&
|
||||
activity is ActionButtonActivity
|
||||
}
|
||||
|
||||
private fun updateViews() {
|
||||
differ.submitList(viewModel.statuses.toList())
|
||||
binding.swipeRefreshLayout.isEnabled = viewModel.failure == null
|
||||
|
||||
if (isAdded) {
|
||||
binding.swipeRefreshLayout.isRefreshing = viewModel.isRefreshing
|
||||
binding.progressBar.visible(viewModel.isLoadingInitially)
|
||||
if (viewModel.failure == null && viewModel.statuses.isEmpty() && !viewModel.isLoadingInitially) {
|
||||
showEmptyView()
|
||||
} else {
|
||||
when (viewModel.failure) {
|
||||
TimelineViewModel.FailureReason.NETWORK -> {
|
||||
binding.statusView.show()
|
||||
binding.statusView.setup(
|
||||
R.drawable.elephant_offline,
|
||||
R.string.error_network
|
||||
) {
|
||||
binding.statusView.hide()
|
||||
viewModel.loadInitial()
|
||||
}
|
||||
}
|
||||
TimelineViewModel.FailureReason.OTHER -> {
|
||||
binding.statusView.show()
|
||||
binding.statusView.setup(
|
||||
R.drawable.elephant_error,
|
||||
R.string.error_generic
|
||||
) {
|
||||
binding.statusView.hide()
|
||||
viewModel.loadInitial()
|
||||
}
|
||||
}
|
||||
null -> binding.statusView.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val listUpdateCallback: ListUpdateCallback = object : ListUpdateCallback {
|
||||
override fun onInserted(position: Int, count: Int) {
|
||||
if (isAdded) {
|
||||
adapter.notifyItemRangeInserted(position, count)
|
||||
val context = context
|
||||
// scroll up when new items at the top are loaded while being in the first position
|
||||
// https://github.com/tuskyapp/Tusky/pull/1905#issuecomment-677819724
|
||||
if (position == 0 && context != null && adapter.itemCount != count) {
|
||||
if (isSwipeToRefreshEnabled) {
|
||||
binding.recyclerView.scrollBy(0, Utils.dpToPx(context, -30))
|
||||
} else binding.recyclerView.scrollToPosition(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRemoved(position: Int, count: Int) {
|
||||
adapter.notifyItemRangeRemoved(position, count)
|
||||
}
|
||||
|
||||
override fun onMoved(fromPosition: Int, toPosition: Int) {
|
||||
adapter.notifyItemMoved(fromPosition, toPosition)
|
||||
}
|
||||
|
||||
override fun onChanged(position: Int, count: Int, payload: Any?) {
|
||||
adapter.notifyItemRangeChanged(position, count, payload)
|
||||
}
|
||||
}
|
||||
private val differ = AsyncListDiffer(
|
||||
listUpdateCallback,
|
||||
AsyncDifferConfig.Builder(diffCallback).build()
|
||||
)
|
||||
|
||||
private val dataSource: TimelineAdapter.AdapterDataSource<StatusViewData> =
|
||||
object : TimelineAdapter.AdapterDataSource<StatusViewData> {
|
||||
override fun getItemCount(): Int {
|
||||
return differ.currentList.size
|
||||
}
|
||||
|
||||
override fun getItemAt(pos: Int): StatusViewData {
|
||||
return differ.currentList[pos]
|
||||
}
|
||||
}
|
||||
|
||||
private var talkBackWasEnabled = false
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
val a11yManager =
|
||||
ContextCompat.getSystemService(requireContext(), AccessibilityManager::class.java)
|
||||
|
||||
val wasEnabled = talkBackWasEnabled
|
||||
talkBackWasEnabled = a11yManager?.isEnabled == true
|
||||
Log.d(TAG, "talkback was enabled: $wasEnabled, now $talkBackWasEnabled")
|
||||
if (talkBackWasEnabled && !wasEnabled) {
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
startUpdateTimestamp()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start to update adapter every minute to refresh timestamp
|
||||
* If setting absoluteTimeView is false
|
||||
* Auto dispose observable on pause
|
||||
*/
|
||||
private fun startUpdateTimestamp() {
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(activity)
|
||||
val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false)
|
||||
if (!useAbsoluteTime) {
|
||||
Observable.interval(1, TimeUnit.MINUTES)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(this, Lifecycle.Event.ON_PAUSE)
|
||||
.subscribe { updateViews() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReselect() {
|
||||
if (isAdded) {
|
||||
layoutManager!!.scrollToPosition(0)
|
||||
binding.recyclerView.stopScroll()
|
||||
scrollListener!!.reset()
|
||||
}
|
||||
}
|
||||
|
||||
override fun refreshContent() {
|
||||
onRefresh()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TimelineF" // logging tag
|
||||
private const val KIND_ARG = "kind"
|
||||
private const val ID_ARG = "id"
|
||||
private const val HASHTAGS_ARG = "hashtags"
|
||||
private const val ARG_ENABLE_SWIPE_TO_REFRESH = "enableSwipeToRefresh"
|
||||
|
||||
|
||||
fun newInstance(
|
||||
kind: TimelineViewModel.Kind,
|
||||
hashtagOrId: String? = null,
|
||||
enableSwipeToRefresh: Boolean = true
|
||||
): TimelineFragment {
|
||||
val fragment = TimelineFragment()
|
||||
val arguments = Bundle(3)
|
||||
arguments.putString(KIND_ARG, kind.name)
|
||||
arguments.putString(ID_ARG, hashtagOrId)
|
||||
arguments.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, enableSwipeToRefresh)
|
||||
fragment.arguments = arguments
|
||||
return fragment
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun newHashtagInstance(hashtags: List<String>): TimelineFragment {
|
||||
val fragment = TimelineFragment()
|
||||
val arguments = Bundle(3)
|
||||
arguments.putString(KIND_ARG, TimelineViewModel.Kind.TAG.name)
|
||||
arguments.putStringArrayList(HASHTAGS_ARG, ArrayList(hashtags))
|
||||
arguments.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true)
|
||||
fragment.arguments = arguments
|
||||
return fragment
|
||||
}
|
||||
|
||||
|
||||
private val diffCallback: DiffUtil.ItemCallback<StatusViewData> =
|
||||
object : DiffUtil.ItemCallback<StatusViewData>() {
|
||||
override fun areItemsTheSame(
|
||||
oldItem: StatusViewData,
|
||||
newItem: StatusViewData
|
||||
): Boolean {
|
||||
return oldItem.viewDataId == newItem.viewDataId
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(
|
||||
oldItem: StatusViewData,
|
||||
newItem: StatusViewData
|
||||
): Boolean {
|
||||
return false // Items are different always. It allows to refresh timestamp on every view holder update
|
||||
}
|
||||
|
||||
override fun getChangePayload(
|
||||
oldItem: StatusViewData,
|
||||
newItem: StatusViewData
|
||||
): Any? {
|
||||
return if (oldItem === newItem) {
|
||||
// If items are equal - update timestamp only
|
||||
listOf(StatusBaseViewHolder.Key.KEY_CREATED)
|
||||
} else // If items are different - update the whole view holder
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,413 @@
|
|||
package com.keylesspalace.tusky.components.timeline
|
||||
|
||||
import android.text.SpannedString
|
||||
import androidx.core.text.parseAsHtml
|
||||
import androidx.core.text.toHtml
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.keylesspalace.tusky.db.*
|
||||
import com.keylesspalace.tusky.entity.*
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.components.timeline.TimelineRequestMode.DISK
|
||||
import com.keylesspalace.tusky.components.timeline.TimelineRequestMode.NETWORK
|
||||
import com.keylesspalace.tusky.util.Either
|
||||
import com.keylesspalace.tusky.util.dec
|
||||
import com.keylesspalace.tusky.util.inc
|
||||
import com.keylesspalace.tusky.util.trimTrailingWhitespace
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
data class Placeholder(val id: String)
|
||||
|
||||
typealias TimelineStatus = Either<Placeholder, Status>
|
||||
|
||||
enum class TimelineRequestMode {
|
||||
DISK, NETWORK, ANY
|
||||
}
|
||||
|
||||
interface TimelineRepository {
|
||||
fun getStatuses(
|
||||
maxId: String?, sinceId: String?, sincedIdMinusOne: String?, limit: Int,
|
||||
requestMode: TimelineRequestMode
|
||||
): Single<out List<TimelineStatus>>
|
||||
|
||||
companion object {
|
||||
val CLEANUP_INTERVAL = TimeUnit.DAYS.toMillis(14)
|
||||
}
|
||||
}
|
||||
|
||||
class TimelineRepositoryImpl(
|
||||
private val timelineDao: TimelineDao,
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val accountManager: AccountManager,
|
||||
private val gson: Gson
|
||||
) : TimelineRepository {
|
||||
|
||||
init {
|
||||
this.cleanup()
|
||||
}
|
||||
|
||||
override fun getStatuses(
|
||||
maxId: String?, sinceId: String?, sincedIdMinusOne: String?,
|
||||
limit: Int, requestMode: TimelineRequestMode
|
||||
): Single<out List<TimelineStatus>> {
|
||||
val acc = accountManager.activeAccount ?: throw IllegalStateException()
|
||||
val accountId = acc.id
|
||||
|
||||
return if (requestMode == DISK) {
|
||||
this.getStatusesFromDb(accountId, maxId, sinceId, limit)
|
||||
} else {
|
||||
getStatusesFromNetwork(maxId, sinceId, sincedIdMinusOne, limit, accountId, requestMode)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getStatusesFromNetwork(
|
||||
maxId: String?, sinceId: String?,
|
||||
sinceIdMinusOne: String?, limit: Int,
|
||||
accountId: Long, requestMode: TimelineRequestMode
|
||||
): Single<out List<TimelineStatus>> {
|
||||
return mastodonApi.homeTimeline(maxId, sinceIdMinusOne, limit + 1)
|
||||
.map { response ->
|
||||
this.saveStatusesToDb(accountId, response.body().orEmpty(), maxId, sinceId)
|
||||
}
|
||||
.flatMap { statuses ->
|
||||
this.addFromDbIfNeeded(accountId, statuses, maxId, sinceId, limit, requestMode)
|
||||
}
|
||||
.onErrorResumeNext { error ->
|
||||
if (error is IOException && requestMode != NETWORK) {
|
||||
this.getStatusesFromDb(accountId, maxId, sinceId, limit)
|
||||
} else {
|
||||
Single.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addFromDbIfNeeded(
|
||||
accountId: Long, statuses: List<Either<Placeholder, Status>>,
|
||||
maxId: String?, sinceId: String?, limit: Int,
|
||||
requestMode: TimelineRequestMode
|
||||
): Single<List<TimelineStatus>> {
|
||||
return if (requestMode != NETWORK && statuses.size < 2) {
|
||||
val newMaxID = if (statuses.isEmpty()) {
|
||||
maxId
|
||||
} else {
|
||||
statuses.last { it.isRight() }.asRight().id
|
||||
}
|
||||
this.getStatusesFromDb(accountId, newMaxID, sinceId, limit)
|
||||
.map { fromDb ->
|
||||
// If it's just placeholders and less than limit (so we exhausted both
|
||||
// db and server at this point)
|
||||
if (fromDb.size < limit && fromDb.all { !it.isRight() }) {
|
||||
statuses
|
||||
} else {
|
||||
statuses + fromDb
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Single.just(statuses)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getStatusesFromDb(
|
||||
accountId: Long, maxId: String?, sinceId: String?,
|
||||
limit: Int
|
||||
): Single<out List<TimelineStatus>> {
|
||||
return timelineDao.getStatusesForAccount(accountId, maxId, sinceId, limit)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.map { statuses ->
|
||||
statuses.map { it.toStatus() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveStatusesToDb(
|
||||
accountId: Long, statuses: List<Status>,
|
||||
maxId: String?, sinceId: String?
|
||||
): List<Either<Placeholder, Status>> {
|
||||
var placeholderToInsert: Placeholder? = null
|
||||
|
||||
// Look for overlap
|
||||
val resultStatuses = if (statuses.isNotEmpty() && sinceId != null) {
|
||||
val indexOfSince = statuses.indexOfLast { it.id == sinceId }
|
||||
if (indexOfSince == -1) {
|
||||
// We didn't find the status which must be there. Add a placeholder
|
||||
placeholderToInsert = Placeholder(sinceId.inc())
|
||||
statuses.mapTo(mutableListOf(), Status::lift)
|
||||
.apply {
|
||||
add(Either.Left(placeholderToInsert))
|
||||
}
|
||||
} else {
|
||||
// There was an overlap. Remove all overlapped statuses. No need for a placeholder.
|
||||
statuses.mapTo(mutableListOf(), Status::lift)
|
||||
.apply {
|
||||
subList(indexOfSince, size).clear()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Just a normal case.
|
||||
statuses.map(Status::lift)
|
||||
}
|
||||
|
||||
Single.fromCallable {
|
||||
|
||||
if (statuses.isNotEmpty()) {
|
||||
timelineDao.deleteRange(accountId, statuses.last().id, statuses.first().id)
|
||||
}
|
||||
|
||||
for (status in statuses) {
|
||||
timelineDao.insertInTransaction(
|
||||
status.toEntity(accountId, gson),
|
||||
status.account.toEntity(accountId, gson),
|
||||
status.reblog?.account?.toEntity(accountId, gson)
|
||||
)
|
||||
}
|
||||
|
||||
placeholderToInsert?.let {
|
||||
timelineDao.insertStatusIfNotThere(placeholderToInsert.toEntity(accountId))
|
||||
}
|
||||
|
||||
// If we're loading in the bottom insert placeholder after every load
|
||||
// (for requests on next launches) but not return it.
|
||||
if (sinceId == null && statuses.isNotEmpty()) {
|
||||
timelineDao.insertStatusIfNotThere(
|
||||
Placeholder(statuses.last().id.dec()).toEntity(accountId)
|
||||
)
|
||||
}
|
||||
|
||||
// There may be placeholders which we thought could be from our TL but they are not
|
||||
if (statuses.size > 2) {
|
||||
timelineDao.removeAllPlaceholdersBetween(
|
||||
accountId, statuses.first().id,
|
||||
statuses.last().id
|
||||
)
|
||||
} else if (placeholderToInsert == null && maxId != null && sinceId != null) {
|
||||
timelineDao.removeAllPlaceholdersBetween(accountId, maxId, sinceId)
|
||||
}
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe()
|
||||
|
||||
return resultStatuses
|
||||
}
|
||||
|
||||
private fun cleanup() {
|
||||
Schedulers.io().scheduleDirect {
|
||||
val olderThan = System.currentTimeMillis() - TimelineRepository.CLEANUP_INTERVAL
|
||||
timelineDao.cleanup(olderThan)
|
||||
}
|
||||
}
|
||||
|
||||
private fun TimelineStatusWithAccount.toStatus(): TimelineStatus {
|
||||
if (this.status.authorServerId == null) {
|
||||
return Either.Left(Placeholder(this.status.serverId))
|
||||
}
|
||||
|
||||
val attachments: ArrayList<Attachment> = gson.fromJson(
|
||||
status.attachments,
|
||||
object : TypeToken<List<Attachment>>() {}.type
|
||||
) ?: ArrayList()
|
||||
val mentions: List<Status.Mention> = gson.fromJson(
|
||||
status.mentions,
|
||||
object : TypeToken<List<Status.Mention>>() {}.type
|
||||
) ?: listOf()
|
||||
val application = gson.fromJson(status.application, Status.Application::class.java)
|
||||
val emojis: List<Emoji> = gson.fromJson(
|
||||
status.emojis,
|
||||
object : TypeToken<List<Emoji>>() {}.type
|
||||
) ?: listOf()
|
||||
val poll: Poll? = gson.fromJson(status.poll, Poll::class.java)
|
||||
|
||||
val reblog = status.reblogServerId?.let { id ->
|
||||
Status(
|
||||
id = id,
|
||||
url = status.url,
|
||||
account = account.toAccount(gson),
|
||||
inReplyToId = status.inReplyToId,
|
||||
inReplyToAccountId = status.inReplyToAccountId,
|
||||
reblog = null,
|
||||
content = status.content?.parseAsHtml()?.trimTrailingWhitespace()
|
||||
?: SpannedString(""),
|
||||
createdAt = Date(status.createdAt),
|
||||
emojis = emojis,
|
||||
reblogsCount = status.reblogsCount,
|
||||
favouritesCount = status.favouritesCount,
|
||||
reblogged = status.reblogged,
|
||||
favourited = status.favourited,
|
||||
bookmarked = status.bookmarked,
|
||||
sensitive = status.sensitive,
|
||||
spoilerText = status.spoilerText!!,
|
||||
visibility = status.visibility!!,
|
||||
attachments = attachments,
|
||||
mentions = mentions,
|
||||
application = application,
|
||||
pinned = false,
|
||||
muted = status.muted,
|
||||
poll = poll,
|
||||
card = null
|
||||
)
|
||||
}
|
||||
val status = if (reblog != null) {
|
||||
Status(
|
||||
id = status.serverId,
|
||||
url = null, // no url for reblogs
|
||||
account = this.reblogAccount!!.toAccount(gson),
|
||||
inReplyToId = null,
|
||||
inReplyToAccountId = null,
|
||||
reblog = reblog,
|
||||
content = SpannedString(""),
|
||||
createdAt = Date(status.createdAt), // lie but whatever?
|
||||
emojis = listOf(),
|
||||
reblogsCount = 0,
|
||||
favouritesCount = 0,
|
||||
reblogged = false,
|
||||
favourited = false,
|
||||
bookmarked = false,
|
||||
sensitive = false,
|
||||
spoilerText = "",
|
||||
visibility = status.visibility!!,
|
||||
attachments = ArrayList(),
|
||||
mentions = listOf(),
|
||||
application = null,
|
||||
pinned = false,
|
||||
muted = status.muted,
|
||||
poll = null,
|
||||
card = null
|
||||
)
|
||||
} else {
|
||||
Status(
|
||||
id = status.serverId,
|
||||
url = status.url,
|
||||
account = account.toAccount(gson),
|
||||
inReplyToId = status.inReplyToId,
|
||||
inReplyToAccountId = status.inReplyToAccountId,
|
||||
reblog = null,
|
||||
content = status.content?.parseAsHtml()?.trimTrailingWhitespace()
|
||||
?: SpannedString(""),
|
||||
createdAt = Date(status.createdAt),
|
||||
emojis = emojis,
|
||||
reblogsCount = status.reblogsCount,
|
||||
favouritesCount = status.favouritesCount,
|
||||
reblogged = status.reblogged,
|
||||
favourited = status.favourited,
|
||||
bookmarked = status.bookmarked,
|
||||
sensitive = status.sensitive,
|
||||
spoilerText = status.spoilerText!!,
|
||||
visibility = status.visibility!!,
|
||||
attachments = attachments,
|
||||
mentions = mentions,
|
||||
application = application,
|
||||
pinned = false,
|
||||
muted = status.muted,
|
||||
poll = poll,
|
||||
card = null
|
||||
)
|
||||
}
|
||||
return Either.Right(status)
|
||||
}
|
||||
}
|
||||
|
||||
private val emojisListTypeToken = object : TypeToken<List<Emoji>>() {}
|
||||
|
||||
fun Account.toEntity(accountId: Long, gson: Gson): TimelineAccountEntity {
|
||||
return TimelineAccountEntity(
|
||||
serverId = id,
|
||||
timelineUserId = accountId,
|
||||
localUsername = localUsername,
|
||||
username = username,
|
||||
displayName = name,
|
||||
url = url,
|
||||
avatar = avatar,
|
||||
emojis = gson.toJson(emojis),
|
||||
bot = bot
|
||||
)
|
||||
}
|
||||
|
||||
fun TimelineAccountEntity.toAccount(gson: Gson): Account {
|
||||
return Account(
|
||||
id = serverId,
|
||||
localUsername = localUsername,
|
||||
username = username,
|
||||
displayName = displayName,
|
||||
note = SpannedString(""),
|
||||
url = url,
|
||||
avatar = avatar,
|
||||
header = "",
|
||||
locked = false,
|
||||
followingCount = 0,
|
||||
followersCount = 0,
|
||||
statusesCount = 0,
|
||||
source = null,
|
||||
bot = bot,
|
||||
emojis = gson.fromJson(this.emojis, emojisListTypeToken.type),
|
||||
fields = null,
|
||||
moved = null
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity {
|
||||
return TimelineStatusEntity(
|
||||
serverId = this.id,
|
||||
url = null,
|
||||
timelineUserId = timelineUserId,
|
||||
authorServerId = null,
|
||||
inReplyToId = null,
|
||||
inReplyToAccountId = null,
|
||||
content = null,
|
||||
createdAt = 0L,
|
||||
emojis = null,
|
||||
reblogsCount = 0,
|
||||
favouritesCount = 0,
|
||||
reblogged = false,
|
||||
favourited = false,
|
||||
bookmarked = false,
|
||||
sensitive = false,
|
||||
spoilerText = null,
|
||||
visibility = null,
|
||||
attachments = null,
|
||||
mentions = null,
|
||||
application = null,
|
||||
reblogServerId = null,
|
||||
reblogAccountId = null,
|
||||
poll = null,
|
||||
muted = false
|
||||
)
|
||||
}
|
||||
|
||||
fun Status.toEntity(
|
||||
timelineUserId: Long,
|
||||
gson: Gson
|
||||
): TimelineStatusEntity {
|
||||
val actionable = actionableStatus
|
||||
return TimelineStatusEntity(
|
||||
serverId = this.id,
|
||||
url = actionable.url!!,
|
||||
timelineUserId = timelineUserId,
|
||||
authorServerId = actionable.account.id,
|
||||
inReplyToId = actionable.inReplyToId,
|
||||
inReplyToAccountId = actionable.inReplyToAccountId,
|
||||
content = actionable.content.toHtml(),
|
||||
createdAt = actionable.createdAt.time,
|
||||
emojis = actionable.emojis.let(gson::toJson),
|
||||
reblogsCount = actionable.reblogsCount,
|
||||
favouritesCount = actionable.favouritesCount,
|
||||
reblogged = actionable.reblogged,
|
||||
favourited = actionable.favourited,
|
||||
bookmarked = actionable.bookmarked,
|
||||
sensitive = actionable.sensitive,
|
||||
spoilerText = actionable.spoilerText,
|
||||
visibility = actionable.visibility,
|
||||
attachments = actionable.attachments.let(gson::toJson),
|
||||
mentions = actionable.mentions.let(gson::toJson),
|
||||
application = actionable.application.let(gson::toJson),
|
||||
reblogServerId = reblog?.id,
|
||||
reblogAccountId = reblog?.let { this.account.id },
|
||||
poll = actionable.poll.let(gson::toJson),
|
||||
muted = actionable.muted
|
||||
)
|
||||
}
|
||||
|
||||
fun Status.lift(): Either<Placeholder, Status> = Either.Right(this)
|
|
@ -0,0 +1,903 @@
|
|||
package com.keylesspalace.tusky.components.timeline
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.keylesspalace.tusky.appstore.*
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.entity.Filter
|
||||
import com.keylesspalace.tusky.entity.Poll
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.network.FilterModel
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.network.TimelineCases
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.util.*
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.rx3.asFlow
|
||||
import kotlinx.coroutines.rx3.await
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
class TimelineViewModel @Inject constructor(
|
||||
private val timelineRepo: TimelineRepository,
|
||||
private val timelineCases: TimelineCases,
|
||||
private val api: MastodonApi,
|
||||
private val eventHub: EventHub,
|
||||
private val accountManager: AccountManager,
|
||||
private val sharedPreferences: SharedPreferences,
|
||||
private val filterModel: FilterModel,
|
||||
) : RxAwareViewModel() {
|
||||
|
||||
enum class FailureReason {
|
||||
NETWORK,
|
||||
OTHER,
|
||||
}
|
||||
|
||||
val viewUpdates: Observable<Unit>
|
||||
get() = updateViewSubject
|
||||
|
||||
var kind: Kind = Kind.HOME
|
||||
private set
|
||||
|
||||
var isLoadingInitially = false
|
||||
private set
|
||||
var isRefreshing = false
|
||||
private set
|
||||
var bottomLoading = false
|
||||
private set
|
||||
var initialUpdateFailed = false
|
||||
private set
|
||||
var failure: FailureReason? = null
|
||||
private set
|
||||
var id: String? = null
|
||||
private set
|
||||
var tags: List<String> = emptyList()
|
||||
private set
|
||||
|
||||
private var alwaysShowSensitiveMedia = false
|
||||
private var alwaysOpenSpoilers = false
|
||||
private var filterRemoveReplies = false
|
||||
private var filterRemoveReblogs = false
|
||||
private var didLoadEverythingBottom = false
|
||||
|
||||
private var updateViewSubject = PublishSubject.create<Unit>()
|
||||
|
||||
/**
|
||||
* For some timeline kinds we must use LINK headers and not just status ids.
|
||||
*/
|
||||
private var nextId: String? = null
|
||||
|
||||
val statuses = mutableListOf<StatusViewData>()
|
||||
|
||||
fun init(
|
||||
kind: Kind,
|
||||
id: String?,
|
||||
tags: List<String>
|
||||
) {
|
||||
this.kind = kind
|
||||
this.id = id
|
||||
this.tags = tags
|
||||
|
||||
if (kind == Kind.HOME) {
|
||||
filterRemoveReplies =
|
||||
!sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true)
|
||||
filterRemoveReblogs =
|
||||
!sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true)
|
||||
}
|
||||
this.alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia
|
||||
this.alwaysOpenSpoilers = accountManager.activeAccount!!.alwaysOpenSpoiler
|
||||
|
||||
viewModelScope.launch {
|
||||
eventHub.events
|
||||
.asFlow()
|
||||
.collect { event -> handleEvent(event) }
|
||||
}
|
||||
|
||||
reloadFilters()
|
||||
}
|
||||
|
||||
private suspend fun updateCurrent() {
|
||||
val topId = statuses.firstIsInstanceOrNull<StatusViewData.Concrete>()?.id ?: return
|
||||
// Request statuses including current top to refresh all of them
|
||||
val topIdMinusOne = topId.inc()
|
||||
val statuses = try {
|
||||
loadStatuses(
|
||||
maxId = topIdMinusOne,
|
||||
sinceId = null,
|
||||
sinceIdMinusOne = null,
|
||||
TimelineRequestMode.NETWORK,
|
||||
)
|
||||
} catch (t: Exception) {
|
||||
initialUpdateFailed = true
|
||||
if (isExpectedRequestException(t)) {
|
||||
Log.d(TAG, "Failed updating timeline", t)
|
||||
triggerViewUpdate()
|
||||
return
|
||||
} else {
|
||||
throw t
|
||||
}
|
||||
}
|
||||
|
||||
initialUpdateFailed = false
|
||||
|
||||
// When cached timeline is too old, we would replace it with nothing
|
||||
if (statuses.isNotEmpty()) {
|
||||
val mutableStatuses = statuses.toMutableList()
|
||||
filterStatuses(mutableStatuses)
|
||||
this.statuses.removeAll { item ->
|
||||
val id = when (item) {
|
||||
is StatusViewData.Concrete -> item.id
|
||||
is StatusViewData.Placeholder -> item.id
|
||||
}
|
||||
|
||||
id == topId || id.isLessThan(topId)
|
||||
}
|
||||
this.statuses.addAll(mutableStatuses.toViewData())
|
||||
}
|
||||
triggerViewUpdate()
|
||||
}
|
||||
|
||||
private fun isExpectedRequestException(t: Exception) = t is IOException || t is HttpException
|
||||
|
||||
fun refresh(): Job {
|
||||
return viewModelScope.launch {
|
||||
isRefreshing = true
|
||||
failure = null
|
||||
triggerViewUpdate()
|
||||
|
||||
try {
|
||||
if (initialUpdateFailed) updateCurrent()
|
||||
loadAbove()
|
||||
} catch (e: Exception) {
|
||||
if (isExpectedRequestException(e)) {
|
||||
Log.e(TAG, "Failed to refresh", e)
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
triggerViewUpdate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** When reaching the end of list. WIll optionally show spinner in the end of list. */
|
||||
fun loadMore(): Job {
|
||||
return viewModelScope.launch {
|
||||
if (didLoadEverythingBottom || bottomLoading) {
|
||||
return@launch
|
||||
}
|
||||
if (statuses.isEmpty()) {
|
||||
loadInitial().join()
|
||||
return@launch
|
||||
}
|
||||
setLoadingPlaceholderBelow()
|
||||
|
||||
val bottomId: String? =
|
||||
if (kind == Kind.FAVOURITES || kind == Kind.BOOKMARKS) {
|
||||
nextId
|
||||
} else {
|
||||
statuses.lastOrNull { it is StatusViewData.Concrete }
|
||||
?.let { (it as StatusViewData.Concrete).id }
|
||||
}
|
||||
try {
|
||||
loadBelow(bottomId)
|
||||
} catch (e: Exception) {
|
||||
if (isExpectedRequestException(e)) {
|
||||
if (statuses.lastOrNull() is StatusViewData.Placeholder) {
|
||||
statuses.removeAt(statuses.lastIndex)
|
||||
}
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
} finally {
|
||||
triggerViewUpdate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Load and insert statuses below the [bottomId]. Does not indicate progress. */
|
||||
private suspend fun loadBelow(bottomId: String?) {
|
||||
this.bottomLoading = true
|
||||
try {
|
||||
val statuses = loadStatuses(
|
||||
bottomId,
|
||||
null,
|
||||
null,
|
||||
TimelineRequestMode.ANY
|
||||
)
|
||||
addStatusesBelow(statuses.toMutableList())
|
||||
} finally {
|
||||
this.bottomLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun setLoadingPlaceholderBelow() {
|
||||
val last = statuses.last()
|
||||
val placeholder: StatusViewData.Placeholder
|
||||
if (last is StatusViewData.Concrete) {
|
||||
val placeholderId = last.id.dec()
|
||||
placeholder = StatusViewData.Placeholder(placeholderId, true)
|
||||
statuses.add(placeholder)
|
||||
} else {
|
||||
placeholder = last as StatusViewData.Placeholder
|
||||
}
|
||||
statuses[statuses.lastIndex] = placeholder
|
||||
triggerViewUpdate()
|
||||
}
|
||||
|
||||
private fun addStatusesBelow(statuses: MutableList<Either<Placeholder, Status>>) {
|
||||
val fullFetch = isFullFetch(statuses)
|
||||
// Remove placeholder in the bottom if it's there
|
||||
if (this.statuses.isNotEmpty()
|
||||
&& this.statuses.last() !is StatusViewData.Concrete
|
||||
) {
|
||||
this.statuses.removeAt(this.statuses.lastIndex)
|
||||
}
|
||||
|
||||
// Removing placeholder if it's the last one from the cache
|
||||
if (statuses.isNotEmpty() && !statuses[statuses.size - 1].isRight()) {
|
||||
statuses.removeAt(statuses.size - 1)
|
||||
}
|
||||
|
||||
val oldSize = this.statuses.size
|
||||
if (this.statuses.isNotEmpty()) {
|
||||
addItems(statuses)
|
||||
} else {
|
||||
updateStatuses(statuses, fullFetch)
|
||||
}
|
||||
if (this.statuses.size == oldSize) {
|
||||
// This may be a brittle check but seems like it works
|
||||
// Can we check it using headers somehow? Do all server support them?
|
||||
didLoadEverythingBottom = true
|
||||
}
|
||||
}
|
||||
|
||||
fun loadGap(position: Int): Job {
|
||||
return viewModelScope.launch {
|
||||
//check bounds before accessing list,
|
||||
if (statuses.size < position || position <= 0) {
|
||||
Log.e(TAG, "Wrong gap position: $position")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val fromStatus = statuses[position - 1].asStatusOrNull()
|
||||
val toStatus = statuses[position + 1].asStatusOrNull()
|
||||
val toMinusOne = statuses.getOrNull(position + 2)?.asStatusOrNull()?.id
|
||||
if (fromStatus == null || toStatus == null) {
|
||||
Log.e(TAG, "Failed to load more at $position, wrong placeholder position")
|
||||
return@launch
|
||||
}
|
||||
val placeholder = statuses[position].asPlaceholderOrNull() ?: run {
|
||||
Log.e(TAG, "Not a placeholder at $position")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val newViewData: StatusViewData = StatusViewData.Placeholder(placeholder.id, true)
|
||||
statuses[position] = newViewData
|
||||
triggerViewUpdate()
|
||||
|
||||
try {
|
||||
val statuses = loadStatuses(
|
||||
fromStatus.id,
|
||||
toStatus.id,
|
||||
toMinusOne,
|
||||
TimelineRequestMode.NETWORK
|
||||
)
|
||||
replacePlaceholderWithStatuses(
|
||||
statuses.toMutableList(),
|
||||
isFullFetch(statuses),
|
||||
position
|
||||
)
|
||||
} catch (t: Exception) {
|
||||
if (isExpectedRequestException(t)) {
|
||||
Log.e(TAG, "Failed to load gap", t)
|
||||
if (statuses[position] is StatusViewData.Placeholder) {
|
||||
statuses[position] = StatusViewData.Placeholder(placeholder.id, false)
|
||||
}
|
||||
} else {
|
||||
throw t
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reblog(reblog: Boolean, position: Int): Job = viewModelScope.launch {
|
||||
val status = statuses[position].asStatusOrNull() ?: return@launch
|
||||
try {
|
||||
timelineCases.reblog(status.id, reblog).await()
|
||||
} catch (t: Exception) {
|
||||
ifExpected(t) {
|
||||
Log.d(TAG, "Failed to reblog status " + status.id, t)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun favorite(favorite: Boolean, position: Int): Job = viewModelScope.launch {
|
||||
val status = statuses[position].asStatusOrNull() ?: return@launch
|
||||
|
||||
try {
|
||||
timelineCases.favourite(status.id, favorite).await()
|
||||
} catch (t: Exception) {
|
||||
ifExpected(t) {
|
||||
Log.d(TAG, "Failed to favourite status " + status.id, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun bookmark(bookmark: Boolean, position: Int): Job = viewModelScope.launch {
|
||||
val status = statuses[position].asStatusOrNull() ?: return@launch
|
||||
try {
|
||||
timelineCases.bookmark(status.id, bookmark).await()
|
||||
} catch (t: Exception) {
|
||||
ifExpected(t) {
|
||||
Log.d(TAG, "Failed to favourite status " + status.id, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun voteInPoll(position: Int, choices: List<Int>): Job = viewModelScope.launch {
|
||||
val status = statuses[position].asStatusOrNull() ?: return@launch
|
||||
|
||||
val poll = status.status.poll ?: run {
|
||||
Log.w(TAG, "No poll on status ${status.id}")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val votedPoll = poll.votedCopy(choices)
|
||||
updatePoll(status, votedPoll)
|
||||
|
||||
try {
|
||||
timelineCases.voteInPoll(status.id, poll.id, choices).await()
|
||||
} catch (t: Exception) {
|
||||
ifExpected(t) {
|
||||
Log.d(TAG, "Failed to vote in poll: " + status.id, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePoll(
|
||||
status: StatusViewData.Concrete,
|
||||
newPoll: Poll
|
||||
) {
|
||||
updateStatusById(status.id) {
|
||||
it.copy(status = it.status.copy(poll = newPoll))
|
||||
}
|
||||
}
|
||||
|
||||
fun changeExpanded(expanded: Boolean, position: Int) {
|
||||
updateStatusAt(position) { it.copy(isExpanded = expanded) }
|
||||
triggerViewUpdate()
|
||||
}
|
||||
|
||||
fun changeContentHidden(isShowing: Boolean, position: Int) {
|
||||
updateStatusAt(position) { it.copy(isShowingContent = isShowing) }
|
||||
triggerViewUpdate()
|
||||
}
|
||||
|
||||
fun changeContentCollapsed(isCollapsed: Boolean, position: Int) {
|
||||
updateStatusAt(position) { it.copy(isCollapsed = isCollapsed) }
|
||||
triggerViewUpdate()
|
||||
}
|
||||
|
||||
private fun removeAllByAccountId(accountId: String) {
|
||||
statuses.removeAll { vm ->
|
||||
val status = vm.asStatusOrNull()?.status ?: return@removeAll false
|
||||
status.account.id == accountId || status.actionableStatus.account.id == accountId
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeAllByInstance(instance: String) {
|
||||
statuses.removeAll { vd ->
|
||||
val status = vd.asStatusOrNull()?.status ?: return@removeAll false
|
||||
LinkHelper.getDomain(status.account.url) == instance
|
||||
}
|
||||
}
|
||||
|
||||
private fun triggerViewUpdate() {
|
||||
this.updateViewSubject.onNext(Unit)
|
||||
}
|
||||
|
||||
private suspend fun loadStatuses(
|
||||
maxId: String?,
|
||||
sinceId: String?,
|
||||
sinceIdMinusOne: String?,
|
||||
homeMode: TimelineRequestMode,
|
||||
): List<TimelineStatus> {
|
||||
val statuses = if (kind == Kind.HOME) {
|
||||
timelineRepo.getStatuses(maxId, sinceId, sinceIdMinusOne, LOAD_AT_ONCE, homeMode)
|
||||
.await()
|
||||
} else {
|
||||
val response = fetchStatusesForKind(maxId, sinceId, LOAD_AT_ONCE).await()
|
||||
if (response.isSuccessful) {
|
||||
val newNextId = extractNextId(response)
|
||||
if (newNextId != null) {
|
||||
// when we reach the bottom of the list, we won't have a new link. If
|
||||
// we blindly write `null` here we will start loading from the top
|
||||
// again.
|
||||
nextId = newNextId
|
||||
}
|
||||
response.body()?.map { Either.Right(it) } ?: listOf()
|
||||
} else {
|
||||
throw Exception(response.message())
|
||||
}
|
||||
}
|
||||
|
||||
filterStatuses(statuses.toMutableList())
|
||||
|
||||
return statuses
|
||||
}
|
||||
|
||||
private fun updateStatuses(
|
||||
newStatuses: MutableList<Either<Placeholder, Status>>,
|
||||
fullFetch: Boolean
|
||||
) {
|
||||
if (statuses.isEmpty()) {
|
||||
statuses.addAll(newStatuses.toViewData())
|
||||
} else {
|
||||
val lastOfNew = newStatuses.lastOrNull()
|
||||
val index = if (lastOfNew == null) -1
|
||||
else statuses.indexOfLast { it.asStatusOrNull()?.id === lastOfNew.asRightOrNull()?.id }
|
||||
if (index >= 0) {
|
||||
statuses.subList(0, index).clear()
|
||||
}
|
||||
|
||||
val newIndex =
|
||||
newStatuses.indexOfFirst {
|
||||
it.isRight() && it.asRight().id == (statuses[0] as? StatusViewData.Concrete)?.id
|
||||
}
|
||||
if (newIndex == -1) {
|
||||
if (index == -1 && fullFetch) {
|
||||
val placeholderId =
|
||||
newStatuses.last { status -> status.isRight() }.asRight().id.inc()
|
||||
newStatuses.add(Either.Left(Placeholder(placeholderId)))
|
||||
}
|
||||
statuses.addAll(0, newStatuses.toViewData())
|
||||
} else {
|
||||
statuses.addAll(0, newStatuses.subList(0, newIndex).toViewData())
|
||||
}
|
||||
}
|
||||
// Remove all consecutive placeholders
|
||||
removeConsecutivePlaceholders()
|
||||
this.triggerViewUpdate()
|
||||
}
|
||||
|
||||
private fun filterViewData(viewData: MutableList<StatusViewData>) {
|
||||
viewData.removeAll { vd ->
|
||||
vd.asStatusOrNull()?.status?.let { shouldFilterStatus(it) } ?: false
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterStatuses(statuses: MutableList<Either<Placeholder, Status>>) {
|
||||
statuses.removeAll { status ->
|
||||
status.asRightOrNull()?.let { shouldFilterStatus(it) } ?: false
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldFilterStatus(status: Status): Boolean {
|
||||
return status.inReplyToId != null && filterRemoveReplies
|
||||
|| status.reblog != null && filterRemoveReblogs
|
||||
|| filterModel.shouldFilterStatus(status.actionableStatus)
|
||||
}
|
||||
|
||||
private fun extractNextId(response: Response<*>): String? {
|
||||
val linkHeader = response.headers()["Link"] ?: return null
|
||||
val links = HttpHeaderLink.parse(linkHeader)
|
||||
val nextHeader = HttpHeaderLink.findByRelationType(links, "next") ?: return null
|
||||
val nextLink = nextHeader.uri ?: return null
|
||||
return nextLink.getQueryParameter("max_id")
|
||||
}
|
||||
|
||||
private suspend fun tryCache() {
|
||||
// Request timeline from disk to make it quick, then replace it with timeline from
|
||||
// the server to update it
|
||||
val statuses =
|
||||
timelineRepo.getStatuses(null, null, null, LOAD_AT_ONCE, TimelineRequestMode.DISK)
|
||||
.await()
|
||||
|
||||
val mutableStatusResponse = statuses.toMutableList()
|
||||
filterStatuses(mutableStatusResponse)
|
||||
if (statuses.size > 1) {
|
||||
clearPlaceholdersForResponse(mutableStatusResponse)
|
||||
this.statuses.clear()
|
||||
this.statuses.addAll(statuses.toViewData())
|
||||
}
|
||||
}
|
||||
|
||||
fun loadInitial(): Job {
|
||||
return viewModelScope.launch {
|
||||
if (statuses.isNotEmpty() || initialUpdateFailed || isLoadingInitially) {
|
||||
return@launch
|
||||
}
|
||||
isLoadingInitially = true
|
||||
failure = null
|
||||
triggerViewUpdate()
|
||||
|
||||
if (kind == Kind.HOME) {
|
||||
tryCache()
|
||||
isLoadingInitially = statuses.isEmpty()
|
||||
updateCurrent()
|
||||
try {
|
||||
loadAbove()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Loading above failed", e)
|
||||
if (!isExpectedRequestException(e)) {
|
||||
throw e
|
||||
} else if (statuses.isEmpty()) {
|
||||
failure =
|
||||
if (e is IOException) FailureReason.NETWORK
|
||||
else FailureReason.OTHER
|
||||
}
|
||||
} finally {
|
||||
isLoadingInitially = false
|
||||
triggerViewUpdate()
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
loadBelow(null)
|
||||
} catch (e: IOException) {
|
||||
failure = FailureReason.NETWORK
|
||||
} catch (e: HttpException) {
|
||||
failure = FailureReason.OTHER
|
||||
} finally {
|
||||
isLoadingInitially = false
|
||||
triggerViewUpdate()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadAbove() {
|
||||
var firstOrNull: String? = null
|
||||
var secondOrNull: String? = null
|
||||
for (i in statuses.indices) {
|
||||
val status = statuses[i].asStatusOrNull() ?: continue
|
||||
firstOrNull = status.id
|
||||
secondOrNull = statuses.getOrNull(i + 1)?.asStatusOrNull()?.id
|
||||
break
|
||||
}
|
||||
|
||||
try {
|
||||
if (firstOrNull != null) {
|
||||
triggerViewUpdate()
|
||||
|
||||
val statuses = loadStatuses(
|
||||
maxId = null,
|
||||
sinceId = firstOrNull,
|
||||
sinceIdMinusOne = secondOrNull,
|
||||
homeMode = TimelineRequestMode.NETWORK
|
||||
)
|
||||
|
||||
val fullFetch = isFullFetch(statuses)
|
||||
updateStatuses(statuses.toMutableList(), fullFetch)
|
||||
} else {
|
||||
loadBelow(null)
|
||||
}
|
||||
} finally {
|
||||
triggerViewUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isFullFetch(statuses: List<TimelineStatus>) = statuses.size >= LOAD_AT_ONCE
|
||||
|
||||
private fun fullyRefresh(): Job {
|
||||
this.statuses.clear()
|
||||
return loadInitial()
|
||||
}
|
||||
|
||||
private fun fetchStatusesForKind(
|
||||
fromId: String?,
|
||||
uptoId: String?,
|
||||
limit: Int
|
||||
): Single<Response<List<Status>>> {
|
||||
return when (kind) {
|
||||
Kind.HOME -> api.homeTimeline(fromId, uptoId, limit)
|
||||
Kind.PUBLIC_FEDERATED -> api.publicTimeline(null, fromId, uptoId, limit)
|
||||
Kind.PUBLIC_LOCAL -> api.publicTimeline(true, fromId, uptoId, limit)
|
||||
Kind.TAG -> {
|
||||
val firstHashtag = tags[0]
|
||||
val additionalHashtags = tags.subList(1, tags.size)
|
||||
api.hashtagTimeline(firstHashtag, additionalHashtags, null, fromId, uptoId, limit)
|
||||
}
|
||||
Kind.USER -> api.accountStatuses(
|
||||
id!!,
|
||||
fromId,
|
||||
uptoId,
|
||||
limit,
|
||||
excludeReplies = true,
|
||||
onlyMedia = null,
|
||||
pinned = null
|
||||
)
|
||||
Kind.USER_PINNED -> api.accountStatuses(
|
||||
id!!,
|
||||
fromId,
|
||||
uptoId,
|
||||
limit,
|
||||
excludeReplies = null,
|
||||
onlyMedia = null,
|
||||
pinned = true
|
||||
)
|
||||
Kind.USER_WITH_REPLIES -> api.accountStatuses(
|
||||
id!!,
|
||||
fromId,
|
||||
uptoId,
|
||||
limit,
|
||||
excludeReplies = null,
|
||||
onlyMedia = null,
|
||||
pinned = null
|
||||
)
|
||||
Kind.FAVOURITES -> api.favourites(fromId, uptoId, limit)
|
||||
Kind.BOOKMARKS -> api.bookmarks(fromId, uptoId, limit)
|
||||
Kind.LIST -> api.listTimeline(id!!, fromId, uptoId, limit)
|
||||
}
|
||||
}
|
||||
|
||||
private fun replacePlaceholderWithStatuses(
|
||||
newStatuses: MutableList<Either<Placeholder, Status>>,
|
||||
fullFetch: Boolean, pos: Int
|
||||
) {
|
||||
val placeholder = statuses[pos]
|
||||
if (placeholder is StatusViewData.Placeholder) {
|
||||
statuses.removeAt(pos)
|
||||
}
|
||||
if (newStatuses.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val newViewData = newStatuses
|
||||
.toViewData()
|
||||
.toMutableList()
|
||||
|
||||
if (fullFetch) {
|
||||
newViewData.add(placeholder)
|
||||
}
|
||||
statuses.addAll(pos, newViewData)
|
||||
removeConsecutivePlaceholders()
|
||||
triggerViewUpdate()
|
||||
}
|
||||
|
||||
private fun removeConsecutivePlaceholders() {
|
||||
for (i in 0 until statuses.size - 1) {
|
||||
if (statuses[i] is StatusViewData.Placeholder &&
|
||||
statuses[i + 1] is StatusViewData.Placeholder
|
||||
) {
|
||||
statuses.removeAt(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addItems(newStatuses: List<Either<Placeholder, Status>>) {
|
||||
if (newStatuses.isEmpty()) {
|
||||
return
|
||||
}
|
||||
statuses.addAll(newStatuses.toViewData())
|
||||
removeConsecutivePlaceholders()
|
||||
}
|
||||
|
||||
/**
|
||||
* For certain requests we don't want to see placeholders, they will be removed some other way
|
||||
*/
|
||||
private fun clearPlaceholdersForResponse(statuses: MutableList<Either<Placeholder, Status>>) {
|
||||
statuses.removeAll { status -> status.isLeft() }
|
||||
}
|
||||
|
||||
private fun handleReblogEvent(reblogEvent: ReblogEvent) {
|
||||
updateStatusById(reblogEvent.statusId) {
|
||||
it.copy(status = it.status.copy(reblogged = reblogEvent.reblog))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleFavEvent(favEvent: FavoriteEvent) {
|
||||
updateStatusById(favEvent.statusId) {
|
||||
it.copy(status = it.status.copy(favourited = favEvent.favourite))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleBookmarkEvent(bookmarkEvent: BookmarkEvent) {
|
||||
updateStatusById(bookmarkEvent.statusId) {
|
||||
it.copy(status = it.status.copy(bookmarked = bookmarkEvent.bookmark))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePinEvent(pinEvent: PinEvent) {
|
||||
updateStatusById(pinEvent.statusId) {
|
||||
it.copy(status = it.status.copy(pinned = pinEvent.pinned))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleStatusComposeEvent(status: Status) {
|
||||
when (kind) {
|
||||
Kind.HOME, Kind.PUBLIC_FEDERATED, Kind.PUBLIC_LOCAL -> refresh()
|
||||
Kind.USER, Kind.USER_WITH_REPLIES -> if (status.account.id == id) {
|
||||
refresh()
|
||||
} else {
|
||||
return
|
||||
}
|
||||
Kind.TAG, Kind.FAVOURITES, Kind.LIST, Kind.BOOKMARKS, Kind.USER_PINNED -> return
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteStatusById(id: String) {
|
||||
for (i in statuses.indices) {
|
||||
val either = statuses[i]
|
||||
if (either.asStatusOrNull()?.id == id) {
|
||||
statuses.removeAt(i)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onPreferenceChanged(key: String) {
|
||||
when (key) {
|
||||
PrefKeys.TAB_FILTER_HOME_REPLIES -> {
|
||||
val filter = sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true)
|
||||
val oldRemoveReplies = filterRemoveReplies
|
||||
filterRemoveReplies = kind == Kind.HOME && !filter
|
||||
if (statuses.isNotEmpty() && oldRemoveReplies != filterRemoveReplies) {
|
||||
fullyRefresh()
|
||||
}
|
||||
}
|
||||
PrefKeys.TAB_FILTER_HOME_BOOSTS -> {
|
||||
val filter = sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true)
|
||||
val oldRemoveReblogs = filterRemoveReblogs
|
||||
filterRemoveReblogs = kind == Kind.HOME && !filter
|
||||
if (statuses.isNotEmpty() && oldRemoveReblogs != filterRemoveReblogs) {
|
||||
fullyRefresh()
|
||||
}
|
||||
}
|
||||
Filter.HOME, Filter.NOTIFICATIONS, Filter.THREAD, Filter.PUBLIC, Filter.ACCOUNT -> {
|
||||
if (filterContextMatchesKind(kind, listOf(key))) {
|
||||
reloadFilters()
|
||||
}
|
||||
}
|
||||
PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> {
|
||||
// it is ok if only newly loaded statuses are affected, no need to fully refresh
|
||||
alwaysShowSensitiveMedia =
|
||||
accountManager.activeAccount!!.alwaysShowSensitiveMedia
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// public for now
|
||||
fun filterContextMatchesKind(
|
||||
kind: Kind,
|
||||
filterContext: List<String>
|
||||
): Boolean {
|
||||
// home, notifications, public, thread
|
||||
return when (kind) {
|
||||
Kind.HOME, Kind.LIST -> filterContext.contains(
|
||||
Filter.HOME
|
||||
)
|
||||
Kind.PUBLIC_FEDERATED, Kind.PUBLIC_LOCAL, Kind.TAG -> filterContext.contains(
|
||||
Filter.PUBLIC
|
||||
)
|
||||
Kind.FAVOURITES -> filterContext.contains(Filter.PUBLIC) || filterContext.contains(
|
||||
Filter.NOTIFICATIONS
|
||||
)
|
||||
Kind.USER, Kind.USER_WITH_REPLIES, Kind.USER_PINNED -> filterContext.contains(
|
||||
Filter.ACCOUNT
|
||||
)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleEvent(event: Event) {
|
||||
when (event) {
|
||||
is FavoriteEvent -> handleFavEvent(event)
|
||||
is ReblogEvent -> handleReblogEvent(event)
|
||||
is BookmarkEvent -> handleBookmarkEvent(event)
|
||||
is PinEvent -> handlePinEvent(event)
|
||||
is MuteConversationEvent -> fullyRefresh()
|
||||
is UnfollowEvent -> {
|
||||
if (kind == Kind.HOME) {
|
||||
val id = event.accountId
|
||||
removeAllByAccountId(id)
|
||||
}
|
||||
}
|
||||
is BlockEvent -> {
|
||||
if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) {
|
||||
val id = event.accountId
|
||||
removeAllByAccountId(id)
|
||||
}
|
||||
}
|
||||
is MuteEvent -> {
|
||||
if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) {
|
||||
val id = event.accountId
|
||||
removeAllByAccountId(id)
|
||||
}
|
||||
}
|
||||
is DomainMuteEvent -> {
|
||||
if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) {
|
||||
val instance = event.instance
|
||||
removeAllByInstance(instance)
|
||||
}
|
||||
}
|
||||
is StatusDeletedEvent -> {
|
||||
if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) {
|
||||
val id = event.statusId
|
||||
deleteStatusById(id)
|
||||
}
|
||||
}
|
||||
is StatusComposedEvent -> {
|
||||
val status = event.status
|
||||
handleStatusComposeEvent(status)
|
||||
}
|
||||
is PreferenceChangedEvent -> {
|
||||
onPreferenceChanged(event.preferenceKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun updateStatusById(
|
||||
id: String,
|
||||
updater: (StatusViewData.Concrete) -> StatusViewData.Concrete
|
||||
) {
|
||||
val pos = statuses.indexOfFirst { it.asStatusOrNull()?.id == id }
|
||||
if (pos == -1) return
|
||||
updateStatusAt(pos, updater)
|
||||
}
|
||||
|
||||
private inline fun updateStatusAt(
|
||||
position: Int,
|
||||
updater: (StatusViewData.Concrete) -> StatusViewData.Concrete
|
||||
) {
|
||||
val status = statuses.getOrNull(position)?.asStatusOrNull() ?: return
|
||||
statuses[position] = updater(status)
|
||||
triggerViewUpdate()
|
||||
}
|
||||
|
||||
private fun List<TimelineStatus>.toViewData(): List<StatusViewData> = this.map {
|
||||
when (it) {
|
||||
is Either.Right -> it.value.toViewData(
|
||||
alwaysShowSensitiveMedia,
|
||||
alwaysOpenSpoilers
|
||||
)
|
||||
is Either.Left -> StatusViewData.Placeholder(it.value.id, false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun reloadFilters() {
|
||||
viewModelScope.launch {
|
||||
val filters = try {
|
||||
api.getFilters().await()
|
||||
} catch (t: Exception) {
|
||||
Log.e(TAG, "Failed to fetch filters", t)
|
||||
return@launch
|
||||
}
|
||||
filterModel.initWithFilters(filters.filter {
|
||||
filterContextMatchesKind(kind, it.context)
|
||||
})
|
||||
filterViewData(this@TimelineViewModel.statuses)
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun ifExpected(
|
||||
t: Exception,
|
||||
cb: () -> Unit
|
||||
) {
|
||||
if (isExpectedRequestException(t)) {
|
||||
cb()
|
||||
} else {
|
||||
throw t
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TimelineVM"
|
||||
internal const val LOAD_AT_ONCE = 30
|
||||
}
|
||||
|
||||
enum class Kind {
|
||||
HOME, PUBLIC_LOCAL, PUBLIC_FEDERATED, TAG, USER, USER_PINNED, USER_WITH_REPLIES, FAVOURITES, LIST, BOOKMARKS
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue