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
@@ -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<io.flamenet.client.MessengerApi.Buddy> = emptyList(),
/** Pictures, so a received bubble can carry its author's. */
avatars: Map<Long, io.flamenet.e2e.Attachment.Ref> = 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<Long>()) }
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<io.flamenet.client.MessengerApi.Buddy>,
): String = buddies.firstOrNull { it.userId == id }
?.let { it.displayName.ifBlank { it.handle } } ?: "user $id"
@@ -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
@@ -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<String, Int> =
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<Int> = 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.
//
@@ -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<Int> = 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<String>()
val now = System.currentTimeMillis() / 1000.0
val incomingStories = mutableListOf<Vault.Story>()
// storyKey, who reacted, the emoji ("" takes one back).
val storyReactionsIncoming = mutableListOf<Triple<String, Long, String>>()
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<String, MutableMap<Long, String>>()
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<Long, String> = 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<Long>, 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<Vault.Story> {
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)
@@ -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") } },
)
}
@@ -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()))
}
}