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:
+141
-355
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user