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 ac094f2..3c7c06e 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -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), 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..2551e26 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt @@ -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), + ) + } +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index f241cd1..6fea338 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -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)) }