A stale presence token stops reporting people offline

Same fault the iPhone had. While the relay still keeps a contact graph there
are two ways to be granted a view of somebody, and a token that has gone stale
answers offline for a contact the graph says is online. The merge took the
token's answer outright; it takes the more visible of the two now.

Once the relay keeps no graph the token is the only source and stands alone,
including when it says offline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-16 13:31:36 -04:00
co-authored by Claude Opus 5
parent 55d8442ccc
commit 13d49fed8e
@@ -2130,14 +2130,32 @@ class Session(
)
}
vault.saveRoster(mirror, offServer = false)
// §8.9c. Where this account holds a contact's token, the token's
// answer wins: it is the one that does not depend on the relay
// keeping a copy of who knows whom. A contact with no token yet
// keeps whatever the roster said, so a half-migrated pair does not
// read as offline while both ends catch up.
// §8.9c. Two ways to be granted a view of somebody: the relay's
// contact graph, and holding their token. While BOTH exist they
// can disagree, and a token that has gone stale answers offline
// for somebody the graph says is online. Take the more visible
// answer: either grant is a grant, and a stale token is not
// somebody going offline.
//
// Once the relay keeps no graph its answer is the only one, so the
// token stands alone including when it says offline. That is
// revocation working rather than a disagreement.
val byToken = presenceFromTokens()
if (byToken.isEmpty()) fetched else fetched.map { b ->
byToken[b.userId]?.let { b.copy(presence = it) } ?: b
if (byToken.isEmpty()) {
fetched
} else {
fetched.map { b ->
val told = byToken[b.userId] ?: return@map b
val take =
if (!answer.relayKeepsRoster) {
told
} else if (visibilityOf(told) > visibilityOf(b.presence)) {
told
} else {
b.presence
}
b.copy(presence = take)
}
}
} catch (e: Exception) {
if (items.length() > 0) stored() else throw e
@@ -2171,6 +2189,18 @@ class Session(
* transient fault. Empty onto empty is allowed, or a new account could
* never take its first contacts from the server.
*/
/**
* How visible a presence reads, for choosing between two answers about the
* same person. Higher is more present. See the merge in [buddies].
*/
private fun visibilityOf(presence: String): Int =
when (presence) {
"online" -> 3
"away" -> 2
"busy" -> 1
else -> 0
}
internal fun shouldAdoptRoster(
incoming: Int,
held: Int,