An input is the one surface a person types into and reads back character by character, and a dark field on a dark ground gave it the least contrast on the screen at the moment it mattered most. Typed text reads 18.3:1 on white; the placeholder 4.6:1, because a placeholder is still text. ⚠️ **Its own colour, not `SurfaceAlt`.** That fill is shared with incoming bubbles and the tab pill, which stay dark — a white message bubble on this ground would read as somebody else's app. 🔴 **Every colour a field draws has to be named, not just the container.** Material derives label, placeholder, cursor and outline from the colour scheme, which is the DARK scheme here: setting only `containerColor` leaves a light-on-dark placeholder on a white box, and it simply disappears. iOS had the same failure in a different dialect — SwiftUI drew the placeholder and caret for a dark background until the field was given `.environment(\.colorScheme, .light)`. Two frameworks, one mistake: a white island in a dark app has to be told it is an island. One `fieldColors()` in `Theme.kt`, applied to all 34 `OutlinedTextField` call sites. Mechanical and wide rather than a new wrapper composable, because a wrapper would have to be adopted one screen at a time and the ones that were missed would be exactly the ones nobody looks at. ⚠️ The message composer is a `BasicTextField` and stays dark. It is a pill in the chat, not a form field, and turning it white would put a white bar across the bottom of every conversation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
353 lines
13 KiB
Kotlin
353 lines
13 KiB
Kotlin
package io.flamenet.messenger.app
|
|
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.clickable
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.ColumnScope
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.Spacer
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.layout.width
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.size
|
|
import androidx.compose.foundation.shape.CircleShape
|
|
import androidx.compose.material3.Divider
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.OutlinedTextField
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.draw.clip
|
|
import androidx.compose.ui.platform.testTag
|
|
import androidx.compose.ui.text.font.FontWeight
|
|
import androidx.compose.ui.text.style.TextOverflow
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.unit.sp
|
|
import io.flamenet.e2e.Vault
|
|
|
|
/**
|
|
* The kit's `07 Messages-Listing` as its own tab: large title, search box,
|
|
* the Recent circles row, then a flat list of conversations.
|
|
*
|
|
* 🔴 **Conversations, not contacts.** Home lists everybody and manages the
|
|
* roster; this screen is the inbox — people you talk to, unread first. Same
|
|
* split iOS ships, so the two apps stay one product.
|
|
*/
|
|
@Composable
|
|
fun ColumnScope.MessagesScreen(
|
|
model: MessengerViewModel,
|
|
phase: MessengerViewModel.Phase.SignedIn,
|
|
) {
|
|
val query = remember { mutableStateOf("") }
|
|
val creatingGroup = remember { mutableStateOf(false) }
|
|
var addingContact by remember { mutableStateOf(false) }
|
|
var scanning by remember { mutableStateOf(false) }
|
|
var scannedPrefill by remember { mutableStateOf("") }
|
|
val scanContext = androidx.compose.ui.platform.LocalContext.current
|
|
val askCamera = androidx.activity.compose.rememberLauncherForActivityResult(
|
|
androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
|
|
) { _ ->
|
|
// Open either way — the dialog explains itself when access is refused.
|
|
scanning = true
|
|
}
|
|
|
|
// Starting a group sits on the Groups heading and a chat on the Chats
|
|
// heading; the bar keeps the two ways a NEW person gets in — scan their
|
|
// code, or add them by name — the same pair the Home bar carries.
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
) {
|
|
Text(
|
|
"Messages",
|
|
fontSize = 24.sp,
|
|
fontWeight = FontWeight.Bold,
|
|
modifier = Modifier.weight(1f),
|
|
)
|
|
androidx.compose.material3.Icon(
|
|
KitIcon.Scan,
|
|
contentDescription = "Scan an address",
|
|
tint = Palette.Accent,
|
|
modifier = Modifier
|
|
.size(24.dp)
|
|
.clickable {
|
|
val has = androidx.core.content.ContextCompat.checkSelfPermission(
|
|
scanContext, android.Manifest.permission.CAMERA
|
|
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
|
if (has) scanning = true
|
|
else askCamera.launch(android.Manifest.permission.CAMERA)
|
|
}
|
|
.testTag("messages-scan"),
|
|
)
|
|
Spacer(Modifier.width(Space.md))
|
|
androidx.compose.material3.Icon(
|
|
if (addingContact) KitIcon.Close else KitIcon.Add,
|
|
contentDescription = "Add contact",
|
|
tint = Palette.Accent,
|
|
modifier = Modifier
|
|
.size(26.dp)
|
|
.clickable { addingContact = !addingContact }
|
|
.testTag("messages-add-contact"),
|
|
)
|
|
}
|
|
|
|
if (scanning) {
|
|
ScanAddressDialog(
|
|
onFound = { raw ->
|
|
scanning = false
|
|
parseScannedAddress(raw)?.let { card ->
|
|
scannedPrefill = if (card.handle != null &&
|
|
card.relay.equals(phase.relay, ignoreCase = true)
|
|
) card.handle else "${card.fnid}@${card.relay}"
|
|
addingContact = true
|
|
}
|
|
},
|
|
onDismiss = { scanning = false },
|
|
)
|
|
}
|
|
|
|
if (addingContact) {
|
|
AddContactForm(model, phase, initial = scannedPrefill) {
|
|
addingContact = false
|
|
scannedPrefill = ""
|
|
}
|
|
}
|
|
Spacer(Modifier.height(Space.sm))
|
|
|
|
OutlinedTextField(
|
|
colors = fieldColors(),
|
|
value = query.value,
|
|
onValueChange = { query.value = it },
|
|
placeholder = { Text("Search conversations") },
|
|
singleLine = true,
|
|
modifier = Modifier.fillMaxWidth().testTag("messages-search"),
|
|
)
|
|
Spacer(Modifier.height(Space.md))
|
|
|
|
// ⚠️ Above the conversation list. Somebody searching for a word they
|
|
// remember is looking for the message, not the person — name matches are
|
|
// already covered by the list underneath.
|
|
MessageHits(model, query.value)
|
|
|
|
BuddyCirclesRow(model, phase)
|
|
Spacer(Modifier.height(Space.sm))
|
|
Divider()
|
|
Spacer(Modifier.height(Space.sm))
|
|
|
|
// The kit's listing holds every conversation — groups included.
|
|
// ⚠️ No label of our own above the groups: GroupsSection draws its own
|
|
// (and only when there is something to head). A second one here printed
|
|
// "GROUPS" twice.
|
|
GroupsSection(model, phase, query.value, creatingGroup.value) { creatingGroup.value = it }
|
|
Spacer(Modifier.height(Space.sm))
|
|
val showNewChat = remember { mutableStateOf(false) }
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
) {
|
|
Text(
|
|
"CHATS",
|
|
fontSize = 12.sp,
|
|
fontWeight = FontWeight.Medium,
|
|
color = Palette.SecondaryText,
|
|
letterSpacing = androidx.compose.ui.unit.TextUnit(
|
|
0.8f, androidx.compose.ui.unit.TextUnitType.Sp),
|
|
modifier = Modifier.weight(1f),
|
|
)
|
|
// The way in, beside the label — exactly as New group sits beside its
|
|
// heading in the bar above.
|
|
androidx.compose.material3.Icon(
|
|
KitIcon.NewChat,
|
|
contentDescription = "New chat",
|
|
tint = Palette.Accent,
|
|
modifier = Modifier
|
|
.size(22.dp)
|
|
.clickable { showNewChat.value = true }
|
|
.testTag("new-chat"),
|
|
)
|
|
}
|
|
if (showNewChat.value) {
|
|
androidx.compose.material3.AlertDialog(
|
|
onDismissRequest = { showNewChat.value = false },
|
|
title = { Text("New chat") },
|
|
text = {
|
|
if (phase.buddies.isEmpty()) {
|
|
Text("Add a contact first — on Home, with their username or address.")
|
|
} else {
|
|
Column {
|
|
phase.buddies.forEach { buddy ->
|
|
val rowName = buddy.displayName.ifBlank { buddy.handle }
|
|
.ifBlank { "user ${buddy.userId}" }
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.clickable {
|
|
showNewChat.value = false
|
|
model.openChat(buddy.userId, rowName)
|
|
}
|
|
.padding(vertical = 10.dp)
|
|
.testTag("new-chat-${buddy.userId}"),
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
) {
|
|
Avatar(
|
|
name = rowName,
|
|
online = buddy.presence == "online",
|
|
picture = phase.avatars[buddy.userId],
|
|
model = model,
|
|
size = 36.dp,
|
|
)
|
|
Spacer(Modifier.width(Space.sm))
|
|
Text(
|
|
rowName,
|
|
modifier = Modifier.weight(1f),
|
|
maxLines = 1,
|
|
overflow = TextOverflow.Ellipsis,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
confirmButton = {
|
|
androidx.compose.material3.TextButton(
|
|
onClick = { showNewChat.value = false }) { Text("Cancel") }
|
|
},
|
|
)
|
|
}
|
|
|
|
val shown = phase.buddies
|
|
.filter {
|
|
query.value.isBlank() ||
|
|
it.displayName.contains(query.value, true) ||
|
|
it.handle.contains(query.value, true)
|
|
}
|
|
.sortedWith(
|
|
compareByDescending<io.flamenet.client.MessengerApi.Buddy> {
|
|
Vault.threadForUser(it.userId) in phase.unread
|
|
}.thenBy { it.displayName.ifBlank { it.handle } }
|
|
)
|
|
|
|
if (shown.isEmpty()) {
|
|
Text(
|
|
if (phase.buddies.isEmpty()) {
|
|
"No conversations yet. Add someone on Home — everyone you add is a tap away."
|
|
} else {
|
|
"Nothing matches that search."
|
|
},
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
modifier = Modifier.padding(vertical = Space.lg),
|
|
)
|
|
return
|
|
}
|
|
|
|
Column(modifier = Modifier.fillMaxWidth().testTag("conversations")) {
|
|
shown.forEach { buddy ->
|
|
val name = buddy.displayName.ifBlank { buddy.handle }
|
|
.ifBlank { "user ${buddy.userId}" }
|
|
val unread = Vault.threadForUser(buddy.userId) in phase.unread
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.clickable { model.openChat(buddy.userId, name) }
|
|
.padding(vertical = Kit.rowGap / 2)
|
|
.testTag("conversation-${buddy.userId}"),
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.spacedBy(Kit.gutter),
|
|
) {
|
|
Avatar(
|
|
name = name,
|
|
online = buddy.presence == "online",
|
|
picture = phase.avatars[buddy.userId],
|
|
model = model,
|
|
)
|
|
Column(modifier = Modifier.weight(1f)) {
|
|
Text(
|
|
name,
|
|
fontSize = 16.sp,
|
|
fontWeight = if (unread) FontWeight.SemiBold else FontWeight.Medium,
|
|
maxLines = 1,
|
|
overflow = TextOverflow.Ellipsis,
|
|
)
|
|
Text(
|
|
if (unread) "New messages" else buddy.presence,
|
|
fontSize = 12.sp,
|
|
color = if (unread) MaterialTheme.colorScheme.primary
|
|
else Palette.SecondaryText,
|
|
)
|
|
}
|
|
if (unread) UnreadDot()
|
|
}
|
|
Divider(modifier = Modifier.padding(start = 64.dp))
|
|
}
|
|
}
|
|
}
|
|
|
|
/** The kit's unread marker: a small filled circle in the row's trailing slot. */
|
|
@Composable
|
|
private fun UnreadDot() {
|
|
Box(
|
|
modifier = Modifier
|
|
.size(10.dp)
|
|
.clip(CircleShape)
|
|
.background(MaterialTheme.colorScheme.primary),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* What a search found inside conversations, rather than in their names.
|
|
*
|
|
* ⚠️ Debounced. Each keystroke would otherwise decrypt every thread on the
|
|
* device — the cost the iPhone app does not pay, because it keeps the whole
|
|
* history in memory.
|
|
*/
|
|
@Composable
|
|
private fun MessageHits(model: MessengerViewModel, query: String) {
|
|
var hits by remember { mutableStateOf<List<io.flamenet.e2e.MessageSearch.Hit>>(emptyList()) }
|
|
|
|
LaunchedEffect(query) {
|
|
if (query.trim().length < io.flamenet.e2e.MessageSearch.MINIMUM_QUERY) {
|
|
hits = emptyList()
|
|
return@LaunchedEffect
|
|
}
|
|
kotlinx.coroutines.delay(250)
|
|
hits = model.searchMessages(query)
|
|
}
|
|
|
|
if (hits.isEmpty()) return
|
|
|
|
Text(
|
|
"IN MESSAGES",
|
|
fontSize = 12.sp,
|
|
fontWeight = FontWeight.Medium,
|
|
color = Palette.SecondaryText,
|
|
letterSpacing = 0.8.sp,
|
|
modifier = Modifier.padding(top = Space.md, bottom = Space.xs),
|
|
)
|
|
for (hit in hits) {
|
|
Column(
|
|
Modifier.fillMaxWidth()
|
|
.padding(horizontal = Space.md, vertical = Space.sm)
|
|
.testTag("search-hit")
|
|
) {
|
|
Text(
|
|
hit.snippet,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
maxLines = 2,
|
|
overflow = TextOverflow.Ellipsis,
|
|
)
|
|
}
|
|
}
|
|
Spacer(Modifier.height(Space.sm))
|
|
Divider()
|
|
Spacer(Modifier.height(Space.sm))
|
|
}
|