Show the presence the relay stored, not the one we asked for

`setPresence` did `it.copy(presence = status)` on success — the value it had
requested. The relay answered 200 for statuses it did not recognise and stored
nothing, and `busy` and `offline` were both in that set, so tapping either lit
the chip and changed nothing. Someone who chose "offline" stayed visible to
every contact with no way to find out.

The relay is fixed too, but a client that reports its own request back to itself
is wrong regardless of how the server behaves — so `setStatus` now returns what
the relay says the status is, and the screen renders that.

⚠️ The reply is not always the word sent: `offline` is stored as `invisible`,
the same choice under two names, so it is mapped back to this screen's
vocabulary or no chip would appear selected right after a successful change.

`PRESENCE_CHOICES` moves to the companion object so the chips and the test read
one list. It is a contract with another repository that no compiler checks, and
the test says so: every entry must be a value the relay's `ChosenStatus`
accepts.

engine 141, client 90, app 32 — all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-29 11:00:06 -04:00
co-authored by Claude Opus 4.8
parent 076e85d756
commit cc8f4ca1d0
4 changed files with 114 additions and 6 deletions
@@ -505,7 +505,7 @@ fun PresencePicker(model: MessengerViewModel, current: String) {
// ⚠️ Chips, not four orange words in a row. As text buttons these read
// as four links and gave no sign that they were a single choice with
// one answer — the selected one was merely bold.
listOf("online", "away", "busy", "offline").forEach { status ->
MessengerViewModel.PRESENCE_CHOICES.forEach { status ->
KitChip(
text = status,
selected = status == current,
@@ -794,13 +794,34 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
// -- Presence ------------------------------------------------------------
/**
* Choose a presence state.
*
* 🔴 **Shows what the relay stored, not what we asked for.** This used to
* do `it.copy(presence = status)` on success — and the relay used to answer
* 200 for statuses it did not recognise while storing nothing. `busy` and
* `offline` were both in that set, so tapping either lit the chip and
* changed nothing: someone who chose "offline" stayed visible to every
* contact with no way to find out. Both ends are fixed; this half stays
* correct even if a relay ever behaves that way again.
*
* ⚠️ `offline` is stored as `invisible` — the two are the same choice under
* different names — so the reply is mapped back to this screen's vocabulary
* or no chip would appear selected.
*/
fun setPresence(status: String) = viewModelScope.launch {
val current = session ?: return@launch
val done = withContext(Dispatchers.IO) { runCatching { current.api.setStatus(status) } }
done.onSuccess { update { it.copy(presence = status, error = null) } }
.onFailure { failure -> update { it.copy(error = describe(failure)) } }
done.onSuccess { stored ->
update { it.copy(presence = displayPresence(stored), error = null) }
}.onFailure { failure -> update { it.copy(error = describe(failure)) } }
}
/** Relay vocabulary -> the words on the presence chips. */
internal fun displayPresence(stored: String): String =
if (stored == "invisible") "offline" else stored
// -- Notifications -------------------------------------------------------
fun refreshNotifications() = viewModelScope.launch {
@@ -1797,7 +1818,19 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
}
}
private companion object {
companion object {
/**
* The presence chips, in order.
*
* ⚠️ **A contract with the relay, checked by no compiler.** Every entry
* must be a value `ChosenStatus` accepts — `offline` is accepted there
* as an alias for `invisible`. Adding a word the relay does not know
* brings the original bug back in a newer form: the relay answers 400
* now rather than lying, so the chip fails visibly instead of silently,
* but it is still a control that does not work.
*/
val PRESENCE_CHOICES = listOf("online", "away", "busy", "offline")
const val CALLS_SEEN_AT = "calls_seen_at"
const val POLL_INTERVAL_MS = 5_000L
const val CALL_SETUP_POLL_MS = 1_000L