Stories as a feed you scroll, which is the kit's Timeline

A screen, not a strip. Stories were a horizontal row of circles on the
Messages screen -- enough to tell you something exists and nothing
more. The kit's Timeline is a column of full-width cards: author over a
description line, the image, the caption. A card feed cannot sit above
a contact list without pushing it off the phone, so it gets a tab; the
strip stays on Messages as the entry point.

⚠️ No like-or-comment bar, and the kit's post card has one -- two emoji
reactions and a share icon. A story here has none of those: no comment
thread, no reaction, nothing to share it to. Drawing the bar and wiring
it to nothing would be a lie told in pixels. What a story DOES have
that the kit's post does not is a deadline, so that is what sits in the
slot the kit gives a description -- the one fact about a story that
changes while you are looking at it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-27 14:58:30 -04:00
co-authored by Claude Opus 5
parent 45aa5b470c
commit 1f2a702100
5 changed files with 215 additions and 2 deletions
@@ -300,7 +300,8 @@ fun ColumnScope.BuddyList(
* width and height for exactly this reason.
*/
@Composable
private fun AttachedImage(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerViewModel) {
/** ⚠️ Internal, not private: the stories feed draws the same blob. */
internal fun AttachedImage(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerViewModel) {
var bitmap by remember(ref.id) { mutableStateOf<android.graphics.Bitmap?>(null) }
var failed by remember(ref.id) { mutableStateOf(false) }
@@ -185,6 +185,8 @@ private fun Root(model: MessengerViewModel = viewModel()) {
) {
when (current.screen) {
MessengerViewModel.Phase.Screen.CONTACTS -> SignedIn(model, current)
MessengerViewModel.Phase.Screen.STORIES ->
StoriesFeed(model, current)
MessengerViewModel.Phase.Screen.NOTIFICATIONS ->
NotificationsScreen(model, current)
MessengerViewModel.Phase.Screen.ACCOUNT ->
@@ -432,6 +434,7 @@ private fun BottomNav(
) {
NavButton(model, phase, MessengerViewModel.Phase.Screen.CONTACTS,
"Contacts" + (phase.unread.size.takeIf { it > 0 }?.let { " ($it)" } ?: ""), "nav-contacts")
NavButton(model, phase, MessengerViewModel.Phase.Screen.STORIES, "Stories", "nav-stories")
NavButton(model, phase, MessengerViewModel.Phase.Screen.NOTIFICATIONS,
"Alerts" + (unreadNotes.takeIf { it > 0 }?.let { " ($it)" } ?: ""), "nav-alerts")
NavButton(model, phase, MessengerViewModel.Phase.Screen.ACCOUNT, "Account", "nav-account")
@@ -119,7 +119,13 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
val forkDetected: Boolean = false,
) : Phase
enum class Screen { CONTACTS, NOTIFICATIONS, ACCOUNT }
/**
* ⚠️ STORIES is new, and it is a tab rather than a strip because a
* feed needs a screen. The horizontal row on Messages stays as the
* entry point — that is where you see there IS something — but a
* card feed cannot live above a contact list without pushing it off.
*/
enum class Screen { CONTACTS, STORIES, NOTIFICATIONS, ACCOUNT }
/** What the call UI needs to draw itself. */
data class CallState(
@@ -0,0 +1,171 @@
package io.flamenet.messenger.app
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.flamenet.e2e.Vault
/**
* Stories as a feed you scroll, which is the kit's Timeline.
*
* 🔴 **A screen, not a strip.** Stories were a horizontal row of circles on the
* Messages screen — enough to tell you something exists and nothing more. The
* kit's Timeline is a column of full-width cards: author, image, caption. A
* card feed cannot sit above a contact list without pushing it off the phone,
* so it gets a tab. The strip stays on Messages as the entry point.
*
* ⚠️ **No like or comment bar, and the kit has one.** Its post card carries two
* emoji reactions and a share icon; a story here has none of those — there is
* no comment thread, no reaction on a story, and nothing to share it to. Drawing
* the bar and wiring it to nothing would be a lie told in pixels. What a story
* DOES have that the kit's post does not is a deadline, so that is what the
* card says.
*/
@Composable
fun ColumnScope.StoriesFeed(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
) {
LaunchedEffect(Unit) { model.refreshStories() }
Text("Stories", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(Space.sm))
if (phase.stories.isEmpty()) {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = Space.xxl),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(Space.sm),
) {
Text("◎", fontSize = 32.sp, color = Palette.SecondaryText)
Text("Nothing here yet", fontSize = 16.sp, fontWeight = FontWeight.Medium)
Text(
"A story goes to your contacts and disappears after a day — on "
+ "your device and theirs, because their app deletes it too. "
+ "It is not a secret you can take back.",
fontSize = 14.sp,
color = Palette.SecondaryText,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
return
}
phase.stories.forEach { story ->
StoryCard(model, phase, story)
Spacer(Modifier.height(Space.xl))
}
}
/**
* One card, laid out like the kit's `Post/full width`: a 40dp avatar header
* with a name over a description line, then the image, then the caption.
*/
@Composable
private fun StoryCard(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
story: Vault.Story,
) {
val author = remember(story.authorId, phase.buddies) {
phase.buddies.firstOrNull { it.userId == story.authorId }
?.let { it.displayName.ifBlank { it.handle } }
?: if (story.mine) "You" else "user ${story.authorId}"
}
val remaining = remember(story.id) {
val seconds = story.ref.expires - System.currentTimeMillis() / 1000.0
when {
story.ref.expires <= 0L -> "no expiry"
seconds <= 0 -> "expired"
seconds < 3600 -> "${(seconds / 60).toInt()} min left"
else -> "${(seconds / 3600).toInt()} h left"
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.clickable { model.openStory(story) }
.testTag("story-card-${story.id}"),
) {
// Header: avatar 40, name 14 medium over a 12 regular description.
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Kit.gutter),
) {
Box(
modifier = Modifier.size(40.dp).clip(CircleShape).background(Palette.AvatarFill),
contentAlignment = Alignment.Center,
) {
Text(
author.take(1).uppercase(),
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
}
Column(modifier = Modifier.weight(1f)) {
Text(author, fontSize = 14.sp, fontWeight = FontWeight.Medium)
// ⚠️ The deadline, in the slot the kit gives a description.
// It is the one fact about a story that changes while you are
// looking at it, and the whole point of the feature.
Text(remaining, fontSize = 12.sp, color = Palette.SecondaryText)
}
// Unseen is marked here rather than by styling the whole card: a
// feed where read and unread look alike is a feed nobody returns to.
if (!story.seen) {
Box(
modifier = Modifier
.size(Kit.presenceDot)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
)
}
}
Spacer(Modifier.height(Space.lg))
// The image. 375x320 in the kit; an aspect ratio here, because the kit
// is drawn on one canvas and this has to hold on every phone.
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(375f / 320f)
.clip(RoundedCornerShape(Radius.small))
.background(Palette.SurfaceAlt),
contentAlignment = Alignment.Center,
) {
AttachedImage(story.ref, model)
}
if (story.caption.isNotBlank()) {
Spacer(Modifier.height(Space.md))
Text(story.caption, fontSize = 14.sp, maxLines = 3)
}
}
}
@@ -87,6 +87,19 @@ class ScreenshotTest {
androidx.compose.foundation.layout.Column { AccountScreen(model, phase) }
}
/** The feed, with a couple of stories on it. */
@Test fun storiesFeed() = shootFullScreen("screen-stories") { model, phase ->
androidx.compose.foundation.layout.Column { StoriesFeed(model, phase) }
}
@Test fun storiesFeedEmpty() = shoot("screen-stories-empty") {
val app = androidx.test.core.app.ApplicationProvider
.getApplicationContext<android.app.Application>()
androidx.compose.foundation.layout.Column {
StoriesFeed(MessengerViewModel(app), samplePhase().copy(stories = emptyList()))
}
}
@Test fun alertsScreen() = shootFullScreen("screen-alerts") { model, phase ->
androidx.compose.foundation.layout.Column { NotificationsScreen(model, phase) }
}
@@ -124,6 +137,25 @@ class ScreenshotTest {
),
buddiesLoaded = true,
groupsLoaded = true,
stories = listOf(
io.flamenet.e2e.Vault.Story(
id = "s1", authorId = 33, mine = false,
ref = io.flamenet.e2e.Attachment.Ref(
kind = io.flamenet.e2e.Attachment.STORY,
id = "blob1", key = ByteArray(32), digest = ByteArray(32),
bytes = 1024, mime = "image/jpeg", width = 1200, height = 900,
expires = System.currentTimeMillis() / 1000.0 + 7 * 3600),
caption = "The view from the workshop this morning.",
postedAt = System.currentTimeMillis(), seen = false),
io.flamenet.e2e.Vault.Story(
id = "s2", authorId = 32, mine = false,
ref = io.flamenet.e2e.Attachment.Ref(
kind = io.flamenet.e2e.Attachment.STORY,
id = "blob2", key = ByteArray(32), digest = ByteArray(32),
bytes = 1024, mime = "image/jpeg", width = 1200, height = 900,
expires = System.currentTimeMillis() / 1000.0 + 1800),
caption = "", postedAt = System.currentTimeMillis(), seen = true),
),
notifications = listOf(
MessengerApi.Notification(1, "contact request",
"Hoang Nguyen wants to add you", read = false),