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:
co-authored by
Claude Opus 4.8
parent
96b43f4e43
commit
0e89003cc0
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user