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:
parent
00a88c7874
commit
3d5c2dd32f
|
@ -136,6 +136,7 @@ dependencies {
|
|||
implementation(projects.feature.about)
|
||||
implementation(projects.feature.lists)
|
||||
implementation(projects.feature.login)
|
||||
implementation(projects.feature.suggestions)
|
||||
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
|
||||
|
|
|
@ -135,7 +135,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-ta/strings.xml"
|
||||
line="154"
|
||||
line="153"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -157,7 +157,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-ga/strings.xml"
|
||||
line="165"
|
||||
line="164"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -168,7 +168,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-sl/strings.xml"
|
||||
line="179"
|
||||
line="178"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -179,7 +179,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-cs/strings.xml"
|
||||
line="193"
|
||||
line="192"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -190,7 +190,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-bn-rIN/strings.xml"
|
||||
line="200"
|
||||
line="199"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -201,7 +201,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-eu/strings.xml"
|
||||
line="202"
|
||||
line="201"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -212,7 +212,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-sl/strings.xml"
|
||||
line="213"
|
||||
line="212"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -223,7 +223,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-ca/strings.xml"
|
||||
line="235"
|
||||
line="234"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -234,7 +234,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-cs/strings.xml"
|
||||
line="236"
|
||||
line="235"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -245,7 +245,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-bn-rIN/strings.xml"
|
||||
line="241"
|
||||
line="240"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -256,7 +256,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-ga/strings.xml"
|
||||
line="268"
|
||||
line="267"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -278,7 +278,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-ca/strings.xml"
|
||||
line="272"
|
||||
line="271"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -289,7 +289,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-ca/strings.xml"
|
||||
line="276"
|
||||
line="275"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -300,7 +300,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-cs/strings.xml"
|
||||
line="277"
|
||||
line="276"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -311,7 +311,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-cs/strings.xml"
|
||||
line="282"
|
||||
line="281"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -322,7 +322,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-ca/strings.xml"
|
||||
line="301"
|
||||
line="300"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -333,7 +333,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-cs/strings.xml"
|
||||
line="312"
|
||||
line="311"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -344,7 +344,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-cs/strings.xml"
|
||||
line="326"
|
||||
line="325"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -355,7 +355,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-cs/strings.xml"
|
||||
line="331"
|
||||
line="330"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -366,7 +366,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-cs/strings.xml"
|
||||
line="336"
|
||||
line="335"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -377,7 +377,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-bg/strings.xml"
|
||||
line="368"
|
||||
line="367"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -388,7 +388,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-ca/strings.xml"
|
||||
line="368"
|
||||
line="367"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -399,7 +399,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-ca/strings.xml"
|
||||
line="396"
|
||||
line="395"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -410,7 +410,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-ca/strings.xml"
|
||||
line="400"
|
||||
line="399"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -421,7 +421,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-ca/strings.xml"
|
||||
line="404"
|
||||
line="403"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -432,7 +432,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-ca/strings.xml"
|
||||
line="408"
|
||||
line="407"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -443,7 +443,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-cs/strings.xml"
|
||||
line="416"
|
||||
line="415"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -454,7 +454,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-cs/strings.xml"
|
||||
line="421"
|
||||
line="420"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -465,7 +465,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-ca/strings.xml"
|
||||
line="424"
|
||||
line="423"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -476,7 +476,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-cs/strings.xml"
|
||||
line="426"
|
||||
line="425"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -575,7 +575,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-nb-rNO/strings.xml"
|
||||
line="101"
|
||||
line="100"
|
||||
column="45"/>
|
||||
</issue>
|
||||
|
||||
|
@ -586,7 +586,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-nb-rNO/strings.xml"
|
||||
line="105"
|
||||
line="104"
|
||||
column="44"/>
|
||||
</issue>
|
||||
|
||||
|
@ -597,7 +597,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-nb-rNO/strings.xml"
|
||||
line="106"
|
||||
line="105"
|
||||
column="49"/>
|
||||
</issue>
|
||||
|
||||
|
@ -608,7 +608,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-nb-rNO/strings.xml"
|
||||
line="109"
|
||||
line="108"
|
||||
column="38"/>
|
||||
</issue>
|
||||
|
||||
|
@ -619,7 +619,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-nb-rNO/strings.xml"
|
||||
line="124"
|
||||
line="123"
|
||||
column="70"/>
|
||||
</issue>
|
||||
|
||||
|
@ -630,7 +630,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-nb-rNO/strings.xml"
|
||||
line="157"
|
||||
line="156"
|
||||
column="78"/>
|
||||
</issue>
|
||||
|
||||
|
@ -641,7 +641,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-nb-rNO/strings.xml"
|
||||
line="163"
|
||||
line="162"
|
||||
column="65"/>
|
||||
</issue>
|
||||
|
||||
|
@ -652,7 +652,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-nb-rNO/strings.xml"
|
||||
line="199"
|
||||
line="198"
|
||||
column="32"/>
|
||||
</issue>
|
||||
|
||||
|
@ -663,7 +663,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-nb-rNO/strings.xml"
|
||||
line="267"
|
||||
line="266"
|
||||
column="43"/>
|
||||
</issue>
|
||||
|
||||
|
@ -674,7 +674,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-nb-rNO/strings.xml"
|
||||
line="395"
|
||||
line="394"
|
||||
column="86"/>
|
||||
</issue>
|
||||
|
||||
|
@ -685,7 +685,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-tr/strings.xml"
|
||||
line="503"
|
||||
line="502"
|
||||
column="294"/>
|
||||
</issue>
|
||||
|
||||
|
@ -696,7 +696,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-nb-rNO/strings.xml"
|
||||
line="525"
|
||||
line="524"
|
||||
column="51"/>
|
||||
</issue>
|
||||
|
||||
|
@ -707,7 +707,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values-fa/strings.xml"
|
||||
line="171"
|
||||
line="170"
|
||||
column="9"/>
|
||||
</issue>
|
||||
|
||||
|
@ -718,7 +718,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values-fa/strings.xml"
|
||||
line="200"
|
||||
line="199"
|
||||
column="9"/>
|
||||
</issue>
|
||||
|
||||
|
|
|
@ -158,6 +158,7 @@
|
|||
<activity android:name=".components.drafts.DraftsActivity" />
|
||||
<activity android:name=".components.filters.EditFilterActivity"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
<activity android:name=".feature.suggestions.SuggestionsActivity" />
|
||||
|
||||
<receiver
|
||||
android:name=".receiver.SendStatusBroadcastReceiver"
|
||||
|
|
|
@ -102,6 +102,7 @@ import app.pachli.core.navigation.PreferencesActivityIntent
|
|||
import app.pachli.core.navigation.PreferencesActivityIntent.PreferenceScreen
|
||||
import app.pachli.core.navigation.ScheduledStatusActivityIntent
|
||||
import app.pachli.core.navigation.SearchActivityIntent
|
||||
import app.pachli.core.navigation.SuggestionsActivityIntent
|
||||
import app.pachli.core.navigation.TabPreferenceActivityIntent
|
||||
import app.pachli.core.navigation.TimelineActivityIntent
|
||||
import app.pachli.core.navigation.TrendingActivityIntent
|
||||
|
@ -699,6 +700,13 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
|
|||
startActivityWithDefaultTransition(intent)
|
||||
}
|
||||
},
|
||||
primaryDrawerItem {
|
||||
nameRes = R.string.action_suggestions
|
||||
iconicsIcon = GoogleMaterial.Icon.gmd_explore
|
||||
onClick = {
|
||||
startActivityWithDefaultTransition(SuggestionsActivityIntent(context))
|
||||
}
|
||||
},
|
||||
SectionDrawerItem().apply {
|
||||
nameRes = listsSectionTitle
|
||||
},
|
||||
|
|
|
@ -180,6 +180,7 @@
|
|||
<string name="action_dismiss">Dismiss</string>
|
||||
<string name="action_details">Details</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_undo">Undo translate</string>
|
||||
<string name="title_mentions_dialog">Mentions</string>
|
||||
|
|
|
@ -21,8 +21,23 @@ import kotlin.time.Duration
|
|||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.TimeMark
|
||||
import kotlin.time.TimeSource
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingCommand
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Returns a flow that mirrors the original flow, but filters out values that occur within
|
||||
|
@ -69,3 +84,86 @@ fun <T> Flow<T>.throttleFirst(
|
|||
}
|
||||
|
||||
private val DEFAULT_THROTTLE_FIRST_TIMEOUT = 500.milliseconds
|
||||
|
||||
/*
|
||||
* Copyright 2022 Christophe Beyls
|
||||
*
|
||||
* This file is copied from
|
||||
* https://github.com/cbeyls/fosdem-companion-android/blob/c70a681f1ed7d25890636ecd149dcbd4950b2df1/app/src/main/java/be/digitalia/fosdem/flow/FlowExt.kt#L4
|
||||
*
|
||||
* and is based on work he describes in
|
||||
* https://bladecoder.medium.com/smarter-shared-kotlin-flows-d6b75fc66754.
|
||||
*
|
||||
* In personal communication Christophe wrote:
|
||||
*
|
||||
* """
|
||||
* [...] the code, for which I claim no ownership. You can use and modify and
|
||||
* redistribute it all you want including in commercial projects, without
|
||||
* attribution.
|
||||
* """
|
||||
*
|
||||
* The fosdem-companion-android repository is under the Apache 2.0 license.
|
||||
*/
|
||||
|
||||
@JvmInline
|
||||
value class SharedFlowContext(private val subscriptionCount: StateFlow<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)
|
||||
}
|
||||
|
|
|
@ -19,6 +19,8 @@ package app.pachli.core.data.di
|
|||
|
||||
import app.pachli.core.data.repository.ListsRepository
|
||||
import app.pachli.core.data.repository.NetworkListsRepository
|
||||
import app.pachli.core.data.repository.NetworkSuggestionsRepository
|
||||
import app.pachli.core.data.repository.SuggestionsRepository
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
|
@ -31,4 +33,9 @@ abstract class DataModule {
|
|||
internal abstract fun bindsListsRepository(
|
||||
listsRepository: NetworkListsRepository,
|
||||
): ListsRepository
|
||||
|
||||
@Binds
|
||||
internal abstract fun bindsSuggestionsRepository(
|
||||
suggestionsRepository: NetworkSuggestionsRepository,
|
||||
): SuggestionsRepository
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -614,6 +614,12 @@ class SearchActivityIntent(context: Context) : Intent() {
|
|||
}
|
||||
}
|
||||
|
||||
class SuggestionsActivityIntent(context: Context) : Intent() {
|
||||
init {
|
||||
setClassName(context, QuadrantConstants.SUGGESTIONS_ACTIVITY)
|
||||
}
|
||||
}
|
||||
|
||||
class TabPreferenceActivityIntent(context: Context) : Intent() {
|
||||
init {
|
||||
setClassName(context, QuadrantConstants.TAB_PREFERENCE_ACTIVITY)
|
||||
|
|
|
@ -40,6 +40,7 @@ data class Account(
|
|||
// Pixelfed might omit `header`
|
||||
val header: String = "",
|
||||
val locked: Boolean = false,
|
||||
@Json(name = "last_status_at") val lastStatusAt: Date? = null,
|
||||
@Json(name = "followers_count") val followersCount: Int = 0,
|
||||
@Json(name = "following_count") val followingCount: Int = 0,
|
||||
@Json(name = "statuses_count") val statusesCount: Int = 0,
|
||||
|
|
|
@ -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,
|
||||
)
|
|
@ -45,6 +45,7 @@ import app.pachli.core.network.model.Status
|
|||
import app.pachli.core.network.model.StatusContext
|
||||
import app.pachli.core.network.model.StatusEdit
|
||||
import app.pachli.core.network.model.StatusSource
|
||||
import app.pachli.core.network.model.Suggestion
|
||||
import app.pachli.core.network.model.TimelineAccount
|
||||
import app.pachli.core.network.model.Translation
|
||||
import app.pachli.core.network.model.TrendingTag
|
||||
|
@ -800,4 +801,17 @@ interface MastodonApi {
|
|||
suspend fun trendingStatuses(
|
||||
@Query("limit") limit: Int? = null,
|
||||
): Response<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>
|
||||
}
|
||||
|
|
|
@ -44,6 +44,6 @@ dependencies {
|
|||
// Some views inherit from AndroidX views
|
||||
implementation(libs.bundles.androidx)
|
||||
|
||||
implementation(libs.material.iconics)
|
||||
implementation(libs.material.typeface)
|
||||
api(libs.material.iconics)
|
||||
api(libs.material.typeface)
|
||||
}
|
||||
|
|
|
@ -1,4 +1,21 @@
|
|||
<?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>
|
||||
<item name="action_expand_collapse_cw" type="id" />
|
||||
<item name="action_reply" type="id" />
|
||||
|
@ -23,4 +40,7 @@
|
|||
<item name="action_open_reblogged_by" type="id" />
|
||||
<item name="action_open_faved_by" type="id" />
|
||||
<item name="action_more" type="id" />
|
||||
|
||||
<item name="action_dismiss_follow_suggestion" type="id" />
|
||||
<item name="action_follow_account" type="id" />
|
||||
</resources>
|
|
@ -15,7 +15,7 @@
|
|||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package app.pachli
|
||||
package app.pachli.core.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.text.SpannableStringBuilder
|
||||
|
@ -26,12 +26,6 @@ import androidx.test.platform.app.InstrumentationRegistry
|
|||
import app.pachli.core.activity.BottomSheetActivity.Companion.looksLikeMastodonUrl
|
||||
import app.pachli.core.network.model.HashTag
|
||||
import app.pachli.core.network.model.Status
|
||||
import app.pachli.core.ui.LinkListener
|
||||
import app.pachli.core.ui.R
|
||||
import app.pachli.core.ui.getDomain
|
||||
import app.pachli.core.ui.getTagName
|
||||
import app.pachli.core.ui.markupHiddenUrls
|
||||
import app.pachli.core.ui.setClickableText
|
||||
import org.junit.Assert
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
|
@ -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>
|
|
@ -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)
|
||||
}
|
|
@ -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>
|
|
@ -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)
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 `<`.
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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<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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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"><b>%1$s</b> followers</string>
|
||||
<string name="follows_count_fmt"><b>%1$s</b> following</string>
|
||||
<string name="statuses_count_fmt"><b>%1$s</b> posts</string>
|
||||
<string name="statuses_count_per_week_fmt"><b>%1$s</b> posts (<b>%2$d</b> per week)</string>
|
||||
<string name="statuses_count_per_week_last_fmt"><b>%1$s</b> posts (<b>%2$d</b> per week, last <b>%3$s</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>
|
|
@ -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
|
|
@ -66,6 +66,7 @@ include(":core:ui")
|
|||
include(":feature:about")
|
||||
include(":feature:lists")
|
||||
include(":feature:login")
|
||||
include(":feature:suggestions")
|
||||
include(":tools")
|
||||
include(":tools:mklanguages")
|
||||
include(":tools:mkserverversions")
|
||||
|
|
Loading…
Reference in New Issue