feat(chapters): upload several files, one proposal each
Phase 4 of docs/chapter-uploads.md: the feature. A review step rather than a submit button, and that is the one place this departs visibly from the web app it mirrors. mantra.press can 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 produces a list; the list is what is proposed. Per-row problems, because a bad file should not cost the other eleven: not text, too large, nothing to translate, no name, more chunks than a session signs, more bytes than a proposal carries -- and a duplicate, which warns rather than blocks. 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 event ids nothing later can merge. Proposals go out sequentially, never concurrently: each writes a session and publishes group events, and a group event that arrives a moment out of order is dropped for good (docs/mls-skipped-keys.md). Indices are allocated once from a freshly read base, so numbering does not depend on how fast the sessions are written. Partial success is the expected outcome and 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: no rollback, no navigating away, the rows that went are marked and the rest keep their reason, which is the only place a retry can be aimed from. A pass that fully succeeds lands on the proposal list rather than a signing screen -- at N there is no one session to watch. Two shapes the writing settled. UploadReview holds the row building and the problem decisions, which are pure functions of the files, the names typed over them and the chapters the version already has; the view model keeps only the orchestration, and the 13 tests need no view-model harness. And uiState is a StateFlow, not `by mutableStateOf`: initiate() writes from Dispatchers.IO after a primary-key lookup, which is exactly the few milliseconds in which a write from another thread into a composition's snapshot is silently discarded -- the bug that once left this app on "Decrypting..." for ever. Scaffolding into translations is not here yet; Phase 5 lifts it out of AddChapterViewModel so one batch covers every chapter rather than one session each. Until then an uploaded chapter reaches a translation through that translation's own catch-up. m3Audit's budgets all met. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
83bd91f46c
commit
2f8df359a8
9 files changed
+1485
-18
No files matched your search
+58
-18
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+559
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -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<UploadChaptersRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<UploadChaptersRoute>()
|
||||
|
||||
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<UploadChaptersRoute> {
|
||||
inclusive = true
|
||||
}
|
||||
}
|
||||
},
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable<AddChapterRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<AddChapterRoute>()
|
||||
|
||||
|
||||
+19
@@ -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()
|
||||
+332
@@ -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<UploadChaptersUIState> = _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<String, String> = 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<PickedFile>) {
|
||||
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<PickedChapter>): List<PickedChapter> =
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+165
@@ -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<PickedChapter> = 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<PickedChapter>
|
||||
get() = chapters.filter { it.sessionId == null && it.problem?.blocking != true }
|
||||
|
||||
val proposed: List<PickedChapter> 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<String>,
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<PickedFile>, startOrdinal: Int): List<PickedChapter> =
|
||||
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<PickedChapter>,
|
||||
existingByText: Map<String, String>,
|
||||
): List<PickedChapter> {
|
||||
val seen = mutableMapOf<String, String>()
|
||||
|
||||
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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user