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:
co-authored by
Claude Opus 4.8
parent
bfe9fff430
commit
fdaf06415e
@@ -2464,7 +2464,7 @@ fun ColumnScope.StoryStrip(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Story) {
|
private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Story) {
|
||||||
val remaining = remember(story.id) {
|
val remaining = remember(story.id) {
|
||||||
val seconds = story.ref.expires - System.currentTimeMillis() / 1000.0
|
val seconds = story.expires - System.currentTimeMillis() / 1000.0
|
||||||
when {
|
when {
|
||||||
seconds <= 0 -> "expired"
|
seconds <= 0 -> "expired"
|
||||||
seconds < 3600 -> "${(seconds / 60).toInt()}m left"
|
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"),
|
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp).testTag("story-viewer"),
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
) {
|
) {
|
||||||
AttachedImage(story.ref, model)
|
val ref = story.ref
|
||||||
if (story.caption.isNotBlank()) Text(story.caption)
|
if (ref == null) TextStoryCard(story.caption) else AttachedImage(ref, model)
|
||||||
|
if (story.caption.isNotBlank() && !story.textOnly) Text(story.caption)
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
@@ -3502,8 +3503,12 @@ private fun FileBubble(ref: io.flamenet.e2e.Attachment.Ref, mine: Boolean) {
|
|||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
modifier = Modifier.padding(vertical = 2.dp).testTag("chat.file"),
|
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(
|
Icon(
|
||||||
Icons.Filled.InsertDriveFile,
|
KitIcon.Attach,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = onColour,
|
tint = onColour,
|
||||||
modifier = Modifier.size(24.dp),
|
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.unit.sp
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import io.flamenet.e2e.Vault
|
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.
|
* 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}"
|
?: if (story.mine) "You" else "user ${story.authorId}"
|
||||||
}
|
}
|
||||||
val remaining = remember(story.id) {
|
val remaining = remember(story.id) {
|
||||||
val seconds = story.ref.expires - System.currentTimeMillis() / 1000.0
|
val seconds = story.expires - System.currentTimeMillis() / 1000.0
|
||||||
when {
|
when {
|
||||||
story.ref.expires <= 0L -> "no expiry"
|
story.expires <= 0L -> "no expiry"
|
||||||
seconds <= 0 -> "expired"
|
seconds <= 0 -> "expired"
|
||||||
seconds < 3600 -> "${(seconds / 60).toInt()} min left"
|
seconds < 3600 -> "${(seconds / 60).toInt()} min left"
|
||||||
else -> "${(seconds / 3600).toInt()} h left"
|
else -> "${(seconds / 3600).toInt()} h left"
|
||||||
@@ -180,14 +182,21 @@ private fun StoryCard(
|
|||||||
if (stream != null && streamHost != null &&
|
if (stream != null && streamHost != null &&
|
||||||
io.flamenet.e2e.StreamDisclosure.acceptable(stream)) {
|
io.flamenet.e2e.StreamDisclosure.acceptable(stream)) {
|
||||||
StoryStreamGate(stream, streamHost)
|
StoryStreamGate(stream, streamHost)
|
||||||
} else if (story.ref.mime.startsWith("video/")) {
|
|
||||||
StoryVideoCard(story.ref, model)
|
|
||||||
} else {
|
} 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))
|
Spacer(Modifier.height(Space.md))
|
||||||
Text(story.caption, fontSize = 14.sp, maxLines = 3)
|
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)
|
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 id: String,
|
||||||
val authorId: Long,
|
val authorId: Long,
|
||||||
val mine: Boolean,
|
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 caption: String,
|
||||||
val postedAt: Long,
|
val postedAt: Long,
|
||||||
val seen: Boolean = false,
|
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).
|
* 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,
|
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. */
|
/** The host a viewer would connect to, for the disclosure. */
|
||||||
val streamHost: String?
|
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()
|
val array = readJson(STORIES)?.optJSONArray("items") ?: return emptyList()
|
||||||
return (0 until array.length()).mapNotNull { i ->
|
return (0 until array.length()).mapNotNull { i ->
|
||||||
val o = array.getJSONObject(i)
|
val o = array.getJSONObject(i)
|
||||||
val ref = Attachment.Ref.fromJson(
|
// ⚠️ `optJSONObject`, not `getJSONObject`. A text story has no ref,
|
||||||
Attachment.STORY, o.getJSONObject("ref"), o.getJSONObject("ref").optDouble("exp", 0.0)
|
// and the throwing accessor would take down the load of EVERY
|
||||||
) ?: return@mapNotNull null
|
// 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(
|
Story(
|
||||||
id = o.getString("id"),
|
id = o.getString("id"),
|
||||||
authorId = o.getLong("author"),
|
authorId = o.getLong("author"),
|
||||||
@@ -594,6 +618,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
|||||||
caption = o.optString("caption"),
|
caption = o.optString("caption"),
|
||||||
postedAt = o.optLong("at"),
|
postedAt = o.optLong("at"),
|
||||||
seen = o.optBoolean("seen", false),
|
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 live = stories.filter { it.isActive(nowSeconds) }
|
||||||
val array = org.json.JSONArray()
|
val array = org.json.JSONArray()
|
||||||
live.forEach { story ->
|
live.forEach { story ->
|
||||||
array.put(JSONObject()
|
val row = JSONObject()
|
||||||
.put("id", story.id)
|
.put("id", story.id)
|
||||||
.put("author", story.authorId)
|
.put("author", story.authorId)
|
||||||
.put("mine", story.mine)
|
.put("mine", story.mine)
|
||||||
.put("ref", story.ref.toJson())
|
|
||||||
.put("caption", story.caption)
|
.put("caption", story.caption)
|
||||||
.put("at", story.postedAt)
|
.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))
|
writeJson(STORIES, JSONObject().put("items", array))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user