mirror of https://github.com/tuskyapp/Tusky.git
Browse Source
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 scrollingpull/5045/head
18 changed files with 654 additions and 605 deletions
@ -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) |
||||
} |
||||
} |
||||
} |
||||
@ -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) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -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 |
||||
) |
||||
@ -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 |
||||
) |
||||
@ -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 |
||||
} |
||||
} |
||||
@ -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> |
||||
|
||||
@ -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> |
||||
Loading…
Reference in new issue