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 d63178f..a8d8e8a 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -141,10 +141,19 @@ fun ColumnScope.BuddyList(model: MessengerViewModel, phase: MessengerViewModel.P } if (phase.buddies.isEmpty()) { + // ⚠️ Only claim the list is empty if it was actually FETCHED. A failed + // load also produces an empty list, and "Nobody here yet" is then a + // statement about the person's account rather than about the request — + // which is how somebody concludes their contacts are gone. + // // Says what to do, not merely that there is nothing. An empty state // that only reports emptiness leaves the person stuck. Text( - "Nobody here yet. Add someone by the username they signed up with.", + if (phase.buddiesLoaded) { + "Nobody here yet. Add someone by the username they signed up with." + } else { + "Couldn't load your contacts. Check the connection and sign in again." + }, style = MaterialTheme.typography.bodyMedium, ) return 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 51471a9..5c636d1 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -51,6 +51,20 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { val relay: String, val status: String, val buddies: List = emptyList(), + + /** + * Whether the contact list was actually FETCHED, as opposed to + * defaulted to empty because the fetch failed. + * + * 🔴 Both load sites swallow failures with + * `runCatching { … }.getOrDefault(emptyList())`, so a network + * hiccup, an expired token or a rate limit all produced an empty + * roster — and the UI then told the user "Nobody here yet", which + * is a claim about their account rather than about the request. + * The same swallow-and-look-empty shape hid a decode bug that made + * groups invisible on iOS for far longer. + */ + val buddiesLoaded: Boolean = false, val groups: List = emptyList(), val blocked: Set = emptySet(), val notifications: List = emptyList(), @@ -221,13 +235,15 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { api = MessengerApi( credential.relayBaseUrl, credential.username, credential.appPassword), ) + val fetchedBuddies = runCatching { session!!.api.buddies() } stored to Phase.SignedIn( handle = credential.username, userId = credential.userId, deviceId = stored.deviceId, relay = SiteConfig(credential.relayBaseUrl).host, status = "Signed in on this device.", - buddies = runCatching { session!!.api.buddies() }.getOrDefault(emptyList()), + buddies = fetchedBuddies.getOrDefault(emptyList()), + buddiesLoaded = fetchedBuddies.isSuccess, groups = runCatching { session!!.relay.listGroups() }.getOrDefault(emptyList()), ) } @@ -334,13 +350,15 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { api = io.flamenet.client.MessengerApi(config.baseUrl, account.username, credential), ) + val fetchedBuddies = runCatching { session!!.api.buddies() } return Phase.SignedIn( handle = account.username, userId = account.userId, deviceId = device.deviceId, relay = config.host, status = "Registered with ${preKeys.size} one-time prekeys published.", - buddies = runCatching { session!!.api.buddies() }.getOrDefault(emptyList()), + buddies = fetchedBuddies.getOrDefault(emptyList()), + buddiesLoaded = fetchedBuddies.isSuccess, groups = runCatching { session!!.relay.listGroups() }.getOrDefault(emptyList()), ) } diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/EmptyVersusFailedTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/EmptyVersusFailedTest.kt new file mode 100644 index 0000000..8abab0c --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/EmptyVersusFailedTest.kt @@ -0,0 +1,45 @@ +package io.flamenet.messenger.app + +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * "Nobody here yet" is a claim about the ACCOUNT. It must not be made about a + * request that failed. + * + * 🔴 Both roster load sites swallow failures with + * `runCatching { … }.getOrDefault(emptyList())`, so a hiccup, an expired token + * or a rate limit all produced an empty list — indistinguishable from a real + * empty account. The same swallow-and-look-empty shape hid a decode bug that + * made groups invisible on iOS. + */ +class EmptyVersusFailedTest { + + private fun phase(loaded: Boolean) = MessengerViewModel.Phase.SignedIn( + handle = "someone", + userId = 1, + deviceId = "d", + relay = "relay.example", + status = "", + buddies = emptyList(), + buddiesLoaded = loaded, + ) + + @Test + fun `an empty roster that loaded is not the same state as one that failed`() { + assertNotEquals(phase(loaded = true), phase(loaded = false)) + } + + @Test + fun `a failed load does not default to claiming the roster loaded`() { + // The default matters: a phase built without saying so must not assert + // that the fetch succeeded. + val untouched = MessengerViewModel.Phase.SignedIn( + handle = "someone", userId = 1, deviceId = "d", + relay = "relay.example", status = "", + ) + assertTrue("the default must be 'not loaded', so the copy stays honest", + !untouched.buddiesLoaded) + } +}