diff --git a/PARITY.md b/PARITY.md index 4508aa6..8479045 100644 --- a/PARITY.md +++ b/PARITY.md @@ -619,3 +619,22 @@ runs BEFORE the rate-limit check on both, so a suppression window cannot make an untimed conversation start reporting. ⚠️ **Last-notice times are in memory on both.** Persisting them would store who screenshotted whom, on disk, to enforce a five-second window. +||||||| parent of 487cead (Post a story that is only words, or a link) +## Text and link stories (§6.21, ios#32) +A story may carry no attachment; the caption is then the whole story. Both +platforms encode, decode, store, render and post one. +🔴 **The decode sits BEFORE the attachment branch on both.** That branch +requires `att` for `kind: "story"`; a text story failing it falls through to +the ordinary-message path and appears as a chat bubble in the conversation that +never expires — both of the things §6.6 says a story must not be. Each engine +has a test named for exactly that. +🔴 **`ref` is nullable/optional rather than a sentinel**, so nothing can read a +blob id off a story that has no blob. Both platforms therefore need a `key` for +seen-marks and de-duplication: with no blob id, a text story is named by what +cannot repeat for one author — when it was posted and what it said. +⚠️ **Android's stored record omits `ref` entirely** rather than writing an +empty object; a reload would otherwise resurrect a blob that fails to fetch. +`expiresAt` folds the two cases so no caller has to know which kind it holds. +⚠️ A text story has no blob, so nothing dies at the server's retention purge. +Client-enforced expiry is the whole mechanism rather than a backstop to one, +and both composers say so instead of implying more. diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index 587da32..96d4535 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -2482,7 +2482,7 @@ fun ColumnScope.StoryStrip( @Composable private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Story) { val remaining = remember(story.id) { - val seconds = story.ref.expires - System.currentTimeMillis() / 1000.0 + val seconds = story.expiresAt - System.currentTimeMillis() / 1000.0 when { seconds <= 0 -> "expired" seconds < 3600 -> "${(seconds / 60).toInt()}m left" @@ -2493,7 +2493,8 @@ private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Stor modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp).testTag("story-viewer"), verticalArrangement = Arrangement.spacedBy(6.dp), ) { - AttachedImage(story.ref, model) + // §6.21 — a text story has no blob; the caption IS the story. + story.ref?.let { AttachedImage(it, model) } if (story.caption.isNotBlank()) Text(story.caption) Row( modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 50093f1..2b7324a 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -1927,6 +1927,42 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { /** Whether this contact's stories are hidden on this device. */ fun isStoryMuted(authorId: Long): Boolean = session?.isStoryMuted(authorId) ?: false + /** + * Post a story that is only words (§6.21). + * + * ⚠️ No blob, so nothing is uploaded and nothing lands in the server's + * retention purge. The whole lifetime of a text story is the `exp` honest + * clients enforce — for a story with a picture that is a backstop to the + * blob dying anyway; here it is the only mechanism. + */ + fun postTextStory(caption: String, background: Int? = null, + audienceOverride: Session.StoryAudience? = null) = + viewModelScope.launch { + val current = session ?: return@launch + val body = caption.trim() + if (body.isEmpty()) return@launch + val contacts = signedIn()?.buddies?.map { it.userId }.orEmpty() + val recipients = (audienceOverride ?: 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( + body, recipients, background, + lifetimeSeconds = MediaSettings(getApplication()).storyLifetimeSeconds) + } + } + done.onSuccess { + update { it.copy(busy = false, stories = current.stories()) } + }.onFailure { f -> update { it.copy(busy = false, error = describe(f)) } } + } + /** Change who may see this account's stories. */ fun setStoryAudience(audience: Session.StoryAudience) = viewModelScope.launch { val current = session ?: return@launch diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index fd0e129..923923e 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -1041,6 +1041,58 @@ class Session( runCatching { conversations.send(peerUserId, Content.screenshot(nowMillis / 1000)) } } + /** + * Post a story that is only its caption (§6.21). + * + * ⚠️ No blob, so there is no upload and nothing for the server's retention + * purge to collect. The whole lifetime of a text story is the `exp` honest + * clients enforce. + * + * 🔴 The link preview is fetched by the CALLER and rides inside the + * ciphertext (§6.12). A card drawn by each viewer would tell the site the + * address of everyone who saw the story — for a story that is the whole + * audience, not one correspondent. + */ + fun postTextStory( + caption: String, + toUserIds: List, + background: Int? = null, + link: Content.LinkPreview? = null, + lifetimeSeconds: Double, + ): Int { + val body = caption.trim() + require(body.isNotEmpty()) { "a text story with no words is not a story" } + val expires = System.currentTimeMillis() / 1000.0 + lifetimeSeconds + val plaintext = Content.textStory(body, expires, background, link) + var reached = 0 + toUserIds.forEach { peer -> + if (runCatching { conversations.send(peer, plaintext) } + .getOrNull()?.reachedSomeone == true) reached++ + } + if (reached > 0) { + val now = System.currentTimeMillis() / 1000.0 + val at = System.currentTimeMillis() + vault.saveStories( + vault.loadStories() + Vault.Story( + // No blob, so no blob id. Named by what cannot repeat for + // one author, matching Story.key. + id = "txt-$userId-$at", + authorId = userId, + mine = true, + ref = null, + caption = body, + postedAt = at, + seen = true, + textExpiry = expires, + background = background, + link = link, + ), + now, + ) + } + return reached + } + /** * Members of a group we are in, or null if the relay does not list it for * us (audit H1). Cached for a short window: a poll pass can carry many diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt index 82725eb..9c964ea 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt @@ -58,6 +58,9 @@ fun ColumnScope.StoriesFeed( // 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. val context = androidx.compose.ui.platform.LocalContext.current + val writing = androidx.compose.runtime.saveable.rememberSaveable { + androidx.compose.runtime.mutableStateOf(false) + } val pickStory = androidx.activity.compose.rememberLauncherForActivityResult( androidx.activity.result.contract.ActivityResultContracts.GetContent() ) { uri -> @@ -74,6 +77,14 @@ fun ColumnScope.StoriesFeed( style = MaterialTheme.typography.headlineMedium, modifier = Modifier.weight(1f), ) + // §6.21 — a sentence is often the whole thought, and needs no camera. + KitChip( + text = "Write", + selected = false, + onClick = { writing.value = true }, + tag = "new-text-story", + ) + Spacer(Modifier.size(Space.sm)) Icon( KitIcon.Add, contentDescription = "New story", @@ -84,6 +95,46 @@ fun ColumnScope.StoriesFeed( .testTag("new-story"), ) } + + if (writing.value) { + val draft = remember { androidx.compose.runtime.mutableStateOf("") } + androidx.compose.material3.AlertDialog( + onDismissRequest = { writing.value = false }, + title = { Text("Write a story") }, + text = { + Column { + androidx.compose.material3.OutlinedTextField( + value = draft.value, + onValueChange = { draft.value = it }, + placeholder = { Text("Say something") }, + modifier = Modifier.fillMaxWidth().testTag("text-story-field"), + ) + Spacer(Modifier.height(Space.sm)) + // ⚠️ Says who enforces it. A text story has no blob, so + // nothing dies at the server's retention purge — the + // client honouring `exp` is the whole of it. + Text( + "Disappears after your story lifetime. There is no photo to " + + "delete, so this relies on their app honouring it.", + fontSize = 12.sp, + color = Palette.SecondaryText, + ) + } + }, + confirmButton = { + androidx.compose.material3.TextButton( + onClick = { model.postTextStory(draft.value); writing.value = false }, + enabled = draft.value.isNotBlank(), + modifier = Modifier.testTag("text-story-post"), + ) { Text("Post") } + }, + dismissButton = { + androidx.compose.material3.TextButton( + onClick = { writing.value = false }) { Text("Cancel") } + }, + ) + } + Spacer(Modifier.height(Space.sm)) StoriesCards(model, phase) @@ -106,9 +157,9 @@ private fun StoryCard( ?: if (story.mine) "You" else "user ${story.authorId}" } val remaining = remember(story.id) { - val seconds = story.ref.expires - System.currentTimeMillis() / 1000.0 + val seconds = story.expiresAt - System.currentTimeMillis() / 1000.0 when { - story.ref.expires <= 0L -> "no expiry" + story.expiresAt <= 0L -> "no expiry" seconds <= 0 -> "expired" seconds < 3600 -> "${(seconds / 60).toInt()} min left" else -> "${(seconds / 3600).toInt()} h left" @@ -180,10 +231,15 @@ private fun StoryCard( if (stream != null && streamHost != null && io.flamenet.e2e.StreamDisclosure.acceptable(stream)) { StoryStreamGate(stream, streamHost) - } else if (story.ref.mime.startsWith("video/")) { - StoryVideoCard(story.ref, model) } else { - StoryImage(story.ref, model) + // §6.21 — a text story has nothing to fetch. Skipped rather + // than rendered as a failed image: "no blob" is not an error. + val ref = story.ref + if (ref != null && ref.mime.startsWith("video/")) { + StoryVideoCard(ref, model) + } else if (ref != null) { + StoryImage(ref, model) + } } } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt index e445ed3..4305e28 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt @@ -87,6 +87,21 @@ data class Content( * 🔴 An id and never a copy of the message. */ val pins: String? = null, + + /** + * Set iff this is a story with no attachment — the caption IS the story + * (§6.21). Belongs to the strip, never the thread. + */ + val isTextStory: Boolean = false, + /** A text story's top-level `exp` (§6.21). */ + val storyExpiry: Double? = null, + /** + * A text story's background style (§6.21). + * + * ⚠️ A hint. A value this build does not know falls back to the default + * rather than refusing the story. + */ + val storyBackground: Int? = null, ) { /** @@ -390,6 +405,20 @@ data class Content( link = link, forwarded = forwarded, lifetimeSeconds = lifetimeSeconds) + /** + * A story that is only its caption (§6.21). + * + * ⚠️ `caption` must not be empty. A story with neither a blob nor + * words is nothing at all, and a receiver is required to ignore it. + */ + fun textStory( + caption: String, + expires: Double, + background: Int? = null, + link: LinkPreview? = null, + ): String = assemble(Attachment.STORY, caption = caption, expires = expires, + link = link, background = background) + fun groupText( body: String, groupId: String, @@ -503,6 +532,8 @@ data class Content( forwarded: Boolean = false, mentions: List = emptyList(), lifetimeSeconds: Int? = null, + + background: Int? = null, ): String { val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(kind)}") // ⚠️ `1`, not `true` — §6.10's shape for every flag on this @@ -534,6 +565,8 @@ data class Content( attachment?.let { parts += "\"att\":${it.toJson()}" } link?.let { parts += "\"lnk\":${it.toJson()}" } streamUrl?.let { parts += "\"stream\":${quote(it)}" } + // §6.21 — a text story's background style. A hint, never required. + background?.let { parts += "\"bg\":$it" } return parts.joinToString(",", prefix = "{", postfix = "}") } @@ -719,6 +752,22 @@ data class Content( )) } + // §6.21 — a story with no attachment: the caption is the story. + // 🔴 BEFORE the attachment branch below, which requires `att` and + // would otherwise drop this onto the ordinary-message path — a + // chat bubble in the conversation that never expires, both things + // §6.6 says a story must not be. + if (kind == Attachment.STORY && json.optJSONObject("att") == null && + caption.isNotEmpty()) { + return Content( + caption, kind = kind, raw = json, messageId = messageId, + link = json.optJSONObject("lnk")?.let { LinkPreview.fromJson(it) }, + isTextStory = true, + storyExpiry = json.optDouble("exp", 0.0).takeIf { it > 0.0 }, + storyBackground = if (json.has("bg")) json.optInt("bg") else null, + ) + } + if (kind == Kind.TEXT) { val link = json.optJSONObject("lnk")?.let { LinkPreview.fromJson(it) } return Content(caption, kind = kind, raw = json, groupId = groupId, link = link, diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index c409446..23c6e3c 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -434,7 +434,14 @@ class Vault(private val storage: Storage, private val key: SecretKey) { val id: String, val authorId: Long, val mine: Boolean, - val ref: Attachment.Ref, + /** + * The sealed blob, when this story has one. + * + * ⚠️ Nullable since §6.21: a text story is its caption and carries no + * attachment. Nullable rather than a sentinel so nothing can read a + * blob id off a story that has no blob. + */ + val ref: Attachment.Ref?, val caption: String, val postedAt: Long, val seen: Boolean = false, @@ -449,8 +456,41 @@ class Vault(private val storage: Storage, private val key: SecretKey) { * POSTER, and playback is an explicit, host-named tap. */ val streamUrl: String? = null, + /** Top-level `exp` for a story with no attachment (§6.21). */ + val textExpiry: Double? = null, + /** A text story's background style hint (§6.21). */ + val background: Int? = null, + /** + * A preview of a link in a text story (§6.12, §6.21). + * + * 🔴 Fetched by the SENDER and carried inside the ciphertext. Drawing + * it viewer-side would tell the site the address of every viewer — for + * a story that is the whole audience, not one correspondent. + */ + val link: Content.LinkPreview? = null, ) { - fun isActive(nowSeconds: Double): Boolean = ref.expires <= 0 || ref.expires > nowSeconds + /** + * When this story stops rendering, unix seconds. 0 = never. + * + * ⚠️ A story with a blob carries its expiry on the ref; a text story + * (§6.21) has none, so the envelope's top-level `exp` is kept here. + * One accessor, so no caller has to know which kind it is holding. + */ + val expiresAt: Double get() = ref?.expires ?: textExpiry ?: 0.0 + + fun isActive(nowSeconds: Double): Boolean = + expiresAt <= 0 || expiresAt > nowSeconds + + /** True when the caption IS the story (§6.21). */ + val isTextOnly: Boolean get() = ref == null + + /** + * What names this story for seen-marks and de-duplication. + * + * ⚠️ A text story has no blob id, so it is named by what cannot repeat + * for one author: when it was posted, and what it said. + */ + val key: String get() = ref?.id ?: "txt-$authorId-$postedAt-${caption.hashCode()}" /** The host a viewer would connect to, for the disclosure. */ val streamHost: String? @@ -654,9 +694,14 @@ class Vault(private val storage: Storage, private val key: SecretKey) { val array = readJson(STORIES)?.optJSONArray("items") ?: return emptyList() return (0 until array.length()).mapNotNull { i -> val o = array.getJSONObject(i) - val ref = Attachment.Ref.fromJson( - Attachment.STORY, o.getJSONObject("ref"), o.getJSONObject("ref").optDouble("exp", 0.0) - ) ?: return@mapNotNull null + // §6.21 — a text story has no `ref`. Absent is legitimate now, so + // it cannot mean "malformed, drop it". + val refJson = o.optJSONObject("ref") + val ref = refJson?.let { + Attachment.Ref.fromJson(Attachment.STORY, it, it.optDouble("exp", 0.0)) + } + if (refJson != null && ref == null) return@mapNotNull null + if (ref == null && o.optString("caption").isEmpty()) return@mapNotNull null Story( id = o.getString("id"), authorId = o.getLong("author"), @@ -665,6 +710,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) { caption = o.optString("caption"), postedAt = o.optLong("at"), seen = o.optBoolean("seen", false), + textExpiry = o.optDouble("exp", 0.0).takeIf { it > 0.0 }, + background = if (o.has("bg")) o.optInt("bg") else null, ) } } @@ -685,7 +732,11 @@ class Vault(private val storage: Storage, private val key: SecretKey) { .put("id", story.id) .put("author", story.authorId) .put("mine", story.mine) - .put("ref", story.ref.toJson()) + // §6.21 — absent for a text story rather than an empty object, + // so a reload cannot resurrect it as a blob that fails to fetch. + .also { if (story.ref != null) it.put("ref", story.ref.toJson()) } + .also { if (story.textExpiry != null) it.put("exp", story.textExpiry) } + .also { if (story.background != null) it.put("bg", story.background) } .put("caption", story.caption) .put("at", story.postedAt) .put("seen", story.seen)) diff --git a/engine/src/test/kotlin/io/flamenet/e2e/TextStoryTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/TextStoryTest.kt new file mode 100644 index 0000000..26c31f3 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/TextStoryTest.kt @@ -0,0 +1,115 @@ +package io.flamenet.e2e + +import org.json.JSONObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Stories that are only words, and the links in them (spec §6.21, ios#32). */ +class TextStoryTest { + + @Test + fun `a text story carries its caption and expiry`() { + val content = Content.parse(Content.textStory("back Thursday", expires = 1788000000.0)) + assertTrue(content.isTextStory) + assertEquals("back Thursday", content.text) + assertEquals(1788000000.0, content.storyExpiry) + } + + /** ⚠️ No `att`: an empty attachment would be fetched, fail, and show broken. */ + @Test + fun `a text story carries no attachment`() { + val json = JSONObject(Content.textStory("hello", expires = 1.0)) + assertFalse(json.has("att")) + assertEquals(Attachment.STORY, json.getString("kind")) + } + + /** + * 🔴 The decode must run BEFORE the attachment branch. If it does not, a + * story with no `att` falls through to the ordinary-message path and shows + * as a chat bubble that never expires — both things §6.6 forbids. + */ + @Test + fun `a text story is never an ordinary message`() { + val content = Content.parse("""{"fnc":1,"kind":"story","caption":"hi","exp":99}""") + assertTrue(content.isTextStory) + assertNull(content.attachment) + } + + /** A story with neither blob nor words is nothing, and is not a story. */ + @Test + fun `a story with no attachment and no caption is not a text story`() { + assertFalse(Content.parse("""{"fnc":1,"kind":"story","caption":"","exp":9}""").isTextStory) + } + + /** An unknown background falls back rather than refusing the story. */ + @Test + fun `an unknown background still yields a story`() { + val content = Content.parse( + """{"fnc":1,"kind":"story","caption":"hi","exp":99,"bg":9999}""") + assertTrue(content.isTextStory) + assertEquals(9999, content.storyBackground) + } + + @Test + fun `the marker stays first`() { + assertTrue(Content.textStory("hi", expires = 1.0).startsWith("{\"fnc\":1")) + } + + // -- The model and its storage -------------------------------------------- + + private class MapStorage : Vault.Storage { + val records = LinkedHashMap() + override fun read(name: String) = records[name] + override fun write(name: String, bytes: ByteArray) { records[name] = bytes } + override fun delete(name: String) { records.remove(name) } + override fun names() = records.keys.toList() + } + + private fun key() = javax.crypto.KeyGenerator.getInstance("AES") + .apply { init(256) }.generateKey() + + private fun textStory(expires: Double) = Vault.Story( + id = "s1", authorId = 7, mine = false, ref = null, caption = "hi", + postedAt = 100, textExpiry = expires, + ) + + @Test + fun `a text story expires on its top-level stamp`() { + val now = System.currentTimeMillis() / 1000.0 + assertFalse(textStory(now - 60).isActive(now)) + assertTrue(textStory(now + 60).isActive(now)) + } + + @Test + fun `a text story is marked as having no blob`() { + assertTrue(textStory(0.0).isTextOnly) + } + + /** + * 🔴 The stored record must omit `ref` entirely rather than write an empty + * object — a reload would otherwise resurrect a blob that fails to fetch. + */ + @Test + fun `a text story round-trips through the vault without a ref`() { + val vault = Vault(MapStorage(), key()) + val now = System.currentTimeMillis() / 1000.0 + vault.saveStories(listOf(textStory(now + 600)), now) + val back = vault.loadStories() + assertEquals(1, back.size) + assertNull(back[0].ref) + assertEquals("hi", back[0].caption) + assertTrue(back[0].isTextOnly) + } + + /** ⚠️ An expired text story must leave on write, like any other. */ + @Test + fun `an expired text story is not written back`() { + val vault = Vault(MapStorage(), key()) + val now = System.currentTimeMillis() / 1000.0 + vault.saveStories(listOf(textStory(now - 10)), now) + assertTrue(vault.loadStories().isEmpty()) + } +}