Browse Source

migrate account list to viewmodel & paging (#5028)

This was the last of our fragments that didn't have a ViewModel and
still used the old custom pagination

Additional benefits:
- Way better error handling (the old one didn't even work, in some cases
it would look like success when it really failed)
- smooter scrolling
pull/5045/head
Konrad Pozniak 11 months ago committed by GitHub
parent
commit
e2a0ccb141
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      app/src/main/java/com/keylesspalace/tusky/adapter/LoadStateFooterAdapter.kt
  2. 7
      app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListActivity.kt
  3. 357
      app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt
  4. 34
      app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListPagingSource.kt
  5. 119
      app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListRemoteMediator.kt
  6. 240
      app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListViewModel.kt
  7. 33
      app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountViewData.kt
  8. 117
      app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt
  9. 54
      app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/BlocksAdapter.kt
  10. 20
      app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowAdapter.kt
  11. 34
      app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt
  12. 118
      app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/MutesAdapter.kt
  13. 3
      app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt
  14. 4
      app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt
  15. 48
      app/src/main/java/com/keylesspalace/tusky/view/EndlessOnScrollListener.kt
  16. 45
      app/src/main/res/layout/fragment_account_list.xml
  17. 12
      app/src/main/res/layout/item_footer.xml
  18. 10
      app/src/main/res/values/strings.xml

4
app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationLoadStateAdapter.kt → app/src/main/java/com/keylesspalace/tusky/adapter/LoadStateFooterAdapter.kt

@ -13,7 +13,7 @@
* 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.conversation
package com.keylesspalace.tusky.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
@ -23,7 +23,7 @@ import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.visible
class ConversationLoadStateAdapter(
class LoadStateFooterAdapter(
private val retryCallback: () -> Unit
) : LoadStateAdapter<BindingHolder<ItemNetworkStateBinding>>() {

7
app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListActivity.kt

@ -61,8 +61,11 @@ class AccountListActivity : BottomSheetActivity() {
setDisplayShowHomeEnabled(true)
}
supportFragmentManager.commit {
replace(R.id.fragment_container, AccountListFragment.newInstance(type, id))
if (supportFragmentManager.findFragmentById(R.id.fragment_container) == null) {
supportFragmentManager.commit {
val fragment = AccountListFragment.newInstance(type, id)
replace(R.id.fragment_container, fragment)
}
}
}

357
app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt

@ -20,21 +20,22 @@ import android.os.Bundle
import android.util.Log
import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.paging.LoadState
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator
import at.connyduck.calladapter.networkresult.fold
import com.google.android.material.snackbar.BaseTransientBottomBar
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.PostLookupFallbackBehavior
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.adapter.LoadStateFooterAdapter
import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.components.accountlist.AccountListActivity.Type
import com.keylesspalace.tusky.components.accountlist.adapter.AccountAdapter
import com.keylesspalace.tusky.components.accountlist.adapter.BlocksAdapter
import com.keylesspalace.tusky.components.accountlist.adapter.FollowAdapter
import com.keylesspalace.tusky.components.accountlist.adapter.FollowRequestsAdapter
@ -42,25 +43,21 @@ import com.keylesspalace.tusky.components.accountlist.adapter.FollowRequestsHead
import com.keylesspalace.tusky.components.accountlist.adapter.MutesAdapter
import com.keylesspalace.tusky.databinding.FragmentAccountListBinding
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.HttpHeaderLink
import com.keylesspalace.tusky.util.ensureBottomPadding
import com.keylesspalace.tusky.util.getSerializableCompat
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.view.EndlessOnScrollListener
import com.keylesspalace.tusky.util.visible
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.withCreationCallback
import javax.inject.Inject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import retrofit2.Response
@AndroidEntryPoint
class AccountListFragment :
@ -68,9 +65,6 @@ class AccountListFragment :
AccountActionListener,
LinkListener {
@Inject
lateinit var api: MastodonApi
@Inject
lateinit var accountManager: AccountManager
@ -79,13 +73,20 @@ class AccountListFragment :
private val binding by viewBinding(FragmentAccountListBinding::bind)
private val viewModel: AccountListViewModel by viewModels(
extrasProducer = {
defaultViewModelCreationExtras.withCreationCallback<AccountListViewModel.Factory> { factory ->
factory.create(
type = requireArguments().getSerializableCompat(ARG_TYPE)!!,
accountId = requireArguments().getString(ARG_ID)
)
}
}
)
private lateinit var type: Type
private var id: String? = null
private var adapter: AccountAdapter<*>? = null
private var fetching = false
private var bottomId: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
type = requireArguments().getSerializableCompat(ARG_TYPE)!!
@ -123,31 +124,59 @@ class AccountListFragment :
}
else -> FollowAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
}
this.adapter = adapter
if (binding.recyclerView.adapter == null) {
binding.recyclerView.adapter = adapter
binding.recyclerView.adapter = adapter.withLoadStateFooter(LoadStateFooterAdapter(adapter::retry))
binding.swipeRefreshLayout.setOnRefreshListener { adapter.refresh() }
lifecycleScope.launch {
viewModel.accountPager.collectLatest { pagingData ->
adapter.submitData(pagingData)
}
}
val scrollListener = object : EndlessOnScrollListener(layoutManager) {
override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) {
if (bottomId == null) {
return
lifecycleScope.launch {
viewModel.uiEvents.collect { event ->
val message = if (event.throwable != null) {
getString(event.message, event.user, event.throwable.message ?: getString(R.string.error_generic))
} else {
getString(event.message, event.user)
}
fetchAccounts(adapter, bottomId)
Snackbar.make(binding.recyclerView, message, Snackbar.LENGTH_LONG)
.setAction(event.actionText, event.action)
.addCallback(object : BaseTransientBottomBar.BaseCallback<Snackbar>() {
override fun onDismissed(transientBottomBar: Snackbar, eventType: Int) {
viewModel.consumeEvent(event)
}
})
.show()
}
}
binding.recyclerView.addOnScrollListener(scrollListener)
binding.swipeRefreshLayout.setOnRefreshListener { fetchAccounts(adapter) }
adapter.addLoadStateListener { loadState ->
binding.progressBar.visible(
loadState.refresh == LoadState.Loading && adapter.itemCount == 0
)
fetchAccounts(adapter)
}
if (loadState.refresh != LoadState.Loading) {
binding.swipeRefreshLayout.isRefreshing = false
}
override fun onDestroyView() {
// Clear the adapter to prevent leaking the View
adapter = null
super.onDestroyView()
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(TAG, "error loading accounts", 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()
}
}
}
override fun onViewTag(tag: String) {
@ -165,275 +194,29 @@ class AccountListFragment :
}
override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) {
viewLifecycleOwner.lifecycleScope.launch {
try {
if (!mute) {
api.unmuteAccount(id)
} else {
api.muteAccount(id, notifications)
}
onMuteSuccess(mute, id, position, notifications)
} catch (_: Throwable) {
onMuteFailure(mute, id, notifications)
}
}
}
private fun onMuteSuccess(muted: Boolean, id: String, position: Int, notifications: Boolean) {
val mutesAdapter = adapter as MutesAdapter
if (muted) {
mutesAdapter.updateMutingNotifications(id, notifications, position)
return
}
val unmutedUser = mutesAdapter.removeItem(position)
if (unmutedUser != null) {
Snackbar.make(binding.recyclerView, R.string.confirmation_unmuted, Snackbar.LENGTH_LONG)
.setAction(R.string.action_undo) {
mutesAdapter.addItem(unmutedUser, position)
onMute(true, id, position, notifications)
}
.show()
}
}
private fun onMuteFailure(mute: Boolean, accountId: String, notifications: Boolean) {
val verb = if (mute) {
if (notifications) {
"mute (notifications = true)"
} else {
"mute (notifications = false)"
}
if (mute) {
viewModel.mute(id, notifications)
} else {
"unmute"
viewModel.unmute(id)
}
Log.e(TAG, "Failed to $verb account id $accountId")
}
override fun onBlock(block: Boolean, id: String, position: Int) {
viewLifecycleOwner.lifecycleScope.launch {
try {
if (!block) {
api.unblockAccount(id)
} else {
api.blockAccount(id)
}
onBlockSuccess(block, id, position)
} catch (_: Throwable) {
onBlockFailure(block, id)
}
}
}
private fun onBlockSuccess(blocked: Boolean, id: String, position: Int) {
if (blocked) {
return
}
val blocksAdapter = adapter as BlocksAdapter
val unblockedUser = blocksAdapter.removeItem(position)
if (unblockedUser != null) {
Snackbar.make(
binding.recyclerView,
R.string.confirmation_unblocked,
Snackbar.LENGTH_LONG
)
.setAction(R.string.action_undo) {
blocksAdapter.addItem(unblockedUser, position)
onBlock(true, id, position)
}
.show()
}
}
private fun onBlockFailure(block: Boolean, accountId: String) {
val verb = if (block) {
"block"
} else {
"unblock"
}
Log.e(TAG, "Failed to $verb account accountId $accountId")
viewModel.unblock(id)
}
override fun onRespondToFollowRequest(accept: Boolean, id: String, position: Int) {
viewLifecycleOwner.lifecycleScope.launch {
if (accept) {
api.authorizeFollowRequest(id)
} else {
api.rejectFollowRequest(id)
}.fold(
onSuccess = {
onRespondToFollowRequestSuccess(position)
},
onFailure = { throwable ->
val verb = if (accept) {
"accept"
} else {
"reject"
}
Log.e(TAG, "Failed to $verb account id $id.", throwable)
}
)
}
}
private fun onRespondToFollowRequestSuccess(position: Int) {
val followRequestsAdapter = adapter as FollowRequestsAdapter
followRequestsAdapter.removeItem(position)
}
private suspend fun getFetchCallByListType(fromId: String?): Response<List<TimelineAccount>> {
return when (type) {
Type.FOLLOWS -> {
val accountId = requireId(type, id)
api.accountFollowing(accountId, fromId)
}
Type.FOLLOWERS -> {
val accountId = requireId(type, id)
api.accountFollowers(accountId, fromId)
}
Type.BLOCKS -> api.blocks(fromId)
Type.MUTES -> api.mutes(fromId)
Type.FOLLOW_REQUESTS -> api.followRequests(fromId)
Type.REBLOGGED -> {
val statusId = requireId(type, id)
api.statusRebloggedBy(statusId, fromId)
}
Type.FAVOURITED -> {
val statusId = requireId(type, id)
api.statusFavouritedBy(statusId, fromId)
}
}
}
private fun requireId(type: Type, id: String?): String {
return requireNotNull(id) { "id must not be null for type " + type.name }
}
private fun fetchAccounts(adapter: AccountAdapter<*>, fromId: String? = null) {
if (fetching) {
return
}
fetching = true
binding.swipeRefreshLayout.isRefreshing = true
if (fromId != null) {
binding.recyclerView.post { adapter.setBottomLoading(true) }
}
viewLifecycleOwner.lifecycleScope.launch {
try {
val response = getFetchCallByListType(fromId)
if (!response.isSuccessful) {
onFetchAccountsFailure(adapter, Exception(response.message()))
return@launch
}
val accountList = response.body()
if (accountList == null) {
onFetchAccountsFailure(adapter, Exception(response.message()))
return@launch
}
val linkHeader = response.headers()["Link"]
onFetchAccountsSuccess(adapter, accountList, linkHeader)
} catch (exception: Exception) {
if (exception is CancellationException) {
// Scope is cancelled, probably because the fragment is destroyed.
// We must not touch any views anymore, so rethrow the exception.
// (CancellationException in a cancelled scope is normal and will be ignored)
throw exception
}
onFetchAccountsFailure(adapter, exception)
}
}
}
private fun onFetchAccountsSuccess(
adapter: AccountAdapter<*>,
accounts: List<TimelineAccount>,
linkHeader: String?
) {
adapter.setBottomLoading(false)
binding.swipeRefreshLayout.isRefreshing = false
val links = HttpHeaderLink.parse(linkHeader)
val next = HttpHeaderLink.findByRelationType(links, "next")
val fromId = next?.uri?.getQueryParameter("max_id")
if (adapter.itemCount > 0) {
adapter.addItems(accounts)
} else {
adapter.update(accounts)
}
if (adapter is MutesAdapter) {
fetchRelationships(adapter, accounts.map { it.id })
}
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 fetchRelationships(mutesAdapter: MutesAdapter, ids: List<String>) {
viewLifecycleOwner.lifecycleScope.launch {
api.relationships(ids)
.fold(
onSuccess = { relationships ->
onFetchRelationshipsSuccess(mutesAdapter, relationships)
},
onFailure = { throwable ->
Log.e(TAG, "Fetch failure for relationships of accounts: $ids", throwable)
}
)
}
}
private fun onFetchRelationshipsSuccess(
mutesAdapter: MutesAdapter,
relationships: List<Relationship>
) {
val mutingNotificationsMap = HashMap<String, Boolean>()
relationships.map { mutingNotificationsMap.put(it.id, it.mutingNotifications) }
mutesAdapter.updateMutingNotificationsMap(mutingNotificationsMap)
}
private fun onFetchAccountsFailure(adapter: AccountAdapter<*>, throwable: Throwable) {
fetching = false
binding.swipeRefreshLayout.isRefreshing = false
Log.e(TAG, "Fetch failure", throwable)
if (adapter.itemCount == 0) {
binding.messageView.show()
binding.messageView.setup(throwable) {
binding.messageView.hide()
this.fetchAccounts(adapter, null)
}
}
viewModel.respondToFollowRequest(accept, id)
}
companion object {
private const val TAG = "AccountList" // logging tag
private const val TAG = "AccountListFragment"
private const val ARG_TYPE = "type"
private const val ARG_ID = "id"
fun newInstance(type: Type, id: String? = null): AccountListFragment {
return AccountListFragment().apply {
arguments = Bundle(3).apply {
arguments = Bundle(2).apply {
putSerializable(ARG_TYPE, type)
putString(ARG_ID, id)
}

34
app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListPagingSource.kt

@ -0,0 +1,34 @@
/* Copyright 2025 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.accountlist
import androidx.paging.PagingSource
import androidx.paging.PagingState
class AccountListPagingSource(
private val accounts: List<AccountViewData>,
private val nextKey: String?
) : PagingSource<String, AccountViewData>() {
override fun getRefreshKey(state: PagingState<String, AccountViewData>): String? = null
override suspend fun load(params: LoadParams<String>): LoadResult<String, AccountViewData> {
return if (params is LoadParams.Refresh) {
LoadResult.Page(accounts, null, nextKey)
} else {
LoadResult.Page(emptyList(), null, null)
}
}
}

119
app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListRemoteMediator.kt

@ -0,0 +1,119 @@
/* Copyright 2025 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.accountlist
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import at.connyduck.calladapter.networkresult.getOrElse
import com.keylesspalace.tusky.components.accountlist.AccountListActivity.Type
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.HttpHeaderLink
import retrofit2.HttpException
import retrofit2.Response
@OptIn(ExperimentalPagingApi::class)
class AccountListRemoteMediator(
private val api: MastodonApi,
private val viewModel: AccountListViewModel,
private val fetchRelationships: Boolean
) : RemoteMediator<String, AccountViewData>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<String, AccountViewData>
): 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<TimelineAccount>>? {
return when (loadType) {
LoadType.PREPEND -> null
LoadType.APPEND -> getFetchCallByListType(fromId = viewModel.nextKey)
LoadType.REFRESH -> {
viewModel.nextKey = null
viewModel.accounts.clear()
getFetchCallByListType(null)
}
}
}
private suspend fun applyResponse(response: Response<List<TimelineAccount>>): MediatorResult {
val accounts = response.body()
if (!response.isSuccessful || accounts == null) {
return MediatorResult.Error(HttpException(response))
}
val links = HttpHeaderLink.parse(response.headers()["Link"])
viewModel.nextKey = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id")
val relationships = if (fetchRelationships) {
api.relationships(accounts.map { it.id }).getOrElse { e ->
return MediatorResult.Error(e)
}
} else {
emptyList()
}
val viewModels = accounts.map { account ->
account.toViewData(
mutingNotifications = relationships.find { it.id == account.id }?.mutingNotifications == true
)
}
viewModel.accounts.addAll(viewModels)
viewModel.invalidate()
return MediatorResult.Success(endOfPaginationReached = viewModel.nextKey == null)
}
private fun requireId(type: Type, id: String?): String {
return requireNotNull(id) { "id must not be null for type " + type.name }
}
private suspend fun getFetchCallByListType(fromId: String?): Response<List<TimelineAccount>> {
return when (viewModel.type) {
Type.FOLLOWS -> {
val accountId = requireId(viewModel.type, viewModel.accountId)
api.accountFollowing(accountId, fromId)
}
Type.FOLLOWERS -> {
val accountId = requireId(viewModel.type, viewModel.accountId)
api.accountFollowers(accountId, fromId)
}
Type.BLOCKS -> api.blocks(fromId)
Type.MUTES -> api.mutes(fromId)
Type.FOLLOW_REQUESTS -> api.followRequests(fromId)
Type.REBLOGGED -> {
val statusId = requireId(viewModel.type, viewModel.accountId)
api.statusRebloggedBy(statusId, fromId)
}
Type.FAVOURITED -> {
val statusId = requireId(viewModel.type, viewModel.accountId)
api.statusFavouritedBy(statusId, fromId)
}
}
}
}

240
app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListViewModel.kt

@ -0,0 +1,240 @@
/* Copyright 2025 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.accountlist
import android.view.View
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.ExperimentalPagingApi
import androidx.paging.InvalidatingPagingSourceFactory
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import at.connyduck.calladapter.networkresult.fold
import at.connyduck.calladapter.networkresult.onFailure
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.network.MastodonApi
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@HiltViewModel(assistedFactory = AccountListViewModel.Factory::class)
class AccountListViewModel @AssistedInject constructor(
private val api: MastodonApi,
@Assisted("type") val type: AccountListActivity.Type,
@Assisted("id") val accountId: String?
) : ViewModel() {
private val factory = InvalidatingPagingSourceFactory {
AccountListPagingSource(accounts.toList(), nextKey)
}
@OptIn(ExperimentalPagingApi::class)
val accountPager = Pager(
config = PagingConfig(40),
remoteMediator = AccountListRemoteMediator(api, this, fetchRelationships = type == AccountListActivity.Type.MUTES),
pagingSourceFactory = factory
).flow
.cachedIn(viewModelScope)
val accounts: MutableList<AccountViewData> = mutableListOf()
var nextKey: String? = null
private val _uiEvents = MutableStateFlow<List<SnackbarEvent>>(emptyList())
val uiEvents: Flow<SnackbarEvent> = _uiEvents.map { it.firstOrNull() }.filterNotNull().distinctUntilChanged()
fun invalidate() {
factory.invalidate()
}
// this is called by the mute notification toggle
fun mute(accountId: String, notifications: Boolean) {
val accountViewData = accounts.find { it.id == accountId } ?: return
viewModelScope.launch {
api.muteAccount(accountId, notifications).onFailure { e ->
sendEvent(
SnackbarEvent(
message = R.string.mute_failure,
user = "@${accountViewData.account.username}",
throwable = e,
actionText = R.string.action_retry,
action = { mute(accountId, notifications) }
)
)
}
}
}
// this is called when unmuting is undone
private fun remute(accountViewData: AccountViewData) {
viewModelScope.launch {
api.muteAccount(accountViewData.id).fold({
accounts.add(accountViewData)
invalidate()
}, { e ->
sendEvent(
SnackbarEvent(
message = R.string.mute_failure,
user = "@${accountViewData.account.username}",
throwable = e,
actionText = R.string.action_retry,
action = { block(accountViewData) }
)
)
})
}
}
fun unmute(accountId: String) {
val accountViewData = accounts.find { it.id == accountId } ?: return
viewModelScope.launch {
api.unmuteAccount(accountId).fold({
accounts.removeIf { it.id == accountId }
invalidate()
sendEvent(
SnackbarEvent(
message = R.string.unmute_success,
user = "@${accountViewData.account.username}",
throwable = null,
actionText = R.string.action_undo,
action = { remute(accountViewData) }
)
)
}, { error ->
sendEvent(
SnackbarEvent(
message = R.string.unmute_failure,
user = "@${accountViewData.account.username}",
throwable = error,
actionText = R.string.action_retry,
action = { unmute(accountId) }
)
)
})
}
}
fun unblock(accountId: String) {
val accountViewData = accounts.find { it.id == accountId } ?: return
viewModelScope.launch {
api.unblockAccount(accountId).fold({
accounts.removeIf { it.id == accountId }
invalidate()
sendEvent(
SnackbarEvent(
message = R.string.unblock_success,
user = "@${accountViewData.account.username}",
throwable = null,
actionText = R.string.action_undo,
action = { block(accountViewData) }
)
)
}, { e ->
sendEvent(
SnackbarEvent(
message = R.string.unblock_failure,
user = "@${accountViewData.account.username}",
throwable = e,
actionText = R.string.action_retry,
action = { unblock(accountId) }
)
)
})
}
}
private fun block(accountViewData: AccountViewData) {
viewModelScope.launch {
api.blockAccount(accountViewData.id).fold({
accounts.add(accountViewData)
invalidate()
}, { e ->
sendEvent(
SnackbarEvent(
message = R.string.block_failure,
user = "@${accountViewData.account.username}",
throwable = e,
actionText = R.string.action_retry,
action = { block(accountViewData) }
)
)
})
}
}
fun respondToFollowRequest(accept: Boolean, accountId: String) {
val accountViewData = accounts.find { it.id == accountId } ?: return
viewModelScope.launch {
if (accept) {
api.authorizeFollowRequest(accountId)
} else {
api.rejectFollowRequest(accountId)
}.fold({
accounts.removeIf { it.id == accountId }
invalidate()
}, { e ->
sendEvent(
SnackbarEvent(
message = if (accept) R.string.accept_follow_request_failure else R.string.reject_follow_request_failure,
user = "@${accountViewData.account.username}",
throwable = e,
actionText = R.string.action_retry,
action = { respondToFollowRequest(accept, accountId) }
)
)
})
}
}
fun consumeEvent(event: SnackbarEvent) {
println("event consumed $event")
_uiEvents.update { uiEvents ->
uiEvents - event
}
}
private fun sendEvent(event: SnackbarEvent) {
println("event sent $event")
_uiEvents.update { uiEvents ->
uiEvents + event
}
}
@AssistedFactory
interface Factory {
fun create(
@Assisted("type") type: AccountListActivity.Type,
@Assisted("id") accountId: String?
): AccountListViewModel
}
}
class SnackbarEvent(
@StringRes val message: Int,
val user: String,
@StringRes val actionText: Int,
val action: (View) -> Unit,
val throwable: Throwable? = null
)

33
app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountViewData.kt

@ -0,0 +1,33 @@
/* Copyright 2025 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.accountlist
import com.keylesspalace.tusky.entity.TimelineAccount
data class AccountViewData(
val account: TimelineAccount,
val mutingNotifications: Boolean
) {
val id: String
get() = account.id
}
fun TimelineAccount.toViewData(
mutingNotifications: Boolean
) = AccountViewData(
account = this,
mutingNotifications = mutingNotifications
)

117
app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt

@ -14,111 +14,34 @@
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.accountlist.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.databinding.ItemFooterBinding
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.components.accountlist.AccountViewData
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.removeDuplicatesTo
/** Generic adapter with bottom loading indicator. */
abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructor(
abstract class AccountAdapter<AVH : RecyclerView.ViewHolder>(
protected val accountActionListener: AccountActionListener,
protected val animateAvatar: Boolean,
protected val animateEmojis: Boolean,
protected val showBotOverlay: Boolean
) : RecyclerView.Adapter<RecyclerView.ViewHolder?>() {
protected var accountList: MutableList<TimelineAccount> = mutableListOf()
private var bottomLoading: Boolean = false
override fun getItemCount(): Int {
return accountList.size + if (bottomLoading) 1 else 0
}
abstract fun createAccountViewHolder(parent: ViewGroup): AVH
abstract fun onBindAccountViewHolder(viewHolder: AVH, position: Int)
final override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (getItemViewType(position) == VIEW_TYPE_ACCOUNT) {
@Suppress("UNCHECKED_CAST")
this.onBindAccountViewHolder(holder as AVH, position)
}
}
final override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): RecyclerView.ViewHolder {
return when (viewType) {
VIEW_TYPE_ACCOUNT -> this.createAccountViewHolder(parent)
VIEW_TYPE_FOOTER -> this.createFooterViewHolder(parent)
else -> error("Unknown item type: $viewType")
}
}
private fun createFooterViewHolder(parent: ViewGroup): RecyclerView.ViewHolder {
val binding = ItemFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return BindingHolder(binding)
}
override fun getItemViewType(position: Int): Int {
return if (position == accountList.size && bottomLoading) {
VIEW_TYPE_FOOTER
} else {
VIEW_TYPE_ACCOUNT
}
}
fun update(newAccounts: List<TimelineAccount>) {
accountList = newAccounts.removeDuplicatesTo(ArrayList())
notifyDataSetChanged()
}
fun addItems(newAccounts: List<TimelineAccount>) {
val end = accountList.size
val last = accountList[end - 1]
if (newAccounts.none { it.id == last.id }) {
accountList.addAll(newAccounts)
notifyItemRangeInserted(end, newAccounts.size)
}
}
fun setBottomLoading(loading: Boolean) {
val wasLoading = bottomLoading
if (wasLoading == loading) {
return
}
bottomLoading = loading
if (loading) {
notifyItemInserted(accountList.size)
} else {
notifyItemRemoved(accountList.size)
}
}
fun removeItem(position: Int): TimelineAccount? {
if (position < 0 || position >= accountList.size) {
return null
}
val account = accountList.removeAt(position)
notifyItemRemoved(position)
return account
}
fun addItem(account: TimelineAccount, position: Int) {
if (position < 0 || position > accountList.size) {
return
}
accountList.add(position, account)
notifyItemInserted(position)
}
) : PagingDataAdapter<AccountViewData, AVH>(AccountViewDataDifferCallback) {
companion object {
const val VIEW_TYPE_ACCOUNT = 0
const val VIEW_TYPE_FOOTER = 1
private val AccountViewDataDifferCallback = object : DiffUtil.ItemCallback<AccountViewData>() {
override fun areItemsTheSame(
oldItem: AccountViewData,
newItem: AccountViewData
): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(
oldItem: AccountViewData,
newItem: AccountViewData
): Boolean {
return oldItem == newItem
}
}
}
}

54
app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/BlocksAdapter.kt

@ -38,42 +38,38 @@ class BlocksAdapter(
showBotOverlay = showBotOverlay
) {
override fun createAccountViewHolder(parent: ViewGroup): BindingHolder<ItemBlockedUserBinding> {
val binding = ItemBlockedUserBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemBlockedUserBinding> {
return BindingHolder(
ItemBlockedUserBinding.inflate(LayoutInflater.from(parent.context), parent, false)
)
return BindingHolder(binding)
}
override fun onBindAccountViewHolder(
viewHolder: BindingHolder<ItemBlockedUserBinding>,
position: Int
) {
val account = accountList[position]
val binding = viewHolder.binding
val context = binding.root.context
override fun onBindViewHolder(viewHolder: BindingHolder<ItemBlockedUserBinding>, position: Int) {
getItem(position)?.let { viewData ->
val account = viewData.account
val binding = viewHolder.binding
val context = binding.root.context
val emojifiedName = account.name.emojify(
account.emojis,
binding.blockedUserDisplayName,
animateEmojis
)
binding.blockedUserDisplayName.text = emojifiedName
val formattedUsername = context.getString(R.string.post_username_format, account.username)
binding.blockedUserUsername.text = formattedUsername
val emojifiedName = account.name.emojify(
account.emojis,
binding.blockedUserDisplayName,
animateEmojis
)
binding.blockedUserDisplayName.text = emojifiedName
val formattedUsername = context.getString(R.string.post_username_format, account.username)
binding.blockedUserUsername.text = formattedUsername
val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
loadAvatar(account.avatar, binding.blockedUserAvatar, avatarRadius, animateAvatar)
val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
loadAvatar(account.avatar, binding.blockedUserAvatar, avatarRadius, animateAvatar)
binding.blockedUserBotBadge.visible(showBotOverlay && account.bot)
binding.blockedUserBotBadge.visible(showBotOverlay && account.bot)
binding.blockedUserUnblock.setOnClickListener {
accountActionListener.onBlock(false, account.id, position)
}
binding.root.setOnClickListener {
accountActionListener.onViewAccount(account.id)
binding.blockedUserUnblock.setOnClickListener {
accountActionListener.onBlock(false, account.id, position)
}
binding.root.setOnClickListener {
accountActionListener.onViewAccount(account.id)
}
}
}
}

20
app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowAdapter.kt

@ -34,18 +34,20 @@ class FollowAdapter(
showBotOverlay = showBotOverlay
) {
override fun createAccountViewHolder(parent: ViewGroup): AccountViewHolder {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountViewHolder {
val binding = ItemAccountBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return AccountViewHolder(binding)
}
override fun onBindAccountViewHolder(viewHolder: AccountViewHolder, position: Int) {
viewHolder.setupWithAccount(
accountList[position],
animateAvatar,
animateEmojis,
showBotOverlay
)
viewHolder.setupActionListener(accountActionListener)
override fun onBindViewHolder(viewHolder: AccountViewHolder, position: Int) {
getItem(position)?.let { viewData ->
viewHolder.setupWithAccount(
viewData.account,
animateAvatar,
animateEmojis,
showBotOverlay
)
viewHolder.setupActionListener(accountActionListener)
}
}
}

34
app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt

@ -35,28 +35,20 @@ class FollowRequestsAdapter(
animateEmojis = animateEmojis,
showBotOverlay = showBotOverlay
) {
override fun createAccountViewHolder(parent: ViewGroup): FollowRequestViewHolder {
val binding = ItemFollowRequestBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return FollowRequestViewHolder(
binding,
accountActionListener,
linkListener,
showHeader = false
)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FollowRequestViewHolder {
val binding = ItemFollowRequestBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return FollowRequestViewHolder(binding, accountActionListener, linkListener, showHeader = false)
}
override fun onBindAccountViewHolder(viewHolder: FollowRequestViewHolder, position: Int) {
viewHolder.setupWithAccount(
account = accountList[position],
animateAvatar = animateAvatar,
animateEmojis = animateEmojis,
showBotOverlay = showBotOverlay
)
viewHolder.setupActionListener(accountActionListener, accountList[position].id)
override fun onBindViewHolder(viewHolder: FollowRequestViewHolder, position: Int) {
getItem(position)?.let { viewData ->
viewHolder.setupWithAccount(
account = viewData.account,
animateAvatar = animateAvatar,
animateEmojis = animateEmojis,
showBotOverlay = showBotOverlay
)
viewHolder.setupActionListener(accountActionListener, viewData.account.id)
}
}
}

118
app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/MutesAdapter.kt

@ -39,82 +39,58 @@ class MutesAdapter(
showBotOverlay = showBotOverlay
) {
private val mutingNotificationsMap = HashMap<String, Boolean>()
override fun createAccountViewHolder(parent: ViewGroup): BindingHolder<ItemMutedUserBinding> {
val binding = ItemMutedUserBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemMutedUserBinding> {
return BindingHolder(
ItemMutedUserBinding.inflate(LayoutInflater.from(parent.context), parent, false)
)
return BindingHolder(binding)
}
override fun onBindAccountViewHolder(
viewHolder: BindingHolder<ItemMutedUserBinding>,
position: Int
) {
val account = accountList[position]
val binding = viewHolder.binding
val context = binding.root.context
val mutingNotifications = mutingNotificationsMap[account.id]
val emojifiedName = account.name.emojify(
account.emojis,
binding.mutedUserDisplayName,
animateEmojis
)
binding.mutedUserDisplayName.text = emojifiedName
val formattedUsername = context.getString(R.string.post_username_format, account.username)
binding.mutedUserUsername.text = formattedUsername
val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
loadAvatar(account.avatar, binding.mutedUserAvatar, avatarRadius, animateAvatar)
binding.mutedUserBotBadge.visible(showBotOverlay && account.bot)
val unmuteString = context.getString(R.string.action_unmute_desc, formattedUsername)
binding.mutedUserUnmute.contentDescription = unmuteString
ViewCompat.setTooltipText(binding.mutedUserUnmute, unmuteString)
binding.mutedUserMuteNotifications.setOnCheckedChangeListener(null)
override fun onBindViewHolder(viewHolder: BindingHolder<ItemMutedUserBinding>, position: Int) {
getItem(position)?.let { viewData ->
val account = viewData.account
val binding = viewHolder.binding
val context = binding.root.context
binding.mutedUserMuteNotifications.isChecked = if (mutingNotifications == null) {
binding.mutedUserMuteNotifications.isEnabled = false
true
} else {
binding.mutedUserMuteNotifications.isEnabled = true
mutingNotifications
}
binding.mutedUserUnmute.setOnClickListener {
accountActionListener.onMute(
false,
account.id,
viewHolder.bindingAdapterPosition,
false
)
}
binding.mutedUserMuteNotifications.setOnCheckedChangeListener { _, isChecked ->
accountActionListener.onMute(
true,
account.id,
viewHolder.bindingAdapterPosition,
isChecked
val emojifiedName = account.name.emojify(
account.emojis,
binding.mutedUserDisplayName,
animateEmojis
)
binding.mutedUserDisplayName.text = emojifiedName
val formattedUsername = context.getString(R.string.post_username_format, account.username)
binding.mutedUserUsername.text = formattedUsername
val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
loadAvatar(account.avatar, binding.mutedUserAvatar, avatarRadius, animateAvatar)
binding.mutedUserBotBadge.visible(showBotOverlay && account.bot)
val unmuteString = context.getString(R.string.action_unmute_desc, formattedUsername)
binding.mutedUserUnmute.contentDescription = unmuteString
ViewCompat.setTooltipText(binding.mutedUserUnmute, unmuteString)
binding.mutedUserMuteNotifications.setOnCheckedChangeListener(null)
binding.mutedUserMuteNotifications.isChecked = viewData.mutingNotifications
binding.mutedUserUnmute.setOnClickListener {
accountActionListener.onMute(
false,
account.id,
viewHolder.bindingAdapterPosition,
false
)
}
binding.mutedUserMuteNotifications.setOnCheckedChangeListener { _, isChecked ->
accountActionListener.onMute(
true,
account.id,
viewHolder.bindingAdapterPosition,
isChecked
)
}
binding.root.setOnClickListener { accountActionListener.onViewAccount(account.id) }
}
binding.root.setOnClickListener { accountActionListener.onViewAccount(account.id) }
}
fun updateMutingNotifications(id: String, mutingNotifications: Boolean, position: Int) {
mutingNotificationsMap[id] = mutingNotifications
notifyItemChanged(position)
}
fun updateMutingNotificationsMap(newMutingNotificationsMap: HashMap<String, Boolean>) {
mutingNotificationsMap.putAll(newMutingNotificationsMap)
notifyDataSetChanged()
}
}

3
app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt

@ -35,6 +35,7 @@ import at.connyduck.sparkbutton.helpers.Utils
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.adapter.LoadStateFooterAdapter
import com.keylesspalace.tusky.appstore.ConversationsLoadingEvent
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
@ -224,7 +225,7 @@ class ConversationsFragment :
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
binding.recyclerView.adapter =
adapter.withLoadStateFooter(ConversationLoadStateAdapter(adapter::retry))
adapter.withLoadStateFooter(LoadStateFooterAdapter(adapter::retry))
}
private fun refreshContent() {

4
app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt

@ -402,10 +402,10 @@ interface MastodonApi {
suspend fun unsubscribeAccount(@Path("id") accountId: String): NetworkResult<Relationship>
@GET("api/v1/blocks")
suspend fun blocks(@Query("max_id") maxId: String?): Response<List<TimelineAccount>>
suspend fun blocks(@Query("max_id") maxId: String? = null): Response<List<TimelineAccount>>
@GET("api/v1/mutes")
suspend fun mutes(@Query("max_id") maxId: String?): Response<List<TimelineAccount>>
suspend fun mutes(@Query("max_id") maxId: String? = null): Response<List<TimelineAccount>>
@GET("api/v1/domain_blocks")
suspend fun domainBlocks(

48
app/src/main/java/com/keylesspalace/tusky/view/EndlessOnScrollListener.kt

@ -1,48 +0,0 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.view
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
abstract class EndlessOnScrollListener(private val layoutManager: LinearLayoutManager) :
RecyclerView.OnScrollListener() {
private var previousTotalItemCount = 0
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
val totalItemCount = layoutManager.itemCount
val lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition()
if (totalItemCount < previousTotalItemCount) {
previousTotalItemCount = totalItemCount
}
if (totalItemCount != previousTotalItemCount) {
previousTotalItemCount = totalItemCount
}
if (lastVisibleItemPosition + VISIBLE_THRESHOLD > totalItemCount) {
onLoadMore(totalItemCount, view)
}
}
fun reset() {
previousTotalItemCount = 0
}
abstract fun onLoadMore(totalItemsCount: Int, view: RecyclerView)
companion object {
private const val VISIBLE_THRESHOLD = 15
}
}

45
app/src/main/res/layout/fragment_account_list.xml

@ -1,34 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
<com.keylesspalace.tusky.view.TuskySwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.keylesspalace.tusky.view.TuskySwipeRefreshLayout
android:id="@+id/swipeRefreshLayout"
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingBottom="@dimen/recyclerview_bottom_padding_no_actionbutton" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingBottom="@dimen/recyclerview_bottom_padding_no_actionbutton" />
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true" />
<com.keylesspalace.tusky.view.BackgroundMessageView
android:id="@+id/messageView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:visibility="gone"
tools:visibility="visible" />
</FrameLayout>
</com.keylesspalace.tusky.view.TuskySwipeRefreshLayout>
</FrameLayout>
<com.keylesspalace.tusky.view.BackgroundMessageView
android:id="@+id/messageView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center" />
</FrameLayout>
</com.keylesspalace.tusky.view.TuskySwipeRefreshLayout>

12
app/src/main/res/layout/item_footer.xml

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="72dp">
<com.google.android.material.progressindicator.CircularProgressIndicator
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true" />
</FrameLayout>

10
app/src/main/res/values/strings.xml

@ -916,4 +916,14 @@
<string name="yes">Yes</string>
<string name="no">No</string>
<string name="confirm_dismiss_caption">Discard caption changes?</string>
<string name="unblock_success">Unblocked %1$s</string>
<string name="unmute_success">Unmuted %1$s</string>
<string name="block_failure">Failed blocking %1$s: %2$s</string>
<string name="mute_failure">Failed muting %1$s: %2$s</string>
<string name="unblock_failure">Failed unblocking %1$s: %2$s</string>
<string name="unmute_failure">Failed unmuting %1$s: %2$s</string>
<string name="accept_follow_request_failure">Failed accepting follow request from %1$s: %2$s</string>
<string name="reject_follow_request_failure">Failed rejecting follow request from %1$s: %2$s</string>
</resources>

Loading…
Cancel
Save