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