The chat took half the screen, and the status control was unfindable

**Three panels became a menu.** Disappearing messages, snooze, remind-me and
send-later were four blocks of chips stacked above the messages — in a
conversation you had opened to read, they took half the screen. The controls are
behind ⋯ now.

🔴 **What stays on the thread is the STATE, in one line.** A timer both people
are deleting on, a snooze, a pending reminder, a queued message — those are facts
about the conversation, and a fact you must open a menu to discover is one you
will not rely on. Absent entirely when nothing is set, so an ordinary chat
carries no chrome at all.

⚠️ "Send later" only appears when the composer has something in it. Offered
against an empty box it is a control whose only possible effect is nothing.

**Status was at the bottom of Account, under the address card, as four
unlabelled chips.** It is directly under the profile picture now, with a heading
and a line saying what each state does to other people. The person who asked for
the feature could not find it, which is the same as it not being there.

🔴 **And it showed a guess.** The state defaulted to "online" and nothing ever
read the real value, so the screen showed the default rather than what contacts
could see. It loads from the relay now — an empty body on that route is a read,
so no new endpoint was needed.

engine 177, client 96, app 46.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-29 14:22:19 -04:00
co-authored by Claude Opus 4.8
parent 96b43f4e43
commit 0e89003cc0
3 changed files with 201 additions and 205 deletions
@@ -497,6 +497,11 @@ fun ColumnScope.GroupsSection(
/** 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),
@@ -514,6 +519,16 @@ fun PresencePicker(model: MessengerViewModel, current: String) {
)
}
}
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
@@ -658,6 +673,11 @@ fun ColumnScope.AccountScreen(
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) }
@@ -684,8 +704,6 @@ fun ColumnScope.AccountScreen(
SectionBand()
SettingsSection {
PresencePicker(model, phase.presence)
// ⚠️ 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.
@@ -1355,6 +1373,7 @@ fun ColumnScope.ChatScreen(
onClick = { model.requestCall(chat.peerId) },
)
}
if (chat.peerId != null) ConversationMenu(model, chat, draft) { draft = "" }
chat.group?.let { group ->
IconAction(
icon = KitIcon.LeaveGroup,
@@ -1372,14 +1391,12 @@ fun ColumnScope.ChatScreen(
// identities and no single answer, so this is for one-to-one threads only.
if (chat.peerId != null) IdentityPanel(model, chat)
// Directly on the thread, for the same reason as the identity panel: both
// are facts about this conversation, and a fact the user has to go looking
// for is one they will not rely on.
if (chat.peerId != null) DisappearingPanel(model, chat)
if (chat.peerId != null) AttentionPanel(model, chat, draft) { draft = "" }
if (chat.peerId != null && chat.scheduled.isNotEmpty()) ScheduledBanner(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,
@@ -2603,55 +2620,6 @@ fun RegisteredDevicesSection(
}
}
/**
* The disappearing-message timer for one conversation (§6.11).
*
* 🔴 **On the thread, not in a menu.** The request was "fewer taps to turn on
* and clearer per-conversation state", and both halves are the same problem: a
* timer nobody can see is a timer nobody trusts. This shows what is set and
* changes it in one tap.
*
* ⚠️ Both sides delete. The peer is told inside the ratchet and adopts the same
* timer, so the copy says "for both of you" — a timer only the setter honoured
* would be worse than none, because they would believe their words were gone
* from a device still holding them.
*/
@Composable
private fun ColumnScope.DisappearingPanel(
model: MessengerViewModel,
chat: MessengerViewModel.Phase.Chat,
) {
Column(modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm)) {
Text("Disappearing messages", style = MaterialTheme.typography.titleSmall)
Row(
modifier = Modifier.fillMaxWidth().padding(top = Space.xs),
horizontalArrangement = Arrangement.spacedBy(Space.xs),
verticalAlignment = Alignment.CenterVertically,
) {
DISAPPEAR_CHOICES.forEach { (label, seconds) ->
KitChip(
text = label,
selected = chat.disappearSeconds == seconds,
onClick = { model.setDisappearing(seconds) },
tag = "disappear-$seconds",
)
}
}
Text(
if (chat.disappearSeconds == 0) {
"Messages stay until one of you deletes them."
} else {
"New and existing messages disappear from BOTH phones " +
"${DISAPPEAR_LABELS[chat.disappearSeconds] ?: "after the timer"} " +
"after they are sent. The relay is never told this is set."
},
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = Space.xs),
)
}
}
/**
* ⚠️ Presets, not a free-form duration. Every extra option is a decision the
@@ -2761,108 +2729,6 @@ private fun ColumnScope.MediaQualitySection() {
)
}
/**
* Snooze, and "remind me if no reply", for one conversation.
*
* 🔴 **Both are local to this device and always will be.** "I am ignoring this
* person until Tuesday" and "remind me if they have not answered" are
* statements about a relationship, and the relay has no business holding
* either. The honest cost is that neither follows you to a new phone — the
* alternative is handing a server the list of people you are avoiding.
*/
@Composable
private fun ColumnScope.AttentionPanel(
model: MessengerViewModel,
chat: MessengerViewModel.Phase.Chat,
/** The composer's current text, for "Send later". */
draft: String,
/** Clear the composer once a message has been queued. */
onScheduled: () -> Unit,
) {
val peerId = chat.peerId ?: return
val snoozed = chat.threadId in (model.phase.value as? MessengerViewModel.Phase.SignedIn)
?.snoozedThreads.orEmpty()
Column(modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm)) {
Text("Snooze this chat", style = MaterialTheme.typography.titleSmall)
Row(
modifier = Modifier.fillMaxWidth().padding(top = Space.xs),
horizontalArrangement = Arrangement.spacedBy(Space.xs),
) {
SNOOZE_CHOICES.forEach { (label, seconds) ->
KitChip(
text = label,
selected = seconds == 0 && !snoozed,
onClick = { model.snooze(peerId, seconds) },
tag = "snooze-$seconds",
)
}
}
Text(
if (snoozed) {
"Snoozed. Messages still arrive — this chat just stops asking " +
"for your attention until it wakes."
} else {
"Snoozing stops this chat notifying you for a while. Nothing is " +
"blocked and nothing is lost."
},
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = Space.xs),
)
Spacer(Modifier.height(Space.sm))
Text("Remind me if no reply", style = MaterialTheme.typography.titleSmall)
Row(
modifier = Modifier.fillMaxWidth().padding(top = Space.xs),
horizontalArrangement = Arrangement.spacedBy(Space.xs),
) {
REMINDER_CHOICES.forEach { (label, seconds) ->
KitChip(
text = label,
selected = (seconds == 0) == (chat.replyReminderAt == 0L),
onClick = { model.setReplyReminder(peerId, seconds) },
tag = "remind-$seconds",
)
}
}
Spacer(Modifier.height(Space.sm))
Text("Send later", style = MaterialTheme.typography.titleSmall)
Row(
modifier = Modifier.fillMaxWidth().padding(top = Space.xs),
horizontalArrangement = Arrangement.spacedBy(Space.xs),
) {
SEND_LATER_CHOICES.forEach { (label, seconds) ->
KitChip(
text = label,
selected = false,
onClick = {
// ⚠️ Clears the composer, like an ordinary send. Text
// left sitting there after scheduling is how somebody
// sends the same message twice.
if (draft.isNotBlank()) {
model.scheduleDraft(peerId, draft, seconds)
onScheduled()
}
},
tag = "send-later-$seconds",
)
}
}
Text(
if (chat.replyReminderAt == 0L) {
"Nothing set. A reminder nudges you if this chat goes unanswered."
} else {
// ⚠️ Says the cancellation rule out loud. A reminder that fired
// after they answered would teach people to ignore reminders,
// so the fact that it will not is worth stating.
"Set. It is cancelled automatically the moment they reply."
},
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = Space.xs),
)
}
}
private val SNOOZE_CHOICES = listOf(
"Off" to 0,
@@ -2884,50 +2750,6 @@ private val REMINDER_CHOICES = listOf(
"Tomorrow" to 86_400,
)
/**
* Messages queued to go out later.
*
* ⚠️ Says "when the app next runs" rather than promising a time. The message is
* held on this phone, so a phone that is off at the appointed minute has not
* sent anything — and a line implying otherwise is the one people would rely on
* and be let down by.
*/
@Composable
private fun ColumnScope.ScheduledBanner(
model: MessengerViewModel,
chat: MessengerViewModel.Phase.Chat,
) {
val peerId = chat.peerId ?: return
val next = chat.scheduled.firstOrNull() ?: return
val fmt = remember {
java.time.format.DateTimeFormatter.ofPattern("MMM d, HH:mm")
.withZone(java.time.ZoneId.systemDefault())
}
val when_ = remember(next.first) {
runCatching { fmt.format(java.time.Instant.ofEpochMilli(next.first)) }
.getOrDefault("later")
}
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = Space.xs),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
if (chat.scheduled.size == 1) {
"1 message waiting to send — $when_, when the app next runs."
} else {
"${chat.scheduled.size} messages waiting — next $when_, " +
"when the app next runs."
},
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
androidx.compose.material3.TextButton(
onClick = { model.cancelSoonestScheduled(peerId) },
modifier = Modifier.testTag("cancel-scheduled"),
) { Text("Cancel") }
}
}
/**
* The card under a message containing a link (spec §6.12).
@@ -2977,3 +2799,150 @@ private fun LinkPreviewCard(
}
}
}
/**
* 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") }
}
}
}
@@ -513,6 +513,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
fun refreshBuddies() = viewModelScope.launch {
val current = session ?: return@launch
refreshPresence()
// Snoozes expire by the clock passing and nothing runs at that moment,
// so the set is recomputed whenever the list is drawn rather than
// cached — a stale flag leaves a conversation muted past its deadline.
@@ -883,6 +884,22 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
}
/** Recompute which threads are snoozed right now. */
/**
* Load the presence the relay actually holds for us.
*
* 🔴 **Without this the chip is a guess.** The state defaulted to "online"
* and nothing ever read the real value, so the screen showed whatever the
* default was rather than what contacts could see — including after a
* relaunch, when "appear offline" had been chosen and forgotten.
*/
fun refreshPresence() = viewModelScope.launch {
val current = session ?: return@launch
val stored = withContext(Dispatchers.IO) {
runCatching { current.api.currentStatus() }.getOrNull()
} ?: return@launch
update { it.copy(presence = displayPresence(stored)) }
}
fun refreshSnoozed() {
val current = session ?: return
update { it.copy(snoozedThreads = current.snoozedThreads()) }
@@ -122,6 +122,16 @@ class MessengerApi(
* ⚠️ The answer is not always the word sent: `offline` is stored as
* `invisible`, which is what "appear offline" has always meant.
*/
/**
* Read the status the relay currently holds for us, without changing it.
*
* ⚠️ An empty body is a READ. The route only writes when `status` is
* present, and answers with the stored value either way — so this needs no
* route of its own, and cannot accidentally set something.
*/
fun currentStatus(): String =
post("status", JSONObject()).optString("status").ifEmpty { "online" }
fun setStatus(status: String): String {
val reply = post("status", JSONObject().put("status", status))
return reply.optString("status").ifEmpty { status }