feat(cache): ThreadScreen → DesktopFeedViewModel + DesktopThreadFilter

Replace EventCollectionState + 7 rememberSubscription blocks + inline
count maps with DesktopFeedViewModel + DesktopThreadFilter (graph walk).
Keep root note + replies relay subscriptions to populate cache. Use
FeedNoteCard for both root and reply notes (reads counts from Note.flowSet).
Remove ~200 lines of inline state management (zapsByEvent, reactionIdsByEvent,
replyIdsByEvent, repostIdsByEvent, bookmarkList, bookmark subscription).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-23 09:07:16 +02:00
parent 8c1e54e147
commit c598c6135c
@@ -42,48 +42,38 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.ui.thread.drawReplyLevel
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.feeds.DesktopThreadFilter
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
import com.vitorpamplona.amethyst.desktop.subscriptions.createNoteSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createReactionsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createRepliesSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createThreadRepliesSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
import com.vitorpamplona.amethyst.desktop.ui.note.extractMentionedPubkeys
import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
/**
* Desktop Thread Screen - displays a note and all its replies in a thread view.
*
* Uses the shared drawReplyLevel modifier from commons to display reply nesting.
* Uses DesktopFeedViewModel + DesktopThreadFilter for cache-backed display.
* Keeps relay subscriptions to populate cache with thread data.
*/
@Composable
fun ThreadScreen(
@@ -100,104 +90,37 @@ fun ThreadScreen(
onReply: (Event) -> Unit = {},
) {
val connectedRelays by relayManager.connectedRelays.collectAsState()
val scope = rememberCoroutineScope()
// State for the root note
var rootNote by remember { mutableStateOf<Event?>(null) }
// State for reply events
val replyEventState =
remember(noteId) {
EventCollectionState<Event>(
getId = { it.id },
sortComparator = compareBy { it.createdAt },
maxSize = 500,
scope = scope,
)
}
val replyEvents by replyEventState.items.collectAsState()
// Cache for calculating reply levels
val levelCache = remember(noteId) { mutableMapOf<String, Int>() }
// Track EOSE to know when initial load is complete
var rootNoteEoseReceived by remember(noteId) { mutableStateOf(false) }
var repliesEoseReceived by remember(noteId) { mutableStateOf(false) }
// Track zaps per event
var zapsByEvent by remember(noteId) { mutableStateOf<Map<String, List<ZapReceipt>>>(emptyMap()) }
// Track reaction event IDs per target event to deduplicate
var reactionIdsByEvent by remember(noteId) { mutableStateOf<Map<String, Set<String>>>(emptyMap()) }
val reactionsByEvent = reactionIdsByEvent.mapValues { it.value.size }
// Track reply/repost event IDs per target event to deduplicate
var replyIdsByEvent by remember(noteId) { mutableStateOf<Map<String, Set<String>>>(emptyMap()) }
val repliesByEvent = replyIdsByEvent.mapValues { it.value.size }
var repostIdsByEvent by remember(noteId) { mutableStateOf<Map<String, Set<String>>>(emptyMap()) }
val repostsByEvent = repostIdsByEvent.mapValues { it.value.size }
// Bookmark state
var bookmarkList by remember { mutableStateOf<BookmarkListEvent?>(null) }
var bookmarkedEventIds by remember { mutableStateOf<Set<String>>(emptySet()) }
// Lightbox state
var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
// Load metadata for thread authors + mentioned users via coordinator
LaunchedEffect(rootNote, replyEvents, subscriptionsCoordinator) {
if (subscriptionsCoordinator != null) {
val pubkeys = mutableListOf<String>()
rootNote?.let { pubkeys.add(it.pubKey) }
pubkeys.addAll(replyEvents.map { it.pubKey })
// Track EOSE for root note subscription
var rootNoteEoseReceived by remember(noteId) { mutableStateOf(false) }
// Also load metadata for users mentioned in note content
val parser = UrlParser()
val allEvents = listOfNotNull(rootNote) + replyEvents
val mentionedPubkeys =
allEvents.flatMap { event ->
extractMentionedPubkeys(parser.parseValidUrls(event.content).bech32s)
}
pubkeys.addAll(mentionedPubkeys)
if (pubkeys.isNotEmpty()) {
subscriptionsCoordinator.loadMetadataForPubkeys(pubkeys.distinct())
}
}
}
// Subscribe to user's bookmark list
rememberSubscription(connectedRelays, account, relayManager = relayManager) {
if (connectedRelays.isNotEmpty() && account != null) {
SubscriptionConfig(
subId = "thread-bookmarks-${account.pubKeyHex.take(8)}",
filters =
listOf(
FilterBuilders.byAuthors(
authors = listOf(account.pubKeyHex),
kinds = listOf(BookmarkListEvent.KIND),
limit = 1,
),
),
relays = connectedRelays,
onEvent = { event, _, _, _ ->
if (event is BookmarkListEvent) {
bookmarkList = event
val pubIds =
event
.publicBookmarks()
.filterIsInstance<EventBookmark>()
.map { it.eventId }
.toSet()
bookmarkedEventIds = pubIds
}
},
onEose = { _, _ -> },
// DesktopFeedViewModel reads thread from cache (root + replies via graph walk)
val threadViewModel =
remember(noteId) {
DesktopFeedViewModel(
DesktopThreadFilter(noteId, localCache),
localCache,
)
} else {
null
}
DisposableEffect(threadViewModel) {
onDispose { threadViewModel.destroy() }
}
val feedState by threadViewModel.feedState.feedContent.collectAsState()
val threadNotes =
if (feedState is FeedState.Loaded) {
val loaded by (feedState as FeedState.Loaded).feed.collectAsState()
loaded.list
} else {
kotlinx.collections.immutable.persistentListOf()
}
// Subscribe to the root note
// Level cache for reply nesting
val levelCache = remember(noteId) { mutableMapOf<String, Int>() }
// Keep relay subscriptions to populate cache — root note
rememberSubscription(connectedRelays, noteId, relayManager = relayManager) {
if (connectedRelays.isNotEmpty()) {
createNoteSubscription(
@@ -205,10 +128,7 @@ fun ThreadScreen(
noteId = noteId,
onEvent = { event, _, relay, _ ->
subscriptionsCoordinator?.consumeEvent(event, relay)
if (event.id == noteId) {
rootNote = event
levelCache[event.id] = 0
}
levelCache[event.id] = 0
},
onEose = { _, _ ->
rootNoteEoseReceived = true
@@ -219,7 +139,7 @@ fun ThreadScreen(
}
}
// Subscribe to replies
// Keep relay subscription for replies
rememberSubscription(connectedRelays, noteId, relayManager = relayManager) {
if (connectedRelays.isNotEmpty()) {
createThreadRepliesSubscription(
@@ -227,122 +147,41 @@ fun ThreadScreen(
noteId = noteId,
onEvent = { event, _, relay, _ ->
subscriptionsCoordinator?.consumeEvent(event, relay)
replyEventState.addItem(event)
},
onEose = { _, _ ->
repliesEoseReceived = true
},
onEose = { _, _ -> },
)
} else {
null
}
}
// Subscribe to zaps for thread events
val allEventIds = listOf(noteId) + replyEvents.map { it.id }
rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) {
if (connectedRelays.isEmpty() || allEventIds.isEmpty()) {
return@rememberSubscription null
}
// Request interaction data (zaps, reactions, reposts) for visible thread notes
DisposableEffect(threadNotes, subscriptionsCoordinator) {
val coordinator = subscriptionsCoordinator ?: return@DisposableEffect onDispose {}
val noteIds = threadNotes.mapNotNull { it.event?.id }
if (noteIds.isEmpty()) return@DisposableEffect onDispose {}
createZapsSubscription(
relays = connectedRelays,
eventIds = allEventIds,
onEvent = { event, _, _, _ ->
if (event is LnZapEvent) {
val receipt = event.toZapReceipt(localCache) ?: return@createZapsSubscription
val targetEventId = event.zappedPost().firstOrNull() ?: return@createZapsSubscription
zapsByEvent =
zapsByEvent.toMutableMap().apply {
val existing = this[targetEventId] ?: emptyList()
if (existing.none { it.createdAt == receipt.createdAt && it.senderPubKey == receipt.senderPubKey }) {
this[targetEventId] = existing + receipt
}
}
}
},
)
val relays = relayManager.relayStatuses.value.keys
val subId = coordinator.requestInteractions(noteIds, relays)
onDispose { coordinator.releaseInteractions(subId) }
}
// Subscribe to reactions for thread events
rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) {
if (connectedRelays.isEmpty() || allEventIds.isEmpty()) {
return@rememberSubscription null
// Load metadata for thread authors via coordinator
LaunchedEffect(threadNotes, subscriptionsCoordinator) {
if (subscriptionsCoordinator != null && threadNotes.isNotEmpty()) {
subscriptionsCoordinator.loadMetadataForNotes(threadNotes)
}
createReactionsSubscription(
relays = connectedRelays,
eventIds = allEventIds,
onEvent = { event, _, _, _ ->
if (event is ReactionEvent) {
val targetEventId = event.originalPost().firstOrNull() ?: return@createReactionsSubscription
reactionIdsByEvent =
reactionIdsByEvent.toMutableMap().apply {
val existing = this[targetEventId] ?: emptySet()
this[targetEventId] = existing + event.id
}
}
},
)
}
// Subscribe to replies for thread events (for counts)
rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) {
if (connectedRelays.isEmpty() || allEventIds.isEmpty()) {
return@rememberSubscription null
}
createRepliesSubscription(
relays = connectedRelays,
eventIds = allEventIds,
onEvent = { event, _, _, _ ->
val replyToId =
event.tags
.filter { it.size >= 2 && it[0] == "e" }
.lastOrNull()
?.get(1) ?: return@createRepliesSubscription
if (replyToId in allEventIds) {
replyIdsByEvent =
replyIdsByEvent.toMutableMap().apply {
val existing = this[replyToId] ?: emptySet()
this[replyToId] = existing + event.id
}
}
},
)
}
// Subscribe to reposts for thread events
rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) {
if (connectedRelays.isEmpty() || allEventIds.isEmpty()) {
return@rememberSubscription null
}
createRepostsSubscription(
relays = connectedRelays,
eventIds = allEventIds,
onEvent = { event, _, _, _ ->
if (event is RepostEvent) {
val targetEventId = event.boostedEventId() ?: return@createRepostsSubscription
repostIdsByEvent =
repostIdsByEvent.toMutableMap().apply {
val existing = this[targetEventId] ?: emptySet()
this[targetEventId] = existing + event.id
}
}
},
)
}
// Calculate reply level for an event based on e-tags
fun calculateLevel(event: Event): Int {
// Calculate reply level for a note based on e-tags
fun calculateLevel(note: Note): Int {
val event = note.event ?: return 1
levelCache[event.id]?.let { return it }
// Find the event this is replying to (last e-tag or marked reply/root)
val replyToId = findReplyToId(event)
val level =
if (replyToId == null || replyToId == noteId) {
1 // Direct reply to root
1
} else {
(levelCache[replyToId] ?: 0) + 1
}
@@ -350,6 +189,9 @@ fun ThreadScreen(
return level
}
val rootNote = threadNotes.firstOrNull { it.idHex == noteId }
val replyNotes = threadNotes.filter { it.idHex != noteId }
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
// Header with back button
@@ -372,165 +214,111 @@ fun ThreadScreen(
)
}
if (connectedRelays.isEmpty()) {
LoadingState("Connecting to relays...")
} else if (rootNote == null && !rootNoteEoseReceived) {
LoadingState("Loading thread...")
} else if (rootNote == null && rootNoteEoseReceived) {
EmptyState(
title = "Note not found",
description = "This note may have been deleted or is not available from connected relays",
onRefresh = onBack,
refreshLabel = "Go back",
)
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
// Root note (no reply level indicator)
item(key = noteId) {
Column {
NoteCard(
note = rootNote!!.toNoteDisplayData(localCache),
localCache = localCache,
onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
onMediaClick = { urls, index, seekPos ->
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.playVideo(urls[index], seekPos)
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.toggleFullscreen()
},
)
if (account != null) {
val rootZaps = zapsByEvent[noteId] ?: emptyList()
NoteActionsRow(
event = rootNote!!,
when {
connectedRelays.isEmpty() -> {
LoadingState("Connecting to relays...")
}
feedState is FeedState.Loading && !rootNoteEoseReceived -> {
LoadingState("Loading thread...")
}
rootNote == null && rootNoteEoseReceived -> {
EmptyState(
title = "Note not found",
description = "This note may have been deleted or is not available from connected relays",
onRefresh = onBack,
refreshLabel = "Go back",
)
}
else -> {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
// Root note
if (rootNote != null) {
item(key = noteId) {
Column {
FeedNoteCard(
note = rootNote,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
onReply = { rootNote.event?.let { onReply(it) } },
onZapFeedback = onZapFeedback,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
onImageClick = { urls, index ->
lightboxState = LightboxState(urls, index)
},
onMediaClick = { urls, index, seekPos ->
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.playVideo(urls[index], seekPos)
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.toggleFullscreen()
},
)
}
HorizontalDivider(thickness = 1.dp)
}
}
// Reply notes with level indicators
items(replyNotes, key = { it.idHex }) { note ->
val level = calculateLevel(note)
Column(
modifier =
Modifier
.drawReplyLevel(
level = level,
color = MaterialTheme.colorScheme.outlineVariant,
selected = MaterialTheme.colorScheme.outlineVariant,
).clickable {
note.event?.let { onNavigateToThread(it.id) }
},
) {
FeedNoteCard(
note = note,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
onReplyClick = { onReply(rootNote!!) },
onReply = { note.event?.let { onReply(it) } },
onZapFeedback = onZapFeedback,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
zapCount = rootZaps.size,
zapAmountSats = rootZaps.sumOf { it.amountSats },
zapReceipts = rootZaps,
reactionCount = reactionsByEvent[noteId] ?: 0,
replyCount = repliesByEvent[noteId] ?: 0,
repostCount = repostsByEvent[noteId] ?: 0,
bookmarkList = bookmarkList,
isBookmarked = bookmarkedEventIds.contains(noteId),
onBookmarkChanged = { newList ->
bookmarkList = newList
val pubIds =
newList
.publicBookmarks()
.filterIsInstance<EventBookmark>()
.map { it.eventId }
.toSet()
bookmarkedEventIds = pubIds
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
onImageClick = { urls, index ->
lightboxState = LightboxState(urls, index)
},
onMediaClick = { urls, index, seekPos ->
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.playVideo(urls[index], seekPos)
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.toggleFullscreen()
},
)
}
HorizontalDivider(thickness = 1.dp)
}
HorizontalDivider(thickness = 1.dp)
}
// Reply notes with level indicators
items(replyEvents.distinctBy { it.id }, key = { it.id }) { event ->
val level = calculateLevel(event)
Column(
modifier =
Modifier
.drawReplyLevel(
level = level,
color = MaterialTheme.colorScheme.outlineVariant,
selected =
if (event.id == noteId) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outlineVariant
},
).clickable {
onNavigateToThread(event.id)
},
) {
NoteCard(
note = event.toNoteDisplayData(localCache),
localCache = localCache,
onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
onMediaClick = { urls, index, seekPos ->
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.playVideo(urls[index], seekPos)
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.toggleFullscreen()
},
)
if (account != null) {
val eventZaps = zapsByEvent[event.id] ?: emptyList()
NoteActionsRow(
event = event,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
onReplyClick = { onReply(event) },
onZapFeedback = onZapFeedback,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
zapCount = eventZaps.size,
zapAmountSats = eventZaps.sumOf { it.amountSats },
zapReceipts = eventZaps,
reactionCount = reactionsByEvent[event.id] ?: 0,
replyCount = repliesByEvent[event.id] ?: 0,
repostCount = repostsByEvent[event.id] ?: 0,
bookmarkList = bookmarkList,
isBookmarked = bookmarkedEventIds.contains(event.id),
onBookmarkChanged = { newList ->
bookmarkList = newList
val pubIds =
newList
.publicBookmarks()
.filterIsInstance<EventBookmark>()
.map { it.eventId }
.toSet()
bookmarkedEventIds = pubIds
},
// Empty/loading state for replies
if (replyNotes.isEmpty()) {
item {
Spacer(Modifier.height(32.dp))
Text(
"No replies yet",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
)
}
}
HorizontalDivider(thickness = 1.dp)
}
// Empty state for no replies
if (replyEvents.isEmpty() && repliesEoseReceived) {
item {
Spacer(Modifier.height(32.dp))
Text(
"No replies yet",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
)
}
} else if (replyEvents.isEmpty() && !repliesEoseReceived) {
item {
Spacer(Modifier.height(32.dp))
Text(
"Loading replies...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
)
}
}
}
}
} // end Column
}
// Lightbox overlay
val lb = lightboxState
@@ -543,7 +331,7 @@ fun ThreadScreen(
onDismiss = { lightboxState = null },
)
}
} // end Box
}
}
/**
@@ -554,13 +342,11 @@ private fun findReplyToId(event: Event): String? {
val eTags = event.tags.filter { it.size >= 2 && it[0] == "e" }
if (eTags.isEmpty()) return null
// Check for NIP-10 marked tags first
val replyTag = eTags.find { it.size >= 4 && it[3] == "reply" }
if (replyTag != null) return replyTag[1]
val rootTag = eTags.find { it.size >= 4 && it[3] == "root" }
if (rootTag != null && eTags.size == 1) return rootTag[1]
// Fall back to positional (last e-tag is the reply-to)
return eTags.lastOrNull()?.get(1)
}