From e1412c1f97c80db8e4d1b29c61ca842f2b35ae41 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 12 Jan 2026 06:52:33 +0200 Subject: [PATCH] fix: Add proper empty states with EOSE tracking - NotificationsScreen: Show "No notifications yet" after EOSE instead of infinite loading - FeedScreen: Show "No notes found" after EOSE for both Global and Following feeds - ThreadScreen: Show "Note not found" if root note missing after EOSE, "No replies yet" for empty threads Uses existing EmptyState component from commons. Tracks EOSE (End of Stored Events) from relays to distinguish between "still loading" and "no data". Co-Authored-By: Claude Opus 4.5 --- .../amethyst/desktop/ui/FeedScreen.kt | 38 +++++++++++++++---- .../desktop/ui/NotificationsScreen.kt | 18 ++++++++- .../amethyst/desktop/ui/ThreadScreen.kt | 32 +++++++++++++++- 3 files changed, 77 insertions(+), 11 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 00cef6714..5cd4d73a7 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 @@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.commons.subscriptions.createContactListSubscri import com.vitorpamplona.amethyst.commons.subscriptions.createFollowingFeedSubscription import com.vitorpamplona.amethyst.commons.subscriptions.createGlobalFeedSubscription import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.note.NoteCard import com.vitorpamplona.amethyst.commons.util.toNoteDisplayData @@ -127,6 +128,10 @@ fun FeedScreen( var feedMode by remember { mutableStateOf(FeedMode.GLOBAL) } var followedUsers 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(relayStatuses, account, feedMode, relayManager = relayManager) { val configuredRelays = relayStatuses.keys @@ -145,9 +150,10 @@ fun FeedScreen( } } - // Clear events when feed mode changes + // Clear events and reset EOSE when feed mode changes remember(feedMode) { eventState.clear() + eoseReceivedCount = 0 } // Subscribe to feed based on mode @@ -164,6 +170,9 @@ fun FeedScreen( onEvent = { event, _, _, _ -> eventState.addItem(event) }, + onEose = { _, _ -> + eoseReceivedCount++ + }, ) } FeedMode.FOLLOWING -> { @@ -174,6 +183,9 @@ fun FeedScreen( onEvent = { event, _, _, _ -> eventState.addItem(event) }, + onEose = { _, _ -> + eoseReceivedCount++ + }, ) } else { null @@ -263,13 +275,23 @@ fun FeedScreen( LoadingState("Connecting to relays...") } else if (feedMode == FeedMode.FOLLOWING && followedUsers.isEmpty()) { LoadingState("Loading followed users...") - } else if (events.isEmpty()) { - LoadingState( - if (feedMode == FeedMode.FOLLOWING) { - "No notes from followed users yet" - } else { - "Loading notes..." - }, + } 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( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt index 772f05219..77edace1c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt @@ -41,8 +41,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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 @@ -53,6 +55,7 @@ import com.vitorpamplona.amethyst.commons.icons.Zap import com.vitorpamplona.amethyst.commons.state.EventCollectionState import com.vitorpamplona.amethyst.commons.subscriptions.createNotificationsSubscription import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.feed.FeedHeader import com.vitorpamplona.amethyst.commons.util.toTimeAgo @@ -120,6 +123,10 @@ fun NotificationsScreen( } val notifications by notificationState.items.collectAsState() + // Track EOSE to know when initial load is complete + var eoseReceivedCount by remember { mutableStateOf(0) } + val initialLoadComplete = eoseReceivedCount > 0 + // Subscribe to notifications rememberSubscription(relayStatuses, account.pubKeyHex, relayManager = relayManager) { val configuredRelays = relayStatuses.keys @@ -168,6 +175,9 @@ fun NotificationsScreen( notificationState.addItem(notification) }, + onEose = { _, _ -> + eoseReceivedCount++ + }, ) } else { null @@ -185,8 +195,14 @@ fun NotificationsScreen( if (connectedRelays.isEmpty()) { LoadingState("Connecting to relays...") - } else if (notifications.isEmpty()) { + } else if (notifications.isEmpty() && !initialLoadComplete) { LoadingState("Loading notifications...") + } else if (notifications.isEmpty() && initialLoadComplete) { + EmptyState( + title = "No notifications yet", + description = "When someone interacts with your posts, you'll see it here", + onRefresh = { relayManager.connect() }, + ) } else { LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index 0c469f085..30c436909 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -55,6 +55,7 @@ import com.vitorpamplona.amethyst.commons.state.EventCollectionState import com.vitorpamplona.amethyst.commons.subscriptions.createNoteSubscription import com.vitorpamplona.amethyst.commons.subscriptions.createThreadRepliesSubscription import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.note.NoteCard import com.vitorpamplona.amethyst.commons.ui.thread.drawReplyLevel @@ -98,6 +99,10 @@ fun ThreadScreen( // Cache for calculating reply levels val levelCache = remember(noteId) { mutableMapOf() } + // Track EOSE to know when initial load is complete + var rootNoteEoseReceived by remember(noteId) { mutableStateOf(false) } + var repliesEoseReceived by remember(noteId) { mutableStateOf(false) } + // Subscribe to the root note rememberSubscription(relayStatuses, noteId, relayManager = relayManager) { val configuredRelays = relayStatuses.keys @@ -111,6 +116,9 @@ fun ThreadScreen( levelCache[event.id] = 0 } }, + onEose = { _, _ -> + rootNoteEoseReceived = true + }, ) } else { null @@ -127,6 +135,9 @@ fun ThreadScreen( onEvent = { event, _, _, _ -> replyEventState.addItem(event) }, + onEose = { _, _ -> + repliesEoseReceived = true + }, ) } else { null @@ -172,8 +183,15 @@ fun ThreadScreen( if (connectedRelays.isEmpty()) { LoadingState("Connecting to relays...") - } else if (rootNote == null) { + } 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), @@ -241,7 +259,7 @@ fun ThreadScreen( } // Empty state for no replies - if (replyEvents.isEmpty()) { + if (replyEvents.isEmpty() && repliesEoseReceived) { item { Spacer(Modifier.height(32.dp)) Text( @@ -251,6 +269,16 @@ fun ThreadScreen( 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), + ) + } } } }