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:
co-authored by
Claude Opus 4.8
parent
1bbf8b460b
commit
4441400cd2
@@ -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") }
|
||||
},
|
||||
|
||||
@@ -226,6 +226,15 @@ class Conversations(
|
||||
toUserIds: List<Long>,
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user