Compare commits

...

1 Commits

Author SHA1 Message Date
Claude
67f7794521 feat: add Follow List Organizer to sort large follow lists into people lists
Adds an in-app tool that helps users with large kind-3 follow lists
(thousands of follows) sort them into NIP-51 kind:30000 people lists
using one of three algorithms:

- Last seen: buckets follows by recency of their most recent post
- Content type: groups by the dominant kind they post (notes, articles,
  photos, video, livestreams)
- Topics: clusters follows by the hashtag they use most, keeping only
  topics shared by several follows

The organizer scans the local cache for per-author activity (last-seen
timestamp, kind histogram, hashtag counts) in a single pass, and backfills
recent events from the follows' outbox relays so cold caches still have
data to group on. Proposed groups are previewed with member counts and
checkboxes; selected groups are published as kind:30000 lists (private by
default).

Reachable from the Lists screen via a new "Organize" entry.
2026-06-14 23:03:37 +00:00
9 changed files with 991 additions and 0 deletions

View File

@@ -0,0 +1,252 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.followOrganizer
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
/**
* Aggregated activity for a single followed pubkey, computed by scanning the
* locally cached events authored by that key. The grouping algorithms below
* operate purely on these stats so they can be unit tested without a cache.
*
* @param lastSeen the most recent createdAt (unix seconds) seen for this author,
* or null when no events authored by them are cached locally.
*/
data class FollowStats(
val pubkey: HexKey,
val lastSeen: Long?,
val totalEvents: Int,
val kindCounts: Map<Int, Int>,
val hashtagCounts: Map<String, Int>,
)
/** A list the organizer proposes to create, with the members it would contain. */
data class ProposedGroup(
val title: String,
val members: List<HexKey>,
)
enum class OrganizeStrategy {
LAST_SEEN,
CONTENT_TYPE,
TOPICS,
}
/**
* Pure grouping algorithms over [FollowStats]. The titles produced here become
* the published NIP-51 people-list names, so they are plain human-readable
* strings rather than localized resources.
*/
object FollowGrouper {
private const val DAY = 24L * 60L * 60L
fun group(
strategy: OrganizeStrategy,
stats: Collection<FollowStats>,
now: Long,
): List<ProposedGroup> =
when (strategy) {
OrganizeStrategy.LAST_SEEN -> byLastSeen(stats, now)
OrganizeStrategy.CONTENT_TYPE -> byContentType(stats)
OrganizeStrategy.TOPICS -> byTopics(stats)
}
// ---------------------------------------------------------------
// Last-seen activity
// ---------------------------------------------------------------
private data class Bucket(
val title: String,
val maxAgeDays: Long?,
)
private val lastSeenBuckets =
listOf(
Bucket("Active (last 7 days)", 7),
Bucket("Recent (last 30 days)", 30),
Bucket("Slowing down (last 90 days)", 90),
Bucket("Dormant (last year)", 365),
Bucket("Silent (over a year)", null),
)
fun byLastSeen(
stats: Collection<FollowStats>,
now: Long,
): List<ProposedGroup> {
val withActivity = stats.filter { it.lastSeen != null }
val noActivity = stats.filter { it.lastSeen == null }
val buckets = lastSeenBuckets.associateWith { mutableListOf<HexKey>() }
for (stat in withActivity) {
val ageDays = (now - stat.lastSeen!!) / DAY
val bucket = lastSeenBuckets.first { it.maxAgeDays == null || ageDays <= it.maxAgeDays }
buckets.getValue(bucket).add(stat.pubkey)
}
val groups =
lastSeenBuckets.mapNotNull { bucket ->
val members = buckets.getValue(bucket)
if (members.isEmpty()) null else ProposedGroup(bucket.title, members)
}
return if (noActivity.isEmpty()) {
groups
} else {
groups + ProposedGroup("No recent posts cached", noActivity.map { it.pubkey })
}
}
// ---------------------------------------------------------------
// Content type
// ---------------------------------------------------------------
// Ordered by priority so ties resolve deterministically toward the more
// distinctive long-form / media content.
private val contentCategories =
listOf(
"Long-form writers" to setOf(30023, 30024),
"Video creators" to setOf(21, 22, 34235, 34236),
"Photographers" to setOf(20),
"Livestreamers" to setOf(30311, 1311),
"Note posters" to setOf(1, 1111),
)
fun byContentType(stats: Collection<FollowStats>): List<ProposedGroup> {
val buckets = linkedMapOf<String, MutableList<HexKey>>()
contentCategories.forEach { buckets[it.first] = mutableListOf() }
val other = mutableListOf<HexKey>()
for (stat in stats) {
val best =
contentCategories.maxByOrNull { (_, kinds) ->
kinds.sumOf { stat.kindCounts[it] ?: 0 }
}
val bestCount = best?.second?.sumOf { stat.kindCounts[it] ?: 0 } ?: 0
if (best != null && bestCount > 0) {
buckets.getValue(best.first).add(stat.pubkey)
} else {
other.add(stat.pubkey)
}
}
val groups =
buckets.mapNotNull { (title, members) ->
if (members.isEmpty()) null else ProposedGroup(title, members)
}
return if (other.isEmpty()) {
groups
} else {
groups + ProposedGroup("Reposters & no cached posts", other)
}
}
// ---------------------------------------------------------------
// Topics / hashtags
// ---------------------------------------------------------------
/**
* Assigns each follow to the single hashtag they use most, then keeps only
* topics shared by at least [minGroupSize] follows (so we don't create a
* list per one-off tag), capped at [maxGroups] of the most popular topics.
*/
fun byTopics(
stats: Collection<FollowStats>,
minGroupSize: Int = 3,
maxGroups: Int = 25,
): List<ProposedGroup> {
val byTopic = linkedMapOf<String, MutableList<HexKey>>()
for (stat in stats) {
val topTag =
stat.hashtagCounts.entries
.maxWithOrNull(compareBy({ it.value }, { it.key }))
?.key ?: continue
byTopic.getOrPut(topTag.lowercase()) { mutableListOf() }.add(stat.pubkey)
}
return byTopic.entries
.filter { it.value.size >= minGroupSize }
.sortedByDescending { it.value.size }
.take(maxGroups)
.map { ProposedGroup("#${it.key}", it.value) }
}
}
/** Scans the local event store and aggregates per-author activity for a follow set. */
object FollowOrganizer {
private class Accumulator {
var lastSeen: Long = 0
var total: Int = 0
val kinds = HashMap<Int, Int>()
val tags = HashMap<String, Int>()
fun add(
kind: Int,
createdAt: Long,
hashtags: List<String>,
) {
total++
if (createdAt > lastSeen) lastSeen = createdAt
kinds[kind] = (kinds[kind] ?: 0) + 1
for (tag in hashtags) {
val key = tag.lowercase()
tags[key] = (tags[key] ?: 0) + 1
}
}
}
/**
* Builds a [FollowStats] for every pubkey in [followPubkeys]. Authors with no
* cached events still get an entry (with null lastSeen) so the UI can report
* how many follows have no local data to organize on.
*/
fun analyze(
followPubkeys: Set<HexKey>,
cache: LocalCache,
): List<FollowStats> {
if (followPubkeys.isEmpty()) return emptyList()
val acc = HashMap<HexKey, Accumulator>(followPubkeys.size)
cache.notes.forEach { _, note ->
val event = note.event ?: return@forEach
val author = event.pubKey
if (author !in followPubkeys) return@forEach
acc
.getOrPut(author) { Accumulator() }
.add(event.kind, event.createdAt, event.hashtags())
}
return followPubkeys.map { pubkey ->
val a = acc[pubkey]
if (a == null) {
FollowStats(pubkey, null, 0, emptyMap(), emptyMap())
} else {
FollowStats(pubkey, a.lastSeen, a.total, a.kinds, a.tags)
}
}
}
}

View File

@@ -216,6 +216,28 @@ class PeopleListsState(
return dTag
}
suspend fun addFollowListWithMembers(
listName: String,
listDescription: String?,
members: List<User>,
isPrivate: Boolean,
account: Account,
): String {
val dTag = UUID.randomUUID().toString()
val tags = members.toSet().toUserTags()
val newList =
PeopleListEvent.createListWithDescription(
dTag = dTag,
title = listName,
description = listDescription,
publicMembers = if (!isPrivate) tags else emptyList(),
privateMembers = if (isPrivate) tags else emptyList(),
signer = account.signer,
)
account.sendMyPublicAndPrivateOutbox(newList)
return dTag
}
suspend fun updateMetadata(
listName: String?,
listDescription: String?,

View File

@@ -135,6 +135,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.ListOfPeopleList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.metadata.FollowPackMetadataScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.metadata.PeopleListMetadataScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.memberEdit.FollowListAndPackAndUserScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.organize.FollowOrganizerScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.LiveStreamsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.LongsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.AddToMusicPlaylistSheet
@@ -330,6 +331,7 @@ fun BuildNavigation(
composableFromBottomArgs<Route.SendPayment> { SendPaymentScreen(it.userHex, it.method, it.lnAddressOverride, it.btcAddressOverride, accountViewModel, nav) }
composableFromEnd<Route.Lists> { ListOfPeopleListsScreen(accountViewModel, nav) }
composableFromEnd<Route.FollowOrganizer> { FollowOrganizerScreen(accountViewModel, nav) }
composableFromEndArgs<Route.MyPeopleListView> { PeopleListScreen(it.dTag, accountViewModel, nav) }
composableFromEndArgs<Route.MyFollowPackView> { FollowPackScreen(it.dTag, accountViewModel, nav) }
composableFromBottomArgs<Route.PeopleListManagement> { FollowListAndPackAndUserScreen(it.userToAdd, accountViewModel, nav) }

View File

@@ -368,6 +368,8 @@ sealed class Route {
@Serializable object Lists : Route()
@Serializable object FollowOrganizer : Route()
@Serializable data class MyPeopleListView(
val dTag: String,
) : Route()

View File

@@ -32,6 +32,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -64,6 +65,30 @@ fun AllPeopleListFeedView(
state = listState,
contentPadding = FeedPadding,
) {
item {
Row(
modifier = MaxWidthWithHorzPadding.padding(bottom = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = SpacedBy5dp,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringRes(R.string.follow_organizer_title),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringRes(R.string.follow_organizer_card_explainer),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
OutlinedButton(onClick = { nav.nav(Route.FollowOrganizer) }) {
Text(stringRes(R.string.follow_organizer_action))
}
}
HorizontalDivider(thickness = DividerThickness)
}
stickyHeader {
Row(
modifier = MaxWidthWithHorzPadding,

View File

@@ -0,0 +1,313 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.organize
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.followOrganizer.OrganizeStrategy
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp
import com.vitorpamplona.amethyst.ui.theme.grayText
@Composable
fun FollowOrganizerScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val organizer: FollowOrganizerViewModel = viewModel()
organizer.init(accountViewModel)
FollowOrganizerScreen(organizer, accountViewModel, nav)
}
@Composable
fun FollowOrganizerScreen(
organizer: FollowOrganizerViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val context = LocalContext.current
val strategy by organizer.strategy.collectAsStateWithLifecycle()
val groups by organizer.groups.collectAsStateWithLifecycle()
val isAnalyzing by organizer.isAnalyzing.collectAsStateWithLifecycle()
val isCreating by organizer.isCreating.collectAsStateWithLifecycle()
val makePrivate by organizer.makePrivate.collectAsStateWithLifecycle()
val totalFollows by organizer.totalFollows.collectAsStateWithLifecycle()
val followsWithData by organizer.followsWithData.collectAsStateWithLifecycle()
val isBackfilling by organizer.isBackfilling.collectAsStateWithLifecycle()
val backfillDone by organizer.backfillDone.collectAsStateWithLifecycle()
val backfillTotal by organizer.backfillTotal.collectAsStateWithLifecycle()
val createdTitle = stringRes(R.string.follow_organizer_title)
androidx.compose.runtime.LaunchedEffect(Unit) {
organizer.analyze()
}
androidx.compose.runtime.LaunchedEffect(organizer) {
organizer.created.collect { count ->
accountViewModel.toastManager.toast(
createdTitle,
pluralStringRes(context, R.plurals.follow_organizer_created_n_lists, count, count),
)
nav.popBack()
}
}
val selectedCount = groups.count { it.selected }
Scaffold(
topBar = {
TopBarWithBackButton(stringRes(R.string.follow_organizer_title), nav)
},
bottomBar = {
Button(
onClick = {
accountViewModel.launchSigner { organizer.createSelected() }
},
enabled = selectedCount > 0 && !isCreating && !isAnalyzing,
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
) {
if (isCreating) {
CircularProgressIndicator(modifier = Modifier.padding(end = 8.dp))
}
Text(pluralStringResource(R.plurals.follow_organizer_create_n_lists, selectedCount, selectedCount))
}
},
) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(
top = padding.calculateTopPadding(),
bottom = padding.calculateBottomPadding(),
),
) {
Text(
text = stringRes(R.string.follow_organizer_explainer),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = SpacedBy5dp,
) {
StrategyChip(
label = stringRes(R.string.follow_organizer_by_activity),
selected = strategy == OrganizeStrategy.LAST_SEEN,
onClick = { organizer.setStrategy(OrganizeStrategy.LAST_SEEN) },
modifier = Modifier.weight(1f),
)
StrategyChip(
label = stringRes(R.string.follow_organizer_by_content),
selected = strategy == OrganizeStrategy.CONTENT_TYPE,
onClick = { organizer.setStrategy(OrganizeStrategy.CONTENT_TYPE) },
modifier = Modifier.weight(1f),
)
StrategyChip(
label = stringRes(R.string.follow_organizer_by_topic),
selected = strategy == OrganizeStrategy.TOPICS,
onClick = { organizer.setStrategy(OrganizeStrategy.TOPICS) },
modifier = Modifier.weight(1f),
)
}
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringRes(R.string.follow_organizer_keep_private),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
Switch(
checked = makePrivate,
onCheckedChange = { organizer.makePrivate.value = it },
)
}
if (totalFollows > 0) {
Text(
text =
pluralStringResource(
R.plurals.follow_organizer_data_coverage,
totalFollows,
followsWithData,
totalFollows,
),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.grayText,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
if (isBackfilling) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = SpacedBy5dp,
) {
CircularProgressIndicator(modifier = Modifier.size(16.dp))
Text(
text =
stringRes(
R.string.follow_organizer_backfilling,
backfillDone,
backfillTotal,
),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
}
HorizontalDivider()
if (isAnalyzing) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
CircularProgressIndicator()
Text(
text = stringRes(R.string.follow_organizer_analyzing),
modifier = Modifier.padding(top = 12.dp),
)
}
} else if (groups.isEmpty()) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = stringRes(R.string.follow_organizer_empty),
modifier = Modifier.padding(16.dp),
)
}
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(groups, key = { it.title }) { group ->
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = group.selected,
onCheckedChange = { organizer.toggle(group.title) },
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = group.title,
style = MaterialTheme.typography.bodyLarge,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
Text(
text =
pluralStringResource(
R.plurals.follow_organizer_member_count,
group.memberCount,
group.memberCount,
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
}
}
}
}
}
}
}
@Composable
private fun StrategyChip(
label: String,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
FilterChip(
selected = selected,
onClick = onClick,
label = {
Text(
text = label,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
modifier = modifier,
)
}

View File

@@ -0,0 +1,224 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.organize
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.nip64Chess.ChessRelayFetchHelper
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.followOrganizer.FollowGrouper
import com.vitorpamplona.amethyst.model.followOrganizer.FollowOrganizer
import com.vitorpamplona.amethyst.model.followOrganizer.FollowStats
import com.vitorpamplona.amethyst.model.followOrganizer.OrganizeStrategy
import com.vitorpamplona.amethyst.model.followOrganizer.ProposedGroup
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
@Immutable
data class OrganizerGroupUi(
val title: String,
val memberCount: Int,
val selected: Boolean,
)
@Stable
class FollowOrganizerViewModel : ViewModel() {
lateinit var account: Account
fun init(accountVM: AccountViewModel) {
if (!this::account.isInitialized || this.account != accountVM.account) {
this.account = accountVM.account
}
}
val strategy = MutableStateFlow(OrganizeStrategy.LAST_SEEN)
val makePrivate = MutableStateFlow(true)
val isAnalyzing = MutableStateFlow(false)
val isCreating = MutableStateFlow(false)
val isBackfilling = MutableStateFlow(false)
val backfillDone = MutableStateFlow(0)
val backfillTotal = MutableStateFlow(0)
val totalFollows = MutableStateFlow(0)
val followsWithData = MutableStateFlow(0)
private var backfillStarted = false
private val stats = MutableStateFlow<List<FollowStats>?>(null)
/** Titles the user has unchecked; everything not in here is created. */
private val deselected = MutableStateFlow<Set<String>>(emptySet())
/** Emits the number of lists created once a creation run finishes. */
val created = MutableSharedFlow<Int>(extraBufferCapacity = 1)
private val proposed: StateFlow<List<ProposedGroup>> =
combine(stats, strategy) { s, strat ->
if (s == null) emptyList() else FollowGrouper.group(strat, s, TimeUtils.now())
}.flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val groups: StateFlow<List<OrganizerGroupUi>> =
combine(proposed, deselected) { list, off ->
list.map { OrganizerGroupUi(it.title, it.members.size, it.title !in off) }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
fun setStrategy(newStrategy: OrganizeStrategy) {
strategy.value = newStrategy
deselected.value = emptySet()
}
fun toggle(title: String) {
deselected.update { if (title in it) it - title else it + title }
}
/** Rescans the local cache and refreshes the proposed groups. */
private suspend fun recompute() {
val follows = account.kind3FollowList.flow.value.authors
val result =
withContext(Dispatchers.Default) {
FollowOrganizer.analyze(follows, account.cache)
}
totalFollows.value = result.size
followsWithData.value = result.count { it.lastSeen != null }
stats.value = result
}
/** Initial analysis from the local cache, then a one-time relay backfill. */
suspend fun analyze() {
isAnalyzing.value = true
try {
deselected.value = emptySet()
recompute()
} finally {
isAnalyzing.value = false
}
if (!backfillStarted) {
backfillStarted = true
backfill()
}
}
/**
* Pulls recent events authored by the follows from their outbox relays so the
* cache has enough activity to group on. Authors are fetched in batches and
* the proposal is refreshed after each batch so the UI fills in progressively.
*/
suspend fun backfill() {
val follows =
account.kind3FollowList.flow.value.authors
.toList()
val relays = account.defaultGlobalRelays.flow.value
if (follows.isEmpty() || relays.isEmpty()) return
val batches = follows.chunked(AUTHOR_BATCH)
isBackfilling.value = true
backfillDone.value = 0
backfillTotal.value = batches.size
try {
val helper = ChessRelayFetchHelper(account.client)
for (batch in batches) {
val filter =
Filter(
authors = batch,
kinds = ACTIVITY_KINDS,
limit = batch.size * EVENTS_PER_AUTHOR,
)
val filters = relays.associateWith { listOf(filter) }
withContext(Dispatchers.IO) {
helper.fetchEvents(filters, timeoutMs = BATCH_TIMEOUT_MS)
}
backfillDone.value += 1
recompute()
}
} finally {
isBackfilling.value = false
}
}
companion object {
private const val AUTHOR_BATCH = 300
private const val EVENTS_PER_AUTHOR = 3
private const val BATCH_TIMEOUT_MS = 10_000L
// Kinds that signal what a person posts: notes, reposts, photos,
// short/long video and long-form articles.
private val ACTIVITY_KINDS =
listOf(
TextNoteEvent.KIND,
RepostEvent.KIND,
PictureEvent.KIND,
21,
22,
LongTextNoteEvent.KIND,
)
}
/** Signs and publishes one kind:30000 people list per selected group. */
suspend fun createSelected() {
val off = deselected.value
val toCreate = proposed.value.filter { it.title !in off && it.members.isNotEmpty() }
if (toCreate.isEmpty()) {
created.tryEmit(0)
return
}
isCreating.value = true
try {
val isPrivate = makePrivate.value
var count = 0
for (group in toCreate) {
val members = group.members.mapNotNull { account.cache.checkGetOrCreateUser(it) }
if (members.isEmpty()) continue
account.peopleLists.addFollowListWithMembers(
listName = group.title,
listDescription = null,
members = members,
isPrivate = isPrivate,
account = account,
)
count++
}
created.tryEmit(count)
} finally {
isCreating.value = false
}
}
}

View File

@@ -1207,6 +1207,33 @@
\nTap below to refresh, or tap the add buttons to create a new one.
</string>
<string name="follow_set_create_btn_label">New</string>
<string name="follow_organizer_title">Organize Follows</string>
<string name="follow_organizer_action">Organize</string>
<string name="follow_organizer_card_explainer">Sort a large follow list into people lists automatically.</string>
<string name="follow_organizer_explainer">Group the people you follow into lists. Choose how to sort them, review the suggested groups, then create the ones you want.</string>
<string name="follow_organizer_by_activity">Last seen</string>
<string name="follow_organizer_by_content">Content type</string>
<string name="follow_organizer_by_topic">Topics</string>
<string name="follow_organizer_keep_private">Keep these lists private</string>
<string name="follow_organizer_analyzing">Analyzing your follows…</string>
<string name="follow_organizer_backfilling">Fetching recent activity from relays… %1$d/%2$d</string>
<string name="follow_organizer_empty">No groups to suggest yet. Browse your feeds to cache more activity from the people you follow, then try again.</string>
<plurals name="follow_organizer_create_n_lists">
<item quantity="one">Create %1$d list</item>
<item quantity="other">Create %1$d lists</item>
</plurals>
<plurals name="follow_organizer_created_n_lists">
<item quantity="one">Created %1$d list</item>
<item quantity="other">Created %1$d lists</item>
</plurals>
<plurals name="follow_organizer_member_count">
<item quantity="one">%1$d member</item>
<item quantity="other">%1$d members</item>
</plurals>
<plurals name="follow_organizer_data_coverage">
<item quantity="one">%1$d of %2$d follow has recent posts cached</item>
<item quantity="other">%1$d of %2$d follows have recent posts cached</item>
</plurals>
<string name="follow_set_add_author_from_note_action">Add author to follow list</string>
<string name="follow_set_profile_actions_menu_description">Add or remove user from lists, or create a new list with this user.</string>
<string name="follow_set_icon_description">Icon for follow set</string>

View File

@@ -0,0 +1,124 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.followOrganizer
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class FollowGrouperTest {
private val day = 24L * 60L * 60L
private val now = 1_000_000_000L
private fun stat(
key: String,
ageDays: Long? = null,
kinds: Map<Int, Int> = emptyMap(),
tags: Map<String, Int> = emptyMap(),
) = FollowStats(
pubkey = key,
lastSeen = ageDays?.let { now - it * day },
totalEvents = kinds.values.sum(),
kindCounts = kinds,
hashtagCounts = tags,
)
@Test
fun lastSeenBucketsByRecency() {
val stats =
listOf(
stat("active", ageDays = 2),
stat("recent", ageDays = 20),
stat("slowing", ageDays = 60),
stat("dormant", ageDays = 200),
stat("silent", ageDays = 500),
stat("nodata", ageDays = null),
)
val groups = FollowGrouper.byLastSeen(stats, now)
val byMember = groups.associateBy({ it.members.single() }, { it.title })
assertEquals("Active (last 7 days)", byMember["active"])
assertEquals("Recent (last 30 days)", byMember["recent"])
assertEquals("Slowing down (last 90 days)", byMember["slowing"])
assertEquals("Dormant (last year)", byMember["dormant"])
assertEquals("Silent (over a year)", byMember["silent"])
assertEquals("No recent posts cached", byMember["nodata"])
}
@Test
fun lastSeenDropsEmptyBuckets() {
val groups = FollowGrouper.byLastSeen(listOf(stat("a", ageDays = 1)), now)
assertEquals(1, groups.size)
assertEquals("Active (last 7 days)", groups.single().title)
}
@Test
fun contentTypePicksDominantKind() {
val stats =
listOf(
stat("writer", kinds = mapOf(30023 to 5, 1 to 1)),
stat("noteguy", kinds = mapOf(1 to 10)),
stat("photog", kinds = mapOf(20 to 4)),
stat("reposter", kinds = mapOf(6 to 9, 7 to 30)),
)
val groups = FollowGrouper.byContentType(stats)
val byMember = groups.flatMap { g -> g.members.map { it to g.title } }.toMap()
assertEquals("Long-form writers", byMember["writer"])
assertEquals("Note posters", byMember["noteguy"])
assertEquals("Photographers", byMember["photog"])
assertEquals("Reposters & no cached posts", byMember["reposter"])
}
@Test
fun topicsKeepsOnlySharedTags() {
val stats =
listOf(
stat("a", tags = mapOf("nostr" to 10)),
stat("b", tags = mapOf("nostr" to 5)),
stat("c", tags = mapOf("nostr" to 3)),
stat("d", tags = mapOf("bitcoin" to 8)),
stat("e", tags = emptyMap()),
)
val groups = FollowGrouper.byTopics(stats, minGroupSize = 3)
assertEquals(1, groups.size)
assertEquals("#nostr", groups.single().title)
assertEquals(setOf("a", "b", "c"), groups.single().members.toSet())
}
@Test
fun topicsAssignsEachFollowToTheirTopTag() {
val stats =
listOf(
stat("a", tags = mapOf("art" to 9, "music" to 1)),
stat("b", tags = mapOf("art" to 4)),
stat("c", tags = mapOf("art" to 2)),
)
val groups = FollowGrouper.byTopics(stats, minGroupSize = 3)
assertEquals(1, groups.size)
assertTrue(groups.single().members.containsAll(listOf("a", "b", "c")))
}
}