diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index 01f1d84..deec052 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -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. * diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 3ea4cf8..8382cf6 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -97,6 +97,9 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { * contact list does not hold a decrypted face per row. */ val avatars: Map = emptyMap(), + + /** Last line per thread, so a row can say more than a name. */ + val previews: Map = emptyMap(), val myAvatar: io.flamenet.e2e.Attachment.Ref? = null, /** Live stories, newest first (§6.6). */ val stories: List = 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, diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index 7cae7ca..4fa3f78 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -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): Map = + 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): Map = userIds.mapNotNull { id -> conversations.avatarFor(id)?.let { id to it } }.toMap() diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt index 80b61b9..7279b79 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt @@ -202,6 +202,18 @@ class ScreenshotTest { ), buddiesLoaded = 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( io.flamenet.e2e.Vault.Story( id = "s1", authorId = 33, mine = false,