From 626a8760ae7c1ef3791fd2d3b9f621a6f8514dfe Mon Sep 17 00:00:00 2001 From: Conny Duck Date: Tue, 4 Jul 2023 19:30:57 +0200 Subject: [PATCH] refactor instance blocks to paging --- .../instancemute/InstanceMutePagingSource.kt | 16 ++ .../InstanceMuteRemoteMediator.kt | 56 ++++++ .../instancemute/InstanceMuteViewModel.kt | 71 +++++++ .../adapter/DomainMutesAdapter.kt | 47 +---- .../fragment/InstanceListFragment.kt | 178 +++++++----------- .../interfaces/InstanceActionListener.kt | 5 - .../tusky/di/ViewModelFactory.kt | 6 + .../tusky/network/MastodonApi.kt | 4 +- app/src/main/res/values/strings.xml | 2 + 9 files changed, 226 insertions(+), 159 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/instancemute/InstanceMutePagingSource.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/instancemute/InstanceMuteRemoteMediator.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/instancemute/InstanceMuteViewModel.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/components/instancemute/interfaces/InstanceActionListener.kt diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/InstanceMutePagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/InstanceMutePagingSource.kt new file mode 100644 index 000000000..e61617e6a --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/InstanceMutePagingSource.kt @@ -0,0 +1,16 @@ +package com.keylesspalace.tusky.components.instancemute + +import androidx.paging.PagingSource +import androidx.paging.PagingState + +class InstanceMutePagingSource(private val viewModel: InstanceMuteViewModel) : PagingSource() { + override fun getRefreshKey(state: PagingState): String? = null + + override suspend fun load(params: LoadParams): LoadResult { + return if (params is LoadParams.Refresh) { + LoadResult.Page(viewModel.domains.toList(), null, viewModel.nextKey) + } else { + LoadResult.Page(emptyList(), null, null) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/InstanceMuteRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/InstanceMuteRemoteMediator.kt new file mode 100644 index 000000000..62ac91b72 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/InstanceMuteRemoteMediator.kt @@ -0,0 +1,56 @@ +package com.keylesspalace.tusky.components.instancemute + +import androidx.paging.ExperimentalPagingApi +import androidx.paging.LoadType +import androidx.paging.PagingState +import androidx.paging.RemoteMediator +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.HttpHeaderLink +import retrofit2.HttpException +import retrofit2.Response + +@OptIn(ExperimentalPagingApi::class) +class InstanceMuteRemoteMediator( + private val api: MastodonApi, + private val viewModel: InstanceMuteViewModel +) : RemoteMediator() { + override suspend fun load( + loadType: LoadType, + state: PagingState + ): MediatorResult { + return try { + val response = request(loadType) + ?: return MediatorResult.Success(endOfPaginationReached = true) + + return applyResponse(response) + } catch (e: Exception) { + MediatorResult.Error(e) + } + } + + private suspend fun request(loadType: LoadType): Response>? { + return when (loadType) { + LoadType.PREPEND -> null + LoadType.APPEND -> api.domainBlocks(maxId = viewModel.nextKey) + LoadType.REFRESH -> { + viewModel.nextKey = null + viewModel.domains.clear() + api.domainBlocks() + } + } + } + + private fun applyResponse(response: Response>): MediatorResult { + val tags = response.body() + if (!response.isSuccessful || tags == null) { + return MediatorResult.Error(HttpException(response)) + } + + val links = HttpHeaderLink.parse(response.headers()["Link"]) + viewModel.nextKey = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id") + viewModel.domains.addAll(tags) + viewModel.currentSource?.invalidate() + + return MediatorResult.Success(endOfPaginationReached = viewModel.nextKey == null) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/InstanceMuteViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/InstanceMuteViewModel.kt new file mode 100644 index 000000000..84444d793 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/InstanceMuteViewModel.kt @@ -0,0 +1,71 @@ +package com.keylesspalace.tusky.components.instancemute + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.ExperimentalPagingApi +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.cachedIn +import at.connyduck.calladapter.networkresult.fold +import com.keylesspalace.tusky.network.MastodonApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +class InstanceMuteViewModel @Inject constructor( + private val api: MastodonApi +) : ViewModel() { + val domains: MutableList = mutableListOf() + val uiEvents = MutableSharedFlow() + var nextKey: String? = null + var currentSource: InstanceMutePagingSource? = null + + @OptIn(ExperimentalPagingApi::class) + val pager = Pager( + config = PagingConfig(pageSize = 20), + remoteMediator = InstanceMuteRemoteMediator(api, this), + pagingSourceFactory = { + InstanceMutePagingSource( + viewModel = this + ).also { source -> + currentSource = source + } + } + ).flow.cachedIn(viewModelScope) + + fun mute(domain: String) { + viewModelScope.launch { + api.blockDomain(domain).fold({ + domains.add(domain) + currentSource?.invalidate() + }, { e -> + Log.w(TAG, "Error muting domain $domain", e) + uiEvents.emit(InstanceMuteEvent.MuteError(domain)) + }) + } + } + + fun unmute(domain: String) { + viewModelScope.launch { + api.unblockDomain(domain).fold({ + domains.remove(domain) + currentSource?.invalidate() + uiEvents.emit(InstanceMuteEvent.UnmuteSuccess(domain)) + }, { e -> + Log.w(TAG, "Error unmuting domain $domain", e) + uiEvents.emit(InstanceMuteEvent.UnmuteError(domain)) + }) + } + } + + companion object { + private const val TAG = "InstanceMuteViewModel" + } +} + +sealed class InstanceMuteEvent { + data class UnmuteSuccess(val domain: String) : InstanceMuteEvent() + data class UnmuteError(val domain: String) : InstanceMuteEvent() + data class MuteError(val domain: String) : InstanceMuteEvent() +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/adapter/DomainMutesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/adapter/DomainMutesAdapter.kt index 13d8f2d83..e4f15d54b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/adapter/DomainMutesAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/adapter/DomainMutesAdapter.kt @@ -2,17 +2,14 @@ package com.keylesspalace.tusky.components.instancemute.adapter import android.view.LayoutInflater import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.keylesspalace.tusky.components.instancemute.interfaces.InstanceActionListener +import androidx.paging.PagingDataAdapter +import com.keylesspalace.tusky.components.followedtags.FollowedTagsAdapter.Companion.STRING_COMPARATOR import com.keylesspalace.tusky.databinding.ItemMutedDomainBinding import com.keylesspalace.tusky.util.BindingHolder class DomainMutesAdapter( - private val actionListener: InstanceActionListener -) : RecyclerView.Adapter>() { - - var instances: MutableList = mutableListOf() - var bottomLoading: Boolean = false + private val onUnmute: (String) -> Unit +) : PagingDataAdapter>(STRING_COMPARATOR) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { val binding = ItemMutedDomainBinding.inflate(LayoutInflater.from(parent.context), parent, false) @@ -20,37 +17,11 @@ class DomainMutesAdapter( } override fun onBindViewHolder(holder: BindingHolder, position: Int) { - val instance = instances[position] - - holder.binding.mutedDomain.text = instance - holder.binding.mutedDomainUnmute.setOnClickListener { - actionListener.mute(false, instance, holder.bindingAdapterPosition) - } - } - - override fun getItemCount(): Int { - var count = instances.size - if (bottomLoading) { - ++count - } - return count - } - - fun addItems(newInstances: List) { - val end = instances.size - instances.addAll(newInstances) - notifyItemRangeInserted(end, instances.size) - } - - fun addItem(instance: String) { - instances.add(instance) - notifyItemInserted(instances.size) - } - - fun removeItem(position: Int) { - if (position >= 0 && position < instances.size) { - instances.removeAt(position) - notifyItemRemoved(position) + getItem(position)?.let { instance -> + holder.binding.mutedDomain.text = instance + holder.binding.mutedDomainUnmute.setOnClickListener { + onUnmute(instance) + } } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/fragment/InstanceListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/fragment/InstanceListFragment.kt index 1da0a2b7d..71e1a0482 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/fragment/InstanceListFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/fragment/InstanceListFragment.kt @@ -4,155 +4,105 @@ import android.os.Bundle import android.util.Log import android.view.View import androidx.fragment.app.Fragment -import androidx.lifecycle.Lifecycle +import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope +import androidx.paging.LoadState import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import at.connyduck.calladapter.networkresult.fold -import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from -import autodispose2.autoDispose import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity +import com.keylesspalace.tusky.components.instancemute.InstanceMuteEvent +import com.keylesspalace.tusky.components.instancemute.InstanceMuteViewModel import com.keylesspalace.tusky.components.instancemute.adapter.DomainMutesAdapter -import com.keylesspalace.tusky.components.instancemute.interfaces.InstanceActionListener import com.keylesspalace.tusky.databinding.FragmentInstanceListBinding import com.keylesspalace.tusky.di.Injectable -import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.HttpHeaderLink +import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding -import com.keylesspalace.tusky.view.EndlessOnScrollListener -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import com.keylesspalace.tusky.util.visible +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import javax.inject.Inject -class InstanceListFragment : Fragment(R.layout.fragment_instance_list), Injectable, InstanceActionListener { +class InstanceListFragment : Fragment(R.layout.fragment_instance_list), Injectable { @Inject - lateinit var api: MastodonApi + lateinit var viewModelFactory: ViewModelFactory private val binding by viewBinding(FragmentInstanceListBinding::bind) - private var fetching = false - private var bottomId: String? = null - private var adapter = DomainMutesAdapter(this) - private lateinit var scrollListener: EndlessOnScrollListener + private val viewModel: InstanceMuteViewModel by viewModels { viewModelFactory } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + val adapter = DomainMutesAdapter(viewModel::unmute) binding.recyclerView.setHasFixedSize(true) binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) binding.recyclerView.adapter = adapter + binding.recyclerView.layoutManager = LinearLayoutManager(view.context) - val layoutManager = LinearLayoutManager(view.context) - binding.recyclerView.layoutManager = layoutManager - - scrollListener = object : EndlessOnScrollListener(layoutManager) { - override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) { - if (bottomId != null) { - fetchInstances(bottomId) - } - } - } - - binding.recyclerView.addOnScrollListener(scrollListener) - fetchInstances() - } - - override fun mute(mute: Boolean, instance: String, position: Int) { viewLifecycleOwner.lifecycleScope.launch { - if (mute) { - api.blockDomain(instance).fold({ - adapter.addItem(instance) - }, { e -> - Log.e(TAG, "Error muting domain $instance", e) - }) - } else { - api.unblockDomain(instance).fold({ - adapter.removeItem(position) - Snackbar.make(binding.recyclerView, getString(R.string.confirmation_domain_unmuted, instance), Snackbar.LENGTH_LONG) - .setAction(R.string.action_undo) { - mute(true, instance, position) - } - .show() - }, { e -> - Log.e(TAG, "Error unmuting domain $instance", e) - }) - } - } - } - - private fun fetchInstances(id: String? = null) { - if (fetching) { - return - } - fetching = true - binding.instanceProgressBar.show() - - if (id != null) { - binding.recyclerView.post { adapter.bottomLoading = true } - } - - api.domainBlocks(id, bottomId) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) - .subscribe( - { response -> - val instances = response.body() - - if (response.isSuccessful && instances != null) { - onFetchInstancesSuccess(instances, response.headers()["Link"]) - } else { - onFetchInstancesFailure(Exception(response.message())) - } - }, - { throwable -> - onFetchInstancesFailure(throwable) + viewModel.uiEvents.collect { event -> + when (event) { + is InstanceMuteEvent.UnmuteError -> showUnmuteError(event.domain) + is InstanceMuteEvent.MuteError -> showMuteError(event.domain) + is InstanceMuteEvent.UnmuteSuccess -> showUnmuteSuccess(event.domain) } - ) - } - - private fun onFetchInstancesSuccess(instances: List, linkHeader: String?) { - adapter.bottomLoading = false - binding.instanceProgressBar.hide() - - val links = HttpHeaderLink.parse(linkHeader) - val next = HttpHeaderLink.findByRelationType(links, "next") - val fromId = next?.uri?.getQueryParameter("max_id") - adapter.addItems(instances) - bottomId = fromId - fetching = false - - if (adapter.itemCount == 0) { - binding.messageView.show() - binding.messageView.setup( - R.drawable.elephant_friend_empty, - R.string.message_empty, - null - ) - } else { - binding.messageView.hide() + } } - } - private fun onFetchInstancesFailure(throwable: Throwable) { - fetching = false - binding.instanceProgressBar.hide() - Log.e(TAG, "Fetch failure", throwable) + lifecycleScope.launch { + viewModel.pager.collectLatest { pagingData -> + adapter.submitData(pagingData) + } + } - if (adapter.itemCount == 0) { - binding.messageView.show() - binding.messageView.setup(throwable) { + adapter.addLoadStateListener { loadState -> + binding.instanceProgressBar.visible(loadState.refresh == LoadState.Loading && adapter.itemCount == 0) + + if (loadState.refresh is LoadState.Error) { + binding.recyclerView.hide() + binding.messageView.show() + val errorState = loadState.refresh as LoadState.Error + binding.messageView.setup(errorState.error) { adapter.retry() } + Log.w(FollowedTagsActivity.TAG, "error loading followed hashtags", errorState.error) + } else if (loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0) { + binding.recyclerView.hide() + binding.messageView.show() + binding.messageView.setup(R.drawable.elephant_friend_empty, R.string.message_empty) + } else { + binding.recyclerView.show() binding.messageView.hide() - this.fetchInstances(null) } } } - companion object { - private const val TAG = "InstanceList" // logging tag + private fun showUnmuteError(domain: String) { + showSnackbar( + getString(R.string.error_unmuting_domain, domain), + R.string.action_retry + ) { viewModel.unmute(domain) } + } + + private fun showMuteError(domain: String) { + showSnackbar( + getString(R.string.error_muting_domain, domain), + R.string.action_retry + ) { viewModel.mute(domain) } + } + + private fun showUnmuteSuccess(domain: String) { + showSnackbar( + getString(R.string.confirmation_domain_unmuted, domain), + R.string.action_undo + ) { viewModel.mute(domain) } + } + + private fun showSnackbar(message: String, actionText: Int, action: (View) -> Unit) { + Snackbar.make(binding.recyclerView, message, Snackbar.LENGTH_LONG) + .setAction(actionText, action) + .show() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/interfaces/InstanceActionListener.kt b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/interfaces/InstanceActionListener.kt deleted file mode 100644 index 9b88ad966..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/interfaces/InstanceActionListener.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.keylesspalace.tusky.components.instancemute.interfaces - -interface InstanceActionListener { - fun mute(mute: Boolean, instance: String, position: Int) -} diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt index af1972d5f..b14604c6a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -31,6 +31,7 @@ import com.keylesspalace.tusky.components.drafts.DraftsViewModel import com.keylesspalace.tusky.components.filters.EditFilterViewModel import com.keylesspalace.tusky.components.filters.FiltersViewModel import com.keylesspalace.tusky.components.followedtags.FollowedTagsViewModel +import com.keylesspalace.tusky.components.instancemute.InstanceMuteViewModel import com.keylesspalace.tusky.components.login.LoginWebViewViewModel import com.keylesspalace.tusky.components.notifications.NotificationsViewModel import com.keylesspalace.tusky.components.report.ReportViewModel @@ -185,5 +186,10 @@ abstract class ViewModelModule { @ViewModelKey(EditFilterViewModel::class) internal abstract fun editFilterViewModel(viewModel: EditFilterViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(InstanceMuteViewModel::class) + internal abstract fun instanceMuteViewModel(viewModel: InstanceMuteViewModel): ViewModel + // Add more ViewModels here } diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index a50ca7964..bfa961713 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -452,11 +452,11 @@ interface MastodonApi { ): Response> @GET("api/v1/domain_blocks") - fun domainBlocks( + suspend fun domainBlocks( @Query("max_id") maxId: String? = null, @Query("since_id") sinceId: String? = null, @Query("limit") limit: Int? = null - ): Single>> + ): Response> @FormUrlEncoded @POST("api/v1/domain_blocks") diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9fbb6fda3..1ed16f2ea 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -44,6 +44,8 @@ This instance does not support following hashtags. Error muting #%s Error unmuting #%s + Failed to mute %s + Failed to mute %s Failed to load the status source from the server. Login