mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 07:48:27 +00:00
Compare commits
1 Commits
c79b795477
...
claude/fol
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67f7794521 |
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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?,
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -368,6 +368,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object Lists : Route()
|
||||
|
||||
@Serializable object FollowOrganizer : Route()
|
||||
|
||||
@Serializable data class MyPeopleListView(
|
||||
val dTag: String,
|
||||
) : Route()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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")))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user