Draw a story that is only words — and fix a bubble that never compiled

🔴 The file bubble committed earlier today DID NOT COMPILE. It reached for
Icons.Filled.InsertDriveFile in a file that imports no Material icons at
all, and I reported it as building.

The reason it went unnoticed matters more than the mistake: ANDROID_HOME
was unset, so `:app:compileDebugKotlin` failed on "SDK location not found"
before Kotlin ran — and the check for errors was a grep for lines starting
`e:`, which a build that never reached the compiler does not produce. A
green-looking check that never compiled anything. Every Android app-side
claim I made today rested on it.

It now uses the kit's own icon set, like the rest of the file, and the app
compiles with the SDK on the path.

Alongside: Vault.Story.ref is nullable, so a story can be words. Every
path here assumed a picture, which is the same shape that made a text
story impossible on iOS rather than merely unbuilt.

⚠️ `loadStories` used `getJSONObject("ref")`, which THROWS. A single text
story would have taken down the load of every story — a feed that is
simply empty, with nothing to say why. It reads the optional accessor now,
and tells "no ref at all" (a text story) apart from "a ref that would not
parse" (a corrupt row), because those deserve different treatment.

Expiry moved onto the story, falling back to the attachment for everything
already stored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-03 14:18:48 -04:00
co-authored by Claude Opus 4.8
parent bfe9fff430
commit fdaf06415e
3 changed files with 91 additions and 18 deletions
@@ -2464,7 +2464,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.expires - System.currentTimeMillis() / 1000.0
when {
seconds <= 0 -> "expired"
seconds < 3600 -> "${(seconds / 60).toInt()}m left"
@@ -2475,8 +2475,9 @@ 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)
if (story.caption.isNotBlank()) Text(story.caption)
val ref = story.ref
if (ref == null) TextStoryCard(story.caption) else AttachedImage(ref, model)
if (story.caption.isNotBlank() && !story.textOnly) Text(story.caption)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -3502,8 +3503,12 @@ private fun FileBubble(ref: io.flamenet.e2e.Attachment.Ref, mine: Boolean) {
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(vertical = 2.dp).testTag("chat.file"),
) {
// ⚠️ The kit's own icon set, not Material's. This file imports no
// Material icons at all, and the version that reached for one did not
// compile — which went unnoticed because the build was failing on a
// missing ANDROID_HOME before it ever got as far as Kotlin.
Icon(
Icons.Filled.InsertDriveFile,
KitIcon.Attach,
contentDescription = null,
tint = onColour,
modifier = Modifier.size(24.dp),
@@ -31,6 +31,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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
/**
* Stories as a feed you scroll, which is the kit's Timeline.
@@ -106,9 +108,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.expires - System.currentTimeMillis() / 1000.0
when {
story.ref.expires <= 0L -> "no expiry"
story.expires <= 0L -> "no expiry"
seconds <= 0 -> "expired"
seconds < 3600 -> "${(seconds / 60).toInt()} min left"
else -> "${(seconds / 3600).toInt()} h left"
@@ -180,14 +182,21 @@ 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)
val ref = story.ref
when {
// 🔴 Words alone, set large on a tinted band — the way
// every other messenger draws one. A text story rendered
// as a caption under an empty rectangle reads as a picture
// that failed to load.
ref == null -> TextStoryCard(story.caption)
ref.mime.startsWith("video/") -> StoryVideoCard(ref, model)
else -> StoryImage(ref, model)
}
}
}
if (story.caption.isNotBlank()) {
if (story.caption.isNotBlank() && !story.textOnly) {
Spacer(Modifier.height(Space.md))
Text(story.caption, fontSize = 14.sp, maxLines = 3)
}
@@ -457,3 +466,32 @@ private fun StoryImage(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerView
else -> Text("Loading…", fontSize = 14.sp, color = Palette.SecondaryText)
}
}
/**
* A story that is only words.
*
* Set large and centred on a tinted band, matching iOS — a text story drawn as
* a caption under an empty grey rectangle reads as a picture that failed to
* load, which is the opposite of what the sender meant.
*/
@Composable
fun TextStoryCard(text: String) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(240.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.10f))
.testTag("story-text"),
contentAlignment = Alignment.Center,
) {
Text(
text,
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
maxLines = 6,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(20.dp),
)
}
}
@@ -397,10 +397,19 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
val id: String,
val authorId: Long,
val mine: Boolean,
val ref: Attachment.Ref,
/**
* The picture or video, when there is one.
*
* 🔴 Nullable because a story can be words alone. Non-nullable, there
* was nowhere for a text story to exist — which is why posting one was
* impossible rather than merely unbuilt, on both platforms.
*/
val ref: Attachment.Ref?,
val caption: String,
val postedAt: Long,
val seen: Boolean = false,
/** When it stops showing, in unix seconds. 0 means it does not. */
val expiresAt: Double? = null,
/**
* A live stream this story embeds, if it is one (§6.13).
*
@@ -413,7 +422,13 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
*/
val streamUrl: String? = null,
) {
fun isActive(nowSeconds: Double): Boolean = ref.expires <= 0 || ref.expires > nowSeconds
/** Where the expiry actually lives — on the story, or on its picture. */
val expires: Double get() = expiresAt ?: ref?.expires ?: 0.0
/** Words with no picture behind them. */
val textOnly: Boolean get() = ref == null
fun isActive(nowSeconds: Double): Boolean = expires <= 0 || expires > nowSeconds
/** The host a viewer would connect to, for the disclosure. */
val streamHost: String?
@@ -583,9 +598,18 @@ 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
// ⚠️ `optJSONObject`, not `getJSONObject`. A text story has no ref,
// and the throwing accessor would take down the load of EVERY
// story rather than skipping one — the feed would simply be empty
// with nothing to say why.
val refJson = o.optJSONObject("ref")
val ref = refJson?.let {
Attachment.Ref.fromJson(Attachment.STORY, it, it.optDouble("exp", 0.0))
}
// A row with a ref that would not parse is broken; one with no ref
// at all is a text story. Telling those apart is the difference
// between dropping a corrupt row and dropping a valid one.
if (refJson != null && ref == null) return@mapNotNull null
Story(
id = o.getString("id"),
authorId = o.getLong("author"),
@@ -594,6 +618,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
caption = o.optString("caption"),
postedAt = o.optLong("at"),
seen = o.optBoolean("seen", false),
expiresAt = if (o.has("expires")) o.optDouble("expires") else null,
)
}
}
@@ -610,14 +635,19 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
val live = stories.filter { it.isActive(nowSeconds) }
val array = org.json.JSONArray()
live.forEach { story ->
array.put(JSONObject()
val row = JSONObject()
.put("id", story.id)
.put("author", story.authorId)
.put("mine", story.mine)
.put("ref", story.ref.toJson())
.put("caption", story.caption)
.put("at", story.postedAt)
.put("seen", story.seen))
.put("seen", story.seen)
.put("expires", story.expires)
// Only when there is one — `put(key, null)` REMOVES a key here,
// which is what we want, but being explicit is clearer than
// relying on it.
story.ref?.let { row.put("ref", it.toJson()) }
array.put(row)
}
writeJson(STORIES, JSONObject().put("items", array))
}