Draw the author's picture beside a received bubble

Reported: profile pictures do not appear in the chat boxes. They did not.

iOS had an avatar slot in MessageBubble that hand-rolled only the initials
fallback - it never had the user id or the inbox, so it could not reach the
picture that AvatarView already downloads and decrypts everywhere else.
Android reserved the kit's 40dp received inset for an avatar and drew nothing
in it at all.

Both now pass the author and the picture through. Sent bubbles get none: you
know who you are, and a picture on both sides halves the width a thread has.

Also gives the iOS list row a real last-message preview and a timestamp,
matching the Android row - it was showing a screen name and the word 'Online',
a second copy of the dot already on the avatar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-27 17:40:40 -04:00
co-authored by Claude Opus 5
parent 5db9f8f7af
commit 0983e760b4
2 changed files with 40 additions and 3 deletions
@@ -1122,6 +1122,8 @@ fun ColumnScope.ChatScreen(
error: String?,
/** Needed to name group members; the relay stores ids and no names. */
buddies: List<io.flamenet.client.MessengerApi.Buddy> = emptyList(),
/** Pictures, so a received bubble can carry its author's. */
avatars: Map<Long, io.flamenet.e2e.Attachment.Ref> = emptyMap(),
) {
var draft by remember { mutableStateOf("") }
val listState = rememberLazyListState()
@@ -1200,7 +1202,17 @@ fun ColumnScope.ChatScreen(
modifier = Modifier.fillMaxWidth().weight(1f).testTag("messages"),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
items(chat.lines) { line -> Bubble(line, model) }
items(chat.lines) { line ->
Bubble(
line = line,
model = model,
authorName = chat.title,
// 🔴 Only for a message you did not send. Your own bubble has
// no avatar in the kit — you know who you are, and a picture on
// both sides halves the width a thread has to say anything in.
picture = chat.peerId?.takeIf { !line.mine }?.let { avatars[it] },
)
}
}
error?.let {
@@ -1358,7 +1370,12 @@ private fun ColumnScope.VoiceComposer(
}
@Composable
private fun Bubble(line: Session.Line, model: MessengerViewModel) {
private fun Bubble(
line: Session.Line,
model: MessengerViewModel,
authorName: String = "",
picture: io.flamenet.e2e.Attachment.Ref? = null,
) {
val time = remember(line.at) {
SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(line.at))
}
@@ -1374,8 +1391,28 @@ private fun Bubble(line: Session.Line, model: MessengerViewModel) {
start = if (line.mine) Kit.receivedInset else 0.dp,
end = if (line.mine) 0.dp else Kit.receivedInset,
),
// Top, so the avatar sits level with the first line of its bubble.
verticalAlignment = Alignment.Top,
horizontalArrangement = if (line.mine) Arrangement.End else Arrangement.Start,
) {
// 🔴 **The avatar the inset was already reserving space for.** The
// received side has been padded by 40 — a 32dp avatar plus its gutter —
// since the bubbles were measured out of the kit, and nothing was ever
// drawn in it. The picture was on the contact row and the contact screen
// and missing from the one screen you spend all your time on.
//
// ⚠️ Top-aligned. Centred, it drifts down the side of a long message and
// reads as belonging to whatever comes next.
if (!line.mine) {
Avatar(
name = authorName,
online = false,
picture = picture,
model = model,
size = Kit.bubbleAvatar,
)
Spacer(Modifier.width(Space.sm))
}
Column(horizontalAlignment = if (line.mine) Alignment.End else Alignment.Start) {
Text(
time,
@@ -147,7 +147,7 @@ private fun Root(model: MessengerViewModel = viewModel()) {
val chat = current.openChat
if (chat != null) {
ChatScreen(model, chat, current.error, current.buddies)
ChatScreen(model, chat, current.error, current.buddies, current.avatars)
} else {
// 🔴 **The content area is weighted, and the tab bar is
// not.** These used to be plain siblings, so the tab bar