Files
flamenet-messenger-android/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt
T
Paul HittandClaude Opus 4.8 83d7803f9e Save a story you posted yourself, before it expires
Android half of ios#34. A story disappears from its author's own device
too, so there was no way to keep something you made.

🔴 Only ever your own, and the rule is `Vault.isSaveable` rather than a
condition on the menu item. Saving somebody else's story keeps a copy of
something they were told would disappear — the opposite of what the
feature promises them — and a guard living in a composable is one
refactor away from being lost. Tested from both directions.

⚠️ Written through MediaStore, so no storage permission is asked for. An
app inserting its own entry needs no runtime grant on any version this
app supports; asking for WRITE_EXTERNAL_STORAGE in order to save one
picture would request read access to everything the person has, to
perform a write.

⚠️ The message says the copy has left the disappearing model and names
where it went. "Saved" alone would be true and useless — somebody not
told that may reasonably assume the expiry still covers it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:44:21 -04:00

3510 lines
147 KiB
Kotlin

package io.flamenet.messenger.app
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Divider
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
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
import io.flamenet.client.IdentityTrust
import io.flamenet.e2e.ContactCard
import io.flamenet.e2e.SafetyNumber
import io.flamenet.e2e.Vault
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlinx.coroutines.launch
/**
* The people you talk to.
*
* ⚠️ Presence is shown only where the relay actually reports it. An "online"
* dot that is really "we have not heard otherwise" is a claim about someone
* else's whereabouts, and this product should not make one it cannot support.
*/
@Composable
fun ColumnScope.BuddyList(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
query: String = "",
/**
* Whether the add-a-contact form is open.
*
* 🔴 **Hoisted out of this section on purpose.** It was a heading row with
* its own button — "Contacts" beside "Add" — and so was Groups, and the
* two of them ate the top third of a screen whose whole content was two
* rows. The kit puts actions in the app bar as icons and gives its list no
* headings at all. The state has to live up there with the button now.
*/
adding: Boolean = false,
onAddingChange: (Boolean) -> Unit = {},
/** Opens the contact screen; the list itself owns no navigation. */
onOpenProfile: (Long) -> Unit = {},
) {
// ⚠️ **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) {
AddContactForm(model, phase) { onAddingChange(false) }
}
if (phase.buddies.isEmpty()) {
// ⚠️ Only claim the list is empty if it was actually FETCHED. A failed
// load also produces an empty list, and "Nobody here yet" is then a
// statement about the person's account rather than about the request —
// which is how somebody concludes their contacts are gone.
//
// Says what to do, not merely that there is nothing. An empty state
// that only reports emptiness leaves the person stuck.
Text(
if (phase.buddiesLoaded) {
"Nobody here yet. Add someone by the username they signed up with."
} else {
"Couldn't load your contacts. Check the connection and sign in again."
},
style = MaterialTheme.typography.bodyMedium,
)
return
}
// ⚠️ heightIn, not weight(1f). Inside its card `weight` always expands to
// the full height it is offered, so a single contact still reserved a
// fixed slab of empty white. A max lets the card size to its content
// and start scrolling only once the list is actually long.
// 🔴 **Plain rows in the page's own scroll, not a bounded lazy list.**
// The list lived in a card with `heightIn(max = 320.dp)` because a lazy
// list inside a scrolling Column is measured with infinite height and
// crashes. Rendering the rows directly sidesteps that entirely — and the
// 320 cap was itself a compromise that made the list a small scrolling
// window inside a scrolling page, which is exactly what the kit's flat
// list is not.
//
// ⚠️ No recycling. Fine for a contact list of tens; if this ever holds
// hundreds it wants a single lazy list owning the whole screen's scroll,
// not a nested one.
val shown = phase.buddies.filter {
query.isBlank() ||
it.displayName.contains(query, true) || it.handle.contains(query, true)
}
// 🔴 **Conversations on other relays get their own section**, because they
// are not contacts and cannot be made into any. A remote peer has no
// account here, so nothing adds them to a roster — and without a row, a
// reply that arrives sits in a thread with no way back to it short of
// retyping a 52-character derived key.
val remote = phase.remoteAddresses.reversed().filter {
query.isBlank() || it.contains(query, true)
}
if (remote.isNotEmpty()) {
SectionLabel("Other relays")
Column(modifier = Modifier.fillMaxWidth().testTag("remote-threads")) {
remote.forEach { address ->
val unread = Vault.threadForAddress(address) in phase.unread
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { model.openAddressChat(address) }
.padding(vertical = Kit.rowGap / 2)
.testTag("remote-$address"),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Kit.gutter),
) {
// ⚠️ No presence dot and no initials. We know nothing about
// this person beyond an address — their relay does not tell
// ours whether they are online, and initials taken off a
// base32 key would be two meaningless characters dressed up
// as a name.
Avatar(name = "", online = false, picture = null, model = model)
Column(modifier = Modifier.weight(1f)) {
Text(
address.substringAfter('@'),
fontSize = 16.sp,
fontWeight = if (unread) FontWeight.Bold else FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
// The address in full underneath, monospaced. An fnid
// is a derived key, not a name — one that is nearly
// right belongs to somebody else — so it is never
// abbreviated to something that merely looks familiar.
Text(
address.substringBefore('@'),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
color = Palette.SecondaryText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
SectionLabel("Contacts")
}
Column(modifier = Modifier.fillMaxWidth().testTag("buddies")) {
shown.forEach { buddy ->
val name = buddy.displayName.ifBlank { buddy.handle }
.ifBlank { "user ${buddy.userId}" }
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { model.openChat(buddy.userId, buddy.displayName) }
// Half the kit's 24dp row gap on each side, which makes the
// 72dp pitch it draws between 48dp rows.
.padding(vertical = Kit.rowGap / 2)
.testTag("buddy-${buddy.userId}"),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Kit.gutter),
) {
// 🔴 **An avatar, which this row did not have.** The kit's list
// is built around a 48dp avatar and a 16dp gutter; without it
// the row was a coloured dot beside two lines of text, and no
// amount of adjusting the type would have made it read like
// the design. Initials rather than a photo, because the relay
// stores no avatars and inventing a placeholder face would be
// worse than the letters.
Avatar(
name = name,
online = buddy.presence.equals("online", ignoreCase = true),
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 (unread) FontWeight.Bold else FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
// 🔴 **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(
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,
)
}
// 🔴 **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.
// 🔴 Opens the contact screen. This used to unfold a row of
// three text buttons under the entry — Block, Clear chat,
// Remove — with nowhere to explain what any of them did and
// nowhere to put a safety number.
IconAction(
icon = KitIcon.More,
label = "About $name",
tag = "contact-menu-${buddy.userId}",
onClick = { onOpenProfile(buddy.userId) },
)
}
Divider()
}
}
}
/**
* 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.
*/
internal 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.
*
* ⚠️ Space is reserved from the reference's dimensions before the bytes
* arrive, so the thread does not jump as images land. The reference carries
* width and height for exactly this reason.
*/
@Composable
/** ⚠️ Internal, not private: the stories feed draws the same blob. */
internal fun AttachedImage(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerViewModel) {
var bitmap by remember(ref.id) { mutableStateOf<android.graphics.Bitmap?>(null) }
var failed by remember(ref.id) { mutableStateOf(false) }
LaunchedEffect(ref.id) {
val bytes = model.attachmentBytes(ref)
if (bytes == null) { failed = true; return@LaunchedEffect }
// Bounded: these bytes came from a peer. See ImagePrep.decodeBounded.
bitmap = ImagePrep.decodeBounded(bytes)
if (bitmap == null) failed = true
}
val ratio = if (ref.width > 0 && ref.height > 0) {
ref.width.toFloat() / ref.height.toFloat()
} else 1f
Box(
modifier = Modifier
.widthIn(max = 260.dp)
.aspectRatio(ratio)
.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
val loaded = bitmap
when {
loaded != null -> androidx.compose.foundation.Image(
bitmap = loaded.asImageBitmap(),
contentDescription = "Attached image",
modifier = Modifier.fillMaxWidth(),
contentScale = androidx.compose.ui.layout.ContentScale.Crop,
)
// Says which half failed. "Could not load" covers a network error,
// a wrong key and a relay serving different bytes, and those are
// very different things to be told.
failed -> Text("Image unavailable", style = MaterialTheme.typography.bodySmall)
else -> Text("Loading…", style = MaterialTheme.typography.bodySmall)
}
}
}
/**
* Groups: list them, make one, open one.
*
* ⚠️ Member names are resolved here, from the contact list. The relay stores
* ids and nothing else on purpose — it has no user table and should not grow
* one, because a relay that caches display names is a relay that leaks them.
* A member you have never added shows as an id, which is honest.
*/
@Composable
fun ColumnScope.GroupsSection(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
/** Filters this section from the one search box above the whole list. */
query: String = "",
/** See the note on [BuddyList]'s `adding` — the button lives in the bar. */
creating: Boolean = false,
onCreatingChange: (Boolean) -> Unit = {},
) {
var name by remember { mutableStateOf("") }
var chosen by remember { mutableStateOf(setOf<Long>()) }
// 🔴 The heading always renders now, because it CARRIES the way in — the
// New-group toggle sits beside the label, as it does on iOS, instead of
// in the screen's top bar two lists away from the list it feeds.
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Box(modifier = Modifier.weight(1f)) { SectionLabel("Groups") }
androidx.compose.material3.Icon(
if (creating) KitIcon.Close else KitIcon.NewGroup,
contentDescription = "New group",
tint = Palette.Accent,
modifier = Modifier
.size(22.dp)
.clickable { onCreatingChange(!creating) }
.testTag("new-group"),
)
}
if (creating) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Group name") },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("group-name"),
)
if (phase.buddies.isEmpty()) {
Text("Add a contact first — a group needs somebody in it.",
style = MaterialTheme.typography.bodySmall)
}
phase.buddies.forEach { buddy ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
chosen = if (buddy.userId in chosen) chosen - buddy.userId
else chosen + buddy.userId
}
.padding(vertical = 8.dp)
.testTag("group-member-${buddy.userId}"),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(if (buddy.userId in chosen) "☑" else "☐")
Text(buddy.displayName.ifBlank { buddy.handle })
}
}
KitPrimaryButton(
text = "Create group",
onClick = {
model.createGroup(name.trim(), chosen.toList())
name = ""; chosen = emptySet(); onCreatingChange(false)
},
enabled = name.isNotBlank() && chosen.isNotEmpty() && !phase.busy,
modifier = Modifier.fillMaxWidth(),
tag = "create-group",
)
}
// 🔴 **Loaded-and-empty says nothing; failed still speaks.** The two used
// to share one sentence, and the reason is worth keeping: an empty list
// rendering NOTHING gave no way to tell "you have no groups" from "the
// list did not load". But a line of prose in the middle of a list, for the
// ordinary state of a new account, is chrome — and the section label above
// it made two lines of furniture for zero rows. A failure is still worth a
// sentence, because that one IS news.
if (phase.groups.isEmpty() && !phase.groupsLoaded) {
Text(
"Couldn't load your groups. Check the connection and sign in again.",
fontSize = 14.sp,
color = Palette.SecondaryText,
modifier = Modifier.testTag("groups-empty"),
)
}
val shownGroups = phase.groups.filter {
query.isBlank() || it.name.contains(query, true)
}
shownGroups.forEach { group ->
val thread = io.flamenet.e2e.Vault.threadForGroup(group.id)
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { model.openGroup(group) }
// ⚠️ Same 72dp pitch as a contact row, from the same tokens. A
// group row measured differently sat at a different rhythm in
// the same list, which reads as two lists that happen to
// follow each other.
.padding(vertical = Kit.rowGap / 2)
.testTag("group-${group.id}"),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Kit.gutter),
) {
// The same avatar shape as a contact, so the two kinds of row read
// as one list. A group has no presence, so no dot.
Avatar(name = group.name.ifBlank { "Group" }, online = false)
Column(modifier = Modifier.weight(1f)) {
Text(
group.name.ifBlank { "Group" },
fontSize = 16.sp,
fontWeight = if (thread in phase.unread) FontWeight.Bold else FontWeight.Medium,
)
Text(
// "You" first: you are a member of your own group and are
// not in your own buddy list, so this used to read
// "user 31, fnreviewbeta".
group.memberIds.joinToString(", ") { id ->
if (id == phase.userId) "You"
else phase.buddies.firstOrNull { it.userId == id }
?.let { b -> b.displayName.ifBlank { b.handle } }
?: "user $id"
},
fontSize = 14.sp,
color = Palette.SecondaryText,
maxLines = 1,
)
}
if (thread in phase.unread) {
Box(modifier = Modifier.size(10.dp).clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.testTag("unread-group-${group.id}"))
}
}
Divider()
}
}
/** Presence, which is a claim about you and therefore yours to set. */
@Composable
fun PresencePicker(model: MessengerViewModel, current: String) {
// 🔴 **A heading, because without one this was four unlabelled chips.**
// They sat at the bottom of Account under the address card with nothing
// saying what they were, and the person who asked for the feature could not
// find it — which is the same as it not being there.
Text("Your status", style = MaterialTheme.typography.titleMedium)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// ⚠️ Chips, not four orange words in a row. As text buttons these read
// as four links and gave no sign that they were a single choice with
// one answer — the selected one was merely bold.
MessengerViewModel.PRESENCE_CHOICES.forEach { status ->
KitChip(
text = status,
selected = status == current,
onClick = { model.setPresence(status) },
tag = "presence-$status",
)
}
}
Text(
when (current) {
"away" -> "Contacts see you as away. Messages still arrive normally."
"busy" -> "Contacts see you as busy. Nothing is blocked — it is a hint."
"offline" -> "Contacts see you as offline. You still receive everything."
else -> "Contacts see you as online while you have used the app recently."
},
fontSize = 12.sp,
color = Palette.SecondaryText,
)
}
@Composable
fun ColumnScope.NotificationsScreen(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
) {
// Same bar grammar as the list screen: title at 24 bold, the one action on
// the trailing edge.
val unread = phase.notifications.count { !it.read }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Alerts", style = MaterialTheme.typography.headlineMedium)
// ⚠️ Only when there is something to mark. It was offered
// unconditionally, so an empty screen showed a control whose only
// possible effect was nothing — and it was the most prominent thing on
// that screen.
if (unread > 0) {
KitChip(
text = "Mark all read",
selected = false,
onClick = model::markAllNotificationsRead,
tag = "mark-all-read",
)
}
}
if (phase.notifications.isEmpty()) {
// 🔴 **An empty state, not a stray sentence under the title.**
// "Nothing new." tucked against the heading read as a subtitle of the
// heading rather than as the content of the screen, and left the other
// nine tenths of it blank with no explanation of what would ever
// appear there.
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = Space.xl * 2),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(Space.sm),
) {
Icon(
KitIcon.Alerts,
contentDescription = null,
tint = Palette.SecondaryText,
modifier = Modifier.size(Space.xxl),
)
Text("Nothing new", fontSize = 16.sp, fontWeight = FontWeight.Medium)
Text(
"Contact requests and things that happened while you were away "
+ "show up here.",
fontSize = 14.sp,
color = Palette.SecondaryText,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
return
}
// 🔴 **Rows on the kit's 72dp pitch with an avatar, like every other list
// in the app.** This was a lazy list of two stacked text lines with an 8dp
// dot beside the unread ones — a different rhythm, a different type scale
// and a different unread marker from the contact list two taps away. One
// app, one row grammar.
//
// ⚠️ Plain rows in the page's own scroll, for the same reason the buddy
// list is: a lazy list inside a scrolling Column is measured with infinite
// height and crashes.
Column(modifier = Modifier.fillMaxWidth().testTag("notifications")) {
phase.notifications.forEach { note ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { model.markNotificationRead(note.id) }
.padding(vertical = Kit.rowGap / 2),
horizontalArrangement = Arrangement.spacedBy(Kit.gutter),
verticalAlignment = Alignment.CenterVertically,
) {
// ⚠️ A glyph for the kind, not initials OF the kind — that
// rendered "contact request" as "CR" and "missed call" as
// "MC", two letters that look like a person's initials and
// name nobody.
AlertGlyph(note.kind)
Column(modifier = Modifier.weight(1f)) {
Text(
note.body.ifBlank { note.kind },
fontSize = 16.sp,
fontWeight = if (note.read) FontWeight.Medium else FontWeight.Bold,
maxLines = 2,
)
Text(note.kind, fontSize = 14.sp, color = Palette.SecondaryText)
}
// The same unread marker the contact list uses, at the same
// size, on the same edge.
if (!note.read) {
Box(
modifier = Modifier
.size(Kit.presenceDot)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
)
}
}
HorizontalDivider(
color = Palette.SecondaryText.copy(alpha = 0.16f),
modifier = Modifier.padding(start = Kit.avatar + Kit.gutter),
)
}
}
}
/**
* The account.
*
* ⚠️ The two destructive actions confirm in place rather than in a dialog. A
* dialog is dismissed reflexively; a button that changes to say what it is
* about to do is read.
*/
@Composable
fun ColumnScope.AccountScreen(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
) {
var displayName by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var confirmingDelete by remember { mutableStateOf(false) }
var confirmingErase by remember { mutableStateOf(false) }
Text("Account", style = MaterialTheme.typography.headlineMedium)
Text(
"${phase.handle} · ${phase.relay}",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
Text(
"Device ${phase.deviceId}",
fontSize = 12.sp,
color = Palette.SecondaryText,
modifier = Modifier.testTag("device-id"),
)
Spacer(Modifier.height(Space.md))
SettingsSection { ProfilePicture(model, phase) }
// Directly under the picture: status is the other thing people expect to
// find first on an account screen, and it was last.
SectionBand()
SettingsSection { PresencePicker(model, phase.presence) }
SectionBand()
SettingsSection { StoryAudienceSection(model, phase) }
SectionBand()
SettingsSection { AppLockSection() }
SectionBand()
SettingsSection { MediaQualitySection() }
SectionBand()
SettingsSection { RegisteredDevicesSection(model, phase) }
SectionBand()
SettingsSection { HistoryBackupSection(model) }
SectionBand()
SettingsSection { ParentalControlsSection() }
SectionBand()
SettingsSection { ContactListPrivacySection(model) }
SectionBand()
SettingsSection { AddressCard(fnid = phase.fnid, relay = phase.relay, handle = phase.handle) }
SectionBand()
SettingsSection { MailboxesSection(model, phase) }
SectionBand()
SettingsSection {
// ⚠️ The row is the label; the field must not repeat it. "Display
// name" over a box whose placeholder also said "Display name" is the
// same duplication the contact rows had.
SettingsRow(label = "Display name") {}
OutlinedTextField(
value = displayName,
onValueChange = { displayName = it },
placeholder = { Text("How others see you", fontSize = 14.sp) },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("display-name"),
)
Spacer(Modifier.height(Space.sm))
KitPrimaryButton(
text = "Save display name",
onClick = { model.updateDisplayName(displayName.trim()); displayName = "" },
enabled = displayName.isNotBlank() && !phase.busy,
modifier = Modifier.fillMaxWidth(),
tag = "save-display-name",
)
Spacer(Modifier.height(Space.lg))
SettingsRow(
label = "Password",
description = "Changing it signs this device out — that is what changing it means.",
) {}
OutlinedTextField(
value = password,
onValueChange = { password = it },
placeholder = { Text("At least 8 characters", fontSize = 14.sp) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth().testTag("new-password"),
)
Spacer(Modifier.height(Space.sm))
KitPrimaryButton(
text = "Change password",
onClick = { model.updatePassword(password); password = "" },
enabled = password.length >= 8 && !phase.busy,
modifier = Modifier.fillMaxWidth(),
tag = "save-password",
)
}
// -- Security (§19) ------------------------------------------------------
SectionBand()
SettingsSection {
if (phase.forkDetected) {
// Above the toggle, not below: this is a statement about the server
// the toggle configures, and it changes what the toggle means.
Text(
"⚠️ This relay showed two different key logs.",
color = MaterialTheme.colorScheme.error,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.testTag("fork-detected"),
)
Text(
"Two devices compared what this server told each of them about its key log, and "
+ "the answers cannot both be true. A server doing that can hand different "
+ "people different keys for the same person. Treat conversations here as "
+ "compromised until you have verified safety numbers in person.",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(Space.sm))
}
// 🔴 This switch used to be what turned checking ON, and it was off by
// default — so the protection shipped and almost nobody had it.
// Checking is now automatic on any server that keeps a log; what is
// left here is the narrower question of what to do where there is
// nothing to check against.
SettingsRow(
label = "Also refuse servers with no log",
description = "Your app already checks that a contact's keys appear in the public "
+ "log this server publishes, on its own, before the first message. A server "
+ "old enough to keep no log has nothing to show, and those chats go ahead "
+ "unchecked — turn this on to refuse them instead. Only if you know your "
+ "server publishes a log, or nobody on it will be reachable.",
) {
Switch(
checked = phase.requireKeyLog,
onCheckedChange = model::setRequireKeyLog,
modifier = Modifier.testTag("require-key-log"),
)
}
SettingsRow(
label = "Sealed sender",
description = "Messages you send are addressed to the recipient without naming you. "
+ "Because nothing names the sender, the server cannot block one — so blocking "
+ "somebody now also cancels the one-use passes you gave them, which is the part "
+ "that can be aimed at a single person. This replaces the shared key instead, "
+ "which everybody holds: it is the way to cut off somebody you cannot identify, "
+ "and it costs every contact until you next message them.",
) {
KitChip(text = "New key", selected = false,
onClick = model::rotateDeliveryKey, tag = "rotate-delivery-key")
}
}
SectionBand()
SettingsSection { AppVersionSection(model) }
// ⚠️ **The destructive pair, in their own section.** They confirm in place
// rather than in a dialog: a dialog is dismissed reflexively; a button that
// changes to say what it is about to do is read.
SectionBand()
SettingsSection {
SettingsRow(
label = "Sign out",
description = "Ends this session and keeps your messages on this phone, "
+ "so signing back in picks up where you left off.",
tag = "sign-out",
onClick = { model.signOut() },
)
// 🔴 **The one that erases is its own row, and is not the default.**
// Both used to be the same button: signing out for an afternoon
// destroyed this phone's identity, so coming back made every contact
// accept a safety-number change first. Erasing is a real thing people
// need — handing a phone on — but it is the rarer thing, and it must be
// chosen rather than stumbled into. Confirms in place, like the pair
// below it, because a dialog is dismissed reflexively.
SettingsRow(
label = if (confirmingErase) "Really erase — this cannot be undone"
else "Sign out and erase this device",
description = "Also removes this phone's keys and stored messages. "
+ "Choose this if you are handing the phone on.",
tag = "sign-out-erase",
onClick = {
if (confirmingErase) model.signOut(erasingDevice = true)
else confirmingErase = true
},
)
SettingsRow(
label = if (confirmingDelete) "Really delete — this cannot be undone"
else "Delete account",
description = "Deletes the account on ${phase.relay} and erases this phone's keys "
+ "and messages.",
tag = "delete-account",
onClick = { if (confirmingDelete) model.deleteAccount() else confirmingDelete = true },
) {
Icon(
KitIcon.RemoveContact,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(Kit.icon),
)
}
}
Spacer(Modifier.height(Space.lg))
}
/**
* Your profile picture: show it, change it, remove it.
*
* 🔴 **It is sent to contacts, not published.** The relay stores an encrypted
* blob it cannot open, and the key reaches each contact inside the ratchet —
* so somebody who has never messaged you cannot see your face, and changing it
* means telling every contact again. That trade is stated on the screen rather
* than left for somebody to discover when a new contact sees initials.
*/
/**
* Which build this is, and a button to go and find out if it is current.
*
* 🔴 **A sideloaded app has to answer this on demand.** The dialog in
* [UpdateAvailableDialog] can be declined, and should be — but then there has
* to be a place that still knows. This is also the answer to "which version am
* I running", which is the first question any support conversation opens with.
*
* ⚠️ **A button, not an automatic check on entering the screen.** Opening
* Account is not a request to talk to the network, and the background check
* already runs on its own schedule. Pressing this is the user asking, which is
* also the only reason it is allowed to fall back to our site — see
* [AppUpdate.checkNow].
*/
@Composable
private fun ColumnScope.AppVersionSection(model: MessengerViewModel) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val baseUrl = model.site?.baseUrl
val installed = remember { AppUpdate.installedVersionName(context) }
var checking by remember { mutableStateOf(false) }
var result by remember { mutableStateOf<AppUpdate.CheckResult?>(null) }
Text("This app", style = MaterialTheme.typography.titleMedium)
SettingsRow(
label = "Version $installed",
description = "This app installs by hand rather than through a store, so it "
+ "cannot update itself.",
tag = "app-version",
)
KitSecondaryButton(
text = if (checking) "Checking…" else "Check for updates",
onClick = {
scope.launch {
checking = true
result = AppUpdate.checkNow(context, baseUrl)
checking = false
}
},
enabled = !checking,
modifier = Modifier.fillMaxWidth(),
tag = "check-for-updates",
)
// ⚠️ Every branch names the host that answered. "Up to date" without saying
// according to whom is a claim the app cannot actually make — a relay can
// be behind, and on a self-hosted one the answer may have come from our
// site instead.
when (val outcome = result) {
null -> Unit
is AppUpdate.CheckResult.UpToDate -> Text(
"Version $installed is the newest ${outcome.source} publishes.",
fontSize = 12.sp,
color = Palette.SecondaryText,
modifier = Modifier.testTag("update-none"),
)
// 🔴 Says plainly that it could not tell, rather than "up to date".
// A relay that publishes no manifest and a site that could not be
// reached both produce silence, and reporting silence as good news is
// how somebody sits on a build with a fix in it believing otherwise.
is AppUpdate.CheckResult.Unknown -> Text(
"Could not check. Neither this relay nor flamenetmessenger.com said "
+ "which build is current — that is not the same as being up to date.",
fontSize = 12.sp,
color = Palette.SecondaryText,
modifier = Modifier.testTag("update-unknown"),
)
is AppUpdate.CheckResult.Available -> {
val release = outcome.release
Spacer(Modifier.height(Space.sm))
Text(
"Version ${release.versionName} is available from ${outcome.source}.",
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.testTag("update-available"),
)
if (release.notes.isNotBlank()) {
Text(release.notes, fontSize = 12.sp, color = Palette.SecondaryText)
}
Spacer(Modifier.height(Space.sm))
KitPrimaryButton(
text = "Download ${release.versionName}",
onClick = { AppUpdate.openDownload(context, release.downloadUrl) },
modifier = Modifier.fillMaxWidth(),
tag = "download-update",
)
Text(
"Opens your browser. Installing over the top keeps your messages and your "
+ "account — Android asks you to allow installs from the browser the "
+ "first time.",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
// For anyone who wants to check the file they got is the file we
// published. Small, because most people will never use it and it
// should not compete with the button.
release.sha256?.let {
Text("SHA-256 $it", fontSize = 10.sp, color = Palette.SecondaryText)
}
}
}
}
/**
* The app lock toggle.
*
* ⚠️ Offered only when the device can actually authenticate. A switch that
* turns on and then cannot show a prompt locks somebody out of their own
* messages with no way back, which is worse than not having the feature.
*/
@Composable
private fun ColumnScope.AppLockSection() {
val context = LocalContext.current
val lock = remember { AppLock(context) }
var enabled by remember { mutableStateOf(lock.enabled) }
val available = remember { lock.available() }
Text("App lock", style = MaterialTheme.typography.titleMedium)
if (!available) {
Text(
"This device has no screen lock set, so there is nothing to unlock " +
"with. Add a PIN, pattern or fingerprint in Android settings first.",
fontSize = 14.sp,
color = Palette.SecondaryText,
)
return
}
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm),
verticalAlignment = Alignment.CenterVertically,
) {
Text("Require unlock to open", fontSize = 16.sp, modifier = Modifier.weight(1f))
Switch(
checked = enabled,
onCheckedChange = { on -> lock.enabled = on; enabled = on },
modifier = Modifier.testTag("app-lock"),
)
}
// 🔴 Says what it does NOT do. The vault and the ratchet keys are protected
// by the Android keystore, not by this switch, and turning it on
// re-encrypts nothing — this stops somebody who picks up your unlocked
// phone, which is the threat people actually have. Claiming more would be
// selling a screen as if it were a key.
Text(
"Asks for your fingerprint, face or device PIN before showing your " +
"messages, and again after a minute away. It stops someone who " +
"picks up your unlocked phone. It does not add encryption — your " +
"messages and keys are already sealed on this device either way.",
fontSize = 14.sp,
color = Palette.SecondaryText,
)
}
/**
* Who may see this account's stories.
*
* 🔴 **Enforced by who is SENT the bytes.** A story is sealed to each
* recipient's devices through the ratchet, so somebody outside this list is
* never sent it — there is no copy on the relay for them to be denied. Every
* mainstream product holds one copy server-side and decides who is shown it;
* this cannot work that way even if somebody wanted it to, and that is the
* feature.
*
* ⚠️ There is no "public" option and cannot be one. A story only ever goes to
* contacts, because it has to be encrypted to somebody in particular.
*/
/** ⚠️ Plain words: "168 hours" would make the reader do the division. */
private fun storyLifetimeLabel(hours: Int): String = when {
hours < 24 -> if (hours == 1) "1 hour" else "$hours hours"
hours == 24 -> "24 hours"
else -> (hours / 24).let { if (it == 1) "1 day" else "$it days" }
}
@Composable
private fun ColumnScope.StoryAudienceSection(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
) {
val audience = phase.storyAudience
Text("Who can see your stories", style = MaterialTheme.typography.titleMedium)
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm),
horizontalArrangement = Arrangement.spacedBy(Space.sm),
) {
KitChip(
text = "All contacts",
selected = audience.mode == Session.AudienceMode.EVERYONE,
onClick = {
model.setStoryAudience(
Session.StoryAudience(Session.AudienceMode.EVERYONE, audience.userIds))
},
tag = "audience-everyone",
)
KitChip(
text = "Only chosen",
selected = audience.mode == Session.AudienceMode.ONLY,
onClick = {
model.setStoryAudience(
Session.StoryAudience(Session.AudienceMode.ONLY, audience.userIds))
},
tag = "audience-only",
)
KitChip(
text = "All except",
selected = audience.mode == Session.AudienceMode.EXCEPT,
onClick = {
model.setStoryAudience(
Session.StoryAudience(Session.AudienceMode.EXCEPT, audience.userIds))
},
tag = "audience-except",
)
}
Text(
when (audience.mode) {
Session.AudienceMode.EVERYONE ->
"Every contact receives your stories. Nobody else can — a story " +
"is encrypted to the people it goes to, so there is no copy " +
"for a stranger to find."
Session.AudienceMode.ONLY ->
"Only the people ticked below are sent your stories."
Session.AudienceMode.EXCEPT ->
"Every contact except the people ticked below."
},
fontSize = 14.sp,
color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.lg))
Text("How long your stories last", style = MaterialTheme.typography.titleMedium)
// 🔴 A day used to be the only answer. The expiry rides inside the sealed
// story, so this is the poster's choice travelling with every copy — not a
// rule the relay is being asked to keep.
val storyContext = LocalContext.current
val mediaSettings = remember { MediaSettings(storyContext) }
var storyHours by remember { mutableStateOf(mediaSettings.storyLifetimeHours) }
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm),
horizontalArrangement = Arrangement.spacedBy(Space.sm),
) {
MediaSettings.STORY_LIFETIME_CHOICES.forEach { hours ->
KitChip(
text = storyLifetimeLabel(hours),
selected = storyHours == hours,
onClick = {
storyHours = hours
mediaSettings.storyLifetimeHours = hours
},
tag = "story-lifetime-$hours",
)
}
}
Text(
// ⚠️ Says what it does NOT promise. "Disappears after" alone reads as a
// guarantee about other people's phones, and a viewer who screenshots a
// story has it for good.
"Stories you post from now on disappear after this long, on your device "
+ "and your contacts'. Ones already posted keep the time they were "
+ "sent with.",
fontSize = 14.sp,
color = Palette.SecondaryText,
)
// The list only earns its place when the mode uses one.
if (audience.mode != Session.AudienceMode.EVERYONE) {
if (phase.buddies.isEmpty()) {
Text("Add a contact first.", fontSize = 14.sp, color = Palette.SecondaryText)
}
phase.buddies.forEach { buddy ->
val name = buddy.displayName.ifBlank { buddy.handle }
val ticked = buddy.userId in audience.userIds
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
val next = if (ticked) audience.userIds - buddy.userId
else audience.userIds + buddy.userId
model.setStoryAudience(audience.copy(userIds = next))
}
.padding(vertical = Space.sm)
.testTag("audience-${buddy.userId}"),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Kit.gutter),
) {
Checkbox(checked = ticked, onCheckedChange = null)
Text(name, fontSize = 16.sp, modifier = Modifier.weight(1f))
}
}
// ⚠️ Says what will actually happen, resolved against the real contact
// list — an id left over from a removed contact must not read as a
// recipient, and the resolver drops it.
val reach = audience.recipients(phase.buddies.map { it.userId }).size
Text(
when (reach) {
0 -> "This would go to nobody."
1 -> "Your next story goes to 1 contact."
else -> "Your next story goes to $reach contacts."
},
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = if (reach == 0) Palette.Pink else Palette.SecondaryText,
modifier = Modifier.padding(top = Space.sm).testTag("audience-reach"),
)
}
}
@Composable
private fun ColumnScope.ProfilePicture(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
) {
val context = LocalContext.current
val pick = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri ?: return@rememberLauncherForActivityResult
val bytes = runCatching {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
}.getOrNull() ?: return@rememberLauncherForActivityResult
val bounds = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true }
android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
model.setAvatar(
bytes,
context.contentResolver.getType(uri) ?: "image/jpeg",
bounds.outWidth,
bounds.outHeight,
)
}
Text("Profile picture", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(Space.md))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Kit.gutter),
) {
Avatar(
name = phase.handle,
online = false,
picture = phase.myAvatar,
model = model,
size = 80.dp,
)
Column(modifier = Modifier.weight(1f)) {
KitChip(
text = if (phase.myAvatar == null) "Choose a picture" else "Change",
selected = false,
onClick = { pick.launch("image/*") },
tag = "set-avatar",
)
if (phase.myAvatar != null) {
Spacer(Modifier.height(Space.sm))
KitChip(
text = "Remove",
selected = false,
onClick = model::clearAvatar,
tag = "clear-avatar",
)
}
}
}
Spacer(Modifier.height(Space.md))
Text(
"Sent to your contacts encrypted, the same way a photo is — this server " +
"stores it and cannot open it. People who have never messaged you see " +
"your initials instead.",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
}
@Composable
private fun SettingsSection(content: @Composable ColumnScope.() -> Unit) {
Surface(
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(vertical = Space.sm), content = content)
}
}
/**
* The 16pt band the kit puts between sections.
*
* ⚠️ Full bleed, which is why it takes a negative inset: the screen frame pads
* everything by 20 and this has to reach the edges to read as a band rather
* than as a wide grey row.
*/
/** ⚠️ Internal, not private: the contact detail uses the same band. */
@Composable
internal fun SectionBandPublic() = SectionBand()
@Composable
private fun SectionBand() {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 0.dp)
.height(16.dp)
.background(MaterialTheme.colorScheme.surfaceVariant)
)
}
/**
* One settings row: label, an optional sentence under it, and one control on
* the trailing edge.
*
* ⚠️ The description belongs to the row, at 12pt, under its label — not as a
* paragraph floating between controls, which is what this screen had. A
* setting whose explanation runs longer than the section it lives in needs a
* shorter explanation, not more room.
*/
@Composable
private fun SettingsRow(
label: String,
description: String? = null,
tag: String? = null,
onClick: (() -> Unit)? = null,
trailing: @Composable (() -> Unit)? = null,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.then(if (tag != null) Modifier.testTag(tag) else Modifier)
.padding(vertical = Kit.bubblePadding),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Space.md),
) {
Column(modifier = Modifier.weight(1f)) {
Text(label, fontSize = 14.sp, fontWeight = FontWeight.Medium)
description?.let {
Text(it, fontSize = 12.sp, color = Palette.SecondaryText)
}
}
trailing?.invoke()
}
}
/**
* A 48dp initials avatar with the presence dot on its corner.
*
* ⚠️ Two letters at most. Three-word display names produced a smear at this
* size, and the point of initials is that they are recognisable at a glance
* rather than complete.
*/
@Composable
internal fun Avatar(
name: String,
online: Boolean,
/** Their picture, if they have sent us one. Initials stand in when not. */
picture: io.flamenet.e2e.Attachment.Ref? = null,
model: MessengerViewModel? = null,
size: androidx.compose.ui.unit.Dp = Kit.avatar,
) {
val initials = remember(name) {
name.trim().split(" ").filter { it.isNotBlank() }
.take(2).mapNotNull { it.firstOrNull() }
.joinToString("").uppercase().ifEmpty { "?" }
}
Box(contentAlignment = Alignment.Center) {
Box(
modifier = Modifier
.size(size)
.clip(CircleShape)
.background(Palette.AvatarFill),
contentAlignment = Alignment.Center,
) {
// ⚠️ Initials are not a placeholder to be replaced when the bytes
// arrive — they are what most people will have, because a picture
// only exists once somebody has sent one and only reaches contacts
// they have messaged. They are the normal case, not the fallback.
if (picture != null && model != null) {
AttachedImage(picture, model)
} else {
Text(
initials,
fontSize = (size.value * 0.33f).sp,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
}
}
if (online) {
Box(
modifier = Modifier
.size(Kit.presenceDot)
.align(Alignment.BottomEnd)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surface)
.padding(2.dp)
.clip(CircleShape)
.background(Palette.Teal),
)
}
}
}
/**
* A section label at the smallest size that still separates two lists.
*
* ⚠️ 12sp muted, not a heading. A heading at 16 or 20 with a button beside it
* is a bar, and two bars for two short lists is most of a phone screen spent
* on saying what the next three rows are.
*/
/**
* A bar action: one glyph in a tappable circle, with the real label for
* anyone who cannot see it.
*
* ⚠️ Glyphs rather than a vector icon set. This app ships none, and adding a
* dependency for eight buttons is a supply chain for decoration. They are
* sized like the kit's 24dp icons.
*/
@Composable
private fun IconAction(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
tag: String,
danger: Boolean = false,
onClick: () -> Unit,
) {
Box(
modifier = Modifier
.size(Kit.icon + Space.md)
.clip(CircleShape)
.clickable(onClick = onClick)
.semantics { contentDescription = label }
.testTag(tag),
contentAlignment = Alignment.Center,
) {
Icon(
icon,
contentDescription = null,
tint = if (danger) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.primary,
modifier = Modifier.size(Kit.icon),
)
}
}
private fun shownGroupsExist(
phase: MessengerViewModel.Phase.SignedIn,
query: String,
): Boolean = phase.groups.any { query.isBlank() || it.name.contains(query, true) }
/** One circle per kind of alert, in the same 48dp footprint as an avatar. */
@Composable
private fun AlertGlyph(kind: String) {
val symbol = when {
kind.contains("call", true) -> KitIcon.Call
kind.contains("contact", true) || kind.contains("request", true) -> KitIcon.Add
kind.contains("group", true) -> KitIcon.NewGroup
kind.contains("story", true) -> KitIcon.Stories
else -> KitIcon.Dot
}
Box(
modifier = Modifier.size(Kit.avatar).clip(CircleShape).background(Palette.AvatarFill),
contentAlignment = Alignment.Center,
) {
Icon(
symbol,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(Kit.icon),
)
}
}
@Composable
private fun SectionLabel(text: String) {
Text(
text.uppercase(),
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = Palette.SecondaryText,
letterSpacing = 0.8.sp,
modifier = Modifier.padding(top = Space.md, bottom = Space.xs),
)
}
@Composable
private fun PresenceDot(status: String) {
// ⚠️ These were an olive, a mustard and a rust — the previous brand's
// palette, left behind when the accent was changed. Nothing else on the
// screen is those colours any more, which is exactly how a dot ends up
// looking like it belongs to a different app.
val colour = when (status.lowercase()) {
"online" -> Palette.Teal
"away" -> Palette.AccentBright
"busy" -> Palette.Pink
else -> Palette.SecondaryText
}
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(colour))
}
/**
* "This person is not in your contacts", with the two things worth offering.
*
* 🔴 **The message has already arrived, and that is on purpose.** Hiding a
* first message behind an accept/ignore gate is the obvious design and it is
* the one that shipped as a bug here: for a week, a first message from anyone
* not already in your contacts was completely invisible — no row, no name,
* just an unread badge over "Nobody here yet". The app now adopts a stranger so
* their message can be seen at all.
*
* ⚠️ But adoption makes a decision for the user: somebody is in your contacts
* because they wrote to you, which is not the same as you choosing them. This
* is the seam — delivery unchanged, the difference made visible, and the choice
* handed back.
*
* ⚠️ Absent for anybody accepted, which after the relay's backfill is every
* contact that existed before this shipped. A banner on an established
* conversation would be noise, and noise is how a safety label stops being
* read.
*/
@Composable
private fun ColumnScope.StrangerBanner(
model: MessengerViewModel,
peerId: Long,
buddies: List<io.flamenet.client.MessengerApi.Buddy>,
) {
val row = buddies.firstOrNull { it.userId == peerId } ?: return
if (row.accepted) return
val name = row.displayName.ifBlank { row.handle.ifBlank { "This person" } }
Surface(
color = Palette.SurfaceAlt,
shape = RoundedCornerShape(Radius.medium),
modifier = Modifier.fillMaxWidth().testTag("stranger-banner"),
) {
Column(modifier = Modifier.padding(Space.md)) {
Text(
"$name isn't in your contacts",
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
Text(
// Says what happened rather than warning about it. Most first
// messages are somebody you gave your address to, and opening
// with an alarm would be wrong far more often than right.
"They messaged you, so they were added automatically. You can keep them "
+ "or stop them reaching you.",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.sm))
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
KitPrimaryButton(
text = "Keep as contact",
onClick = { model.acceptCorrespondent(peerId) },
modifier = Modifier.weight(1f),
tag = "accept-stranger",
)
KitSecondaryButton(
text = "Block",
onClick = {
model.setBlocked(peerId, true)
model.closeChat()
},
modifier = Modifier.weight(1f),
danger = true,
tag = "block-stranger",
)
}
}
}
}
/** One conversation. */
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ColumnScope.ChatScreen(
model: MessengerViewModel,
chat: MessengerViewModel.Phase.Chat,
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(),
) {
// ⚠️ Keyed on the thread, so switching conversations re-reads rather than
// carrying one person's half-written message into somebody else's composer.
var draft by remember(chat.threadId) { mutableStateOf(model.openDraft()) }
// 🔴 **Every write to `draft` goes through here.** Sending clears the
// composer by assigning to the state directly in three places, and each one
// that forgot to also clear the stored copy would bring the message back the
// next time the conversation was opened — as a draft of something already
// sent, which is worse than not having drafts at all.
val setDraft: (String) -> Unit = { text ->
draft = text
model.rememberDraft(text)
}
val listState = rememberLazyListState()
// Open AT the newest message — a jump, not a tour of the history.
LaunchedEffect(Unit) {
if (chat.lines.isNotEmpty()) listState.scrollToItem(chat.lines.lastIndex)
}
// Follow the conversation as it grows. Without this a reply arrives
// off-screen and the app looks like it did nothing.
LaunchedEffect(chat.lines.size) {
if (chat.lines.isNotEmpty()) listState.animateScrollToItem(chat.lines.lastIndex)
}
// 🔴 **One bar, not three stacked rows.** Back and the name sat on one
// line, Call on a line of its own beneath it, and the identity panel
// under that — so a third of the conversation was chrome before a single
// message. The kit's conversation bar is 88 tall and holds all of it:
// back, the name over its subtitle, and the actions on the trailing edge.
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Space.sm),
) {
IconAction(icon = KitIcon.Back, label = "Back", tag = "back", onClick = model::closeChat)
Column(modifier = Modifier.weight(1f)) {
Text(
chat.title.ifBlank { "Conversation" },
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
)
// The kit's second line: a presence dot, then a 12sp fact.
// ⚠️ A group says how many people are in it and shows no dot — a
// group has many presences and one dot cannot speak for them.
val presence = chat.peerId?.let { id ->
buddies.firstOrNull { it.userId == id }?.presence
}
val subtitle = chat.group?.let { "${it.memberIds.size} members" }
?: presence?.takeIf { it.isNotBlank() }
subtitle?.let { line ->
Row(verticalAlignment = Alignment.CenterVertically) {
if (chat.group == null && presence.equals("online", true)) {
Box(Modifier.size(8.dp).clip(CircleShape).background(Palette.Teal))
Spacer(Modifier.width(6.dp))
}
Text(line, fontSize = 12.sp, color = Palette.SecondaryText)
}
}
}
// Calling is one-to-one. A group call is a different feature with
// different key handling, and an enabled-looking button that cannot
// work is worse than an absent one.
if (chat.peerId != null) {
val videoCall = rememberVideoCall(model)
IconAction(
icon = KitIcon.Video,
label = "Video call",
tag = "start-video-call",
onClick = { videoCall(chat.peerId) },
)
// ⚠️ **The kit's bar has a video icon here and this does not**, and
// that is deliberate. Neither CallManager has any video path at
// all — no capturer, no video track, nothing — so the button would
// be a control that does nothing. This app has already shipped one
// of those on the call screen once. A mockup can draw a feature;
// the app cannot draw one it does not have.
IconAction(
icon = KitIcon.Call,
label = "Call",
tag = "start-call",
onClick = { model.requestCall(chat.peerId) },
)
}
if (chat.peerId != null) ConversationMenu(model, chat, draft) { setDraft("") }
chat.group?.let { group ->
IconAction(
icon = KitIcon.LeaveGroup,
label = "Leave group",
tag = "leave-group",
danger = true,
onClick = { model.leaveGroup(group.id) },
)
}
}
// 🔴 **Above everything else in the thread**, because it is a statement
// about who you are talking to and it has to be read before the messages
// are, not after scrolling past them.
if (chat.peerId != null) StrangerBanner(model, chat.peerId, buddies)
chat.group?.let { group -> GroupMembers(model, group, buddies) }
// Identity, directly under the name it belongs to. Groups have many
// identities and no single answer, so this is for one-to-one threads only.
if (chat.peerId != null) IdentityPanel(model, chat)
// 🔴 **One line, not three panels.** These were three full-width blocks of
// chips — disappearing, snooze, remind, send later — stacked above the
// messages, and together they took half the screen in a conversation. The
// controls moved into the ⋯ menu; what stays on the thread is the STATE,
// because a timer nobody can see is a timer nobody trusts.
if (chat.peerId != null) ConversationStatusLine(model, chat)
LazyColumn(
state = listState,
modifier = Modifier.fillMaxWidth().weight(1f).testTag("messages"),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
items(chat.lines) { line ->
// 🔴 THE AUTHOR, not the thread. In a group, `chat.peerId` is null
// and `chat.title` is the group's name — so every received bubble
// wore the group's initial as its avatar and the group's name as
// its author. The line has carried the writer's id all along.
val authorId = when {
line.mine -> null
chat.group == null -> chat.peerId
else -> line.author.takeIf { it != 0L }
}
var menuOpen by remember(line.at, line.mine) { mutableStateOf(false) }
val clipboard = LocalClipboardManager.current
Box(modifier = Modifier.combinedClickable(
onClick = {},
onLongClick = { menuOpen = true },
)) {
Bubble(
line = line,
model = model,
authorName = when {
line.mine || chat.group == null -> chat.title
authorId == null -> chat.title
else -> buddies.firstOrNull { it.userId == authorId }
?.let { it.displayName.ifBlank { it.handle } }
?: "user $authorId"
},
// 🔴 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.
picture = authorId?.let { avatars[it] },
)
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
if (line.text.isNotBlank()) {
DropdownMenuItem(
text = { Text("Copy") },
onClick = { clipboard.setText(AnnotatedString(line.text)); menuOpen = false },
)
}
DropdownMenuItem(
text = { Text(if (line.mine) "Unsend" else "Delete for me") },
onClick = { model.deleteMessage(line); menuOpen = false },
modifier = Modifier.testTag("delete-message"),
)
}
}
}
}
error?.let {
Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.testTag("error"))
}
val context = androidx.compose.ui.platform.LocalContext.current
// ⚠️ Hoisted out of VoiceComposer so the microphone can sit IN the composer
// row. The kit's Message Box is one row — plus, the pill, then icons — and
// a voice control on a line of its own underneath was the last thing on
// this screen that did not belong to that row.
var showStickers by remember { mutableStateOf(false) }
var showEmoji by remember { mutableStateOf(false) }
var micGranted by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)
== PackageManager.PERMISSION_GRANTED
)
}
val askMic = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { allowed ->
micGranted = allowed
if (allowed) model.startRecording()
}
// The system picker, so the app never asks for storage permission — it is
// handed exactly the one file the person chose and nothing else.
val pickImage = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.GetContent()
) { uri ->
uri ?: return@rememberLauncherForActivityResult
val bytes = runCatching {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
}.getOrNull() ?: return@rememberLauncherForActivityResult
// 🔴 The ORIGINAL bytes used to go straight out. A modern camera photo
// is 5-15 MB and often over the relay's blob cap, so large pictures
// simply failed to send — and the ones that fit made the recipient
// download tens of megabytes nobody asked for.
val prepared = ImagePrep.prepare(bytes, MediaSettings(context).fullQuality)
?: return@rememberLauncherForActivityResult
model.sendImage(prepared.bytes, prepared.mime, prepared.width, prepared.height)
}
// The kit's composer: a 50-tall pill with radius 25, a leading attach
// control and the send action on the trailing edge. An outlined text field
// with two labelled buttons beside it was the one part of this screen that
// still looked like a form.
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Space.sm),
) {
IconAction(icon = KitIcon.Attach, label = "Attach a photo", tag = "attach",
onClick = { pickImage.launch("image/*") })
// ⚠️ `BasicTextField` inside our own pill rather than a Material text
// field with its shape overridden: `OutlinedTextField` reserves room
// for a floating label and enforces a 56dp minimum, so it cannot be
// made 50 tall no matter what shape it is given.
Box(
modifier = Modifier
.weight(1f)
.heightIn(min = Kit.pillHeight)
.clip(RoundedCornerShape(Kit.pillRadius))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = Kit.gutter, vertical = 14.dp),
contentAlignment = Alignment.CenterStart,
) {
if (draft.isEmpty()) {
Text("Type here …", fontSize = 14.sp, color = Palette.SecondaryText)
}
BasicTextField(
value = draft,
onValueChange = setDraft,
textStyle = LocalTextStyle.current.copy(
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
// Return sends — same convention iOS now defaults to. The
// send button stays for anyone who taps instead.
singleLine = true,
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
imeAction = androidx.compose.ui.text.input.ImeAction.Send),
keyboardActions = androidx.compose.foundation.text.KeyboardActions(
onSend = {
if (draft.isNotBlank()) {
model.sendMessage(draft)
setDraft("")
}
}
),
modifier = Modifier.fillMaxWidth().testTag("draft"),
)
}
// ⚠️ **Two controls, not one, because they are two different things.**
// An emoji goes INTO the text and is indistinguishable from any other
// character once sent. A sticker is its own message kind that is never
// rendered as text. Putting them behind one button would imply they
// behave the same way, and they do not — see docs/STICKERS.md.
IconAction(
icon = KitIcon.Emoji,
label = if (showEmoji) "Close emoji" else "Emoji",
tag = "emoji",
onClick = { showEmoji = !showEmoji; if (showEmoji) showStickers = false },
)
IconAction(
icon = KitIcon.Sticker,
label = if (showStickers) "Close stickers" else "Stickers",
tag = "stickers",
onClick = { showStickers = !showStickers; if (showStickers) showEmoji = false },
)
if (chat.peerId != null && chat.recordingSeconds == null) {
IconAction(
icon = KitIcon.Mic,
label = "Record a voice note",
tag = "record-voice",
onClick = {
if (micGranted) model.startRecording()
else askMic.launch(Manifest.permission.RECORD_AUDIO)
},
)
}
// ⚠️ **No emoji icon, which the kit's pill has**, for the same reason
// there is no video button: there is no emoji picker to open, and the
// system keyboard already carries one. A glyph that does nothing is
// worse than a missing one.
//
// 🔴 **The kit's Message Box has no send button.** Measured: plus at
// x=16, the F3F3F3 pill from x=56 with an emoji icon inside it at the
// trailing end, camera at x=287, more at x=335. Nothing else.
//
// ⚠️ It is kept anyway, and only while there is something to send. A
// composer with no visible way to send relies on the keyboard's return
// key, which is a real convention but not one to adopt silently on
// somebody else's behalf — and when the field is empty this collapses
// to exactly the kit's row. Camera takes its place at rest.
if (draft.isBlank()) {
IconAction(icon = KitIcon.Camera, label = "Take a photo", tag = "camera",
onClick = { pickImage.launch("image/*") })
} else {
Button(
onClick = {
model.sendMessage(draft)
setDraft("")
},
shape = CircleShape,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.size(Kit.pillHeight).testTag("send"),
) {
Icon(
KitIcon.Send,
contentDescription = null,
modifier = Modifier.size(Kit.icon),
)
}
}
}
if (showEmoji) {
EmojiPicker { emoji ->
// Into the draft, not sent — an emoji is a character, and somebody
// picking three in a row expects three in the box, not three
// messages. Closing the panel on each tap would make that
// impossible, so it stays open.
draft += emoji
}
}
if (showStickers) {
StickerPicker { sticker ->
// A sticker IS the message. There is nothing to add it to and
// nothing further to say, so it sends and the panel closes.
model.sendSticker(chat, sticker.id)
showStickers = false
}
}
// Voice notes sit under the composer rather than inside it: recording
// replaces the row with its own controls, and squeezing a fourth control
// into a row that already has three makes every one of them too small.
if (chat.peerId != null) VoiceComposer(model, chat)
}
/**
* Record and send a voice note.
*
* ⚠️ **Tap to start, tap to send** rather than hold-to-talk. Hold-to-talk reads
* well in a demo and badly on a real phone: the gesture competes with scrolling,
* and letting go early — which happens constantly — silently discards what was
* said. Two deliberate taps cannot be lost by accident.
*/
@Composable
private fun ColumnScope.VoiceComposer(
model: MessengerViewModel,
chat: MessengerViewModel.Phase.Chat,
) {
// ⚠️ The permission plumbing moved to the composer row with the mic icon
// that needs it — asked when somebody taps record, never at startup. A
// microphone prompt on first launch, before anything has been said, is the
// kind a person denies on reflex, and a denied permission is far harder to
// recover than one that was never asked for.
// 🔴 Only the ACTIVE recording. Starting one is the mic icon in the
// composer row now; this is the timer and the two decisions, which the kit
// has no equivalent for and which genuinely need a line of their own.
val seconds = chat.recordingSeconds ?: return
run {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
// A visible timer: a recorder with no feedback is one people talk
// into after it has already stopped.
Text(
"Recording ${"%.1f".format(seconds)}s",
modifier = Modifier.weight(1f).testTag("recording-timer"),
)
KitChip(text = "Cancel", selected = false,
onClick = model::cancelRecording, tag = "cancel-voice")
KitChip(text = "Send", selected = true,
onClick = model::stopAndSendRecording, tag = "send-voice")
}
}
}
@Composable
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))
}
Row(
modifier = Modifier
.fillMaxWidth()
// The kit insets a received bubble by 40 from the leading edge —
// the 32dp avatar plus its gutter — and lets a sent one run to the
// margin. The same number on the far side is what stops a long
// message spanning the full width, which is what makes a thread
// readable rather than a wall.
.padding(
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) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Space.xs),
modifier = Modifier.padding(horizontal = Kit.bubblePadding, vertical = 2.dp),
) {
Text(time, fontSize = 10.sp, color = Palette.SecondaryText)
// 🔴 **A message nobody received must not look like one that was
// received.** That sameness is the whole of what makes a ghost
// message possible; every specific cause behind one is downstream
// of it. Only `false` marks — null means nothing was claimed, which
// is every received message and everything stored before the field
// existed.
// ⚠️ PENDING is deliberately quiet and grey, not a warning. It is
// the normal thing that happens when a friend is signed out, and
// there is nothing for the sender to do about it — the outbox will
// send it when they register a device again, however long that
// takes. It says "undelivered" because that is true, and "will
// send" because otherwise the reader has to guess whose job it is.
//
// FAILED is now unreachable from the outbox, which never expires.
// It stays in the enum for a send that fails for a reason waiting
// cannot fix.
when (line.delivery) {
Session.Delivery.PENDING -> {
Icon(
KitIcon.Pending,
contentDescription = null,
tint = Palette.SecondaryText,
modifier = Modifier.size(12.dp).testTag("pending"),
)
Text("Undelivered — will send", fontSize = 10.sp,
color = Palette.SecondaryText)
}
Session.Delivery.FAILED -> {
Icon(
KitIcon.NotDelivered,
contentDescription = "Not delivered",
tint = Palette.Pink,
modifier = Modifier.size(12.dp).testTag("undelivered"),
)
Text("Not delivered", fontSize = 10.sp, color = Palette.Pink)
}
// DELIVERED and null both draw nothing: a message that arrived
// needs no decoration, and null is every received message.
else -> Unit
}
}
// 🔴 **A sticker has no bubble.** The kit draws none, and a glyph
// inside a grey rounded rectangle reads as a broken image rather than
// as a sticker. It keeps the timestamp above it — that is the line that
// says who and when — and nothing else.
val stk = line.sticker
if (stk != null) {
val context = LocalContext.current
Text(
StickerPack.glyph(context, stk.pack, stk.id),
fontSize = 64.sp,
modifier = Modifier
.padding(horizontal = Kit.bubblePadding)
.semantics {
contentDescription =
StickerPack.name(context, stk.pack, stk.id) + " sticker"
}
.testTag("sticker-${stk.id}"),
)
return@Column
}
Column(
modifier = Modifier
// 🔴 **One corner is square, and it points at its author.**
// Radius 16 everywhere except bottom-end on a message you
// sent and top-start on one you received. Rounding all four
// equally is what makes a chat app look like every other chat
// app, and it is the first thing lost when a spec is eyeballed
// from a screenshot rather than measured. See `DESIGN.md`.
.clip(
RoundedCornerShape(
topStart = if (line.mine) Kit.bubbleRadius else Kit.bubbleTail,
topEnd = Kit.bubbleRadius,
bottomEnd = if (line.mine) Kit.bubbleTail else Kit.bubbleRadius,
bottomStart = Kit.bubbleRadius,
)
)
.background(
if (line.mine) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surfaceVariant
)
.padding(Kit.bubblePadding),
) {
// ⚠️ The kit puts the time ABOVE the bubble, outside it. Inside, a
// one-word message became a tall two-line box — "sup" rendered
// taller than it was wide.
line.attachment?.let { ref ->
// One field, two very different things. Audio has no thumbnail
// to show and a duration that matters; an image has the
// reverse. Routing on the kind rather than trying to render
// both with one composable.
if (ref.kind == io.flamenet.e2e.Attachment.AUDIO) VoiceBubble(ref, model)
else AttachedImage(ref, model)
}
if (line.text.isNotEmpty()) {
Text(
line.text,
// 16sp regular, per the kit's Body Text 2.
fontSize = 16.sp,
color = if (line.mine) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurface,
)
}
line.link?.let { LinkPreviewCard(it, mine = line.mine) }
}
}
}
}
/**
* What we know about the other side's keys, and the two things a person can do.
*
* ⚠️ **The changed state is not a warning banner to be dismissed.** It offers
* exactly one action — accept — because the only way past it is a decision
* somebody makes on purpose. An "ignore" affordance here would be a way to
* silence the one signal in the product that means an interception.
*
* ⚠️ The safety number is shown, not hidden behind a menu. A number nobody can
* find is a number nobody compares, and an uncompared number verifies nothing.
*/
@Composable
private fun ColumnScope.IdentityPanel(
model: MessengerViewModel,
chat: MessengerViewModel.Phase.Chat,
) {
val peerId = chat.peerId ?: return
var showNumber by remember(peerId) { mutableStateOf(false) }
if (chat.trust == IdentityTrust.CHANGED) {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp).testTag("identity-changed"),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
"This person's security keys changed.",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
// Both readings, in the order that matters. Naming only the benign
// one trains people to tap through it.
Text(
"That happens when someone reinstalls or adds a device — and it is also what "
+ "an intercepted conversation looks like. Compare safety numbers with them "
+ "on a channel this app does not control before you accept.",
style = MaterialTheme.typography.bodySmall,
)
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
// ⚠️ Accepting a changed key is the most consequential tap in
// the app. As matching orange text beside "Show number" the two
// looked interchangeable; the outline makes accepting a
// deliberate act and marks it as the dangerous one.
KitSecondaryButton(
text = "Accept new keys",
onClick = { model.acceptIdentityChange(peerId) },
tag = "accept-identity",
danger = true,
)
KitChip(
text = if (showNumber) "Hide number" else "Show number",
selected = false,
onClick = { showNumber = !showNumber },
tag = "show-safety-number",
)
}
}
} else {
Row(
modifier = Modifier.fillMaxWidth().testTag("identity-state"),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
// ⚠️ **Quiet, but never absent.** The reassuring states are 12sp
// grey: they were as loud as the conversation's own title bar,
// which is the wrong weight for "nothing has happened". The
// CHANGED state above is untouched and stays loud — it is the one
// signal this whole mechanism exists to produce.
Text(
when (chat.trust) {
IdentityTrust.VERIFIED -> "Verified"
IdentityTrust.PINNED -> "Keys unchanged since you met"
else -> "No keys seen yet"
},
fontSize = 12.sp,
color = Palette.SecondaryText,
modifier = Modifier.weight(1f).testTag("trust-${chat.trust.name.lowercase()}"),
)
if (chat.safetyNumber != null) {
KitChip(
text = if (showNumber) "Hide number" else "Safety number",
selected = false,
onClick = { showNumber = !showNumber },
tag = "show-safety-number",
)
}
}
}
if (showNumber) {
chat.safetyNumber?.let { number ->
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
SafetyNumber.format(number),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.testTag("safety-number"),
)
Text(
"Read this to each other in person or on a call. If it matches, mark them "
+ "verified. ⚠️ It covers their first device only — someone signed in on "
+ "several has more than one number.",
style = MaterialTheme.typography.bodySmall,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(
onClick = { model.setVerified(peerId, chat.trust != IdentityTrust.VERIFIED) },
modifier = Modifier.testTag("toggle-verified"),
) {
Text(if (chat.trust == IdentityTrust.VERIFIED) "Unmark verified"
else "Mark verified")
}
}
}
}
}
}
/**
* A received voice note: play, stop, and how long it runs.
*
* ⚠️ Fetched and decrypted **on tap**, never on render. A thread of twenty
* notes would otherwise download and decrypt all twenty on scroll — bytes
* nobody asked for, and twenty decrypted files in the cache for the sake of a
* list that has not been listened to.
*/
@Composable
private fun VoiceBubble(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerViewModel) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val player = remember { VoiceNote.Player(context.cacheDir) }
var playing by remember(ref.id) { mutableStateOf(false) }
var busy by remember(ref.id) { mutableStateOf(false) }
// ⚠️ A MediaPlayer that outlives the composable holds an audio focus and a
// file handle. Leaving the thread mid-playback must stop it, or the note
// keeps playing into a screen that is no longer there.
DisposableEffect(ref.id) { onDispose { player.stop() } }
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.testTag("voice-${ref.id}"),
) {
TextButton(
enabled = !busy,
onClick = {
if (playing) {
player.stop()
playing = false
return@TextButton
}
busy = true
scope.launch {
val bytes = model.attachmentBytes(ref)
busy = false
if (bytes == null) return@launch
runCatching {
player.play(bytes) { playing = false }
playing = true
}
}
},
modifier = Modifier.testTag("play-voice"),
) { Text(if (playing) "Stop" else "Play") }
Text(
if (ref.duration > 0) "%.0fs".format(ref.duration) else "Voice note",
style = MaterialTheme.typography.bodySmall,
)
}
}
/**
* The story strip (§6.6).
*
* ⚠️ **Stories are sent to contacts, not published.** There is no feed on the
* relay and there should not be: a server-side story would need the relay to
* know who is allowed to read it, which means handing it your contact list for
* a feature that works without one. Each story goes down an existing ratchet,
* one per person.
*
* ⚠️ **Expiry is a promise, not a guarantee, and the copy says so.** This
* client deletes the bytes when the clock runs out because it chooses to.
* Nothing stops a recipient's client keeping them, and a UI implying otherwise
* would be telling people a story is safe to post when it is not.
*/
@Composable
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
fun ColumnScope.StoryStrip(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
) {
val context = LocalContext.current
// The system picker again: the app is handed the one file the person chose
// and never asks for storage permission.
val pickStory = rememberLauncherForActivityResult(
ActivityResultContracts.GetContent()
) { uri ->
uri ?: return@rememberLauncherForActivityResult
StoryMedia.stage(context, uri, model)
}
LaunchedEffect(Unit) { model.refreshStories() }
// 🔴 **A strip of faces, which is what the kit's "Recent" row is.** This
// was a heading, a Post button, and — with nothing posted, which is the
// normal state — three lines of prose explaining what a story is. Prose
// where the design has a row of circles is the single loudest thing
// telling you this is not the same app. The explanation still exists; it
// moved to the one place it is actually needed, under the button that
// posts one.
//
// ⚠️ The add-circle is always first and always present, so the strip never
// collapses to nothing and there is always somewhere to tap.
LazyRow(
modifier = Modifier.fillMaxWidth().testTag("stories"),
horizontalArrangement = Arrangement.spacedBy(Space.md),
) {
item {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.testTag("post-story"),
) {
Box(
modifier = Modifier
.size(Kit.avatar)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable { pickStory.launch("*/*") },
contentAlignment = Alignment.Center,
) {
Icon(
KitIcon.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(Kit.icon),
)
}
Text("Post", fontSize = 12.sp, color = Palette.SecondaryText)
}
}
items(phase.stories, key = { it.id }) { story ->
val label = phase.buddies.firstOrNull { it.userId == story.authorId }
?.let { it.displayName.ifBlank { it.handle } }
?: if (story.mine) "You" else "#${story.authorId}"
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.testTag("story-${story.id}"),
) {
Box(
modifier = Modifier
.size(Kit.avatar)
.clip(CircleShape)
// Unseen is marked rather than styled away: a strip
// where read and unread look alike is a strip nobody
// comes back to. A ring, since there is no room for a
// bullet beside a 48dp circle.
.background(
if (story.seen) Palette.AvatarFill
else MaterialTheme.colorScheme.primary
)
.padding(if (story.seen) 0.dp else 2.dp)
.clip(CircleShape)
.background(Palette.AvatarFill)
// Long press hides this contact's stories (ios#33).
// On the bubble because the moment somebody wants this
// is the moment they are looking at the bubble.
.combinedClickable(
onClick = { model.openStory(story) },
onLongClick = {
if (!story.mine) {
model.setStoryMuted(
story.authorId, !model.isStoryMuted(story.authorId))
}
},
),
contentAlignment = Alignment.Center,
) {
Text(
label.take(1).uppercase(),
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
}
Text(
label,
fontSize = 12.sp,
color = Palette.SecondaryText,
maxLines = 1,
modifier = Modifier.widthIn(max = Kit.avatar + 8.dp),
)
}
}
}
phase.openStory?.let { story -> StoryViewer(model, story) }
}
/** One story, full width, with what it says about its own lifetime. */
@Composable
private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Story) {
val remaining = remember(story.id) {
val seconds = story.expiresAt - System.currentTimeMillis() / 1000.0
when {
seconds <= 0 -> "expired"
seconds < 3600 -> "${(seconds / 60).toInt()}m left"
else -> "${(seconds / 3600).toInt()}h left"
}
}
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp).testTag("story-viewer"),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
// §6.21 — a text story has no blob; the caption IS the story.
story.ref?.let { AttachedImage(it, model) }
if (story.caption.isNotBlank()) Text(story.caption)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(remaining, style = MaterialTheme.typography.bodySmall)
Row {
// ios#34 — only on your own, and the rule is
// `Vault.isSaveable`, not this condition.
if (Vault.isSaveable(story.mine, story.ref != null)) {
KitChip(text = "Save", selected = false,
onClick = { model.saveStory(story) }, tag = "save-story")
Spacer(Modifier.size(Space.sm))
}
KitChip(text = "Close", selected = false,
onClick = model::closeStory, tag = "close-story")
}
}
}
}
/**
* Add and remove people after a group exists.
*
* ⚠️ **Membership is the relay's list, and it decides who receives.** A member
* added here starts getting messages from the next send; one removed stops.
* Neither is retroactive — removing somebody does not take back what they
* already have, and the copy says so rather than implying a group can be
* un-read.
*/
@Composable
private fun ColumnScope.GroupMembers(
model: MessengerViewModel,
group: io.flamenet.client.RelayClient.Group,
buddies: List<io.flamenet.client.MessengerApi.Buddy>,
) {
var editing by remember(group.id) { mutableStateOf(false) }
var renaming by remember(group.id) { mutableStateOf(false) }
var draftName by remember(group.id) { mutableStateOf(group.name) }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("${group.memberIds.size} members", style = MaterialTheme.typography.bodySmall)
Row {
// 🔴 **The only way a group gets a name at all after the first
// one.** Names used to be fixed at creation and served by the
// relay; the relay has stopped holding them, so every group made
// before that change reads as a placeholder on every phone and
// there is nobody left to ask what it was called.
TextButton(
onClick = { draftName = group.name; renaming = !renaming },
modifier = Modifier.testTag("rename-group"),
) { Text(if (renaming) "Cancel" else "Rename") }
TextButton(
onClick = { editing = !editing },
modifier = Modifier.testTag("edit-members"),
) { Text(if (editing) "Done" else "Members") }
}
}
if (renaming) {
OutlinedTextField(
value = draftName,
onValueChange = { draftName = it },
label = { Text("Group name") },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("group-name-field"),
)
Text(
"Everyone in the group sees the new name. The server never does — it holds no "
+ "group names at all.",
style = MaterialTheme.typography.bodySmall,
)
TextButton(
onClick = {
model.renameGroup(group.id, draftName)
renaming = false
},
modifier = Modifier.testTag("save-group-name"),
) { Text("Save") }
}
// 🔴 **Only the owner, and only while it still applies.** A group made
// before sealing keeps a readable member list on the server until somebody
// replaces it, and only the owner can. Recomposes away once sealed, because
// sealing refreshes the group list.
if (model.canSealGroup(group)) {
Text(
// ⚠️ Says what is true now, what changes, and what it does NOT buy.
// "The server cannot see who you talk to" would be the easy sentence
// and it is not true: messages are addressed to each device, so who
// receives a group's traffic still gives the group away to whoever
// runs the server.
"This group was made before the server stopped keeping member lists, so it still "
+ "holds this one in the clear. Encrypting it puts something in its place that "
+ "the server has no key for. Everyone stays in the group and nobody else has "
+ "to do anything. It protects the list if the server's database is ever stolen "
+ "or handed over; it does not hide the group from whoever runs the server day "
+ "to day.",
style = MaterialTheme.typography.bodySmall,
)
TextButton(
onClick = { model.sealGroup(group.id) },
modifier = Modifier.testTag("seal-group"),
) { Text("Encrypt the member list") }
}
if (!editing) return
Text(
"Adding someone lets them read what is sent from now on. Removing them stops future "
+ "messages — it does not take back what they already have.",
style = MaterialTheme.typography.bodySmall,
)
buddies.forEach { buddy ->
val isMember = buddy.userId in group.memberIds
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
buddy.displayName.ifBlank { buddy.handle },
modifier = Modifier.weight(1f),
)
TextButton(
onClick = {
if (isMember) model.updateGroupMembers(group.id, emptyList(), listOf(buddy.userId))
else model.updateGroupMembers(group.id, listOf(buddy.userId), emptyList())
},
modifier = Modifier.testTag(
(if (isMember) "remove-member-" else "add-member-") + buddy.userId),
) { Text(if (isMember) "Remove" else "Add") }
}
}
}
/**
* The call banner: ringing, in progress, and the one-time privacy warning.
*
* 🔴 **The warning is shown BEFORE the first call, and it waits.** A direct
* connection shows the other person your IP address — that is what makes it
* direct — and it is not something to discover afterwards. Asked once rather
* than every time, because a prompt people see repeatedly is a prompt they
* stop reading.
*/
@Composable
fun ColumnScope.CallBanner(model: MessengerViewModel, phase: MessengerViewModel.Phase.SignedIn) {
phase.callPrivacyPrompt?.let {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp).testTag("call-privacy"),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text("Before your first call", style = MaterialTheme.typography.titleMedium)
Text(
"Calls connect your phone directly to theirs, which is what keeps them fast and "
+ "private from this server — but a direct connection means their device "
+ "sees your IP address, and yours sees theirs. What you say is encrypted "
+ "either way.",
style = MaterialTheme.typography.bodySmall,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = { model.acknowledgeCallPrivacy(andCall = true) },
modifier = Modifier.testTag("call-privacy-accept"),
) { Text("Understood, call") }
TextButton(
onClick = { model.acknowledgeCallPrivacy(andCall = false) },
modifier = Modifier.testTag("call-privacy-cancel"),
) { Text("Not now") }
}
}
return
}
val call = phase.call ?: return
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp).testTag("call-banner"),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
when (call.state) {
CallManager.State.RINGING ->
if (call.incoming) "${call.peerName} is calling" else "Ringing ${call.peerName}"
CallManager.State.DIALLING -> "Calling ${call.peerName}…"
CallManager.State.CONNECTING -> "Connecting…"
CallManager.State.ACTIVE -> "On a call with ${call.peerName}"
else -> "Call ended"
},
style = MaterialTheme.typography.titleMedium,
)
// ⚠️ Said out loud rather than left as a mystery. "Calling did not
// connect", with no reason, is the kind of failure people blame on the
// app for ever.
if (call.relayless && call.state != CallManager.State.ACTIVE) {
Text(
"This server has no call relay, so the call only connects if a direct path "
+ "exists between the two networks.",
style = MaterialTheme.typography.bodySmall,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (call.state == CallManager.State.RINGING && call.incoming) {
Button(
onClick = model::acceptCall,
modifier = Modifier.testTag("accept-call"),
) { Text("Answer") }
TextButton(
onClick = model::declineCall,
modifier = Modifier.testTag("decline-call"),
) { Text("Decline") }
} else {
TextButton(
onClick = { model.setCallMuted(!call.muted) },
modifier = Modifier.testTag("mute-call"),
) { Text(if (call.muted) "Unmute" else "Mute") }
TextButton(
onClick = model::hangUp,
modifier = Modifier.testTag("end-call"),
) { Text("Hang up", color = MaterialTheme.colorScheme.error) }
}
}
}
}
/**
* The kit's `Recent/White` row: 56dp photo circles, an 11sp name centred
* beneath, 24dp gaps — every contact one tap from their chat. Shared by the
* Home screen and the Messages tab, so the two rows cannot drift.
*/
@Composable
fun BuddyCirclesRow(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
/** Long-press: the contact's profile. Tap stays a chat. */
onOpenProfile: ((Long) -> Unit)? = null,
) {
androidx.compose.foundation.lazy.LazyRow(
modifier = Modifier.fillMaxWidth().testTag("buddy-circles"),
horizontalArrangement = Arrangement.spacedBy(24.dp),
) {
items(phase.buddies.size) { i ->
val buddy = phase.buddies[i]
val circleName = buddy.displayName.ifBlank { buddy.handle }
.ifBlank { "user ${buddy.userId}" }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.pointerInput(buddy.userId) {
detectTapGestures(
onTap = { model.openChat(buddy.userId, circleName) },
onLongPress = { onOpenProfile?.invoke(buddy.userId) },
)
}
.testTag("buddy-circle-${buddy.userId}"),
) {
Avatar(
name = circleName,
online = buddy.presence == "online",
picture = phase.avatars[buddy.userId],
model = model,
size = 56.dp,
)
Text(
circleName,
fontSize = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(60.dp),
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
}
}
}
/**
* Look-up-then-add, shared by Home (its Add action) and anywhere else that
* adds a person. Extracted so the form exists exactly once.
*/
@Composable
fun ColumnScope.AddContactForm(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
/** Prefill — a scanned address lands here. The person still looks it up. */
initial: String = "",
onDone: () -> Unit,
) {
var username by remember(initial) { mutableStateOf(initial) }
// Supervision: adding someone is the guarded act. The form only renders
// once the parent's PIN has been entered, and the unlock lasts for this
// form's life, not the app's.
val gateContext = androidx.compose.ui.platform.LocalContext.current
var supervisionUnlocked by remember { mutableStateOf(false) }
if (ParentalControls.contactsGated(gateContext) && !supervisionUnlocked) {
var gatePin by remember { mutableStateOf("") }
var gateWrong by remember { mutableStateOf(false) }
Text("Supervision is on — a parent's PIN is needed to add a contact.",
fontSize = 12.sp, color = Palette.SecondaryText)
Spacer(Modifier.height(Space.xs))
OutlinedTextField(
value = gatePin, onValueChange = { gatePin = it; gateWrong = false },
placeholder = { Text("Parent PIN", fontSize = 14.sp) },
singleLine = true,
visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(),
isError = gateWrong,
modifier = Modifier.fillMaxWidth().testTag("contact-gate-pin"),
)
if (gateWrong) {
Text("That's not the PIN.", fontSize = 12.sp,
color = MaterialTheme.colorScheme.error)
}
Spacer(Modifier.height(Space.xs))
KitPrimaryButton(
text = "Unlock",
onClick = {
if (ParentalControls.verify(gateContext, gatePin)) supervisionUnlocked = true
else gateWrong = true
gatePin = ""
},
enabled = gatePin.isNotEmpty(),
modifier = Modifier.fillMaxWidth(),
tag = "contact-gate-unlock",
)
return
}
// 🔴 **A scanned code is an ADDRESS, and this form treated everything
// as a username.** The scanner already prefills here, so somebody who
// scanned a card from another relay got their address looked up as a
// handle on ours, was told no such person exists, and had no other way
// in. One field either way — what it is, is a fact about the text, not
// a mode the person has to pick.
val card = remember(username) { ContactCard.decode(username) }
OutlinedTextField(
value = username,
onValueChange = {
username = it
if (phase.lookedUp != null || phase.lookupMiss != null) model.clearLookup()
},
label = { Text(if (card != null) "Address" else "Username or address") },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("new-contact"),
)
if (card != null) {
Text(
"That's an address on ${card.relay}. Messages go straight there — nothing "
+ "to add to your contacts first, and you don't need an account on it.",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.testTag("address-recognised"),
)
KitPrimaryButton(
text = "Message them",
onClick = { model.openAddressChat(username); onDone() },
enabled = !phase.busy,
modifier = Modifier.fillMaxWidth(),
tag = "message-address",
)
} else {
// ⚠️ **Look up first, then add.** Adding straight from a typed
// string means a typo silently becomes a contact who is a stranger,
// and the first thing sent to them is a message meant for somebody
// else.
KitPrimaryButton(
text = "Look up",
onClick = { model.lookupMember(username) },
enabled = username.isNotBlank() && !phase.busy,
modifier = Modifier.fillMaxWidth(),
tag = "lookup-contact",
)
}
phase.lookupMiss?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.testTag("lookup-miss"),
)
}
phase.lookedUp?.let { found ->
Column(
modifier = Modifier.fillMaxWidth().testTag("lookup-result"),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(found.displayName.ifBlank { found.handle })
Text("@${found.handle}", style = MaterialTheme.typography.bodySmall)
// Named plainly: identity is checked by comparing safety
// numbers once the conversation exists, and a lookup result is
// not a substitute for that.
Text(
"Check their safety number after you start talking — this only says the "
+ "handle exists on this server.",
style = MaterialTheme.typography.bodySmall,
)
}
}
Button(
onClick = {
model.addBuddy(username.trim())
username = ""
onDone()
model.clearLookup()
},
enabled = phase.lookedUp != null && !phase.busy,
modifier = Modifier.fillMaxWidth().testTag("confirm-contact"),
) { Text("Add contact") }
}
/** P1: where the contact list lives, and the door that moves it. */
@Composable
fun ContactListPrivacySection(model: MessengerViewModel) {
var confirming by remember { mutableStateOf(false) }
val off = model.rosterIsOffServer()
Text("Contact list", style = MaterialTheme.typography.titleMedium)
if (off) {
Text(
"Your contact list lives only on your devices — kept in this "
+ "phone's encrypted store, and carried by your encrypted "
+ "history export. The server no longer knows who your "
+ "contacts are.",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
} else {
Text(
"Today the server stores who your contacts are, because your "
+ "online status is shown only to people on that list. Removing "
+ "it keeps contacts in this phone's encrypted store instead — "
+ "the honest cost is that buddies will see you as offline, and "
+ "a new device gets your contacts from your encrypted history "
+ "export rather than the server. Do this after your other "
+ "devices have updated to this version.",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.sm))
if (!confirming) {
OutlinedButton(
onClick = { confirming = true },
modifier = Modifier.testTag("roster-forget"),
) { Text("Take my contact list off the server") }
} else {
Text("The server's copy is deleted, and buddies will see you as "
+ "offline from now on.",
fontSize = 12.sp, color = Palette.SecondaryText)
Spacer(Modifier.height(Space.xs))
Row {
OutlinedButton(
onClick = { model.takeRosterOffServer(); confirming = false },
modifier = Modifier.testTag("roster-forget-really"),
) { Text("Really remove it", color = MaterialTheme.colorScheme.error) }
Spacer(Modifier.width(Space.sm))
OutlinedButton(onClick = { confirming = false }) { Text("Cancel") }
}
}
}
}
/**
* The account's registered devices — every one is a full mailbox and a prekey
* pool on the relay, which caps them at three.
*
* 🔴 **This card is the ONLY way off the cap.** Registration refuses a fourth
* device, and every reinstall that didn't sign out leaves a corpse behind —
* sends still fan out to it, and it counts against the limit. The remedy has
* to be user-visible, or "the limit is 3" is a support ticket.
*/
@Composable
fun RegisteredDevicesSection(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
) {
LaunchedEffect(Unit) { model.refreshDevices() }
var confirming by remember { mutableStateOf<String?>(null) }
Text("Registered devices", style = MaterialTheme.typography.titleMedium)
Text(
"${phase.registeredDevices.size} of 3 registered. Each device holds its "
+ "own keys and its own mailbox; removing one deletes its undelivered "
+ "messages and signs it out for good.",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.sm))
if (phase.registeredDevices.isEmpty()) {
Text(
"Couldn't load the device list. Check your connection.",
fontSize = 14.sp,
color = Palette.SecondaryText,
)
return
}
phase.registeredDevices
.sortedBy { it.createdAt ?: "" }
.forEach { device ->
val isThis = device.deviceId == phase.deviceId
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = Space.xs)
.testTag("device-${device.deviceId}"),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
device.deviceId.take(8),
fontSize = 14.sp,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
)
if (isThis) {
Spacer(Modifier.width(Space.sm))
Text(
"This device",
fontSize = 11.sp,
color = Palette.Accent,
fontWeight = FontWeight.SemiBold,
)
}
}
Text(
registeredLine(device.createdAt),
fontSize = 12.sp,
color = Palette.SecondaryText,
)
}
// ⚠️ The device you are holding is not removable from itself —
// that is Sign out, which also wipes the local keys. Removing
// it here would leave this app confidently talking to a
// mailbox that no longer exists.
if (!isThis) {
if (confirming == device.deviceId) {
androidx.compose.material3.TextButton(
onClick = {
confirming = null
model.removeDevice(device.deviceId)
},
modifier = Modifier.testTag("confirm-remove-${device.deviceId}"),
) {
Text("Really remove", color = MaterialTheme.colorScheme.error)
}
} else {
androidx.compose.material3.TextButton(
onClick = { confirming = device.deviceId },
modifier = Modifier.testTag("remove-${device.deviceId}"),
) { Text("Remove") }
}
}
}
}
}
/**
* ⚠️ Presets, not a free-form duration. Every extra option is a decision the
* user has to make about something they mostly want to be roughly right, and
* the arbitrary values are the ones nobody can reason about later.
*/
private val DISAPPEAR_CHOICES = listOf(
"Off" to 0,
"1 hour" to 3_600,
"1 day" to 86_400,
"1 week" to 604_800,
)
private val DISAPPEAR_LABELS = mapOf(
3_600 to "an hour",
86_400 to "a day",
604_800 to "a week",
)
/**
* "Registered <date>" for one device row.
*
* 🔴 **This shipped rendering its own source code.** The string was written
* `"Registered ${'$'}{fmt.format(instant)}"`, and `${'$'}` emits a LITERAL
* dollar sign — so the template was never evaluated and the screen showed
* `Registered ${fmt.format(instant)}` to the user. Kotlin cannot warn about
* it: escaping a dollar is a legitimate thing to want, just never here.
*
* Lifted out of the Composable so it can be asserted. A string built inline in
* a `Text(...)` is a string nothing can test, which is why this survived.
*/
internal fun registeredLine(createdAt: String?): String {
val raw = createdAt ?: return "Registered date unknown"
return runCatching {
val instant = java.time.Instant.parse(raw)
val fmt = java.time.format.DateTimeFormatter
.ofPattern("MMM d, yyyy HH:mm")
.withZone(java.time.ZoneId.systemDefault())
"Registered ${fmt.format(instant)}"
}.getOrDefault("Registered $raw")
}
/**
* How photos are sent.
*
* 🔴 **This app sent the ORIGINAL file.** The picker read the bytes and handed
* them straight on, so a modern camera photo — often 5-15 MB — went at the
* relay's blob cap directly: over it the send simply failed, under it the
* recipient downloaded tens of megabytes nobody asked for. iOS has downscaled
* since it shipped, so the two platforms disagreed about what sending a photo
* even means.
*/
@Composable
private fun ColumnScope.MediaQualitySection() {
val context = LocalContext.current
val settings = remember { MediaSettings(context) }
var full by remember { mutableStateOf(settings.fullQuality) }
var previews by remember { mutableStateOf(settings.linkPreviews) }
Text("Photos", style = MaterialTheme.typography.titleMedium)
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm),
verticalAlignment = Alignment.CenterVertically,
) {
Text("Send full quality", fontSize = 16.sp, modifier = Modifier.weight(1f))
Switch(
checked = full,
onCheckedChange = { on -> settings.fullQuality = on; full = on },
modifier = Modifier.testTag("full-quality-media"),
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm),
verticalAlignment = Alignment.CenterVertically,
) {
Text("Preview links you send", fontSize = 16.sp, modifier = Modifier.weight(1f))
Switch(
checked = previews,
onCheckedChange = { on -> settings.linkPreviews = on; previews = on },
modifier = Modifier.testTag("link-previews"),
)
}
// Says exactly who learns what. The receiving side is the part people
// assume is leaky and is the part that is not.
Text(
"Link previews are fetched by THIS phone before the message is sent, and " +
"travel inside the encryption — whoever you send to never contacts the " +
"site, so the link cannot tell anyone that they read it. Turning this off " +
"stops this phone fetching anything either.",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
// ⚠️ Says what the ceiling is rather than implying there is none. "Full
// quality" that silently failed on a large photo would be a worse answer
// than the compression it replaced.
Text(
"Photos are sent at up to " + (if (full) "4096" else "2048") + " pixels on the "
+ "longest side. Very large photos are reduced further until they fit what "
+ "the server accepts — a photo that failed to send would be worse than a "
+ "slightly softer one.",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
}
private val SNOOZE_CHOICES = listOf(
"Off" to 0,
"1 hour" to 3_600,
"8 hours" to 28_800,
"1 day" to 86_400,
)
private val SEND_LATER_CHOICES = listOf(
"In 1 hour" to 3_600,
"In 3 hours" to 10_800,
"Tomorrow" to 86_400,
)
private val REMINDER_CHOICES = listOf(
"Off" to 0,
"1 hour" to 3_600,
"3 hours" to 10_800,
"Tomorrow" to 86_400,
)
/**
* The card under a message containing a link (spec §6.12).
*
* 🔴 **Drawn entirely from bytes the sender put in the ciphertext.** Nothing
* here fetches: a card each recipient fetched would tell the site the reader's
* IP, that they received a link, and roughly when they opened it — once per
* member on a group message.
*/
@Composable
private fun LinkPreviewCard(
preview: io.flamenet.e2e.Content.LinkPreview,
mine: Boolean,
) {
val onBubble = if (mine) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurface
Column(
modifier = Modifier
.padding(top = Space.xs)
.fillMaxWidth()
.testTag("link-preview"),
) {
// 🔴 **The host, always, and separately from the title.** Title and
// description come from a page the sender did not write, so a card
// claiming one destination while the link points at another is a
// phishing primitive. The host is the part that cannot lie.
Text(preview.host, fontSize = 11.sp, color = onBubble.copy(alpha = 0.7f))
// ⚠️ Plain text, never markup, and line-limited. Both fields are
// attacker-controlled; they are bounded on the way in as well, so this
// is the second of two limits rather than the only one.
Text(
preview.title,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = onBubble,
maxLines = 2,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
)
if (preview.desc.isNotEmpty()) {
Text(
preview.desc,
fontSize = 12.sp,
color = onBubble.copy(alpha = 0.7f),
maxLines = 3,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
)
}
}
}
/**
* Everything you can set about one conversation, behind ⋯.
*
* 🔴 **These were three full-width panels of chips** — disappearing, snooze,
* remind me, send later — stacked above the messages, and together they took
* half the screen in a conversation you had opened to read. Controls belong in
* a menu; only STATE belongs on the thread, which is what
* [ConversationStatusLine] keeps.
*/
@Composable
private fun ConversationMenu(
model: MessengerViewModel,
chat: MessengerViewModel.Phase.Chat,
draft: String,
onScheduled: () -> Unit,
) {
val peerId = chat.peerId ?: return
var open by remember { mutableStateOf(false) }
val snoozed = chat.threadId in
((model.phase.value as? MessengerViewModel.Phase.SignedIn)?.snoozedThreads ?: emptySet())
Box {
IconAction(icon = KitIcon.More, label = "More", tag = "chat-more",
onClick = { open = true })
androidx.compose.material3.DropdownMenu(
expanded = open,
onDismissRequest = { open = false },
) {
MenuHeading("Disappearing messages")
DISAPPEAR_CHOICES.forEach { (label, seconds) ->
MenuChoice(label, selected = chat.disappearSeconds == seconds,
tag = "disappear-$seconds") {
model.setDisappearing(seconds); open = false
}
}
androidx.compose.material3.HorizontalDivider()
MenuHeading("Snooze this chat")
SNOOZE_CHOICES.forEach { (label, seconds) ->
MenuChoice(label, selected = (seconds == 0) == !snoozed,
tag = "snooze-$seconds") {
model.snooze(peerId, seconds); open = false
}
}
androidx.compose.material3.HorizontalDivider()
MenuHeading("Remind me if no reply")
REMINDER_CHOICES.forEach { (label, seconds) ->
MenuChoice(label, selected = (seconds == 0) == (chat.replyReminderAt == 0L),
tag = "remind-$seconds") {
model.setReplyReminder(peerId, seconds); open = false
}
}
// ⚠️ Only offered when there is something to send. "Send later" with
// an empty composer is a control whose only possible effect is
// nothing.
if (draft.isNotBlank()) {
androidx.compose.material3.HorizontalDivider()
MenuHeading("Send later")
SEND_LATER_CHOICES.forEach { (label, seconds) ->
MenuChoice(label, selected = false, tag = "send-later-$seconds") {
model.scheduleDraft(peerId, draft, seconds)
onScheduled()
open = false
}
}
}
}
}
}
@Composable
private fun MenuHeading(text: String) {
Text(
text,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
color = Palette.SecondaryText,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
@Composable
private fun MenuChoice(
label: String,
selected: Boolean,
tag: String,
onClick: () -> Unit,
) {
androidx.compose.material3.DropdownMenuItem(
text = { Text(if (selected) "$label ✓" else label) },
onClick = onClick,
modifier = Modifier.testTag(tag),
)
}
/**
* One line saying what is currently set on this conversation.
*
* 🔴 **State stays on the thread even though the controls moved.** A
* disappearing timer both people are deleting on, a snooze, a pending reminder
* and a queued message are facts about the room — and a fact you have to open a
* menu to discover is one you will not rely on. Absent entirely when nothing is
* set, so an ordinary conversation carries no chrome at all.
*/
@Composable
private fun ColumnScope.ConversationStatusLine(
model: MessengerViewModel,
chat: MessengerViewModel.Phase.Chat,
) {
val peerId = chat.peerId ?: return
val snoozed = chat.threadId in
((model.phase.value as? MessengerViewModel.Phase.SignedIn)?.snoozedThreads ?: emptySet())
val parts = buildList {
DISAPPEAR_LABELS[chat.disappearSeconds]?.let {
add("disappearing after $it, on both phones")
}
if (snoozed) add("snoozed")
if (chat.replyReminderAt != 0L) add("reminder set")
if (chat.scheduled.isNotEmpty()) {
add(if (chat.scheduled.size == 1) "1 message waiting to send"
else "${chat.scheduled.size} messages waiting to send")
}
}
if (parts.isEmpty()) return
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = Space.xs),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
parts.joinToString(" · ").replaceFirstChar { it.uppercase() } + ".",
fontSize = 12.sp,
color = Palette.SecondaryText,
modifier = Modifier.weight(1f).testTag("conversation-status"),
)
if (chat.scheduled.isNotEmpty()) {
androidx.compose.material3.TextButton(
onClick = { model.cancelSoonestScheduled(peerId) },
modifier = Modifier.testTag("cancel-scheduled"),
) { Text("Cancel") }
}
}
}
/**
* The relays that hold mail for this account (§20.4).
*
* 🔴 **Your address survives a relay disappearing; your mailbox does not.**
* Mail sitting at a relay that goes away is gone with it, and a contact holding
* only that relay's name has nowhere to write. A second mailbox fixes both —
* and only if it is added first, because afterwards there is nothing left to
* publish a new record from and no way to say where you went.
*
* So the copy here does not say "backup" and stop. It says what is protected,
* what is not, and why it has to be done in advance.
*/
@Composable
fun ColumnScope.MailboxesSection(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
) {
var adding by remember { mutableStateOf(false) }
var host by remember { mutableStateOf("") }
var passphrase by remember { mutableStateOf("") }
LaunchedEffect(phase.fnid) { if (phase.fnid != null) model.loadMailboxes() }
SettingsRow(
label = "Mailboxes",
description = "Senders deliver a copy to every relay listed here, so losing one "
+ "does not lose the message. Add one before you need it: if a relay "
+ "disappears while it is your only mailbox your address still works, but "
+ "nobody can find out where you moved to without you telling them.",
) {}
if (phase.mailboxes.isEmpty()) {
Text(
"Mail for you is held at ${phase.relay} only.",
fontSize = 12.sp,
color = Palette.SecondaryText,
modifier = Modifier.testTag("mailboxes-empty"),
)
} else {
phase.mailboxes.forEach { relay ->
Row(
Modifier.fillMaxWidth().padding(vertical = Space.xs),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
relay,
fontSize = 13.sp,
fontFamily = FontFamily.Monospace,
modifier = Modifier.weight(1f).testTag("mailbox-$relay"),
)
when {
relay == phase.relay ->
Text("this device", fontSize = 11.sp, color = Palette.SecondaryText)
// ⚠️ A mailbox in the published record that this phone holds
// no credential for. Mail arrives there and this device
// cannot read it — worth saying rather than drawing as an
// ordinary row. Another of your devices added it, or this
// one was reinstalled.
relay !in phase.readableMailboxes ->
Text("not readable here", fontSize = 11.sp,
color = MaterialTheme.colorScheme.error)
else -> KitChip(
text = "Remove", selected = false,
onClick = { model.removeMailbox(relay) },
tag = "remove-mailbox-$relay")
}
}
}
}
Spacer(Modifier.height(Space.sm))
if (!adding) {
KitChip(text = "Add a mailbox", selected = false,
onClick = { adding = true }, tag = "add-mailbox")
return
}
OutlinedTextField(
value = host,
onValueChange = { host = it },
placeholder = { Text("relay.example.com", fontSize = 14.sp) },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("new-mailbox-host"),
)
Spacer(Modifier.height(Space.xs))
OutlinedTextField(
value = passphrase,
onValueChange = { passphrase = it },
placeholder = { Text("Your passphrase", fontSize = 14.sp) },
singleLine = true,
visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth().testTag("new-mailbox-passphrase"),
)
Spacer(Modifier.height(Space.xs))
Text(
"You need an account on that relay already. Sign up there with the same "
+ "passphrase first — that is what gives you the same address, and it is "
+ "why the passphrase is asked for here.",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.xs))
KitPrimaryButton(
text = "Add",
onClick = {
model.addMailbox(host, passphrase)
// 🔴 The passphrase does not outlive the attempt. It was collected
// for one sign-in at one relay; leaving it in a composable's state
// keeps a copy of the account's secret on screen for as long as the
// section is open.
passphrase = ""
host = ""
adding = false
},
enabled = host.isNotBlank() && passphrase.isNotEmpty() && !phase.busy,
modifier = Modifier.fillMaxWidth(),
tag = "confirm-add-mailbox",
)
}