refactor instance blocks to paging

This commit is contained in:
Conny Duck 2023-07-04 19:30:57 +02:00
parent 961de796b7
commit 626a8760ae
9 changed files with 226 additions and 159 deletions

View File

@ -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<String, String>() {
override fun getRefreshKey(state: PagingState<String, String>): String? = null
override suspend fun load(params: LoadParams<String>): LoadResult<String, String> {
return if (params is LoadParams.Refresh) {
LoadResult.Page(viewModel.domains.toList(), null, viewModel.nextKey)
} else {
LoadResult.Page(emptyList(), null, null)
}
}
}

View File

@ -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<String, String>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<String, String>
): 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<List<String>>? {
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<List<String>>): 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)
}
}

View File

@ -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<String> = mutableListOf()
val uiEvents = MutableSharedFlow<InstanceMuteEvent>()
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()
}

View File

@ -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<BindingHolder<ItemMutedDomainBinding>>() {
var instances: MutableList<String> = mutableListOf()
var bottomLoading: Boolean = false
private val onUnmute: (String) -> Unit
) : PagingDataAdapter<String, BindingHolder<ItemMutedDomainBinding>>(STRING_COMPARATOR) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemMutedDomainBinding> {
val binding = ItemMutedDomainBinding.inflate(LayoutInflater.from(parent.context), parent, false)
@ -20,37 +17,11 @@ class DomainMutesAdapter(
}
override fun onBindViewHolder(holder: BindingHolder<ItemMutedDomainBinding>, 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<String>) {
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)
}
}
}
}

View File

@ -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<String>, 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()
}
}

View File

@ -1,5 +0,0 @@
package com.keylesspalace.tusky.components.instancemute.interfaces
interface InstanceActionListener {
fun mute(mute: Boolean, instance: String, position: Int)
}

View File

@ -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
}

View File

@ -452,11 +452,11 @@ interface MastodonApi {
): Response<List<TimelineAccount>>
@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<List<String>>>
): Response<List<String>>
@FormUrlEncoded
@POST("api/v1/domain_blocks")

View File

@ -44,6 +44,8 @@
<string name="error_following_hashtags_unsupported">This instance does not support following hashtags.</string>
<string name="error_muting_hashtag_format">Error muting #%s</string>
<string name="error_unmuting_hashtag_format">Error unmuting #%s</string>
<string name="error_muting_domain">Failed to mute %s</string>
<string name="error_unmuting_domain">Failed to mute %s</string>
<string name="error_status_source_load">Failed to load the status source from the server.</string>
<string name="title_login">Login</string>