From c63ace1d9cd6164865120bbcf82dc15ea48a2731 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 23 Mar 2026 08:32:59 +0200 Subject: [PATCH] =?UTF-8?q?feat(cache):=20FeedScreen=20=E2=86=92=20Desktop?= =?UTF-8?q?FeedViewModel=20+=20Note-based=20FeedNoteCard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite FeedScreen from inline EventCollectionState + 8 rememberSubscription() calls to cache-centric DesktopFeedViewModel pattern: - ViewModel keyed on feedMode with DisposableEffect cleanup (destroy()) - FeedState pattern matching (Loading/Loaded/Empty/Error) - FeedNoteCard takes Note instead of Event, reads counts from Note.flowSet - DisposableEffect for note.clearFlow() cleanup in LazyColumn - DisposableEffect for Coordinator interaction subscription lifecycle - LaunchedEffect for rate-limited metadata loading via Coordinator - Extract FeedHeader into separate composable - Add destroy() to DesktopFeedViewModel (ViewModel.clear() is internal in KMP) - Update UserProfileScreen to look up Note from cache for FeedNoteCard Removes ~350 lines of inline state management (EventCollectionState, zapsByEvent, reactionIdsByEvent, replyIdsByEvent, repostIdsByEvent, followedUsers, bookmarkList, 8 rememberSubscription blocks). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/FeedScreen.kt | 727 ++++++------------ .../amethyst/desktop/ui/UserProfileScreen.kt | 42 +- .../viewmodels/DesktopFeedViewModel.kt | 9 + 3 files changed, 263 insertions(+), 515 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index c493247a3..1b0b8063f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -45,49 +45,32 @@ 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.desktop.DesktopPreferences import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.feeds.DesktopFollowingFeedFilter +import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode -import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders -import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig -import com.vitorpamplona.amethyst.desktop.subscriptions.createBatchMetadataSubscription -import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription -import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingFeedSubscription -import com.vitorpamplona.amethyst.desktop.subscriptions.createGlobalFeedSubscription -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.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.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip18Reposts.RepostEvent -import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map data class LightboxState( val urls: List, @@ -97,11 +80,12 @@ data class LightboxState( ) /** - * Note card with action buttons. + * Note card that reads counts from the Note model (cache-backed). + * Event is extracted from Note for signing operations in NoteActionsRow. */ @Composable fun FeedNoteCard( - event: Event, + note: Note, relayManager: DesktopRelayConnectionManager, localCache: DesktopLocalCache, account: AccountState.LoggedIn?, @@ -112,15 +96,25 @@ fun FeedNoteCard( onNavigateToThread: (String) -> Unit = {}, onImageClick: ((List, Int) -> Unit)? = null, onMediaClick: ((List, Int, Float) -> Unit)? = null, - zapReceipts: List = emptyList(), - reactionCount: Int = 0, - replyCount: Int = 0, - repostCount: Int = 0, - bookmarkList: BookmarkListEvent? = null, - isBookmarked: Boolean = false, - onBookmarkChanged: (BookmarkListEvent) -> Unit = {}, ) { - val zapAmountSats = zapReceipts.sumOf { it.amountSats } + val event = note.event ?: return + + // Observe Note.flowSet for live count updates + val flowSet = remember(note) { note.flow() } + val reactionsState by flowSet.reactions.stateFlow.collectAsState() + val repliesState by flowSet.replies.stateFlow.collectAsState() + val zapsState by flowSet.zaps.stateFlow.collectAsState() + + // Read counts from Note model (re-read on each stateFlow emission) + val reactionCount = note.countReactions() + val replyCount = note.replies.size + val repostCount = note.boosts.size + val zapAmount = note.zapsAmount + + // Clean up flowSet when card leaves composition + DisposableEffect(note) { + onDispose { note.clearFlow() } + } Column { NoteCard( @@ -144,15 +138,12 @@ fun FeedNoteCard( onReplyClick = onReply, onZapFeedback = onZapFeedback, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - zapCount = zapReceipts.size, - zapAmountSats = zapAmountSats, - zapReceipts = zapReceipts, + zapCount = note.zaps.size, + zapAmountSats = zapAmount.toLong(), + zapReceipts = emptyList(), // TODO: extract ZapReceipts from Note.zaps reactionCount = reactionCount, replyCount = replyCount, repostCount = repostCount, - bookmarkList = bookmarkList, - isBookmarked = isBookmarked, - onBookmarkChanged = onBookmarkChanged, ) } } @@ -172,488 +163,146 @@ fun FeedScreen( onZapFeedback: (ZapFeedback) -> Unit = {}, ) { val connectedRelays by relayManager.connectedRelays.collectAsState() - // Configured relay URLs only — stabilized with distinctUntilChanged() to prevent - // subscription churn from relay status changes (pings, connect/disconnect). - // openReqSubscription connects relays on demand; no need to wait for connectedRelays. - val configuredRelays by remember { - relayManager.relayStatuses - .map { it.keys } - .distinctUntilChanged() - }.collectAsState(emptySet()) - val scope = rememberCoroutineScope() - val eventState = - remember { - EventCollectionState( - getId = { it.id }, - sortComparator = compareByDescending { it.createdAt }, - maxSize = 200, - scope = scope, - ) - } - val events by eventState.items.collectAsState() + val followedUsers by localCache.followedUsers.collectAsState() + var replyToEvent by remember { mutableStateOf(null) } var lightboxState by remember { mutableStateOf(null) } var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) } - var followedUsers by remember { mutableStateOf>(emptySet()) } - var zapsByEvent by remember { mutableStateOf>>(emptyMap()) } - // Track reaction event IDs per target event to deduplicate - var reactionIdsByEvent by remember { mutableStateOf>>(emptyMap()) } - val reactionsByEvent = reactionIdsByEvent.mapValues { it.value.size } - // Track reply/repost event IDs per target event to deduplicate - var replyIdsByEvent by remember { mutableStateOf>>(emptyMap()) } - val repliesByEvent = replyIdsByEvent.mapValues { it.value.size } - var repostIdsByEvent by remember { mutableStateOf>>(emptyMap()) } - val repostsByEvent = repostIdsByEvent.mapValues { it.value.size } - var bookmarkList by remember { mutableStateOf(null) } - var bookmarkedEventIds by remember { mutableStateOf>(emptySet()) } - // Track EOSE to know when initial load is complete - var eoseReceivedCount by remember { mutableStateOf(0) } - val initialLoadComplete = eoseReceivedCount > 0 - - // Load followed users for Following feed mode - rememberSubscription(configuredRelays, account, feedMode, relayManager = relayManager) { - if (configuredRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) { - createContactListSubscription( - relays = configuredRelays, - pubKeyHex = account.pubKeyHex, - onEvent = { event, _, relay, _ -> - if (event is ContactListEvent) { - val follows = event.verifiedFollowKeySet() - followedUsers = follows + // DesktopFeedViewModel keyed on feedMode — recreated on mode switch + val viewModel = + remember(feedMode) { + val filter = + when (feedMode) { + FeedMode.GLOBAL -> { + DesktopGlobalFeedFilter(localCache) } - }, - ) - } else { - null - } - } - // Load user's bookmark list - rememberSubscription(configuredRelays, account, relayManager = relayManager) { - if (configuredRelays.isNotEmpty() && account != null) { - SubscriptionConfig( - subId = "bookmarks-${account.pubKeyHex.take(8)}", - filters = - listOf( - FilterBuilders.byAuthors( - authors = listOf(account.pubKeyHex), - kinds = listOf(BookmarkListEvent.KIND), - limit = 1, - ), - ), - relays = configuredRelays, - onEvent = { event, _, _, _ -> - if (event is BookmarkListEvent) { - bookmarkList = event - // Extract public bookmarked event IDs - val pubIds = - event - .publicBookmarks() - .filterIsInstance() - .map { it.eventId } - .toSet() - bookmarkedEventIds = pubIds + FeedMode.FOLLOWING -> { + DesktopFollowingFeedFilter(localCache) { + localCache.followedUsers.value + } } - }, - onEose = { _, _ -> }, - ) - } else { - null - } - } - - // Clear events and reset EOSE when feed mode changes - remember(feedMode) { - eventState.clear() - eoseReceivedCount = 0 - } - - // Subscribe to feed based on mode - rememberSubscription(configuredRelays, feedMode, followedUsers, relayManager = relayManager) { - if (configuredRelays.isEmpty()) { - return@rememberSubscription null - } - - when (feedMode) { - FeedMode.GLOBAL -> { - createGlobalFeedSubscription( - relays = configuredRelays, - onEvent = { event, _, relay, _ -> - subscriptionsCoordinator?.consumeEvent(event, relay) - // Store metadata events in cache - if (event is MetadataEvent) { - localCache.consumeMetadata(event) - } - eventState.addItem(event) - }, - onEose = { _, _ -> - eoseReceivedCount++ - }, - ) - } - - FeedMode.FOLLOWING -> { - if (followedUsers.isNotEmpty()) { - createFollowingFeedSubscription( - relays = configuredRelays, - followedUsers = followedUsers.toList(), - onEvent = { event, _, _, _ -> - // Store metadata events in cache - if (event is MetadataEvent) { - localCache.consumeMetadata(event) - } - eventState.addItem(event) - }, - onEose = { _, _ -> - eoseReceivedCount++ - }, - ) - } else { - null } + DesktopFeedViewModel(filter, localCache) + } + + // Cancel old ViewModel's viewModelScope on recreation + DisposableEffect(viewModel) { + onDispose { viewModel.destroy() } + } + + val feedState by viewModel.feedState.feedContent.collectAsState() + + // Load metadata for visible notes via Coordinator (rate-limited) + LaunchedEffect(feedState, subscriptionsCoordinator) { + if (subscriptionsCoordinator != null && feedState is FeedState.Loaded) { + val notes = viewModel.feedState.visibleNotes() + if (notes.isNotEmpty()) { + subscriptionsCoordinator.loadMetadataForNotes(notes) } } } - // Subscribe to zaps for visible events - val eventIds = events.map { it.id } - rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) { - if (configuredRelays.isEmpty() || eventIds.isEmpty()) { - return@rememberSubscription null - } + // Request interaction subscriptions (zaps, reactions, reposts) for visible notes + DisposableEffect(feedState, subscriptionsCoordinator) { + val coordinator = subscriptionsCoordinator ?: return@DisposableEffect onDispose {} + val notes = viewModel.feedState.visibleNotes() + val noteIds = notes.mapNotNull { it.event?.id } + if (noteIds.isEmpty()) return@DisposableEffect onDispose {} - createZapsSubscription( - relays = configuredRelays, - eventIds = eventIds, - 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 metadata for zap senders (to show display names) - val zapSenderPubkeys = - zapsByEvent.values - .flatten() - .map { it.senderPubKey } - .distinct() - rememberSubscription(configuredRelays, zapSenderPubkeys, relayManager = relayManager) { - if (configuredRelays.isEmpty() || zapSenderPubkeys.isEmpty()) { - return@rememberSubscription null - } - - // Only fetch metadata for users we don't have yet - val missingPubkeys = - zapSenderPubkeys.filter { pubkey -> - localCache - .getUserIfExists(pubkey) - ?.metadataOrNull() - ?.flow - ?.value == null - } - if (missingPubkeys.isEmpty()) { - return@rememberSubscription null - } - - createBatchMetadataSubscription( - relays = configuredRelays, - pubKeyHexList = missingPubkeys, - onEvent = { event, _, _, _ -> - if (event is MetadataEvent) { - localCache.consumeMetadata(event) - } - }, - ) - } - - // Subscribe to reactions for visible events - rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) { - if (configuredRelays.isEmpty() || eventIds.isEmpty()) { - return@rememberSubscription null - } - - createReactionsSubscription( - relays = configuredRelays, - eventIds = eventIds, - 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 visible events - rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) { - if (configuredRelays.isEmpty() || eventIds.isEmpty()) { - return@rememberSubscription null - } - - createRepliesSubscription( - relays = configuredRelays, - eventIds = eventIds, - onEvent = { event, _, _, _ -> - // Find the event this is replying to - val replyToId = - event.tags - .filter { it.size >= 2 && it[0] == "e" } - .lastOrNull() - ?.get(1) ?: return@createRepliesSubscription - if (replyToId in eventIds) { - replyIdsByEvent = - replyIdsByEvent.toMutableMap().apply { - val existing = this[replyToId] ?: emptySet() - this[replyToId] = existing + event.id - } - } - }, - ) - } - - // Subscribe to reposts for visible events - rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) { - if (configuredRelays.isEmpty() || eventIds.isEmpty()) { - return@rememberSubscription null - } - - createRepostsSubscription( - relays = configuredRelays, - eventIds = eventIds, - 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 - } - } - }, - ) - } - - // Subscribe to metadata for note authors + mentioned users - val authorPubkeys = events.map { it.pubKey }.distinct() - val mentionedPubkeys = - remember(events) { - val parser = UrlParser() - events - .flatMap { event -> - val urls = parser.parseValidUrls(event.content) - extractMentionedPubkeys(urls.bech32s) - }.distinct() - } - val allPubkeys = remember(authorPubkeys, mentionedPubkeys) { (authorPubkeys + mentionedPubkeys).distinct() } - - // Use coordinator for rate-limited metadata loading (preferred) - LaunchedEffect(allPubkeys, subscriptionsCoordinator) { - if (subscriptionsCoordinator != null && allPubkeys.isNotEmpty()) { - subscriptionsCoordinator.loadMetadataForPubkeys(allPubkeys) - } - } - - // Fallback subscription if coordinator not available - rememberSubscription(configuredRelays, allPubkeys, subscriptionsCoordinator, relayManager = relayManager) { - // Skip if using coordinator - if (subscriptionsCoordinator != null) { - return@rememberSubscription null - } - - if (configuredRelays.isEmpty() || allPubkeys.isEmpty()) { - return@rememberSubscription null - } - - // Only fetch metadata for users we don't have yet - val missingPubkeys = - allPubkeys.filter { pubkey -> - localCache - .getUserIfExists(pubkey) - ?.metadataOrNull() - ?.flow - ?.value == null - } - if (missingPubkeys.isEmpty()) { - return@rememberSubscription null - } - - createBatchMetadataSubscription( - relays = configuredRelays, - pubKeyHexList = missingPubkeys, - onEvent = { event, _, _, _ -> - if (event is MetadataEvent) { - localCache.consumeMetadata(event) - } - }, - ) + val relays = + relayManager.relayStatuses.value.keys + val subId = coordinator.requestInteractions(noteIds, relays) + onDispose { coordinator.releaseInteractions(subId) } } @OptIn(ExperimentalLayoutApi::class) Box(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) { - // Header with compose button — wraps on narrow columns - FlowRow( - modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Column { - FlowRow( - verticalArrangement = Arrangement.Center, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - if (feedMode == FeedMode.GLOBAL) "Global Feed" else "Following Feed", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground, - ) - - // Feed mode selector - if (account != null) { - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - FilterChip( - selected = feedMode == FeedMode.GLOBAL, - onClick = { - feedMode = FeedMode.GLOBAL - DesktopPreferences.feedMode = FeedMode.GLOBAL - }, - label = { Text("Global") }, - ) - FilterChip( - selected = feedMode == FeedMode.FOLLOWING, - onClick = { - feedMode = FeedMode.FOLLOWING - DesktopPreferences.feedMode = FeedMode.FOLLOWING - }, - label = { Text("Following") }, - ) - } - } - } - - Spacer(Modifier.height(4.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - "${connectedRelays.size} relays connected", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - if (feedMode == FeedMode.FOLLOWING) { - Text( - " • ${followedUsers.size} followed", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Spacer(Modifier.width(8.dp)) - IconButton( - onClick = { relayManager.connect() }, - modifier = Modifier.size(24.dp), - ) { - Icon( - Icons.Default.Refresh, - contentDescription = "Refresh", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp), - ) - } - } - } - - // New Post button (primary action) - Button( - onClick = onCompose, - enabled = account != null && !account.isReadOnly, - ) { - Icon(Icons.Default.Add, "New Post", Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text("New Post") - } - } + // Header with compose button + FeedHeader( + feedMode = feedMode, + account = account, + connectedRelays = connectedRelays, + followedUsersCount = followedUsers.size, + onFeedModeChange = { mode -> + feedMode = mode + DesktopPreferences.feedMode = mode + }, + onRefresh = { relayManager.connect() }, + onCompose = onCompose, + ) Spacer(Modifier.height(8.dp)) - if (connectedRelays.isEmpty()) { - LoadingState("Connecting to relays...") - } else if (feedMode == FeedMode.FOLLOWING && followedUsers.isEmpty()) { - LoadingState("Loading followed users...") - } else if (events.isEmpty() && !initialLoadComplete) { - LoadingState("Loading notes...") - } else if (events.isEmpty() && initialLoadComplete) { - EmptyState( - title = - if (feedMode == FeedMode.FOLLOWING) { - "No notes from followed users" - } else { - "No notes found" - }, - description = - if (feedMode == FeedMode.FOLLOWING) { - "Notes from people you follow will appear here" - } else { - "Notes from the network will appear here" - }, - onRefresh = { relayManager.connect() }, - ) - } else { - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - // Use distinctBy to prevent duplicate key crashes from events with same ID - items(events.distinctBy { it.id }, key = { it.id }) { event -> - FeedNoteCard( - event = event, - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - onReply = { replyToEvent = event }, - 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() + // Feed content based on FeedState + when (val state = feedState) { + is FeedState.Loading -> { + if (connectedRelays.isEmpty()) { + LoadingState("Connecting to relays...") + } else { + LoadingState("Loading notes...") + } + } + + is FeedState.Empty -> { + EmptyState( + title = + if (feedMode == FeedMode.FOLLOWING) { + "No notes from followed users" + } else { + "No notes found" }, - zapReceipts = zapsByEvent[event.id] ?: emptyList(), - 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() - .map { it.eventId } - .toSet() - bookmarkedEventIds = pubIds + description = + if (feedMode == FeedMode.FOLLOWING) { + "Notes from people you follow will appear here" + } else { + "Notes from the network will appear here" }, - ) + onRefresh = { relayManager.connect() }, + ) + } + + is FeedState.FeedError -> { + EmptyState( + title = "Error loading feed", + description = state.errorMessage, + onRefresh = { relayManager.connect() }, + ) + } + + is FeedState.Loaded -> { + val loadedState by state.feed.collectAsState() + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(loadedState.list, key = { it.idHex }) { note -> + FeedNoteCard( + note = note, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReply = { replyToEvent = note.event }, + 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() + }, + ) + } } } } - } // end Column + } // Reply dialog if (replyToEvent != null && account != null) { @@ -675,5 +324,91 @@ fun FeedScreen( onDismiss = { lightboxState = null }, ) } - } // end Box + } +} + +/** + * Feed header with title, mode selector, relay count, and compose button. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun FeedHeader( + feedMode: FeedMode, + account: AccountState.LoggedIn?, + connectedRelays: Set, + followedUsersCount: Int, + onFeedModeChange: (FeedMode) -> Unit, + onRefresh: () -> Unit, + onCompose: () -> Unit, +) { + FlowRow( + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column { + FlowRow( + verticalArrangement = Arrangement.Center, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + if (feedMode == FeedMode.GLOBAL) "Global Feed" else "Following Feed", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + + if (account != null) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + FilterChip( + selected = feedMode == FeedMode.GLOBAL, + onClick = { onFeedModeChange(FeedMode.GLOBAL) }, + label = { Text("Global") }, + ) + FilterChip( + selected = feedMode == FeedMode.FOLLOWING, + onClick = { onFeedModeChange(FeedMode.FOLLOWING) }, + label = { Text("Following") }, + ) + } + } + } + + Spacer(Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "${connectedRelays.size} relays connected", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (feedMode == FeedMode.FOLLOWING) { + Text( + " \u2022 $followedUsersCount followed", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(8.dp)) + IconButton( + onClick = onRefresh, + modifier = Modifier.size(24.dp), + ) { + Icon( + Icons.Default.Refresh, + contentDescription = "Refresh", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + } + } + } + + Button( + onClick = onCompose, + enabled = account != null && !account.isReadOnly, + ) { + Icon(Icons.Default.Add, "New Post", Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("New Post") + } + } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 15ab92d4a..36b769f0e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -786,25 +786,29 @@ fun UserProfileScreen( else -> { items(events.distinctBy { it.id }, key = { it.id }) { event -> - FeedNoteCard( - event = event, - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - onReply = onCompose, - onZapFeedback = onZapFeedback, - onNavigateToProfile = 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() - }, - ) + // Look up Note from cache for the new FeedNoteCard API + val note = localCache.getNoteIfExists(event.id) + if (note != null) { + FeedNoteCard( + note = note, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReply = onCompose, + onZapFeedback = onZapFeedback, + onNavigateToProfile = 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() + }, + ) + } } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/viewmodels/DesktopFeedViewModel.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/viewmodels/DesktopFeedViewModel.kt index a19d3ef59..b094bcc79 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/viewmodels/DesktopFeedViewModel.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/viewmodels/DesktopFeedViewModel.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter import com.vitorpamplona.amethyst.commons.viewmodels.FeedViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel import kotlinx.coroutines.launch /** @@ -45,4 +46,12 @@ class DesktopFeedViewModel( feedState.refreshSuspended() } } + + /** + * Cancel viewModelScope. ViewModel.clear() is internal in lifecycle KMP, + * so Desktop composables use this for cleanup via DisposableEffect. + */ + fun destroy() { + viewModelScope.cancel() + } }