Stories you can answer, and an @ that finds people

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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-03 15:11:21 -04:00
co-authored by Claude Opus 4.8
parent fdaf06415e
commit 2179615263
9 changed files with 657 additions and 29 deletions
@@ -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<Int> = emptyList()): Sent {
val delivered = mutableListOf<String>()
val failed = LinkedHashMap<String, String>()
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<Long>,
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.