diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index c7c7a4b..08edc2b 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -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") } + } + } +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 8b03abc..b76d092 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -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()) } diff --git a/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt b/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt index 99ae73a..e8741dc 100644 --- a/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt +++ b/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt @@ -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 }