Profile pictures, end to end, and the chat bar's second line

🔴 **An avatar is an attachment, not a field on the account.** The
obvious design — upload a JPEG and let the relay serve one per user —
hands the server everybody's face, on a product whose whole claim is
that it cannot see what you send. This rides the same encrypted blob
path as a photo message: the relay stores ciphertext, and the key
travels to each contact inside the ratchet exactly as the delivery key
already does.

⚠️ **The cost, stated on the screen rather than discovered.** It is per
contact. Somebody who has never messaged you cannot see your picture,
and changing it means telling every contact again. Sealed sender
already makes this bargain; a different answer for avatars would mean
two answers to the same question.

The pieces: a new `avatar` control kind, which is parsed WITH its
attachment ref rather than falling through the CONTROL early-return —
otherwise a profile picture arrives in the conversation as though
somebody had sent you their face as a photo. Encrypted at rest in the
vault like everything else, because a face on disk in the clear is the
one thing a stolen phone should not hand over about your contacts.
Sharing is keyed on the blob id, not a boolean: a flag would say "told"
for ever and a new picture would never reach anyone who had the old one.

⚠️ Contacts are told on OPENING a conversation, not when the picture is
chosen. Somebody added afterwards would otherwise never be told, would
see initials for ever, and nothing on any screen would explain why.

⚠️ Partial delivery is reported. "Sent, but 2 of 5 could not be told"
is the truth when two contacts have a changed key, and silence would
let somebody believe it had gone out.

Also: the chat bar gained the kit's second line — a presence dot and a
12sp fact, or a member count for a group, which shows no dot because a
group has many presences and one dot cannot speak for them. And the
reassuring identity states dropped to 12sp grey; they were as loud as
the title bar, which is the wrong weight for "nothing has happened".
The CHANGED state is untouched and stays loud.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-27 15:58:58 -04:00
co-authored by Claude Opus 5
parent 7295f43a13
commit c9de1f26b8
8 changed files with 419 additions and 20 deletions
@@ -236,6 +236,75 @@ class Conversations(
data class StoryPosted(val ref: Attachment.Ref, val reached: Int, val missed: Int)
/**
* Publish a profile picture: upload it encrypted, then tell each contact
* where it is and how to open it.
*
* 🔴 **The relay never sees the face.** It stores the same opaque blob it
* stores for a photo message; the key travels inside the ratchet, exactly
* as the delivery key already does. The obvious alternative — a JPEG the
* server serves per user — would hand it everybody's face on a product
* whose claim is that it cannot see what you send.
*
* ⚠️ **Per contact, and that is the trade.** Somebody who has never
* messaged you cannot see your picture, and changing it means telling
* every contact again. Sealed sender already makes this bargain; making a
* different one for avatars would mean two answers to the same question.
*
* ⚠️ Saved locally BEFORE the fan-out, so a picture that reached nobody is
* still yours and is still re-sent to the next contact you talk to.
*/
fun setAvatar(
data: ByteArray,
mime: String,
width: Int,
height: Int,
toUserIds: List<Long>,
): AvatarPosted {
val sealed = Attachment.seal(data)
val id = relay.uploadAttachment(sealed.blob)
val ref = Attachment.Ref(
kind = Attachment.IMAGE, id = id, key = sealed.key, digest = sealed.digest,
bytes = sealed.blob.size, mime = mime, width = width, height = height,
)
vault.saveMyAvatar(ref)
val body = Content.avatar(ref)
var reached = 0
var missed = 0
for (userId in toUserIds) {
// ⚠️ One failing contact must not stop the rest. A key that
// changed under one person is a real refusal and it is theirs, not
// a reason nobody else gets the picture.
val ok = runCatching { sendPlaintext(userId, body).reachedSomeone }.getOrDefault(false)
if (ok) reached++ else missed++
}
return AvatarPosted(ref, reached, missed)
}
data class AvatarPosted(val ref: Attachment.Ref, val reached: Int, val missed: Int)
/** Our own picture, or null. */
fun myAvatar(): Attachment.Ref? = vault.myAvatar()
/** A contact's picture, or null if they have not sent one. */
fun avatarFor(userId: Long): Attachment.Ref? = vault.avatarFor(userId)
/**
* Send our picture to one contact, if we have one and they have not been
* told about this version.
*
* ⚠️ Called on first contact rather than on every message: the reference is
* a few hundred bytes, but sending it with every line would put a control
* envelope in front of every word anybody says.
*/
fun shareAvatarIfNeeded(toUserId: Long) {
val ref = vault.myAvatar() ?: return
if (vault.avatarSharedWith(toUserId) == ref.id) return
runCatching { sendPlaintext(toUserId, Content.avatar(ref)) }
.onSuccess { vault.markAvatarShared(toUserId, ref.id) }
}
/**
* Send one call signal (§8.10).
*
@@ -512,6 +581,13 @@ class Conversations(
vault.saveDelivery(
delivery.copy(peers = delivery.peers + (fromUser to Wire.unb64(key))))
}
Content.Kind.AVATAR -> {
// ⚠️ Consumed, never rendered. Without this branch an avatar
// would fall through as an ordinary image and appear in the
// conversation as though they had sent you their face as a
// photo message.
vault.saveAvatar(fromUser, content.attachment)
}
Content.Kind.KEY_LOG_HEAD -> {
val raw = content.raw ?: return
trust.gossip(raw.optInt("size"), raw.optString("root"))