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
Detailsadd reaction
+ Suggested accountsTranslateUndo translateMentions
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/feature/suggestions/src/main/res/menu/fragment_suggestions.xml b/feature/suggestions/src/main/res/menu/fragment_suggestions.xml
new file mode 100644
index 000000000..cc6556965
--- /dev/null
+++ b/feature/suggestions/src/main/res/menu/fragment_suggestions.xml
@@ -0,0 +1,24 @@
+
+
+
diff --git a/feature/suggestions/src/main/res/values/strings.xml b/feature/suggestions/src/main/res/values/strings.xml
new file mode 100644
index 000000000..c014be979
--- /dev/null
+++ b/feature/suggestions/src/main/res/values/strings.xml
@@ -0,0 +1,44 @@
+
+
+
+ Suggested accounts
+ (server returned unrecognized source)
+ Hand-picked by your server team
+ One of the most followed on your server
+ Getting a lot of attention on your server
+ Similar to accounts you have recently followed
+ Popular among people you follow
+ (server returned unrecognized sources)
+
+ Follow
+ Dismiss
+
+ Dismissing suggestion for %1$s failed: %2$s
+ Following %1$s failed: %2$s
+
+ <b>%1$s</b> followers
+ <b>%1$s</b> following
+ <b>%1$s</b> posts
+ <b>%1$s</b> posts (<b>%2$d</b> per week)
+ <b>%1$s</b> posts (<b>%2$d</b> per week, last <b>%3$s</b>)
+
+
+
+ %1$s; %2$s; %3$s; %4$s. %5$s
+
+
diff --git a/feature/suggestions/src/test/resources/robolectric.properties b/feature/suggestions/src/test/resources/robolectric.properties
new file mode 100644
index 000000000..69da0b97b
--- /dev/null
+++ b/feature/suggestions/src/test/resources/robolectric.properties
@@ -0,0 +1,20 @@
+#
+# Copyright 2023 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 .
+#
+
+# Robolectric does not support SDK 34 yet
+# https://github.com/robolectric/robolectric/issues/8404
+sdk=33
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 35c382685..17a6cbb0b 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -66,6 +66,7 @@ include(":core:ui")
include(":feature:about")
include(":feature:lists")
include(":feature:login")
+include(":feature:suggestions")
include(":tools")
include(":tools:mklanguages")
include(":tools:mkserverversions")