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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.richtext.UrlParser import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState 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.commons.ui.thread.drawReplyLevel
import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache 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.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator 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.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.createThreadRepliesSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel
import com.vitorpamplona.amethyst.desktop.ui.note.extractMentionedPubkeys
import com.vitorpamplona.quartz.nip01Core.core.Event 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. * 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 @Composable
fun ThreadScreen( fun ThreadScreen(
@@ -100,104 +90,37 @@ fun ThreadScreen(
onReply: (Event) -> Unit = {}, onReply: (Event) -> Unit = {},
) { ) {
val connectedRelays by relayManager.connectedRelays.collectAsState() 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 // Lightbox state
var lightboxState by remember { mutableStateOf<LightboxState?>(null) } var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
// Load metadata for thread authors + mentioned users via coordinator // Track EOSE for root note subscription
LaunchedEffect(rootNote, replyEvents, subscriptionsCoordinator) { var rootNoteEoseReceived by remember(noteId) { mutableStateOf(false) }
if (subscriptionsCoordinator != null) {
val pubkeys = mutableListOf<String>()
rootNote?.let { pubkeys.add(it.pubKey) }
pubkeys.addAll(replyEvents.map { it.pubKey })
// Also load metadata for users mentioned in note content // DesktopFeedViewModel reads thread from cache (root + replies via graph walk)
val parser = UrlParser() val threadViewModel =
val allEvents = listOfNotNull(rootNote) + replyEvents remember(noteId) {
val mentionedPubkeys = DesktopFeedViewModel(
allEvents.flatMap { event -> DesktopThreadFilter(noteId, localCache),
extractMentionedPubkeys(parser.parseValidUrls(event.content).bech32s) localCache,
}
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 = { _, _ -> },
) )
} 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) { rememberSubscription(connectedRelays, noteId, relayManager = relayManager) {
if (connectedRelays.isNotEmpty()) { if (connectedRelays.isNotEmpty()) {
createNoteSubscription( createNoteSubscription(
@@ -205,10 +128,7 @@ fun ThreadScreen(
noteId = noteId, noteId = noteId,
onEvent = { event, _, relay, _ -> onEvent = { event, _, relay, _ ->
subscriptionsCoordinator?.consumeEvent(event, relay) subscriptionsCoordinator?.consumeEvent(event, relay)
if (event.id == noteId) { levelCache[event.id] = 0
rootNote = event
levelCache[event.id] = 0
}
}, },
onEose = { _, _ -> onEose = { _, _ ->
rootNoteEoseReceived = true rootNoteEoseReceived = true
@@ -219,7 +139,7 @@ fun ThreadScreen(
} }
} }
// Subscribe to replies // Keep relay subscription for replies
rememberSubscription(connectedRelays, noteId, relayManager = relayManager) { rememberSubscription(connectedRelays, noteId, relayManager = relayManager) {
if (connectedRelays.isNotEmpty()) { if (connectedRelays.isNotEmpty()) {
createThreadRepliesSubscription( createThreadRepliesSubscription(
@@ -227,122 +147,41 @@ fun ThreadScreen(
noteId = noteId, noteId = noteId,
onEvent = { event, _, relay, _ -> onEvent = { event, _, relay, _ ->
subscriptionsCoordinator?.consumeEvent(event, relay) subscriptionsCoordinator?.consumeEvent(event, relay)
replyEventState.addItem(event)
},
onEose = { _, _ ->
repliesEoseReceived = true
}, },
onEose = { _, _ -> },
) )
} else { } else {
null null
} }
} }
// Subscribe to zaps for thread events // Request interaction data (zaps, reactions, reposts) for visible thread notes
val allEventIds = listOf(noteId) + replyEvents.map { it.id } DisposableEffect(threadNotes, subscriptionsCoordinator) {
rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) { val coordinator = subscriptionsCoordinator ?: return@DisposableEffect onDispose {}
if (connectedRelays.isEmpty() || allEventIds.isEmpty()) { val noteIds = threadNotes.mapNotNull { it.event?.id }
return@rememberSubscription null if (noteIds.isEmpty()) return@DisposableEffect onDispose {}
}
createZapsSubscription( val relays = relayManager.relayStatuses.value.keys
relays = connectedRelays, val subId = coordinator.requestInteractions(noteIds, relays)
eventIds = allEventIds, onDispose { coordinator.releaseInteractions(subId) }
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
}
}
}
},
)
} }
// Subscribe to reactions for thread events // Load metadata for thread authors via coordinator
rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) { LaunchedEffect(threadNotes, subscriptionsCoordinator) {
if (connectedRelays.isEmpty() || allEventIds.isEmpty()) { if (subscriptionsCoordinator != null && threadNotes.isNotEmpty()) {
return@rememberSubscription null 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) // Calculate reply level for a note based on e-tags
rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) { fun calculateLevel(note: Note): Int {
if (connectedRelays.isEmpty() || allEventIds.isEmpty()) { val event = note.event ?: return 1
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 {
levelCache[event.id]?.let { return it } 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 replyToId = findReplyToId(event)
val level = val level =
if (replyToId == null || replyToId == noteId) { if (replyToId == null || replyToId == noteId) {
1 // Direct reply to root 1
} else { } else {
(levelCache[replyToId] ?: 0) + 1 (levelCache[replyToId] ?: 0) + 1
} }
@@ -350,6 +189,9 @@ fun ThreadScreen(
return level return level
} }
val rootNote = threadNotes.firstOrNull { it.idHex == noteId }
val replyNotes = threadNotes.filter { it.idHex != noteId }
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// Header with back button // Header with back button
@@ -372,165 +214,111 @@ fun ThreadScreen(
) )
} }
if (connectedRelays.isEmpty()) { when {
LoadingState("Connecting to relays...") connectedRelays.isEmpty() -> {
} else if (rootNote == null && !rootNoteEoseReceived) { LoadingState("Connecting to relays...")
LoadingState("Loading thread...") }
} else if (rootNote == null && rootNoteEoseReceived) {
EmptyState( feedState is FeedState.Loading && !rootNoteEoseReceived -> {
title = "Note not found", LoadingState("Loading thread...")
description = "This note may have been deleted or is not available from connected relays", }
onRefresh = onBack,
refreshLabel = "Go back", rootNote == null && rootNoteEoseReceived -> {
) EmptyState(
} else { title = "Note not found",
LazyColumn( description = "This note may have been deleted or is not available from connected relays",
verticalArrangement = Arrangement.spacedBy(0.dp), onRefresh = onBack,
) { refreshLabel = "Go back",
// Root note (no reply level indicator) )
item(key = noteId) { }
Column {
NoteCard( else -> {
note = rootNote!!.toNoteDisplayData(localCache), LazyColumn(
localCache = localCache, verticalArrangement = Arrangement.spacedBy(0.dp),
onAuthorClick = onNavigateToProfile, ) {
onMentionClick = onNavigateToProfile, // Root note
onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) }, if (rootNote != null) {
onMediaClick = { urls, index, seekPos -> item(key = noteId) {
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer Column {
.playVideo(urls[index], seekPos) FeedNoteCard(
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer note = rootNote,
.toggleFullscreen() relayManager = relayManager,
}, localCache = localCache,
) account = account,
if (account != null) { nwcConnection = nwcConnection,
val rootZaps = zapsByEvent[noteId] ?: emptyList() onReply = { rootNote.event?.let { onReply(it) } },
NoteActionsRow( onZapFeedback = onZapFeedback,
event = rootNote!!, 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, relayManager = relayManager,
localCache = localCache, localCache = localCache,
account = account, account = account,
nwcConnection = nwcConnection, nwcConnection = nwcConnection,
onReplyClick = { onReply(rootNote!!) }, onReply = { note.event?.let { onReply(it) } },
onZapFeedback = onZapFeedback, onZapFeedback = onZapFeedback,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), onNavigateToProfile = onNavigateToProfile,
zapCount = rootZaps.size, onNavigateToThread = onNavigateToThread,
zapAmountSats = rootZaps.sumOf { it.amountSats }, onImageClick = { urls, index ->
zapReceipts = rootZaps, lightboxState = LightboxState(urls, index)
reactionCount = reactionsByEvent[noteId] ?: 0, },
replyCount = repliesByEvent[noteId] ?: 0, onMediaClick = { urls, index, seekPos ->
repostCount = repostsByEvent[noteId] ?: 0, com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
bookmarkList = bookmarkList, .playVideo(urls[index], seekPos)
isBookmarked = bookmarkedEventIds.contains(noteId), com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
onBookmarkChanged = { newList -> .toggleFullscreen()
bookmarkList = newList
val pubIds =
newList
.publicBookmarks()
.filterIsInstance<EventBookmark>()
.map { it.eventId }
.toSet()
bookmarkedEventIds = pubIds
}, },
) )
} }
HorizontalDivider(thickness = 1.dp)
} }
HorizontalDivider(thickness = 1.dp)
}
// Reply notes with level indicators // Empty/loading state for replies
items(replyEvents.distinctBy { it.id }, key = { it.id }) { event -> if (replyNotes.isEmpty()) {
val level = calculateLevel(event) item {
Spacer(Modifier.height(32.dp))
Column( Text(
modifier = "No replies yet",
Modifier style = MaterialTheme.typography.bodyMedium,
.drawReplyLevel( color = MaterialTheme.colorScheme.onSurfaceVariant,
level = level, modifier = Modifier.padding(16.dp),
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
},
) )
} }
} }
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 // Lightbox overlay
val lb = lightboxState val lb = lightboxState
@@ -543,7 +331,7 @@ fun ThreadScreen(
onDismiss = { lightboxState = null }, 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" } val eTags = event.tags.filter { it.size >= 2 && it[0] == "e" }
if (eTags.isEmpty()) return null if (eTags.isEmpty()) return null
// Check for NIP-10 marked tags first
val replyTag = eTags.find { it.size >= 4 && it[3] == "reply" } val replyTag = eTags.find { it.size >= 4 && it[3] == "reply" }
if (replyTag != null) return replyTag[1] if (replyTag != null) return replyTag[1]
val rootTag = eTags.find { it.size >= 4 && it[3] == "root" } val rootTag = eTags.find { it.size >= 4 && it[3] == "root" }
if (rootTag != null && eTags.size == 1) return rootTag[1] 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) return eTags.lastOrNull()?.get(1)
} }