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) + } +}