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.about)
|
||||||
implementation(projects.feature.lists)
|
implementation(projects.feature.lists)
|
||||||
implementation(projects.feature.login)
|
implementation(projects.feature.login)
|
||||||
|
implementation(projects.feature.suggestions)
|
||||||
|
|
||||||
implementation(libs.kotlinx.coroutines.android)
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
|
||||||
|
|
|
@ -135,7 +135,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-ta/strings.xml"
|
file="src/main/res/values-ta/strings.xml"
|
||||||
line="154"
|
line="153"
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -157,7 +157,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-ga/strings.xml"
|
file="src/main/res/values-ga/strings.xml"
|
||||||
line="165"
|
line="164"
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -168,7 +168,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-sl/strings.xml"
|
file="src/main/res/values-sl/strings.xml"
|
||||||
line="179"
|
line="178"
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -179,7 +179,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-cs/strings.xml"
|
file="src/main/res/values-cs/strings.xml"
|
||||||
line="193"
|
line="192"
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -190,7 +190,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-bn-rIN/strings.xml"
|
file="src/main/res/values-bn-rIN/strings.xml"
|
||||||
line="200"
|
line="199"
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -201,7 +201,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-eu/strings.xml"
|
file="src/main/res/values-eu/strings.xml"
|
||||||
line="202"
|
line="201"
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -212,7 +212,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-sl/strings.xml"
|
file="src/main/res/values-sl/strings.xml"
|
||||||
line="213"
|
line="212"
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -223,7 +223,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-ca/strings.xml"
|
file="src/main/res/values-ca/strings.xml"
|
||||||
line="235"
|
line="234"
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -234,7 +234,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-cs/strings.xml"
|
file="src/main/res/values-cs/strings.xml"
|
||||||
line="236"
|
line="235"
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -245,7 +245,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-bn-rIN/strings.xml"
|
file="src/main/res/values-bn-rIN/strings.xml"
|
||||||
line="241"
|
line="240"
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -256,7 +256,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-ga/strings.xml"
|
file="src/main/res/values-ga/strings.xml"
|
||||||
line="268"
|
line="267"
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -278,7 +278,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-ca/strings.xml"
|
file="src/main/res/values-ca/strings.xml"
|
||||||
line="272"
|
line="271"
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -289,7 +289,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-ca/strings.xml"
|
file="src/main/res/values-ca/strings.xml"
|
||||||
line="276"
|
line="275"
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -300,7 +300,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-cs/strings.xml"
|
file="src/main/res/values-cs/strings.xml"
|
||||||
line="277"
|
line="276"
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -311,7 +311,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-cs/strings.xml"
|
file="src/main/res/values-cs/strings.xml"
|
||||||
line="282"
|
line="281"
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -322,7 +322,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-ca/strings.xml"
|
file="src/main/res/values-ca/strings.xml"
|
||||||
line="301"
|
line="300"
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -333,7 +333,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-cs/strings.xml"
|
file="src/main/res/values-cs/strings.xml"
|
||||||
line="312"
|
line="311"
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -344,7 +344,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-cs/strings.xml"
|
file="src/main/res/values-cs/strings.xml"
|
||||||
line="326"
|
line="325"
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -355,7 +355,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-cs/strings.xml"
|
file="src/main/res/values-cs/strings.xml"
|
||||||
line="331"
|
line="330"
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -366,7 +366,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-cs/strings.xml"
|
file="src/main/res/values-cs/strings.xml"
|
||||||
line="336"
|
line="335"
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -377,7 +377,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-bg/strings.xml"
|
file="src/main/res/values-bg/strings.xml"
|
||||||
line="368"
|
line="367"
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -388,7 +388,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-ca/strings.xml"
|
file="src/main/res/values-ca/strings.xml"
|
||||||
line="368"
|
line="367"
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -399,7 +399,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-ca/strings.xml"
|
file="src/main/res/values-ca/strings.xml"
|
||||||
line="396"
|
line="395"
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -410,7 +410,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-ca/strings.xml"
|
file="src/main/res/values-ca/strings.xml"
|
||||||
line="400"
|
line="399"
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -421,7 +421,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-ca/strings.xml"
|
file="src/main/res/values-ca/strings.xml"
|
||||||
line="404"
|
line="403"
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -432,7 +432,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-ca/strings.xml"
|
file="src/main/res/values-ca/strings.xml"
|
||||||
line="408"
|
line="407"
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -443,7 +443,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-cs/strings.xml"
|
file="src/main/res/values-cs/strings.xml"
|
||||||
line="416"
|
line="415"
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -454,7 +454,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-cs/strings.xml"
|
file="src/main/res/values-cs/strings.xml"
|
||||||
line="421"
|
line="420"
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -465,7 +465,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-ca/strings.xml"
|
file="src/main/res/values-ca/strings.xml"
|
||||||
line="424"
|
line="423"
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -476,7 +476,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-cs/strings.xml"
|
file="src/main/res/values-cs/strings.xml"
|
||||||
line="426"
|
line="425"
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -575,7 +575,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-nb-rNO/strings.xml"
|
file="src/main/res/values-nb-rNO/strings.xml"
|
||||||
line="101"
|
line="100"
|
||||||
column="45"/>
|
column="45"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -586,7 +586,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-nb-rNO/strings.xml"
|
file="src/main/res/values-nb-rNO/strings.xml"
|
||||||
line="105"
|
line="104"
|
||||||
column="44"/>
|
column="44"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -597,7 +597,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-nb-rNO/strings.xml"
|
file="src/main/res/values-nb-rNO/strings.xml"
|
||||||
line="106"
|
line="105"
|
||||||
column="49"/>
|
column="49"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -608,7 +608,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-nb-rNO/strings.xml"
|
file="src/main/res/values-nb-rNO/strings.xml"
|
||||||
line="109"
|
line="108"
|
||||||
column="38"/>
|
column="38"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -619,7 +619,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-nb-rNO/strings.xml"
|
file="src/main/res/values-nb-rNO/strings.xml"
|
||||||
line="124"
|
line="123"
|
||||||
column="70"/>
|
column="70"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -630,7 +630,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-nb-rNO/strings.xml"
|
file="src/main/res/values-nb-rNO/strings.xml"
|
||||||
line="157"
|
line="156"
|
||||||
column="78"/>
|
column="78"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -641,7 +641,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-nb-rNO/strings.xml"
|
file="src/main/res/values-nb-rNO/strings.xml"
|
||||||
line="163"
|
line="162"
|
||||||
column="65"/>
|
column="65"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -652,7 +652,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-nb-rNO/strings.xml"
|
file="src/main/res/values-nb-rNO/strings.xml"
|
||||||
line="199"
|
line="198"
|
||||||
column="32"/>
|
column="32"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -663,7 +663,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-nb-rNO/strings.xml"
|
file="src/main/res/values-nb-rNO/strings.xml"
|
||||||
line="267"
|
line="266"
|
||||||
column="43"/>
|
column="43"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -674,7 +674,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-nb-rNO/strings.xml"
|
file="src/main/res/values-nb-rNO/strings.xml"
|
||||||
line="395"
|
line="394"
|
||||||
column="86"/>
|
column="86"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -685,7 +685,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-tr/strings.xml"
|
file="src/main/res/values-tr/strings.xml"
|
||||||
line="503"
|
line="502"
|
||||||
column="294"/>
|
column="294"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -696,7 +696,7 @@
|
||||||
errorLine2=" ^">
|
errorLine2=" ^">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-nb-rNO/strings.xml"
|
file="src/main/res/values-nb-rNO/strings.xml"
|
||||||
line="525"
|
line="524"
|
||||||
column="51"/>
|
column="51"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -707,7 +707,7 @@
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-fa/strings.xml"
|
file="src/main/res/values-fa/strings.xml"
|
||||||
line="171"
|
line="170"
|
||||||
column="9"/>
|
column="9"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -718,7 +718,7 @@
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values-fa/strings.xml"
|
file="src/main/res/values-fa/strings.xml"
|
||||||
line="200"
|
line="199"
|
||||||
column="9"/>
|
column="9"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
|
|
@ -158,6 +158,7 @@
|
||||||
<activity android:name=".components.drafts.DraftsActivity" />
|
<activity android:name=".components.drafts.DraftsActivity" />
|
||||||
<activity android:name=".components.filters.EditFilterActivity"
|
<activity android:name=".components.filters.EditFilterActivity"
|
||||||
android:windowSoftInputMode="adjustResize" />
|
android:windowSoftInputMode="adjustResize" />
|
||||||
|
<activity android:name=".feature.suggestions.SuggestionsActivity" />
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".receiver.SendStatusBroadcastReceiver"
|
android:name=".receiver.SendStatusBroadcastReceiver"
|
||||||
|
|
|
@ -102,6 +102,7 @@ import app.pachli.core.navigation.PreferencesActivityIntent
|
||||||
import app.pachli.core.navigation.PreferencesActivityIntent.PreferenceScreen
|
import app.pachli.core.navigation.PreferencesActivityIntent.PreferenceScreen
|
||||||
import app.pachli.core.navigation.ScheduledStatusActivityIntent
|
import app.pachli.core.navigation.ScheduledStatusActivityIntent
|
||||||
import app.pachli.core.navigation.SearchActivityIntent
|
import app.pachli.core.navigation.SearchActivityIntent
|
||||||
|
import app.pachli.core.navigation.SuggestionsActivityIntent
|
||||||
import app.pachli.core.navigation.TabPreferenceActivityIntent
|
import app.pachli.core.navigation.TabPreferenceActivityIntent
|
||||||
import app.pachli.core.navigation.TimelineActivityIntent
|
import app.pachli.core.navigation.TimelineActivityIntent
|
||||||
import app.pachli.core.navigation.TrendingActivityIntent
|
import app.pachli.core.navigation.TrendingActivityIntent
|
||||||
|
@ -699,6 +700,13 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
|
||||||
startActivityWithDefaultTransition(intent)
|
startActivityWithDefaultTransition(intent)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
primaryDrawerItem {
|
||||||
|
nameRes = R.string.action_suggestions
|
||||||
|
iconicsIcon = GoogleMaterial.Icon.gmd_explore
|
||||||
|
onClick = {
|
||||||
|
startActivityWithDefaultTransition(SuggestionsActivityIntent(context))
|
||||||
|
}
|
||||||
|
},
|
||||||
SectionDrawerItem().apply {
|
SectionDrawerItem().apply {
|
||||||
nameRes = listsSectionTitle
|
nameRes = listsSectionTitle
|
||||||
},
|
},
|
||||||
|
|
|
@ -180,6 +180,7 @@
|
||||||
<string name="action_dismiss">Dismiss</string>
|
<string name="action_dismiss">Dismiss</string>
|
||||||
<string name="action_details">Details</string>
|
<string name="action_details">Details</string>
|
||||||
<string name="action_add_reaction">add reaction</string>
|
<string name="action_add_reaction">add reaction</string>
|
||||||
|
<string name="action_suggestions">Suggested accounts</string>
|
||||||
<string name="action_translate">Translate</string>
|
<string name="action_translate">Translate</string>
|
||||||
<string name="action_translate_undo">Undo translate</string>
|
<string name="action_translate_undo">Undo translate</string>
|
||||||
<string name="title_mentions_dialog">Mentions</string>
|
<string name="title_mentions_dialog">Mentions</string>
|
||||||
|
|
|
@ -21,8 +21,23 @@ import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.TimeMark
|
import kotlin.time.TimeMark
|
||||||
import kotlin.time.TimeSource
|
import kotlin.time.TimeSource
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.FlowCollector
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingCommand
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.mapLatest
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a flow that mirrors the original flow, but filters out values that occur within
|
* Returns a flow that mirrors the original flow, but filters out values that occur within
|
||||||
|
@ -69,3 +84,86 @@ fun <T> Flow<T>.throttleFirst(
|
||||||
}
|
}
|
||||||
|
|
||||||
private val DEFAULT_THROTTLE_FIRST_TIMEOUT = 500.milliseconds
|
private val DEFAULT_THROTTLE_FIRST_TIMEOUT = 500.milliseconds
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Copyright 2022 Christophe Beyls
|
||||||
|
*
|
||||||
|
* This file is copied from
|
||||||
|
* https://github.com/cbeyls/fosdem-companion-android/blob/c70a681f1ed7d25890636ecd149dcbd4950b2df1/app/src/main/java/be/digitalia/fosdem/flow/FlowExt.kt#L4
|
||||||
|
*
|
||||||
|
* and is based on work he describes in
|
||||||
|
* https://bladecoder.medium.com/smarter-shared-kotlin-flows-d6b75fc66754.
|
||||||
|
*
|
||||||
|
* In personal communication Christophe wrote:
|
||||||
|
*
|
||||||
|
* """
|
||||||
|
* [...] the code, for which I claim no ownership. You can use and modify and
|
||||||
|
* redistribute it all you want including in commercial projects, without
|
||||||
|
* attribution.
|
||||||
|
* """
|
||||||
|
*
|
||||||
|
* The fosdem-companion-android repository is under the Apache 2.0 license.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@JvmInline
|
||||||
|
value class SharedFlowContext(private val subscriptionCount: StateFlow<Int>) {
|
||||||
|
/**
|
||||||
|
* A shared flow that does not cancel collecting the upstream flow after
|
||||||
|
* a state (lifecycle) change.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
fun <T> Flow<T>.flowWhileShared(started: SharingStarted): Flow<T> {
|
||||||
|
return started.command(subscriptionCount)
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.flatMapLatest {
|
||||||
|
when (it) {
|
||||||
|
SharingCommand.START -> this
|
||||||
|
SharingCommand.STOP,
|
||||||
|
SharingCommand.STOP_AND_RESET_REPLAY_CACHE,
|
||||||
|
-> emptyFlow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <T> stateFlow(
|
||||||
|
scope: CoroutineScope,
|
||||||
|
initialValue: T,
|
||||||
|
producer: SharedFlowContext.() -> Flow<T>,
|
||||||
|
): StateFlow<T> {
|
||||||
|
val state = MutableStateFlow(initialValue)
|
||||||
|
producer(SharedFlowContext(state.subscriptionCount)).launchIn(scope, state)
|
||||||
|
return state.asStateFlow()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> Flow<T>.launchIn(scope: CoroutineScope, collector: FlowCollector<T>): Job = scope.launch {
|
||||||
|
collect(collector)
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <T> countSubscriptionsFlow(producer: SharedFlowContext.() -> Flow<T>): Flow<T> {
|
||||||
|
val subscriptionCount = MutableStateFlow(0)
|
||||||
|
return producer(SharedFlowContext(subscriptionCount.asStateFlow()))
|
||||||
|
.countSubscriptionsTo(subscriptionCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> Flow<T>.countSubscriptionsTo(subscriptionCount: MutableStateFlow<Int>): Flow<T> {
|
||||||
|
return flow {
|
||||||
|
subscriptionCount.update { it + 1 }
|
||||||
|
try {
|
||||||
|
collect(this)
|
||||||
|
} finally {
|
||||||
|
subscriptionCount.update { it - 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
fun <T> SharedFlowContext.versionedResourceFlow(
|
||||||
|
version: Flow<Int>,
|
||||||
|
producer: suspend (version: Int) -> T,
|
||||||
|
): Flow<T> {
|
||||||
|
return version
|
||||||
|
.flowWhileShared(SharingStarted.WhileSubscribed())
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.mapLatest(producer)
|
||||||
|
}
|
||||||
|
|
|
@ -19,6 +19,8 @@ package app.pachli.core.data.di
|
||||||
|
|
||||||
import app.pachli.core.data.repository.ListsRepository
|
import app.pachli.core.data.repository.ListsRepository
|
||||||
import app.pachli.core.data.repository.NetworkListsRepository
|
import app.pachli.core.data.repository.NetworkListsRepository
|
||||||
|
import app.pachli.core.data.repository.NetworkSuggestionsRepository
|
||||||
|
import app.pachli.core.data.repository.SuggestionsRepository
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
|
@ -31,4 +33,9 @@ abstract class DataModule {
|
||||||
internal abstract fun bindsListsRepository(
|
internal abstract fun bindsListsRepository(
|
||||||
listsRepository: NetworkListsRepository,
|
listsRepository: NetworkListsRepository,
|
||||||
): ListsRepository
|
): ListsRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
internal abstract fun bindsSuggestionsRepository(
|
||||||
|
suggestionsRepository: NetworkSuggestionsRepository,
|
||||||
|
): SuggestionsRepository
|
||||||
}
|
}
|
||||||
|
|
|
@ -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() {
|
class TabPreferenceActivityIntent(context: Context) : Intent() {
|
||||||
init {
|
init {
|
||||||
setClassName(context, QuadrantConstants.TAB_PREFERENCE_ACTIVITY)
|
setClassName(context, QuadrantConstants.TAB_PREFERENCE_ACTIVITY)
|
||||||
|
|
|
@ -40,6 +40,7 @@ data class Account(
|
||||||
// Pixelfed might omit `header`
|
// Pixelfed might omit `header`
|
||||||
val header: String = "",
|
val header: String = "",
|
||||||
val locked: Boolean = false,
|
val locked: Boolean = false,
|
||||||
|
@Json(name = "last_status_at") val lastStatusAt: Date? = null,
|
||||||
@Json(name = "followers_count") val followersCount: Int = 0,
|
@Json(name = "followers_count") val followersCount: Int = 0,
|
||||||
@Json(name = "following_count") val followingCount: Int = 0,
|
@Json(name = "following_count") val followingCount: Int = 0,
|
||||||
@Json(name = "statuses_count") val statusesCount: Int = 0,
|
@Json(name = "statuses_count") val statusesCount: Int = 0,
|
||||||
|
|
|
@ -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.StatusContext
|
||||||
import app.pachli.core.network.model.StatusEdit
|
import app.pachli.core.network.model.StatusEdit
|
||||||
import app.pachli.core.network.model.StatusSource
|
import app.pachli.core.network.model.StatusSource
|
||||||
|
import app.pachli.core.network.model.Suggestion
|
||||||
import app.pachli.core.network.model.TimelineAccount
|
import app.pachli.core.network.model.TimelineAccount
|
||||||
import app.pachli.core.network.model.Translation
|
import app.pachli.core.network.model.Translation
|
||||||
import app.pachli.core.network.model.TrendingTag
|
import app.pachli.core.network.model.TrendingTag
|
||||||
|
@ -800,4 +801,17 @@ interface MastodonApi {
|
||||||
suspend fun trendingStatuses(
|
suspend fun trendingStatuses(
|
||||||
@Query("limit") limit: Int? = null,
|
@Query("limit") limit: Int? = null,
|
||||||
): Response<List<Status>>
|
): Response<List<Status>>
|
||||||
|
|
||||||
|
@GET("api/v2/suggestions")
|
||||||
|
suspend fun getSuggestions(
|
||||||
|
@Query("limit") limit: Int? = null,
|
||||||
|
): ApiResult<List<Suggestion>>
|
||||||
|
|
||||||
|
@DELETE("api/v1/suggestions/{accountId}")
|
||||||
|
suspend fun deleteSuggestion(@Path("accountId") accountId: String): ApiResult<Unit>
|
||||||
|
|
||||||
|
// Copy of followAccount, except it returns an ApiResult. Temporary, until followAccount
|
||||||
|
// is converted to also return ApiResult.
|
||||||
|
@POST("api/v1/accounts/{id}/follow")
|
||||||
|
suspend fun followSuggestedAccount(@Path("id") accountId: String): ApiResult<Relationship>
|
||||||
}
|
}
|
||||||
|
|
|
@ -44,6 +44,6 @@ dependencies {
|
||||||
// Some views inherit from AndroidX views
|
// Some views inherit from AndroidX views
|
||||||
implementation(libs.bundles.androidx)
|
implementation(libs.bundles.androidx)
|
||||||
|
|
||||||
implementation(libs.material.iconics)
|
api(libs.material.iconics)
|
||||||
implementation(libs.material.typeface)
|
api(libs.material.typeface)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,21 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
~ Copyright 2024 Pachli Association
|
||||||
|
~
|
||||||
|
~ This file is a part of Pachli.
|
||||||
|
~
|
||||||
|
~ This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||||
|
~ GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||||
|
~ License, or (at your option) any later version.
|
||||||
|
~
|
||||||
|
~ Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||||
|
~ the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||||
|
~ Public License for more details.
|
||||||
|
~
|
||||||
|
~ You should have received a copy of the GNU General Public License along with Pachli; if not,
|
||||||
|
~ see <http://www.gnu.org/licenses>.
|
||||||
|
-->
|
||||||
|
|
||||||
<resources>
|
<resources>
|
||||||
<item name="action_expand_collapse_cw" type="id" />
|
<item name="action_expand_collapse_cw" type="id" />
|
||||||
<item name="action_reply" type="id" />
|
<item name="action_reply" type="id" />
|
||||||
|
@ -23,4 +40,7 @@
|
||||||
<item name="action_open_reblogged_by" type="id" />
|
<item name="action_open_reblogged_by" type="id" />
|
||||||
<item name="action_open_faved_by" type="id" />
|
<item name="action_open_faved_by" type="id" />
|
||||||
<item name="action_more" type="id" />
|
<item name="action_more" type="id" />
|
||||||
</resources>
|
|
||||||
|
<item name="action_dismiss_follow_suggestion" type="id" />
|
||||||
|
<item name="action_follow_account" type="id" />
|
||||||
|
</resources>
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
* see <http://www.gnu.org/licenses>.
|
* see <http://www.gnu.org/licenses>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package app.pachli
|
package app.pachli.core.ui
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.text.SpannableStringBuilder
|
import android.text.SpannableStringBuilder
|
||||||
|
@ -26,12 +26,6 @@ import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import app.pachli.core.activity.BottomSheetActivity.Companion.looksLikeMastodonUrl
|
import app.pachli.core.activity.BottomSheetActivity.Companion.looksLikeMastodonUrl
|
||||||
import app.pachli.core.network.model.HashTag
|
import app.pachli.core.network.model.HashTag
|
||||||
import app.pachli.core.network.model.Status
|
import app.pachli.core.network.model.Status
|
||||||
import app.pachli.core.ui.LinkListener
|
|
||||||
import app.pachli.core.ui.R
|
|
||||||
import app.pachli.core.ui.getDomain
|
|
||||||
import app.pachli.core.ui.getTagName
|
|
||||||
import app.pachli.core.ui.markupHiddenUrls
|
|
||||||
import app.pachli.core.ui.setClickableText
|
|
||||||
import org.junit.Assert
|
import org.junit.Assert
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
|
@ -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:about")
|
||||||
include(":feature:lists")
|
include(":feature:lists")
|
||||||
include(":feature:login")
|
include(":feature:login")
|
||||||
|
include(":feature:suggestions")
|
||||||
include(":tools")
|
include(":tools")
|
||||||
include(":tools:mklanguages")
|
include(":tools:mklanguages")
|
||||||
include(":tools:mkserverversions")
|
include(":tools:mkserverversions")
|
||||||
|
|
Loading…
Reference in New Issue