diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index a8d8e8a..a882bb8 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -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( diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 5c636d1..f45b1b1 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -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 = emptyList(), val blocked: Set = emptySet(), val notifications: List = 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 ->