From 4441400cd21970797a7719c376beb7ff3acbf6b2 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sat, 29 Aug 2026 14:10:25 -0400 Subject: [PATCH] Actually let you attach a live stream to a story MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same gap as iOS: §6.13 shipped the wire format, the model, and the viewer's consent gate, with no way to enter a URL. Adds the field to the composer, with the disclosure said when attaching rather than only when watching. ⚠️ Post is disabled for a URL that could not be disclosed — a destination with no host cannot be named, and a viewer cannot consent to one they cannot be shown. ⚠️ The poster's own copy carries the stream too, so the story does not look different to the person who sent it than to everyone who receives it. engine 177, client 96, app 46. Co-Authored-By: Claude Opus 4.8 --- .../messenger/app/MessengerViewModel.kt | 16 ++++++-- .../io/flamenet/messenger/app/Session.kt | 7 +++- .../io/flamenet/messenger/app/StoriesFeed.kt | 39 +++++++++++++++++++ .../io/flamenet/client/Conversations.kt | 11 +++++- 4 files changed, 67 insertions(+), 6 deletions(-) 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 fac835e..8b03abc 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -1517,6 +1517,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { val height: Int, /** Seconds; non-zero exactly when this is a video. */ val duration: Double = 0.0, + /** A live stream to embed (§6.13); the picked media becomes its poster. */ + val streamUrl: String? = null, ) /** Hold picked media for the composer sheet rather than posting it. */ @@ -1531,17 +1533,20 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { fun discardStagedStory() = update { it.copy(storyDraft = null) } /** The deliberate Post from the composer sheet. */ - fun postStagedStory(caption: String?, audience: Session.StoryAudience? = null) { + fun postStagedStory(caption: String?, audience: Session.StoryAudience? = null, + streamUrl: String? = null) { val draft = signedIn()?.storyDraft ?: return update { it.copy(storyDraft = null) } postStory(draft.bytes, draft.mime, draft.width, draft.height, - caption?.takeIf { it.isNotBlank() }, audience, draft.duration) + caption?.takeIf { it.isNotBlank() }, audience, draft.duration, + streamUrl?.takeIf { it.isNotBlank() }) } fun postStory(bytes: ByteArray, mime: String, width: Int, height: Int, caption: String?, /** THIS story's audience from the composer; null = the account setting. */ audienceOverride: Session.StoryAudience? = null, - duration: Double = 0.0) = + duration: Double = 0.0, + streamUrl: String? = null) = viewModelScope.launch { val current = session ?: return@launch val contacts = signedIn()?.buddies?.map { it.userId }.orEmpty() @@ -1561,7 +1566,10 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { } update { it.copy(busy = true, error = null) } val done = withContext(Dispatchers.IO) { - runCatching { current.postStory(bytes, mime, width, height, caption, recipients, duration) } + runCatching { + current.postStory(bytes, mime, width, height, caption, recipients, + duration, streamUrl) + } } done.onSuccess { posted -> update { diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index 804c919..b7f080b 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -825,9 +825,11 @@ class Session( caption: String?, toUserIds: List, duration: Double = 0.0, + /** A live stream this story embeds (§6.13); [bytes] is then the poster. */ + streamUrl: String? = null, ): io.flamenet.client.Conversations.StoryPosted { val posted = conversations.postStory(bytes, mime, width, height, caption, toUserIds, - duration = duration) + duration = duration, streamUrl = streamUrl) if (posted.reached > 0) { val now = System.currentTimeMillis() / 1000.0 vault.saveStories( @@ -839,6 +841,9 @@ class Session( caption = caption.orEmpty(), postedAt = System.currentTimeMillis(), seen = true, + // The poster sees the same gate their contacts will, rather + // than a story that looks different to the person who sent it. + streamUrl = streamUrl, ), now, ) 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 be5591d..5c44a33 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt @@ -206,6 +206,10 @@ fun StoryComposerDialog( ) { val draft = phase.storyDraft ?: return val caption = remember(draft) { androidx.compose.runtime.mutableStateOf("") } + val streamUrl = remember(draft) { androidx.compose.runtime.mutableStateOf("") } + // Whether the typed URL is one that could be disclosed to a viewer (§6.13). + val streamOk = streamUrl.value.isBlank() || + io.flamenet.e2e.StreamDisclosure.acceptable(streamUrl.value) // Per-story audience, starting where the account setting points. val mode = remember(draft) { androidx.compose.runtime.mutableStateOf(phase.storyAudience.mode) @@ -252,6 +256,36 @@ fun StoryComposerDialog( // Who THIS story goes to. The audience decides who is SENT // the bytes — there is no relay copy to deny anyone later — // so the choice belongs here, before posting. Seeded from the + // §6.13 — attach a live stream. The picked picture becomes its + // poster: what people see before deciding whether to connect. + androidx.compose.material3.OutlinedTextField( + value = streamUrl.value, + onValueChange = { streamUrl.value = it }, + label = { Text("Live stream link (optional)", fontSize = 12.sp) }, + singleLine = true, + isError = !streamOk, + modifier = Modifier.fillMaxWidth().testTag("story-stream-field"), + ) + if (streamUrl.value.isNotBlank()) { + // 🔴 Says what a stream costs the people watching, at the + // moment of attaching one. Everything else in a story is + // sealed; this is the one thing that sends viewers to + // somebody else's server. + Text( + if (streamOk) { + "Your picture becomes the cover. Anyone who taps play connects " + + "to that site, which will see their IP address — they are " + + "told before it happens." + } else { + "That needs to be a full http:// or https:// link with a site " + + "name in it." + }, + fontSize = 12.sp, + color = if (streamOk) Palette.SecondaryText + else MaterialTheme.colorScheme.error, + ) + } + // account setting; never written back to it. Row(horizontalArrangement = Arrangement.spacedBy(Space.xs)) { listOf( @@ -307,8 +341,13 @@ fun StoryComposerDialog( model.postStagedStory( caption.value, Session.StoryAudience(mode.value, chosen.value), + streamUrl.value, ) }, + // ⚠️ Refuses to post a stream URL it could not disclose. A + // destination with no host cannot be named, and a viewer cannot + // consent to one they cannot be shown. + enabled = streamOk, modifier = Modifier.testTag("story-post"), ) { Text("Post") } }, diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index 5e45ca8..bff18cb 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -226,6 +226,15 @@ class Conversations( toUserIds: List, lifetimeSeconds: Double = STORY_LIFETIME_SECONDS, duration: Double = 0.0, + /** + * A live stream this story embeds (§6.13). + * + * ⚠️ When set, [data] is the POSTER — a still the viewer sees before + * deciding whether to connect — not the stream. A stream cannot be + * sealed: it is continuous and hosted elsewhere, so watching it means + * the viewer's device contacts that host directly. + */ + streamUrl: String? = null, ): StoryPosted { val sealed = Attachment.seal(data) val id = relay.uploadAttachment(sealed.blob) @@ -235,7 +244,7 @@ class Conversations( duration = duration, expires = System.currentTimeMillis() / 1000.0 + lifetimeSeconds, ) - val body = Content.attachment(ref, caption, null) + val body = Content.attachment(ref, caption, null, streamUrl = streamUrl) var reached = 0 var missed = 0