Actually let you attach a live stream to a story

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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-29 14:10:25 -04:00
co-authored by Claude Opus 4.8
parent 1bbf8b460b
commit 4441400cd2
4 changed files with 67 additions and 6 deletions
@@ -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 {
@@ -825,9 +825,11 @@ class Session(
caption: String?,
toUserIds: List<Long>,
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,
)
@@ -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") }
},