A test run must not publish a credential (ios#62, Android half) #63
@@ -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
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Long> {
|
||||
val array = readJson(STORY_MUTES)?.optJSONArray("ids") ?: return emptySet()
|
||||
return (0 until array.length()).map { array.optLong(it) }.toSet()
|
||||
}
|
||||
|
||||
fun saveStoryMutes(ids: Set<Long>) {
|
||||
val array = org.json.JSONArray()
|
||||
ids.sorted().forEach { array.put(it) }
|
||||
writeJson(STORY_MUTES, JSONObject().put("ids", array))
|
||||
}
|
||||
|
||||
// -- Key-log issuer pin (audit M1) ---------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user