From 86550f3f615f7333a927704f795d74eef616c1ca Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Thu, 27 Aug 2026 14:58:30 -0400 Subject: [PATCH] Stories as a feed you scroll, which is the kit's Timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../io/flamenet/messenger/app/ChatScreens.kt | 3 +- .../io/flamenet/messenger/app/MainActivity.kt | 3 + .../messenger/app/MessengerViewModel.kt | 8 +- .../io/flamenet/messenger/app/StoriesFeed.kt | 171 ++++++++++++++++++ .../flamenet/messenger/app/ScreenshotTest.kt | 32 ++++ 5 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt 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 ed88de3..ccd7ef0 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -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(null) } var failed by remember(ref.id) { mutableStateOf(false) } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt index b347a78..1462b0e 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -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") diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index b2b84b1..b909a02 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -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( diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt new file mode 100644 index 0000000..95d6772 --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt @@ -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) + } + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt index c19de63..bb46930 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt @@ -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() + 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),