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:
co-authored by
Claude Opus 5
parent
682d691a04
commit
5db9f8f7af
@@ -61,6 +61,7 @@ import androidx.compose.ui.semantics.contentDescription
|
|||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
@@ -100,10 +101,12 @@ fun ColumnScope.BuddyList(
|
|||||||
) {
|
) {
|
||||||
var username by remember { mutableStateOf("") }
|
var username by remember { mutableStateOf("") }
|
||||||
|
|
||||||
// ⚠️ A quiet label, not a heading. The kit's messages list has no section
|
// ⚠️ **Only when there is something to tell it apart from.** The kit's
|
||||||
// headings; ours holds two kinds of thing and would be a guessing game
|
// messages list has no section headings at all; ours holds two kinds of
|
||||||
// without them, so they are as small as they can be and still do the job.
|
// thing, so a label earns its place exactly when both kinds are on screen.
|
||||||
SectionLabel("Contacts")
|
// 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) {
|
if (adding) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
@@ -233,39 +236,75 @@ fun ColumnScope.BuddyList(
|
|||||||
picture = phase.avatars[buddy.userId],
|
picture = phase.avatars[buddy.userId],
|
||||||
model = model,
|
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)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
name,
|
name,
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = if (io.flamenet.e2e.Vault.threadForUser(buddy.userId) in phase.unread)
|
fontWeight = if (unread) FontWeight.Bold else FontWeight.Medium,
|
||||||
FontWeight.Bold else FontWeight.Medium,
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
// ⚠️ The handle is dropped when it is the same string as
|
// 🔴 **The last message, which the kit's row leads with.**
|
||||||
// the display name, which is the common case — it rendered
|
// This used to be presence — "online" — because the relay
|
||||||
// as "fnreviewbeta / fnreviewbeta · online", the name twice
|
// sends no preview. It does not have to: the history is
|
||||||
// and a status. The kit's second line is the last message;
|
// already decrypted on this device. Presence has not gone,
|
||||||
// until the relay gives us one, presence alone is honest
|
// it moved to the dot on the avatar, which is where the kit
|
||||||
// and the handle only earns its place when it differs.
|
// 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(
|
Text(
|
||||||
listOfNotNull(
|
preview?.let {
|
||||||
buddy.handle.takeIf {
|
if (it.mine) "You: ${it.text}" else it.text
|
||||||
it.isNotBlank() && !it.equals(name, ignoreCase = true)
|
}?.takeIf { it.isNotBlank() }
|
||||||
},
|
?: listOfNotNull(
|
||||||
buddy.presence.takeIf { it.isNotBlank() },
|
buddy.handle.takeIf { h ->
|
||||||
).joinToString(" · "),
|
h.isNotBlank() && !h.equals(name, ignoreCase = true)
|
||||||
|
},
|
||||||
|
buddy.presence.takeIf { p -> p.isNotBlank() },
|
||||||
|
).joinToString(" · "),
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (io.flamenet.e2e.Vault.threadForUser(buddy.userId) in phase.unread) {
|
// 🔴 **A right-hand column.** The kit puts the time at the top
|
||||||
Box(
|
// of it and the unread count under, and that column is most of
|
||||||
modifier = Modifier
|
// what stops a row looking like a heading with nothing beside
|
||||||
.size(10.dp)
|
// it. Ours had a bare 10dp dot and then an overflow button.
|
||||||
.clip(CircleShape)
|
Column(
|
||||||
.background(MaterialTheme.colorScheme.primary)
|
horizontalAlignment = Alignment.End,
|
||||||
.testTag("unread-${buddy.userId}")
|
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
|
// Everything you can do to a contact, reachable without a
|
||||||
// long-press nobody discovers.
|
// 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.
|
* 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.
|
* contact list does not hold a decrypted face per row.
|
||||||
*/
|
*/
|
||||||
val avatars: Map<Long, io.flamenet.e2e.Attachment.Ref> = emptyMap(),
|
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,
|
val myAvatar: io.flamenet.e2e.Attachment.Ref? = null,
|
||||||
/** Live stories, newest first (§6.6). */
|
/** Live stories, newest first (§6.6). */
|
||||||
val stories: List<Vault.Story> = emptyList(),
|
val stories: List<Vault.Story> = emptyList(),
|
||||||
@@ -289,6 +292,9 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
avatars = session?.avatars(
|
avatars = session?.avatars(
|
||||||
fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId }
|
fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId }
|
||||||
) ?: emptyMap(),
|
) ?: emptyMap(),
|
||||||
|
previews = session?.previews(
|
||||||
|
fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId }
|
||||||
|
) ?: emptyMap(),
|
||||||
myAvatar = session?.myAvatar(),
|
myAvatar = session?.myAvatar(),
|
||||||
groups = fetchedGroups.getOrDefault(emptyList()),
|
groups = fetchedGroups.getOrDefault(emptyList()),
|
||||||
groupsLoaded = fetchedGroups.isSuccess,
|
groupsLoaded = fetchedGroups.isSuccess,
|
||||||
@@ -440,6 +446,9 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
avatars = session?.avatars(
|
avatars = session?.avatars(
|
||||||
fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId }
|
fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId }
|
||||||
) ?: emptyMap(),
|
) ?: emptyMap(),
|
||||||
|
previews = session?.previews(
|
||||||
|
fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId }
|
||||||
|
) ?: emptyMap(),
|
||||||
myAvatar = session?.myAvatar(),
|
myAvatar = session?.myAvatar(),
|
||||||
groups = fetchedGroups.getOrDefault(emptyList()),
|
groups = fetchedGroups.getOrDefault(emptyList()),
|
||||||
groupsLoaded = fetchedGroups.isSuccess,
|
groupsLoaded = fetchedGroups.isSuccess,
|
||||||
|
|||||||
@@ -256,6 +256,38 @@ class Session(
|
|||||||
|
|
||||||
fun avatarFor(userId: Long) = conversations.avatarFor(userId)
|
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. */
|
/** 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> =
|
fun avatars(userIds: List<Long>): Map<Long, io.flamenet.e2e.Attachment.Ref> =
|
||||||
userIds.mapNotNull { id -> conversations.avatarFor(id)?.let { id to it } }.toMap()
|
userIds.mapNotNull { id -> conversations.avatarFor(id)?.let { id to it } }.toMap()
|
||||||
|
|||||||
@@ -202,6 +202,18 @@ class ScreenshotTest {
|
|||||||
),
|
),
|
||||||
buddiesLoaded = true,
|
buddiesLoaded = true,
|
||||||
groupsLoaded = true,
|
groupsLoaded = true,
|
||||||
|
// The row's right-hand column and second line only exist when there is
|
||||||
|
// a thread behind them, so the render has to have one or it photographs
|
||||||
|
// the empty state and looks like the change never landed.
|
||||||
|
previews = mapOf(
|
||||||
|
32L to Session.Preview(
|
||||||
|
text = "Sure! Please give me the campaign details soon",
|
||||||
|
at = System.currentTimeMillis() - 25 * 60_000L, mine = false),
|
||||||
|
33L to Session.Preview(
|
||||||
|
text = "okay let me see",
|
||||||
|
at = System.currentTimeMillis() - 26 * 3600_000L, mine = true),
|
||||||
|
),
|
||||||
|
unread = setOf(io.flamenet.e2e.Vault.threadForUser(32)),
|
||||||
stories = listOf(
|
stories = listOf(
|
||||||
io.flamenet.e2e.Vault.Story(
|
io.flamenet.e2e.Vault.Story(
|
||||||
id = "s1", authorId = 33, mine = false,
|
id = "s1", authorId = 33, mine = false,
|
||||||
|
|||||||
Reference in New Issue
Block a user