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),
)
}
}