refactor(hls): replace auto kind-1 cross-post with editable draft handoff

The auto-publish behaviour from the previous commit silently sent a
kind-1 note the user could not edit before it hit the relays. Replace
it with a handoff into Amethyst's existing NewShortNote composer
pre-filled with the title, description and master playlist URL so the
author can tweak the text, add hashtags/mentions, and send when ready.

Changes:
- HlsPublishOrchestrator drops the signAndPublishNote callback and
  stops publishing any kind-1 itself. HlsPublishState.Success drops
  the noteEventId field.
- HlsPublishRequest drops crossPostAsNote (the orchestrator no longer
  cares about the toggle).
- NewHlsVideoViewModel renames crossPostAsNote -> draftNoteAfterUpload
  and the screen renames the switch to "Draft note after upload".
- SuccessBody now reads vm.draftNoteAfterUpload: when on, it offers a
  "Draft note" primary button that navigates to Route.NewShortNote
  with the message parameter filled via a small buildDraftNoteText
  helper (title + description + masterUrl, blanks collapsed). The
  existing short-note composer accepts message as its initial text.
  When off, the button reverts to "Done".
- Production factory wiring drops the TextNoteEvent import + closure.
- Orchestrator tests drop the two cross-post cases; a single
  signAndPublish callback is now enough.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
davotoula
2026-04-14 21:57:55 +02:00
parent 2dab90fc2e
commit 453bdab202
7 changed files with 24 additions and 112 deletions
@@ -41,7 +41,6 @@ data class HlsPublishRequest(
val codec: VideoCodec, val codec: VideoCodec,
val server: ServerName, val server: ServerName,
val durationSeconds: Int? = null, val durationSeconds: Int? = null,
val crossPostAsNote: Boolean = true,
) )
/** /**
@@ -61,7 +60,6 @@ class HlsPublishOrchestrator(
) -> HlsBundle, ) -> HlsBundle,
private val buildUploader: (ServerName) -> HlsBlobUploader, private val buildUploader: (ServerName) -> HlsBlobUploader,
private val signAndPublish: suspend (HlsVideoEventTemplate) -> String, private val signAndPublish: suspend (HlsVideoEventTemplate) -> String,
private val signAndPublishNote: suspend (content: String) -> String,
private val workDirFactory: () -> File, private val workDirFactory: () -> File,
) { ) {
val state: StateFlow<HlsPublishState> = _state val state: StateFlow<HlsPublishState> = _state
@@ -98,19 +96,10 @@ class HlsPublishOrchestrator(
) )
val eventId = signAndPublish(template) val eventId = signAndPublish(template)
val noteEventId =
if (request.crossPostAsNote) {
val content = buildCompanionNoteContent(request, uploadResult.masterUrl)
signAndPublishNote(content)
} else {
null
}
_state.value = _state.value =
HlsPublishState.Success( HlsPublishState.Success(
eventId = eventId, eventId = eventId,
masterUrl = uploadResult.masterUrl, masterUrl = uploadResult.masterUrl,
noteEventId = noteEventId,
) )
} catch (e: CancellationException) { } catch (e: CancellationException) {
_state.value = HlsPublishState.Failure(message = "Cancelled") _state.value = HlsPublishState.Failure(message = "Cancelled")
@@ -125,13 +114,4 @@ class HlsPublishOrchestrator(
} }
private fun contentWarningOrNull(request: HlsPublishRequest): String? = if (request.sensitiveContent) request.contentWarningReason else null private fun contentWarningOrNull(request: HlsPublishRequest): String? = if (request.sensitiveContent) request.contentWarningReason else null
private fun buildCompanionNoteContent(
request: HlsPublishRequest,
masterUrl: String,
): String =
listOf(request.title, request.description, masterUrl)
.map { it.trim() }
.filter { it.isNotEmpty() }
.joinToString("\n\n")
} }
@@ -26,7 +26,6 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.hls.HlsBlobUploaderFactory import com.vitorpamplona.amethyst.service.uploads.hls.HlsBlobUploaderFactory
import com.vitorpamplona.amethyst.service.uploads.hls.HlsTranscoder import com.vitorpamplona.amethyst.service.uploads.hls.HlsTranscoder
import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoEventTemplate import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoEventTemplate
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import java.io.File import java.io.File
@@ -68,11 +67,6 @@ fun createProductionHlsPublishOrchestrator(
account.sendAutomatic(signed) account.sendAutomatic(signed)
signed.id signed.id
}, },
signAndPublishNote = { content ->
val signed = account.signer.sign(TextNoteEvent.build(content))
account.sendAutomatic(signed)
signed.id
},
workDirFactory = { workDirFactory = {
File(context.cacheDir, "hls-${System.currentTimeMillis()}").apply { mkdirs() } File(context.cacheDir, "hls-${System.currentTimeMillis()}").apply { mkdirs() }
}, },
@@ -38,7 +38,6 @@ sealed class HlsPublishState {
data class Success( data class Success(
val eventId: String, val eventId: String,
val masterUrl: String, val masterUrl: String,
val noteEventId: String? = null,
) : HlsPublishState() ) : HlsPublishState()
data class Failure( data class Failure(
@@ -321,25 +321,26 @@ private fun FormFields(vm: NewHlsVideoViewModel) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
// Cross-post as kind-1 note toggle // Draft-a-note-after-upload toggle — opens the existing short-note composer prefilled
// with the title, description and master playlist URL; the user edits and posts.
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = stringResource(R.string.hls_cross_post_as_note), text = stringResource(R.string.hls_draft_note_after_upload),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
) )
Text( Text(
text = stringResource(R.string.hls_cross_post_as_note_explainer), text = stringResource(R.string.hls_draft_note_after_upload_explainer),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
Switch( Switch(
checked = vm.crossPostAsNote, checked = vm.draftNoteAfterUpload,
onCheckedChange = { vm.crossPostAsNote = it }, onCheckedChange = { vm.draftNoteAfterUpload = it },
) )
} }
@@ -613,16 +614,16 @@ private fun SuccessBody(
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
val noteId = state.noteEventId if (vm.draftNoteAfterUpload) {
if (noteId != null) {
Button( Button(
onClick = { onClick = {
val draft = buildDraftNoteText(vm.title, vm.description, state.masterUrl)
vm.reset() vm.reset()
nav.nav(Route.Note(noteId)) nav.nav(Route.NewShortNote(message = draft))
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text(stringResource(R.string.hls_view_note)) Text(stringResource(R.string.hls_draft_note_button))
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
OutlinedButton( OutlinedButton(
@@ -648,6 +649,16 @@ private fun SuccessBody(
} }
} }
private fun buildDraftNoteText(
title: String,
description: String,
masterUrl: String,
): String =
listOf(title, description, masterUrl)
.map { it.trim() }
.filter { it.isNotEmpty() }
.joinToString("\n\n")
@Composable @Composable
private fun FailureBody( private fun FailureBody(
vm: NewHlsVideoViewModel, vm: NewHlsVideoViewModel,
@@ -63,7 +63,7 @@ open class NewHlsVideoViewModel : ViewModel() {
var sensitiveContent by mutableStateOf(false) var sensitiveContent by mutableStateOf(false)
var contentWarningReason by mutableStateOf("") var contentWarningReason by mutableStateOf("")
var useH265 by mutableStateOf(true) var useH265 by mutableStateOf(true)
var crossPostAsNote by mutableStateOf(true) var draftNoteAfterUpload by mutableStateOf(true)
var selectedServer by mutableStateOf<ServerName?>(null) var selectedServer by mutableStateOf<ServerName?>(null)
private val _state = MutableStateFlow<HlsPublishState>(HlsPublishState.Idle) private val _state = MutableStateFlow<HlsPublishState>(HlsPublishState.Idle)
@@ -147,7 +147,6 @@ open class NewHlsVideoViewModel : ViewModel() {
codec = codec, codec = codec,
server = server, server = server,
durationSeconds = sourceMetadata?.durationSeconds, durationSeconds = sourceMetadata?.durationSeconds,
crossPostAsNote = crossPostAsNote,
) )
currentJob = currentJob =
+3 -2
View File
@@ -2112,8 +2112,9 @@
<string name="hls_view_note">View note</string> <string name="hls_view_note">View note</string>
<string name="hls_done">Done</string> <string name="hls_done">Done</string>
<string name="hls_try_again">Try again</string> <string name="hls_try_again">Try again</string>
<string name="hls_cross_post_as_note">Cross-post as note</string> <string name="hls_draft_note_after_upload">Draft note after upload</string>
<string name="hls_cross_post_as_note_explainer">Also publish a regular Nostr note linking to the video so it shows in the home feed.</string> <string name="hls_draft_note_after_upload_explainer">Open the note composer pre-filled with the title, description and video link so you can tweak it before posting.</string>
<string name="hls_draft_note_button">Draft note</string>
<string name="pack_actions_dialog_title">Pack Actions</string> <string name="pack_actions_dialog_title">Pack Actions</string>
<string name="list_actions_dialog_title">List Actions</string> <string name="list_actions_dialog_title">List Actions</string>
@@ -98,7 +98,6 @@ class HlsPublishOrchestratorTest {
description: String = "A test clip", description: String = "A test clip",
sensitive: Boolean = false, sensitive: Boolean = false,
warningReason: String = "", warningReason: String = "",
crossPostAsNote: Boolean = false,
) = HlsPublishRequest( ) = HlsPublishRequest(
title = title, title = title,
description = description, description = description,
@@ -106,7 +105,6 @@ class HlsPublishOrchestratorTest {
contentWarningReason = warningReason, contentWarningReason = warningReason,
codec = VideoCodec.H265, codec = VideoCodec.H265,
server = server, server = server,
crossPostAsNote = crossPostAsNote,
) )
@Test @Test
@@ -121,7 +119,6 @@ class HlsPublishOrchestratorTest {
publishedTemplates += tpl publishedTemplates += tpl
"signed-event-id" "signed-event-id"
}, },
signAndPublishNote = { "note-id" },
workDirFactory = { File(workDir, "work").apply { mkdirs() } }, workDirFactory = { File(workDir, "work").apply { mkdirs() } },
) )
@@ -164,7 +161,6 @@ class HlsPublishOrchestratorTest {
capturedDuringPublish += orchestrator.state.value capturedDuringPublish += orchestrator.state.value
"event-id" "event-id"
}, },
signAndPublishNote = { "note-id" },
workDirFactory = { File(workDir, "work").apply { mkdirs() } }, workDirFactory = { File(workDir, "work").apply { mkdirs() } },
) )
@@ -188,7 +184,6 @@ class HlsPublishOrchestratorTest {
runTranscode = { _, _, _ -> throw RuntimeException("decode failed") }, runTranscode = { _, _, _ -> throw RuntimeException("decode failed") },
buildUploader = { CannedUploader() }, buildUploader = { CannedUploader() },
signAndPublish = { "never" }, signAndPublish = { "never" },
signAndPublishNote = { "note-id" },
workDirFactory = { File(workDir, "work").apply { mkdirs() } }, workDirFactory = { File(workDir, "work").apply { mkdirs() } },
) )
@@ -209,7 +204,6 @@ class HlsPublishOrchestratorTest {
HlsBlobUploader { _, _ -> throw RuntimeException("server 500") } HlsBlobUploader { _, _ -> throw RuntimeException("server 500") }
}, },
signAndPublish = { "never" }, signAndPublish = { "never" },
signAndPublishNote = { "note-id" },
workDirFactory = { File(workDir, "work").apply { mkdirs() } }, workDirFactory = { File(workDir, "work").apply { mkdirs() } },
) )
@@ -228,7 +222,6 @@ class HlsPublishOrchestratorTest {
runTranscode = { _, _, _ -> fakeBundle() }, runTranscode = { _, _, _ -> fakeBundle() },
buildUploader = { CannedUploader() }, buildUploader = { CannedUploader() },
signAndPublish = { throw RuntimeException("relay rejected") }, signAndPublish = { throw RuntimeException("relay rejected") },
signAndPublishNote = { "note-id" },
workDirFactory = { File(workDir, "work").apply { mkdirs() } }, workDirFactory = { File(workDir, "work").apply { mkdirs() } },
) )
@@ -251,7 +244,6 @@ class HlsPublishOrchestratorTest {
captured += tpl captured += tpl
"event-id" "event-id"
}, },
signAndPublishNote = { "note-id" },
workDirFactory = { File(workDir, "work").apply { mkdirs() } }, workDirFactory = { File(workDir, "work").apply { mkdirs() } },
) )
@@ -290,7 +282,6 @@ class HlsPublishOrchestratorTest {
captured += tpl captured += tpl
"event-id" "event-id"
}, },
signAndPublishNote = { "note-id" },
workDirFactory = { File(workDir, "work").apply { mkdirs() } }, workDirFactory = { File(workDir, "work").apply { mkdirs() } },
) )
@@ -299,68 +290,6 @@ class HlsPublishOrchestratorTest {
assertTrue(captured.single() is HlsVideoEventTemplate.Vertical) assertTrue(captured.single() is HlsVideoEventTemplate.Vertical)
} }
@Test
fun crossPostAsNoteInvokesSignAndPublishNoteWithTitleDescriptionAndMasterUrl() {
val capturedNoteContent = mutableListOf<String>()
val orchestrator =
HlsPublishOrchestrator(
_state = MutableStateFlow(HlsPublishState.Idle),
runTranscode = { _, _, _ -> fakeBundle() },
buildUploader = { CannedUploader() },
signAndPublish = { "video-event-id" },
signAndPublishNote = { content ->
capturedNoteContent += content
"note-event-id"
},
workDirFactory = { File(workDir, "work").apply { mkdirs() } },
)
runBlocking {
orchestrator.publish(
newRequest(
title = "Sunset",
description = "Golden hour",
crossPostAsNote = true,
),
)
}
assertEquals(1, capturedNoteContent.size)
val note = capturedNoteContent[0]
assertTrue("note missing title: $note", note.contains("Sunset"))
assertTrue("note missing description: $note", note.contains("Golden hour"))
assertTrue("note missing master url: $note", note.contains("https://cdn.test/"))
val final = orchestrator.state.value as HlsPublishState.Success
assertEquals("note-event-id", final.noteEventId)
assertEquals("video-event-id", final.eventId)
}
@Test
fun crossPostDisabledLeavesNoteEventIdNull() {
val noteCallbackInvocations = mutableListOf<String>()
val orchestrator =
HlsPublishOrchestrator(
_state = MutableStateFlow(HlsPublishState.Idle),
runTranscode = { _, _, _ -> fakeBundle() },
buildUploader = { CannedUploader() },
signAndPublish = { "video-event-id" },
signAndPublishNote = {
noteCallbackInvocations += it
"should-not-be-used"
},
workDirFactory = { File(workDir, "work").apply { mkdirs() } },
)
runBlocking {
orchestrator.publish(newRequest(crossPostAsNote = false))
}
assertEquals(0, noteCallbackInvocations.size)
val final = orchestrator.state.value as HlsPublishState.Success
assertEquals(null, final.noteEventId)
}
@Test @Test
fun resetRestoresIdleState() { fun resetRestoresIdleState() {
val orchestrator = val orchestrator =
@@ -369,7 +298,6 @@ class HlsPublishOrchestratorTest {
runTranscode = { _, _, _ -> throw RuntimeException("boom") }, runTranscode = { _, _, _ -> throw RuntimeException("boom") },
buildUploader = { CannedUploader() }, buildUploader = { CannedUploader() },
signAndPublish = { "never" }, signAndPublish = { "never" },
signAndPublishNote = { "note-id" },
workDirFactory = { File(workDir, "work").apply { mkdirs() } }, workDirFactory = { File(workDir, "work").apply { mkdirs() } },
) )