Believe the relay about its own roster, including an empty one

Same as the iPhone. Yesterday's guard refused every empty list and stranded a
removed last contact on every other device; the relay says which kind of empty
it means now, so the rule is simply whether the relay is the one holding the
roster.

MessengerApi.roster() carries both halves; buddies() stays for callers that
only want the list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-16 11:15:26 -04:00
co-authored by Claude Opus 5
parent dafb4f8adb
commit 55d8442ccc
3 changed files with 74 additions and 27 deletions
@@ -44,8 +44,31 @@ class MessengerApi(
val read: Boolean,
)
fun buddies(): List<Buddy> {
val list = get("buddies").optJSONArray("buddies") ?: JSONArray()
/**
* The buddy list, and whether the relay is still the one holding it.
*
* §8.9c. A relay that has stopped keeping a contact graph answers with an
* empty list, and so does an account that genuinely has no contacts. They
* are different things and only the relay can tell them apart, so it says
* which. Absent on an older relay, and absent reads as "still keeps one",
* because that is what an older relay does.
*/
data class Roster(
val buddies: List<Buddy>,
val relayKeepsRoster: Boolean,
)
fun roster(): Roster {
val body = get("buddies")
return Roster(
buddies = parseBuddies(body.optJSONArray("buddies") ?: JSONArray()),
relayKeepsRoster = body.optBoolean("roster_on_server", true),
)
}
fun buddies(): List<Buddy> = roster().buddies
private fun parseBuddies(list: JSONArray): List<Buddy> {
return (0 until list.length()).map {
val b = list.getJSONObject(it)
// `id`, `display_name`, `screen_name`, `presence`, this route does