diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 161e370e..8a55b6cf 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -501,4 +501,26 @@
%1$d words · %2$d characters · %3$s · %4$d of %5$d chunks
%1$s of %2$s
%1$s KiB
+ Upload chapters
+ Upload chapters to %1$s
+ Choose files
+ Add more files
+ No files picked yet.
+ Every file becomes its own proposal, and each one needs a quorum of the group to sign it. Twelve files is twelve things every other member is asked to read and approve.
+ At most %1$d files in one upload.
+ Propose %1$d chapters
+ Propose the remaining %1$d
+ %1$d proposed, %2$d failed.
+ %1$d chapters proposed.
+ Chapter %1$d of the version
+ Waiting for the group to sign
+ Remove from this upload
+ Not text. A chapter is markdown or plain text.
+ Too large to be a chapter.
+ Nothing in it to translate.
+ Give this chapter a name.
+ %1$d chunks, and the group signs at most %2$d in one go. Split this file.
+ %1$s, and a group message carries %2$s. Split this file.
+ The same text as %1$s. Proposing both makes two chapters.
+ Proposing this one failed. Try again.
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt
index b8fd9da9..06c128fd 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt
@@ -1,6 +1,7 @@
package press.mantra.compose.ui.composable
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -36,7 +37,10 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.database.model.MantraArtifact
import press.mantra.compose.repository.MantraRepository
import press.mantra.compose.ui.composable.widgets.DetailRow
+import androidx.compose.material.icons.filled.UploadFile
+import mantra.composeapp.generated.resources.upload_chapters
import press.mantra.compose.ui.composable.navigation.routes.AddChapterRoute
+import press.mantra.compose.ui.composable.navigation.routes.UploadChaptersRoute
import press.mantra.compose.ui.composable.navigation.routes.AddTranslationRoute
import press.mantra.compose.ui.composable.navigation.routes.ChapterDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.Route
@@ -162,25 +166,61 @@ fun ArtifactDetailScreen(
}
item {
- TextButton(
- enabled = artifactDetailUIState.versions.isNotEmpty(),
- onClick = {
- onNavigateToRoute.invoke(
- AddChapterRoute(
- activeUserPublicKey = activeUserPublicKey,
- artifactId = artifact.id,
- chatRoomId = chatRoomId,
- relayHint = relayHint
- )
- )
- }
- ) {
- Icon(
- Icons.Default.PostAdd,
- contentDescription = "Add chapter"
+ // Two ways in, because they are two different acts.
+ // One chapter typed or pasted is a text field and one
+ // proposal; a folder of files is a review list and one
+ // proposal each, which is a different thing to put in
+ // front of the room.
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(
+ MaterialTheme.spacing.itemGap
)
- Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125))
- Text(stringResource(Res.string.add_chapter))
+ ) {
+ TextButton(
+ enabled = artifactDetailUIState.versions.isNotEmpty(),
+ onClick = {
+ onNavigateToRoute.invoke(
+ AddChapterRoute(
+ activeUserPublicKey = activeUserPublicKey,
+ artifactId = artifact.id,
+ chatRoomId = chatRoomId,
+ relayHint = relayHint
+ )
+ )
+ }
+ ) {
+ Icon(
+ Icons.Default.PostAdd,
+ contentDescription = "Add chapter"
+ )
+ Spacer(
+ modifier = Modifier.width(MaterialTheme.spacing.space125)
+ )
+ Text(stringResource(Res.string.add_chapter))
+ }
+
+ TextButton(
+ enabled = artifactDetailUIState.versions.isNotEmpty(),
+ onClick = {
+ onNavigateToRoute.invoke(
+ UploadChaptersRoute(
+ activeUserPublicKey = activeUserPublicKey,
+ artifactId = artifact.id,
+ chatRoomId = chatRoomId,
+ relayHint = relayHint
+ )
+ )
+ }
+ ) {
+ Icon(
+ Icons.Default.UploadFile,
+ contentDescription = "Upload chapters from files"
+ )
+ Spacer(
+ modifier = Modifier.width(MaterialTheme.spacing.space125)
+ )
+ Text(stringResource(Res.string.upload_chapters))
+ }
}
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UploadChaptersScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UploadChaptersScreen.kt
new file mode 100644
index 00000000..23ae2785
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UploadChaptersScreen.kt
@@ -0,0 +1,559 @@
+package press.mantra.compose.ui.composable
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.HourglassTop
+import androidx.compose.material.icons.filled.UploadFile
+import androidx.compose.material3.BottomAppBar
+import androidx.compose.material3.Card
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import mantra.composeapp.generated.resources.Res
+import mantra.composeapp.generated.resources.add_more_files
+import mantra.composeapp.generated.resources.at_most_d_files_in_one_upload
+import mantra.composeapp.generated.resources.chapter_d_of_the_version
+import mantra.composeapp.generated.resources.chapter_name
+import mantra.composeapp.generated.resources.choose_chapter_files
+import mantra.composeapp.generated.resources.choose_files
+import mantra.composeapp.generated.resources.d_chapters_proposed
+import mantra.composeapp.generated.resources.d_chunks_is_too_many_to_sign
+import mantra.composeapp.generated.resources.d_proposed_d_failed
+import mantra.composeapp.generated.resources.no_files_picked_yet
+import mantra.composeapp.generated.resources.one_proposal_per_file
+import mantra.composeapp.generated.resources.propose_d_chapters
+import mantra.composeapp.generated.resources.propose_the_remaining_d
+import mantra.composeapp.generated.resources.proposing_this_one_failed
+import mantra.composeapp.generated.resources.remove_from_this_upload
+import mantra.composeapp.generated.resources.s_is_more_than_a_proposal_carries
+import mantra.composeapp.generated.resources.s_kib
+import mantra.composeapp.generated.resources.same_text_as_s
+import mantra.composeapp.generated.resources.this_artifact_has_no_version_for_a_chapter
+import mantra.composeapp.generated.resources.this_file_has_no_name
+import mantra.composeapp.generated.resources.this_file_is_empty
+import mantra.composeapp.generated.resources.this_file_is_not_text
+import mantra.composeapp.generated.resources.this_file_is_too_large
+import mantra.composeapp.generated.resources.this_group_has_no_shared_key_for_a_chapter
+import mantra.composeapp.generated.resources.upload_chapters_to
+import mantra.composeapp.generated.resources.waiting_for_the_group_to_sign
+import mantra.composeapp.generated.resources.words_characters
+import org.jetbrains.compose.resources.stringResource
+import press.mantra.compose.database.model.ChatRoom
+import press.mantra.compose.database.model.MantraArtifact
+import press.mantra.compose.database.model.MantraArtifactVersion
+import press.mantra.compose.database.model.intermdiate.LocalChatRoom
+import press.mantra.compose.files.rememberFilePicker
+import press.mantra.compose.nostr.nip30303.ChapterProposal
+import press.mantra.compose.repository.ChatRepository
+import press.mantra.compose.repository.FrostSigningRepository
+import press.mantra.compose.repository.MantraRepository
+import press.mantra.compose.text.kibibytes
+import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute
+import press.mantra.compose.ui.composable.navigation.routes.Route
+import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
+import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
+import press.mantra.compose.ui.composable.widgets.NavigateBackButton
+import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
+import press.mantra.compose.ui.composable.widgets.EmptyState
+import press.mantra.compose.ui.composable.widgets.ErrorState
+import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab
+import press.mantra.compose.ui.composable.widgets.rememberNotifier
+import press.mantra.compose.ui.theme.ConformancePreviews
+import press.mantra.compose.ui.theme.MantraTheme
+import press.mantra.compose.ui.theme.readableContent
+import press.mantra.compose.ui.theme.spacing
+import press.mantra.compose.ui.view.model.UploadChaptersViewModel
+import press.mantra.compose.ui.view.state.PickedChapter
+import press.mantra.compose.ui.view.state.UploadChaptersUIState
+import press.mantra.compose.ui.view.state.UploadProblem
+import press.mantra.compose.ui.view.state.UploadReview
+
+/**
+ * Picking files, reviewing what they would become, and putting each to the
+ * group.
+ *
+ * **A review step, not a submit button**, and that is where this departs
+ * visibly from the web app it mirrors. mantra.press can afford to post twelve
+ * files and redirect, because what it creates belongs to the uploader and the
+ * uploader can fix it. Here each file becomes a request for the group's own
+ * signature, so a filename standing in for a title, a file that decoded to
+ * nothing, or a chapter too long to carry is wrong in something a quorum is
+ * about to put its name to. Picking files therefore produces a list, and the
+ * list is what is proposed.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun UploadChaptersScreen(
+ activeUserPublicKey: HexKey,
+ artifactId: String,
+ chatRoomId: String,
+ relayHint: String?,
+ initialUIState: UploadChaptersUIState = UploadChaptersUIState.Loading,
+ mantraRepository: MantraRepository,
+ chatRepository: ChatRepository,
+ frostSigningRepository: FrostSigningRepository,
+ onNavigateToRouteAndPopUpInclusive: (Route) -> Unit,
+ onNavigateBack: () -> Unit,
+) {
+ val viewModel: UploadChaptersViewModel = viewModel(
+ factory = UploadChaptersViewModel.factory(
+ activeUserPublicKey = activeUserPublicKey,
+ artifactId = artifactId,
+ chatRoomId = chatRoomId,
+ relayHint = relayHint,
+ initialUIState = initialUIState,
+ mantraRepository = mantraRepository,
+ chatRepository = chatRepository,
+ frostSigningRepository = frostSigningRepository,
+ )
+ )
+
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+ ScreenStateTransition(uiState) { state ->
+ when (state) {
+ UploadChaptersUIState.Loading -> LoadingDataIndicator(fillScreen = true)
+
+ is UploadChaptersUIState.Error ->
+ ErrorState(
+ message = state.message,
+ onRetry = { viewModel.initiate() },
+ )
+
+ is UploadChaptersUIState.Loaded -> {
+ val notify = rememberNotifier(rememberCoroutineScope())
+
+ // The sentence is built in composition and shown from an effect,
+ // because `stringResource` is composable and the propose
+ // callback is not. Formatting it by hand in the callback was the
+ // first draft and meant doing a resource's own job with
+ // `replaceFirst`.
+ val outcomeMessage = state.lastOutcome?.let { outcome ->
+ if (outcome.failed > 0) {
+ stringResource(
+ Res.string.d_proposed_d_failed,
+ outcome.proposed,
+ outcome.failed,
+ )
+ } else {
+ stringResource(Res.string.d_chapters_proposed, outcome.proposed)
+ }
+ }
+
+ LaunchedEffect(state.lastOutcome?.pass) {
+ outcomeMessage?.let(notify)
+ }
+
+ val picker = rememberFilePicker(
+ title = stringResource(Res.string.choose_chapter_files)
+ ) { files -> viewModel.addFiles(files) }
+
+ val version = state.artifactVersion
+
+ // Where each row lands, counted over the rows not yet proposed:
+ // a proposed chapter has taken its number and the next one does
+ // not take it again.
+ val offsets = remember(state.chapters) {
+ var next = 0
+ buildMap {
+ state.chapters.forEach { chapter ->
+ if (chapter.sessionId == null) put(chapter.id, next++)
+ }
+ }
+ }
+
+ Scaffold(
+ snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
+ // A name field per row, so the keyboard must not cover the
+ // row being named.
+ modifier = Modifier.imePadding(),
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ stringResource(
+ Res.string.upload_chapters_to,
+ state.artifact.name,
+ )
+ )
+ },
+ navigationIcon = { NavigateBackButton(onNavigateBack) },
+ )
+ },
+ bottomBar = {
+ BottomAppBar(
+ actions = {},
+ floatingActionButton = {
+ val remaining = state.proposable.size
+ val isRetry = state.proposed.isNotEmpty()
+
+ ExtendedFab(
+ label = if (isRetry) {
+ stringResource(Res.string.propose_the_remaining_d, remaining)
+ } else {
+ stringResource(Res.string.propose_d_chapters, remaining)
+ },
+ icon = {
+ Icon(
+ Icons.Default.UploadFile,
+ contentDescription = "Propose these chapters"
+ )
+ },
+ enabled = state.canPropose,
+ onClick = {
+ if (version == null) return@ExtendedFab
+
+ viewModel.propose(
+ localChatRoom = state.localChatRoom,
+ artifactVersion = version,
+ ) { proposed, failed ->
+ // Leaves only when there is nothing
+ // left to aim a retry from. On a
+ // partial pass the screen stays: the
+ // rows that went are marked and the
+ // rest keep their reason, and that
+ // list is the only place a retry can
+ // be aimed from.
+ if (failed == 0 && proposed > 0) {
+ // The proposal list, not a
+ // signing screen: at N there is
+ // no one session to watch, and
+ // the list is the screen that
+ // exists for showing several.
+ onNavigateToRouteAndPopUpInclusive(
+ ProposalListRoute(
+ activeUserPublicKey = activeUserPublicKey,
+ chatRoomId = chatRoomId,
+ )
+ )
+ }
+ }
+ }
+ )
+ }
+ )
+ }
+ ) { innerPadding ->
+ LazyColumn(
+ modifier = Modifier
+ .padding(innerPadding)
+ .readableContent()
+ .fillMaxSize()
+ .padding(MaterialTheme.spacing.containerPadding),
+ verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)
+ ) {
+ item {
+ // Said before the picker opens, not after. An upload
+ // spends the room's attention, and the person
+ // picking files is the only one who can weigh that.
+ Text(
+ text = stringResource(Res.string.one_proposal_per_file),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ if (!state.canSign) {
+ item {
+ Text(
+ text = stringResource(
+ Res.string.this_group_has_no_shared_key_for_a_chapter
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ } else if (version == null) {
+ item {
+ Text(
+ text = stringResource(
+ Res.string.this_artifact_has_no_version_for_a_chapter
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+
+ item {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(
+ MaterialTheme.spacing.itemGap
+ ),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ TextButton(
+ enabled = state.chapters.size <
+ UploadChaptersViewModel.MAX_FILES_PER_UPLOAD,
+ onClick = { picker.launch(multiple = true) }
+ ) {
+ Icon(
+ Icons.Default.UploadFile,
+ contentDescription = "Choose files to upload"
+ )
+ Text(
+ modifier = Modifier.padding(
+ start = MaterialTheme.spacing.itemGap
+ ),
+ text = if (state.chapters.isEmpty()) {
+ stringResource(Res.string.choose_files)
+ } else {
+ stringResource(Res.string.add_more_files)
+ }
+ )
+ }
+
+ Text(
+ text = stringResource(
+ Res.string.at_most_d_files_in_one_upload,
+ UploadChaptersViewModel.MAX_FILES_PER_UPLOAD,
+ ),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ if (state.chapters.isEmpty()) {
+ item {
+ EmptyState(
+ message = stringResource(Res.string.no_files_picked_yet),
+ icon = Icons.Default.UploadFile,
+ )
+ }
+ }
+
+ items(items = state.chapters, key = { it.id }) { chapter ->
+ PickedChapterCard(
+ chapter = chapter,
+ index = state.baseIndex + (offsets[chapter.id] ?: 0),
+ onRename = { viewModel.rename(chapter.id, it) },
+ onRemove = { viewModel.remove(chapter.id) },
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ LaunchedEffect(true) {
+ if (initialUIState == UploadChaptersUIState.Loading) viewModel.initiate()
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun PickedChapterCard(
+ chapter: PickedChapter,
+ index: Int,
+ onRename: (String) -> Unit,
+ onRemove: () -> Unit,
+) {
+ val proposed = chapter.sessionId != null
+
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(
+ modifier = Modifier.padding(MaterialTheme.spacing.containerPadding),
+ verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.compactPadding)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = chapter.fileName,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = if (proposed) {
+ stringResource(Res.string.waiting_for_the_group_to_sign)
+ } else {
+ stringResource(Res.string.chapter_d_of_the_version, index)
+ },
+ style = MaterialTheme.typography.labelMedium
+ )
+ }
+
+ if (proposed) {
+ Icon(
+ Icons.Default.HourglassTop,
+ contentDescription = "Waiting for the group to sign"
+ )
+ } else {
+ IconButton(onClick = onRemove) {
+ Icon(
+ Icons.Default.Close,
+ contentDescription = stringResource(
+ Res.string.remove_from_this_upload
+ )
+ )
+ }
+ }
+ }
+
+ if (!proposed) {
+ OutlinedTextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = chapter.name,
+ onValueChange = onRename,
+ isError = chapter.problem == UploadProblem.Unnamed,
+ label = { Text(stringResource(Res.string.chapter_name)) },
+ singleLine = true,
+ )
+ }
+
+ Text(
+ text = stringResource(
+ Res.string.words_characters,
+ chapter.wordCount,
+ chapter.characterCount,
+ ),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ chapter.problem?.let { problem ->
+ Text(
+ text = problem.describe(),
+ style = MaterialTheme.typography.bodySmall,
+ // A warning is not an error. A duplicate is a thing worth
+ // saying and not a thing that stops the upload, and giving
+ // the two one colour would make the second look like the
+ // first.
+ color = if (problem.blocking) {
+ MaterialTheme.colorScheme.error
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ }
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun UploadProblem.describe(): String = when (this) {
+ UploadProblem.NotText -> stringResource(Res.string.this_file_is_not_text)
+ UploadProblem.TooLarge -> stringResource(Res.string.this_file_is_too_large)
+ UploadProblem.Empty -> stringResource(Res.string.this_file_is_empty)
+ UploadProblem.Unnamed -> stringResource(Res.string.this_file_has_no_name)
+ UploadProblem.ProposalFailed -> stringResource(Res.string.proposing_this_one_failed)
+ is UploadProblem.TooManyChunks -> stringResource(
+ Res.string.d_chunks_is_too_many_to_sign,
+ count,
+ UploadReview.MAX_CHUNKS_PER_CHAPTER,
+ )
+ is UploadProblem.TooLargeToPropose -> stringResource(
+ Res.string.s_is_more_than_a_proposal_carries,
+ stringResource(Res.string.s_kib, kibibytes(bytes)),
+ stringResource(Res.string.s_kib, kibibytes(ChapterProposal.MAX_PROPOSAL_BYTES)),
+ )
+ is UploadProblem.Duplicate -> stringResource(Res.string.same_text_as_s, of)
+}
+
+@ConformancePreviews
+@Composable
+private fun UploadChaptersScreenPreview() {
+ MantraTheme {
+ Surface(modifier = Modifier.fillMaxSize()) {
+ UploadChaptersScreen(
+ activeUserPublicKey = "",
+ artifactId = "artifactId",
+ chatRoomId = "chatRoomId",
+ relayHint = null,
+ initialUIState = UploadChaptersUIState.Loaded(
+ localChatRoom = LocalChatRoom(
+ chatRoom = ChatRoom(
+ id = "chatRoomId",
+ userPublicKey = "",
+ subject = "Message title",
+ description = "See something. Say somethin",
+ initialGiftWrapPayloadId = "sdfaer",
+ mlsGroupState = null
+ ),
+ ),
+ artifact = MantraArtifact(
+ id = "artifactId",
+ publicKey = "author",
+ name = "To Kill a Mockingbird",
+ url = "https://harper.com/2-kill-Bird",
+ visibility = "private",
+ dialectId = "dialectId",
+ license = "cc",
+ chatRoomId = "chatRoomId",
+ signature = ""
+ ),
+ artifactVersion = MantraArtifactVersion(
+ id = "artifactVersionId",
+ artifactId = "artifactId",
+ publicKey = "author",
+ versionLabel = "1.0",
+ chatRoomId = "chatRoomId",
+ signature = ""
+ ),
+ canSign = true,
+ baseIndex = 3,
+ chapters = listOf(
+ PickedChapter(
+ id = "0:01-the-wreck.md",
+ fileName = "01-the-wreck.md",
+ name = "The wreck",
+ markdown = "One.\n\nTwo.",
+ chunks = listOf("One.", "Two."),
+ wordCount = 2,
+ characterCount = 10,
+ proposalBytes = 1400,
+ ),
+ PickedChapter(
+ id = "1:02-cover.png",
+ fileName = "02-cover.png",
+ name = "",
+ markdown = "",
+ chunks = emptyList(),
+ wordCount = 0,
+ characterCount = 0,
+ proposalBytes = 448,
+ problem = UploadProblem.NotText,
+ ),
+ ),
+ ),
+ mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY,
+ chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
+ frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
+ onNavigateToRouteAndPopUpInclusive = {},
+ onNavigateBack = {}
+ )
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt
index 92f022d0..de29594f 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt
@@ -140,6 +140,7 @@ import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSigned
import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute
import press.mantra.compose.ui.composable.navigation.routes.AddChapterRoute
+import press.mantra.compose.ui.composable.navigation.routes.UploadChaptersRoute
import press.mantra.compose.ui.composable.navigation.routes.AddTranslationRoute
import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.ChapterDetailRoute
@@ -148,6 +149,7 @@ import press.mantra.compose.ui.composable.navigation.routes.TranslationChapterRo
import press.mantra.compose.ui.composable.navigation.routes.TranslationArtifactVersionDetailRoute
import press.mantra.compose.ui.composable.ArtifactDetailScreen
import press.mantra.compose.ui.composable.AddChapterScreen
+import press.mantra.compose.ui.composable.UploadChaptersScreen
import press.mantra.compose.ui.composable.AddDialectScreen
import press.mantra.compose.ui.composable.FrostSigningScreen
import press.mantra.compose.ui.composable.ProposalListScreen
@@ -1334,6 +1336,33 @@ fun MantraNavHost(
}
)
}
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+
+ UploadChaptersScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ artifactId = route.artifactId,
+ chatRoomId = route.chatRoomId,
+ relayHint = route.relayHint,
+ mantraRepository = databaseMantraRepository,
+ chatRepository = databaseChatRepository,
+ frostSigningRepository = databaseFrostSigningRepository,
+ onNavigateToRouteAndPopUpInclusive = { listRoute ->
+ // Replace this screen, so back returns to the artifact
+ // rather than to a review list whose proposals have
+ // already gone out.
+ navController.navigate(route = listRoute) {
+ popUpTo {
+ inclusive = true
+ }
+ }
+ },
+ onNavigateBack = {
+ navController.popBackStack()
+ }
+ )
+ }
+
composable { backStackEntry ->
val route = backStackEntry.toRoute()
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/UploadChaptersRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/UploadChaptersRoute.kt
new file mode 100644
index 00000000..e3dd24ec
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/UploadChaptersRoute.kt
@@ -0,0 +1,19 @@
+package press.mantra.compose.ui.composable.navigation.routes
+
+import kotlinx.serialization.Serializable
+
+/**
+ * Several files at once, each becoming a chapter the group is asked to sign.
+ *
+ * The same arguments as [AddChapterRoute], because it is the same act at a
+ * different scale -- and deliberately a separate destination rather than a mode
+ * on that one. The add form is a text field and one proposal; this is a review
+ * list and N of them, with a different thing to say before it is used.
+ */
+@Serializable
+data class UploadChaptersRoute(
+ val activeUserPublicKey: String,
+ val artifactId: String,
+ val chatRoomId: String,
+ val relayHint: String?
+): Route()
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UploadChaptersViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UploadChaptersViewModel.kt
new file mode 100644
index 00000000..6ea233e5
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UploadChaptersViewModel.kt
@@ -0,0 +1,332 @@
+package press.mantra.compose.ui.view.model
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import androidx.lifecycle.viewmodel.initializer
+import androidx.lifecycle.viewmodel.viewModelFactory
+import co.touchlab.kermit.Logger
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.IO
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import press.mantra.compose.database.model.MantraArtifactVersion
+import press.mantra.compose.database.model.intermdiate.LocalChatRoom
+import press.mantra.compose.files.PickedFile
+import press.mantra.compose.managers.ChapterIndexing
+import press.mantra.compose.managers.FrostSigningManager
+import press.mantra.compose.nostr.nip30303.ChapterEvent
+import press.mantra.compose.nostr.nip30303.ChunkEvent
+import press.mantra.compose.repository.ChatRepository
+import press.mantra.compose.repository.FrostSigningRepository
+import press.mantra.compose.repository.MantraRepository
+import press.mantra.compose.text.Markdown
+import press.mantra.compose.ui.view.state.PickedChapter
+import press.mantra.compose.ui.view.state.ProposeOutcome
+import press.mantra.compose.ui.view.state.UploadChaptersUIState
+import press.mantra.compose.ui.view.state.UploadReview
+import press.mantra.compose.ui.view.state.UploadProblem
+
+/**
+ * Several files in, one signing session per chapter out.
+ *
+ * **Why not one session for the lot.** The web app this mirrors does N files in
+ * one transaction; a batch cannot. A session signs at most
+ * [FrostSigningManager.MAX_BATCH_SIZE] events and a chapter spends one place
+ * with its paragraphs taking the rest, so two chapters of thirty paragraphs
+ * already overflow. Worse, a batch is all-or-nothing: one chapter that failed to
+ * reach a quorum would take the other eleven with it, and a book is precisely a
+ * set of things that do not need to arrive together. And the approval gate's own
+ * rule is that it shows every event rather than a count, so twelve chapters in
+ * one gate would be a worse prompt than the twelve it replaced.
+ *
+ * So an upload is N proposals, each needing its own quorum. That is a real
+ * imposition on every other member of the room, and the only person in a
+ * position to weigh it is the one picking files -- which is why the screen says
+ * so before the picker opens, and why [MAX_FILES_PER_UPLOAD] exists.
+ *
+ * **Partial success is the expected outcome, not an error.** Nothing can retract
+ * a proposal, so if the fourth fails the first three are real and the group can
+ * still sign them. There is no rollback and no navigating away on failure: the
+ * rows that went are marked, the rest keep their reason, and the button becomes
+ * a retry.
+ */
+class UploadChaptersViewModel(
+ val artifactId: String,
+ val chatRoomId: String,
+ val activeUserPublicKey: HexKey,
+ val relayHint: String?,
+ initialUIState: UploadChaptersUIState,
+ val mantraRepository: MantraRepository,
+ val chatRepository: ChatRepository,
+ val frostSigningRepository: FrostSigningRepository,
+) : ViewModel() {
+
+ /**
+ * A `StateFlow` rather than `by mutableStateOf`, and not as a matter of
+ * taste.
+ *
+ * View models here are built by `viewModel(factory = ...)` *inside*
+ * composition, so a `mutableStateOf` created in a constructor belongs to
+ * that composition's snapshot -- and a write from another thread that lands
+ * before the composition is applied is silently discarded. No exception, no
+ * log; the state keeps its initial value for the life of the process.
+ *
+ * [initiate] writes from `Dispatchers.IO`, and the reads it does are a
+ * primary-key lookup and a `canSign` check: a few milliseconds, which is
+ * exactly the width of that window. The failure would be this screen sitting
+ * on its loading indicator for ever on a fast device while a slow one worked.
+ * That bug has already been had here once; see
+ * `SovereignWalletViewModel.listWalletState`.
+ */
+ private val _uiState = MutableStateFlow(initialUIState)
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val logger = Logger.withTag(TAG)
+
+ /**
+ * The text of every chapter this version already has, against its name.
+ *
+ * Held so a picked file can be recognised as one that is already in the
+ * version. The whole artifact's prose in memory is not free, and it is the
+ * price of answering the question at all: two chapters with the same text
+ * are two different event ids, and nothing later can merge them.
+ */
+ private var existingByText: Map = emptyMap()
+
+ /** Counts passes, so two with the same outcome are still two announcements. */
+ private var passes: Int = 0
+
+ fun initiate() {
+ viewModelScope.launch(Dispatchers.IO) {
+ val artifact = mantraRepository.getArtifact(artifactId)
+ val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
+
+ _uiState.value = when {
+ artifact == null -> UploadChaptersUIState.Error("Couldn't find the artifact")
+ localChatRoom == null -> UploadChaptersUIState.Error("Couldn't find the chat room")
+ else -> {
+ val version = mantraRepository.getArtifactVersions(artifactId).firstOrNull()
+
+ existingByText = version?.let { v ->
+ mantraRepository.getChaptersForArtifactVersion(v.id)
+ .associate { it.originalText to it.name }
+ }.orEmpty()
+
+ UploadChaptersUIState.Loaded(
+ localChatRoom = localChatRoom,
+ artifact = artifact,
+ artifactVersion = version,
+ canSign = frostSigningRepository.canSign(chatRoomId),
+ baseIndex = version?.let { nextIndex(it) } ?: 0,
+ )
+ }
+ }
+ }
+ }
+
+ /**
+ * Decodes picked files into rows, appending to whatever is already listed.
+ *
+ * Appending rather than replacing, so a second visit to the picker adds to
+ * the upload instead of discarding it -- a reader who meant to replace can
+ * remove rows, and one who meant to add cannot undo a replacement.
+ */
+ fun addFiles(files: List) {
+ if (files.isEmpty()) return
+
+ updateLoaded { loaded ->
+ val room = loaded.chapters.size
+ val added = UploadReview.rows(
+ files = files.take((MAX_FILES_PER_UPLOAD - room).coerceAtLeast(0)),
+ startOrdinal = room,
+ )
+
+ loaded.copy(chapters = withProblems(loaded.chapters + added))
+ }
+ }
+
+ fun rename(id: String, name: String) {
+ updateLoaded { loaded ->
+ loaded.copy(
+ chapters = withProblems(
+ loaded.chapters.map { if (it.id == id) it.copy(name = name) else it }
+ )
+ )
+ }
+ }
+
+ fun remove(id: String) {
+ updateLoaded { loaded ->
+ loaded.copy(chapters = withProblems(loaded.chapters.filterNot { it.id == id }))
+ }
+ }
+
+ private fun withProblems(chapters: List): List =
+ UploadReview.withProblems(chapters, existingByText)
+
+ private inline fun updateLoaded(
+ crossinline block: (UploadChaptersUIState.Loaded) -> UploadChaptersUIState.Loaded,
+ ) {
+ _uiState.update { state ->
+ if (state is UploadChaptersUIState.Loaded) block(state) else state
+ }
+ }
+
+ /**
+ * Puts every proposable chapter to the group, one session each, in order.
+ *
+ * **Sequentially, not concurrently.** Each proposal writes a session and
+ * publishes group events, so N at once is N interleaved MLS epochs -- and a
+ * group event that arrives a moment out of order is dropped for good; see
+ * `docs/mls-skipped-keys.md`. An upload is not in a hurry.
+ *
+ * Indices are allocated once here rather than re-read between sessions. The
+ * base is read fresh, because a chapter may have arrived since the files
+ * were picked, and then they are handed out in order -- re-asking per
+ * chapter would make the numbering depend on how fast the sessions are
+ * written.
+ */
+ fun propose(
+ localChatRoom: LocalChatRoom,
+ artifactVersion: MantraArtifactVersion,
+ onDone: (proposed: Int, failed: Int) -> Unit,
+ ) {
+ val loaded = _uiState.value as? UploadChaptersUIState.Loaded ?: return
+ if (loaded.isProposing) return
+
+ val queue = loaded.proposable.map { it.id }
+ if (queue.isEmpty()) return
+
+ updateLoaded { it.copy(isProposing = true) }
+
+ viewModelScope.launch(Dispatchers.IO) {
+ var index = nextIndex(artifactVersion)
+ var proposed = 0
+ var failed = 0
+
+ for (id in queue) {
+ val row = (_uiState.value as? UploadChaptersUIState.Loaded)
+ ?.chapters
+ ?.firstOrNull { it.id == id }
+ ?: continue
+
+ val session = runCatching {
+ frostSigningRepository.proposeSigningBatch(
+ localChatRoom = localChatRoom,
+ userPublicKey = activeUserPublicKey,
+ lead = ChapterEvent.build(
+ artifactVersionId = artifactVersion.id,
+ name = row.name,
+ originalText = row.markdown,
+ index = index,
+ wordCount = row.wordCount,
+ characterCount = row.characterCount,
+ ),
+ // The chunks are cut from the chapter the session
+ // authored, because a chunk names its chapter's id and
+ // that id is a hash over the group's key at the room's
+ // path -- which this view model neither knows nor should.
+ dependents = ChunkEvent::splitOf,
+ )
+ }.onFailure { error ->
+ logger.e("Could not propose ${row.fileName} as a chapter", error)
+ }.getOrNull()
+
+ if (session != null) {
+ markProposed(id, session.id)
+ proposed++
+ index++
+ } else {
+ markFailed(id)
+ failed++
+ }
+ }
+
+ passes++
+ updateLoaded {
+ it.copy(
+ isProposing = false,
+ lastOutcome = ProposeOutcome(
+ proposed = proposed,
+ failed = failed,
+ pass = passes,
+ ),
+ )
+ }
+
+ viewModelScope.launch(Dispatchers.Main) { onDone(proposed, failed) }
+ }
+ }
+
+ private fun markProposed(id: String, sessionId: String) {
+ updateLoaded { loaded ->
+ loaded.copy(
+ chapters = loaded.chapters.map {
+ if (it.id == id) it.copy(sessionId = sessionId, problem = null) else it
+ }
+ )
+ }
+ }
+
+ private fun markFailed(id: String) {
+ updateLoaded { loaded ->
+ loaded.copy(
+ chapters = loaded.chapters.map {
+ if (it.id == id) it.copy(problem = UploadProblem.ProposalFailed) else it
+ }
+ )
+ }
+ }
+
+ private suspend fun nextIndex(artifactVersion: MantraArtifactVersion): Int =
+ ChapterIndexing.nextIndex(
+ mantraRepository = mantraRepository,
+ frostSigningRepository = frostSigningRepository,
+ chatRoomId = chatRoomId,
+ artifactVersionId = artifactVersion.id,
+ )
+
+ companion object {
+ private const val TAG = "UploadChaptersViewModel"
+
+ /**
+ * The most files one upload will take.
+ *
+ * Not a protocol limit -- the protocol has no opinion -- but a limit on
+ * how much of the room's attention one tap may spend. Twenty files is
+ * twenty proposals, each of which every other member is asked to read
+ * and approve. Somebody who means to upload a hundred chapters can do it
+ * in five passes and will know they did.
+ */
+ const val MAX_FILES_PER_UPLOAD: Int = 20
+
+ fun factory(
+ activeUserPublicKey: HexKey,
+ artifactId: String,
+ chatRoomId: String,
+ relayHint: String?,
+ initialUIState: UploadChaptersUIState = UploadChaptersUIState.Loading,
+ mantraRepository: MantraRepository,
+ chatRepository: ChatRepository,
+ frostSigningRepository: FrostSigningRepository,
+ ): ViewModelProvider.Factory = viewModelFactory {
+ initializer {
+ UploadChaptersViewModel(
+ activeUserPublicKey = activeUserPublicKey,
+ artifactId = artifactId,
+ chatRoomId = chatRoomId,
+ relayHint = relayHint,
+ initialUIState = initialUIState,
+ mantraRepository = mantraRepository,
+ chatRepository = chatRepository,
+ frostSigningRepository = frostSigningRepository,
+ )
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UploadChaptersUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UploadChaptersUIState.kt
new file mode 100644
index 00000000..78070b7e
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UploadChaptersUIState.kt
@@ -0,0 +1,165 @@
+package press.mantra.compose.ui.view.state
+
+import androidx.compose.runtime.Immutable
+import press.mantra.compose.database.model.MantraArtifact
+import press.mantra.compose.database.model.MantraArtifactVersion
+import press.mantra.compose.database.model.intermdiate.LocalChatRoom
+
+sealed interface UploadChaptersUIState {
+ data object Loading : UploadChaptersUIState
+
+ data class Error(val message: String) : UploadChaptersUIState
+
+ data class Loaded(
+ val localChatRoom: LocalChatRoom,
+ val artifact: MantraArtifact,
+ /** Chapters hang off a version; without one there is nothing to upload into. */
+ val artifactVersion: MantraArtifactVersion?,
+ val canSign: Boolean,
+ /**
+ * The files picked so far, in the order they will be proposed.
+ *
+ * Nothing here has been proposed until a row carries a session id. The
+ * screen is a review step, which is the one place this departs visibly
+ * from the web app it mirrors: there, twelve files become twelve
+ * chapters that belong to the uploader and can be fixed afterwards. Here
+ * they become twelve requests for the group's own signature, and
+ * anything wrong in one is wrong in something a quorum is about to put
+ * its name to.
+ */
+ val chapters: List = emptyList(),
+ /**
+ * The index the first proposable chapter will take.
+ *
+ * Read when the files are picked so the list can show where each one
+ * lands, and read again when proposing -- a chapter may have arrived in
+ * between, and the signed number has to be the later answer.
+ */
+ val baseIndex: Int = 0,
+ val isProposing: Boolean = false,
+ /**
+ * What the last pass did, so the screen can say so.
+ *
+ * In the state rather than handed to the callback, because the sentence
+ * is a formatted string resource and `stringResource` is composable
+ * while a callback is not. Read in composition, shown from an effect.
+ */
+ val lastOutcome: ProposeOutcome? = null,
+ ) : UploadChaptersUIState {
+
+ /** Rows that could be proposed as they stand. */
+ val proposable: List
+ get() = chapters.filter { it.sessionId == null && it.problem?.blocking != true }
+
+ val proposed: List get() = chapters.filter { it.sessionId != null }
+
+ val canPropose: Boolean
+ get() = artifactVersion != null && canSign && !isProposing && proposable.isNotEmpty()
+ }
+}
+
+/** The result of one pass of proposing. */
+@Immutable
+data class ProposeOutcome(
+ val proposed: Int,
+ val failed: Int,
+ /** Distinguishes two passes with identical counts, so the second is still announced. */
+ val pass: Int,
+)
+
+/**
+ * One picked file, as the chapter it would become.
+ *
+ * [markdown] is already decoded, BOM-stripped and newline-normalised by
+ * `ChapterText.decode`, so it is the text that would be signed rather than
+ * whatever the file held.
+ */
+@Immutable
+data class PickedChapter(
+ /** Stable across edits and removals, so the list keeps its identity. */
+ val id: String,
+ val fileName: String,
+ /**
+ * Editable, defaulting to `ChapterNaming.forFile`.
+ *
+ * A filename is right often enough to be the default and wrong often enough
+ * -- `chapter-01.md`, `ch1_final_v2.md` -- that it has to be fixable before a
+ * quorum sees it. The name is signed into the chapter along with the text.
+ */
+ val name: String,
+ val markdown: String,
+ val chunks: List,
+ val wordCount: Int,
+ val characterCount: Int,
+ val proposalBytes: Int,
+ val problem: UploadProblem? = null,
+ /** Set once this chapter has been put to the group. Never unset. */
+ val sessionId: String? = null,
+)
+
+/**
+ * What is wrong with one row.
+ *
+ * Per row rather than per upload, because a bad file should not cost the other
+ * eleven. [blocking] is the difference between "this cannot be proposed" and
+ * "this is probably not what you meant".
+ */
+@Immutable
+sealed interface UploadProblem {
+ val blocking: Boolean
+
+ /** Not decodable as UTF-8, or carrying a NUL. */
+ data object NotText : UploadProblem {
+ override val blocking: Boolean get() = true
+ }
+
+ /** Over `ChapterText.MAX_FILE_BYTES`. */
+ data object TooLarge : UploadProblem {
+ override val blocking: Boolean get() = true
+ }
+
+ /** Nothing left after trimming, which is a chapter with nothing to translate. */
+ data object Empty : UploadProblem {
+ override val blocking: Boolean get() = true
+ }
+
+ /** More paragraphs than one session signs. */
+ data class TooManyChunks(val count: Int) : UploadProblem {
+ override val blocking: Boolean get() = true
+ }
+
+ /** Within the chunk cap but over what a proposal carries. */
+ data class TooLargeToPropose(val bytes: Int) : UploadProblem {
+ override val blocking: Boolean get() = true
+ }
+
+ /** No name, and nothing in the file to derive one from. */
+ data object Unnamed : UploadProblem {
+ override val blocking: Boolean get() = true
+ }
+
+ /**
+ * The same text as a chapter this version already has, or as another row.
+ *
+ * **A warning, not a block.** A book can repeat a page. But the common cause
+ * is picking the same directory twice, and two chapters with the same text
+ * are two different event ids that no later de-duplication can merge -- so
+ * it is worth saying before a quorum signs both.
+ */
+ data class Duplicate(val of: String) : UploadProblem {
+ override val blocking: Boolean get() = false
+ }
+
+ /**
+ * Proposing this one failed.
+ *
+ * **Not blocking**, because a retry is exactly what should happen: a failed
+ * proposal is retried by making a new one, never by re-opening the session
+ * that failed, and `proposeSigningBatch` mints fresh nonce seeds by
+ * construction. The row keeps its reason so "propose the remaining" has
+ * something to point at.
+ */
+ data object ProposalFailed : UploadProblem {
+ override val blocking: Boolean get() = false
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UploadReview.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UploadReview.kt
new file mode 100644
index 00000000..cf817af2
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UploadReview.kt
@@ -0,0 +1,121 @@
+package press.mantra.compose.ui.view.state
+
+import press.mantra.compose.files.PickedFile
+import press.mantra.compose.managers.FrostSigningManager
+import press.mantra.compose.nostr.nip30303.ChapterProposal
+import press.mantra.compose.text.ChapterNaming
+import press.mantra.compose.text.ChapterText
+import press.mantra.compose.text.Markdown
+
+/**
+ * Turning picked files into reviewable rows, and deciding what is wrong with
+ * each.
+ *
+ * Pure, and separate from `UploadChaptersViewModel` on purpose. Everything here
+ * is a function of the files, the names typed over them and the chapters the
+ * version already has -- no repository, no coroutine, no database -- which is
+ * most of what the upload screen actually does and all of what is worth testing
+ * closely. What the view model keeps is the orchestration: reading the version,
+ * allocating indices and opening a session per chapter.
+ */
+object UploadReview {
+
+ /** The most paragraphs a chapter can be proposed with; the chapter takes a place too. */
+ const val MAX_CHUNKS_PER_CHAPTER: Int = FrostSigningManager.MAX_BATCH_SIZE - 1
+
+ /**
+ * Rows for [files], numbered from [startOrdinal] so they can be appended to
+ * rows already listed.
+ *
+ * Problems are not decided here -- see [withProblems] -- beyond the two that
+ * are properties of the bytes rather than of the list.
+ */
+ fun rows(files: List, startOrdinal: Int): List =
+ files.mapIndexed { offset, file -> row(file, startOrdinal + offset) }
+
+ private fun row(file: PickedFile, ordinal: Int): PickedChapter {
+ val decoded = ChapterText.decode(file.bytes)
+ val markdown = (decoded as? ChapterText.Result.Text)?.markdown.orEmpty()
+ val chunks = Markdown.splitParagraphs(markdown)
+
+ return PickedChapter(
+ // Neither the file's name nor its text is unique -- two directories
+ // can each hold a `01.md`, and a book can repeat a page -- and the
+ // ordinal is, within the one upload this id has to be unique in.
+ id = "$ordinal:${file.name}",
+ fileName = file.name,
+ name = if (decoded is ChapterText.Result.Text) {
+ ChapterNaming.forFile(file.name, markdown)
+ } else {
+ ""
+ },
+ markdown = markdown,
+ chunks = chunks,
+ wordCount = Markdown.wordCount(markdown),
+ characterCount = Markdown.characterCount(markdown),
+ proposalBytes = ChapterProposal.proposalBytes(markdown, chunks),
+ problem = when (decoded) {
+ ChapterText.Result.NotText -> UploadProblem.NotText
+ ChapterText.Result.TooLarge -> UploadProblem.TooLarge
+ is ChapterText.Result.Text -> null
+ },
+ )
+ }
+
+ /**
+ * Re-decides every row's problem.
+ *
+ * Whole-list rather than per row, because two of the answers are about the
+ * list: a duplicate is a relation between rows, and removing one row can
+ * clear the warning on another.
+ *
+ * A row already proposed is returned untouched. Its chapter is in front of
+ * the group, and saying something new about it would be a comment on a
+ * decision already taken.
+ *
+ * [existingByText] maps the text of each chapter the version already has to
+ * its name, so a file that is already in the version can be recognised as
+ * one -- by its text, since that is what two chapters would have in common.
+ */
+ fun withProblems(
+ chapters: List,
+ existingByText: Map,
+ ): List {
+ val seen = mutableMapOf()
+
+ return chapters.map { chapter ->
+ if (chapter.sessionId != null) return@map chapter
+
+ // Nothing about the list can fix a file that is not text, so those
+ // two survive whatever else is decided below.
+ if (chapter.problem == UploadProblem.NotText ||
+ chapter.problem == UploadProblem.TooLarge
+ ) {
+ return@map chapter
+ }
+
+ val duplicateOf = existingByText[chapter.markdown] ?: seen[chapter.markdown]
+ if (!seen.containsKey(chapter.markdown)) {
+ seen[chapter.markdown] = chapter.name.ifBlank { chapter.fileName }
+ }
+
+ chapter.copy(
+ problem = when {
+ chapter.chunks.isEmpty() -> UploadProblem.Empty
+ chapter.name.isBlank() -> UploadProblem.Unnamed
+ chapter.chunks.size > MAX_CHUNKS_PER_CHAPTER ->
+ UploadProblem.TooManyChunks(chapter.chunks.size)
+ !ChapterProposal.fits(chapter.markdown, chapter.chunks) ->
+ UploadProblem.TooLargeToPropose(chapter.proposalBytes)
+ duplicateOf != null -> UploadProblem.Duplicate(duplicateOf)
+ // A row that failed to propose keeps saying so until it is
+ // retried, which is what makes the button honest about there
+ // being something left to do.
+ chapter.problem == UploadProblem.ProposalFailed ->
+ UploadProblem.ProposalFailed
+ else -> null
+ }
+ )
+ }
+ }
+}
diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/UploadReviewTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/UploadReviewTest.kt
new file mode 100644
index 00000000..479ed1f3
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/UploadReviewTest.kt
@@ -0,0 +1,180 @@
+package press.mantra.compose.ui.view.state
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import press.mantra.compose.files.PickedFile
+
+/**
+ * What a picked file is offered as, and what the screen refuses to offer.
+ *
+ * The point of the review step is that nothing here reaches a quorum without
+ * somebody having looked at it, so these assert the two halves of that: the
+ * defaults are good enough that looking is quick, and everything that would be
+ * wrong in a signed chapter is said before it is signed.
+ */
+class UploadReviewTest {
+
+ private fun file(name: String, text: String) =
+ PickedFile(name = name, bytes = text.encodeToByteArray())
+
+ private fun reviewed(
+ vararg files: PickedFile,
+ existing: Map = emptyMap(),
+ ): List =
+ UploadReview.withProblems(UploadReview.rows(files.toList(), startOrdinal = 0), existing)
+
+ private fun paragraphs(count: Int) =
+ (1..count).joinToString("\n\n") { "Paragraph $it." }
+
+ @Test
+ fun `an ordinary markdown file is offered with no problem`() {
+ val rows = reviewed(file("03 - The Wreck.md", "# The wreck\n\nIt began badly.\n"))
+
+ assertEquals(1, rows.size)
+ assertNull(rows.single().problem)
+ assertEquals("The Wreck", rows.single().name)
+ assertEquals(listOf("# The wreck", "It began badly."), rows.single().chunks)
+ }
+
+ @Test
+ fun `the name falls through to the text when the filename is only an ordinal`() {
+ val rows = reviewed(file("01.md", "# Chapter one\n\nIt began badly.\n"))
+
+ assertEquals("Chapter one", rows.single().name)
+ }
+
+ @Test
+ fun `counts are of the decoded text, not of the file`() {
+ // CRLF in, LF counted: the row shows what would be signed.
+ val rows = reviewed(file("a.md", "One two.\r\n\r\nThree.\r\n"))
+
+ assertEquals(listOf("One two.", "Three."), rows.single().chunks)
+ assertEquals(3, rows.single().wordCount)
+ }
+
+ @Test
+ fun `a file that is not text is refused, and blocks only its own row`() {
+ val rows = reviewed(
+ file("good.md", "Fine.\n"),
+ PickedFile("cover.png", byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x00, 0x0D)),
+ file("also-good.md", "Also fine.\n"),
+ )
+
+ assertEquals(UploadProblem.NotText, rows[1].problem)
+ assertNull(rows[0].problem)
+ assertNull(rows[2].problem)
+ }
+
+ @Test
+ fun `an empty file has nothing to translate`() {
+ assertEquals(UploadProblem.Empty, reviewed(file("blank.md", " \n\n ")).single().problem)
+ }
+
+ @Test
+ fun `a name cleared by hand blocks the row until it is typed`() {
+ val rows = reviewed(file("The Wreck.md", "It began badly.\n"))
+ val cleared = UploadReview.withProblems(
+ rows.map { it.copy(name = " ") },
+ emptyMap(),
+ )
+
+ assertEquals(UploadProblem.Unnamed, cleared.single().problem)
+
+ val named = UploadReview.withProblems(
+ cleared.map { it.copy(name = "The Wreck") },
+ emptyMap(),
+ )
+ assertNull(named.single().problem)
+ }
+
+ @Test
+ fun `too many paragraphs for one session is said per file`() {
+ val rows = reviewed(
+ file("long.md", paragraphs(UploadReview.MAX_CHUNKS_PER_CHAPTER + 1)),
+ file("fine.md", paragraphs(UploadReview.MAX_CHUNKS_PER_CHAPTER)),
+ )
+
+ assertEquals(
+ UploadProblem.TooManyChunks(UploadReview.MAX_CHUNKS_PER_CHAPTER + 1),
+ rows[0].problem,
+ )
+ assertNull(rows[1].problem)
+ }
+
+ @Test
+ fun `a chapter within the chunk cap but over what a proposal carries is refused`() {
+ // Forty paragraphs is well inside the chunk cap, and 28 KiB of them is
+ // not inside what one group message carries -- the two caps are
+ // different limits and this is the file that proves it.
+ val long = (1..40).joinToString("\n\n") { "w ".repeat(360).trim() }
+
+ val problem = reviewed(file("fat.md", long)).single().problem
+
+ assertTrue(problem is UploadProblem.TooLargeToPropose, "got $problem")
+ }
+
+ @Test
+ fun `the same file picked twice warns rather than blocks`() {
+ val rows = reviewed(
+ file("the-wreck.md", "It began badly.\n"),
+ file("copy-of-the-wreck.md", "It began badly.\n"),
+ )
+
+ assertNull(rows[0].problem)
+ assertEquals(UploadProblem.Duplicate("the-wreck"), rows[1].problem)
+ // Not blocking, because a book can repeat a page -- but two chapters
+ // with the same text are two event ids that nothing later can merge.
+ assertEquals(false, rows[1].problem?.blocking)
+ }
+
+ @Test
+ fun `a file already in the version is recognised by its text`() {
+ val rows = reviewed(
+ file("ch1.md", "It began badly.\n"),
+ existing = mapOf("It began badly.\n" to "The Wreck"),
+ )
+
+ assertEquals(UploadProblem.Duplicate("The Wreck"), rows.single().problem)
+ }
+
+ @Test
+ fun `removing one row clears the warning on the other`() {
+ val rows = reviewed(
+ file("a.md", "Same words.\n"),
+ file("b.md", "Same words.\n"),
+ )
+ assertEquals(UploadProblem.Duplicate("a"), rows[1].problem)
+
+ val remaining = UploadReview.withProblems(rows.drop(1), emptyMap())
+
+ // The reason a whole-list pass exists rather than a per-row check.
+ assertNull(remaining.single().problem)
+ }
+
+ @Test
+ fun `a proposed row is left exactly as it is`() {
+ val proposed = reviewed(file("a.md", "Same words.\n"))
+ .map { it.copy(sessionId = "session", name = " ") }
+
+ // Blank name, duplicate text, and still untouched: its chapter is in
+ // front of the group and nothing here is a comment on that.
+ val after = UploadReview.withProblems(
+ proposed,
+ existingByText = mapOf("Same words.\n" to "The Wreck"),
+ )
+
+ assertEquals(proposed, after)
+ }
+
+ @Test
+ fun `rows appended later keep distinct ids`() {
+ val first = UploadReview.rows(listOf(file("01.md", "One.")), startOrdinal = 0)
+ val second = UploadReview.rows(listOf(file("01.md", "Two.")), startOrdinal = first.size)
+
+ // Two directories can each hold a `01.md`, and a list keyed by name
+ // would drop the second or animate the two into each other.
+ assertEquals(2, (first + second).map { it.id }.toSet().size)
+ }
+}