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>
3510 lines
147 KiB
Kotlin
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",
|
|
)
|
|
}
|