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:
co-authored by
Claude Opus 4.8
parent
fdaf06415e
commit
2179615263
@@ -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()))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user