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 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-01-12 06:52:33 +02:00
parent ec629ac3fe
commit e1412c1f97
3 changed files with 77 additions and 11 deletions
@@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.commons.subscriptions.createContactListSubscri
import com.vitorpamplona.amethyst.commons.subscriptions.createFollowingFeedSubscription import com.vitorpamplona.amethyst.commons.subscriptions.createFollowingFeedSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createGlobalFeedSubscription import com.vitorpamplona.amethyst.commons.subscriptions.createGlobalFeedSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription 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.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.note.NoteCard import com.vitorpamplona.amethyst.commons.ui.note.NoteCard
import com.vitorpamplona.amethyst.commons.util.toNoteDisplayData import com.vitorpamplona.amethyst.commons.util.toNoteDisplayData
@@ -127,6 +128,10 @@ fun FeedScreen(
var feedMode by remember { mutableStateOf(FeedMode.GLOBAL) } var feedMode by remember { mutableStateOf(FeedMode.GLOBAL) }
var followedUsers by remember { mutableStateOf<Set<String>>(emptySet()) } var followedUsers by remember { mutableStateOf<Set<String>>(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 // Load followed users for Following feed mode
rememberSubscription(relayStatuses, account, feedMode, relayManager = relayManager) { rememberSubscription(relayStatuses, account, feedMode, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys 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) { remember(feedMode) {
eventState.clear() eventState.clear()
eoseReceivedCount = 0
} }
// Subscribe to feed based on mode // Subscribe to feed based on mode
@@ -164,6 +170,9 @@ fun FeedScreen(
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
eventState.addItem(event) eventState.addItem(event)
}, },
onEose = { _, _ ->
eoseReceivedCount++
},
) )
} }
FeedMode.FOLLOWING -> { FeedMode.FOLLOWING -> {
@@ -174,6 +183,9 @@ fun FeedScreen(
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
eventState.addItem(event) eventState.addItem(event)
}, },
onEose = { _, _ ->
eoseReceivedCount++
},
) )
} else { } else {
null null
@@ -263,13 +275,23 @@ fun FeedScreen(
LoadingState("Connecting to relays...") LoadingState("Connecting to relays...")
} else if (feedMode == FeedMode.FOLLOWING && followedUsers.isEmpty()) { } else if (feedMode == FeedMode.FOLLOWING && followedUsers.isEmpty()) {
LoadingState("Loading followed users...") LoadingState("Loading followed users...")
} else if (events.isEmpty()) { } else if (events.isEmpty() && !initialLoadComplete) {
LoadingState( LoadingState("Loading notes...")
if (feedMode == FeedMode.FOLLOWING) { } else if (events.isEmpty() && initialLoadComplete) {
"No notes from followed users yet" EmptyState(
} else { title =
"Loading notes..." 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 { } else {
LazyColumn( LazyColumn(
@@ -41,8 +41,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
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
@@ -53,6 +55,7 @@ import com.vitorpamplona.amethyst.commons.icons.Zap
import com.vitorpamplona.amethyst.commons.state.EventCollectionState import com.vitorpamplona.amethyst.commons.state.EventCollectionState
import com.vitorpamplona.amethyst.commons.subscriptions.createNotificationsSubscription import com.vitorpamplona.amethyst.commons.subscriptions.createNotificationsSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription 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.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.feed.FeedHeader import com.vitorpamplona.amethyst.commons.ui.feed.FeedHeader
import com.vitorpamplona.amethyst.commons.util.toTimeAgo import com.vitorpamplona.amethyst.commons.util.toTimeAgo
@@ -120,6 +123,10 @@ fun NotificationsScreen(
} }
val notifications by notificationState.items.collectAsState() 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 // Subscribe to notifications
rememberSubscription(relayStatuses, account.pubKeyHex, relayManager = relayManager) { rememberSubscription(relayStatuses, account.pubKeyHex, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys val configuredRelays = relayStatuses.keys
@@ -168,6 +175,9 @@ fun NotificationsScreen(
notificationState.addItem(notification) notificationState.addItem(notification)
}, },
onEose = { _, _ ->
eoseReceivedCount++
},
) )
} else { } else {
null null
@@ -185,8 +195,14 @@ fun NotificationsScreen(
if (connectedRelays.isEmpty()) { if (connectedRelays.isEmpty()) {
LoadingState("Connecting to relays...") LoadingState("Connecting to relays...")
} else if (notifications.isEmpty()) { } else if (notifications.isEmpty() && !initialLoadComplete) {
LoadingState("Loading notifications...") 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 { } else {
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -55,6 +55,7 @@ import com.vitorpamplona.amethyst.commons.state.EventCollectionState
import com.vitorpamplona.amethyst.commons.subscriptions.createNoteSubscription import com.vitorpamplona.amethyst.commons.subscriptions.createNoteSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createThreadRepliesSubscription import com.vitorpamplona.amethyst.commons.subscriptions.createThreadRepliesSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription 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.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.note.NoteCard import com.vitorpamplona.amethyst.commons.ui.note.NoteCard
import com.vitorpamplona.amethyst.commons.ui.thread.drawReplyLevel import com.vitorpamplona.amethyst.commons.ui.thread.drawReplyLevel
@@ -98,6 +99,10 @@ fun ThreadScreen(
// Cache for calculating reply levels // Cache for calculating reply levels
val levelCache = remember(noteId) { mutableMapOf<String, Int>() } 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) }
// Subscribe to the root note // Subscribe to the root note
rememberSubscription(relayStatuses, noteId, relayManager = relayManager) { rememberSubscription(relayStatuses, noteId, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys val configuredRelays = relayStatuses.keys
@@ -111,6 +116,9 @@ fun ThreadScreen(
levelCache[event.id] = 0 levelCache[event.id] = 0
} }
}, },
onEose = { _, _ ->
rootNoteEoseReceived = true
},
) )
} else { } else {
null null
@@ -127,6 +135,9 @@ fun ThreadScreen(
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
replyEventState.addItem(event) replyEventState.addItem(event)
}, },
onEose = { _, _ ->
repliesEoseReceived = true
},
) )
} else { } else {
null null
@@ -172,8 +183,15 @@ fun ThreadScreen(
if (connectedRelays.isEmpty()) { if (connectedRelays.isEmpty()) {
LoadingState("Connecting to relays...") LoadingState("Connecting to relays...")
} else if (rootNote == null) { } else if (rootNote == null && !rootNoteEoseReceived) {
LoadingState("Loading thread...") 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 { } else {
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(0.dp), verticalArrangement = Arrangement.spacedBy(0.dp),
@@ -241,7 +259,7 @@ fun ThreadScreen(
} }
// Empty state for no replies // Empty state for no replies
if (replyEvents.isEmpty()) { if (replyEvents.isEmpty() && repliesEoseReceived) {
item { item {
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
Text( Text(
@@ -251,6 +269,16 @@ fun ThreadScreen(
modifier = Modifier.padding(16.dp), 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),
)
}
} }
} }
} }