Post a story that is only words, or a link

Android half of ios#32, spec §6.21. `att` becomes optional and the
caption becomes the story.

🔴 The decode sits before the attachment branch, and that placement is
the feature. The branch requires `att` for a story; a text story failing
it falls through to the ordinary-message path and shows as a chat bubble
in the conversation that never expires — both of the things §6.6 says a
story must not be. There is a test named for it.

`Vault.Story.ref` is nullable rather than carrying a sentinel, so nothing
can read a blob id off a story that has none. That forced a `key` for
seen-marks and dedup, and an `expiresAt` that folds the two cases so no
caller has to know which kind it is holding.

⚠️ The stored record omits `ref` entirely rather than writing an empty
object — a reload would otherwise resurrect a blob that fails to fetch,
and `loadStories` had to stop treating an absent ref as malformed.

A "Write" entry on the Stories tab posts one, and says who enforces the
expiry: there is no photo to delete, so it relies on the receiving app
honouring `exp`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-02 17:43:43 -04:00
co-authored by Claude Opus 4.8
parent 021dc2c24d
commit 7c51bc2521
8 changed files with 392 additions and 13 deletions
@@ -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(),
@@ -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
@@ -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<Long>,
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
@@ -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)
}
}
}