diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationLoadStateAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/LoadStateFooterAdapter.kt similarity index 95% rename from app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationLoadStateAdapter.kt rename to app/src/main/java/com/keylesspalace/tusky/adapter/LoadStateFooterAdapter.kt index b8373f986..99f510623 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationLoadStateAdapter.kt +++ b/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 . */ -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>() { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListActivity.kt index b8d972c43..0c556f946 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListActivity.kt +++ b/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) + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt index e43b87d45..ffc7ab4d6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt +++ b/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 { 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() { + 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> { - 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, - 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) { - 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 - ) { - val mutingNotificationsMap = HashMap() - 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) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListPagingSource.kt new file mode 100644 index 000000000..be5ee6909 --- /dev/null +++ b/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 . */ + +package com.keylesspalace.tusky.components.accountlist + +import androidx.paging.PagingSource +import androidx.paging.PagingState + +class AccountListPagingSource( + private val accounts: List, + private val nextKey: String? +) : PagingSource() { + override fun getRefreshKey(state: PagingState): String? = null + + override suspend fun load(params: LoadParams): LoadResult { + return if (params is LoadParams.Refresh) { + LoadResult.Page(accounts, null, nextKey) + } else { + LoadResult.Page(emptyList(), null, null) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListRemoteMediator.kt new file mode 100644 index 000000000..62686219a --- /dev/null +++ b/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 . */ + +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() { + + 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 -> getFetchCallByListType(fromId = viewModel.nextKey) + LoadType.REFRESH -> { + viewModel.nextKey = null + viewModel.accounts.clear() + getFetchCallByListType(null) + } + } + } + + private suspend fun applyResponse(response: Response>): 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> { + 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) + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListViewModel.kt new file mode 100644 index 000000000..b07bf7420 --- /dev/null +++ b/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 . */ + +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 = mutableListOf() + var nextKey: String? = null + + private val _uiEvents = MutableStateFlow>(emptyList()) + val uiEvents: Flow = _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 +) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountViewData.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountViewData.kt new file mode 100644 index 000000000..fc9ffd265 --- /dev/null +++ b/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 . */ + +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 +) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt index ac327ac03..7c00aa3ac 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt @@ -14,111 +14,34 @@ * see . */ 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 internal constructor( +abstract class AccountAdapter( protected val accountActionListener: AccountActionListener, protected val animateAvatar: Boolean, protected val animateEmojis: Boolean, protected val showBotOverlay: Boolean -) : RecyclerView.Adapter() { - - protected var accountList: MutableList = 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) { - accountList = newAccounts.removeDuplicatesTo(ArrayList()) - notifyDataSetChanged() - } - - fun addItems(newAccounts: List) { - 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(AccountViewDataDifferCallback) { companion object { - const val VIEW_TYPE_ACCOUNT = 0 - const val VIEW_TYPE_FOOTER = 1 + private val AccountViewDataDifferCallback = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: AccountViewData, + newItem: AccountViewData + ): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame( + oldItem: AccountViewData, + newItem: AccountViewData + ): Boolean { + return oldItem == newItem + } + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/BlocksAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/BlocksAdapter.kt index c1132e7f7..2df515d74 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/BlocksAdapter.kt +++ b/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 { - val binding = ItemBlockedUserBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { + return BindingHolder( + ItemBlockedUserBinding.inflate(LayoutInflater.from(parent.context), parent, false) ) - return BindingHolder(binding) } - override fun onBindAccountViewHolder( - viewHolder: BindingHolder, - position: Int - ) { - val account = accountList[position] - val binding = viewHolder.binding - val context = binding.root.context + override fun onBindViewHolder(viewHolder: BindingHolder, 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) + } } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowAdapter.kt index 87b62486d..b91f09f12 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowAdapter.kt +++ b/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) + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt index fc860e59e..7487fb6f4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt +++ b/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) + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/MutesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/MutesAdapter.kt index d685730de..7e87e7d3b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/MutesAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/MutesAdapter.kt @@ -39,82 +39,58 @@ class MutesAdapter( showBotOverlay = showBotOverlay ) { - private val mutingNotificationsMap = HashMap() - - override fun createAccountViewHolder(parent: ViewGroup): BindingHolder { - val binding = ItemMutedUserBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { + return BindingHolder( + ItemMutedUserBinding.inflate(LayoutInflater.from(parent.context), parent, false) ) - return BindingHolder(binding) } - override fun onBindAccountViewHolder( - viewHolder: BindingHolder, - 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, 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) { - mutingNotificationsMap.putAll(newMutingNotificationsMap) - notifyDataSetChanged() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt index 4303c700f..60eb749bb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt +++ b/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() { 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 40617328b..c329ea0fe 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -402,10 +402,10 @@ interface MastodonApi { suspend fun unsubscribeAccount(@Path("id") accountId: String): NetworkResult @GET("api/v1/blocks") - suspend fun blocks(@Query("max_id") maxId: String?): Response> + suspend fun blocks(@Query("max_id") maxId: String? = null): Response> @GET("api/v1/mutes") - suspend fun mutes(@Query("max_id") maxId: String?): Response> + suspend fun mutes(@Query("max_id") maxId: String? = null): Response> @GET("api/v1/domain_blocks") suspend fun domainBlocks( diff --git a/app/src/main/java/com/keylesspalace/tusky/view/EndlessOnScrollListener.kt b/app/src/main/java/com/keylesspalace/tusky/view/EndlessOnScrollListener.kt deleted file mode 100644 index c240adf90..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/view/EndlessOnScrollListener.kt +++ /dev/null @@ -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 . */ -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 - } -} diff --git a/app/src/main/res/layout/fragment_account_list.xml b/app/src/main/res/layout/fragment_account_list.xml index b4f9df5be..a2c287ccc 100644 --- a/app/src/main/res/layout/fragment_account_list.xml +++ b/app/src/main/res/layout/fragment_account_list.xml @@ -1,34 +1,31 @@ - - - + android:layout_height="match_parent" + android:clipToPadding="false" + android:paddingBottom="@dimen/recyclerview_bottom_padding_no_actionbutton" /> - + - - - - - - + + + diff --git a/app/src/main/res/layout/item_footer.xml b/app/src/main/res/layout/item_footer.xml deleted file mode 100644 index aed4880bb..000000000 --- a/app/src/main/res/layout/item_footer.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d904fb85e..d8deded4d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -916,4 +916,14 @@ Yes No Discard caption changes? + + Unblocked %1$s + Unmuted %1$s + Failed blocking %1$s: %2$s + Failed muting %1$s: %2$s + Failed unblocking %1$s: %2$s + Failed unmuting %1$s: %2$s + Failed accepting follow request from %1$s: %2$s + Failed rejecting follow request from %1$s: %2$s +