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.
This commit is contained in:
Nik Clayton 2024-06-17 21:43:12 +02:00 committed by GitHub
parent 00a88c7874
commit 3d5c2dd32f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 2235 additions and 54 deletions

View File

@ -136,6 +136,7 @@ dependencies {
implementation(projects.feature.about) implementation(projects.feature.about)
implementation(projects.feature.lists) implementation(projects.feature.lists)
implementation(projects.feature.login) implementation(projects.feature.login)
implementation(projects.feature.suggestions)
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)

View File

@ -135,7 +135,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-ta/strings.xml" file="src/main/res/values-ta/strings.xml"
line="154" line="153"
column="5"/> column="5"/>
</issue> </issue>
@ -157,7 +157,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-ga/strings.xml" file="src/main/res/values-ga/strings.xml"
line="165" line="164"
column="5"/> column="5"/>
</issue> </issue>
@ -168,7 +168,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-sl/strings.xml" file="src/main/res/values-sl/strings.xml"
line="179" line="178"
column="5"/> column="5"/>
</issue> </issue>
@ -179,7 +179,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-cs/strings.xml" file="src/main/res/values-cs/strings.xml"
line="193" line="192"
column="5"/> column="5"/>
</issue> </issue>
@ -190,7 +190,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-bn-rIN/strings.xml" file="src/main/res/values-bn-rIN/strings.xml"
line="200" line="199"
column="5"/> column="5"/>
</issue> </issue>
@ -201,7 +201,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-eu/strings.xml" file="src/main/res/values-eu/strings.xml"
line="202" line="201"
column="5"/> column="5"/>
</issue> </issue>
@ -212,7 +212,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-sl/strings.xml" file="src/main/res/values-sl/strings.xml"
line="213" line="212"
column="5"/> column="5"/>
</issue> </issue>
@ -223,7 +223,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-ca/strings.xml" file="src/main/res/values-ca/strings.xml"
line="235" line="234"
column="5"/> column="5"/>
</issue> </issue>
@ -234,7 +234,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-cs/strings.xml" file="src/main/res/values-cs/strings.xml"
line="236" line="235"
column="5"/> column="5"/>
</issue> </issue>
@ -245,7 +245,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-bn-rIN/strings.xml" file="src/main/res/values-bn-rIN/strings.xml"
line="241" line="240"
column="5"/> column="5"/>
</issue> </issue>
@ -256,7 +256,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-ga/strings.xml" file="src/main/res/values-ga/strings.xml"
line="268" line="267"
column="5"/> column="5"/>
</issue> </issue>
@ -278,7 +278,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-ca/strings.xml" file="src/main/res/values-ca/strings.xml"
line="272" line="271"
column="5"/> column="5"/>
</issue> </issue>
@ -289,7 +289,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-ca/strings.xml" file="src/main/res/values-ca/strings.xml"
line="276" line="275"
column="5"/> column="5"/>
</issue> </issue>
@ -300,7 +300,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-cs/strings.xml" file="src/main/res/values-cs/strings.xml"
line="277" line="276"
column="5"/> column="5"/>
</issue> </issue>
@ -311,7 +311,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-cs/strings.xml" file="src/main/res/values-cs/strings.xml"
line="282" line="281"
column="5"/> column="5"/>
</issue> </issue>
@ -322,7 +322,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-ca/strings.xml" file="src/main/res/values-ca/strings.xml"
line="301" line="300"
column="5"/> column="5"/>
</issue> </issue>
@ -333,7 +333,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-cs/strings.xml" file="src/main/res/values-cs/strings.xml"
line="312" line="311"
column="5"/> column="5"/>
</issue> </issue>
@ -344,7 +344,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-cs/strings.xml" file="src/main/res/values-cs/strings.xml"
line="326" line="325"
column="5"/> column="5"/>
</issue> </issue>
@ -355,7 +355,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-cs/strings.xml" file="src/main/res/values-cs/strings.xml"
line="331" line="330"
column="5"/> column="5"/>
</issue> </issue>
@ -366,7 +366,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-cs/strings.xml" file="src/main/res/values-cs/strings.xml"
line="336" line="335"
column="5"/> column="5"/>
</issue> </issue>
@ -377,7 +377,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-bg/strings.xml" file="src/main/res/values-bg/strings.xml"
line="368" line="367"
column="5"/> column="5"/>
</issue> </issue>
@ -388,7 +388,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-ca/strings.xml" file="src/main/res/values-ca/strings.xml"
line="368" line="367"
column="5"/> column="5"/>
</issue> </issue>
@ -399,7 +399,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-ca/strings.xml" file="src/main/res/values-ca/strings.xml"
line="396" line="395"
column="5"/> column="5"/>
</issue> </issue>
@ -410,7 +410,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-ca/strings.xml" file="src/main/res/values-ca/strings.xml"
line="400" line="399"
column="5"/> column="5"/>
</issue> </issue>
@ -421,7 +421,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-ca/strings.xml" file="src/main/res/values-ca/strings.xml"
line="404" line="403"
column="5"/> column="5"/>
</issue> </issue>
@ -432,7 +432,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-ca/strings.xml" file="src/main/res/values-ca/strings.xml"
line="408" line="407"
column="5"/> column="5"/>
</issue> </issue>
@ -443,7 +443,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-cs/strings.xml" file="src/main/res/values-cs/strings.xml"
line="416" line="415"
column="5"/> column="5"/>
</issue> </issue>
@ -454,7 +454,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-cs/strings.xml" file="src/main/res/values-cs/strings.xml"
line="421" line="420"
column="5"/> column="5"/>
</issue> </issue>
@ -465,7 +465,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-ca/strings.xml" file="src/main/res/values-ca/strings.xml"
line="424" line="423"
column="5"/> column="5"/>
</issue> </issue>
@ -476,7 +476,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-cs/strings.xml" file="src/main/res/values-cs/strings.xml"
line="426" line="425"
column="5"/> column="5"/>
</issue> </issue>
@ -575,7 +575,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-nb-rNO/strings.xml" file="src/main/res/values-nb-rNO/strings.xml"
line="101" line="100"
column="45"/> column="45"/>
</issue> </issue>
@ -586,7 +586,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-nb-rNO/strings.xml" file="src/main/res/values-nb-rNO/strings.xml"
line="105" line="104"
column="44"/> column="44"/>
</issue> </issue>
@ -597,7 +597,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-nb-rNO/strings.xml" file="src/main/res/values-nb-rNO/strings.xml"
line="106" line="105"
column="49"/> column="49"/>
</issue> </issue>
@ -608,7 +608,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-nb-rNO/strings.xml" file="src/main/res/values-nb-rNO/strings.xml"
line="109" line="108"
column="38"/> column="38"/>
</issue> </issue>
@ -619,7 +619,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-nb-rNO/strings.xml" file="src/main/res/values-nb-rNO/strings.xml"
line="124" line="123"
column="70"/> column="70"/>
</issue> </issue>
@ -630,7 +630,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-nb-rNO/strings.xml" file="src/main/res/values-nb-rNO/strings.xml"
line="157" line="156"
column="78"/> column="78"/>
</issue> </issue>
@ -641,7 +641,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-nb-rNO/strings.xml" file="src/main/res/values-nb-rNO/strings.xml"
line="163" line="162"
column="65"/> column="65"/>
</issue> </issue>
@ -652,7 +652,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-nb-rNO/strings.xml" file="src/main/res/values-nb-rNO/strings.xml"
line="199" line="198"
column="32"/> column="32"/>
</issue> </issue>
@ -663,7 +663,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-nb-rNO/strings.xml" file="src/main/res/values-nb-rNO/strings.xml"
line="267" line="266"
column="43"/> column="43"/>
</issue> </issue>
@ -674,7 +674,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-nb-rNO/strings.xml" file="src/main/res/values-nb-rNO/strings.xml"
line="395" line="394"
column="86"/> column="86"/>
</issue> </issue>
@ -685,7 +685,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-tr/strings.xml" file="src/main/res/values-tr/strings.xml"
line="503" line="502"
column="294"/> column="294"/>
</issue> </issue>
@ -696,7 +696,7 @@
errorLine2=" ^"> errorLine2=" ^">
<location <location
file="src/main/res/values-nb-rNO/strings.xml" file="src/main/res/values-nb-rNO/strings.xml"
line="525" line="524"
column="51"/> column="51"/>
</issue> </issue>
@ -707,7 +707,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location <location
file="src/main/res/values-fa/strings.xml" file="src/main/res/values-fa/strings.xml"
line="171" line="170"
column="9"/> column="9"/>
</issue> </issue>
@ -718,7 +718,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location <location
file="src/main/res/values-fa/strings.xml" file="src/main/res/values-fa/strings.xml"
line="200" line="199"
column="9"/> column="9"/>
</issue> </issue>

View File

@ -158,6 +158,7 @@
<activity android:name=".components.drafts.DraftsActivity" /> <activity android:name=".components.drafts.DraftsActivity" />
<activity android:name=".components.filters.EditFilterActivity" <activity android:name=".components.filters.EditFilterActivity"
android:windowSoftInputMode="adjustResize" /> android:windowSoftInputMode="adjustResize" />
<activity android:name=".feature.suggestions.SuggestionsActivity" />
<receiver <receiver
android:name=".receiver.SendStatusBroadcastReceiver" android:name=".receiver.SendStatusBroadcastReceiver"

View File

@ -102,6 +102,7 @@ import app.pachli.core.navigation.PreferencesActivityIntent
import app.pachli.core.navigation.PreferencesActivityIntent.PreferenceScreen import app.pachli.core.navigation.PreferencesActivityIntent.PreferenceScreen
import app.pachli.core.navigation.ScheduledStatusActivityIntent import app.pachli.core.navigation.ScheduledStatusActivityIntent
import app.pachli.core.navigation.SearchActivityIntent import app.pachli.core.navigation.SearchActivityIntent
import app.pachli.core.navigation.SuggestionsActivityIntent
import app.pachli.core.navigation.TabPreferenceActivityIntent import app.pachli.core.navigation.TabPreferenceActivityIntent
import app.pachli.core.navigation.TimelineActivityIntent import app.pachli.core.navigation.TimelineActivityIntent
import app.pachli.core.navigation.TrendingActivityIntent import app.pachli.core.navigation.TrendingActivityIntent
@ -699,6 +700,13 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
startActivityWithDefaultTransition(intent) startActivityWithDefaultTransition(intent)
} }
}, },
primaryDrawerItem {
nameRes = R.string.action_suggestions
iconicsIcon = GoogleMaterial.Icon.gmd_explore
onClick = {
startActivityWithDefaultTransition(SuggestionsActivityIntent(context))
}
},
SectionDrawerItem().apply { SectionDrawerItem().apply {
nameRes = listsSectionTitle nameRes = listsSectionTitle
}, },

View File

@ -180,6 +180,7 @@
<string name="action_dismiss">Dismiss</string> <string name="action_dismiss">Dismiss</string>
<string name="action_details">Details</string> <string name="action_details">Details</string>
<string name="action_add_reaction">add reaction</string> <string name="action_add_reaction">add reaction</string>
<string name="action_suggestions">Suggested accounts</string>
<string name="action_translate">Translate</string> <string name="action_translate">Translate</string>
<string name="action_translate_undo">Undo translate</string> <string name="action_translate_undo">Undo translate</string>
<string name="title_mentions_dialog">Mentions</string> <string name="title_mentions_dialog">Mentions</string>

View File

@ -21,8 +21,23 @@ import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.TimeMark import kotlin.time.TimeMark
import kotlin.time.TimeSource 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.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.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 * Returns a flow that mirrors the original flow, but filters out values that occur within
@ -69,3 +84,86 @@ fun <T> Flow<T>.throttleFirst(
} }
private val DEFAULT_THROTTLE_FIRST_TIMEOUT = 500.milliseconds 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<Int>) {
/**
* A shared flow that does not cancel collecting the upstream flow after
* a state (lifecycle) change.
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun <T> Flow<T>.flowWhileShared(started: SharingStarted): Flow<T> {
return started.command(subscriptionCount)
.distinctUntilChanged()
.flatMapLatest {
when (it) {
SharingCommand.START -> this
SharingCommand.STOP,
SharingCommand.STOP_AND_RESET_REPLAY_CACHE,
-> emptyFlow()
}
}
}
}
inline fun <T> stateFlow(
scope: CoroutineScope,
initialValue: T,
producer: SharedFlowContext.() -> Flow<T>,
): StateFlow<T> {
val state = MutableStateFlow(initialValue)
producer(SharedFlowContext(state.subscriptionCount)).launchIn(scope, state)
return state.asStateFlow()
}
fun <T> Flow<T>.launchIn(scope: CoroutineScope, collector: FlowCollector<T>): Job = scope.launch {
collect(collector)
}
inline fun <T> countSubscriptionsFlow(producer: SharedFlowContext.() -> Flow<T>): Flow<T> {
val subscriptionCount = MutableStateFlow(0)
return producer(SharedFlowContext(subscriptionCount.asStateFlow()))
.countSubscriptionsTo(subscriptionCount)
}
fun <T> Flow<T>.countSubscriptionsTo(subscriptionCount: MutableStateFlow<Int>): Flow<T> {
return flow {
subscriptionCount.update { it + 1 }
try {
collect(this)
} finally {
subscriptionCount.update { it - 1 }
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
fun <T> SharedFlowContext.versionedResourceFlow(
version: Flow<Int>,
producer: suspend (version: Int) -> T,
): Flow<T> {
return version
.flowWhileShared(SharingStarted.WhileSubscribed())
.distinctUntilChanged()
.mapLatest(producer)
}

View File

@ -19,6 +19,8 @@ package app.pachli.core.data.di
import app.pachli.core.data.repository.ListsRepository import app.pachli.core.data.repository.ListsRepository
import app.pachli.core.data.repository.NetworkListsRepository 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.Binds
import dagger.Module import dagger.Module
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -31,4 +33,9 @@ abstract class DataModule {
internal abstract fun bindsListsRepository( internal abstract fun bindsListsRepository(
listsRepository: NetworkListsRepository, listsRepository: NetworkListsRepository,
): ListsRepository ): ListsRepository
@Binds
internal abstract fun bindsSuggestionsRepository(
suggestionsRepository: NetworkSuggestionsRepository,
): SuggestionsRepository
} }

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<SuggestionSources> = 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,
)
}
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<List<Suggestion>, 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<Unit, DeleteSuggestionError> = binding {
externalScope.async {
api.deleteSuggestion(accountId).mapError { DeleteSuggestionError(it) }.bind()
}.await()
}
override suspend fun followAccount(accountId: String): Result<Unit, FollowAccountError> = binding {
externalScope.async {
api.followSuggestedAccount(accountId).mapError { FollowAccountError(it) }.bind()
}.await()
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<List<Suggestion>, GetSuggestionsError>
/**
* Remove a follow suggestion.
*
* @param accountId ID of the account to remove
* @return Unit, or an error
*/
suspend fun deleteSuggestion(accountId: String): Result<Unit, DeleteSuggestionError>
/**
* 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<Unit, FollowAccountError>
}

View File

@ -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() { class TabPreferenceActivityIntent(context: Context) : Intent() {
init { init {
setClassName(context, QuadrantConstants.TAB_PREFERENCE_ACTIVITY) setClassName(context, QuadrantConstants.TAB_PREFERENCE_ACTIVITY)

View File

@ -40,6 +40,7 @@ data class Account(
// Pixelfed might omit `header` // Pixelfed might omit `header`
val header: String = "", val header: String = "",
val locked: Boolean = false, val locked: Boolean = false,
@Json(name = "last_status_at") val lastStatusAt: Date? = null,
@Json(name = "followers_count") val followersCount: Int = 0, @Json(name = "followers_count") val followersCount: Int = 0,
@Json(name = "following_count") val followingCount: Int = 0, @Json(name = "following_count") val followingCount: Int = 0,
@Json(name = "statuses_count") val statusesCount: Int = 0, @Json(name = "statuses_count") val statusesCount: Int = 0,

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<SuggestionSources>? = null,
val account: Account,
)

View File

@ -45,6 +45,7 @@ import app.pachli.core.network.model.Status
import app.pachli.core.network.model.StatusContext import app.pachli.core.network.model.StatusContext
import app.pachli.core.network.model.StatusEdit import app.pachli.core.network.model.StatusEdit
import app.pachli.core.network.model.StatusSource 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.TimelineAccount
import app.pachli.core.network.model.Translation import app.pachli.core.network.model.Translation
import app.pachli.core.network.model.TrendingTag import app.pachli.core.network.model.TrendingTag
@ -800,4 +801,17 @@ interface MastodonApi {
suspend fun trendingStatuses( suspend fun trendingStatuses(
@Query("limit") limit: Int? = null, @Query("limit") limit: Int? = null,
): Response<List<Status>> ): Response<List<Status>>
@GET("api/v2/suggestions")
suspend fun getSuggestions(
@Query("limit") limit: Int? = null,
): ApiResult<List<Suggestion>>
@DELETE("api/v1/suggestions/{accountId}")
suspend fun deleteSuggestion(@Path("accountId") accountId: String): ApiResult<Unit>
// 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<Relationship>
} }

View File

@ -44,6 +44,6 @@ dependencies {
// Some views inherit from AndroidX views // Some views inherit from AndroidX views
implementation(libs.bundles.androidx) implementation(libs.bundles.androidx)
implementation(libs.material.iconics) api(libs.material.iconics)
implementation(libs.material.typeface) api(libs.material.typeface)
} }

View File

@ -1,4 +1,21 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!--
~ 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 <http://www.gnu.org/licenses>.
-->
<resources> <resources>
<item name="action_expand_collapse_cw" type="id" /> <item name="action_expand_collapse_cw" type="id" />
<item name="action_reply" type="id" /> <item name="action_reply" type="id" />
@ -23,4 +40,7 @@
<item name="action_open_reblogged_by" type="id" /> <item name="action_open_reblogged_by" type="id" />
<item name="action_open_faved_by" type="id" /> <item name="action_open_faved_by" type="id" />
<item name="action_more" type="id" /> <item name="action_more" type="id" />
</resources>
<item name="action_dismiss_follow_suggestion" type="id" />
<item name="action_follow_account" type="id" />
</resources>

View File

@ -15,7 +15,7 @@
* see <http://www.gnu.org/licenses>. * see <http://www.gnu.org/licenses>.
*/ */
package app.pachli package app.pachli.core.ui
import android.content.Context import android.content.Context
import android.text.SpannableStringBuilder 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.activity.BottomSheetActivity.Companion.looksLikeMastodonUrl
import app.pachli.core.network.model.HashTag import app.pachli.core.network.model.HashTag
import app.pachli.core.network.model.Status 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.Assert
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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 <http://www.gnu.org/licenses>.
-->
<manifest>
</manifest>

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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)
}

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<issues format="6" by="lint 8.3.2" type="baseline" client="gradle" dependencies="false" name="AGP (8.3.2)" variant="all" version="8.3.2">
</issues>

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<LinkSpanInfo>) = 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<LinkSpanInfo>) = 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<LinkType, List<LinkSpanInfo>> {
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)
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<SuggestionsFragment>() as? ReselectableFragment)?.onReselect()
}
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<SuggestionViewData, SuggestionViewHolder>(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<Any?>) {
val viewData = currentList[position]
if (payloads.isEmpty()) {
onBindViewHolder(holder, position)
} else {
payloads.filterIsInstance<ChangePayload>().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 `<b>...</b>` to render different sections in bold
// without needing to compute spannable widths from arbitrary content. The `<b>` in
// the resource strings must have the leading `<` escaped as `&lt;`.
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<SuggestionViewData>() {
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)
}
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<UiAction>()
/** 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<Suggestions, GetSuggestionsError>) {
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<UiSuccess, UiError>) {
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
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<SuggestionViewData>) : 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<Result<UiSuccess, UiError>>
/**
* 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<Int>
/** Suggestions to display, with associated error. */
val suggestions: StateFlow<Result<Suggestions, GetSuggestionsError>>
/** Additional UI state metadata. */
val uiState: Flow<UiState>
}
@HiltViewModel
internal class SuggestionsViewModel @Inject constructor(
private val suggestionsRepository: SuggestionsRepository,
statusDisplayOptionsRepository: StatusDisplayOptionsRepository,
) : ViewModel(),
ISuggestionsViewModel {
private val uiAction = MutableSharedFlow<UiAction>()
override val accept: (UiAction) -> Unit = { action -> viewModelScope.launch { uiAction.emit(action) } }
private val _uiResult = Channel<Result<UiSuccess, UiError>>()
override val uiResult = _uiResult.receiveAsFlow()
private val _operationCount = MutableStateFlow(0)
override val operationCount = _operationCount.asStateFlow()
private val reload = MutableSharedFlow<Unit>(replay = 1)
private var disabledSuggestions = MutableStateFlow<Set<String>>(setOf()) // mutableSetOf<String>()
private var _suggestions = MutableStateFlow<Result<Suggestions, GetSuggestionsError>>(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<SuggestionAction>().collect {
launch { onSuggestionAction(it) }
}
}
viewModelScope.launch {
reload.collect {
_suggestions.emit(Ok(Suggestions.Loading))
_suggestions.emit(getSuggestions())
}
}
viewModelScope.launch {
reload.emit(Unit)
uiAction.filterIsInstance<GetSuggestions>().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<Suggestions.Loaded, GetSuggestionsError> = 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<Unit, DeleteSuggestionError> = operation {
suggestionsRepository.deleteSuggestion(suggestion.account.id)
}
/** Accept the suggestion and follow the account. */
private suspend fun acceptSuggestion(suggestion: Suggestion): Result<Unit, FollowAccountError> = 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 <R> operation(block: suspend () -> R): R {
_operationCount.getAndUpdate { it + 1 }
val result = block.invoke()
_operationCount.getAndUpdate { it - 1 }
return result
}
}
/**
* Maps this [Result<V, E>][Result] to [Result<V, E>][Result] by either applying the [transform]
* function to the [value][Ok.value] if this [Result] is [Ok&lt;T>][Ok], or returning the result
* unchanged.
*/
@OptIn(ExperimentalContracts::class)
inline infix fun <V, E, reified T : V> Result<V, E>.mapIfInstance(transform: (T) -> V): Result<V, E> {
contract {
callsInPlace(transform, InvocationKind.AT_MOST_ONCE)
}
return when (this) {
is Ok -> (value as? T)?.let { Ok(transform(it)) } ?: this
is Err -> this
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<out String>? = 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)
}
}
}

View File

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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 <http://www.gnu.org/licenses>.
-->
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="app.pachli.feature.suggestions.SuggestionsActivity">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:elevation="@dimen/actionbar_elevation"
app:liftOnScroll="true"
app:liftOnScrollTargetViewId="@id/fragment_container"
app:elevationOverlayEnabled="false">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:contentInsetStartWithNavigation="0dp"
app:layout_scrollFlags="scroll|enterAlways" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.fragment.app.FragmentContainerView
android:id="@+id/fragment_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:name="app.pachli.feature.suggestions.SuggestionsFragment"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
<include layout="@layout/item_status_bottom_sheet"/>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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 <http://www.gnu.org/licenses>.
-->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:context=".SuggestionsActivity">
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressIndicator"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:indeterminate="true"
android:contentDescription=""
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="8dp"
android:scrollbars="vertical"
tools:listitem="@layout/item_suggestion"/>
<app.pachli.core.ui.BackgroundMessageView
android:id="@+id/messageView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:src="@android:color/transparent"
android:visibility="gone"
tools:src="@drawable/errorphant_error"
tools:visibility="gone" />
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,188 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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 <http://www.gnu.org/licenses>.
-->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
android:paddingBottom="8dp"
android:focusable="true"
android:importantForAccessibility="yes"
tools:ignore="SelectableText">
<TextView
android:id="@+id/suggestionReason"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:drawablePadding="10dp"
android:ellipsize="end"
android:gravity="center_vertical"
android:maxLines="3"
android:paddingStart="28dp"
android:textColor="?android:textColorSecondary"
android:textSize="?attr/status_text_medium"
android:importantForAccessibility="no"
app:drawableStartCompat="@drawable/ic_person_add_24dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="RtlSymmetry"
tools:text="Someone requested to follow you" />
<ImageView
android:id="@+id/avatar"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_centerVertical="false"
android:layout_marginTop="10dp"
android:contentDescription="@string/action_view_profile"
android:importantForAccessibility="no"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/suggestionReason"
tools:src="@drawable/avatar_default" />
<ImageView
android:id="@+id/avatarBadge"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@string/profile_badge_bot_text"
android:src="@drawable/bot_badge"
android:importantForAccessibility="no"
app:layout_constraintBottom_toBottomOf="@id/avatar"
app:layout_constraintEnd_toEndOf="@id/avatar" />
<TextView
android:id="@+id/displayName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_marginTop="6dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?android:textColorPrimary"
android:textSize="?attr/status_text_medium"
android:textStyle="normal|bold"
android:importantForAccessibility="no"
app:layout_constraintStart_toEndOf="@+id/avatar"
app:layout_constraintTop_toBottomOf="@id/suggestionReason"
app:layout_constraintEnd_toEndOf="parent"
tools:text="Display name" />
<TextView
android:id="@+id/username"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?android:textColorSecondary"
android:textSize="?attr/status_text_medium"
android:importantForAccessibility="no"
app:layout_constraintStart_toEndOf="@id/avatar"
app:layout_constraintTop_toBottomOf="@id/displayName"
app:layout_constraintEnd_toEndOf="parent"
tools:text="\@username" />
<app.pachli.core.ui.ClickableSpanTextView
android:id="@+id/account_note"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textSize="?attr/status_text_medium"
android:hyphenationFrequency="full"
android:lineSpacingMultiplier="1.1"
android:importantForAccessibility="no"
app:layout_constraintStart_toStartOf="@+id/username"
app:layout_constraintTop_toBottomOf="@+id/username"
app:layout_constraintEnd_toEndOf="parent"
tools:text="Account note" />
<TextView
android:id="@+id/follower_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textSize="?attr/status_text_small"
android:importantForAccessibility="no"
app:layout_constraintStart_toStartOf="@id/displayName"
app:layout_constraintTop_toBottomOf="@id/account_note"
tools:text="4.6K followers" />
<TextView
android:id="@+id/follows_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textSize="?attr/status_text_small"
android:layout_marginStart="8dp"
android:importantForAccessibility="no"
app:layout_constraintStart_toEndOf="@id/follower_count"
app:layout_constraintTop_toBottomOf="@id/account_note"
tools:text="46 follows" />
<TextView
android:id="@+id/statuses_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textSize="?attr/status_text_small"
android:importantForAccessibility="no"
app:layout_constraintStart_toStartOf="@+id/follower_count"
app:layout_constraintTop_toBottomOf="@id/follower_count"
tools:text="46 posts (4.6 per week)" />
<Button
android:id="@+id/delete_suggestion"
style="@style/AppButton.Outlined"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:minHeight="48dp"
android:paddingStart="16dp"
android:paddingTop="4dp"
android:paddingEnd="16dp"
android:paddingBottom="4dp"
android:text="@string/action_dismiss_follow_suggestion"
android:textAllCaps="true"
android:textSize="?attr/status_text_medium"
android:importantForAccessibility="no"
app:layout_constraintStart_toStartOf="@id/displayName"
app:layout_constraintTop_toBottomOf="@id/statuses_count" />
<Button
android:id="@+id/followAccount"
style="@style/AppButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="48dp"
android:paddingStart="16dp"
android:paddingTop="4dp"
android:paddingEnd="16dp"
android:paddingBottom="4dp"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:textAllCaps="true"
android:textSize="?attr/status_text_medium"
android:importantForAccessibility="no"
app:layout_constraintStart_toEndOf="@id/delete_suggestion"
app:layout_constraintTop_toTopOf="@+id/delete_suggestion"
android:text="@string/action_follow_account" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ 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 <http://www.gnu.org/licenses>.
-->
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_refresh"
android:title="@string/action_refresh"
app:showAsAction="never" />
</menu>

View File

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ 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 <http://www.gnu.org/licenses>.
-->
<resources>
<string name="title_suggestions">Suggested accounts</string>
<string name="source_unknown">(server returned unrecognized source)</string>
<string name="sources_featured">Hand-picked by your server team</string>
<string name="sources_most_followed">One of the most followed on your server</string>
<string name="sources_most_interactions">Getting a lot of attention on your server</string>
<string name="sources_similar_to_recently_followed">Similar to accounts you have recently followed</string>
<string name="sources_friends_of_friends">Popular among people you follow</string>
<string name="sources_unknown">(server returned unrecognized sources)</string>
<string name="action_follow_account">Follow</string>
<string name="action_dismiss_follow_suggestion">Dismiss</string>
<string name="ui_error_delete_suggestion_fmt">Dismissing suggestion for %1$s failed: %2$s</string>
<string name="ui_error_follow_account_fmt">Following %1$s failed: %2$s</string>
<string name="follower_count_fmt">&lt;b>%1$s&lt;/b> followers</string>
<string name="follows_count_fmt">&lt;b>%1$s&lt;/b> following</string>
<string name="statuses_count_fmt">&lt;b>%1$s&lt;/b> posts</string>
<string name="statuses_count_per_week_fmt">&lt;b>%1$s&lt;/b> posts (&lt;b>%2$d&lt;/b> per week)</string>
<string name="statuses_count_per_week_last_fmt">&lt;b>%1$s&lt;/b> posts (&lt;b>%2$d&lt;/b> per week, last &lt;b>%3$s&lt;/b>)</string>
<string name="account_content_description_fmt" translatable="false">
<!-- Display name, follower count, follows count, statuses count, note -->
%1$s; %2$s; %3$s; %4$s. %5$s
</string>
</resources>

View File

@ -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 <http://www.gnu.org/licenses>.
#
# Robolectric does not support SDK 34 yet
# https://github.com/robolectric/robolectric/issues/8404
sdk=33

View File

@ -66,6 +66,7 @@ include(":core:ui")
include(":feature:about") include(":feature:about")
include(":feature:lists") include(":feature:lists")
include(":feature:login") include(":feature:login")
include(":feature:suggestions")
include(":tools") include(":tools")
include(":tools:mklanguages") include(":tools:mklanguages")
include(":tools:mkserverversions") include(":tools:mkserverversions")