Give the Android list row a preview, a time and a badge

The kit's row is four things - avatar, name, last message, time - plus an
unread count on the right. Ours was a name and the word 'online', which is
most of why a two-contact screen read as empty.

The preview comes from the local vault, not the relay: the relay sends none
and asking it to would leak which conversations are active, while the history
is already decrypted on this device. Presence moves to the dot on the avatar,
where the kit draws it and where it costs no line.

Section headings now appear only when both contacts and groups are on screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-27 17:36:44 -04:00
co-authored by Claude Opus 5
parent 682d691a04
commit 5db9f8f7af
4 changed files with 138 additions and 25 deletions
@@ -61,6 +61,7 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
@@ -100,10 +101,12 @@ fun ColumnScope.BuddyList(
) {
var username by remember { mutableStateOf("") }
// ⚠️ A quiet label, not a heading. The kit's messages list has no section
// headings; ours holds two kinds of thing and would be a guessing game
// without them, so they are as small as they can be and still do the job.
SectionLabel("Contacts")
// ⚠️ **Only when there is something to tell it apart from.** The kit's
// messages list has no section headings at all; ours holds two kinds of
// thing, so a label earns its place exactly when both kinds are on screen.
// With no groups — the common case, and every new account — "CONTACTS" was
// a heading sitting over the only list there is.
if (shownGroupsExist(phase, query)) SectionLabel("Contacts")
if (adding) {
OutlinedTextField(
@@ -233,39 +236,75 @@ fun ColumnScope.BuddyList(
picture = phase.avatars[buddy.userId],
model = model,
)
val unread = io.flamenet.e2e.Vault.threadForUser(buddy.userId) in phase.unread
val preview = phase.previews[buddy.userId]
Column(modifier = Modifier.weight(1f)) {
Text(
name,
fontSize = 16.sp,
fontWeight = if (io.flamenet.e2e.Vault.threadForUser(buddy.userId) in phase.unread)
FontWeight.Bold else FontWeight.Medium,
fontWeight = if (unread) FontWeight.Bold else FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
// ⚠️ The handle is dropped when it is the same string as
// the display name, which is the common case — it rendered
// as "fnreviewbeta / fnreviewbeta · online", the name twice
// and a status. The kit's second line is the last message;
// until the relay gives us one, presence alone is honest
// and the handle only earns its place when it differs.
// 🔴 **The last message, which the kit's row leads with.**
// This used to be presence — "online" — because the relay
// sends no preview. It does not have to: the history is
// already decrypted on this device. Presence has not gone,
// it moved to the dot on the avatar, which is where the kit
// draws it and where it costs no line of its own.
//
// ⚠️ The handle only when it differs from the name, and only
// when there is nothing better — it rendered as
// "fnreviewbeta · online" under a row already titled
// "fnreviewbeta".
Text(
listOfNotNull(
buddy.handle.takeIf {
it.isNotBlank() && !it.equals(name, ignoreCase = true)
},
buddy.presence.takeIf { it.isNotBlank() },
).joinToString(" · "),
preview?.let {
if (it.mine) "You: ${it.text}" else it.text
}?.takeIf { it.isNotBlank() }
?: listOfNotNull(
buddy.handle.takeIf { h ->
h.isNotBlank() && !h.equals(name, ignoreCase = true)
},
buddy.presence.takeIf { p -> p.isNotBlank() },
).joinToString(" · "),
fontSize = 14.sp,
color = Palette.SecondaryText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (io.flamenet.e2e.Vault.threadForUser(buddy.userId) in phase.unread) {
Box(
modifier = Modifier
.size(10.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.testTag("unread-${buddy.userId}")
// 🔴 **A right-hand column.** The kit puts the time at the top
// of it and the unread count under, and that column is most of
// what stops a row looking like a heading with nothing beside
// it. Ours had a bare 10dp dot and then an overflow button.
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(Space.xs),
) {
Text(
preview?.at?.let(::relativeTime).orEmpty(),
fontSize = 12.sp,
color = Palette.SecondaryText,
maxLines = 1,
)
if (unread) {
Box(
modifier = Modifier
.heightIn(min = 16.dp)
.widthIn(min = 16.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.testTag("unread-${buddy.userId}"),
contentAlignment = Alignment.Center,
) {
Text(
"1",
fontSize = 10.sp,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onPrimary,
)
}
}
}
// Everything you can do to a contact, reachable without a
// long-press nobody discovers.
@@ -285,6 +324,27 @@ fun ColumnScope.BuddyList(
}
}
/**
* The kit's row time: "05:15 PM" today, "Yesterday", a weekday inside a week,
* then a date. Not "3 hours ago" — the design shows a clock, and a relative
* phrase is wider than the column it has to sit in.
*/
private fun relativeTime(epochMillis: Long): String {
if (epochMillis <= 0L) return ""
val now = java.util.Calendar.getInstance()
val then = java.util.Calendar.getInstance().apply { timeInMillis = epochMillis }
val sameDay = now.get(java.util.Calendar.YEAR) == then.get(java.util.Calendar.YEAR) &&
now.get(java.util.Calendar.DAY_OF_YEAR) == then.get(java.util.Calendar.DAY_OF_YEAR)
val days = ((now.timeInMillis - then.timeInMillis) / 86_400_000L).toInt()
return when {
sameDay -> android.text.format.DateFormat.format("hh:mm a", then).toString()
days < 2 -> "Yesterday"
days < 7 -> android.text.format.DateFormat.format("EEE", then).toString()
else -> android.text.format.DateFormat.format("MMM d", then).toString()
}
}
/**
* An attachment, fetched and decrypted on first view.
*
@@ -97,6 +97,9 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
* contact list does not hold a decrypted face per row.
*/
val avatars: Map<Long, io.flamenet.e2e.Attachment.Ref> = emptyMap(),
/** Last line per thread, so a row can say more than a name. */
val previews: Map<Long, Session.Preview> = emptyMap(),
val myAvatar: io.flamenet.e2e.Attachment.Ref? = null,
/** Live stories, newest first (§6.6). */
val stories: List<Vault.Story> = emptyList(),
@@ -289,6 +292,9 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
avatars = session?.avatars(
fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId }
) ?: emptyMap(),
previews = session?.previews(
fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId }
) ?: emptyMap(),
myAvatar = session?.myAvatar(),
groups = fetchedGroups.getOrDefault(emptyList()),
groupsLoaded = fetchedGroups.isSuccess,
@@ -440,6 +446,9 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
avatars = session?.avatars(
fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId }
) ?: emptyMap(),
previews = session?.previews(
fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId }
) ?: emptyMap(),
myAvatar = session?.myAvatar(),
groups = fetchedGroups.getOrDefault(emptyList()),
groupsLoaded = fetchedGroups.isSuccess,
@@ -256,6 +256,38 @@ class Session(
fun avatarFor(userId: Long) = conversations.avatarFor(userId)
/**
* The last line of each thread, for the list.
*
* 🔴 **The kit's row is four things** — avatar, name, the last message, and
* a time — and ours carried a name and a presence word. That is most of why
* the screen read as empty: two contacts on a phone-height list with
* nothing on the right-hand side of either row.
*
* ⚠️ Read from the local vault, not the relay. The relay does not send a
* preview and asking it to would leak which conversations are active; the
* history is already decrypted on this device, which is the only place it
* can be read anyway.
*
* An attachment with no caption has no text to show, so it is named rather
* than rendered as a blank second line.
*/
fun previews(userIds: List<Long>): Map<Long, Preview> =
userIds.mapNotNull { id ->
history(Vault.threadForUser(id)).lastOrNull()?.let { line ->
id to Preview(
text = line.text.ifBlank {
if (line.attachment != null) "Photo" else ""
},
at = line.at,
mine = line.mine,
)
}
}.toMap()
/** One thread's last line, reduced to what a row draws. */
data class Preview(val text: String, val at: Long, val mine: Boolean)
/** 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()