From 3d5c2dd32f72d8075ca7860aa5dbebaf2a971795 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Mon, 17 Jun 2024 21:43:12 +0200 Subject: [PATCH] feat: Show "Suggested accounts" (#734) Implement suggestions as a new `feature:suggestions` module, with associated activity, fragment, etc. Suggested accounts are shown with their normal information, as well as information about the number of follows / followers, and a guide to posting frequency, so the user can make a more informed decision about whether to follow or not. --- app/build.gradle.kts | 1 + app/lint-baseline.xml | 88 ++--- app/src/main/AndroidManifest.xml | 1 + app/src/main/java/app/pachli/MainActivity.kt | 8 + app/src/main/res/values/strings.xml | 1 + .../core/common/extensions/FlowExtensions.kt | 98 ++++++ .../app/pachli/core/data/di/DataModule.kt | 7 + .../app/pachli/core/data/model/Suggestion.kt | 89 +++++ .../NetworkSuggestionsRepository.kt | 58 +++ .../data/repository/SuggestionsRepository.kt | 68 ++++ .../app/pachli/core/navigation/Navigation.kt | 6 + .../app/pachli/core/network/model/Account.kt | 1 + .../pachli/core/network/model/Suggestion.kt | 91 +++++ .../core/network/retrofit/MastodonApi.kt | 14 + core/ui/build.gradle.kts | 4 +- core/ui/src/main/res/values/actions.xml | 22 +- .../pachli/{ => core/ui}/LinkHelperTest.kt | 8 +- feature/suggestions/AndroidManifest.xml | 21 ++ feature/suggestions/build.gradle.kts | 47 +++ feature/suggestions/lint-baseline.xml | 4 + .../SuggestionAccessibilityDelegate.kt | 206 +++++++++++ .../suggestions/SuggestionsActivity.kt | 50 +++ .../feature/suggestions/SuggestionsAdapter.kt | 333 ++++++++++++++++++ .../suggestions/SuggestionsFragment.kt | 290 +++++++++++++++ .../suggestions/SuggestionsViewModel.kt | 275 +++++++++++++++ .../pachli/feature/suggestions/UiAction.kt | 104 ++++++ .../main/res/layout/activity_suggestions.xml | 52 +++ .../main/res/layout/fragment_suggestions.xml | 65 ++++ .../src/main/res/layout/item_suggestion.xml | 188 ++++++++++ .../main/res/menu/fragment_suggestions.xml | 24 ++ .../src/main/res/values/strings.xml | 44 +++ .../src/test/resources/robolectric.properties | 20 ++ settings.gradle.kts | 1 + 33 files changed, 2235 insertions(+), 54 deletions(-) create mode 100644 core/data/src/main/kotlin/app/pachli/core/data/model/Suggestion.kt create mode 100644 core/data/src/main/kotlin/app/pachli/core/data/repository/NetworkSuggestionsRepository.kt create mode 100644 core/data/src/main/kotlin/app/pachli/core/data/repository/SuggestionsRepository.kt create mode 100644 core/network/src/main/kotlin/app/pachli/core/network/model/Suggestion.kt rename core/ui/src/test/kotlin/app/pachli/{ => core/ui}/LinkHelperTest.kt (98%) create mode 100644 feature/suggestions/AndroidManifest.xml create mode 100644 feature/suggestions/build.gradle.kts create mode 100644 feature/suggestions/lint-baseline.xml create mode 100644 feature/suggestions/src/main/kotlin/app/pachli/feature/suggestions/SuggestionAccessibilityDelegate.kt create mode 100644 feature/suggestions/src/main/kotlin/app/pachli/feature/suggestions/SuggestionsActivity.kt create mode 100644 feature/suggestions/src/main/kotlin/app/pachli/feature/suggestions/SuggestionsAdapter.kt create mode 100644 feature/suggestions/src/main/kotlin/app/pachli/feature/suggestions/SuggestionsFragment.kt create mode 100644 feature/suggestions/src/main/kotlin/app/pachli/feature/suggestions/SuggestionsViewModel.kt create mode 100644 feature/suggestions/src/main/kotlin/app/pachli/feature/suggestions/UiAction.kt create mode 100644 feature/suggestions/src/main/res/layout/activity_suggestions.xml create mode 100644 feature/suggestions/src/main/res/layout/fragment_suggestions.xml create mode 100644 feature/suggestions/src/main/res/layout/item_suggestion.xml create mode 100644 feature/suggestions/src/main/res/menu/fragment_suggestions.xml create mode 100644 feature/suggestions/src/main/res/values/strings.xml create mode 100644 feature/suggestions/src/test/resources/robolectric.properties diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 004759c4c..b457274a7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -136,6 +136,7 @@ dependencies { implementation(projects.feature.about) implementation(projects.feature.lists) implementation(projects.feature.login) + implementation(projects.feature.suggestions) implementation(libs.kotlinx.coroutines.android) diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index ce0adc4fd..281f2aeee 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -135,7 +135,7 @@ errorLine2=" ^"> @@ -157,7 +157,7 @@ errorLine2=" ^"> @@ -168,7 +168,7 @@ errorLine2=" ^"> @@ -179,7 +179,7 @@ errorLine2=" ^"> @@ -190,7 +190,7 @@ errorLine2=" ^"> @@ -201,7 +201,7 @@ errorLine2=" ^"> @@ -212,7 +212,7 @@ errorLine2=" ^"> @@ -223,7 +223,7 @@ errorLine2=" ^"> @@ -234,7 +234,7 @@ errorLine2=" ^"> @@ -245,7 +245,7 @@ errorLine2=" ^"> @@ -256,7 +256,7 @@ errorLine2=" ^"> @@ -278,7 +278,7 @@ errorLine2=" ^"> @@ -289,7 +289,7 @@ errorLine2=" ^"> @@ -300,7 +300,7 @@ errorLine2=" ^"> @@ -311,7 +311,7 @@ errorLine2=" ^"> @@ -322,7 +322,7 @@ errorLine2=" ^"> @@ -333,7 +333,7 @@ errorLine2=" ^"> @@ -344,7 +344,7 @@ errorLine2=" ^"> @@ -355,7 +355,7 @@ errorLine2=" ^"> @@ -366,7 +366,7 @@ errorLine2=" ^"> @@ -377,7 +377,7 @@ errorLine2=" ^"> @@ -388,7 +388,7 @@ errorLine2=" ^"> @@ -399,7 +399,7 @@ errorLine2=" ^"> @@ -410,7 +410,7 @@ errorLine2=" ^"> @@ -421,7 +421,7 @@ errorLine2=" ^"> @@ -432,7 +432,7 @@ errorLine2=" ^"> @@ -443,7 +443,7 @@ errorLine2=" ^"> @@ -454,7 +454,7 @@ errorLine2=" ^"> @@ -465,7 +465,7 @@ errorLine2=" ^"> @@ -476,7 +476,7 @@ errorLine2=" ^"> @@ -575,7 +575,7 @@ errorLine2=" ^"> @@ -586,7 +586,7 @@ errorLine2=" ^"> @@ -597,7 +597,7 @@ errorLine2=" ^"> @@ -608,7 +608,7 @@ errorLine2=" ^"> @@ -619,7 +619,7 @@ errorLine2=" ^"> @@ -630,7 +630,7 @@ errorLine2=" ^"> @@ -641,7 +641,7 @@ errorLine2=" ^"> @@ -652,7 +652,7 @@ errorLine2=" ^"> @@ -663,7 +663,7 @@ errorLine2=" ^"> @@ -674,7 +674,7 @@ errorLine2=" ^"> @@ -685,7 +685,7 @@ errorLine2=" ^"> @@ -696,7 +696,7 @@ errorLine2=" ^"> @@ -707,7 +707,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -718,7 +718,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ea749845b..6db0f4a50 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -158,6 +158,7 @@ + Dismiss Details add reaction + Suggested accounts Translate Undo translate Mentions diff --git a/core/common/src/main/kotlin/app/pachli/core/common/extensions/FlowExtensions.kt b/core/common/src/main/kotlin/app/pachli/core/common/extensions/FlowExtensions.kt index 5d470f71d..f287249d4 100644 --- a/core/common/src/main/kotlin/app/pachli/core/common/extensions/FlowExtensions.kt +++ b/core/common/src/main/kotlin/app/pachli/core/common/extensions/FlowExtensions.kt @@ -21,8 +21,23 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.TimeMark import kotlin.time.TimeSource +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingCommand +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch /** * Returns a flow that mirrors the original flow, but filters out values that occur within @@ -69,3 +84,86 @@ fun Flow.throttleFirst( } private val DEFAULT_THROTTLE_FIRST_TIMEOUT = 500.milliseconds + +/* + * Copyright 2022 Christophe Beyls + * + * This file is copied from + * https://github.com/cbeyls/fosdem-companion-android/blob/c70a681f1ed7d25890636ecd149dcbd4950b2df1/app/src/main/java/be/digitalia/fosdem/flow/FlowExt.kt#L4 + * + * and is based on work he describes in + * https://bladecoder.medium.com/smarter-shared-kotlin-flows-d6b75fc66754. + * + * In personal communication Christophe wrote: + * + * """ + * [...] the code, for which I claim no ownership. You can use and modify and + * redistribute it all you want including in commercial projects, without + * attribution. + * """ + * + * The fosdem-companion-android repository is under the Apache 2.0 license. + */ + +@JvmInline +value class SharedFlowContext(private val subscriptionCount: StateFlow) { + /** + * A shared flow that does not cancel collecting the upstream flow after + * a state (lifecycle) change. + */ + @OptIn(ExperimentalCoroutinesApi::class) + fun Flow.flowWhileShared(started: SharingStarted): Flow { + return started.command(subscriptionCount) + .distinctUntilChanged() + .flatMapLatest { + when (it) { + SharingCommand.START -> this + SharingCommand.STOP, + SharingCommand.STOP_AND_RESET_REPLAY_CACHE, + -> emptyFlow() + } + } + } +} + +inline fun stateFlow( + scope: CoroutineScope, + initialValue: T, + producer: SharedFlowContext.() -> Flow, +): StateFlow { + val state = MutableStateFlow(initialValue) + producer(SharedFlowContext(state.subscriptionCount)).launchIn(scope, state) + return state.asStateFlow() +} + +fun Flow.launchIn(scope: CoroutineScope, collector: FlowCollector): Job = scope.launch { + collect(collector) +} + +inline fun countSubscriptionsFlow(producer: SharedFlowContext.() -> Flow): Flow { + val subscriptionCount = MutableStateFlow(0) + return producer(SharedFlowContext(subscriptionCount.asStateFlow())) + .countSubscriptionsTo(subscriptionCount) +} + +fun Flow.countSubscriptionsTo(subscriptionCount: MutableStateFlow): Flow { + return flow { + subscriptionCount.update { it + 1 } + try { + collect(this) + } finally { + subscriptionCount.update { it - 1 } + } + } +} + +@OptIn(ExperimentalCoroutinesApi::class) +fun SharedFlowContext.versionedResourceFlow( + version: Flow, + producer: suspend (version: Int) -> T, +): Flow { + return version + .flowWhileShared(SharingStarted.WhileSubscribed()) + .distinctUntilChanged() + .mapLatest(producer) +} diff --git a/core/data/src/main/kotlin/app/pachli/core/data/di/DataModule.kt b/core/data/src/main/kotlin/app/pachli/core/data/di/DataModule.kt index 90968d645..10a4b3882 100644 --- a/core/data/src/main/kotlin/app/pachli/core/data/di/DataModule.kt +++ b/core/data/src/main/kotlin/app/pachli/core/data/di/DataModule.kt @@ -19,6 +19,8 @@ package app.pachli.core.data.di import app.pachli.core.data.repository.ListsRepository import app.pachli.core.data.repository.NetworkListsRepository +import app.pachli.core.data.repository.NetworkSuggestionsRepository +import app.pachli.core.data.repository.SuggestionsRepository import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -31,4 +33,9 @@ abstract class DataModule { internal abstract fun bindsListsRepository( listsRepository: NetworkListsRepository, ): ListsRepository + + @Binds + internal abstract fun bindsSuggestionsRepository( + suggestionsRepository: NetworkSuggestionsRepository, + ): SuggestionsRepository } diff --git a/core/data/src/main/kotlin/app/pachli/core/data/model/Suggestion.kt b/core/data/src/main/kotlin/app/pachli/core/data/model/Suggestion.kt new file mode 100644 index 000000000..e8341bf36 --- /dev/null +++ b/core/data/src/main/kotlin/app/pachli/core/data/model/Suggestion.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +package app.pachli.core.data.model + +import app.pachli.core.network.json.Default +import app.pachli.core.network.model.Account +import app.pachli.core.network.model.SuggestionSource + +/** + * Sources of suggestions for followed accounts. + * + * Wraps and merges the different sources supplied across different Mastodon API + * versions. + */ +enum class SuggestionSources { + /** "Hand-picked by the {domain} team" */ + FEATURED, + + /** "This profile is one of the most followed on {domain}." */ + MOST_FOLLOWED, + + /** "This profile has been recently getting a lot of attention on {domain}." */ + MOST_INTERACTIONS, + + /** "This profile is similar to the profiles you have most recently followed." */ + SIMILAR_TO_RECENTLY_FOLLOWED, + + /** "Popular among people you follow" */ + FRIENDS_OF_FRIENDS, + + @Default + UNKNOWN, + + ; + + companion object { + fun from(networkSuggestionSources: app.pachli.core.network.model.SuggestionSources) = when (networkSuggestionSources) { + app.pachli.core.network.model.SuggestionSources.FEATURED -> FEATURED + app.pachli.core.network.model.SuggestionSources.MOST_FOLLOWED -> MOST_FOLLOWED + app.pachli.core.network.model.SuggestionSources.MOST_INTERACTIONS -> MOST_INTERACTIONS + app.pachli.core.network.model.SuggestionSources.SIMILAR_TO_RECENTLY_FOLLOWED -> SIMILAR_TO_RECENTLY_FOLLOWED + app.pachli.core.network.model.SuggestionSources.FRIENDS_OF_FRIENDS -> FRIENDS_OF_FRIENDS + app.pachli.core.network.model.SuggestionSources.UNKNOWN -> UNKNOWN + } + + fun from(networkSuggestionSource: SuggestionSource) = when (networkSuggestionSource) { + SuggestionSource.STAFF -> FEATURED + SuggestionSource.PAST_INTERACTIONS -> SIMILAR_TO_RECENTLY_FOLLOWED + SuggestionSource.GLOBAL -> MOST_FOLLOWED + SuggestionSource.UNKNOWN -> UNKNOWN + } + } +} + +data class Suggestion( + val sources: List = emptyList(), + val account: Account, +) { + companion object { + fun from(networkSuggestion: app.pachli.core.network.model.Suggestion): Suggestion { + networkSuggestion.sources?.let { sources -> + return Suggestion( + sources = sources.map { SuggestionSources.from(it) }, + account = networkSuggestion.account, + ) + } + + return Suggestion( + sources = listOf(SuggestionSources.from(networkSuggestion.source)), + account = networkSuggestion.account, + ) + } + } +} diff --git a/core/data/src/main/kotlin/app/pachli/core/data/repository/NetworkSuggestionsRepository.kt b/core/data/src/main/kotlin/app/pachli/core/data/repository/NetworkSuggestionsRepository.kt new file mode 100644 index 000000000..2adc3b424 --- /dev/null +++ b/core/data/src/main/kotlin/app/pachli/core/data/repository/NetworkSuggestionsRepository.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +package app.pachli.core.data.repository + +import app.pachli.core.common.di.ApplicationScope +import app.pachli.core.data.model.Suggestion +import app.pachli.core.data.repository.SuggestionsError.DeleteSuggestionError +import app.pachli.core.data.repository.SuggestionsError.FollowAccountError +import app.pachli.core.data.repository.SuggestionsError.GetSuggestionsError +import app.pachli.core.network.retrofit.MastodonApi +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.coroutines.binding.binding +import com.github.michaelbull.result.map +import com.github.michaelbull.result.mapError +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async + +@Singleton +class NetworkSuggestionsRepository @Inject constructor( + @ApplicationScope private val externalScope: CoroutineScope, + private val api: MastodonApi, +) : SuggestionsRepository { + override suspend fun getSuggestions(): Result, GetSuggestionsError> = binding { + api.getSuggestions(limit = 80) + .map { response -> response.body.map { Suggestion.from(it) } } + .mapError { GetSuggestionsError(it) } + .bind() + } + + override suspend fun deleteSuggestion(accountId: String): Result = binding { + externalScope.async { + api.deleteSuggestion(accountId).mapError { DeleteSuggestionError(it) }.bind() + }.await() + } + + override suspend fun followAccount(accountId: String): Result = binding { + externalScope.async { + api.followSuggestedAccount(accountId).mapError { FollowAccountError(it) }.bind() + }.await() + } +} diff --git a/core/data/src/main/kotlin/app/pachli/core/data/repository/SuggestionsRepository.kt b/core/data/src/main/kotlin/app/pachli/core/data/repository/SuggestionsRepository.kt new file mode 100644 index 000000000..a26cb353d --- /dev/null +++ b/core/data/src/main/kotlin/app/pachli/core/data/repository/SuggestionsRepository.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +package app.pachli.core.data.repository + +import app.pachli.core.common.PachliError +import app.pachli.core.data.model.Suggestion +import app.pachli.core.data.repository.SuggestionsError.DeleteSuggestionError +import app.pachli.core.data.repository.SuggestionsError.FollowAccountError +import app.pachli.core.data.repository.SuggestionsError.GetSuggestionsError +import app.pachli.core.network.retrofit.apiresult.ApiError +import com.github.michaelbull.result.Result + +/** Errors that can be returned from this repository. */ +sealed interface SuggestionsError : PachliError { + @JvmInline + value class GetSuggestionsError(private val error: ApiError) : + SuggestionsError, + PachliError by error + + @JvmInline + value class DeleteSuggestionError(private val error: ApiError) : + SuggestionsError, + PachliError by error + + // TODO: Doesn't belong here. When there's a repository for the user's account + // this should move there. + @JvmInline + value class FollowAccountError(private val error: ApiError) : + SuggestionsError, + PachliError by error +} + +/** Operations that can be performed on this repository. */ +interface SuggestionsRepository { + /** Get a set of fresh suggestions from the server. */ + suspend fun getSuggestions(): Result, GetSuggestionsError> + + /** + * Remove a follow suggestion. + * + * @param accountId ID of the account to remove + * @return Unit, or an error + */ + suspend fun deleteSuggestion(accountId: String): Result + + /** + * Follow an account from a suggestion + * + * @param accountId ID of the account to follow + * @return Unit, or an error + */ + suspend fun followAccount(accountId: String): Result +} diff --git a/core/navigation/src/main/kotlin/app/pachli/core/navigation/Navigation.kt b/core/navigation/src/main/kotlin/app/pachli/core/navigation/Navigation.kt index 56610657f..61b201187 100644 --- a/core/navigation/src/main/kotlin/app/pachli/core/navigation/Navigation.kt +++ b/core/navigation/src/main/kotlin/app/pachli/core/navigation/Navigation.kt @@ -614,6 +614,12 @@ class SearchActivityIntent(context: Context) : Intent() { } } +class SuggestionsActivityIntent(context: Context) : Intent() { + init { + setClassName(context, QuadrantConstants.SUGGESTIONS_ACTIVITY) + } +} + class TabPreferenceActivityIntent(context: Context) : Intent() { init { setClassName(context, QuadrantConstants.TAB_PREFERENCE_ACTIVITY) diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/Account.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/Account.kt index 834f5af1f..4f884b8eb 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/Account.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/Account.kt @@ -40,6 +40,7 @@ data class Account( // Pixelfed might omit `header` val header: String = "", val locked: Boolean = false, + @Json(name = "last_status_at") val lastStatusAt: Date? = null, @Json(name = "followers_count") val followersCount: Int = 0, @Json(name = "following_count") val followingCount: Int = 0, @Json(name = "statuses_count") val statusesCount: Int = 0, diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/Suggestion.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/Suggestion.kt new file mode 100644 index 000000000..8467fdc72 --- /dev/null +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/Suggestion.kt @@ -0,0 +1,91 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +package app.pachli.core.network.model + +import app.pachli.core.network.json.Default +import app.pachli.core.network.json.HasDefault +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Sources for suggestions returned from Mastodon 3.x. + * + * See [https://docs.joinmastodon.org/entities/Suggestion/](https://docs.joinmastodon.org/entities/Suggestion/). + */ +@HasDefault +enum class SuggestionSource { + /** "This account was manually recommended by your administration team" */ + @Json(name = "staff") + STAFF, + + /** "You have interacted with this account previously" */ + @Json(name = "past_interactions") + PAST_INTERACTIONS, + + /** "This account has many reblogs, favourites, and active local followers within the last 30 days" */ + @Json(name = "global") + GLOBAL, + + @Default + UNKNOWN, +} + +/** + * Sources for suggestions returned from Mastodon 4.x + * ([https://github.com/mastodon/documentation/issues/1398](https://github.com/mastodon/documentation/issues/1398)) + */ +@HasDefault +enum class SuggestionSources { + /** "Hand-picked by the {domain} team" */ + @Json(name = "featured") + FEATURED, + + /** "This profile is one of the most followed on {domain}." */ + @Json(name = "most_followed") + MOST_FOLLOWED, + + /** "This profile has been recently getting a lot of attention on {domain}." */ + @Json(name = "most_interactions") + MOST_INTERACTIONS, + + /** "This profile is similar to the profiles you have most recently followed." */ + @Json(name = "similar_to_recently_followed") + SIMILAR_TO_RECENTLY_FOLLOWED, + + /** "Popular among people you follow" */ + @Json(name = "friends_of_friends") + FRIENDS_OF_FRIENDS, + + @Default + UNKNOWN, +} + +/** + * A suggested account to follow. + * + * @property source The single reason for this suggestion. + * @property sources One or more reasons for this suggestion. + * @property account The suggested account. + */ +@JsonClass(generateAdapter = true) +data class Suggestion( + @Deprecated("Mastodon 4.x switched to sources") + val source: SuggestionSource, + val sources: List? = null, + val account: Account, +) diff --git a/core/network/src/main/kotlin/app/pachli/core/network/retrofit/MastodonApi.kt b/core/network/src/main/kotlin/app/pachli/core/network/retrofit/MastodonApi.kt index 42ebcad4c..eacd867f1 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/retrofit/MastodonApi.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/retrofit/MastodonApi.kt @@ -45,6 +45,7 @@ import app.pachli.core.network.model.Status import app.pachli.core.network.model.StatusContext import app.pachli.core.network.model.StatusEdit import app.pachli.core.network.model.StatusSource +import app.pachli.core.network.model.Suggestion import app.pachli.core.network.model.TimelineAccount import app.pachli.core.network.model.Translation import app.pachli.core.network.model.TrendingTag @@ -800,4 +801,17 @@ interface MastodonApi { suspend fun trendingStatuses( @Query("limit") limit: Int? = null, ): Response> + + @GET("api/v2/suggestions") + suspend fun getSuggestions( + @Query("limit") limit: Int? = null, + ): ApiResult> + + @DELETE("api/v1/suggestions/{accountId}") + suspend fun deleteSuggestion(@Path("accountId") accountId: String): ApiResult + + // Copy of followAccount, except it returns an ApiResult. Temporary, until followAccount + // is converted to also return ApiResult. + @POST("api/v1/accounts/{id}/follow") + suspend fun followSuggestedAccount(@Path("id") accountId: String): ApiResult } diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 88b2e5bba..6c30d1e6e 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -44,6 +44,6 @@ dependencies { // Some views inherit from AndroidX views implementation(libs.bundles.androidx) - implementation(libs.material.iconics) - implementation(libs.material.typeface) + api(libs.material.iconics) + api(libs.material.typeface) } diff --git a/core/ui/src/main/res/values/actions.xml b/core/ui/src/main/res/values/actions.xml index 0d78f2d17..2f1461dca 100644 --- a/core/ui/src/main/res/values/actions.xml +++ b/core/ui/src/main/res/values/actions.xml @@ -1,4 +1,21 @@ + + @@ -23,4 +40,7 @@ - \ No newline at end of file + + + + diff --git a/core/ui/src/test/kotlin/app/pachli/LinkHelperTest.kt b/core/ui/src/test/kotlin/app/pachli/core/ui/LinkHelperTest.kt similarity index 98% rename from core/ui/src/test/kotlin/app/pachli/LinkHelperTest.kt rename to core/ui/src/test/kotlin/app/pachli/core/ui/LinkHelperTest.kt index 7cba6e50e..b23a6ff67 100644 --- a/core/ui/src/test/kotlin/app/pachli/LinkHelperTest.kt +++ b/core/ui/src/test/kotlin/app/pachli/core/ui/LinkHelperTest.kt @@ -15,7 +15,7 @@ * see . */ -package app.pachli +package app.pachli.core.ui import android.content.Context import android.text.SpannableStringBuilder @@ -26,12 +26,6 @@ import androidx.test.platform.app.InstrumentationRegistry import app.pachli.core.activity.BottomSheetActivity.Companion.looksLikeMastodonUrl import app.pachli.core.network.model.HashTag import app.pachli.core.network.model.Status -import app.pachli.core.ui.LinkListener -import app.pachli.core.ui.R -import app.pachli.core.ui.getDomain -import app.pachli.core.ui.getTagName -import app.pachli.core.ui.markupHiddenUrls -import app.pachli.core.ui.setClickableText import org.junit.Assert import org.junit.Test import org.junit.runner.RunWith diff --git a/feature/suggestions/AndroidManifest.xml b/feature/suggestions/AndroidManifest.xml new file mode 100644 index 000000000..76e9ba6e7 --- /dev/null +++ b/feature/suggestions/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/feature/suggestions/build.gradle.kts b/feature/suggestions/build.gradle.kts new file mode 100644 index 000000000..f4ce590fb --- /dev/null +++ b/feature/suggestions/build.gradle.kts @@ -0,0 +1,47 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +plugins { + alias(libs.plugins.pachli.android.library) + alias(libs.plugins.pachli.android.hilt) + alias(libs.plugins.kotlin.parcelize) +} + +android { + namespace = "app.pachli.feature.suggestions" + + defaultConfig { + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments["disableAnalytics"] = "true" + } +} + +dependencies { + implementation(projects.core.activity) + implementation(projects.core.common) + implementation(projects.core.data) + implementation(projects.core.designsystem) + implementation(projects.core.navigation) + implementation(projects.core.network) + implementation(projects.core.ui) + + // TODO: These three dependencies are required by BottomSheetActivity, + // make this part of the projects.core.activity API? + implementation(projects.core.network) + implementation(projects.core.preferences) + implementation(libs.bundles.androidx) +} diff --git a/feature/suggestions/lint-baseline.xml b/feature/suggestions/lint-baseline.xml new file mode 100644 index 000000000..a84701647 --- /dev/null +++ b/feature/suggestions/lint-baseline.xml @@ -0,0 +1,4 @@ + + + + diff --git a/feature/suggestions/src/main/kotlin/app/pachli/feature/suggestions/SuggestionAccessibilityDelegate.kt b/feature/suggestions/src/main/kotlin/app/pachli/feature/suggestions/SuggestionAccessibilityDelegate.kt new file mode 100644 index 000000000..fcd878809 --- /dev/null +++ b/feature/suggestions/src/main/kotlin/app/pachli/feature/suggestions/SuggestionAccessibilityDelegate.kt @@ -0,0 +1,206 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +package app.pachli.feature.suggestions + +import android.content.Context +import android.os.Bundle +import android.text.Spannable +import android.text.style.URLSpan +import android.view.View +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityManager +import android.widget.ArrayAdapter +import androidx.appcompat.app.AlertDialog +import androidx.core.view.AccessibilityDelegateCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerViewAccessibilityDelegate + +/** + * Accessibility delegate for items in [SuggestionViewHolder]. + * + * Each item shows actions to: + * + * - Open the suggested account's profile + * - Dismiss the suggestion + * - Follow the account + * + * If the account's bio includes any links or hashtags then actions to show those + * in a dialog allowing the user to activate one are also included. + */ +internal class SuggestionAccessibilityDelegate( + private val recyclerView: RecyclerView, + private val accept: (UiAction) -> Unit, +) : RecyclerViewAccessibilityDelegate(recyclerView) { + private val context = recyclerView.context + + private val a11yManager = context.getSystemService(Context.ACCESSIBILITY_SERVICE) + as AccessibilityManager + + private val openProfileAction = AccessibilityNodeInfoCompat.AccessibilityActionCompat( + app.pachli.core.ui.R.id.action_open_profile, + context.getString(app.pachli.core.ui.R.string.action_view_profile), + ) + + private val deleteSuggestionAction = AccessibilityNodeInfoCompat.AccessibilityActionCompat( + app.pachli.core.ui.R.id.action_dismiss_follow_suggestion, + context.getString(R.string.action_dismiss_follow_suggestion), + ) + + private val followAccountAction = AccessibilityNodeInfoCompat.AccessibilityActionCompat( + app.pachli.core.ui.R.id.action_follow_account, + context.getString(R.string.action_follow_account), + ) + + private val linksAction = AccessibilityNodeInfoCompat.AccessibilityActionCompat( + app.pachli.core.ui.R.id.action_links, + context.getString(app.pachli.core.ui.R.string.action_links), + ) + + private val hashtagsAction = AccessibilityNodeInfoCompat.AccessibilityActionCompat( + app.pachli.core.ui.R.id.action_hashtags, + context.getString(app.pachli.core.ui.R.string.action_hashtags), + ) + + private val delegate = object : ItemDelegate(this) { + override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) { + super.onInitializeAccessibilityNodeInfo(host, info) + + val viewHolder = recyclerView.findContainingViewHolder(host) as SuggestionViewHolder + + if (!viewHolder.viewData.isEnabled) return + + info.addAction(openProfileAction) + info.addAction(deleteSuggestionAction) + info.addAction(followAccountAction) + + val links = getLinks(viewHolder) + + // Listed in the same order as ListStatusAccessibilityDelegate to + // ensure consistent order (links, mentions, hashtags). + if (links.containsKey(LinkType.Link)) info.addAction(linksAction) + + // Disabling support for mentions at the moment, as the API response + // doesn't break them out (https://github.com/mastodon/mastodon/issues/27745). + // if (links.containsKey(LinkType.Mention)) info.addAction(mentionsAction) + + if (links.containsKey(LinkType.HashTag)) info.addAction(hashtagsAction) + } + + override fun performAccessibilityAction(host: View, action: Int, args: Bundle?): Boolean { + val viewHolder = recyclerView.findContainingViewHolder(host) as? SuggestionViewHolder ?: return false + val viewData = viewHolder.viewData + + if (!viewData.isEnabled) return false + + return when (action) { + app.pachli.core.ui.R.id.action_open_profile -> { + interrupt() + accept(UiAction.NavigationAction.ViewAccount(viewData.suggestion.account.id)) + true + } + + app.pachli.core.ui.R.id.action_dismiss_follow_suggestion -> { + interrupt() + accept(UiAction.SuggestionAction.DeleteSuggestion(viewData.suggestion)) + true + } + + app.pachli.core.ui.R.id.action_follow_account -> { + interrupt() + accept(UiAction.SuggestionAction.AcceptSuggestion(viewData.suggestion)) + true + } + + app.pachli.core.ui.R.id.action_links -> { + val links = getLinks(viewHolder)[LinkType.Link] ?: return true + showLinksDialog(host.context, links) + true + } + + app.pachli.core.ui.R.id.action_hashtags -> { + val hashtags = getLinks(viewHolder)[LinkType.HashTag] ?: return true + showHashTagsDialog(host.context, hashtags) + true + } + + else -> super.performAccessibilityAction(host, action, args) + } + } + + private fun showLinksDialog(context: Context, links: List) = AlertDialog.Builder(context) + .setTitle(app.pachli.core.ui.R.string.title_links_dialog) + .setAdapter( + ArrayAdapter( + context, + android.R.layout.simple_list_item_1, + links.map { it.link }, + ), + ) { _, which -> accept(UiAction.NavigationAction.ViewUrl(links[which].link)) } + .show() + .let { forceFocus(it.listView) } + + private fun showHashTagsDialog(context: Context, hashtags: List) = AlertDialog.Builder(context) + .setTitle(app.pachli.core.ui.R.string.title_hashtags_dialog) + .setAdapter( + ArrayAdapter( + context, + android.R.layout.simple_list_item_1, + hashtags.map { it.text.subSequence(1, it.text.length) }, + ), + ) { _, which -> accept(UiAction.NavigationAction.ViewHashtag(hashtags[which].text)) } + .show() + .let { forceFocus(it.listView) } + } + + private fun forceFocus(view: View) { + interrupt() + view.post { + view.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED) + } + } + + private fun interrupt() = a11yManager.interrupt() + + override fun getItemDelegate(): AccessibilityDelegateCompat = delegate + + enum class LinkType { + Mention, + HashTag, + Link, + } + + private fun getLinks(viewHolder: SuggestionViewHolder): Map> { + val note = viewHolder.binding.accountNote.text + if (note !is Spannable) return emptyMap() + + return note.getSpans(0, note.length, URLSpan::class.java) + .map { + LinkSpanInfo(note.subSequence(note.getSpanStart(it), note.getSpanEnd(it)).toString(), it.url) + } + .groupBy { + when { + it.text.startsWith("@") -> LinkType.Mention + it.text.startsWith("#") -> LinkType.HashTag + else -> LinkType.Link + } + } + } + + private data class LinkSpanInfo(val text: String, val link: String) +} diff --git a/feature/suggestions/src/main/kotlin/app/pachli/feature/suggestions/SuggestionsActivity.kt b/feature/suggestions/src/main/kotlin/app/pachli/feature/suggestions/SuggestionsActivity.kt new file mode 100644 index 000000000..a43386858 --- /dev/null +++ b/feature/suggestions/src/main/kotlin/app/pachli/feature/suggestions/SuggestionsActivity.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +package app.pachli.feature.suggestions + +import android.os.Bundle +import app.pachli.core.activity.BottomSheetActivity +import app.pachli.core.activity.ReselectableFragment +import app.pachli.core.common.extensions.viewBinding +import app.pachli.feature.suggestions.databinding.ActivitySuggestionsBinding +import dagger.hilt.android.AndroidEntryPoint + +/** + * Show the user a list of suggested accounts, and allow them to dismiss or follow + * the suggestion. + */ +@AndroidEntryPoint +class SuggestionsActivity : BottomSheetActivity() { + private val binding by viewBinding(ActivitySuggestionsBinding::inflate) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + + setSupportActionBar(binding.toolbar) + supportActionBar?.apply { + setTitle(R.string.title_suggestions) + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + } + + binding.toolbar.setOnClickListener { + (binding.fragmentContainer.getFragment() as? ReselectableFragment)?.onReselect() + } + } +} diff --git a/feature/suggestions/src/main/kotlin/app/pachli/feature/suggestions/SuggestionsAdapter.kt b/feature/suggestions/src/main/kotlin/app/pachli/feature/suggestions/SuggestionsAdapter.kt new file mode 100644 index 000000000..debe0a2db --- /dev/null +++ b/feature/suggestions/src/main/kotlin/app/pachli/feature/suggestions/SuggestionsAdapter.kt @@ -0,0 +1,333 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +package app.pachli.feature.suggestions + +import android.annotation.SuppressLint +import android.text.format.DateUtils +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.text.HtmlCompat +import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY +import androidx.core.view.children +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import app.pachli.core.activity.emojify +import app.pachli.core.activity.loadAvatar +import app.pachli.core.common.extensions.hide +import app.pachli.core.common.extensions.show +import app.pachli.core.common.extensions.visible +import app.pachli.core.common.string.unicodeWrap +import app.pachli.core.common.util.formatNumber +import app.pachli.core.data.model.Suggestion +import app.pachli.core.network.parseAsMastodonHtml +import app.pachli.core.ui.LinkListener +import app.pachli.core.ui.setClickableText +import app.pachli.feature.suggestions.SuggestionViewHolder.ChangePayload +import app.pachli.feature.suggestions.UiAction.NavigationAction +import app.pachli.feature.suggestions.UiAction.SuggestionAction +import app.pachli.feature.suggestions.databinding.ItemSuggestionBinding +import java.time.Duration +import java.time.Instant +import kotlin.math.roundToInt + +/** + * Adapter for [Suggestion]. + * + * Suggestions are shown with buttons to dismiss the suggestion or follow the + * account. + */ +// TODO: This is quite similar to AccountAdapter, see if some functionality can be +// made common. See things like FollowRequestViewHolder.setupWithAccount as well. +internal class SuggestionsAdapter( + private var animateAvatars: Boolean, + private var animateEmojis: Boolean, + private var showBotOverlay: Boolean, + private val accept: (UiAction) -> Unit, +) : ListAdapter(SuggestionDiffer) { + override fun getItemViewType(position: Int) = R.layout.item_suggestion + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SuggestionViewHolder { + val binding = ItemSuggestionBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return SuggestionViewHolder(binding, accept) + } + + fun setAnimateAvatars(animateAvatars: Boolean) { + if (this.animateAvatars == animateAvatars) return + this.animateAvatars = animateAvatars + notifyItemRangeChanged(0, currentList.size, ChangePayload.AnimateAvatars(animateAvatars)) + } + + fun setAnimateEmojis(animateEmojis: Boolean) { + if (this.animateEmojis == animateEmojis) return + this.animateEmojis = animateEmojis + notifyItemRangeChanged(0, currentList.size, ChangePayload.AnimateEmojis(animateEmojis)) + } + + fun setShowBotOverlay(showBotOverlay: Boolean) { + if (this.showBotOverlay == showBotOverlay) return + this.showBotOverlay = showBotOverlay + notifyItemRangeChanged(0, currentList.size, ChangePayload.ShowBotOverlay(showBotOverlay)) + } + + override fun onBindViewHolder(holder: SuggestionViewHolder, position: Int, payloads: List) { + val viewData = currentList[position] + if (payloads.isEmpty()) { + onBindViewHolder(holder, position) + } else { + payloads.filterIsInstance().forEach { payload -> + when (payload) { + is ChangePayload.IsEnabled -> holder.bindIsEnabled(payload.isEnabled) + is ChangePayload.AnimateAvatars -> holder.bindAvatar(viewData, payload.animateAvatars) + is ChangePayload.AnimateEmojis -> holder.bindAnimateEmojis(viewData, payload.animateEmojis) + is ChangePayload.ShowBotOverlay -> holder.bindShowBotOverlay(viewData, payload.showBotOverlay) + } + } + } + } + + override fun onBindViewHolder(holder: SuggestionViewHolder, position: Int) { + holder.bind( + currentList[position], + animateEmojis, + animateAvatars, + showBotOverlay, + ) + } +} + +/** + * Manage the display of a single suggestion. + * + * @param binding View binding. + * @param accept Asynchronous receiver of [UiAction]. + */ +internal class SuggestionViewHolder( + internal val binding: ItemSuggestionBinding, + private val accept: (UiAction) -> Unit, +) : RecyclerView.ViewHolder(binding.root) { + internal lateinit var viewData: SuggestionViewData + private lateinit var suggestion: Suggestion + + private val avatarRadius: Int + + /** Payloads for partial notification of item changes. */ + internal sealed interface ChangePayload { + /** The [isEnabled] state of the suggestion has changed. */ + data class IsEnabled(val isEnabled: Boolean) : ChangePayload + + /** The [animateAvatars] state of the UI has changed. */ + data class AnimateAvatars(val animateAvatars: Boolean) : ChangePayload + + /** The [animateEmojis] state of the UI has changed. */ + data class AnimateEmojis(val animateEmojis: Boolean) : ChangePayload + + /** The [showBotOverlay] state of the UI has changed. */ + data class ShowBotOverlay(val showBotOverlay: Boolean) : ChangePayload + } + + /** + * Link listener for [setClickableText] that generates the appropriate + * navigation actions. + */ + private val linkListener = object : LinkListener { + override fun onViewTag(tag: String) = accept(NavigationAction.ViewHashtag(tag)) + override fun onViewAccount(id: String) = accept(NavigationAction.ViewAccount(id)) + override fun onViewUrl(url: String) = accept(NavigationAction.ViewUrl(url)) + } + + init { + with(binding) { + followAccount.setOnClickListener { accept(SuggestionAction.AcceptSuggestion(suggestion)) } + deleteSuggestion.setOnClickListener { accept(SuggestionAction.DeleteSuggestion(suggestion)) } + accountNote.setOnClickListener { accept(NavigationAction.ViewAccount(suggestion.account.id)) } + root.setOnClickListener { accept(NavigationAction.ViewAccount(suggestion.account.id)) } + + avatarRadius = avatar.context.resources.getDimensionPixelSize(app.pachli.core.designsystem.R.dimen.avatar_radius_48dp) + } + } + + /** Bind [viewData] to the UI elements. */ + // TODO: Similar to FollowRequestViewHolder.setupWithAccount + fun bind( + viewData: SuggestionViewData, + animateEmojis: Boolean, + animateAvatars: Boolean, + showBotOverlay: Boolean, + ) { + this.viewData = viewData + this.suggestion = viewData.suggestion + val account = suggestion.account + + with(binding) { + suggestion.sources.firstOrNull()?.let { + suggestionReason.text = suggestionReason.context.getString(it.stringResource()) + suggestionReason.show() + } ?: suggestionReason.hide() + + username.text = username.context.getString(app.pachli.core.designsystem.R.string.post_username_format, account.username) + + bindAvatar(viewData, animateAvatars) + bindAnimateEmojis(viewData, animateEmojis) + bindShowBotOverlay(viewData, showBotOverlay) + bindPostStatistics(viewData) + bindIsEnabled(viewData.isEnabled) + + // Build an accessible content description. + root.contentDescription = root.context.getString( + R.string.account_content_description_fmt, + account.displayName, + followerCount.text, + followsCount.text, + statusesCount.text, + accountNote.text, + ) + + // Workaround for RecyclerView 1.0.0 / androidx.core 1.0.0 + // RecyclerView tries to set AccessibilityDelegateCompat to null + // but ViewCompat code replaces is with the default one. RecyclerView never + // fetches another one from its delegate because it checks that it's set so we remove it + // and let RecyclerView ask for a new delegate. + root.accessibilityDelegate = null + } + } + + /** Enables or disables all views depending on [isEnabled]. */ + fun bindIsEnabled(isEnabled: Boolean) = with(binding) { + (root as? ViewGroup)?.children?.forEach { it.isEnabled = isEnabled } + root.isEnabled = isEnabled + } + + /** Binds the avatar image, respecting [animateAvatars]. */ + fun bindAvatar(viewData: SuggestionViewData, animateAvatars: Boolean) = with(binding) { + loadAvatar(viewData.suggestion.account.avatar, avatar, avatarRadius, animateAvatars) + } + + /** + * Binds the account's [name][app.pachli.core.network.model.Account.name] and + * [note][app.pachli.core.network.model.Account.note] respecting [animateEmojis]. + */ + fun bindAnimateEmojis(viewData: SuggestionViewData, animateEmojis: Boolean) = with(binding) { + val account = viewData.suggestion.account + displayName.text = account.name.unicodeWrap().emojify(account.emojis, itemView, animateEmojis) + + if (account.note.isBlank()) { + @SuppressLint("SetTextI18n") + accountNote.text = "" + accountNote.hide() + } else { + accountNote.show() + val emojifiedNote = account.note.parseAsMastodonHtml() + .emojify(account.emojis, accountNote, animateEmojis) + + setClickableText(accountNote, emojifiedNote, emptyList(), null, linkListener) + } + } + + /** + * Display's the bot overlay on the avatar image (if appropriate), respecting + * [showBotOverlay]. + */ + fun bindShowBotOverlay(viewData: SuggestionViewData, showBotOverlay: Boolean) = with(binding) { + avatarBadge.visible(viewData.suggestion.account.bot && showBotOverlay) + } + + /** Bind's the account's post statistics. */ + private fun bindPostStatistics(viewData: SuggestionViewData) = with(binding) { + val account = viewData.suggestion.account + + // Strings all have embedded HTML `...` to render different sections in bold + // without needing to compute spannable widths from arbitrary content. The `` in + // the resource strings must have the leading `<` escaped as `<`. + + followerCount.text = HtmlCompat.fromHtml( + followerCount.context.getString( + R.string.follower_count_fmt, + formatNumber(account.followersCount.toLong(), 1000), + ), + FROM_HTML_MODE_LEGACY, + ) + + followsCount.text = HtmlCompat.fromHtml( + followsCount.context.getString( + R.string.follows_count_fmt, + formatNumber(account.followingCount.toLong(), 1000), + ), + FROM_HTML_MODE_LEGACY, + ) + + // statusesCount can be displayed as either: + // + // 1. A count of posts (if the account has no creation date). + // 2. (1) + a breakdown of posts per week (if there is no "last post" date). + // 3. (1) + (2) + when the account last posted. + statusesCount.apply { + if (account.createdAt == null) { + text = HtmlCompat.fromHtml( + context.getString( + R.string.statuses_count_fmt, + formatNumber(account.statusesCount.toLong(), 1000), + ), + FROM_HTML_MODE_LEGACY, + ) + } else { + val then = account.createdAt!!.toInstant() + val now = Instant.now() + val elapsed = Duration.between(then, now).toDays() / 7.0 + + if (account.lastStatusAt == null) { + text = HtmlCompat.fromHtml( + context.getString( + R.string.statuses_count_per_week_fmt, + formatNumber(account.statusesCount.toLong(), 1000), + (account.statusesCount / elapsed).roundToInt(), + ), + FROM_HTML_MODE_LEGACY, + ) + } else { + text = HtmlCompat.fromHtml( + context.getString( + R.string.statuses_count_per_week_last_fmt, + formatNumber(account.statusesCount.toLong(), 1000), + (account.statusesCount / elapsed).roundToInt(), + DateUtils.getRelativeTimeSpanString( + account.lastStatusAt!!.time, + now.toEpochMilli(), + DateUtils.DAY_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE, + ), + ), + FROM_HTML_MODE_LEGACY, + ) + } + } + } + } +} + +private object SuggestionDiffer : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: SuggestionViewData, newItem: SuggestionViewData) = oldItem.suggestion.account.id == newItem.suggestion.account.id + override fun areContentsTheSame(oldItem: SuggestionViewData, newItem: SuggestionViewData) = oldItem == newItem + + override fun getChangePayload(oldItem: SuggestionViewData, newItem: SuggestionViewData): Any? { + return when { + oldItem.isEnabled != newItem.isEnabled -> ChangePayload.IsEnabled(newItem.isEnabled) + else -> super.getChangePayload(oldItem, newItem) + } + } +} diff --git a/feature/suggestions/src/main/kotlin/app/pachli/feature/suggestions/SuggestionsFragment.kt b/feature/suggestions/src/main/kotlin/app/pachli/feature/suggestions/SuggestionsFragment.kt new file mode 100644 index 000000000..fa9df433c --- /dev/null +++ b/feature/suggestions/src/main/kotlin/app/pachli/feature/suggestions/SuggestionsFragment.kt @@ -0,0 +1,290 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +package app.pachli.feature.suggestions + +import android.content.Context +import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.accessibility.AccessibilityManager +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import androidx.core.view.MenuProvider +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener +import app.pachli.core.activity.BottomSheetActivity +import app.pachli.core.activity.PostLookupFallbackBehavior +import app.pachli.core.activity.RefreshableFragment +import app.pachli.core.activity.ReselectableFragment +import app.pachli.core.activity.extensions.TransitionKind +import app.pachli.core.activity.extensions.startActivityWithTransition +import app.pachli.core.common.extensions.hide +import app.pachli.core.common.extensions.show +import app.pachli.core.common.extensions.throttleFirst +import app.pachli.core.common.extensions.viewBinding +import app.pachli.core.common.util.unsafeLazy +import app.pachli.core.data.model.SuggestionSources +import app.pachli.core.data.model.SuggestionSources.FEATURED +import app.pachli.core.data.model.SuggestionSources.FRIENDS_OF_FRIENDS +import app.pachli.core.data.model.SuggestionSources.MOST_FOLLOWED +import app.pachli.core.data.model.SuggestionSources.MOST_INTERACTIONS +import app.pachli.core.data.model.SuggestionSources.SIMILAR_TO_RECENTLY_FOLLOWED +import app.pachli.core.data.model.SuggestionSources.UNKNOWN +import app.pachli.core.navigation.AccountActivityIntent +import app.pachli.core.navigation.TimelineActivityIntent +import app.pachli.core.ui.BackgroundMessage +import app.pachli.core.ui.makeIcon +import app.pachli.feature.suggestions.UiAction.GetSuggestions +import app.pachli.feature.suggestions.UiAction.NavigationAction +import app.pachli.feature.suggestions.databinding.FragmentSuggestionsBinding +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.onSuccess +import com.google.android.material.color.MaterialColors +import com.google.android.material.divider.MaterialDividerItemDecoration +import com.google.android.material.snackbar.Snackbar +import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +/** + * Show the user a list of suggested accounts, and allow them to dismiss or follow + * the suggestion. + */ +@AndroidEntryPoint +class SuggestionsFragment : + Fragment(R.layout.fragment_suggestions), + MenuProvider, + OnRefreshListener, + RefreshableFragment, + ReselectableFragment { + private val viewModel: SuggestionsViewModel by viewModels() + + private val binding by viewBinding(FragmentSuggestionsBinding::bind) + + private lateinit var bottomSheetActivity: BottomSheetActivity + + private lateinit var suggestionsAdapter: SuggestionsAdapter + + private var talkBackWasEnabled = false + + private val iconSize by unsafeLazy { resources.getDimensionPixelSize(app.pachli.core.designsystem.R.dimen.preference_icon_size) } + + /** Flow of actions the user has taken in the UI */ + private val uiAction = MutableSharedFlow() + + /** Accepts user actions from UI components and emits them in to [uiAction]. */ + private val accept: (UiAction) -> Unit = { action -> lifecycleScope.launch { uiAction.emit(action) } } + + /** The active snackbar */ + private var snackbar: Snackbar? = null + + override fun onAttach(context: Context) { + super.onAttach(context) + bottomSheetActivity = (context as? BottomSheetActivity) ?: throw IllegalStateException("Fragment must be attached to a BottomSheetActivity") + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) + + suggestionsAdapter = SuggestionsAdapter( + animateAvatars = viewModel.uiState.value.animateAvatars, + animateEmojis = viewModel.uiState.value.animateEmojis, + showBotOverlay = viewModel.uiState.value.showBotOverlay, + accept = accept, + ) + + with(binding.swipeRefreshLayout) { + isEnabled = true + setOnRefreshListener(this@SuggestionsFragment) + setColorSchemeColors(MaterialColors.getColor(this, androidx.appcompat.R.attr.colorPrimary)) + } + + with(binding.recyclerView) { + layoutManager = LinearLayoutManager(context) + adapter = suggestionsAdapter + addItemDecoration(MaterialDividerItemDecoration(context, MaterialDividerItemDecoration.VERTICAL)) + setHasFixedSize(true) + + setAccessibilityDelegateCompat(SuggestionAccessibilityDelegate(this, accept)) + } + + bind() + } + + /** Binds data to the UI */ + private fun bind() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + launch { viewModel.uiState.collectLatest(::bindUiState) } + + launch { uiAction.throttleFirst().collect(::bindUiAction) } + + launch { viewModel.suggestions.collectLatest(::bindSuggestions) } + + launch { viewModel.uiResult.collect(::bindUiResult) } + + launch { + viewModel.operationCount.collectLatest { + if (it == 0) binding.progressIndicator.hide() else binding.progressIndicator.show() + } + } + } + } + } + + /** Update the adapter if [UiState] information changes. */ + private fun bindUiState(uiState: UiState) { + suggestionsAdapter.setAnimateEmojis(uiState.animateEmojis) + suggestionsAdapter.setAnimateAvatars(uiState.animateAvatars) + suggestionsAdapter.setShowBotOverlay(uiState.showBotOverlay) + } + + /** Process user actions. */ + private fun bindUiAction(uiAction: UiAction) { + when (uiAction) { + is NavigationAction -> { + when (uiAction) { + is NavigationAction.ViewAccount -> requireActivity().startActivityWithTransition(AccountActivityIntent(requireContext(), uiAction.accountId), TransitionKind.SLIDE_FROM_END) + is NavigationAction.ViewHashtag -> requireActivity().startActivityWithTransition(TimelineActivityIntent.hashtag(requireContext(), uiAction.hashtag), TransitionKind.SLIDE_FROM_END) + is NavigationAction.ViewUrl -> bottomSheetActivity.viewUrl(uiAction.url, PostLookupFallbackBehavior.OPEN_IN_BROWSER) + } + } + else -> { + viewModel.accept(uiAction) + } + } + } + + /** Bind suggestions results to the UI. */ + private fun bindSuggestions(result: Result) { + binding.swipeRefreshLayout.isRefreshing = false + + result.onFailure { + binding.messageView.show() + binding.recyclerView.hide() + + binding.messageView.setup(it) { viewModel.accept(GetSuggestions) } + } + + result.onSuccess { + when (it) { + Suggestions.Loading -> { /* nothing to do */ } + is Suggestions.Loaded -> { + if (it.suggestions.isEmpty()) { + binding.messageView.show() + binding.messageView.setup(BackgroundMessage.Empty()) + } else { + suggestionsAdapter.submitList(it.suggestions) + binding.messageView.hide() + binding.recyclerView.show() + } + } + } + } + } + + /** Act on the result of UI actions */ + private fun bindUiResult(uiResult: Result) { + uiResult.onFailure { uiError -> + val message = uiError.fmt(requireContext()) + snackbar?.dismiss() + try { + Snackbar.make(binding.root, message, Snackbar.LENGTH_INDEFINITE).apply { + uiError.action.let { uiAction -> setAction(app.pachli.core.ui.R.string.action_retry) { viewModel.accept(uiAction) } } + show() + snackbar = this + } + } catch (_: IllegalArgumentException) { + // On rare occasions this code is running before the fragment's + // view is connected to the parent. This causes Snackbar.make() + // to crash. See https://issuetracker.google.com/issues/228215869. + // For now, swallow the exception. + } + } + } + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.fragment_suggestions, menu) + menu.findItem(R.id.action_refresh)?.apply { + icon = makeIcon(requireContext(), GoogleMaterial.Icon.gmd_refresh, iconSize) + } + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.action_refresh -> { + refreshContent() + true + } + else -> false + } + } + + override fun refreshContent() { + binding.swipeRefreshLayout.isRefreshing = true + onRefresh() + } + + override fun onRefresh() { + snackbar?.dismiss() + viewModel.accept(GetSuggestions) + } + + override fun onReselect() { + binding.recyclerView.scrollToPosition(0) + } + + override fun onResume() { + super.onResume() + + val a11yManager = ContextCompat.getSystemService(requireContext(), AccessibilityManager::class.java) + val wasEnabled = talkBackWasEnabled + talkBackWasEnabled = a11yManager?.isEnabled == true + if (talkBackWasEnabled && !wasEnabled) { + suggestionsAdapter.notifyItemRangeChanged(0, suggestionsAdapter.itemCount) + } + } + + companion object { + fun newInstance() = SuggestionsFragment() + } +} + +/** + * @return A string resource for the given [SuggestionSources], suitable for use + * as the reason this suggestion is present. + */ +@StringRes +fun SuggestionSources.stringResource() = when (this) { + FEATURED -> R.string.sources_featured + MOST_FOLLOWED -> R.string.sources_most_followed + MOST_INTERACTIONS -> R.string.sources_most_interactions + SIMILAR_TO_RECENTLY_FOLLOWED -> R.string.sources_similar_to_recently_followed + FRIENDS_OF_FRIENDS -> R.string.sources_friends_of_friends + UNKNOWN -> R.string.sources_unknown +} diff --git a/feature/suggestions/src/main/kotlin/app/pachli/feature/suggestions/SuggestionsViewModel.kt b/feature/suggestions/src/main/kotlin/app/pachli/feature/suggestions/SuggestionsViewModel.kt new file mode 100644 index 000000000..fc39217f1 --- /dev/null +++ b/feature/suggestions/src/main/kotlin/app/pachli/feature/suggestions/SuggestionsViewModel.kt @@ -0,0 +1,275 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +package app.pachli.feature.suggestions + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.pachli.core.common.extensions.stateFlow +import app.pachli.core.data.model.StatusDisplayOptions +import app.pachli.core.data.model.Suggestion +import app.pachli.core.data.repository.StatusDisplayOptionsRepository +import app.pachli.core.data.repository.SuggestionsError.DeleteSuggestionError +import app.pachli.core.data.repository.SuggestionsError.FollowAccountError +import app.pachli.core.data.repository.SuggestionsRepository +import app.pachli.feature.suggestions.UiAction.GetSuggestions +import app.pachli.feature.suggestions.UiAction.SuggestionAction +import app.pachli.feature.suggestions.UiAction.SuggestionAction.AcceptSuggestion +import app.pachli.feature.suggestions.UiAction.SuggestionAction.DeleteSuggestion +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.mapEither +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * High-level UI state, derived from [StatusDisplayOptions]. + * + * @property animateEmojis True if emojis should be animated + * @property animateAvatars True if avatars should be animated + * @property showBotOverlay True if avatars for bot accounts should show an overlay + */ +internal data class UiState( + val animateEmojis: Boolean = false, + val animateAvatars: Boolean = false, + val showBotOverlay: Boolean = false, +) { + companion object { + fun from(statusDisplayOptions: StatusDisplayOptions) = UiState( + animateEmojis = statusDisplayOptions.animateEmojis, + animateAvatars = statusDisplayOptions.animateAvatars, + showBotOverlay = statusDisplayOptions.showBotOverlay, + ) + } +} + +/** Data to show a [Suggestion]. */ +internal data class SuggestionViewData( + /** If false the user should not be able to interact with the suggestion. */ + val isEnabled: Boolean = true, + /** The suggestion. */ + val suggestion: Suggestion, +) + +/** States for the list of suggestions. */ +internal sealed interface Suggestions { + /** Suggestions are being loaded. */ + data object Loading : Suggestions + + /** Loaded suggestions, in [suggestions] */ + data class Loaded(val suggestions: List) : Suggestions +} + +/** Public interface for [SuggestionsViewModel]. */ +internal interface ISuggestionsViewModel { + /** Asynchronous receiver for [UiAction]. */ + val accept: (UiAction) -> Unit + + /** + * Results from each action sent to [accept]. Results may not be returned + * in the same order as the actions. + */ + val uiResult: Flow> + + /** + * Count of active network operations. Every API call increments this on + * start and decrements on finish. + * + * If this is non-zero the UI should show a "loading" indicator of some + * sort. + */ + val operationCount: Flow + + /** Suggestions to display, with associated error. */ + val suggestions: StateFlow> + + /** Additional UI state metadata. */ + val uiState: Flow +} + +@HiltViewModel +internal class SuggestionsViewModel @Inject constructor( + private val suggestionsRepository: SuggestionsRepository, + statusDisplayOptionsRepository: StatusDisplayOptionsRepository, +) : ViewModel(), + ISuggestionsViewModel { + private val uiAction = MutableSharedFlow() + override val accept: (UiAction) -> Unit = { action -> viewModelScope.launch { uiAction.emit(action) } } + + private val _uiResult = Channel>() + override val uiResult = _uiResult.receiveAsFlow() + + private val _operationCount = MutableStateFlow(0) + override val operationCount = _operationCount.asStateFlow() + + private val reload = MutableSharedFlow(replay = 1) + + private var disabledSuggestions = MutableStateFlow>(setOf()) // mutableSetOf() + + private var _suggestions = MutableStateFlow>(Ok(Suggestions.Loading)) + override val suggestions = stateFlow(viewModelScope, Ok(Suggestions.Loading)) { + disabledSuggestions.combine(_suggestions) { disabled, suggestions -> + // Mark any disabled suggestions. + suggestions.mapIfInstance<_, _, Suggestions.Loaded> { + it.copy( + suggestions = it.suggestions.map { + it.copy(isEnabled = !disabled.contains(it.suggestion.account.id)) + }, + ) + } + }.flowWhileShared(SharingStarted.WhileSubscribed(5000)) + } + + override val uiState = stateFlow(viewModelScope, UiState.from(statusDisplayOptionsRepository.flow.value)) { + statusDisplayOptionsRepository.flow.map { UiState.from(it) } + .flowWhileShared(SharingStarted.WhileSubscribed(5000)) + } + + init { + viewModelScope.launch { + uiAction.filterIsInstance().collect { + launch { onSuggestionAction(it) } + } + } + + viewModelScope.launch { + reload.collect { + _suggestions.emit(Ok(Suggestions.Loading)) + _suggestions.emit(getSuggestions()) + } + } + + viewModelScope.launch { + reload.emit(Unit) + uiAction.filterIsInstance().collect { reload.emit(Unit) } + } + } + + /** + * Process each [SuggestionAction]. + * + * Successful actions do not reload from the server, as there is no guarantee the + * reloaded list would contain the same accounts in the same order, so reloading + * risks the user losing their place. + */ + private suspend fun onSuggestionAction(suggestionAction: SuggestionAction) { + // Mark this suggestion as disabled for the duration of the operation. + disabledSuggestions.update { it.plus(suggestionAction.suggestion.account.id) } + + // Process the suggestion, and handle the success/failure + val result = when (suggestionAction) { + is DeleteSuggestion -> deleteSuggestion(suggestionAction.suggestion) + is AcceptSuggestion -> acceptSuggestion(suggestionAction.suggestion) + }.mapEither( + { + // Remove this suggestion from the list. + _suggestions.update { suggestions -> + suggestions.mapIfInstance<_, _, Suggestions.Loaded> { + it.copy( + suggestions = it.suggestions.filterNot { + it.suggestion.account.id == suggestionAction.suggestion.account.id + }, + ) + } + } + UiSuccess.from(suggestionAction) + }, + { UiError.make(it, suggestionAction) }, + ) + + // Re-enable the suggestion. + disabledSuggestions.update { it.minus(suggestionAction.suggestion.account.id) } + _uiResult.send(result) + } + + /** Get fresh suggestions from the repository. */ + private suspend fun getSuggestions(): Result = operation { + // Note: disabledSuggestions is *not* cleared here. Suppose the user has + // dismissed a suggestion and the network operation has not completed yet. + // They reload, and get a list of suggestions that includes the suggestion + // they have just dismissed. In that case the suggestion should still be + // disabled. + suggestionsRepository.getSuggestions().mapEither( + { Suggestions.Loaded(it.map { SuggestionViewData(suggestion = it) }) }, + { GetSuggestionsError(it) }, + ) + } + + /** Delete a suggestion from the repository. */ + private suspend fun deleteSuggestion(suggestion: Suggestion): Result = operation { + suggestionsRepository.deleteSuggestion(suggestion.account.id) + } + + /** Accept the suggestion and follow the account. */ + private suspend fun acceptSuggestion(suggestion: Suggestion): Result = operation { + suggestionsRepository.followAccount(suggestion.account.id) + } + + /** + * Runs [block] incrementing the network operation count before [block] + * starts, decrementing it when [block] ends. + * + * ```kotlin + * suspend fun foo(): SomeType = operation { + * some_network_operation() + * } + * ``` + * + * @return Whatever [block] returned + */ + private suspend fun operation(block: suspend () -> R): R { + _operationCount.getAndUpdate { it + 1 } + val result = block.invoke() + _operationCount.getAndUpdate { it - 1 } + return result + } +} + +/** + * Maps this [Result][Result] to [Result][Result] by either applying the [transform] + * function to the [value][Ok.value] if this [Result] is [Ok<T>][Ok], or returning the result + * unchanged. + */ +@OptIn(ExperimentalContracts::class) +inline infix fun Result.mapIfInstance(transform: (T) -> V): Result { + contract { + callsInPlace(transform, InvocationKind.AT_MOST_ONCE) + } + + return when (this) { + is Ok -> (value as? T)?.let { Ok(transform(it)) } ?: this + is Err -> this + } +} diff --git a/feature/suggestions/src/main/kotlin/app/pachli/feature/suggestions/UiAction.kt b/feature/suggestions/src/main/kotlin/app/pachli/feature/suggestions/UiAction.kt new file mode 100644 index 000000000..d30f47701 --- /dev/null +++ b/feature/suggestions/src/main/kotlin/app/pachli/feature/suggestions/UiAction.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +package app.pachli.feature.suggestions + +import androidx.annotation.StringRes +import app.pachli.core.common.PachliError +import app.pachli.core.data.model.Suggestion +import app.pachli.core.data.repository.SuggestionsError +import app.pachli.core.data.repository.SuggestionsError.DeleteSuggestionError +import app.pachli.core.data.repository.SuggestionsError.FollowAccountError +import app.pachli.feature.suggestions.UiAction.SuggestionAction + +/** Actions the user can take from the UI. */ +internal sealed interface UiAction { + /** Get fresh suggestions. */ + data object GetSuggestions : UiAction + + /** Actions that navigate the user to another part of the app. */ + sealed interface NavigationAction : UiAction { + data class ViewAccount(val accountId: String) : NavigationAction + data class ViewHashtag(val hashtag: String) : NavigationAction + data class ViewUrl(val url: String) : NavigationAction + } + + /** Actions that operate on a suggestion. */ + sealed interface SuggestionAction : UiAction { + val suggestion: Suggestion + + /** Delete the suggestion. */ + data class DeleteSuggestion(override val suggestion: Suggestion) : SuggestionAction + + /** Accept the suggestion and follow the user. */ + data class AcceptSuggestion(override val suggestion: Suggestion) : SuggestionAction + } +} + +/** Represents actions that succeeded. */ +internal sealed interface UiSuccess { + /** The successful action. */ + val action: SuggestionAction + + /** A successful [SuggestionAction.DeleteSuggestion]. */ + data class DeleteSuggestion(override val action: SuggestionAction.DeleteSuggestion) : UiSuccess + + /** A successful [SuggestionAction.AcceptSuggestion]. */ + data class AcceptSuggestion(override val action: SuggestionAction.AcceptSuggestion) : UiSuccess + + companion object { + /** Create a [UiSuccess] from a [SuggestionAction]. */ + fun from(action: SuggestionAction) = when (action) { + is SuggestionAction.DeleteSuggestion -> DeleteSuggestion(action) + is SuggestionAction.AcceptSuggestion -> AcceptSuggestion(action) + } + } +} + +@JvmInline +value class GetSuggestionsError(val error: SuggestionsError.GetSuggestionsError) : PachliError by error + +/** + * Errors that can occur from actions the user takes in the UI. + */ +internal sealed class UiError( + @StringRes override val resourceId: Int, + open val action: SuggestionAction, + override val cause: SuggestionsError, + override val formatArgs: Array? = action.suggestion.account.displayName?.let { arrayOf(it) }, +) : PachliError { + + /** A failed [SuggestionAction.DeleteSuggestion]. */ + data class DeleteSuggestion( + override val action: SuggestionAction.DeleteSuggestion, + override val cause: DeleteSuggestionError, + ) : UiError(R.string.ui_error_delete_suggestion_fmt, action, cause) + + /** A failed [SuggestionAction.AcceptSuggestion]. */ + data class AcceptSuggestion( + override val action: SuggestionAction.AcceptSuggestion, + override val cause: FollowAccountError, + ) : UiError(R.string.ui_error_follow_account_fmt, action, cause) + + companion object { + /** Create a [UiError] from the [SuggestionAction] and [SuggestionsError]. */ + fun make(error: SuggestionsError, action: SuggestionAction) = when (action) { + is SuggestionAction.DeleteSuggestion -> DeleteSuggestion(action, error as DeleteSuggestionError) + is SuggestionAction.AcceptSuggestion -> AcceptSuggestion(action, error as FollowAccountError) + } + } +} diff --git a/feature/suggestions/src/main/res/layout/activity_suggestions.xml b/feature/suggestions/src/main/res/layout/activity_suggestions.xml new file mode 100644 index 000000000..566638e43 --- /dev/null +++ b/feature/suggestions/src/main/res/layout/activity_suggestions.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + diff --git a/feature/suggestions/src/main/res/layout/fragment_suggestions.xml b/feature/suggestions/src/main/res/layout/fragment_suggestions.xml new file mode 100644 index 000000000..4161623f7 --- /dev/null +++ b/feature/suggestions/src/main/res/layout/fragment_suggestions.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + diff --git a/feature/suggestions/src/main/res/layout/item_suggestion.xml b/feature/suggestions/src/main/res/layout/item_suggestion.xml new file mode 100644 index 000000000..813ff0fc6 --- /dev/null +++ b/feature/suggestions/src/main/res/layout/item_suggestion.xml @@ -0,0 +1,188 @@ + + + + + + + + + + + + + + + + + + + + + + + +