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:
co-authored by
Claude Opus 5
parent
7295f43a13
commit
c9de1f26b8
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user