refactor instance blocks to paging
This commit is contained in:
parent
961de796b7
commit
626a8760ae
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
package com.keylesspalace.tusky.components.instancemute.interfaces
|
||||
|
||||
interface InstanceActionListener {
|
||||
fun mute(mute: Boolean, instance: String, position: Int)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue