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) --------------------------------------- /**