Finish the swallowed-failure sweep, and stop stories being wiped

Completing the four sites the previous commit named rather than leaving them.

🔴 **Stories were CLOBBERED by a failed fetch, not merely mis-described.** Both
`refreshStories` and the poll loop did
`runCatching { current.stories() }.getOrDefault(emptyList())` and then wrote
that straight into state — so one hiccup emptied the strip the user was looking
at, and the poll repeated it on every tick. A poll that cannot reach the relay
has learned nothing, and nothing is not "no stories". Both now keep what is on
screen and skip that tick.

⚠️ Groups, both platforms. Android rendered NOTHING for an empty list — no
guidance and no way to tell "you have no groups" from "the list did not load".
It now says which, matching the contacts card. iOS already had copy and it had
the same flaw: `refreshGroups` uses `try?`, so a failure left the list empty and
the section confidently said "No groups yet. Start one with your buddies."
`groupsLoaded` gates that on both sides.

That shape is worth naming, because it produced three separate findings today:
a swallowed failure, written into state, rendered as a confident empty state.
The group DECODE bug hid inside it for months — groups were invisible on iOS
and working on Android, and nothing anywhere said a word.

Android 194 tests green, iOS 46 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-26 12:20:49 -04:00
co-authored by Claude Opus 5
parent 62b4798881
commit f6f2fbd283
2 changed files with 42 additions and 8 deletions
@@ -352,6 +352,21 @@ fun ColumnScope.GroupsSection(
) { Text("Create group") }
}
// ⚠️ An empty list rendered NOTHING here — no guidance, and no way to tell
// "you have no groups" from "the list did not load". Say which.
if (phase.groups.isEmpty()) {
Text(
if (phase.groupsLoaded) {
"No groups yet. Start one with your buddies."
} else {
"Couldn't load your groups. Check the connection and sign in again."
},
style = MaterialTheme.typography.bodyMedium,
color = Palette.SecondaryText,
modifier = Modifier.testTag("groups-empty"),
)
}
phase.groups.forEach { group ->
val thread = io.flamenet.e2e.Vault.threadForGroup(group.id)
Row(
@@ -65,6 +65,9 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
* groups invisible on iOS for far longer.
*/
val buddiesLoaded: Boolean = false,
/** Same question for the group list. See [buddiesLoaded]. */
val groupsLoaded: Boolean = false,
val groups: List<io.flamenet.client.RelayClient.Group> = emptyList(),
val blocked: Set<Long> = emptySet(),
val notifications: List<MessengerApi.Notification> = emptyList(),
@@ -236,6 +239,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
credential.relayBaseUrl, credential.username, credential.appPassword),
)
val fetchedBuddies = runCatching { session!!.api.buddies() }
val fetchedGroups = runCatching { session!!.relay.listGroups() }
stored to Phase.SignedIn(
handle = credential.username,
userId = credential.userId,
@@ -244,7 +248,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
status = "Signed in on this device.",
buddies = fetchedBuddies.getOrDefault(emptyList()),
buddiesLoaded = fetchedBuddies.isSuccess,
groups = runCatching { session!!.relay.listGroups() }.getOrDefault(emptyList()),
groups = fetchedGroups.getOrDefault(emptyList()),
groupsLoaded = fetchedGroups.isSuccess,
)
}
}
@@ -351,6 +356,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
)
val fetchedBuddies = runCatching { session!!.api.buddies() }
val fetchedGroups = runCatching { session!!.relay.listGroups() }
return Phase.SignedIn(
handle = account.username,
userId = account.userId,
@@ -359,7 +365,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
status = "Registered with ${preKeys.size} one-time prekeys published.",
buddies = fetchedBuddies.getOrDefault(emptyList()),
buddiesLoaded = fetchedBuddies.isSuccess,
groups = runCatching { session!!.relay.listGroups() }.getOrDefault(emptyList()),
groups = fetchedGroups.getOrDefault(emptyList()),
groupsLoaded = fetchedGroups.isSuccess,
)
}
@@ -904,7 +911,12 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
fun refreshStories() = viewModelScope.launch {
val current = session ?: return@launch
val live = withContext(Dispatchers.IO) { runCatching { current.stories() }.getOrDefault(emptyList()) }
// 🔴 **A failed fetch must not empty the strip.** Defaulting to an
// empty list here did not merely look wrong, it CLOBBERED whatever was
// on screen — so one hiccup wiped the stories the user was looking at,
// and the poll below repeated that on every tick.
val fetched = withContext(Dispatchers.IO) { runCatching { current.stories() } }
val live = fetched.getOrNull() ?: return@launch
update { it.copy(stories = live) }
}
@@ -1166,12 +1178,19 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
// Read back every tick rather than only when a thread was
// touched: a story files itself outside the thread map, so
// `threads` is empty for a poll that brought one in.
val live = withContext(Dispatchers.IO) {
runCatching { current.stories() }.getOrDefault(emptyList())
// Keep what is on screen when the fetch fails; see
// refreshStories. A poll that cannot reach the relay has
// learned nothing, and "nothing" is not "no stories".
val polled = withContext(Dispatchers.IO) {
runCatching { current.stories() }
}
update { state ->
if (state.stories.map { it.id } == live.map { it.id }) state
else state.copy(stories = live)
// Skip THIS tick's update on failure rather than leaving
// the loop — the next poll may well succeed.
polled.getOrNull()?.let { live ->
update { state ->
if (state.stories.map { it.id } == live.map { it.id }) state
else state.copy(stories = live)
}
}
if (threads.isNotEmpty()) {
update { state ->