From 21796152635660eecdbd3b7c831231f88e85bcb2 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Thu, 3 Sep 2026 15:11:21 -0400 Subject: [PATCH] Stories you can answer, and an @ that finds people MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The story features existed in the codec and nowhere a person could reach them. + went straight to the file chooser, so a story of words was unreachable even though the engine could carry one; there was no way to react, reply, mute, or see what anybody had said back. + is a menu now. Reactions are a fixed row of five — a story is glanced at and scrolled past, and anything that takes two taps to say "nice" does not get used. Your own story shows the tally instead, because you cannot react to yourself and a row of dead buttons is worse than none. Mentions get an `@` picker in the group composer, matching iOS down to the rule that matters: the id is recorded when somebody is PICKED, never re-derived from the text at send time. Deriving it would mean typing a name reached that person whether or not they were chosen, and renaming a contact would silently unmention them. Only the ones still named in the body are sent, so picking somebody and then deleting their name doesn't ring them about a message that no longer mentions them. A group member who isn't in your contacts has no name to show, so they appear under their id. Un-mentionable would be worse than ugly — it would mean a group where some people cannot be reached. Mentions on Android are carried and rendered but override nothing: there is no per-conversation notification mute here to override yet. 640 unit tests green across app, engine and client. PARITY.md updated — mentions and the story features move to ✅, and Files and Wallet get rows saying plainly that Android has none of either. Co-Authored-By: Claude Opus 4.8 --- PARITY.md | 26 ++- .../io/flamenet/messenger/app/ChatScreens.kt | 105 ++++++++- .../io/flamenet/messenger/app/MainActivity.kt | 3 +- .../messenger/app/MessengerViewModel.kt | 76 ++++++- .../io/flamenet/messenger/app/Session.kt | 114 +++++++++- .../io/flamenet/messenger/app/StoriesFeed.kt | 200 +++++++++++++++++- .../messenger/app/MentionPickerTest.kt | 81 +++++++ .../io/flamenet/client/Conversations.kt | 61 +++++- .../src/main/kotlin/io/flamenet/e2e/Vault.kt | 20 ++ 9 files changed, 657 insertions(+), 29 deletions(-) create mode 100644 app/src/test/kotlin/io/flamenet/messenger/app/MentionPickerTest.kt diff --git a/PARITY.md b/PARITY.md index 341c05f..0d2f2d6 100644 --- a/PARITY.md +++ b/PARITY.md @@ -98,14 +98,15 @@ From `E2E/E2EInbox.swift`, which is the real feature list. | Drafts that survive a relaunch | ✅ sealed in the vault, not SharedPreferences | | Forwarding (§6.16) | 🟡 codec done, in progress — an inbound forward is recognised and shown as one; originating needs #10 | | Group message ids and quotes (§6.9) | ✅ transcript stores both; sent and queued copies share one id | -| Mentions (§6.18) | 🟡 codec done, in progress — an inbound mention parses and is bounded; composing and notifying need the UI | +| Mentions (§6.18) | ✅ codec, an `@` picker in the group composer, and ids recorded at pick time rather than re-derived from the text | | Per-message timers (§6.17) | ✅ combining rule and per-message pruning; composer control in progress | | Reactions | 🟡 codec done, no UI | | Search inside messages | ✅ scanned, never indexed — no plaintext index at rest | | Pinned message (§6.19) | 🟡 codec done, in progress — an inbound pin is recognised and not drawn as an empty bubble | | Photos | ✅ send via the system picker, render inline | | Voice notes (record + play) | ✅ AAC/MP4, tap to record, tap to send | -| Stories, and seen-state | ✅ 24h, sent per-contact, expired bytes deleted | +| Stories, and seen-state | ✅ sent per-contact, expired bytes deleted | +| Stories: text-only, lifetime, reactions, replies, mute | ✅ a menu on + rather than straight to the picker; lifetime rides inside the sealed envelope | | Groups: create, list, members, leave | ✅ members editable after creation | | Group text and images | ✅ one upload, one envelope per member | | Delivery keys + rotation | ✅ published as a digest, rotatable from Account | @@ -149,6 +150,27 @@ emulator. --- +## Files and Wallet + +From `Sources/Views/Files/*` and `Sources/Wallet/*`, both of which moved into +the messenger rather than staying separate apps. + +| Feature | Android | +|---|:--:| +| Local vault: add, organise, delete | ⚪ | +| Bell–LaPadula labels and clearance | ⚪ | +| Share a file into a chat or group | ⚪ | +| Wallet: BIP-39 seed, create and restore | ⚪ | +| Wallet: fresh receive address per receive | ⚪ | +| Wallet: balance and transactions from a node | ⚪ | + +⚠️ Nothing here exists on Android yet. The iOS side is built and tested; the +engine work it rests on (BIP-39, SLIP-0010, Base58, the label encoding) is +Swift-only and has no Kotlin counterpart, so this is the largest remaining gap +and it is a build, not a port of a screen. + +--- + ## Settings Every key in `Views/Settings/SettingsKeys.swift` has to exist, because each one 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 3c7c06e..2889fe4 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -5,6 +5,8 @@ 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.horizontalScroll +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable @@ -1618,8 +1620,15 @@ fun ColumnScope.ChatScreen( buddies: List = emptyList(), /** Pictures, so a received bubble can carry its author's. */ avatars: Map = emptyMap(), + /** Ours, so the mention list never offers you yourself. */ + myUserId: Long = 0L, ) { var draft by remember { mutableStateOf("") } + // 🔴 Recorded by ID at the moment of picking, never re-derived from the + // text when sending. Deriving it would mean typing somebody's name reached + // them whether or not they were chosen, and renaming a contact would + // silently unmention them. Same rule as iOS. + var pendingMentions by remember { mutableStateOf(emptySet()) } val listState = rememberLazyListState() // Open AT the newest message — a jump, not a tour of the history. @@ -1822,6 +1831,62 @@ fun ColumnScope.ChatScreen( // 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. + // 🔴 Groups only. There is nobody to disambiguate in a one-to-one chat, + // and an @ there is just a character. + val mentionable = chat.group?.let { group -> + val query = mentionQuery(draft) + if (query == null) emptyList() + else group.memberIds.filter { id -> + id != myUserId && + (query.isEmpty() || + memberLabel(id, buddies).contains(query, ignoreCase = true)) + } + } ?: emptyList() + + if (mentionable.isNotEmpty()) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(bottom = Space.xs) + .testTag("mentions"), + horizontalArrangement = Arrangement.spacedBy(Space.xs), + ) { + mentionable.forEach { id -> + val label = memberLabel(id, buddies) + Text( + label, + fontSize = 14.sp, + color = Palette.Accent, + modifier = Modifier + .clip(RoundedCornerShape(Radius.small)) + .background(Palette.Accent.copy(alpha = 0.12f)) + .clickable { + val at = draft.lastIndexOf('@') + if (at >= 0) { + draft = draft.substring(0, at) + "@" + label + " " + pendingMentions = pendingMentions + id + } + } + .padding(horizontal = 12.dp, vertical = 6.dp) + .testTag("mention-$id"), + ) + } + } + } + + // ⚠️ Only the ones still named in the text. Picking somebody and then + // deleting their name would otherwise still ring them — from their side, a + // notification about a message that does not mention them. + val sendDraft: () -> Unit = { + val named = pendingMentions + .filter { draft.contains("@" + memberLabel(it, buddies)) } + .map { it.toInt() } + model.sendMessage(draft, named) + draft = "" + pendingMentions = emptySet() + } + Row( modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm), verticalAlignment = Alignment.CenterVertically, @@ -1860,12 +1925,7 @@ fun ColumnScope.ChatScreen( 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) - draft = "" - } - } + onSend = { if (draft.isNotBlank()) sendDraft() } ), modifier = Modifier.fillMaxWidth().testTag("draft"), ) @@ -1920,10 +1980,7 @@ fun ColumnScope.ChatScreen( onClick = { pickImage.launch("image/*") }) } else { Button( - onClick = { - model.sendMessage(draft) - draft = "" - }, + onClick = sendDraft, shape = CircleShape, contentPadding = PaddingValues(0.dp), modifier = Modifier.size(Kit.pillHeight).testTag("send"), @@ -3564,3 +3621,31 @@ private fun classificationName(ordinal: Int): String = when (ordinal) { 3 -> "Restricted" else -> "Level $ordinal" } + +/** + * What is being typed after an unfinished `@`, or null when there isn't one. + * + * 🔴 Driven off the draft rather than a mode flag: somebody deleting their way + * back past the `@` expects the list to go, and a flag has to be remembered to + * be cleared. A space ends it — "@ " is somebody typing an address. + */ +internal fun mentionQuery(draft: String): String? { + val at = draft.lastIndexOf('@') + if (at < 0) return null + val after = draft.substring(at + 1) + if (after.contains(' ') || after.contains('\n')) return null + return after +} + +/** + * A group member by the name you know them by. + * + * ⚠️ The relay stores ids and no names, so somebody who is in the group but not + * in your contacts has no name to show. They stay mentionable under their id + * rather than being hidden — being un-mentionable is worse than being ugly. + */ +internal fun memberLabel( + id: Long, + buddies: List, +): String = buddies.firstOrNull { it.userId == id } + ?.let { it.displayName.ifBlank { it.handle } } ?: "user $id" diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt index f6f67f8..07188dc 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -232,7 +232,8 @@ private fun Root(model: MessengerViewModel = viewModel()) { val chat = current.openChat if (chat != null) { - ChatScreen(model, chat, current.error, current.buddies, current.avatars) + ChatScreen(model, chat, current.error, current.buddies, current.avatars, + myUserId = current.userId) } else { // 🔴 **The content area is weighted, and the tab bar is // not.** These used to be plain siblings, so the tab bar 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 620f1a1..c25745d 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -1953,6 +1953,78 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { streamUrl?.takeIf { it.isNotBlank() }) } + /** + * Post a story that is only words. + * + * Same audience rule as a picture: the audience decides who is SENT it, + * because there is no copy on the relay that anybody could be denied. + */ + fun postTextStory(text: String) = viewModelScope.launch { + val current = session ?: return@launch + val contacts = signedIn()?.buddies?.map { it.userId }.orEmpty() + if (contacts.isEmpty()) { + update { it.copy(error = "Add a contact before posting a story.") } + return@launch + } + val recipients = current.storyAudience().recipients(contacts) + if (recipients.isEmpty()) { + update { + it.copy(error = "Nobody is in your story audience, so this " + + "would go to no one. Change who can see your stories on Account.") + } + return@launch + } + update { it.copy(busy = true, error = null) } + val done = withContext(Dispatchers.IO) { + runCatching { + current.postTextStory(text, recipients, + MediaSettings(getApplication()).storyLifetimeSeconds) + } + } + done.onSuccess { posted -> + update { + it.copy(busy = false, stories = current.stories(), + error = if (posted.missed == 0) null + else "Posted to ${posted.reached} of " + + "${posted.reached + posted.missed} contacts.") + } + }.onFailure { failure -> + update { it.copy(busy = false, error = failure.message ?: "Couldn't post that.") } + } + } + + /** Counts per emoji, for the author's own view of their story. */ + fun storyTally(storyKey: String): Map = + session?.reactionsFor(storyKey).orEmpty().values + .groupingBy { it }.eachCount() + + fun myStoryReaction(storyKey: String): String? = session?.myReactionTo(storyKey) + + /** React to somebody's story. Goes to the author alone. */ + fun reactToStory(emoji: String, story: Vault.Story) = viewModelScope.launch { + val current = session ?: return@launch + withContext(Dispatchers.IO) { runCatching { current.reactToStory(emoji, story) } } + update { it.copy(stories = current.stories()) } + } + + /** Reply privately to somebody's story, in the ordinary conversation. */ + fun replyToStory(body: String, story: Vault.Story) = viewModelScope.launch { + val current = session ?: return@launch + val done = withContext(Dispatchers.IO) { + runCatching { current.replyToStory(body, story) } + } + done.onFailure { failure -> + update { it.copy(error = failure.message ?: "Couldn't send that reply.") } + } + } + + /** Stop showing somebody's stories on this device. */ + fun setStoriesMuted(authorId: Long, muted: Boolean) = viewModelScope.launch { + val current = session ?: return@launch + withContext(Dispatchers.IO) { current.setStoriesMuted(authorId, muted) } + update { it.copy(stories = current.stories()) } + } + fun postStory(bytes: ByteArray, mime: String, width: Int, height: Int, caption: String?, /** THIS story's audience from the composer; null = the account setting. */ audienceOverride: Session.StoryAudience? = null, @@ -2180,7 +2252,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { }.onFailure { failure -> update { it.copy(error = describe(failure)) } } } - fun sendMessage(text: String) = viewModelScope.launch { + fun sendMessage(text: String, mentions: List = emptyList()) = viewModelScope.launch { val current = session ?: return@launch val chat = signedIn()?.openChat ?: return@launch if (text.isBlank()) return@launch @@ -2189,7 +2261,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { runCatching { val group = chat.group val address = chat.address - if (group != null) current.sendGroup(group, text) + if (group != null) current.sendGroup(group, text, mentions) // 🔴 Before the `peerId!!` below, because a remote thread has // no peer id and that assertion would crash rather than send. // diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index 4374c31..bac0ecd 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -646,8 +646,9 @@ class Session( } .getOrNull() - fun sendGroup(group: RelayClient.Group, text: String): Conversations.Sent { - val sent = conversations.sendToGroup(group, text, userId) + fun sendGroup(group: RelayClient.Group, text: String, + mentions: List = emptyList()): Conversations.Sent { + val sent = conversations.sendToGroup(group, text, userId, mentions) val at = System.currentTimeMillis() record(Vault.threadForGroup(group.id), text, mine = true, author = userId, at = at, delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING) @@ -657,7 +658,7 @@ class Session( // the group thread to restate. A member whose devices took it is not // queued — resending to them would duplicate the message. val body = io.flamenet.e2e.Content.groupText( - text, group.id, groupName = vault.groupName(group.id)) + text, group.id, groupName = vault.groupName(group.id), mentions = mentions) sent.failed.keys.filter { it.startsWith("user:") } .mapNotNull { it.removePrefix("user:").toLongOrNull() } .forEach { member -> @@ -723,6 +724,8 @@ class Session( val touched = mutableSetOf() val now = System.currentTimeMillis() / 1000.0 val incomingStories = mutableListOf() + // storyKey, who reacted, the emoji ("" takes one back). + val storyReactionsIncoming = mutableListOf>() for (message in arrived) { // ⚠️ **A story bypasses the thread entirely (§6.6).** It belongs to @@ -730,6 +733,33 @@ class Session( // somebody posted into a one-to-one conversation they did not have. // One that arrives already expired — posted while this device was // offline — is dropped rather than shown late. + // A story that is only words. Checked before the attachment path, + // which has nothing to key on for one. + val textExpires = message.content.textStoryExpires + if (textExpires != null && message.fromAddress == null) { + val story = Vault.Story( + id = message.content.messageId ?: java.util.UUID.randomUUID().toString(), + authorId = message.fromUser, + mine = false, + ref = null, + caption = message.content.text, + postedAt = System.currentTimeMillis(), + expiresAt = textExpires, + ) + if (story.isActive(now)) incomingStories += story + continue + } + // A reaction to one of OUR stories. Folded onto the story rather + // than filed as a message — and never notified, for the same + // reason a thumbs-up on a message is not. + if (message.content.reactsToStory) { + val about = message.content.about + if (about != null) { + storyReactionsIncoming += Triple( + about.messageId, message.fromUser, message.content.reaction.orEmpty()) + } + continue + } val ref = message.content.attachment if (ref != null && ref.kind == Attachment.STORY && message.fromAddress == null) { val story = Vault.Story( @@ -809,15 +839,91 @@ class Session( val known = existing.map { it.id }.toSet() vault.saveStories(existing + incomingStories.filterNot { it.id in known }, now) } + storyReactionsIncoming.forEach { (key, who, emoji) -> + applyStoryReaction(key, who, emoji) + } return touched } // -- Stories (§6.6) ------------------------------------------------------ + /** + * Who reacted to which story, and with what. + * + * ⚠️ One reaction per person, exactly like a message: a second emoji + * replaces the first rather than accumulating, or a reaction becomes a way + * to spam somebody's story. + * + * Session-lived, like `seen`. A reaction to a story that expires tomorrow + * does not need to outlive it. + */ + private val storyReactions = mutableMapOf>() + + fun applyStoryReaction(storyKey: String, who: Long, emoji: String) { + val forStory = storyReactions.getOrPut(storyKey) { mutableMapOf() } + if (emoji.isEmpty()) forStory.remove(who) else forStory[who] = emoji + if (forStory.isEmpty()) storyReactions.remove(storyKey) + } + + fun reactionsFor(storyKey: String): Map = storyReactions[storyKey].orEmpty() + + fun myReactionTo(storyKey: String): String? = storyReactions[storyKey]?.get(userId) + + fun reactToStory(emoji: String, story: Vault.Story) { + if (story.mine) return + conversations.reactToStory(emoji, story.id, story.authorId) + applyStoryReaction(story.id, userId, emoji) + } + + fun replyToStory(body: String, story: Vault.Story) { + conversations.replyToStory(body, story.id, story.authorId, + story.caption.take(80)) + } + + fun postTextStory(text: String, toUserIds: List, lifetimeSeconds: Double) + : io.flamenet.client.Conversations.TextStoryPosted { + val posted = conversations.postTextStory(text, toUserIds, lifetimeSeconds) + if (posted.reached > 0) { + val now = System.currentTimeMillis() / 1000.0 + vault.saveStories( + vault.loadStories() + Vault.Story( + id = posted.key, + authorId = userId, + mine = true, + ref = null, + caption = text, + postedAt = System.currentTimeMillis(), + seen = true, + expiresAt = posted.expires, + ), + now, + ) + } + return posted + } + + /** + * 🔴 Muting somebody's stories is NOT muting their messages, and uses its + * own key. Wanting somebody's news to stop is not wanting to stop hearing + * from them, and folding the two together would make "I don't need their + * holiday photos" cost a missed message. It is not blocking either: + * nothing is told to the other person and nothing is refused. + */ + fun storiesMuted(authorId: Long): Boolean = vault.storyMutes().contains(authorId) + + fun setStoriesMuted(authorId: Long, muted: Boolean) { + val current = vault.storyMutes().toMutableSet() + if (muted) current += authorId else current -= authorId + vault.saveStoryMutes(current) + } + /** Everything still live, newest first. */ fun stories(): List { val now = System.currentTimeMillis() / 1000.0 - val live = vault.loadStories().filter { it.isActive(now) } + val live = vault.loadStories() + .filter { it.isActive(now) } + // One place, so every screen agrees about whose stories are hidden. + .filter { it.mine || !storiesMuted(it.authorId) } // Written back so expired bytes leave the device rather than lingering // until the next post. vault.saveStories(live, now) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt index 2551e26..7e7c067 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt @@ -33,6 +33,14 @@ import androidx.compose.ui.graphics.asImageBitmap import io.flamenet.e2e.Vault import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.TextButton +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue /** * Stories as a feed you scroll, which is the kit's Timeline. @@ -56,6 +64,7 @@ fun ColumnScope.StoriesFeed( phase: MessengerViewModel.Phase.SignedIn, ) { LaunchedEffect(Unit) { model.refreshStories() } + var writingWords by remember { mutableStateOf(false) } // Post from HERE too. The strip on Messages was the only way in, which // means the tab named Stories could show them but not make one. @@ -76,14 +85,41 @@ fun ColumnScope.StoriesFeed( style = MaterialTheme.typography.headlineMedium, modifier = Modifier.weight(1f), ) - Icon( - KitIcon.Add, - contentDescription = "New story", - tint = Palette.Accent, - modifier = Modifier - .size(28.dp) - .clickable { pickStory.launch("*/*") } - .testTag("new-story"), + // 🔴 A menu, not a picker. + // + // This went straight to the file chooser, so there was no way to reach + // anything else — a story of words was unreachable even once it + // existed, for the same reason it was on iOS. + var choosing by remember { mutableStateOf(false) } + Box { + Icon( + KitIcon.Add, + contentDescription = "New story", + tint = Palette.Accent, + modifier = Modifier + .size(28.dp) + .clickable { choosing = true } + .testTag("new-story"), + ) + DropdownMenu(expanded = choosing, onDismissRequest = { choosing = false }) { + DropdownMenuItem( + text = { Text("Photo or video") }, + onClick = { choosing = false; pickStory.launch("*/*") }, + modifier = Modifier.testTag("new-story-photo"), + ) + DropdownMenuItem( + text = { Text("Just words") }, + onClick = { choosing = false; writingWords = true }, + modifier = Modifier.testTag("new-story-text"), + ) + } + } + } + + if (writingWords) { + TextStoryComposer( + onDismiss = { writingWords = false }, + onPost = { text -> writingWords = false; model.postTextStory(text) }, ) } Spacer(Modifier.height(Space.sm)) @@ -200,6 +236,122 @@ private fun StoryCard( Spacer(Modifier.height(Space.md)) Text(story.caption, fontSize = 14.sp, maxLines = 3) } + + Spacer(Modifier.height(Space.md)) + StoryActions(story, model) + } +} + +/** + * React, reply, or stop seeing somebody's stories. + * + * 🔴 A fixed row of five, not a picker. A story is glanced at and scrolled + * past; anything that takes two taps to say "nice" does not get used, and the + * feature is only worth having if it is faster than typing. + * + * ⚠️ Your OWN story gets the tally instead — you cannot react to yourself, and + * a row of buttons that do nothing is worse than no row. + */ +@Composable +private fun StoryActions(story: Vault.Story, model: MessengerViewModel) { + val quick = listOf("❤️", "😂", "😮", "😢", "🔥") + var replying by remember { mutableStateOf(false) } + var muting by remember { mutableStateOf(false) } + + if (story.mine) { + val tally = model.storyTally(story.id) + if (tally.isNotEmpty()) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.testTag("story-tally")) { + tally.entries.sortedBy { it.key }.forEach { (emoji, count) -> + Text("$emoji $count", fontSize = 13.sp, color = Palette.Mist) + } + } + } + return + } + + val mine = model.myStoryReaction(story.id) + Row(verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp)) { + quick.forEach { emoji -> + Text( + emoji, + fontSize = 20.sp, + modifier = Modifier + .clip(RoundedCornerShape(Radius.small)) + .background( + if (mine == emoji) Palette.Accent.copy(alpha = 0.15f) + else androidx.compose.ui.graphics.Color.Transparent + ) + .clickable { + // Tapping the one already sent takes it back. + model.reactToStory(if (mine == emoji) "" else emoji, story) + } + .padding(4.dp) + .testTag("story-react-$emoji"), + ) + } + Spacer(Modifier.weight(1f)) + Text("Reply", fontSize = 13.sp, color = Palette.Accent, + modifier = Modifier.clickable { replying = true }.testTag("story-reply")) + Spacer(Modifier.size(12.dp)) + Text("Mute", fontSize = 13.sp, color = Palette.Mist, + modifier = Modifier.clickable { muting = true }.testTag("story-mute")) + } + + if (replying) { + var draft by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = { replying = false }, + title = { Text("Reply privately") }, + // 🔴 Says where it goes. A reply that looked like a comment under + // the story — readable by whoever else saw it — would be a privacy + // surprise in the wrong direction. + text = { + Column { + Text("Goes to them in your conversation. Nobody else who " + + "saw this story can read it.", fontSize = 13.sp) + Spacer(Modifier.height(Space.sm)) + OutlinedTextField( + value = draft, onValueChange = { draft = it }, + placeholder = { Text("Message") }, + modifier = Modifier.fillMaxWidth().testTag("story-reply-field"), + ) + } + }, + confirmButton = { + TextButton( + onClick = { replying = false; model.replyToStory(draft.trim(), story) }, + enabled = draft.isNotBlank(), + modifier = Modifier.testTag("story-reply-send"), + ) { Text("Send") } + }, + dismissButton = { TextButton(onClick = { replying = false }) { Text("Cancel") } }, + ) + } + + if (muting) { + AlertDialog( + onDismissRequest = { muting = false }, + title = { Text("Mute their stories?") }, + // ⚠️ Says what muting is NOT. People reach for it expecting + // something closer to blocking, and are owed the difference before + // they use it rather than after. + text = { + Text("Their stories stop showing on this phone. They aren't " + + "told, and their messages still arrive as usual. You " + + "can turn it back on from their contact page.", + fontSize = 13.sp) + }, + confirmButton = { + TextButton( + onClick = { muting = false; model.setStoriesMuted(story.authorId, true) }, + modifier = Modifier.testTag("story-mute-confirm"), + ) { Text("Mute") } + }, + dismissButton = { TextButton(onClick = { muting = false }) { Text("Cancel") } }, + ) } } @@ -495,3 +647,35 @@ fun TextStoryCard(text: String) { ) } } + + +/** + * Write a story that is only words. + * + * A dialog rather than a screen: a text story is a line or two, and a + * full-screen composer for that is the reason people do not post them. + */ +@Composable +private fun TextStoryComposer(onDismiss: () -> Unit, onPost: (String) -> Unit) { + var text by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("New story") }, + text = { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + placeholder = { Text("Say something") }, + modifier = Modifier.fillMaxWidth().testTag("story-text-field"), + ) + }, + confirmButton = { + TextButton( + onClick = { onPost(text.trim()) }, + enabled = text.isNotBlank(), + modifier = Modifier.testTag("story-text-post"), + ) { Text("Post") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/MentionPickerTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/MentionPickerTest.kt new file mode 100644 index 0000000..3ac464f --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/MentionPickerTest.kt @@ -0,0 +1,81 @@ +package io.flamenet.messenger.app + +import io.flamenet.client.MessengerApi +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * The two decisions behind the `@` picker, tested away from Compose. + * + * 🔴 Both of these are about somebody being rung, or not being rung, by a + * message. A picker that opens at the wrong moment is an annoyance; a mention + * that reaches the wrong person, or fails to reach the right one, is the + * feature not working. + */ +class MentionPickerTest { + + private fun buddy(id: Long, handle: String, display: String = "") = + MessengerApi.Buddy(userId = id, handle = handle, displayName = display, + presence = "offline") + + // MARK: - When the list is open + + @Test + fun `no at sign means no list`() { + assertNull(mentionQuery("dinner at eight")) + assertNull(mentionQuery("")) + } + + @Test + fun `a bare at opens the list with everybody`() { + assertEquals("", mentionQuery("@")) + assertEquals("", mentionQuery("hello @")) + } + + @Test + fun `typing after the at narrows it`() { + assertEquals("ali", mentionQuery("hello @ali")) + } + + /** + * 🔴 The list closes on a space, and closing has to be driven by the text + * rather than by a flag. Somebody deleting their way back past the `@` + * expects it gone, and a flag has to be remembered to be cleared. + */ + @Test + fun `a space after the at closes the list`() { + assertNull(mentionQuery("@ali said")) + assertNull(mentionQuery("mail me @ ")) + } + + @Test + fun `the last at is the one being typed`() { + assertEquals("bo", mentionQuery("@ali and @bo")) + } + + // MARK: - Who the list offers + + @Test + fun `a contact is named by the name you know them by`() { + val buddies = listOf(buddy(7, "alicew", "Alice")) + assertEquals("Alice", memberLabel(7, buddies)) + } + + @Test + fun `a contact with no display name falls back to the handle`() { + val buddies = listOf(buddy(7, "alicew")) + assertEquals("alicew", memberLabel(7, buddies)) + } + + /** + * ⚠️ The relay stores ids and no names, so a member who is not in your + * contacts has nothing to show. They stay mentionable under their id — + * being un-mentionable is worse than being ugly, because the alternative + * is a group where some people cannot be reached. + */ + @Test + fun `a member who is not a contact is still mentionable`() { + assertEquals("user 42", memberLabel(42, emptyList())) + } +} diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index 1b5ee14..ac4027a 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -212,10 +212,12 @@ class Conversations( * at the sender as an incoming one. The sender's own copy is written * locally by the caller. */ - fun sendToGroup(group: RelayClient.Group, text: String, myUserId: Long): Sent { + fun sendToGroup(group: RelayClient.Group, text: String, myUserId: Long, + mentions: List = emptyList()): Sent { val delivered = mutableListOf() val failed = LinkedHashMap() - val body = Content.groupText(text, group.id, groupName = vault.groupName(group.id)) + val body = Content.groupText(text, group.id, groupName = vault.groupName(group.id), + mentions = mentions) for (member in group.memberIds) { if (member == myUserId) continue @@ -356,6 +358,61 @@ class Conversations( data class StoryPosted(val ref: Attachment.Ref, val reached: Int, val missed: Int) + /** + * Post a story that is only words. + * + * 🔴 No blob and no upload — the whole story is inside the ratchet, so the + * relay holds nothing at all for it. The lifetime is the sender's choice + * and rides in the sealed envelope rather than being something the server + * is told. + */ + fun postTextStory( + text: String, + toUserIds: List, + lifetimeSeconds: Double = STORY_LIFETIME_SECONDS, + ): TextStoryPosted { + val expires = System.currentTimeMillis() / 1000.0 + lifetimeSeconds + // ⚠️ Identifies the story across the sender's devices. A picture story + // is identified by its blob token and this has none, so without it the + // same story appears once per phone the sender posted from. + val key = java.util.UUID.randomUUID().toString() + val body = Content.textStory(text, expires, key) + + var reached = 0 + var missed = 0 + for (userId in toUserIds) { + if (sendPlaintext(userId, body).reachedSomeone) reached++ else missed++ + } + return TextStoryPosted(key, expires, reached, missed) + } + + data class TextStoryPosted( + val key: String, val expires: Double, val reached: Int, val missed: Int, + ) + + /** + * React to somebody's story. Goes to the author alone — a story is sent to + * each viewer rather than hosted, so there is nobody else to tell, and no + * other viewer can learn who reacted. + */ + fun reactToStory(emoji: String, storyKey: String, author: Long) { + sendPlaintext(author, Content.storyReaction(emoji, storyKey, author)) + } + + /** + * Reply to somebody's story, privately, in the ordinary conversation. + * + * ⚠️ [preview] travels with it because stories expire: by the time this is + * read the story may be gone, and "replied to your story" with no idea + * which one cannot be understood. + */ + fun replyToStory(body: String, storyKey: String, author: Long, preview: String): String { + val messageId = java.util.UUID.randomUUID().toString() + sendPlaintext(author, + Content.storyReply(body, storyKey, author, preview, messageId)) + return messageId + } + /** * Publish a profile picture: upload it encrypted, then tell each contact * where it is and how to open it. diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index 6fea338..77677b3 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -82,6 +82,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) { private const val OUTBOX = "outbox" private const val CALL_LOG = "call-log" private const val STORY_AUDIENCE = "story-audience" + private const val STORY_MUTES = "story-mutes" private const val DISAPPEAR = "disappear" private const val SNOOZE = "snooze" private const val REPLY_REMINDER = "reply-reminder" @@ -1091,6 +1092,25 @@ class Vault(private val storage: Storage, private val key: SecretKey) { fun saveStoryAudience(audience: JSONObject) = writeJson(STORY_AUDIENCE, audience) + /** + * Whose stories this device does not show. + * + * 🔴 Encrypted at rest with everything else in here. Who somebody has + * quietly stopped watching is exactly the kind of thing a stolen phone + * should not hand over — and it is a judgement about a person, which is + * worse to leak than a preference. + */ + fun storyMutes(): Set { + val array = readJson(STORY_MUTES)?.optJSONArray("ids") ?: return emptySet() + return (0 until array.length()).map { array.optLong(it) }.toSet() + } + + fun saveStoryMutes(ids: Set) { + val array = org.json.JSONArray() + ids.sorted().forEach { array.put(it) } + writeJson(STORY_MUTES, JSONObject().put("ids", array)) + } + // -- Key-log issuer pin (audit M1) --------------------------------------- /**