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
@@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -225,7 +226,12 @@ fun ColumnScope.BuddyList(
|
||||
// the design. Initials rather than a photo, because the relay
|
||||
// stores no avatars and inventing a placeholder face would be
|
||||
// worse than the letters.
|
||||
Avatar(name = name, online = buddy.presence.equals("online", ignoreCase = true))
|
||||
Avatar(
|
||||
name = name,
|
||||
online = buddy.presence.equals("online", ignoreCase = true),
|
||||
picture = phase.avatars[buddy.userId],
|
||||
model = model,
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
name,
|
||||
@@ -618,6 +624,9 @@ fun ColumnScope.AccountScreen(
|
||||
|
||||
Spacer(Modifier.height(Space.md))
|
||||
|
||||
SettingsSection { ProfilePicture(model, phase) }
|
||||
|
||||
SectionBand()
|
||||
SettingsSection { AddressCard(fnid = phase.fnid, relay = phase.relay) }
|
||||
|
||||
SectionBand()
|
||||
@@ -746,6 +755,78 @@ fun ColumnScope.AccountScreen(
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Your profile picture: show it, change it, remove it.
|
||||
*
|
||||
* 🔴 **It is sent to contacts, not published.** The relay stores an encrypted
|
||||
* blob it cannot open, and the key reaches each contact inside the ratchet —
|
||||
* so somebody who has never messaged you cannot see your face, and changing it
|
||||
* means telling every contact again. That trade is stated on the screen rather
|
||||
* than left for somebody to discover when a new contact sees initials.
|
||||
*/
|
||||
@Composable
|
||||
private fun ColumnScope.ProfilePicture(
|
||||
model: MessengerViewModel,
|
||||
phase: MessengerViewModel.Phase.SignedIn,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val pick = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
uri ?: return@rememberLauncherForActivityResult
|
||||
val bytes = runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
}.getOrNull() ?: return@rememberLauncherForActivityResult
|
||||
val bounds = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||
model.setAvatar(
|
||||
bytes,
|
||||
context.contentResolver.getType(uri) ?: "image/jpeg",
|
||||
bounds.outWidth,
|
||||
bounds.outHeight,
|
||||
)
|
||||
}
|
||||
|
||||
Text("Profile picture", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(Space.md))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Kit.gutter),
|
||||
) {
|
||||
Avatar(
|
||||
name = phase.handle,
|
||||
online = false,
|
||||
picture = phase.myAvatar,
|
||||
model = model,
|
||||
size = 80.dp,
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
KitChip(
|
||||
text = if (phase.myAvatar == null) "Choose a picture" else "Change",
|
||||
selected = false,
|
||||
onClick = { pick.launch("image/*") },
|
||||
tag = "set-avatar",
|
||||
)
|
||||
if (phase.myAvatar != null) {
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
KitChip(
|
||||
text = "Remove",
|
||||
selected = false,
|
||||
onClick = model::clearAvatar,
|
||||
tag = "clear-avatar",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(Space.md))
|
||||
Text(
|
||||
"Sent to your contacts encrypted, the same way a photo is — this server " +
|
||||
"stores it and cannot open it. People who have never messaged you see " +
|
||||
"your initials instead.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsSection(content: @Composable ColumnScope.() -> Unit) {
|
||||
Surface(
|
||||
@@ -822,7 +903,14 @@ private fun SettingsRow(
|
||||
* rather than complete.
|
||||
*/
|
||||
@Composable
|
||||
private fun Avatar(name: String, online: Boolean) {
|
||||
internal fun Avatar(
|
||||
name: String,
|
||||
online: Boolean,
|
||||
/** Their picture, if they have sent us one. Initials stand in when not. */
|
||||
picture: io.flamenet.e2e.Attachment.Ref? = null,
|
||||
model: MessengerViewModel? = null,
|
||||
size: androidx.compose.ui.unit.Dp = Kit.avatar,
|
||||
) {
|
||||
val initials = remember(name) {
|
||||
name.trim().split(" ").filter { it.isNotBlank() }
|
||||
.take(2).mapNotNull { it.firstOrNull() }
|
||||
@@ -831,17 +919,25 @@ private fun Avatar(name: String, online: Boolean) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(Kit.avatar)
|
||||
.size(size)
|
||||
.clip(CircleShape)
|
||||
.background(Palette.AvatarFill),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// ⚠️ Initials are not a placeholder to be replaced when the bytes
|
||||
// arrive — they are what most people will have, because a picture
|
||||
// only exists once somebody has sent one and only reaches contacts
|
||||
// they have messaged. They are the normal case, not the fallback.
|
||||
if (picture != null && model != null) {
|
||||
AttachedImage(picture, model)
|
||||
} else {
|
||||
Text(
|
||||
initials,
|
||||
fontSize = 16.sp,
|
||||
fontSize = (size.value * 0.33f).sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (online) {
|
||||
Box(
|
||||
@@ -980,14 +1076,22 @@ fun ColumnScope.ChatScreen(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
// ⚠️ One subtitle slot, whichever fact applies. A group says how
|
||||
// many people are in it; a one-to-one has nothing to say here yet.
|
||||
chat.group?.let {
|
||||
Text(
|
||||
"${it.memberIds.size} members",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
// The kit's second line: a presence dot, then a 12sp fact.
|
||||
// ⚠️ A group says how many people are in it and shows no dot — a
|
||||
// group has many presences and one dot cannot speak for them.
|
||||
val presence = chat.peerId?.let { id ->
|
||||
buddies.firstOrNull { it.userId == id }?.presence
|
||||
}
|
||||
val subtitle = chat.group?.let { "${it.memberIds.size} members" }
|
||||
?: presence?.takeIf { it.isNotBlank() }
|
||||
subtitle?.let { line ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (chat.group == null && presence.equals("online", true)) {
|
||||
Box(Modifier.size(8.dp).clip(CircleShape).background(Palette.Teal))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
}
|
||||
Text(line, fontSize = 12.sp, color = Palette.SecondaryText)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Calling is one-to-one. A group call is a different feature with
|
||||
@@ -1308,13 +1412,19 @@ private fun ColumnScope.IdentityPanel(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// ⚠️ **Quiet, but never absent.** The reassuring states are 12sp
|
||||
// grey: they were as loud as the conversation's own title bar,
|
||||
// which is the wrong weight for "nothing has happened". The
|
||||
// CHANGED state above is untouched and stays loud — it is the one
|
||||
// signal this whole mechanism exists to produce.
|
||||
Text(
|
||||
when (chat.trust) {
|
||||
IdentityTrust.VERIFIED -> "Verified"
|
||||
IdentityTrust.PINNED -> "Keys unchanged since you met"
|
||||
else -> "No keys seen yet"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
modifier = Modifier.weight(1f).testTag("trust-${chat.trust.name.lowercase()}"),
|
||||
)
|
||||
if (chat.safetyNumber != null) {
|
||||
|
||||
@@ -93,12 +93,21 @@ fun ColumnScope.ContactDetail(
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
initials,
|
||||
fontSize = 64.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
// Their picture if they have sent us one; their initials if
|
||||
// not. ⚠️ Initials are the normal case, not a placeholder — a
|
||||
// picture only exists once somebody has set one AND messaged
|
||||
// you, so most covers will be letters and that is fine.
|
||||
val picture = phase.avatars[buddy.userId]
|
||||
if (picture != null) {
|
||||
AttachedImage(picture, model)
|
||||
} else {
|
||||
Text(
|
||||
initials,
|
||||
fontSize = 64.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Name plate: 60 tall, radius 16, on the kit's neutral fill.
|
||||
|
||||
@@ -87,6 +87,17 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val groups: List<io.flamenet.client.RelayClient.Group> = emptyList(),
|
||||
val blocked: Set<Long> = emptySet(),
|
||||
val notifications: List<MessengerApi.Notification> = emptyList(),
|
||||
|
||||
/**
|
||||
* Profile pictures we hold, by user id, plus our own.
|
||||
*
|
||||
* 🔴 **References, not bytes.** An avatar is an encrypted blob on
|
||||
* the relay; these say where it is and how to open it. The bytes
|
||||
* go through the same cache every other attachment uses, so a
|
||||
* contact list does not hold a decrypted face per row.
|
||||
*/
|
||||
val avatars: Map<Long, io.flamenet.e2e.Attachment.Ref> = emptyMap(),
|
||||
val myAvatar: io.flamenet.e2e.Attachment.Ref? = null,
|
||||
/** Live stories, newest first (§6.6). */
|
||||
val stories: List<Vault.Story> = emptyList(),
|
||||
/** The story being viewed full-screen, if any. */
|
||||
@@ -275,6 +286,10 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
status = "Signed in on this device.",
|
||||
buddies = fetchedBuddies.getOrDefault(emptyList()),
|
||||
buddiesLoaded = fetchedBuddies.isSuccess,
|
||||
avatars = session?.avatars(
|
||||
fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId }
|
||||
) ?: emptyMap(),
|
||||
myAvatar = session?.myAvatar(),
|
||||
groups = fetchedGroups.getOrDefault(emptyList()),
|
||||
groupsLoaded = fetchedGroups.isSuccess,
|
||||
)
|
||||
@@ -422,6 +437,10 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
status = "Registered with ${preKeys.size} one-time prekeys published.",
|
||||
buddies = fetchedBuddies.getOrDefault(emptyList()),
|
||||
buddiesLoaded = fetchedBuddies.isSuccess,
|
||||
avatars = session?.avatars(
|
||||
fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId }
|
||||
) ?: emptyMap(),
|
||||
myAvatar = session?.myAvatar(),
|
||||
groups = fetchedGroups.getOrDefault(emptyList()),
|
||||
groupsLoaded = fetchedGroups.isSuccess,
|
||||
)
|
||||
@@ -472,6 +491,21 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
fun openChat(peerId: Long, peerName: String) {
|
||||
val current = session ?: return
|
||||
val thread = Vault.threadForUser(peerId)
|
||||
|
||||
// 🔴 **Hand them our picture here, not when it was chosen.** An avatar
|
||||
// reaches a contact inside the ratchet, so somebody added AFTER it was
|
||||
// set would never have been told — they would see initials for ever
|
||||
// and there would be nothing on any screen to explain why. Opening a
|
||||
// conversation is the first moment there is certainly a session to
|
||||
// send down.
|
||||
//
|
||||
// ⚠️ Off the main thread and failure-tolerant: this is a courtesy, and
|
||||
// it must never be the reason a chat does not open.
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching { current.shareAvatarIfNeeded(peerId) }
|
||||
}
|
||||
}
|
||||
update {
|
||||
it.copy(
|
||||
openChat = Phase.Chat(
|
||||
@@ -783,6 +817,45 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
// -- Attachments ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Set a profile picture and tell every contact.
|
||||
*
|
||||
* ⚠️ Reports partial delivery rather than swallowing it. "Sent to 3 of 5"
|
||||
* is the truth when two contacts have a changed identity key, and silence
|
||||
* would let somebody believe a picture had gone out when it had not.
|
||||
*/
|
||||
fun setAvatar(bytes: ByteArray, mime: String, width: Int, height: Int) =
|
||||
viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
val ids = signedIn()?.buddies?.map { it.userId } ?: emptyList()
|
||||
update { it.copy(busy = true, error = null) }
|
||||
val done = withContext(Dispatchers.IO) {
|
||||
runCatching { current.setAvatar(bytes, mime, width, height, ids) }
|
||||
}
|
||||
done.fold(
|
||||
onSuccess = { posted ->
|
||||
update {
|
||||
it.copy(
|
||||
busy = false,
|
||||
myAvatar = posted.ref,
|
||||
error = if (posted.missed > 0) {
|
||||
"Picture saved, but ${posted.missed} of ${ids.size} contacts " +
|
||||
"could not be told — check their keys."
|
||||
} else null,
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = { failure -> update { it.copy(busy = false, error = describe(failure)) } },
|
||||
)
|
||||
}
|
||||
|
||||
/** Drop our picture. Contacts keep the last one they were told about. */
|
||||
fun clearAvatar() = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
withContext(Dispatchers.IO) { runCatching { current.vault.saveMyAvatar(null) } }
|
||||
update { it.copy(myAvatar = null) }
|
||||
}
|
||||
|
||||
fun sendImage(bytes: ByteArray, mime: String, width: Int, height: Int) =
|
||||
viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
|
||||
@@ -248,6 +248,20 @@ class Session(
|
||||
* Recorded locally from the reference we just built, so the author's own
|
||||
* strip shows it without fetching their own image back from the relay.
|
||||
*/
|
||||
/** Publish a profile picture and tell every contact where it is. */
|
||||
fun setAvatar(bytes: ByteArray, mime: String, width: Int, height: Int, toUserIds: List<Long>) =
|
||||
conversations.setAvatar(bytes, mime, width, height, toUserIds)
|
||||
|
||||
fun myAvatar() = conversations.myAvatar()
|
||||
|
||||
fun avatarFor(userId: Long) = conversations.avatarFor(userId)
|
||||
|
||||
/** Every picture we hold, for the UI to render without asking one by one. */
|
||||
fun avatars(userIds: List<Long>): Map<Long, io.flamenet.e2e.Attachment.Ref> =
|
||||
userIds.mapNotNull { id -> conversations.avatarFor(id)?.let { id to it } }.toMap()
|
||||
|
||||
fun shareAvatarIfNeeded(toUserId: Long) = conversations.shareAvatarIfNeeded(toUserId)
|
||||
|
||||
fun postStory(
|
||||
bytes: ByteArray,
|
||||
mime: String,
|
||||
|
||||
Reference in New Issue
Block a user