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.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<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
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(
@@ -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),
@@ -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<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
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),
)
}
}
}
}