diff --git a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/22.json b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/22.json index 1e2187ee..cd29f234 100644 --- a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/22.json +++ b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/22.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 22, - "identityHash": "00e9b271c52e170bcaa990489c8e0805", + "identityHash": "eea30c363f8d70e31d7f5fa306650a58", "entities": [ { "tableName": "BroadcastNostrEventReceipt", @@ -782,7 +782,7 @@ }, { "tableName": "DkgParticipantMessage", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `participantPublicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`sessionId`, `userPublicKey`, `participantPublicKey`, `kind`), FOREIGN KEY(`sessionId`, `userPublicKey`) REFERENCES `DkgSession`(`id`, `userPublicKey`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `participantPublicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`sessionId`, `participantPublicKey`, `kind`), FOREIGN KEY(`sessionId`) REFERENCES `DkgSession`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "sessionId", @@ -790,12 +790,6 @@ "affinity": "TEXT", "notNull": true }, - { - "fieldPath": "userPublicKey", - "columnName": "userPublicKey", - "affinity": "TEXT", - "notNull": true - }, { "fieldPath": "participantPublicKey", "columnName": "participantPublicKey", @@ -825,21 +819,19 @@ "autoGenerate": false, "columnNames": [ "sessionId", - "userPublicKey", "participantPublicKey", "kind" ] }, "indices": [ { - "name": "index_DkgParticipantMessage_sessionId_userPublicKey", + "name": "index_DkgParticipantMessage_sessionId", "unique": false, "columnNames": [ - "sessionId", - "userPublicKey" + "sessionId" ], "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_DkgParticipantMessage_sessionId_userPublicKey` ON `${TABLE_NAME}` (`sessionId`, `userPublicKey`)" + "createSql": "CREATE INDEX IF NOT EXISTS `index_DkgParticipantMessage_sessionId` ON `${TABLE_NAME}` (`sessionId`)" } ], "foreignKeys": [ @@ -848,19 +840,17 @@ "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ - "sessionId", - "userPublicKey" + "sessionId" ], "referencedColumns": [ - "id", - "userPublicKey" + "id" ] } ] }, { "tableName": "DkgSession", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `coordinatorPublicKey` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, `participantPublicKeys` TEXT, `subject` TEXT, `stage` TEXT NOT NULL, `hostPublicKey` TEXT NOT NULL, `round1Random` TEXT NOT NULL, `round2AuxRandom` TEXT NOT NULL, `coordinatorRound1` TEXT, `certificate` TEXT, `thresholdPublicKey` TEXT, `secretShare` TEXT, `publicShares` TEXT, `recoveryData` TEXT, `failureReason` TEXT, `hostKeyApprovedAt` INTEGER, `round1ApprovedAt` INTEGER, `round2ApprovedAt` INTEGER, `approvalRequestedThrough` TEXT, `parentChatRoomId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`, `userPublicKey`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `coordinatorPublicKey` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, `participantPublicKeys` TEXT, `subject` TEXT, `stage` TEXT NOT NULL, `hostPublicKey` TEXT NOT NULL, `round1Random` TEXT NOT NULL, `round2AuxRandom` TEXT NOT NULL, `coordinatorRound1` TEXT, `certificate` TEXT, `thresholdPublicKey` TEXT, `secretShare` TEXT, `publicShares` TEXT, `recoveryData` TEXT, `failureReason` TEXT, `hostKeyApprovedAt` INTEGER, `round1ApprovedAt` INTEGER, `round2ApprovedAt` INTEGER, `approvalRequestedThrough` TEXT, `parentChatRoomId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "id", @@ -1014,8 +1004,7 @@ "primaryKey": { "autoGenerate": false, "columnNames": [ - "id", - "userPublicKey" + "id" ] }, "indices": [ @@ -2592,6 +2581,410 @@ } ] }, + { + "tableName": "MantraProject", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `creatorPublicKey` TEXT NOT NULL, `ownerPublicKeys` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creatorPublicKey", + "columnName": "creatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerPublicKeys", + "columnName": "ownerPublicKeys", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MantraProject_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProject_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraProjectArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `projectId` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`projectId`) REFERENCES `MantraProject`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artifactVersionId`) REFERENCES `MantraArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "projectId", + "columnName": "projectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactVersionId", + "columnName": "artifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MantraProjectArtifactVersion_projectId", + "unique": false, + "columnNames": [ + "projectId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectArtifactVersion_projectId` ON `${TABLE_NAME}` (`projectId`)" + }, + { + "name": "index_MantraProjectArtifactVersion_artifactVersionId", + "unique": false, + "columnNames": [ + "artifactVersionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectArtifactVersion_artifactVersionId` ON `${TABLE_NAME}` (`artifactVersionId`)" + }, + { + "name": "index_MantraProjectArtifactVersion_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectArtifactVersion_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "MantraProject", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "projectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraProjectTranslationArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `projectId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`projectId`) REFERENCES `MantraProject`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "projectId", + "columnName": "projectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MantraProjectTranslationArtifactVersion_projectId", + "unique": false, + "columnNames": [ + "projectId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectTranslationArtifactVersion_projectId` ON `${TABLE_NAME}` (`projectId`)" + }, + { + "name": "index_MantraProjectTranslationArtifactVersion_translationArtifactVersionId", + "unique": false, + "columnNames": [ + "translationArtifactVersionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectTranslationArtifactVersion_translationArtifactVersionId` ON `${TABLE_NAME}` (`translationArtifactVersionId`)" + }, + { + "name": "index_MantraProjectTranslationArtifactVersion_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectTranslationArtifactVersion_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "MantraProject", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "projectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraTranslationArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, { "tableName": "MantraTranslation", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationChunkId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `text` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationChunkId`) REFERENCES `MantraTranslationChunk`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", @@ -5743,7 +6136,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '00e9b271c52e170bcaa990489c8e0805')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'eea30c363f8d70e31d7f5fa306650a58')" ] } } \ No newline at end of file diff --git a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/23.json b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/23.json index f41a1a2a..5dbbacd7 100644 --- a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/23.json +++ b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/23.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 23, - "identityHash": "9d137b9254fbe742396c6197ad6e016f", + "identityHash": "cb06123cecc1cc533da4e006c3b19ed4", "entities": [ { "tableName": "BroadcastNostrEventReceipt", @@ -671,7 +671,7 @@ }, { "tableName": "ChatRoom", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subject` TEXT, `description` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `subject` TEXT, `description` TEXT, `mlsGroupState` TEXT, `initialGiftWrapPayloadId` TEXT, `leftGroupAt` INTEGER, `joinedGroupAt` INTEGER, `chronicleRequestedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`userPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`initialGiftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "id", @@ -679,6 +679,12 @@ "affinity": "TEXT", "notNull": true }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, { "fieldPath": "subject", "columnName": "subject", @@ -689,48 +695,6 @@ "columnName": "description", "affinity": "TEXT" }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "updatedAt", - "columnName": "updatedAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "savedAt", - "columnName": "savedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "ChatRoomMembership", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `mlsGroupState` TEXT, `initialGiftWrapPayloadId` TEXT, `leftGroupAt` INTEGER, `joinedGroupAt` INTEGER, `chronicleRequestedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`chatRoomId`, `userPublicKey`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`initialGiftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "chatRoomId", - "columnName": "chatRoomId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "userPublicKey", - "columnName": "userPublicKey", - "affinity": "TEXT", - "notNull": true - }, { "fieldPath": "mlsGroupState", "columnName": "mlsGroupState", @@ -788,42 +752,10 @@ "primaryKey": { "autoGenerate": false, "columnNames": [ - "chatRoomId", - "userPublicKey" + "id" ] }, - "indices": [ - { - "name": "index_ChatRoomMembership_userPublicKey", - "unique": false, - "columnNames": [ - "userPublicKey" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_ChatRoomMembership_userPublicKey` ON `${TABLE_NAME}` (`userPublicKey`)" - }, - { - "name": "index_ChatRoomMembership_initialGiftWrapPayloadId", - "unique": false, - "columnNames": [ - "initialGiftWrapPayloadId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_ChatRoomMembership_initialGiftWrapPayloadId` ON `${TABLE_NAME}` (`initialGiftWrapPayloadId`)" - } - ], "foreignKeys": [ - { - "table": "ChatRoom", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "chatRoomId" - ], - "referencedColumns": [ - "id" - ] - }, { "table": "Profile", "onDelete": "CASCADE", @@ -2660,6 +2592,410 @@ } ] }, + { + "tableName": "MantraProject", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `creatorPublicKey` TEXT NOT NULL, `ownerPublicKeys` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creatorPublicKey", + "columnName": "creatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerPublicKeys", + "columnName": "ownerPublicKeys", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MantraProject_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProject_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraProjectArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `projectId` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`projectId`) REFERENCES `MantraProject`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artifactVersionId`) REFERENCES `MantraArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "projectId", + "columnName": "projectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactVersionId", + "columnName": "artifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MantraProjectArtifactVersion_projectId", + "unique": false, + "columnNames": [ + "projectId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectArtifactVersion_projectId` ON `${TABLE_NAME}` (`projectId`)" + }, + { + "name": "index_MantraProjectArtifactVersion_artifactVersionId", + "unique": false, + "columnNames": [ + "artifactVersionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectArtifactVersion_artifactVersionId` ON `${TABLE_NAME}` (`artifactVersionId`)" + }, + { + "name": "index_MantraProjectArtifactVersion_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectArtifactVersion_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "MantraProject", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "projectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraProjectTranslationArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `projectId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`projectId`) REFERENCES `MantraProject`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "projectId", + "columnName": "projectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MantraProjectTranslationArtifactVersion_projectId", + "unique": false, + "columnNames": [ + "projectId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectTranslationArtifactVersion_projectId` ON `${TABLE_NAME}` (`projectId`)" + }, + { + "name": "index_MantraProjectTranslationArtifactVersion_translationArtifactVersionId", + "unique": false, + "columnNames": [ + "translationArtifactVersionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectTranslationArtifactVersion_translationArtifactVersionId` ON `${TABLE_NAME}` (`translationArtifactVersionId`)" + }, + { + "name": "index_MantraProjectTranslationArtifactVersion_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectTranslationArtifactVersion_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "MantraProject", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "projectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraTranslationArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, { "tableName": "MantraTranslation", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationChunkId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `text` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationChunkId`) REFERENCES `MantraTranslationChunk`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", @@ -5811,7 +6147,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9d137b9254fbe742396c6197ad6e016f')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'cb06123cecc1cc533da4e006c3b19ed4')" ] } } \ No newline at end of file diff --git a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/24.json b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/24.json new file mode 100644 index 00000000..517fc8ac --- /dev/null +++ b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/24.json @@ -0,0 +1,6221 @@ +{ + "formatVersion": 1, + "database": { + "version": 24, + "identityHash": "1e328aab136444e802ffa7443e06ebe6", + "entities": [ + { + "tableName": "BroadcastNostrEventReceipt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `nostrEventId` TEXT NOT NULL, `unsignedNostrEventId` INTEGER, `isAccepted` INTEGER NOT NULL, `isSync` INTEGER NOT NULL, `relayURL` TEXT NOT NULL, `messages` TEXT, FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "isAccepted", + "columnName": "isAccepted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSync", + "columnName": "isSync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messages", + "columnName": "messages", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BroadcastNostrEventReceipt_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventReceipt_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_BroadcastNostrEventReceipt_isAccepted", + "unique": false, + "columnNames": [ + "isAccepted" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventReceipt_isAccepted` ON `${TABLE_NAME}` (`isAccepted`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "BroadcastNostrEventRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `nostrEventId` TEXT NOT NULL, `unsignedNostrEventId` INTEGER, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BroadcastNostrEventRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_BroadcastNostrEventRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Connection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourcePublicKey` TEXT NOT NULL, `destinationPublicKey` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`sourcePublicKey`, `destinationPublicKey`), FOREIGN KEY(`sourcePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`destinationPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourcePublicKey", + "columnName": "sourcePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "destinationPublicKey", + "columnName": "destinationPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourcePublicKey", + "destinationPublicKey" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourcePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "destinationPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + } + ] + }, + { + "tableName": "ChatMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `senderPublicKey` TEXT NOT NULL, `isUserMessage` INTEGER NOT NULL, `giftWrapPayloadId` TEXT, `marmotGroupEventId` TEXT, `marmotInnerEventId` TEXT, `chatRoomId` TEXT NOT NULL, `replyToMessageId` INTEGER, `quotedMessageId` INTEGER, `content` TEXT NOT NULL, `messageType` TEXT NOT NULL, `directMessageRecipientPublicKey` TEXT, `frostSigningSessionId` TEXT, `dkgSessionId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`giftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`marmotGroupEventId`) REFERENCES `MarmotGroupEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`marmotInnerEventId`) REFERENCES `MarmotInnerEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderPublicKey", + "columnName": "senderPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUserMessage", + "columnName": "isUserMessage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "giftWrapPayloadId", + "columnName": "giftWrapPayloadId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotInnerEventId", + "columnName": "marmotInnerEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToMessageId", + "columnName": "replyToMessageId", + "affinity": "INTEGER" + }, + { + "fieldPath": "quotedMessageId", + "columnName": "quotedMessageId", + "affinity": "INTEGER" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageType", + "columnName": "messageType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "directMessageRecipientPublicKey", + "columnName": "directMessageRecipientPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "frostSigningSessionId", + "columnName": "frostSigningSessionId", + "affinity": "TEXT" + }, + { + "fieldPath": "dkgSessionId", + "columnName": "dkgSessionId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_ChatMessage_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ChatMessage_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "GiftWrapPayload", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapPayloadId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MarmotGroupEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotGroupEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MarmotInnerEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotInnerEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageBroadcastNostrEventRequestRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `broadcastNostrEventRequestId` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`broadcastNostrEventRequestId`) REFERENCES `BroadcastNostrEventRequest`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "broadcastNostrEventRequestId", + "columnName": "broadcastNostrEventRequestId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "BroadcastNostrEventRequest", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "broadcastNostrEventRequestId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageBroadcastNostrEventReceiptRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `broadcastNostrEventReceiptId` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`broadcastNostrEventReceiptId`) REFERENCES `BroadcastNostrEventReceipt`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "broadcastNostrEventReceiptId", + "columnName": "broadcastNostrEventReceiptId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "BroadcastNostrEventReceipt", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "broadcastNostrEventReceiptId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageNostrEventRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatRoom", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subject` TEXT, `description` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "ChatRoomMembership", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `mlsGroupState` TEXT, `initialGiftWrapPayloadId` TEXT, `leftGroupAt` INTEGER, `joinedGroupAt` INTEGER, `chronicleRequestedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`chatRoomId`, `userPublicKey`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`initialGiftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mlsGroupState", + "columnName": "mlsGroupState", + "affinity": "TEXT" + }, + { + "fieldPath": "initialGiftWrapPayloadId", + "columnName": "initialGiftWrapPayloadId", + "affinity": "TEXT" + }, + { + "fieldPath": "leftGroupAt", + "columnName": "leftGroupAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "joinedGroupAt", + "columnName": "joinedGroupAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "chronicleRequestedAt", + "columnName": "chronicleRequestedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId", + "userPublicKey" + ] + }, + "indices": [ + { + "name": "index_ChatRoomMembership_userPublicKey", + "unique": false, + "columnNames": [ + "userPublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ChatRoomMembership_userPublicKey` ON `${TABLE_NAME}` (`userPublicKey`)" + }, + { + "name": "index_ChatRoomMembership_initialGiftWrapPayloadId", + "unique": false, + "columnNames": [ + "initialGiftWrapPayloadId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ChatRoomMembership_initialGiftWrapPayloadId` ON `${TABLE_NAME}` (`initialGiftWrapPayloadId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "GiftWrapPayload", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "initialGiftWrapPayloadId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DkgParticipantMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `participantPublicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`sessionId`, `userPublicKey`, `participantPublicKey`, `kind`), FOREIGN KEY(`sessionId`, `userPublicKey`) REFERENCES `DkgSession`(`id`, `userPublicKey`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "participantPublicKey", + "columnName": "participantPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId", + "userPublicKey", + "participantPublicKey", + "kind" + ] + }, + "indices": [ + { + "name": "index_DkgParticipantMessage_sessionId_userPublicKey", + "unique": false, + "columnNames": [ + "sessionId", + "userPublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DkgParticipantMessage_sessionId_userPublicKey` ON `${TABLE_NAME}` (`sessionId`, `userPublicKey`)" + } + ], + "foreignKeys": [ + { + "table": "DkgSession", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId", + "userPublicKey" + ], + "referencedColumns": [ + "id", + "userPublicKey" + ] + } + ] + }, + { + "tableName": "DkgSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `coordinatorPublicKey` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, `participantPublicKeys` TEXT, `subject` TEXT, `stage` TEXT NOT NULL, `hostPublicKey` TEXT NOT NULL, `round1Random` TEXT NOT NULL, `round2AuxRandom` TEXT NOT NULL, `coordinatorRound1` TEXT, `certificate` TEXT, `thresholdPublicKey` TEXT, `secretShare` TEXT, `publicShares` TEXT, `recoveryData` TEXT, `failureReason` TEXT, `hostKeyApprovedAt` INTEGER, `round1ApprovedAt` INTEGER, `round2ApprovedAt` INTEGER, `approvalRequestedThrough` TEXT, `parentChatRoomId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`, `userPublicKey`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coordinatorPublicKey", + "columnName": "coordinatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "threshold", + "columnName": "threshold", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantCount", + "columnName": "participantCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantPublicKeys", + "columnName": "participantPublicKeys", + "affinity": "TEXT" + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT" + }, + { + "fieldPath": "stage", + "columnName": "stage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hostPublicKey", + "columnName": "hostPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "round1Random", + "columnName": "round1Random", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "round2AuxRandom", + "columnName": "round2AuxRandom", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coordinatorRound1", + "columnName": "coordinatorRound1", + "affinity": "TEXT" + }, + { + "fieldPath": "certificate", + "columnName": "certificate", + "affinity": "TEXT" + }, + { + "fieldPath": "thresholdPublicKey", + "columnName": "thresholdPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "secretShare", + "columnName": "secretShare", + "affinity": "TEXT" + }, + { + "fieldPath": "publicShares", + "columnName": "publicShares", + "affinity": "TEXT" + }, + { + "fieldPath": "recoveryData", + "columnName": "recoveryData", + "affinity": "TEXT" + }, + { + "fieldPath": "failureReason", + "columnName": "failureReason", + "affinity": "TEXT" + }, + { + "fieldPath": "hostKeyApprovedAt", + "columnName": "hostKeyApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "round1ApprovedAt", + "columnName": "round1ApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "round2ApprovedAt", + "columnName": "round2ApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "approvalRequestedThrough", + "columnName": "approvalRequestedThrough", + "affinity": "TEXT" + }, + { + "fieldPath": "parentChatRoomId", + "columnName": "parentChatRoomId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "userPublicKey" + ] + }, + "indices": [ + { + "name": "index_DkgSession_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DkgSession_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FrostSignerMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `signerPublicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`sessionId`, `signerPublicKey`, `kind`), FOREIGN KEY(`sessionId`) REFERENCES `FrostSigningSession`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signerPublicKey", + "columnName": "signerPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId", + "signerPublicKey", + "kind" + ] + }, + "indices": [ + { + "name": "index_FrostSignerMessage_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSignerMessage_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "FrostSigningSession", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FrostSigningItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `itemIndex` INTEGER NOT NULL, `unsignedEventJson` TEXT NOT NULL, `eventId` TEXT NOT NULL, `nonceRandom` TEXT NOT NULL, `aggregatedNonce` TEXT, `signature` TEXT, PRIMARY KEY(`sessionId`, `itemIndex`), FOREIGN KEY(`sessionId`) REFERENCES `FrostSigningSession`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemIndex", + "columnName": "itemIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unsignedEventJson", + "columnName": "unsignedEventJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nonceRandom", + "columnName": "nonceRandom", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "aggregatedNonce", + "columnName": "aggregatedNonce", + "affinity": "TEXT" + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId", + "itemIndex" + ] + }, + "indices": [ + { + "name": "index_FrostSigningItem_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningItem_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "FrostSigningSession", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FrostSigningSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `coordinatorPublicKey` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `dkgSessionId` TEXT NOT NULL, `threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, `signerId` INTEGER NOT NULL, `derivationPath` TEXT, `stage` TEXT NOT NULL, `signerIds` TEXT, `failureReason` TEXT, `signApprovedAt` INTEGER, `approvalRequestedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coordinatorPublicKey", + "columnName": "coordinatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dkgSessionId", + "columnName": "dkgSessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "threshold", + "columnName": "threshold", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantCount", + "columnName": "participantCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signerId", + "columnName": "signerId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT" + }, + { + "fieldPath": "stage", + "columnName": "stage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signerIds", + "columnName": "signerIds", + "affinity": "TEXT" + }, + { + "fieldPath": "failureReason", + "columnName": "failureReason", + "affinity": "TEXT" + }, + { + "fieldPath": "signApprovedAt", + "columnName": "signApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "approvalRequestedAt", + "columnName": "approvalRequestedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_FrostSigningSession_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningSession_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + }, + { + "name": "index_FrostSigningSession_dkgSessionId", + "unique": false, + "columnNames": [ + "dkgSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningSession_dkgSessionId` ON `${TABLE_NAME}` (`dkgSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `receiverPublicKey` TEXT NOT NULL, `receiverRelayHit` TEXT, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "receiverPublicKey", + "columnName": "receiverPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receiverRelayHit", + "columnName": "receiverRelayHit", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapSeal", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `giftWrapMessageId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`giftWrapMessageId`) REFERENCES `GiftWrapMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "giftWrapMessageId", + "columnName": "giftWrapMessageId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "GiftWrapMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapMessageId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapPayload", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `quotedEventId` TEXT, `giftWrapSealId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`giftWrapSealId`) REFERENCES `GiftWrapSeal`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedEventId", + "columnName": "quotedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "giftWrapSealId", + "columnName": "giftWrapSealId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "GiftWrapSeal", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapSealId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GroupKeyState", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `dkgSessionId` TEXT NOT NULL, `thresholdPublicKey` TEXT NOT NULL, `derivationPath` TEXT NOT NULL, `announcedBy` TEXT NOT NULL, `announcedAt` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`chatRoomId`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dkgSessionId", + "columnName": "dkgSessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thresholdPublicKey", + "columnName": "thresholdPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "announcedBy", + "columnName": "announcedBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "announcedAt", + "columnName": "announcedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId" + ] + }, + "indices": [ + { + "name": "index_GroupKeyState_dkgSessionId", + "unique": false, + "columnNames": [ + "dkgSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupKeyState_dkgSessionId` ON `${TABLE_NAME}` (`dkgSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GroupKeyStateParent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `parentChatRoomId` TEXT NOT NULL, `birthCertificateJson` TEXT NOT NULL, PRIMARY KEY(`chatRoomId`, `parentChatRoomId`), FOREIGN KEY(`chatRoomId`) REFERENCES `GroupKeyState`(`chatRoomId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentChatRoomId", + "columnName": "parentChatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "birthCertificateJson", + "columnName": "birthCertificateJson", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId", + "parentChatRoomId" + ] + }, + "indices": [ + { + "name": "index_GroupKeyStateParent_parentChatRoomId", + "unique": false, + "columnNames": [ + "parentChatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupKeyStateParent_parentChatRoomId` ON `${TABLE_NAME}` (`parentChatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "GroupKeyState", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "chatRoomId" + ] + } + ] + }, + { + "tableName": "GroupSignedEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `derivationPath` TEXT, `frostSigningSessionId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT" + }, + { + "fieldPath": "frostSigningSessionId", + "columnName": "frostSigningSessionId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_GroupSignedEvent_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupSignedEvent_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + }, + { + "name": "index_GroupSignedEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupSignedEvent_kind` ON `${TABLE_NAME}` (`kind`)" + }, + { + "name": "index_GroupSignedEvent_frostSigningSessionId", + "unique": false, + "columnNames": [ + "frostSigningSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupSignedEvent_frostSigningSessionId` ON `${TABLE_NAME}` (`frostSigningSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "InReplyToRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`replyingNostrEventId` TEXT NOT NULL, `inReplyToNostrEventId` TEXT NOT NULL, `inReplyToProfilePublicKey` TEXT, `inReplyToRootNostrEventId` TEXT, `inReplyToRootProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`replyingNostrEventId`, `inReplyToNostrEventId`), FOREIGN KEY(`inReplyToProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToRootProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToRootNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "replyingNostrEventId", + "columnName": "replyingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToNostrEventId", + "columnName": "inReplyToNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToProfilePublicKey", + "columnName": "inReplyToProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootNostrEventId", + "columnName": "inReplyToRootNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootProfilePublicKey", + "columnName": "inReplyToRootProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "replyingNostrEventId", + "inReplyToNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToRootProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToRootNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraArtifact", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `visibility` TEXT NOT NULL, `dialectId` TEXT NOT NULL, `license` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`dialectId`) REFERENCES `MantraDialect`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dialectId", + "columnName": "dialectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "license", + "columnName": "license", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraDialect", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dialectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artifactId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `versionLabel` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactId`) REFERENCES `MantraArtifact`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactId", + "columnName": "artifactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionLabel", + "columnName": "versionLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifact", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraChapter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `originalText` TEXT NOT NULL, `index` INTEGER NOT NULL, `wordCount` INTEGER NOT NULL, `characterCount` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactVersionId`) REFERENCES `MantraArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactVersionId", + "columnName": "artifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalText", + "columnName": "originalText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "characterCount", + "columnName": "characterCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraChunk", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chapterId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `text` TEXT NOT NULL, `index` INTEGER NOT NULL, `wordCount` INTEGER NOT NULL, `characterCount` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chapterId`) REFERENCES `MantraChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterId", + "columnName": "chapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "characterCount", + "columnName": "characterCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraDialect", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `country` TEXT NOT NULL, `language` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "country", + "columnName": "country", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraProject", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `creatorPublicKey` TEXT NOT NULL, `ownerPublicKeys` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creatorPublicKey", + "columnName": "creatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerPublicKeys", + "columnName": "ownerPublicKeys", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MantraProject_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProject_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraProjectArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `projectId` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`projectId`) REFERENCES `MantraProject`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artifactVersionId`) REFERENCES `MantraArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "projectId", + "columnName": "projectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactVersionId", + "columnName": "artifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MantraProjectArtifactVersion_projectId", + "unique": false, + "columnNames": [ + "projectId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectArtifactVersion_projectId` ON `${TABLE_NAME}` (`projectId`)" + }, + { + "name": "index_MantraProjectArtifactVersion_artifactVersionId", + "unique": false, + "columnNames": [ + "artifactVersionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectArtifactVersion_artifactVersionId` ON `${TABLE_NAME}` (`artifactVersionId`)" + }, + { + "name": "index_MantraProjectArtifactVersion_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectArtifactVersion_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "MantraProject", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "projectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraProjectTranslationArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `projectId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`projectId`) REFERENCES `MantraProject`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "projectId", + "columnName": "projectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MantraProjectTranslationArtifactVersion_projectId", + "unique": false, + "columnNames": [ + "projectId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectTranslationArtifactVersion_projectId` ON `${TABLE_NAME}` (`projectId`)" + }, + { + "name": "index_MantraProjectTranslationArtifactVersion_translationArtifactVersionId", + "unique": false, + "columnNames": [ + "translationArtifactVersionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectTranslationArtifactVersion_translationArtifactVersionId` ON `${TABLE_NAME}` (`translationArtifactVersionId`)" + }, + { + "name": "index_MantraProjectTranslationArtifactVersion_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectTranslationArtifactVersion_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "MantraProject", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "projectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraTranslationArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationChunkId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `text` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationChunkId`) REFERENCES `MantraTranslationChunk`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChunkId", + "columnName": "translationChunkId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationChunk", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChunkId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraTranslationArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `dialectId` TEXT NOT NULL, `name` TEXT NOT NULL, `visibility` TEXT NOT NULL, `license` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactVersionId`) REFERENCES `MantraArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`dialectId`) REFERENCES `MantraDialect`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactVersionId", + "columnName": "artifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dialectId", + "columnName": "dialectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "license", + "columnName": "license", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraDialect", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dialectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationArtifactVersionContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `editorPublicKey` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersionContributor`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "editorPublicKey", + "columnName": "editorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationArtifactVersionContributor", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChapter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chapterId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `index` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chapterId`) REFERENCES `MantraChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterId", + "columnName": "chapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChapterContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationChapterId` TEXT NOT NULL, `translatorPublicKey` TEXT NOT NULL, `role` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationChapterId`) REFERENCES `MantraTranslationChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChapterId", + "columnName": "translationChapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translatorPublicKey", + "columnName": "translatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChunk", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chunkId` TEXT NOT NULL, `translationChapterId` TEXT NOT NULL, `text` TEXT NOT NULL, `index` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chunkId`) REFERENCES `MantraChunk`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationChapterId`) REFERENCES `MantraTranslationChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chunkId", + "columnName": "chunkId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChapterId", + "columnName": "translationChapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraChunk", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chunkId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraTranslationChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `dTag` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationId` TEXT NOT NULL, `translatorPublicKey` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationId`) REFERENCES `MantraTranslation`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dTag", + "columnName": "dTag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationId", + "columnName": "translationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translatorPublicKey", + "columnName": "translatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslation", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotCommitResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `peerKeyPackageEventId` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `isOneMemberInitialGroupCreation` INTEGER NOT NULL, `commitBytes` BLOB NOT NULL, `welcomeBytes` BLOB, `groupInfoBytes` BLOB, `framedCommitBytes` BLOB NOT NULL, `preCommitExporterSecret` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "peerKeyPackageEventId", + "columnName": "peerKeyPackageEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isOneMemberInitialGroupCreation", + "columnName": "isOneMemberInitialGroupCreation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "commitBytes", + "columnName": "commitBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "welcomeBytes", + "columnName": "welcomeBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "groupInfoBytes", + "columnName": "groupInfoBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "framedCommitBytes", + "columnName": "framedCommitBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "preCommitExporterSecret", + "columnName": "preCommitExporterSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "MarmotGroupEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `encryptedContent` TEXT NOT NULL, `expiresAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encryptedContent", + "columnName": "encryptedContent", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expiresAt", + "columnName": "expiresAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotInnerEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `quotedEventId` TEXT, `payloadEventId` TEXT, `marmotGroupEventId` TEXT, `directMessageRecipientPublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`marmotGroupEventId`) REFERENCES `MarmotGroupEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedEventId", + "columnName": "quotedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "payloadEventId", + "columnName": "payloadEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "directMessageRecipientPublicKey", + "columnName": "directMessageRecipientPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MarmotGroupEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotGroupEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotKeyPackage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `tlsEncodedMarmotKeyPackage` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`publicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tlsEncodedMarmotKeyPackage", + "columnName": "tlsEncodedMarmotKeyPackage", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "publicKey" + ], + "referencedColumns": [ + "publicKey" + ] + } + ] + }, + { + "tableName": "MarmotKeyPackageBundle", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `tlsEncodedMarmotKeyPackage` BLOB NOT NULL, `ncryptsecInitPrivateKey` TEXT NOT NULL, `ncryptsecEncryptionPrivateKey` TEXT NOT NULL, `ncryptsecSignaturePrivateKey` TEXT NOT NULL, `consumed` INTEGER NOT NULL, `rotated` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tlsEncodedMarmotKeyPackage", + "columnName": "tlsEncodedMarmotKeyPackage", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ncryptsecInitPrivateKey", + "columnName": "ncryptsecInitPrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ncryptsecEncryptionPrivateKey", + "columnName": "ncryptsecEncryptionPrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ncryptsecSignaturePrivateKey", + "columnName": "ncryptsecSignaturePrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "consumed", + "columnName": "consumed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rotated", + "columnName": "rotated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MarmotKeyPackageBundle_publicKey", + "unique": false, + "columnNames": [ + "publicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MarmotKeyPackageBundle_publicKey` ON `${TABLE_NAME}` (`publicKey`)" + } + ] + }, + { + "tableName": "MarmotRetainedEpochSecret", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `epoch` INTEGER NOT NULL, `senderDataSecret` BLOB NOT NULL, `encryptionSecret` BLOB NOT NULL, `leafCount` INTEGER NOT NULL, `exporterSecret` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`chatRoomId`, `epoch`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epoch", + "columnName": "epoch", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderDataSecret", + "columnName": "senderDataSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "encryptionSecret", + "columnName": "encryptionSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "leafCount", + "columnName": "leafCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exporterSecret", + "columnName": "exporterSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId", + "epoch" + ] + }, + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Mention", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mentionedPublicKey` TEXT NOT NULL, `mentioningNostrEventId` TEXT, `relayUrl` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, FOREIGN KEY(`mentionedPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`mentioningNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mentionedPublicKey", + "columnName": "mentionedPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mentioningNostrEventId", + "columnName": "mentioningNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "relayUrl", + "columnName": "relayUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mentionedPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mentioningNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NegentropySynchronizeRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `uuid` TEXT NOT NULL, `purpose` TEXT NOT NULL, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `isRecommendedRelay` INTEGER NOT NULL, `level` INTEGER NOT NULL, `synchronizationFilter` TEXT NOT NULL, `ownerPublicKey` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isRecommendedRelay", + "columnName": "isRecommendedRelay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synchronizationFilter", + "columnName": "synchronizationFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerPublicKey", + "columnName": "ownerPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_NegentropySynchronizeRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NegentropySynchronizeRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_NegentropySynchronizeRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NegentropySynchronizeRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NegentropySynchronizeResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `status` TEXT NOT NULL, `negentropySynchronizeRequestId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`negentropySynchronizeRequestId`) REFERENCES `SynchronizeNostrEventResult`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "negentropySynchronizeRequestId", + "columnName": "negentropySynchronizeRequestId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "SynchronizeNostrEventResult", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "negentropySynchronizeRequestId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NostrEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `pubKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `sig` TEXT NOT NULL, `relayUrl` TEXT, `quotedNostrEventId` TEXT, `quotedAuthorPublicKey` TEXT, `inReplyToNostrEventId` TEXT, `inReplyToAuthorPublicKey` TEXT, `inReplyToRootNostrEventId` TEXT, `inReplyToRootAuthorPublicKey` TEXT, `repostedNostrEventId` TEXT, `repostedAuthorPublicKey` TEXT, `unsignedNostrEventId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubKey", + "columnName": "pubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sig", + "columnName": "sig", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayUrl", + "columnName": "relayUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "quotedNostrEventId", + "columnName": "quotedNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "quotedAuthorPublicKey", + "columnName": "quotedAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToNostrEventId", + "columnName": "inReplyToNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToAuthorPublicKey", + "columnName": "inReplyToAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootNostrEventId", + "columnName": "inReplyToRootNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootAuthorPublicKey", + "columnName": "inReplyToRootAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "repostedNostrEventId", + "columnName": "repostedNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "repostedAuthorPublicKey", + "columnName": "repostedAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_NostrEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_kind` ON `${TABLE_NAME}` (`kind`)" + }, + { + "name": "index_NostrEvent_pubKey", + "unique": false, + "columnNames": [ + "pubKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_pubKey` ON `${TABLE_NAME}` (`pubKey`)" + }, + { + "name": "index_NostrEvent_quotedNostrEventId", + "unique": false, + "columnNames": [ + "quotedNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_quotedNostrEventId` ON `${TABLE_NAME}` (`quotedNostrEventId`)" + }, + { + "name": "index_NostrEvent_inReplyToNostrEventId", + "unique": false, + "columnNames": [ + "inReplyToNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_inReplyToNostrEventId` ON `${TABLE_NAME}` (`inReplyToNostrEventId`)" + }, + { + "name": "index_NostrEvent_inReplyToRootNostrEventId", + "unique": false, + "columnNames": [ + "inReplyToRootNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_inReplyToRootNostrEventId` ON `${TABLE_NAME}` (`inReplyToRootNostrEventId`)" + } + ] + }, + { + "tableName": "NostrEventRelay", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`relayURL` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`nostrEventId`, `relayURL`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "nostrEventId", + "relayURL" + ] + }, + "indices": [ + { + "name": "index_NostrEventRelay_relayURL", + "unique": false, + "columnNames": [ + "relayURL" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEventRelay_relayURL` ON `${TABLE_NAME}` (`relayURL`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Participant", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `participantPublicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `adminAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, FOREIGN KEY(`participantPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantPublicKey", + "columnName": "participantPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "adminAt", + "columnName": "adminAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "participantPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Post", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `repostId` TEXT, `quote` TEXT, `content` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `replyToId` TEXT, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`profilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyToId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostId", + "columnName": "repostId", + "affinity": "TEXT" + }, + { + "fieldPath": "quote", + "columnName": "quote", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToId", + "columnName": "replyToId", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePublicKey", + "columnName": "profilePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Post_profilePublicKey", + "unique": false, + "columnNames": [ + "profilePublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_profilePublicKey` ON `${TABLE_NAME}` (`profilePublicKey`)" + }, + { + "name": "index_Post_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Post_replyToId", + "unique": false, + "columnNames": [ + "replyToId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_replyToId` ON `${TABLE_NAME}` (`replyToId`)" + }, + { + "name": "index_Post_repostId", + "unique": false, + "columnNames": [ + "repostId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_repostId` ON `${TABLE_NAME}` (`repostId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "profilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyToId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Profile", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`publicKey` TEXT NOT NULL, `userName` TEXT, `displayName` TEXT, `picture` TEXT, `banner` TEXT, `website` TEXT, `about` TEXT, `bot` INTEGER, `pronouns` TEXT, `nip05` TEXT, `domain` TEXT, `lud06` TEXT, `lud16` TEXT, `twitter` TEXT, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`publicKey`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userName", + "columnName": "userName", + "affinity": "TEXT" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "picture", + "columnName": "picture", + "affinity": "TEXT" + }, + { + "fieldPath": "banner", + "columnName": "banner", + "affinity": "TEXT" + }, + { + "fieldPath": "website", + "columnName": "website", + "affinity": "TEXT" + }, + { + "fieldPath": "about", + "columnName": "about", + "affinity": "TEXT" + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER" + }, + { + "fieldPath": "pronouns", + "columnName": "pronouns", + "affinity": "TEXT" + }, + { + "fieldPath": "nip05", + "columnName": "nip05", + "affinity": "TEXT" + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT" + }, + { + "fieldPath": "lud06", + "columnName": "lud06", + "affinity": "TEXT" + }, + { + "fieldPath": "lud16", + "columnName": "lud16", + "affinity": "TEXT" + }, + { + "fieldPath": "twitter", + "columnName": "twitter", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "publicKey" + ] + }, + "indices": [ + { + "name": "index_Profile_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Profile_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "QuotedRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`quotingNostrEventId` TEXT NOT NULL, `quotedNostrEventId` TEXT NOT NULL, `quotedProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`quotingNostrEventId`, `quotedNostrEventId`), FOREIGN KEY(`quotedProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`quotingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`quotedNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "quotingNostrEventId", + "columnName": "quotingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedNostrEventId", + "columnName": "quotedNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedProfilePublicKey", + "columnName": "quotedProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "quotingNostrEventId", + "quotedNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotedProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Reaction", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `content` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `replyToId` TEXT NOT NULL, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`profilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyToId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToId", + "columnName": "replyToId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePublicKey", + "columnName": "profilePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Reaction_profilePublicKey", + "unique": false, + "columnNames": [ + "profilePublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_profilePublicKey` ON `${TABLE_NAME}` (`profilePublicKey`)" + }, + { + "name": "index_Reaction_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Reaction_replyToId", + "unique": false, + "columnNames": [ + "replyToId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_replyToId` ON `${TABLE_NAME}` (`replyToId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "profilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyToId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "RecentSearch", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query` TEXT NOT NULL, `synchronizationFilters` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`query`))", + "fields": [ + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "synchronizationFilters", + "columnName": "synchronizationFilters", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "query" + ] + } + }, + { + "tableName": "Relay", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`publicKey` TEXT NOT NULL, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `read` INTEGER NOT NULL, `write` INTEGER NOT NULL, PRIMARY KEY(`publicKey`, `type`, `url`))", + "fields": [ + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "write", + "columnName": "write", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "publicKey", + "type", + "url" + ] + } + }, + { + "tableName": "RepostedRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repostingNostrEventId` TEXT NOT NULL, `repostedNostrEventId` TEXT NOT NULL, `repostedProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`repostingNostrEventId`, `repostedNostrEventId`), FOREIGN KEY(`repostedProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostedNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repostingNostrEventId", + "columnName": "repostingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostedNostrEventId", + "columnName": "repostedNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostedProfilePublicKey", + "columnName": "repostedProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repostingNostrEventId", + "repostedNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostedProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SynchronizeNostrEventRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `purpose` TEXT NOT NULL, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `isRecommendedRelay` INTEGER NOT NULL, `level` INTEGER NOT NULL, `synchronizationFilters` TEXT NOT NULL, `ownerPublicKey` TEXT, `nostrEventId` TEXT, `unsignedNostrEventId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isRecommendedRelay", + "columnName": "isRecommendedRelay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synchronizationFilters", + "columnName": "synchronizationFilters", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerPublicKey", + "columnName": "ownerPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SynchronizeNostrEventRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_SynchronizeNostrEventRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SynchronizeNostrEventResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SynchronizeNostrEventResult_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventResult_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "UnsignedNostrEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `pubKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `privateTags` TEXT, `content` TEXT NOT NULL, `signedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pubKey", + "columnName": "pubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateTags", + "columnName": "privateTags", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signedAt", + "columnName": "signedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_UnsignedNostrEvent_pubKey", + "unique": false, + "columnNames": [ + "pubKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnsignedNostrEvent_pubKey` ON `${TABLE_NAME}` (`pubKey`)" + }, + { + "name": "index_UnsignedNostrEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnsignedNostrEvent_kind` ON `${TABLE_NAME}` (`kind`)" + } + ] + }, + { + "tableName": "Zap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `postId` TEXT NOT NULL, `senderPublicKey` TEXT NOT NULL, `receiverPublicKey` TEXT NOT NULL, `message` TEXT, `invoice` TEXT, `amountInMillisatoshis` INTEGER, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`senderPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`receiverPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`postId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "postId", + "columnName": "postId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderPublicKey", + "columnName": "senderPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receiverPublicKey", + "columnName": "receiverPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT" + }, + { + "fieldPath": "invoice", + "columnName": "invoice", + "affinity": "TEXT" + }, + { + "fieldPath": "amountInMillisatoshis", + "columnName": "amountInMillisatoshis", + "affinity": "INTEGER" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Zap_senderPublicKey", + "unique": false, + "columnNames": [ + "senderPublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_senderPublicKey` ON `${TABLE_NAME}` (`senderPublicKey`)" + }, + { + "name": "index_Zap_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Zap_postId", + "unique": false, + "columnNames": [ + "postId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_postId` ON `${TABLE_NAME}` (`postId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "senderPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "receiverPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "postId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1e328aab136444e802ffa7443e06ebe6')" + ] + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/press/mantra/compose/files/FilePicker.android.kt b/composeApp/src/androidMain/kotlin/press/mantra/compose/files/FilePicker.android.kt new file mode 100644 index 00000000..fe82857d --- /dev/null +++ b/composeApp/src/androidMain/kotlin/press/mantra/compose/files/FilePicker.android.kt @@ -0,0 +1,109 @@ +package press.mantra.compose.files + +import android.content.ContentResolver +import android.net.Uri +import android.provider.OpenableColumns +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import co.touchlab.kermit.Logger +import press.mantra.compose.text.ChapterText +import java.io.InputStream + +/** + * The Storage Access Framework, which needs no manifest permission at all. + * + * `OpenDocument` and `OpenMultipleDocuments` are a handoff to the system + * picker: the app never sees the file system, and the grant it comes back with + * covers exactly the documents the reader chose. `READ_EXTERNAL_STORAGE` and + * its API 33 replacements are for apps that browse storage themselves, which + * this does not. + * + * **Both contracts are registered, not just the multiple one.** Registering + * only `OpenMultipleDocuments` and ignoring the flag would work, and would mean + * that the one-file case -- loading a chapter into the add form -- offers a + * picker that lets the reader select six. Asking for one thing and accepting + * six is how a form ends up quietly discarding five of them. + * + * The mime types are deliberately loose. Markdown has no registered type: + * providers report `.md` as `text/markdown`, `text/plain`, `application/octet-stream` + * or nothing at all depending on the provider and the Android version, so a + * narrow filter hides the very files this is for. Filtering on `text/*` plus + * `*/*` and letting `ChapterText.decode` be the judge is both more permissive + * at the picker and stricter about what becomes a chapter. + * + * `title` is unused. `Intent.EXTRA_TITLE` is honoured by `CreateDocument`, where + * it is the suggested filename, and ignored by the open contracts -- the system + * picker titles itself. Nothing is lost by taking the parameter; something would + * be lost by making desktop's title a literal to avoid it. + */ +@Composable +actual fun rememberFilePicker( + title: String, + onPicked: (List) -> Unit, +): FilePicker { + val context = LocalContext.current + val resolver = remember(context) { context.contentResolver } + + val single = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri -> + onPicked(listOfNotNull(uri?.let { resolver.readPickedFile(it) })) + } + + val multiple = rememberLauncherForActivityResult( + ActivityResultContracts.OpenMultipleDocuments() + ) { uris -> + onPicked(uris.mapNotNull { resolver.readPickedFile(it) }) + } + + return remember(single, multiple) { + FilePicker { wantsMultiple -> + if (wantsMultiple) multiple.launch(MIME_TYPES) else single.launch(MIME_TYPES) + } + } +} + +private val MIME_TYPES = arrayOf("text/*", "*/*") + +/** + * Reads a picked document, stopping one byte past the cap. + * + * One byte *past*, so an over-size file comes back over-size rather than + * truncated to exactly the limit -- a truncated chapter would decode cleanly + * and be proposed with its ending missing. + * + * A failure here is a document that went away between being picked and being + * read, or a provider that threw; logged and dropped, because the alternative + * is failing the whole selection over one file. + */ +private fun ContentResolver.readPickedFile(uri: Uri): PickedFile? = + runCatching { + val bytes = openInputStream(uri)?.use { it.readAtMost(ChapterText.MAX_FILE_BYTES + 1) } + ?: return null + PickedFile(name = displayName(uri) ?: uri.lastPathSegment.orEmpty(), bytes = bytes) + }.onFailure { error -> + Logger.withTag(TAG).e("Could not read the picked document $uri", error) + }.getOrNull() + +private fun ContentResolver.displayName(uri: Uri): String? = + runCatching { + query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) cursor.getString(0) else null + } + }.getOrNull() + +private fun InputStream.readAtMost(limit: Int): ByteArray { + val buffer = ByteArray(limit) + var filled = 0 + while (filled < limit) { + val read = read(buffer, filled, limit - filled) + if (read < 0) break + filled += read + } + return buffer.copyOf(filled) +} + +private const val TAG = "FilePicker" diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 2f5f0b2d..a0aa8b78 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -20,6 +20,11 @@ an em dash or a comma. The compose resources plugin reports that only as "XML file ... is not valid. Check the file content." --> + Add an owner + Add project + Add source version + Add translation + An owner is a 64-character public key. Mantra Add a dialect the group can translate into Add artifact to library @@ -58,6 +63,23 @@ Create project Create the admin room Agree the group's signing key + Created by + What this project is + e.g. The four gospels into Sesotho + Every translation in this group is already in this project. + Everything in this group's library is already in this project. + Name of project + No source versions in this project yet. + No translations in this project yet. + This group + Owner public key + Owners + Project detail + Propose adding + Propose project + Remove owner + Source versions + Ask the group to sign a project into being The group is agreeing what the admin room will sign with. It takes %1$s of %2$s members, and the request is in this chat. The group has agreed what the admin room will sign with. The group could not agree what the admin room will sign with. Ask again — a fresh request is the only safe way to retry. @@ -212,6 +234,7 @@ Shared key Signing key The key this room signs as, and the ceremony it came from. + This group has no library yet, so a project would have nothing in it. Add an artifact first. What the group signed This room signs as Derivation path @@ -491,6 +514,38 @@ You already have a chat with %1$s. Checking whether %1$s can receive messages here. %1$s hasn't set up messaging on Mantra yet. Try again later, or ask them to open Mantra. + Load from file + Choose chapter files + That file isn't text. A chapter is markdown or plain text. + That file is too large to be a chapter. + This group has no shared key, so it cannot sign a chapter into existence. Run a shared key ceremony first. + %1$d chunks is more than the group can sign in one go. Split the chapter so that no part of it is over %2$d paragraphs. + This chapter is too long to propose. Asking the group to sign it and its %1$d chunks would send %2$s, and a group message carries %3$s. Split it in two. + %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. Who adopts this group? Which group should adopt this one? No group you administer can adopt this one. A group can only be offered as a parent by somebody who is in it. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt index 5a585379..49f001be 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt @@ -29,6 +29,9 @@ import press.mantra.compose.database.dao.MantraChapterDao import press.mantra.compose.database.dao.MantraChunkDao import press.mantra.compose.database.dao.MantraDao import press.mantra.compose.database.dao.MantraDialectDao +import press.mantra.compose.database.dao.MantraProjectArtifactVersionDao +import press.mantra.compose.database.dao.MantraProjectDao +import press.mantra.compose.database.dao.MantraProjectTranslationArtifactVersionDao import press.mantra.compose.database.dao.MantraTranslationArtifactVersionDao import press.mantra.compose.database.dao.MantraTranslationArtifactVersionContributorDao import press.mantra.compose.database.dao.MantraTranslationChapterDao @@ -83,6 +86,9 @@ import press.mantra.compose.database.model.MantraArtifactVersion import press.mantra.compose.database.model.MantraChapter import press.mantra.compose.database.model.MantraChunk import press.mantra.compose.database.model.MantraDialect +import press.mantra.compose.database.model.MantraProject +import press.mantra.compose.database.model.MantraProjectArtifactVersion +import press.mantra.compose.database.model.MantraProjectTranslationArtifactVersion import press.mantra.compose.database.model.MantraTranslation import press.mantra.compose.database.model.MantraTranslationArtifactVersion import press.mantra.compose.database.model.MantraTranslationArtifactVersionContributor @@ -150,6 +156,9 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) MantraChapter::class, MantraChunk::class, MantraDialect::class, + MantraProject::class, + MantraProjectArtifactVersion::class, + MantraProjectTranslationArtifactVersion::class, MantraTranslation::class, MantraTranslationArtifactVersion::class, MantraTranslationArtifactVersionContributor::class, @@ -181,7 +190,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) UnsignedNostrEvent::class, Zap::class ], - version = 23, + version = 24, autoMigrations = [ // v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can // generate the migration itself — nothing existing changes shape. @@ -335,30 +344,56 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) // than unused. See MIGRATION_20_21 and // docs/subgroups-multiple-parents.md. // - // v22 keys a DkgSession by the profile that ran the ritual as well as by the + // v22 adds the three project tables -- a project, and one row per source + // version or translation that is part of it. Three new tables and + // nothing existing changes shape, so Room generates this one itself. + // + // It is v22 rather than v21 because two trees allocated out of the same + // space in one release window: this landed as 21 on its own branch while + // the parentage table above took 21 on `mantra`. Renumbering was free -- + // nothing had shipped -- but see Phase 0 of docs/project-feature.md, + // which says to check, and checked the wrong tree. + // + // A project's contents are foreign keys into this room's own rows, with + // CASCADE, like every other reference in this family. That is the + // one-room scope decision made load-bearing: a membership naming a + // version this device does not hold fails at insert rather than landing + // as a row pointing at nothing. Projects that span rooms take those keys + // away again -- see Phase 9 of docs/project-feature.md -- and that is a + // migration rather than something to carry unused until then. + // + // The owners are a comma-joined sorted string on the project row rather + // than a fourth table, which is what DkgSession.participantPublicKeys + // already does with a set of keys and for the same reason. + AutoMigration(from = 21, to = 22), + // + // v23 keys a DkgSession by the profile that ran the ritual as well as by the // ceremony, and gives DkgParticipantMessage the profile that received each - // message. A ceremony id is minted by its coordinator and travels on the - // wire, so it is the same string for every participant -- including two - // profiles on one device, which is two participants and was one row. The - // second of them adopted the first's host key, its randomness and in the end - // its share. + // message. A ceremony id is minted by its coordinator and travels on the wire, so + // it is the same string for every participant -- including two profiles on one + // device, which is two participants and was one row. The second of them adopted + // the first's host key, its randomness and in the end its share. // - // Changing a primary key is not a shape Room migrates, and the column added - // to the messages is NOT NULL off a value read from another table, so this is - // manual too and passed to the builder rather than listed here. See - // MIGRATION_21_22. + // Changing a primary key is not a shape Room migrates, and the column added to + // the messages is NOT NULL off a value read from another table, so this is manual + // and passed to the builder rather than listed here. See MIGRATION_23_24. // - // v23 splits ChatRoomMembership off ChatRoom. The room held the group's own - // facts and the asking profile's membership of it in one row keyed by the group - // id, so a second profile joining the same group wrote over the first's MLS - // ratchet state. The group's half stays where nineteen tables already point at - // it; the profile's half -- the MLS state, the join and leave times, the - // chronicle request, the archive flag -- moves to a row keyed by - // (chatRoomId, userPublicKey). + // v24 splits ChatRoomMembership off ChatRoom. The room held the group's own facts + // and the asking profile's membership of it in one row keyed by the group id, so a + // second profile joining the same group wrote over the first's MLS ratchet state. + // The group's half stays where twenty-two tables already point at it; the + // profile's half -- the MLS state, the join and leave times, the chronicle + // request, the archive flag -- moves to a row keyed by (chatRoomId, userPublicKey). // - // Not an AutoMigration twice over: it copies before it drops, and the table it - // rebuilds is the parent of nineteen cascades, which is why it renames rather - // than drops. See MIGRATION_22_23. + // Manual for two reasons: it copies before it drops, and the table it rebuilds is + // the parent of all twenty-two of those cascades. See MIGRATION_23_24, which + // documents the one thing it depends on and the test that proves it. + // + // Both of these were written as 21->22 and 22->23 while the projects line above + // took 22 on `mantra`. Renumbered here rather than there, by the rule that a + // version belongs to whichever tree lands first -- the third time in this release + // window that two branches have reached for the same number. + ] ) @ColumnTypeConverters(MantraConverters::class) @@ -406,6 +441,12 @@ abstract class MantraDatabase: RoomDatabase() { abstract fun mantraDialectDao(): MantraDialectDao + abstract fun mantraProjectDao(): MantraProjectDao + + abstract fun mantraProjectArtifactVersionDao(): MantraProjectArtifactVersionDao + + abstract fun mantraProjectTranslationArtifactVersionDao(): MantraProjectTranslationArtifactVersionDao + abstract fun mantraTranslationArtifactVersionDao(): MantraTranslationArtifactVersionDao abstract fun mantraTranslationArtifactVersionContributorDao(): MantraTranslationArtifactVersionContributorDao diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt index 72cd19d5..35eaa933 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt @@ -7,8 +7,8 @@ import press.mantra.compose.database.migrations.MIGRATION_9_10 import press.mantra.compose.database.migrations.MIGRATION_13_14 import press.mantra.compose.database.migrations.MIGRATION_14_15 import press.mantra.compose.database.migrations.MIGRATION_20_21 -import press.mantra.compose.database.migrations.MIGRATION_21_22 import press.mantra.compose.database.migrations.MIGRATION_22_23 +import press.mantra.compose.database.migrations.MIGRATION_23_24 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -38,8 +38,8 @@ fun getRoomDatabase( MIGRATION_13_14, MIGRATION_14_15, MIGRATION_20_21, - MIGRATION_21_22, MIGRATION_22_23, + MIGRATION_23_24, ) .setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatRoomDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatRoomDao.kt index f15c2b7e..6239beac 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatRoomDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatRoomDao.kt @@ -60,7 +60,7 @@ interface ChatRoomDao { * * Deliberately not scoped by profile and deliberately not filtered by anybody's * `deletedAt`. What a caller wants from this is the group it is about to act in -- - * its name, its description, the row the nineteen tables hang off -- and that exists + * its name, its description, the row the twenty-two tables hang off -- and that exists * whether or not somebody archived their chat-list row for it. The memberships come * with it, so a caller needing one profile's MLS state asks * `LocalChatRoom.membershipOf`. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraProjectArtifactVersionDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraProjectArtifactVersionDao.kt new file mode 100644 index 00000000..1d0a44a2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraProjectArtifactVersionDao.kt @@ -0,0 +1,47 @@ +package press.mantra.compose.database.dao + +import androidx.room3.Dao +import androidx.room3.Query +import androidx.room3.Upsert +import press.mantra.compose.database.model.MantraArtifactVersion +import press.mantra.compose.database.model.MantraProject +import press.mantra.compose.database.model.MantraProjectArtifactVersion + +@Dao +interface MantraProjectArtifactVersionDao { + @Upsert + suspend fun upsert(mantraProjectArtifactVersion: MantraProjectArtifactVersion) + + @Query("SELECT * FROM MantraProjectArtifactVersion WHERE projectId = :projectId") + suspend fun getByProjectId(projectId: String): List + + @Query("SELECT * FROM MantraProjectArtifactVersion WHERE id = :id") + suspend fun getById(id: String): MantraProjectArtifactVersion? + + /** + * The versions themselves, resolved here rather than by the caller. + * + * No screen wants a join row, and resolving it in one indexed query beats an + * N+1 in a view model. + */ + @Query( + """ + SELECT v.* FROM MantraArtifactVersion v + INNER JOIN MantraProjectArtifactVersion m ON m.artifactVersionId = v.id + WHERE m.projectId = :projectId + ORDER BY v.versionLabel + """ + ) + suspend fun getArtifactVersionsByProjectId(projectId: String): List + + /** The inverse: which projects a version is part of. */ + @Query( + """ + SELECT p.* FROM MantraProject p + INNER JOIN MantraProjectArtifactVersion m ON m.projectId = p.id + WHERE m.artifactVersionId = :artifactVersionId + ORDER BY p.name + """ + ) + suspend fun getProjectsByArtifactVersionId(artifactVersionId: String): List +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraProjectDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraProjectDao.kt new file mode 100644 index 00000000..d6954b5f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraProjectDao.kt @@ -0,0 +1,18 @@ +package press.mantra.compose.database.dao + +import androidx.room3.Dao +import androidx.room3.Query +import androidx.room3.Upsert +import press.mantra.compose.database.model.MantraProject + +@Dao +interface MantraProjectDao { + @Upsert + suspend fun upsert(mantraProject: MantraProject) + + @Query("SELECT * FROM MantraProject WHERE chatRoomId = :chatRoomId ORDER BY name") + suspend fun getProjectsByChatRoomId(chatRoomId: String): List + + @Query("SELECT * FROM MantraProject WHERE id = :id") + suspend fun getProjectById(id: String): MantraProject? +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraProjectTranslationArtifactVersionDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraProjectTranslationArtifactVersionDao.kt new file mode 100644 index 00000000..49f3d3e4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraProjectTranslationArtifactVersionDao.kt @@ -0,0 +1,49 @@ +package press.mantra.compose.database.dao + +import androidx.room3.Dao +import androidx.room3.Query +import androidx.room3.Upsert +import press.mantra.compose.database.model.MantraProject +import press.mantra.compose.database.model.MantraProjectTranslationArtifactVersion +import press.mantra.compose.database.model.MantraTranslationArtifactVersion + +@Dao +interface MantraProjectTranslationArtifactVersionDao { + @Upsert + suspend fun upsert( + mantraProjectTranslationArtifactVersion: MantraProjectTranslationArtifactVersion + ) + + @Query("SELECT * FROM MantraProjectTranslationArtifactVersion WHERE projectId = :projectId") + suspend fun getByProjectId(projectId: String): List + + @Query("SELECT * FROM MantraProjectTranslationArtifactVersion WHERE id = :id") + suspend fun getById(id: String): MantraProjectTranslationArtifactVersion? + + /** The translations themselves. See the sibling DAO's note. */ + @Query( + """ + SELECT t.* FROM MantraTranslationArtifactVersion t + INNER JOIN MantraProjectTranslationArtifactVersion m + ON m.translationArtifactVersionId = t.id + WHERE m.projectId = :projectId + ORDER BY t.name + """ + ) + suspend fun getTranslationArtifactVersionsByProjectId( + projectId: String + ): List + + /** The inverse: which projects a translation is part of. */ + @Query( + """ + SELECT p.* FROM MantraProject p + INNER JOIN MantraProjectTranslationArtifactVersion m ON m.projectId = p.id + WHERE m.translationArtifactVersionId = :translationArtifactVersionId + ORDER BY p.name + """ + ) + suspend fun getProjectsByTranslationArtifactVersionId( + translationArtifactVersionId: String + ): List +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/ChatRoomMembershipMigration.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/ChatRoomMembershipMigration.kt index 39480401..bf510422 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/ChatRoomMembershipMigration.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/ChatRoomMembershipMigration.kt @@ -20,7 +20,7 @@ import androidx.sqlite.execSQL * * ### Nineteen cascades, and the one thing that makes this possible * - * `ChatRoom` is the parent of nineteen tables, every one of them `ON DELETE CASCADE` -- + * `ChatRoom` is the parent of twenty-two tables, every one of them `ON DELETE CASCADE` -- * the messages, the participants, the ceremonies, the signing sessions, the key states and * the whole translation domain. Two of the columns to remove are named by foreign keys * (`userPublicKey` onto `Profile`, `initialGiftWrapPayloadId` onto `GiftWrapPayload`), and @@ -28,7 +28,7 @@ import androidx.sqlite.execSQL * * With foreign keys enforced there is no way to rebuild it. Measured, not assumed: * - * - `DROP TABLE` performs an implicit `DELETE FROM`, so all nineteen cascades fire; + * - `DROP TABLE` performs an implicit `DELETE FROM`, so all twenty-two cascades fire; * - `ALTER TABLE ... RENAME TO` rewrites the children's foreign-key clauses to follow the * rename, pointing them at a table about to be dropped -- and `PRAGMA * legacy_alter_table` does **not** stop it. It reads back as set and the clause is @@ -40,7 +40,7 @@ import androidx.sqlite.execSQL * the ordinary rebuild-a-parent-table migration possible at all. `MIGRATION_9_10` warns * against taking that on trust, and it is not taken on trust: * `ChatRoomSplitThroughRoomJvmTest` asserts it against a real v22 file, through - * `getRoomDatabase`, with rows seeded in four of the nineteen. If that test fails this + * `getRoomDatabase`, with rows seeded in four of the twenty-two. If that test fails this * migration is unsafe, and no reordering fixes it -- the children would have to be copied * out and back. * @@ -49,9 +49,9 @@ import androidx.sqlite.execSQL * The replacement is built beside the original and renamed into its place *after* the * original is gone, rather than the original being renamed out of the way first. Nothing * ever references `ChatRoomNew`, so nothing is rewritten to follow that rename, and the - * nineteen children go on naming `ChatRoom` throughout -- which resolves to the old table - * before the swap and the new one after it. Renaming the original first would repoint all - * nineteen at `ChatRoomOld` and leave them dangling. + * children go on naming `ChatRoom` throughout -- which resolves to the old table + * before the swap and the new one after it. Renaming the original first would repoint every + * one of them at `ChatRoomOld` and leave them dangling. * * `legacy_alter_table` is set for the rename after all, for a different reason than the * one above: the modern path re-parses the whole schema on rename and errors on the @@ -63,7 +63,7 @@ import androidx.sqlite.execSQL * approximate: before several profiles, a room *was* one profile's, and its * `userPublicKey` says whose. */ -val MIGRATION_22_23 = object : Migration(22, 23) { +val MIGRATION_23_24 = object : Migration(23, 24) { override suspend fun migrate(connection: SQLiteConnection) { // 1. The membership, and one per room that exists. connection.execSQL( @@ -102,7 +102,7 @@ val MIGRATION_22_23 = object : Migration(22, 23) { ) // 2. The group's own row, built beside the original under a name nothing - // references -- so the swap below rewrites none of the nineteen children. + // references -- so the swap below rewrites none of those children. connection.execSQL( "CREATE TABLE IF NOT EXISTS `ChatRoomNew` (" + "`id` TEXT NOT NULL, `subject` TEXT, `description` TEXT, " + diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/DkgSessionPerProfileMigration.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/DkgSessionPerProfileMigration.kt index c6229e57..f8e2bd13 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/DkgSessionPerProfileMigration.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/DkgSessionPerProfileMigration.kt @@ -58,7 +58,7 @@ import androidx.sqlite.execSQL * while foreign keys were off, and it could not satisfy the new key either -- * keeping it would mean inventing an owner for somebody else's secret. */ -val MIGRATION_21_22 = object : Migration(21, 22) { +val MIGRATION_22_23 = object : Migration(22, 23) { override suspend fun migrate(connection: SQLiteConnection) { // 1. Backups, deliberately without constraints of any kind. connection.execSQL( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index 0cd98645..34464476 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -37,6 +37,9 @@ import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionContributor import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationChapterEvent import press.mantra.compose.nostr.nip30303.TranslationChunkEvent +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationContributorListEvent import press.mantra.compose.nostr.nip30303.TranslationEvent import co.touchlab.kermit.Logger @@ -1291,6 +1294,127 @@ data class ChatMessage( ) } } + // A body of work the group has named: which source versions it + // is translating and which translations it is producing. + // + // The memberships are their own events, signed in the same + // session and applied after this one -- see + // AddProjectViewModel.addProject. Nothing to do here but the + // project itself. + // + // The line names the project and not its creator. On a + // group-signed row `senderPublicKey` is the session's + // coordinator, so "X started the project" would be the + // transcript's own guess at something the event states exactly; + // the creator and the owners are on the row, and the detail + // screen is where they are read. + ProjectEvent.KIND -> { + MantraProject.fromProjectEvent( + projectEvent = ProjectEvent( + id = event.id, + pubKey = event.pubKey, + createdAt = event.createdAt, + tags = event.tags, + content = event.content, + sig = event.sig + ), + chatRoomId = groupId, + )?.let { mantraProject -> + database.mantraProjectDao().upsert( + mantraProject.copy( + marmotGroupEventId = marmotGroupEventId, + groupSignedEventId = groupSignedEventId, + ) + ) + + ChatMessage( + giftWrapPayloadId = null, + messageType = "project", + marmotGroupEventId = marmotGroupEventId, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = isUserMessage, + chatRoomId = groupId, + createdAt = createdAt, + content = "Started the ${mantraProject.name} project" + ) + } + } + + // One source version is part of one project. + // + // No line of its own, the way a version writes none beside its + // artifact and a chunk none beside its chapter: a membership + // arrives as a later item of the batch that founds the project, + // which has already said so. One added to an existing project + // later is not silent either -- `FrostSigningManager.announce` + // writes "The group signed ..." when the session completes, and + // three more lines here would be a second account of it. + // + // **The foreign key violation is allowed out, and has to be.** + // Both references are keys, so a membership naming a version or + // a project this device does not hold cannot be written -- and + // `ChronicleManager.applyPage` learns that only by catching it. + // It counts the payload failed, and `sweep` runs the pages again + // until failures stop falling, which is the whole mechanism that + // delivers a membership whose project landed in a *later* page. + // Swallowing it here counts the payload as applied, the sweep + // terminates, and the membership is lost silently on the one + // device that has nothing else to fall back on. + // + // It was caught here for one commit, on the reasoning that + // `applySignedEvent` must not report a succeeded session as + // failed. That reasoning was already satisfied: it has its own + // try/catch. And no sibling arm catches -- a translation chapter + // naming an absent chapter throws today -- so catching made this + // the only kind the chronicle could not retry. + ProjectArtifactVersionEvent.KIND -> { + MantraProjectArtifactVersion.fromProjectArtifactVersionEvent( + projectArtifactVersionEvent = ProjectArtifactVersionEvent( + id = event.id, + pubKey = event.pubKey, + createdAt = event.createdAt, + tags = event.tags, + content = event.content, + sig = event.sig + ), + chatRoomId = groupId, + )?.let { membership -> + database.mantraProjectArtifactVersionDao().upsert( + membership.copy( + marmotGroupEventId = marmotGroupEventId, + groupSignedEventId = groupSignedEventId, + ) + ) + } + null + } + + // The other half. See the arm above. + ProjectTranslationArtifactVersionEvent.KIND -> { + MantraProjectTranslationArtifactVersion + .fromProjectTranslationArtifactVersionEvent( + projectTranslationArtifactVersionEvent = + ProjectTranslationArtifactVersionEvent( + id = event.id, + pubKey = event.pubKey, + createdAt = event.createdAt, + tags = event.tags, + content = event.content, + sig = event.sig + ), + chatRoomId = groupId, + )?.let { membership -> + database.mantraProjectTranslationArtifactVersionDao().upsert( + membership.copy( + marmotGroupEventId = marmotGroupEventId, + groupSignedEventId = groupSignedEventId, + ) + ) + } + null + } + // A signing session's own protocol messages. FrostSigningManager // applies them and writes its own transcript lines naming who did // what, so an "unsupported" row here would be a second, worse diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatRoom.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatRoom.kt index 33145717..cd9559d7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatRoom.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatRoom.kt @@ -22,7 +22,7 @@ import kotlin.time.Instant * the group and one profile's membership of it and meant a second profile joining the * same group wrote over the first's ratchet state. * - * Keyed by [id] alone, which is what the nineteen tables referencing a room depend on: + * Keyed by [id] alone, which is what the twenty-two tables referencing a room depend on: * most of them hold the group's signed work, which is nobody's in particular. See * [ChatRoomMembership] for why the room was split rather than its key widened. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatRoomMembership.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatRoomMembership.kt index cc5b2292..28e634c3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatRoomMembership.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatRoomMembership.kt @@ -20,14 +20,14 @@ import press.mantra.compose.database.model.traits.UserViewableEntity * * [ChatRoom] used to hold both, keyed by the group id alone, which made it two things * in one row. The group's half -- its id, its name, its description -- is the same for - * every member and is what the nineteen tables hanging off a room point at. This half + * every member and is what the twenty-two tables hanging off a room point at. This half * is not: an MLS ratchet state belongs to one member, the moment somebody joined is * theirs, and a room one profile has archived is still in the other's list. * * ### Why a row rather than a wider key on the room * * Keying `ChatRoom` by (`id`, `userPublicKey`) would have been the same isolation and a - * much worse schema. Of the nineteen tables that reference a room, fourteen hold the + * much worse schema. Of the twenty-two tables that reference a room, fourteen hold the * *group's* signed work -- `GroupSignedEvent`, `GroupKeyState` and the twelve `Mantra*` * tables -- and two profiles in one group read the same chapters and the same signed * events. Those fourteen would have had to either carry a `userPublicKey` that @@ -35,7 +35,7 @@ import press.mantra.compose.database.model.traits.UserViewableEntity * deleted room up. `GroupSignedEvent` could not have carried one at all: its key is the * event's hash, so a second copy is not a row the table can hold. * - * Splitting instead leaves all nineteen pointing at a room that is unambiguously the + * Splitting instead leaves all twenty-two pointing at a room that is unambiguously the * group's, and puts the profile in the key of the one thing that is a profile's. * * ### What this does not decide diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraProject.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraProject.kt new file mode 100644 index 00000000..abbf505f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraProject.kt @@ -0,0 +1,165 @@ +package press.mantra.compose.database.model + +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.Index +import androidx.room3.PrimaryKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import press.mantra.compose.database.model.traits.OptionalNostrEventEntity +import press.mantra.compose.database.model.traits.TimestampedEntity +import press.mantra.compose.nostr.nip30303.ProjectEvent +import kotlin.time.Clock +import kotlin.time.Instant + +/** + * 30330 + * + * No `toProjectEvent()`, deliberately. The eight `toXEvent()` methods exist to + * rebuild events for a chronicle in rooms whose work predates `GroupSignedEvent` + * (schema v13); they are deprecated and there is a removal checklist in + * docs/member-chronicle.md. Projects arrive at v21, so every project this app + * will ever hold is on file as a signed event and `signedEventsOf` finds it. A + * rebuild here would be adding to a mechanism being retired, to serve rows that + * cannot exist. + */ +@Entity( + foreignKeys = [ + ForeignKey( + entity = ChatRoom::class, + parentColumns = ["id"], + childColumns = ["chatRoomId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [ + Index("chatRoomId"), + ], +) +data class MantraProject( + @PrimaryKey + val id: HexKey, + + /** The room's own key on a group-signed row, which is every project. */ + val publicKey: HexKey, + + val name: String, + val description: String, + + /** + * Who started it, off the event's `creator` tag. + * + * Never the submitter, and never filled in from what this device happens to + * know: the point of the tag is that it is inside the id the quorum signed, + * and a column that could disagree with it would be worth less than no + * column. It is the group's claim about whose work this is rather than proof + * -- the creator signs nothing, the room does -- and the fact about who + * actually acted is on the `FrostSigningSession`. + */ + val creatorPublicKey: HexKey, + + /** + * Who it belongs to: the owner contributor tags, comma-joined and sorted. + * Read it through [ownerPublicKeySet]. + * + * One column rather than a join table, following + * `DkgSession.participantPublicKeys` -- including the sort, which is there so + * two devices that assembled the set in different orders store the same + * string. A table would buy querying by owner, which no screen does. + * + * Never empty: `ProjectEvent.build` refuses an empty set and + * [fromProjectEvent] refuses an event with no owners. + */ + val ownerPublicKeys: String, + + val chatRoomId: String, + + /** Signature will be an empty string if this is a rumor */ + val signature: String, + + val relayHint: String? = null, + val marmotGroupEventId: HexKey? = null, + + /** The [GroupSignedEvent] this row was made from, when the group signed it. */ + val groupSignedEventId: HexKey? = null, + + /** Related nostrEventId if this is not a rumor... */ + override val nostrEventId: HexKey? = null, + + override val createdAt: Instant = Clock.System.now(), + override val updatedAt: Instant = createdAt +): OptionalNostrEventEntity, TimestampedEntity { + + /** The owners as the set they are. Empty only on a row written around this. */ + fun ownerPublicKeySet(): Set = ownerPublicKeys + .split(",") + .mapNotNull { key -> key.trim().takeIf { it.isNotEmpty() } } + .toSet() + + /** Whether [publicKey] owns this, for a screen deciding what to say. */ + fun isOwnedBy(publicKey: HexKey) = publicKey in ownerPublicKeySet() + + companion object { + fun fromProjectEventTemplate( + projectEventTemplate: EventTemplate, + chatRoomId: HexKey, + userPublicKey: HexKey, + ): MantraProject? { + return fromProjectEvent( + ProjectEvent( + id = EventHasher.hashId( + pubKey = userPublicKey, + tags = projectEventTemplate.tags, + content = projectEventTemplate.content, + createdAt = projectEventTemplate.createdAt, + kind = projectEventTemplate.kind, + ), + content = projectEventTemplate.content, + tags = projectEventTemplate.tags, + createdAt = projectEventTemplate.createdAt, + pubKey = userPublicKey, + sig = "", // Unsigned rumor + ), + chatRoomId = chatRoomId, + ) + } + + /** + * Null for an event missing anything it was built to carry. + * + * The name, the creator and at least one owner are all required, and an + * event without one of them has either been truncated or is not what it + * claims. Filling the gap from local knowledge is exactly the failure the + * tags exist to prevent, so this refuses instead. + * + * An empty owner list is also what `ContributorTag.ROLE_OWNER` missing + * from that tag's known roles looks like from here -- the role parses as + * null and every owner disappears -- so refusing is the alarm for that + * bug as well as the guard against a malformed event. + */ + fun fromProjectEvent( + projectEvent: ProjectEvent, + chatRoomId: HexKey, + ): MantraProject? { + val name = projectEvent.name() ?: return null + val creatorPublicKey = projectEvent.creator() ?: return null + val owners = projectEvent.owners().takeIf { it.isNotEmpty() } ?: return null + + return MantraProject( + id = projectEvent.id, + publicKey = projectEvent.pubKey, + name = name, + description = projectEvent.content, + creatorPublicKey = creatorPublicKey, + // Sorted here as well as in the event. The event's order is what + // the signature covers; this is so two rows built from the same + // owners compare equal whatever order they arrived in. + ownerPublicKeys = owners.toSortedSet().joinToString(","), + chatRoomId = chatRoomId, + signature = projectEvent.sig, + createdAt = Instant.fromEpochSeconds(projectEvent.createdAt), + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraProjectArtifactVersion.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraProjectArtifactVersion.kt new file mode 100644 index 00000000..5353c9ef --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraProjectArtifactVersion.kt @@ -0,0 +1,125 @@ +package press.mantra.compose.database.model + +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.Index +import androidx.room3.PrimaryKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import press.mantra.compose.database.model.traits.OptionalNostrEventEntity +import press.mantra.compose.database.model.traits.TimestampedEntity +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import kotlin.time.Clock +import kotlin.time.Instant + +/** + * 30331 -- one source version is part of one project. + * + * Both references are real foreign keys, which is the whole of the + * one-room scope decision: a proposal naming a version this room does not hold + * fails loudly at apply time rather than landing as a row pointing at nothing. + * Cross-room projects are Phase 9 of docs/project-feature.md and start by taking + * these away, following `parentChatRoomId`'s precedent -- a plain column, never a + * foreign key, because a dangling value is the normal state there. + */ +@Entity( + foreignKeys = [ + ForeignKey( + entity = MantraProject::class, + parentColumns = ["id"], + childColumns = ["projectId"], + onDelete = ForeignKey.CASCADE, + ), + ForeignKey( + entity = MantraArtifactVersion::class, + parentColumns = ["id"], + childColumns = ["artifactVersionId"], + onDelete = ForeignKey.CASCADE, + ), + ForeignKey( + entity = ChatRoom::class, + parentColumns = ["id"], + childColumns = ["chatRoomId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [ + // Every read of this table is "this project's contents". + Index("projectId"), + // And the inverse, for an artifact version saying which projects it is in. + Index("artifactVersionId"), + Index("chatRoomId"), + ], +) +data class MantraProjectArtifactVersion( + @PrimaryKey + val id: HexKey, + val publicKey: HexKey, + + val projectId: HexKey, + val artifactVersionId: HexKey, + + val chatRoomId: String, + + /** Signature will be an empty string if this is a rumor */ + val signature: String, + + val relayHint: String? = null, + val marmotGroupEventId: HexKey? = null, + + /** The [GroupSignedEvent] this row was made from, when the group signed it. */ + val groupSignedEventId: HexKey? = null, + + /** Related nostrEventId if this is not a rumor... */ + override val nostrEventId: HexKey? = null, + + override val createdAt: Instant = Clock.System.now(), + override val updatedAt: Instant = createdAt +): OptionalNostrEventEntity, TimestampedEntity { + + companion object { + fun fromProjectArtifactVersionEventTemplate( + projectArtifactVersionEventTemplate: EventTemplate, + chatRoomId: HexKey, + userPublicKey: HexKey, + ): MantraProjectArtifactVersion? { + return fromProjectArtifactVersionEvent( + ProjectArtifactVersionEvent( + id = EventHasher.hashId( + pubKey = userPublicKey, + tags = projectArtifactVersionEventTemplate.tags, + content = projectArtifactVersionEventTemplate.content, + createdAt = projectArtifactVersionEventTemplate.createdAt, + kind = projectArtifactVersionEventTemplate.kind, + ), + content = projectArtifactVersionEventTemplate.content, + tags = projectArtifactVersionEventTemplate.tags, + createdAt = projectArtifactVersionEventTemplate.createdAt, + pubKey = userPublicKey, + sig = "", // Unsigned rumor + ), + chatRoomId = chatRoomId, + ) + } + + fun fromProjectArtifactVersionEvent( + projectArtifactVersionEvent: ProjectArtifactVersionEvent, + chatRoomId: HexKey, + ): MantraProjectArtifactVersion? { + val projectId = projectArtifactVersionEvent.projectId() ?: return null + val artifactVersionId = + projectArtifactVersionEvent.artifactVersionId() ?: return null + + return MantraProjectArtifactVersion( + id = projectArtifactVersionEvent.id, + publicKey = projectArtifactVersionEvent.pubKey, + projectId = projectId, + artifactVersionId = artifactVersionId, + chatRoomId = chatRoomId, + signature = projectArtifactVersionEvent.sig, + createdAt = Instant.fromEpochSeconds(projectArtifactVersionEvent.createdAt), + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraProjectTranslationArtifactVersion.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraProjectTranslationArtifactVersion.kt new file mode 100644 index 00000000..b1fc7e5a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraProjectTranslationArtifactVersion.kt @@ -0,0 +1,123 @@ +package press.mantra.compose.database.model + +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.Index +import androidx.room3.PrimaryKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import press.mantra.compose.database.model.traits.OptionalNostrEventEntity +import press.mantra.compose.database.model.traits.TimestampedEntity +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent +import kotlin.time.Clock +import kotlin.time.Instant + +/** + * 30332 -- one translation is part of one project. + * + * The other half of [MantraProjectArtifactVersion]; see there for why both + * references are real foreign keys. + */ +@Entity( + foreignKeys = [ + ForeignKey( + entity = MantraProject::class, + parentColumns = ["id"], + childColumns = ["projectId"], + onDelete = ForeignKey.CASCADE, + ), + ForeignKey( + entity = MantraTranslationArtifactVersion::class, + parentColumns = ["id"], + childColumns = ["translationArtifactVersionId"], + onDelete = ForeignKey.CASCADE, + ), + ForeignKey( + entity = ChatRoom::class, + parentColumns = ["id"], + childColumns = ["chatRoomId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [ + Index("projectId"), + Index("translationArtifactVersionId"), + Index("chatRoomId"), + ], +) +data class MantraProjectTranslationArtifactVersion( + @PrimaryKey + val id: HexKey, + val publicKey: HexKey, + + val projectId: HexKey, + val translationArtifactVersionId: HexKey, + + val chatRoomId: String, + + /** Signature will be an empty string if this is a rumor */ + val signature: String, + + val relayHint: String? = null, + val marmotGroupEventId: HexKey? = null, + + /** The [GroupSignedEvent] this row was made from, when the group signed it. */ + val groupSignedEventId: HexKey? = null, + + /** Related nostrEventId if this is not a rumor... */ + override val nostrEventId: HexKey? = null, + + override val createdAt: Instant = Clock.System.now(), + override val updatedAt: Instant = createdAt +): OptionalNostrEventEntity, TimestampedEntity { + + companion object { + fun fromProjectTranslationArtifactVersionEventTemplate( + template: EventTemplate, + chatRoomId: HexKey, + userPublicKey: HexKey, + ): MantraProjectTranslationArtifactVersion? { + return fromProjectTranslationArtifactVersionEvent( + ProjectTranslationArtifactVersionEvent( + id = EventHasher.hashId( + pubKey = userPublicKey, + tags = template.tags, + content = template.content, + createdAt = template.createdAt, + kind = template.kind, + ), + content = template.content, + tags = template.tags, + createdAt = template.createdAt, + pubKey = userPublicKey, + sig = "", // Unsigned rumor + ), + chatRoomId = chatRoomId, + ) + } + + fun fromProjectTranslationArtifactVersionEvent( + projectTranslationArtifactVersionEvent: ProjectTranslationArtifactVersionEvent, + chatRoomId: HexKey, + ): MantraProjectTranslationArtifactVersion? { + val projectId = + projectTranslationArtifactVersionEvent.projectId() ?: return null + val translationArtifactVersionId = + projectTranslationArtifactVersionEvent.translationArtifactVersionId() + ?: return null + + return MantraProjectTranslationArtifactVersion( + id = projectTranslationArtifactVersionEvent.id, + publicKey = projectTranslationArtifactVersionEvent.pubKey, + projectId = projectId, + translationArtifactVersionId = translationArtifactVersionId, + chatRoomId = chatRoomId, + signature = projectTranslationArtifactVersionEvent.sig, + createdAt = Instant.fromEpochSeconds( + projectTranslationArtifactVersionEvent.createdAt + ), + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt index d1113390..eedfb434 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt @@ -221,8 +221,8 @@ class DatabaseChatRepository( false } - override suspend fun parentsOf(chatRoomId: String): List = try { - SubgroupManager.parentsOf(database, chatRoomId) + override suspend fun parentsOf(chatRoomId: String): List = try { + SubgroupManager.parentGroupsOf(database, chatRoomId) } catch (e: Throwable) { logger.e("Error reading the parents of $chatRoomId", e) emptyList() @@ -711,7 +711,7 @@ class DatabaseChatRepository( * One person tidying their chat list is not the group being deleted, and it is not the * other profile's list changing either -- so this marks the membership rather than the * room. A room with no membership left archived is still the group's, and the - * nineteen tables hanging off it are untouched. + * twenty-two tables hanging off it are untouched. */ override suspend fun softDeleteChatRoom( chatRoom: ChatRoom, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt index 892c9724..15363350 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt @@ -134,27 +134,35 @@ class DatabaseDkgRepository( ): Flow> = database.frostSigningSessionDao().observeSessionsForChatRoom(chatRoomId) + /** + * By session id, not by room. A ceremony room holds more than one ceremony + * the moment a subgroup's admins are the whole group, and a lookup by room + * answered about whichever the room's rule picked -- for a room holding only + * a subgroup's ceremony, asking for the room's *own* found nothing at all, + * and a finished subgroup with a signed key state could never be created. + */ + /** + * Off the ceremony's own key rather than a profile's row. + * + * The threshold key is the ceremony's published result and the same value on every + * participant's row, so this is the one read here that names no profile -- see + * `DkgSessionDao.getCeremonyThresholdPublicKey`. Scoping it would make the two flows + * below resolve nothing while no profile is open, for a fact that does not depend on + * one. + */ + override suspend fun roomDerivedBy(dkgSessionId: String): String? = + database.dkgSessionDao() + .getCeremonyThresholdPublicKey(dkgSessionId) + ?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() } + override fun observeSignedGroupKeyState(dkgSessionId: String): Flow = database.groupSignedEventDao() .observeByKind(GroupKeyStateEvent.KIND) .map { signedEvents -> - // The room this ceremony's key derives, resolved per emission - // rather than once: the ceremony has no key until it finishes, - // and this flow is running before it does. - // - // By session id, not by room. A ceremony room holds more than one - // ceremony the moment a subgroup's admins are the whole group, and - // a lookup by room answered about whichever the room's rule picked - // -- for a room holding only a subgroup's ceremony, asking for the - // room's *own* found nothing at all, and a finished subgroup with - // a signed key state could never be created. - // The ceremony's own key, not a profile's row: what is wanted is the - // room it derives, which every member agrees on. - val subjectRoomId = database.dkgSessionDao() - .getCeremonyThresholdPublicKey(dkgSessionId) - ?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() } - - subjectRoomId?.let { GroupKeyStateManager.stateAmong(signedEvents, it) } + // Resolved per emission rather than once: the ceremony has no key + // until it finishes, and this flow is running before it does. + roomDerivedBy(dkgSessionId) + ?.let { GroupKeyStateManager.stateAmong(signedEvents, it) } } override suspend fun adoptGroupKeyState(chatRoomId: String): LocalGroupKeyState? = try { @@ -201,13 +209,9 @@ class DatabaseDkgRepository( database.groupSignedEventDao() .observeByKind(SubgroupBirthCertificateEvent.KIND) .map { signedEvents -> - // The room the ceremony's key derives, resolved per emission - // rather than once: the ceremony has no key until it finishes, - // and this flow is running before it does. - val subgroupChatRoomId = database.dkgSessionDao() - .getCeremonyThresholdPublicKey(dkgSessionId) - ?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() } - ?: return@map null + // Resolved per emission rather than once: the ceremony has no key + // until it finishes, and this flow is running before it does. + val subgroupChatRoomId = roomDerivedBy(dkgSessionId) ?: return@map null signedEvents .filter { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt index 74552762..8eafb679 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt @@ -9,6 +9,7 @@ import press.mantra.compose.database.model.MantraArtifactVersion import press.mantra.compose.database.model.MantraChapter import press.mantra.compose.database.model.MantraChunk import press.mantra.compose.database.model.MantraDialect +import press.mantra.compose.database.model.MantraProject import press.mantra.compose.database.model.MantraTranslationArtifactVersion import press.mantra.compose.database.model.MantraTranslationChapter import press.mantra.compose.database.model.MantraTranslationChunk @@ -41,6 +42,35 @@ class DatabaseMantraRepository( override suspend fun getChaptersForArtifactVersion(artifactVersionId: String): List = database.mantraChapterDao().getChaptersByArtifactVersionId(artifactVersionId) + override suspend fun getProjects(chatRoomId: String): List = + database.mantraProjectDao().getProjectsByChatRoomId(chatRoomId) + + override suspend fun getProject(id: String): MantraProject? = + database.mantraProjectDao().getProjectById(id) + + override suspend fun getProjectArtifactVersions( + projectId: String + ): List = + database.mantraProjectArtifactVersionDao().getArtifactVersionsByProjectId(projectId) + + override suspend fun getProjectTranslationArtifactVersions( + projectId: String + ): List = + database.mantraProjectTranslationArtifactVersionDao() + .getTranslationArtifactVersionsByProjectId(projectId) + + override suspend fun getProjectsForArtifactVersion( + artifactVersionId: String + ): List = + database.mantraProjectArtifactVersionDao() + .getProjectsByArtifactVersionId(artifactVersionId) + + override suspend fun getProjectsForTranslationArtifactVersion( + translationArtifactVersionId: String + ): List = + database.mantraProjectTranslationArtifactVersionDao() + .getProjectsByTranslationArtifactVersionId(translationArtifactVersionId) + override suspend fun getChapter(id: String): MantraChapter? = database.mantraChapterDao().getChapterById(id) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/files/FilePicker.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/files/FilePicker.kt new file mode 100644 index 00000000..4640839f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/files/FilePicker.kt @@ -0,0 +1,72 @@ +package press.mantra.compose.files + +import androidx.compose.runtime.Composable + +/** + * A file the reader picked, as its name and its bytes. + * + * Bytes rather than a path or an open stream, and that is a decision rather + * than laziness. A path is meaningless on iOS the moment the picker's security + * scope closes, so a path would be a handle that works on two platforms and + * expires on the third. A stream would push the size cap and the decode into + * whichever coroutine happened to read it. Chapters are tens of kilobytes; + * `ChapterText.MAX_FILE_BYTES` bounds the read and the whole file fits in hand. + * + * [name] is for naming the chapter, not for deciding what the file is -- that + * is `ChapterText.decode`'s job, by looking at the bytes. On android the name + * comes from the document provider and can be absent or a lie; it is a default + * a person edits, never a check. + */ +data class PickedFile( + val name: String, + val bytes: ByteArray, +) { + // ByteArray makes the generated equals/hashCode identity-based, which is + // wrong for a data class that will sit in a list in UI state and be + // compared across recompositions. + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is PickedFile) return false + return name == other.name && bytes.contentEquals(other.bytes) + } + + override fun hashCode(): Int = 31 * name.hashCode() + bytes.contentHashCode() +} + +/** Opens the platform's file chooser. */ +fun interface FilePicker { + fun launch(multiple: Boolean) +} + +/** + * The platform's file chooser, wired to hand [onPicked] what came back. + * + * `expect` rather than a dependency. The obvious alternative is FileKit, which + * covers all three targets from common code -- but this is the only thing in + * the app that needs a dialog, the three calls below are a dozen lines each, + * and the project already keeps four platform seams of exactly this shape + * (`PlatformContext`, `PlatformDatabaseBuilder`, `platformReducedMotion`, + * `platformThemeContrast`). A dependency whose Compose version has to track + * ours is a poor trade for that. + * + * **Nothing is read beyond `ChapterText.MAX_FILE_BYTES`.** Each actual stops + * there, so picking a video by mistake costs a bounded read rather than the + * whole file, and the over-size file still arrives -- with more bytes than the + * cap allows -- so that `decode` can say `TooLarge` instead of the picker + * silently dropping it. + * + * Cancelling hands back an empty list, which is how a caller tells "no files" + * from "files that would not decode". + * + * [title] is the dialog's own title, read from the string catalogue by the + * caller because `stringResource` is composable and the dialog is not. Only + * desktop uses it: android's Storage Access Framework and iOS's document + * browser each title themselves and offer no way to say otherwise. Taken on all + * three anyway, so that the one platform which shows text to the reader gets it + * from the catalogue like every other string in the app. + */ +@Composable +expect fun rememberFilePicker( + title: String, + onPicked: (List) -> Unit, +): FilePicker diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChapterIndexing.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChapterIndexing.kt new file mode 100644 index 00000000..425da261 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChapterIndexing.kt @@ -0,0 +1,114 @@ +package press.mantra.compose.managers + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.flow.first +import press.mantra.compose.nostr.nip30303.ChapterEvent +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.MantraRepository + +/** + * Where the next chapter of a version goes. + * + * The index is signed into the chapter and cannot be recounted on arrival -- a + * device applying one cannot recount from a version other members may have + * added to in a different order -- so the number has to be right at proposal + * time. "How many chapters are in the table" is not it. + * + * **A proposed chapter is not a row.** It becomes one when a quorum signs, + * which takes as long as it takes, and until then nothing in + * `MantraChapter` knows about it. So reading the table gives the same answer to + * two proposals made a second apart, and both get signed at the same index. + * At one chapter at a time that is a race nobody hits; an upload of twelve + * files hits it eleven times out of eleven. + * + * The information needed is already published. A session's items carry the + * unsigned events it is signing, and an unsigned event's id and fields are + * settled at proposal time -- that is the whole reason + * [FrostSigningRepository.unsignedEvents] exists rather than only + * `signedEvents`. So the answer is the table *and* the room's open proposals. + */ +object ChapterIndexing { + + private const val TAG = "ChapterIndexing" + + /** + * The index for the next chapter of [artifactVersionId], counting chapters + * this room has proposed but not yet signed. + * + * **One past the highest, not a count.** A count is wrong the moment the + * sequence has a gap, and gaps are expected here: a chapter that fails to + * reach a quorum never becomes a row, and its index is spent -- a + * re-proposed chapter is a different event. Ordering survives a gap, so + * leaving one is the cheap answer and renumbering would be the expensive + * wrong one. + * + * **Every session counts, whatever stage it is at.** A failed session's + * index is wasted, which costs a gap; a stalled session's chapter may still + * arrive, and stepping over it costs nothing. Filtering by stage would + * trade a harmless gap for a possible collision. + * + * **This fixes one device, not the group.** Two members proposing at the + * same moment still collide: neither sees the other's proposal until it + * arrives, and no amount of local reading will change that. That is + * tolerable where this is not -- an index orders reading, two chapters at + * index 4 sort arbitrarily between themselves, and nothing else breaks. The + * local case is worth fixing because an upload makes it certain rather than + * unlikely. + * + * Failing soft on purpose: if the proposals cannot be read, the table's + * answer is still better than nothing, and refusing to propose a chapter + * because its number could not be checked would be the wrong trade. + */ + suspend fun nextIndex( + mantraRepository: MantraRepository, + frostSigningRepository: FrostSigningRepository, + chatRoomId: String, + artifactVersionId: String, + ): Int { + val signed = mantraRepository + .getChaptersForArtifactVersion(artifactVersionId) + .maxOfOrNull { it.index } + + val proposed = runCatching { + proposedIndices(frostSigningRepository, chatRoomId, artifactVersionId).maxOrNull() + }.onFailure { error -> + Logger.withTag(TAG).e( + "Could not read this room's proposals; numbering the next chapter from the " + + "table alone, which may collide with a chapter already proposed", + error, + ) + }.getOrNull() + + val highest = listOfNotNull(signed, proposed).maxOrNull() ?: return 0 + return highest + 1 + } + + /** + * The indices of chapters this room has proposed for [artifactVersionId]. + * + * Read off the unsigned events rather than from anything stored about them, + * because there is nothing stored about them: a proposal's items are its + * only record until it is signed. Non-chapter items -- a dialect, a + * translation, the chunks beside a chapter -- are ignored by kind, and a + * chapter belonging to another version of the same artifact by its + * `artifactVersionId` tag. + */ + private suspend fun proposedIndices( + frostSigningRepository: FrostSigningRepository, + chatRoomId: String, + artifactVersionId: String, + ): List = + frostSigningRepository.observeSessionsForChatRoom(chatRoomId).first() + .flatMap { session -> frostSigningRepository.unsignedEvents(session.items) } + .filter { event -> event.kind == ChapterEvent.KIND } + .mapNotNull { event -> + ChapterEvent( + id = event.id, + pubKey = event.pubKey, + createdAt = event.createdAt, + tags = event.tags, + content = event.content, + sig = event.sig, + ).takeIf { it.artifactVersionId() == artifactVersionId }?.index() + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChapterScaffolding.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChapterScaffolding.kt new file mode 100644 index 00000000..bc53680a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChapterScaffolding.kt @@ -0,0 +1,127 @@ +package press.mantra.compose.managers + +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.MantraRepository + +/** + * Asking the group to scaffold newly proposed chapters into every translation + * of their version. + * + * A translation chapter carries no words -- it is `(translation, chapter, + * position)` -- and it exists so a translated chunk has somewhere to hang. See + * [TranslationScaffold] for the rows themselves; this is the part that reads + * the version, finds the chapters as the group will author them, and opens the + * sessions. + * + * **Its own session, not more items on the chapter's.** A chapter is capped at + * [TranslationScaffold] -- really at `FrostSigningManager.MAX_BATCH_SIZE` minus + * the chapter itself -- and every dialect the group works in would take one of + * those places away. How long a chapter may be and how many languages it is + * read in have nothing to do with each other, and a shared cap would make the + * first move whenever somebody did the second. + * + * **But one session for all of them, not one per chapter.** This is what the + * upload needed and what `AddChapterViewModel` had wrong for a single chapter + * only by not noticing: twelve chapters in a group with three dialects is + * thirty-six join rows, which fit in one batch, and the per-chapter shape would + * have opened twelve sessions to carry three rows each. Twenty-four proposals + * where thirteen will do. + * + * **Failure is logged and swallowed**, as it was before. The chapters have + * already been proposed and are the thing that was asked for; losing them + * because their scaffolding could not be opened would be the wrong way round. A + * join row naming a chapter that never reached a quorum fails a foreign key on + * the way in, is logged by `applySignedEvent`, and never becomes a row -- so the + * catch-up on the translation is what fills the gap afterwards. + */ +object ChapterScaffolding { + + private const val TAG = "ChapterScaffolding" + + /** + * A chapter that has just been put to the group, named by the session that + * will sign it. + * + * The session rather than the chapter, because there is no chapter yet. Its + * id is settled at proposal time -- it is the hash the signers are putting + * their shares behind -- and [FrostSigningRepository.unsignedEvents] is how + * to read it while the session is still running. + */ + data class ProposedChapter( + val sessionId: String, + val index: Int, + ) + + /** + * Opens as few sessions as will carry the join rows for [chapters]. + * + * Chunked over the **cross product**, not over the translations. With one + * chapter the two are the same and the old code was right by coincidence; + * with twelve, chunking the translations would propose twelve chapters' + * worth of rows in one batch per sixty-four dialects, which is the wrong + * axis and overflows at the fifth chapter of a three-dialect group. + * + * Returns the sessions opened, which is nothing the caller has to act on -- + * it is there so a test can count them. + */ + suspend fun propose( + mantraRepository: MantraRepository, + frostSigningRepository: FrostSigningRepository, + localChatRoom: LocalChatRoom, + userPublicKey: HexKey, + artifactVersionId: String, + chapters: List, + ): List { + if (chapters.isEmpty()) return emptyList() + + return runCatching { + val translations = mantraRepository + .getTranslationsForArtifactVersion(artifactVersionId) + .ifEmpty { return emptyList() } + + // The chapters as the group will author them. A session whose lead + // event cannot be read is left out rather than guessed at: a join + // row naming the wrong chapter is worse than one that is missing, + // because the catch-up can find a missing one. + val sources = chapters.mapNotNull { proposed -> + val lead = frostSigningRepository + .unsignedEvents(frostSigningRepository.getItems(proposed.sessionId)) + .firstOrNull() + ?: return@mapNotNull null + + TranslationScaffold.SourceChapter(id = lead.id, index = proposed.index) to + lead.createdAt + } + if (sources.isEmpty()) return emptyList() + + // One timestamp for the lot, so a scaffolding proposed as one act + // reads as one. The latest of the chapters' own, which is the moment + // the act finished -- not the clock, which would be a third time + // unrelated to either end of it. + val createdAt = sources.maxOf { it.second } + + val rows = TranslationScaffold.chaptersOf( + translationArtifactVersionIds = translations.map { it.id }, + chapters = sources.map { it.first }, + createdAt = createdAt, + ) + + rows.chunked(FrostSigningManager.MAX_BATCH_SIZE).mapNotNull { batch -> + frostSigningRepository.proposeSigningBatch( + localChatRoom = localChatRoom, + userPublicKey = userPublicKey, + events = batch, + )?.id + } + }.onFailure { error -> + Logger.withTag(TAG).e( + "Proposed ${chapters.size} chapter(s) but could not ask for them in this " + + "version's translations", + error, + ) + }.getOrDefault(emptyList()) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt index 0f7895f8..96acafd9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt @@ -146,6 +146,29 @@ object FrostSigningManager { */ const val MAX_BATCH_SIZE: Int = 64 + /** + * The most JSON a proposal may carry. + * + * [MAX_BATCH_SIZE] bounds how many events a batch holds; this bounds how + * large they are, and the two are not the same limit. Sixty-four dialects + * are a few kilobytes and sixty-four chunks of a chapter are eighty, so a + * count cap alone lets a perfectly legal batch exceed what the transport + * carries -- and the proposal is the largest message in this protocol by an + * order of magnitude: a signer's nonce payload is `k * 133` bytes of hex and + * their partial `k * 65`, where the proposal carries `k` whole events. + * + * Deliberately the same number as `ChronicleEvent.MAX_PAGE_BYTES`, because + * it is the same question asked twice -- what one kind:445 clears once the + * MLS framing and the NIP-44 expansion outside it are paid for. The two are + * not shared as one constant because neither module should depend on the + * other for it, but they do have to be re-sized together, and whichever is + * raised first should say so here. + * + * Before this existed the failure was silent: the proposal went out, the + * group event was over size, and the session stalled with nothing to blame. + */ + const val MAX_PROPOSAL_BYTES: Int = 64 * 1024 + /** * Opens a signing session, making this device the coordinator. * @@ -276,6 +299,16 @@ object FrostSigningManager { "A signing session will sign at most $MAX_BATCH_SIZE events, not ${unsignedEvents.size}" } + // Encoded once, here, so the size check is the real encoding rather than + // an estimate of it -- and so the broadcast below sends the bytes that + // were measured. + val proposal = FrostSigningEvents.encodeProposal(unsignedEvents) + val proposalBytes = proposal.encodeToByteArray().size + + require(proposalBytes <= MAX_PROPOSAL_BYTES) { + "A signing proposal carries at most $MAX_PROPOSAL_BYTES bytes, not $proposalBytes" + } + val sessionId = RandomInstance.bytes(32).toHex() val session = FrostSigningSession( @@ -305,7 +338,7 @@ object FrostSigningManager { localChatRoom = localChatRoom, session = session, kind = FrostSigningEvents.PROPOSAL, - content = FrostSigningEvents.encodeProposal(unsignedEvents), + content = proposal, includeKey = true ) @@ -416,6 +449,18 @@ object FrostSigningManager { return null } + // Before parsing, not after. This and the count check below are the two + // places a remote party decides how much work this device does, and the + // cheaper of the two refusals should come first. + val proposalBytes = innerEvent.content.encodeToByteArray().size + if (proposalBytes > MAX_PROPOSAL_BYTES) { + logger.w( + "Signing proposal $sessionId carries $proposalBytes bytes, more than the " + + "$MAX_PROPOSAL_BYTES this device will accept; dropping" + ) + return null + } + val proposed = FrostSigningEvents.decodeProposal(innerEvent.content) if (proposed == null) { logger.w("Signing proposal $sessionId does not carry any events; dropping") diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt index c74baa68..3360dc36 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt @@ -852,6 +852,35 @@ object SubgroupManager { database.groupKeyStateDao().getLocalByChatRoomId(chatRoomId)?.parentChatRoomIds() .orEmpty() + /** + * A group this room is a child of, plus the room itself where this device + * has it. + * + * [room] is the only place a parent's *name* can come from: a certificate + * names the child, never the certifier, so a device that is not in the + * parent room knows the lineage and not what to call it. Null is therefore + * the ordinary case for a subgroup member who is not also a parent member, + * and the id is all such a screen can honestly show. + */ + data class Parent( + val chatRoomId: HexKey, + val room: LocalChatRoom?, + ) + + /** + * [parentsOf], with each parent's room where this device holds one. + * + * The same verified set in the same order -- this only looks each id up, and + * a parent whose room is missing stays in the list rather than being dropped. + */ + suspend fun parentGroupsOf(database: MantraDatabase, chatRoomId: String): List = + parentsOf(database, chatRoomId).map { parentChatRoomId -> + Parent( + chatRoomId = parentChatRoomId, + room = database.chatRoomDao().findChatRoomById(parentChatRoomId) + ) + } + /** * What a certificate arriving in a room is, or null if it is nothing this * room should believe. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEvent.kt index c6a96f5d..0e6bf12a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEvent.kt @@ -19,6 +19,9 @@ import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent import press.mantra.compose.nostr.nip30303.ChapterEvent import press.mantra.compose.nostr.nip30303.ChunkEvent import press.mantra.compose.nostr.nip30303.DialectEvent +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationChapterEvent import press.mantra.compose.nostr.nip30303.TranslationChunkEvent @@ -167,8 +170,8 @@ class ChronicleEvent( * ### What is missing from it, and why * * **Only kinds the group actually signs can be here**, because a chronicle - * that cannot be verified is one that has to be believed. Six of the - * thirteen nip30303 kinds reach a signing session; the rest travel as + * that cannot be verified is one that has to be believed. Eleven of the + * sixteen nip30303 kinds reach a signing session; the rest travel as * rumors -- empty signature, member author, vouched for by the MLS frame * they arrived in and by nothing that survives leaving it. * @@ -179,6 +182,8 @@ class ChronicleEvent( * - The contributor lists (30305, 30307, 30310). `applyInnerEvent` has * no arm that writes a row for any of them, so chronicling them would * cost bytes and restore nothing. + * - `SubmissionEvent` (30312), which is the envelope rather than the + * work: a chronicle carries payloads. * * Two kinds were on that list and are not any more, because the app * stopped leaving them unsigned. `ArtifactVersionEvent` (30301) used to be @@ -188,6 +193,10 @@ class ChronicleEvent( * and is now proposed to the group like everything else. Both are * therefore checkable, and a chronicle that left them out would hand a new * member the whole structure and none of the prose. + * + * The three project kinds (30330-30332) were signed from the day they + * existed, so they have no such story: they are here because a group's + * work includes what the work is for. */ private val APPLY_ORDER: List = listOf( DialectEvent.KIND, @@ -198,6 +207,20 @@ class ChronicleEvent( TranslationArtifactVersionEvent.KIND, TranslationChapterEvent.KIND, TranslationChunkEvent.KIND, + // Projects are last, and that is the whole of what this list says + // about them. A membership is a foreign key into both a project and + // a version, so it has to apply after everything it can name -- and + // appending gets that right by construction, since everything a + // project groups is already above it. + // + // Chroniclable where the contributor lists are not, for the opposite + // of their reason: these have an arm that writes a row, and they are + // signed rather than submitted, so a receiver can check them. A late + // member handed the whole library and no account of what any of it + // is for has been handed the smaller half. + ProjectEvent.KIND, + ProjectArtifactVersionEvent.KIND, + ProjectTranslationArtifactVersionEvent.KIND, ) /** Every kind a chronicle may carry. */ diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChapterProposal.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChapterProposal.kt new file mode 100644 index 00000000..3961d80c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChapterProposal.kt @@ -0,0 +1,94 @@ +package press.mantra.compose.nostr.nip30303 + +import press.mantra.compose.managers.FrostSigningManager +import press.mantra.compose.text.Markdown + +/** + * What proposing a chapter will cost on the wire, close enough to say so while + * it is being typed. + * + * `FrostSigningManager.MAX_PROPOSAL_BYTES` is the limit and the manager enforces + * it exactly, by encoding the batch and measuring it. That answer arrives too + * late to be useful: by then the chapter has been built, the chunks cut and the + * session is one line from being opened, and the only thing left to do with the + * number is throw. This is the same question answered cheaply enough to put in + * front of somebody with a cursor still in the text field. + * + * **Why a chapter needs this at all.** A chapter travels with a chunk per + * paragraph, and the chapter carries the whole markdown while the chunks carry + * the same words again between them -- see `ChapterEvent.originalText` for why + * that duplication is wanted. So a proposal is a little over twice the prose, + * plus an envelope per event, and the limit arrives at about 20 KiB of markdown + * at the maximum chunk count. That is inside the range of a real book chapter. + * + * **Why this is not a constant.** A flat `MAX_ORIGINAL_TEXT_BYTES` was the + * obvious shape and is wrong by half. The envelopes are what varies: measured, + * a 63-chunk chapter fits about 20 KiB of markdown and a 10-chunk chapter fits + * about 30. A single number safe for the first refuses a third of the second's + * text for no reason, and a single number sized for the second stalls sessions. + * The count is known as the text is typed, so the limit may as well be. + */ +object ChapterProposal { + + /** + * What one unsigned event costs beside its own text. + * + * **Measured, not derived.** Across chapters from 1 KiB to 24 KiB and from 1 + * to 63 chunks, the per-event cost of an encoded proposal runs between 357 + * and 388 bytes: a 64-character id, a 64-character pubkey, an empty `sig` + * (a proposal carries unsigned events, which is why this is smaller than a + * signed event's ~370-byte floor), the JSON structure, the alt tag, and -- on + * a chunk -- the 64-character chapter id it points at. + * + * Rounded up to 448 rather than to the measured maximum, and that direction + * is the deliberate one. Over-estimating costs an author a slightly shorter + * chapter; under-estimating costs the group a session that stalls with + * nothing to blame. `ChapterProposalSizeTest` asserts the estimate stays at + * or above the real encoding, so a tag added to either kind fails a test + * rather than a quorum. + */ + const val EVENT_OVERHEAD_BYTES: Int = 448 + + /** The limit, which is the manager's and not this file's to choose. */ + const val MAX_PROPOSAL_BYTES: Int = FrostSigningManager.MAX_PROPOSAL_BYTES + + /** + * What a proposal for this chapter would weigh. + * + * Takes the chunks rather than re-splitting, because every caller has just + * counted them for the other cap and because summing their text is exact + * where doubling the chapter's would not be -- the blank lines between + * paragraphs are in the chapter's text and in no chunk's. + */ + fun proposalBytes(originalText: String, chunks: List): Int = + originalText.utf8Size() + + chunks.sumOf { it.utf8Size() } + + (chunks.size + 1) * EVENT_OVERHEAD_BYTES + + fun proposalBytes(originalText: String): Int = + proposalBytes(originalText, Markdown.splitChunks(originalText)) + + fun fits(originalText: String, chunks: List): Boolean = + proposalBytes(originalText, chunks) <= MAX_PROPOSAL_BYTES + + /** + * The most markdown a chapter of this many chunks may carry. + * + * For the form, which has to say what the limit *is* and not only that it + * has been passed. Inverts [proposalBytes]: the envelopes come off the top + * and the prose is counted twice, so what is left is halved. + */ + fun maxOriginalTextBytes(chunkCount: Int): Int = + ((MAX_PROPOSAL_BYTES - (chunkCount + 1) * EVENT_OVERHEAD_BYTES) / 2) + .coerceAtLeast(0) +} + +/** + * The bytes a string takes as UTF-8, which is what the wire counts. + * + * Not `length`, which counts UTF-16 code units: the languages this app exists + * for are full of characters that are one unit and three bytes, so measuring a + * Setswana or Amharic chapter by its `length` would under-count it by half and + * the cap would stop binding exactly where it matters most. + */ +private fun String.utf8Size(): Int = encodeToByteArray().size diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChunkEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChunkEvent.kt index 48876fc9..9c363b2f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChunkEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChunkEvent.kt @@ -63,13 +63,13 @@ class ChunkEvent( sig = chapter.sig ).originalText() ?: return emptyList() - return Markdown.splitParagraphs(originalText).mapIndexed { index, paragraph -> + return Markdown.splitChunks(originalText).mapIndexed { index, chunk -> build( chapterId = chapter.id, - text = paragraph, + text = chunk, index = index, - wordCount = Markdown.wordCount(paragraph), - characterCount = Markdown.characterCount(paragraph), + wordCount = Markdown.wordCount(chunk), + characterCount = Markdown.characterCount(chunk), createdAt = chapter.createdAt, ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectArtifactVersionEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectArtifactVersionEvent.kt new file mode 100644 index 00000000..071399fb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectArtifactVersionEvent.kt @@ -0,0 +1,92 @@ +package press.mantra.compose.nostr.nip30303 + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils +import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionIdTag +import press.mantra.compose.nostr.nip30303.tags.ProjectIdTag + + +/** + * One source version is part of one project. + * + * Its own event rather than a line in a list the project carries. Every entity + * in this family is its own event and its own row, and the only list-shaped kinds + * here -- the three contributor lists -- are precisely the ones nothing applies. + * A membership is also something a group decided, and one quorum per decision is + * the grain this app signs at: a replaceable list would make "we added this" and + * "we removed that" the same event. + * + * It carries no creator. Who moved a version into a project is a fact about + * process, which the `FrostSigningSession` records; a membership signed in the + * batch that founds a project has the project's creator anyway. + */ +@Immutable +class ProjectArtifactVersionEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + + fun projectIdReference() = tags.firstNotNullOfOrNull(ProjectIdTag::parse)?.ref + fun projectId() = projectIdReference()?.eventId + + fun artifactVersionIdReference() = tags.firstNotNullOfOrNull(ArtifactVersionIdTag::parse)?.ref + fun artifactVersionId() = artifactVersionIdReference()?.eventId + + companion object { + const val KIND = 30331 + const val ALT_DESCRIPTION = "ProjectArtifactVersion" + + /** + * The source versions [project] starts life with, ready to be signed + * with it. + * + * A project and its first contents go to the group as one batch, so this + * is built from the project *after* it has been authored under the + * group's key -- [project] is the unsigned event the session will sign, + * which is where the id these carry comes from. Working it out anywhere + * else would mean naming a project id before one exists: it is a hash + * over the group's key at the room's path, and neither is resolved until + * the proposal is made. + * + * They take the project's own timestamp, so the batch reads as one act + * rather than several events that happen to share a session. + * + * The same shape as `ArtifactVersionEvent.initialVersionOf`, and empty + * for a project founded with no contents -- which is allowed, unlike an + * artifact with no version: an empty project is a name waiting for work. + */ + fun membershipsOf( + project: Event, + artifactVersionIds: List, + ): List> = + artifactVersionIds.map { artifactVersionId -> + build( + projectId = project.id, + artifactVersionId = artifactVersionId, + createdAt = project.createdAt, + ) + } + + fun build( + projectId: HexKey, + artifactVersionId: HexKey, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + addUnique(ProjectIdTag.assemble(projectId)) + addUnique(ArtifactVersionIdTag.assemble(artifactVersionId)) + initializer() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectEvent.kt new file mode 100644 index 00000000..3fdc8d48 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectEvent.kt @@ -0,0 +1,107 @@ +package press.mantra.compose.nostr.nip30303 + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils +import press.mantra.compose.nostr.nip30303.tags.ContributorTag +import press.mantra.compose.nostr.nip30303.tags.CreatorTag +import press.mantra.compose.nostr.nip30303.tags.NameTag + + +/** + * A body of work: the source versions to translate from and the translations + * being produced against them, under one name. + * + * A project names two kinds of person and they are not the same shape. There is + * one [creator], so it is a [CreatorTag] -- a single-value tag, like every other + * field here. There may be several [owners], so each is a [ContributorTag] with + * [ContributorTag.ROLE_OWNER], which is what that tag is for. + * + * Both are written before the event is proposed, so they are inside the id the + * quorum signs. That makes them the group's statement about whose work this is + * rather than one device's note about who inserted a row -- and it is a group + * attestation, not proof of authorship: the creator does not sign this, the room + * does, so a quorum that signs without reading has credited whoever the proposer + * typed. `ProposedEvent.summarize` shows both for that reason. + * + * Which group a project belongs to is not a tag at all. It is signed by the + * group's threshold key at the room's derivation path, so it says so by existing. + */ +@Immutable +class ProjectEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + + fun name() = tags.firstNotNullOfOrNull(NameTag::parse)?.name + + fun creator() = tags.firstNotNullOfOrNull(CreatorTag::parse)?.pubKey + + /** + * Everyone this project belongs to, in the order the event carries them. + * + * Filtered on the role rather than taking every `p` tag. Every contributor + * tag on a project is an owner today, and that is one future role away from + * being false -- at which point an unfiltered read would quietly hand back an + * owner list with somebody else in it. + * + * Empty is not a project this app wrote: [build] refuses it. It is what a + * reader sees when [ContributorTag.ROLE_OWNER] is missing from that tag's + * known roles, since the role then parses as null -- so treat empty as that + * bug rather than as a project nobody owns. + */ + fun owners() = tags.mapNotNull(ContributorTag::parse) + .filter { it.role == ContributorTag.ROLE_OWNER } + .map { it.pubKey } + + companion object { + const val KIND = 30330 + const val ALT_DESCRIPTION = "Project" + + /** + * [ownerPublicKeys] is a set and must not be empty. + * + * The caller always has an answer -- the room's own key, when the group + * owns what it makes -- and an empty set would make "owned by us" and + * "owner unstated" the same event, which leaves every reader needing to + * know the default. + */ + fun build( + name: String, + description: String, + creatorPublicKey: HexKey, + ownerPublicKeys: Set, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, description, createdAt) { + require(ownerPublicKeys.isNotEmpty()) { "A project has at least one owner" } + + alt(ALT_DESCRIPTION) + addUnique(NameTag.assemble(name)) + addUnique(CreatorTag.assemble(creatorPublicKey)) + + // Sorted, because the id is a hash over the tags as emitted and a + // multi-select hands its selection over in no particular order -- + // the same reason DkgSession.participantPublicKeys sorts. + // + // addUniqueValueIfNew, never addUnique: addUnique keeps one tag per + // *name*, so three owners would come out as one and the loss is + // silent. Deduping on the pubkey is the right thing for a set. + ownerPublicKeys.sorted().forEach { ownerPublicKey -> + addUniqueValueIfNew( + ContributorTag.assemble(ownerPublicKey, ContributorTag.ROLE_OWNER) + ) + } + + initializer() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectTranslationArtifactVersionEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectTranslationArtifactVersionEvent.kt new file mode 100644 index 00000000..d2119485 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectTranslationArtifactVersionEvent.kt @@ -0,0 +1,67 @@ +package press.mantra.compose.nostr.nip30303 + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils +import press.mantra.compose.nostr.nip30303.tags.ProjectIdTag +import press.mantra.compose.nostr.nip30303.tags.TranslationArtifactVersionIdTag + + +/** + * One translation is part of one project. + * + * The other half of [ProjectArtifactVersionEvent], and the same shape for the + * same reasons -- see there. + */ +@Immutable +class ProjectTranslationArtifactVersionEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + + fun projectIdReference() = tags.firstNotNullOfOrNull(ProjectIdTag::parse)?.ref + fun projectId() = projectIdReference()?.eventId + + fun translationArtifactVersionIdReference() = + tags.firstNotNullOfOrNull(TranslationArtifactVersionIdTag::parse)?.ref + fun translationArtifactVersionId() = translationArtifactVersionIdReference()?.eventId + + companion object { + const val KIND = 30332 + const val ALT_DESCRIPTION = "ProjectTranslationArtifactVersion" + + /** The translations [project] starts life with. See the sibling's note. */ + fun membershipsOf( + project: Event, + translationArtifactVersionIds: List, + ): List> = + translationArtifactVersionIds.map { translationArtifactVersionId -> + build( + projectId = project.id, + translationArtifactVersionId = translationArtifactVersionId, + createdAt = project.createdAt, + ) + } + + fun build( + projectId: HexKey, + translationArtifactVersionId: HexKey, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + addUnique(ProjectIdTag.assemble(projectId)) + addUnique(TranslationArtifactVersionIdTag.assemble(translationArtifactVersionId)) + initializer() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/ContributorTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/ContributorTag.kt index d73e9e3e..f01ffdbf 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/ContributorTag.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/ContributorTag.kt @@ -20,7 +20,27 @@ class ContributorTag( const val ROLE_PROOF_READER = "proofreader" const val ROLE_EDITOR = "editor" - private val KNOWN_ROLES = setOf(ROLE_TRANSLATOR, ROLE_PROOF_READER, ROLE_EDITOR) + /** + * Whoever the work belongs to, which need not be whoever is doing it. + * + * A project carries one of these per owner -- the room's own key when the + * group owns what it makes, and somebody else's when it does not. There + * is no matching creator role: a project has exactly one creator and a + * single-valued field gets a tag of its own, which is `CreatorTag`. + */ + const val ROLE_OWNER = "owner" + + // Four, where NIP-F4 defines host/cohost/editor. A role missing from this + // set is the quietest bug in the family: `parse` returns the tag with a + // null role, the string stays inside the signed event id, the event still + // verifies, and whatever was reading for that role finds nothing. Add to + // both halves or to neither. + private val KNOWN_ROLES = setOf( + ROLE_TRANSLATOR, + ROLE_PROOF_READER, + ROLE_EDITOR, + ROLE_OWNER, + ) fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64 diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/CreatorTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/CreatorTag.kt new file mode 100644 index 00000000..a3868e74 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/CreatorTag.kt @@ -0,0 +1,44 @@ +package press.mantra.compose.nostr.nip30303.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * Whoever started the thing this tag is on. + * + * A tag of its own rather than a [ContributorTag] with a creator role, because + * there is exactly one creator and a single-valued field is what every other tag + * in this package is. The owners are the other shape -- a set, open-ended, and so + * [ContributorTag] with [ContributorTag.ROLE_OWNER]. + * + * The name is multi-character on purpose. NIP-01 indexes single-letter tag names + * and makes them queryable, so a `p` tag is an address: relays index it and a + * client may read it as a mention. These events live inside a Marmot group and + * are never on a relay as themselves, but `BroadcastGroupSignedEventScreen` can + * put one there, and a creator should travel in that as data rather than as a + * recipient. + */ +class CreatorTag( + val pubKey: HexKey, +) { + fun toTagArray() = assemble(pubKey = pubKey) + + companion object { + const val TAG_NAME = "creator" + + fun parse(tag: Array): CreatorTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + // Without this a malformed value parses, lands on the row, and names + // nobody. ContributorTag checks the same thing for the same reason. + ensure(tag[1].length == 64) { return null } + + return CreatorTag(pubKey = tag[1]) + } + + fun assemble(pubKey: HexKey): Array = arrayOf(TAG_NAME, pubKey) + + fun assemble(creatorTag: CreatorTag) = assemble(pubKey = creatorTag.pubKey) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/ProjectIdTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/ProjectIdTag.kt new file mode 100644 index 00000000..a5817693 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/ProjectIdTag.kt @@ -0,0 +1,44 @@ +package press.mantra.compose.nostr.nip30303.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.tags.events.EventReference +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +data class ProjectIdTag( + val ref: EventReference, +) { + constructor(eventId: String, relayHint: NormalizedRelayUrl?, pubkey: String?) : this( + EventReference(eventId, pubkey, relayHint) + ) + + companion object { + // Plain, like `artifactId`. Six tags in this package carry a stray `Tag` + // suffix in the wire value -- `chapterIdTag` and friends -- which is a + // copy-paste slip and is unfixable: those strings are inside event ids + // the group has signed, so renaming one invalidates every signature over + // it. Do not copy it and do not tidy it. + const val TAG_NAME = "projectId" + + fun parse(tag: Array): ProjectIdTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + + val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return ProjectIdTag(tag[1], relayHint, tag.getOrNull(3)) + } + + fun assemble( + eventId: HexKey, + relay: NormalizedRelayUrl? = null, + pubkey: String? = null, + ) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, pubkey) + + fun assemble(ref: EventReference) = assemble(ref.eventId, ref.relayHint, ref.author) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEvent.kt index 9c1f8155..0554151d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEvent.kt @@ -170,15 +170,7 @@ object SubgroupBirthCertificateEvent { subgroupChatRoomId: String, parentChatRoomId: String ): Boolean = runCatching { - if (event.kind != KIND) return false - - if (!event.content.trim().equals(subgroupChatRoomId, ignoreCase = true)) return false - - val addressed = parseSubgroupChatRoomId(event.tags) ?: return false - if (!addressed.equals(subgroupChatRoomId, ignoreCase = true)) return false - - val named = parseParentChatRoomId(event.tags) ?: return false - if (!named.equals(parentChatRoomId, ignoreCase = true)) return false + if (!addresses(event, subgroupChatRoomId, parentChatRoomId)) return false val thresholdPublicKey = parseThresholdPublicKey(event.tags) ?: return false val path = parsePath(event.tags) ?: return false @@ -189,6 +181,38 @@ object SubgroupBirthCertificateEvent { GroupKeyStateEvent.isSignedByRoom(event, parentChatRoomId) }.getOrDefault(false) + /** + * Whether this event is *addressed to* that child by that parent -- the + * half of [certifies] that can be asked of a proposal nobody has signed. + * + * **Not a weaker [certifies].** It says who the event is about, not that + * the parent's quorum agreed to it: no derivation and no signature, so an + * event that merely claims the right pair passes. Nothing may decide a + * lineage on this. + * + * What it is for is telling one *in-flight* certificate signing from + * another in the same room. A parent certifies every subgroup it ever makes + * from one room, so the ceremony screen has several to choose between and + * no signature to choose by -- the proposal is what the quorum has not yet + * signed. The three values it reads are assembled together by + * [assembleTags], so they agree by construction. + */ + fun addresses( + event: Event, + subgroupChatRoomId: String, + parentChatRoomId: String + ): Boolean = runCatching { + if (event.kind != KIND) return false + + if (!event.content.trim().equals(subgroupChatRoomId, ignoreCase = true)) return false + + val addressed = parseSubgroupChatRoomId(event.tags) ?: return false + if (!addressed.equals(subgroupChatRoomId, ignoreCase = true)) return false + + val named = parseParentChatRoomId(event.tags) ?: return false + named.equals(parentChatRoomId, ignoreCase = true) + }.getOrDefault(false) + /** * The name tag. A bare two-element tag with no class of its own, because * nothing but the proposal screen and the subgroup list ever reads it and diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt index 319e3782..9dc7189f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -19,7 +19,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf interface ChatRepository { suspend fun observeChatRoomListByPublicKey(publicKey: String): Flow> @@ -78,8 +78,12 @@ interface ChatRepository { * may be adopted by another later. A member welcomed in after the founding * holds no state and gets an empty list even when the room really is a * subgroup. + * + * Each carries the parent's room where this device has one, which is the + * only place its name can come from -- a certificate names the child and + * never the certifier. */ - suspend fun parentsOf(chatRoomId: String): List + suspend fun parentsOf(chatRoomId: String): List /** * One event the group signed, as the group signed it, or null when this @@ -308,7 +312,13 @@ interface ChatRepository { ): MarmotReindexReport companion object { - val NO_OP_CHAT_REPOSITORY = object: ChatRepository { + /** + * A chat repository with no database behind it: every room is absent, every + * membership change is refused with a reason, and no member throws. See + * [NostrRepository.NO_OP_NOSTR_REPOSITORY] for why a `TODO()` here is a silent + * misattribution rather than a loud failure, and for the rules a new member follows. + */ + val NO_OP_CHAT_REPOSITORY: ChatRepository = object : ChatRepository { override suspend fun requestGroupHistoryIfMissing( chatRoomId: String, userPublicKey: HexKey @@ -323,7 +333,9 @@ interface ChatRepository { override suspend fun subgroupsOf(chatRoomId: String): List = emptyList() - override suspend fun parentsOf(chatRoomId: String): List = emptyList() + override suspend fun parentsOf( + chatRoomId: String + ): List = emptyList() override suspend fun groupSignedEvent( chatRoomId: String, @@ -338,9 +350,8 @@ interface ChatRepository { coordinatorPublicKey: HexKey, ): String? = "No database" - override suspend fun observeChatRoomListByPublicKey(publicKey: String): Flow> { - return flow { } - } + override suspend fun observeChatRoomListByPublicKey(publicKey: String): Flow> = + flowOf(emptyList()) override suspend fun getChatRoomListByPublicKey(publicKey: String): List { return emptyList() @@ -375,22 +386,18 @@ interface ChatRepository { userPublicKey: HexKey ): Boolean = false - override suspend fun getAllParticipantsWithPubKey(publicKey: HexKey): List { - TODO("Not yet implemented") - } + override suspend fun getAllParticipantsWithPubKey(publicKey: HexKey): List = + emptyList() - override suspend fun findDirectMessageRoom(userPublicKey: HexKey, peerPublicKey: HexKey): LocalChatRoom? { - TODO("Not yet implemented") - } + override suspend fun findDirectMessageRoom(userPublicKey: HexKey, peerPublicKey: HexKey): LocalChatRoom? = + null override suspend fun getChatMessageListByChatRoomId(chatRoomId: String): List { return emptyList() } - override suspend fun observeChatMessageListByChatRoomId(chatRoomId: String): Flow> { - return flow { - } - } + override suspend fun observeChatMessageListByChatRoomId(chatRoomId: String): Flow> = + flowOf(emptyList()) override suspend fun getOrCreateChatRoom( chatRoomId: String, @@ -407,13 +414,11 @@ interface ChatRepository { return null } - override suspend fun getMarmotKeyPackageForPublicKey(publicKey: HexKey): MarmotKeyPackage? { - TODO("Not yet implemented") - } + override suspend fun getMarmotKeyPackageForPublicKey(publicKey: HexKey): MarmotKeyPackage? = + null - override suspend fun observeMarmotKeyPackageForPublicKey(publicKey: HexKey): Flow { - TODO("Not yet implemented") - } + override suspend fun observeMarmotKeyPackageForPublicKey(publicKey: HexKey): Flow = + flowOf(null) override suspend fun createNip17ChatRoom( userPublicKey: HexKey, @@ -424,23 +429,30 @@ interface ChatRepository { return null } + /** + * The empty id, meaning no room was created. + * + * The only member here whose return type cannot say "nothing": it owes the id + * of a room, and `String` has no absent value. Both creators beside it can say + * they failed -- [createNip17ChatRoom] returns null, [createMarmotGroup] an + * [MarmotGroupCreation.Outcome] -- so this one's signature is the odd one out + * rather than this body being the odd answer. Nothing reaches it in practice: + * `ChatRoomMessagingViewModel.initiateNewChat` calls it only once a profile and + * a key package have both resolved, and against this object neither ever does. + */ override suspend fun createMlsDirectMessage( name: String?, description: String?, userPublicKey: HexKey, peerPublicKey: HexKey, peerKeyPackage: MarmotKeyPackage - ): String { - TODO("Not yet implemented") - } + ): String = "" override suspend fun inviteMember( localChatRoom: LocalChatRoom, peerPublicKey: HexKey, peerKeyPackage: MarmotKeyPackage - ) { - TODO("Not yet implemented") - } + ) = Unit override suspend fun addMembers( localChatRoom: LocalChatRoom, @@ -464,45 +476,36 @@ interface ChatRepository { localChatRoom: LocalChatRoom, messageType: Kind, directMessageRecipientPublicKey: HexKey? - ) { - // TODO("Not yet implemented") - } + ) = Unit override fun updateChatRoomSubject(publicKey: String, subject: String): ChatRoom? { return null } - override suspend fun observeUnsealedGiftWrapPayloads(publicKey: HexKey): Flow> { - TODO("Not yet implemented") - } + override suspend fun observeUnsealedGiftWrapPayloads(publicKey: HexKey): Flow> = + flowOf(emptyList()) override suspend fun sealGiftWrapPayload( giftWrapPayload: GiftWrapPayload, nostrSignerSync: NostrSignerSync - ) { - TODO("Not yet implemented") - } + ) = Unit + // None of the three calls onCompletion: nothing was left or deleted, and each + // of these callbacks navigates away from a room that is still there. override suspend fun leaveChatRoom( localChatRoom: LocalChatRoom, onCompletion: () -> Unit - ) { - TODO("Not yet implemented") - } + ) = Unit override suspend fun softDeleteChatRoom( chatRoom: ChatRoom, onCompletion: () -> Unit - ) { - TODO("Not yet implemented") - } + ) = Unit override suspend fun deleteChatRoom( chatRoom: ChatRoom, onCompletion: () -> Unit - ) { - TODO("Not yet implemented") - } + ) = Unit override suspend fun reindexMarmotGroupEvents( chatRoomId: String, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt index 1e6c16d6..c61a9e85 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt @@ -65,6 +65,19 @@ interface DkgRepository { suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession? + /** + * The id of the room a ceremony's key derives, or null while it has no key. + * + * The one name a ceremony and the room it makes have in common, and the only + * way to tell two of a parent's subgroups apart in anything written about + * the room rather than about the ceremony -- a birth certificate names the + * child, not the ceremony that produced it. + * + * Null is the ordinary answer rather than a failure: a ceremony has no key + * until it finishes, and every caller here is watching from before that. + */ + suspend fun roomDerivedBy(dkgSessionId: String): String? + /** * Opens a ritual in [localChatRoom], making this device the coordinator. * @@ -213,6 +226,8 @@ interface DkgRepository { override suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession? = null + override suspend fun roomDerivedBy(dkgSessionId: String): String? = null + override suspend fun proposeRitual( localChatRoom: LocalChatRoom, userPublicKey: HexKey, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt index 677998d6..3d197ad5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt @@ -6,6 +6,7 @@ import press.mantra.compose.database.model.MantraArtifactVersion import press.mantra.compose.database.model.MantraChapter import press.mantra.compose.database.model.MantraChunk import press.mantra.compose.database.model.MantraDialect +import press.mantra.compose.database.model.MantraProject import press.mantra.compose.database.model.MantraTranslationArtifactVersion import press.mantra.compose.database.model.MantraTranslationChapter import press.mantra.compose.database.model.MantraTranslationChunk @@ -56,6 +57,31 @@ interface MantraRepository { suspend fun getDialect(id: String): MantraDialect? + suspend fun getProjects(chatRoomId: String): List + + suspend fun getProject(id: String): MantraProject? + + /** + * The source versions a project is translating from. + * + * The versions, not the join rows: no screen wants a join row, and resolving + * it here is one indexed query instead of an N+1 in a view model. + */ + suspend fun getProjectArtifactVersions(projectId: String): List + + /** The translations a project is producing. */ + suspend fun getProjectTranslationArtifactVersions( + projectId: String + ): List + + /** The inverse: which projects a source version is part of. */ + suspend fun getProjectsForArtifactVersion(artifactVersionId: String): List + + /** The inverse again, for a translation. */ + suspend fun getProjectsForTranslationArtifactVersion( + translationArtifactVersionId: String + ): List + suspend fun addArtifactVersion( localChatRoom: LocalChatRoom, artifactId: HexKey, @@ -102,6 +128,26 @@ interface MantraRepository { override suspend fun getDialect(id: String): MantraDialect? = null + override suspend fun getProjects(chatRoomId: String): List = emptyList() + + override suspend fun getProject(id: String): MantraProject? = null + + override suspend fun getProjectArtifactVersions( + projectId: String + ): List = emptyList() + + override suspend fun getProjectTranslationArtifactVersions( + projectId: String + ): List = emptyList() + + override suspend fun getProjectsForArtifactVersion( + artifactVersionId: String + ): List = emptyList() + + override suspend fun getProjectsForTranslationArtifactVersion( + translationArtifactVersionId: String + ): List = emptyList() + override suspend fun addArtifactVersion( localChatRoom: LocalChatRoom, artifactId: HexKey, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MarmotRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MarmotRepository.kt index c9875665..0f9af0d5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MarmotRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MarmotRepository.kt @@ -5,6 +5,7 @@ import press.mantra.compose.database.model.MarmotKeyPackageBundle import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf interface MarmotRepository { @@ -33,40 +34,35 @@ interface MarmotRepository { suspend fun encryptAndSendMarmotInnerEvent(marmotInnerEvent: MarmotInnerEvent, nostrSignerSync: NostrSignerSync) companion object { - val NO_OP_MARMOT_KEY_PACKAGE_BUNDLE = object: MarmotRepository { + /** + * A repository with no key packages behind it: inert, and no member throws. See + * [NostrRepository.NO_OP_NOSTR_REPOSITORY] for why a `TODO()` here is a silent + * misattribution rather than a loud failure, and for the rules a new member follows. + */ + val NO_OP_MARMOT_KEY_PACKAGE_BUNDLE: MarmotRepository = object : MarmotRepository { override suspend fun publishMarmotKeyPackageBundle( publicKey: HexKey, nsecPassword: String - ) { - TODO("Not yet implemented") - } + ) = Unit - override suspend fun rotateMarmotKeyPackageBundle(marmotKeyPackageBundle: MarmotKeyPackageBundle) { - TODO("Not yet implemented") - } + override suspend fun rotateMarmotKeyPackageBundle(marmotKeyPackageBundle: MarmotKeyPackageBundle) = Unit - override suspend fun observeMarmotKeyPackageBundles(publicKey: HexKey): Flow> { - TODO("Not yet implemented") - } + override suspend fun observeMarmotKeyPackageBundles(publicKey: HexKey): Flow> = + flowOf(emptyList()) - override suspend fun getMarmotKeyPackageBundles(publicKey: HexKey): List { - return emptyList() - } + override suspend fun getMarmotKeyPackageBundles(publicKey: HexKey): List = + emptyList() - override suspend fun observeUnprocessedMarmotInnerEvents(publicKey: HexKey): Flow> { - TODO("Not yet implemented") - } + override suspend fun observeUnprocessedMarmotInnerEvents(publicKey: HexKey): Flow> = + flowOf(emptyList()) - override suspend fun observeActiveMarmotKeyPackageBundle(publicKey: HexKey): Flow { - TODO("Not yet implemented") - } + override suspend fun observeActiveMarmotKeyPackageBundle(publicKey: HexKey): Flow = + flowOf(null) override suspend fun encryptAndSendMarmotInnerEvent( marmotInnerEvent: MarmotInnerEvent, nostrSignerSync: NostrSignerSync - ) { - TODO("Not yet implemented") - } + ) = Unit } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt index 4e8a9805..0a8d6432 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt @@ -20,6 +20,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf interface NostrRepository { suspend fun getLocalAccounts(): List @@ -209,78 +210,97 @@ interface NostrRepository { suspend fun rescheduleBroadcastNostrEventRequests(broadcastNostrEventRequests: List) companion object { - val NO_OP_NOSTR_REPOSITORY = object : NostrRepository { - override suspend fun getLocalAccounts(): List { - TODO("Not yet implemented") - } + /** + * A repository with nothing behind it: every question answers "nothing here", + * every write goes nowhere, and **no member throws**. + * + * Previews and test fixtures are the only callers, and both want inertness. It is + * written out here because these members were once `TODO()`, and a `TODO()` in a + * no-op is not a loud failure -- it is a silent misattribution. Callers reach most + * of them from a `viewModelScope.launch(Dispatchers.IO)` that nobody awaits, so the + * `NotImplementedError` escapes to the platform handler, is stashed by + * kotlinx-coroutines-test's `ExceptionCollector`, and is re-thrown as + * `UncaughtExceptionsBeforeTest` inside *the next* `runTest` -- under that test's + * name, with the real culprit buried as a suppressed cause. One `TODO()` here + * (`queueNegentropySynchronizeRequest`, reached from + * `ChatMessageListViewModel.scheduleSynchronization`) failed `NavigationRoutingTest` + * four times a run for a call that test never made. + * + * So the rules for a member added here, which the other three no-op repositories + * follow too: + * + * - **Answer the call, never stub it out.** `NoOpRepositoryTotalityJvmTest` reads + * these four objects and fails if an unimplemented stub reappears in one. + * - **A flow emits its empty value once** -- `flowOf(null)`, `flowOf(emptyList())` -- + * rather than `flow { }`. A screen collecting an empty flow waits in Loading + * forever; one collecting `flowOf(null)` renders its empty state, which is what + * a preview is for. + * - **A callback is not invoked.** Nothing happened, and calling `onCompletion` or + * `onSuccess` would say it did -- letting a test pass on the fixture rather than + * on the code under test. + * - **Throwing is never the way to be loud.** A member that a test must not call is + * the test's assertion to make, not the fixture's: override it with a recorder and + * assert it stayed untouched. An exception thrown here lands on whichever test + * runs next. + * + * A test that needs a real answer from one member overrides just that one: + * `object : NostrRepository by NO_OP_NOSTR_REPOSITORY { … }`. + */ + val NO_OP_NOSTR_REPOSITORY: NostrRepository = object : NostrRepository { + override suspend fun getLocalAccounts(): List = emptyList() - override suspend fun observeLocalAccount(publicKey: HexKey): Flow { - TODO("Not yet implemented") - } + override suspend fun observeLocalAccount(publicKey: HexKey): Flow = + flowOf(null) - override suspend fun getProfileWithPublicKey(publicKey: HexKey): Profile? { - TODO("Not yet implemented") - } + override suspend fun getProfileWithPublicKey(publicKey: HexKey): Profile? = null - override suspend fun observeProfileWithPublicKey(publicKey: HexKey): Flow { - TODO("Not yet implemented") - } + override suspend fun observeProfileWithPublicKey(publicKey: HexKey): Flow = + flowOf(null) override suspend fun observeRelation( publicKey: HexKey, activePublicKey: HexKey - ): Flow> { - TODO("Not yet implemented") - } + ): Flow> = flowOf(emptyList()) - override suspend fun deleteConnection(connection: Connection) { - TODO("Not yet implemented") - } + override suspend fun deleteConnection(connection: Connection) = Unit - override suspend fun observeUnsignedNostrEvents(publicKey: HexKey): Flow> { - TODO("Not yet implemented") - } + override suspend fun observeUnsignedNostrEvents(publicKey: HexKey): Flow> = + flowOf(emptyList()) - override suspend fun observePendingBroadcastNostrEventRequests(): Flow { - TODO("Not yet implemented") - } + override suspend fun observePendingBroadcastNostrEventRequests(): Flow = + flowOf(null) override suspend fun reopenInbox(activeKeyPair: KeyPair): Int = 0 - override suspend fun observePendingSynchronizeNostrEventRequests(ownerPublicKey: HexKey): Flow { - TODO("Not yet implemented") - } + override suspend fun observePendingSynchronizeNostrEventRequests(ownerPublicKey: HexKey): Flow = + flowOf(null) - override suspend fun observePendingNegentropySynchronizeRequests(ownerPublicKey: HexKey): Flow { - TODO("Not yet implemented") - } + override suspend fun observePendingNegentropySynchronizeRequests(ownerPublicKey: HexKey): Flow = + flowOf(null) + // onCompletion is deliberately not called: no profile was created, and a + // fixture that reports completion is a fixture a test can pass against. override suspend fun createNewProfile( publicKey: HexKey, name: String?, biography: String?, onCompletion: suspend () -> Unit - ) { - TODO("") - } + ) = Unit - override suspend fun signInToProfile(publicKey: HexKey) { - TODO("Not yet implemented") - } + override suspend fun signInToProfile(publicKey: HexKey) = Unit override suspend fun setUpProfileForExistingKey( publicKey: HexKey, placeholderUnsignedNostrEventId: Long, name: String?, biography: String?, - ) { - TODO("Not yet implemented") - } + ) = Unit - override suspend fun forgetLocalAccount(publicKey: HexKey) { - TODO("Not yet implemented") - } + override suspend fun forgetLocalAccount(publicKey: HexKey) = Unit + // Neither onSuccess nor onError: the note was not written, and it did not fail + // to be written either -- there was nowhere to write it. A test that wants + // either branch overrides this member and calls the one it is testing. override suspend fun createNewTextNote( publicKey: HexKey, mentionedPublicKeys: List, @@ -289,74 +309,50 @@ interface NostrRepository { quotedNostrEvent: LocalNostrEvent?, onError: () -> Unit, onSuccess: () -> Unit - ) { - TODO("Not yet implemented") - } + ) = Unit - override suspend fun saveUnsignedNostrEvent(unsignedNostrEvent: UnsignedNostrEvent): UnsignedNostrEvent? { - return null - } + override suspend fun saveUnsignedNostrEvent(unsignedNostrEvent: UnsignedNostrEvent): UnsignedNostrEvent? = + null - override suspend fun saveUnsignedNostrEvents(unsignedNostrEvents: List) { - TODO("Not yet implemented") - } + override suspend fun saveUnsignedNostrEvents(unsignedNostrEvents: List) = Unit - override suspend fun wipeDatabase() { - } + override suspend fun wipeDatabase() = Unit override suspend fun publishNostrEvent( unsignedNostrEvent: UnsignedNostrEvent, nostrEvent: NostrEvent, relayURLs: List, activeKeyPair: KeyPair - ) { - - } + ) = Unit override suspend fun broadcastProcessed( broadcastNostrEventRequest: BroadcastNostrEventRequest, status: String - ) { - TODO("Not yet implemented") - } + ) = Unit - override suspend fun requeueStaleBroadcastNostrEventRequests(): Int { - TODO("Not yet implemented") - } + override suspend fun requeueStaleBroadcastNostrEventRequests(): Int = 0 - override suspend fun synchronizeNostrEventRequestProcessed(synchronizeNostrEventRequest: SynchronizeNostrEventRequest) { - TODO("Not yet implemented") - } + override suspend fun synchronizeNostrEventRequestProcessed(synchronizeNostrEventRequest: SynchronizeNostrEventRequest) = Unit - override suspend fun synchronizeNostrEventRequestCompleted(synchronizeNostrEventRequest: SynchronizeNostrEventRequest) { - TODO("Not yet implemented") - } + override suspend fun synchronizeNostrEventRequestCompleted(synchronizeNostrEventRequest: SynchronizeNostrEventRequest) = Unit - override suspend fun negentropySynchronizeRequestProcessed(negentropySynchronizeRequest: NegentropySynchronizeRequest) { - TODO("Not yet implemented") - } + override suspend fun negentropySynchronizeRequestProcessed(negentropySynchronizeRequest: NegentropySynchronizeRequest) = Unit - override suspend fun saveBroadcastReceipt(broadcastNostrEventReceipt: BroadcastNostrEventReceipt) { - TODO("Not yet implemented") - } + override suspend fun saveBroadcastReceipt(broadcastNostrEventReceipt: BroadcastNostrEventReceipt) = Unit override suspend fun saveNostrEvent( nostrEvent: NostrEvent, synchronizeNostrEventRequest: SynchronizeNostrEventRequest, synchronizationRelayURLs: List, activeKeyPair: KeyPair - ) { - TODO("Not yet implemented") - } + ) = Unit override suspend fun saveNostrEvent( nostrEvent: NostrEvent, negentropySynchronizeRequest: NegentropySynchronizeRequest, synchronizationRelayURLs: List, activeKeyPair: KeyPair - ) { - TODO("Not yet implemented") - } + ) = Unit override suspend fun saveNostrEvent( nostrEvent: NostrEvent, @@ -364,82 +360,54 @@ interface NostrRepository { synchronizationRelayURLs: List, level: Int, activeKeyPair: KeyPair - ) { - TODO("Not yet implemented") - } + ) = Unit - override suspend fun queueSynchronizeNostrEvent(synchronizeNostrEventRequests: List) { - TODO("Not yet implemented") - } + override suspend fun queueSynchronizeNostrEvent(synchronizeNostrEventRequests: List) = Unit - override suspend fun queueNegentropySynchronizeRequest(negentropySynchronizeRequests: List) { - TODO("Not yet implemented") - } + override suspend fun queueNegentropySynchronizeRequest(negentropySynchronizeRequests: List) = Unit - override suspend fun observeSynchronizeNostrEventRequestByPurposeAndStatusCount(purpose: String): Flow { - TODO("Not yet implemented") - } + override suspend fun observeSynchronizeNostrEventRequestByPurposeAndStatusCount(purpose: String): Flow = + flowOf(0) override suspend fun getNostrEvent( publicKey: HexKey, kind: Kind - ): NostrEvent? { - TODO("Not yet implemented") - } + ): NostrEvent? = null - override suspend fun observeNostrFeed(): Flow> { - TODO("Not yet implemented") - } + override suspend fun observeNostrFeed(): Flow> = flowOf(emptyList()) - override suspend fun observeNostrFeed(synchronizationFilter: SynchronizationFilter): Flow> { - TODO("Not yet implemented") - } + override suspend fun observeNostrFeed(synchronizationFilter: SynchronizationFilter): Flow> = + flowOf(emptyList()) override suspend fun getNegentropicNostrFeedIds( synchronizationFilters: Array, applyLimits: Boolean - ): List { - TODO("Not yet implemented") - } + ): List = emptyList() - override suspend fun observeLocalNostrEventById(nostrEventId: String): Flow { - TODO("Not yet implemented") - } + override suspend fun observeLocalNostrEventById(nostrEventId: String): Flow = + flowOf(null) - override suspend fun searchableProfiles(excludingPublicKeys: Array): List { - TODO("Not yet implemented") - } + override suspend fun searchableProfiles(excludingPublicKeys: Array): List = + emptyList() - override suspend fun filterProfiles(searchTerm: String): List { - TODO("Not yet implemented") - } + override suspend fun filterProfiles(searchTerm: String): List = emptyList() - override suspend fun observeProfileWithFollowers(publicKey: String): Flow { - TODO("Not yet implemented") - } + override suspend fun observeProfileWithFollowers(publicKey: String): Flow = + flowOf(null) - override suspend fun observeProfileWithFollowing(publicKey: String): Flow { - TODO("Not yet implemented") - } + override suspend fun observeProfileWithFollowing(publicKey: String): Flow = + flowOf(null) - override suspend fun getRecentSearches(): List { - TODO("Not yet implemented") - } + override suspend fun getRecentSearches(): List = emptyList() - override suspend fun removeRecentSearch(recentSearch: RecentSearch) { - TODO("Not yet implemented") - } + override suspend fun removeRecentSearch(recentSearch: RecentSearch) = Unit override suspend fun saveSearchQuery( query: String, synchronizationFilterArray: press.mantra.compose.database.model.typealiases.SynchronizationFilterArray? - ) { - TODO("Not yet implemented") - } + ) = Unit - override suspend fun rescheduleBroadcastNostrEventRequests(broadcastNostrEventRequests: List) { - TODO("Not yet implemented") - } + override suspend fun rescheduleBroadcastNostrEventRequests(broadcastNostrEventRequests: List) = Unit } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/SearchRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/SearchRepository.kt index 71aeb2b3..4e07e9d3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/SearchRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/SearchRepository.kt @@ -7,10 +7,15 @@ interface SearchRepository { ) companion object { - val NO_OP_SEARCH_REPOSITORY = object: SearchRepository { - override suspend fun submitSearch(synchronizeNostrEventRequests: List) { - TODO("Not yet implemented") - } + /** + * A search that reaches no relay: inert, and the one member does not throw. See + * [NostrRepository.NO_OP_NOSTR_REPOSITORY] for why a `TODO()` here is a silent + * misattribution rather than a loud failure. + */ + val NO_OP_SEARCH_REPOSITORY: SearchRepository = object : SearchRepository { + override suspend fun submitSearch( + synchronizeNostrEventRequests: List + ) = Unit } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ChapterNaming.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ChapterNaming.kt new file mode 100644 index 00000000..f992335e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ChapterNaming.kt @@ -0,0 +1,66 @@ +package press.mantra.compose.text + +/** + * What to call a chapter that arrived as a file. + * + * The web app this mirrors has two answers and uses a different one per route: + * the upload names a chapter `file.name` and the paste form names it + * `chunks[0].trim()`. Both are right some of the time. `chapter-01.md` is a + * filename, not a title, and a chapter whose first paragraph is prose has a + * better name in its own text; a chapter whose file is called + * `03 - The Wreck of the Hesperus.md` does not. + * + * So: the filename first, because somebody chose it, and the text only when the + * filename says nothing a reader would want -- and a heading in the text before + * a paragraph of it, because a heading is a title and a paragraph is the + * beginning of the work. + * + * Whatever this returns is a **default in an editable field**, never the final + * answer. A quorum is asked to sign the name along with the chapter, so the + * person uploading has to be able to fix it first. + */ +object ChapterNaming { + + /** Past this, a name is the beginning of a paragraph rather than a title. */ + private const val MAX_DERIVED_LENGTH = 80 + + private val LEADING_ORDINAL = Regex("^[\\s\\-_.0-9]+") + + fun forFile(fileName: String, markdown: String): String = + fromFileName(fileName) ?: fromMarkdown(markdown) ?: "" + + /** + * The filename with its extension and its ordinal prefix removed. + * + * `03 - The Wreck.md` is `The Wreck`; `chapter-01.md` is `chapter`, which is + * a poor title but an honest reflection of a poor filename and is the one + * the person will edit. What is dropped entirely is a name that is *only* + * an ordinal -- `01.md`, `1.txt` -- because "01" as a chapter title is worse + * than falling through to the text. + */ + private fun fromFileName(fileName: String): String? = + fileName.substringBeforeLast('.', fileName) + .replace('_', ' ') + .replace(LEADING_ORDINAL, "") + .trim() + .takeIf { it.isNotEmpty() } + + /** + * The first heading in the text, or failing that its first line. + * + * Reads the markdown rather than [Markdown]'s split, because this runs while + * a file is being looked at rather than while a chapter is being proposed, + * and a heading's `#` has to be stripped either way. + */ + private fun fromMarkdown(markdown: String): String? { + val lines = markdown.lineSequence().map { it.trim() }.filter { it.isNotEmpty() } + + val heading = lines.firstOrNull { it.startsWith("#") } + ?.trimStart('#') + ?.trim() + ?.takeIf { it.isNotEmpty() } + if (heading != null) return heading.take(MAX_DERIVED_LENGTH) + + return lines.firstOrNull()?.take(MAX_DERIVED_LENGTH) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ChapterText.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ChapterText.kt new file mode 100644 index 00000000..1f281deb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ChapterText.kt @@ -0,0 +1,97 @@ +package press.mantra.compose.text + +/** + * Turning the bytes of a picked file into the markdown a chapter is made of. + * + * Deliberately not `bytes.decodeToString()`, which is what the web app this + * mirrors does (`file.data.toString()` in `server/router/versions/index.js`). + * There, a `.docx` dropped into the form becomes a chapter of zip-header + * garbage that the uploader can go back and delete. Here the next thing that + * happens to this text is that a quorum is asked to sign it, so what cannot be + * read as prose has to be refused at the door rather than corrected afterwards. + * + * All four rules below exist because of that one difference. None of them is + * about being tidy; each is about something that would otherwise be permanent. + */ +object ChapterText { + + /** + * The most bytes a picked file may be. + * + * A crash guard, not the real limit -- what a chapter may actually carry is + * bounded by what one signing proposal can put on the wire, which is far + * tighter. This is here so that picking a video by mistake fails + * on a comparison rather than on a megabyte-counting read, and so the + * platform pickers have a number to stop reading at. + */ + const val MAX_FILE_BYTES: Int = 1 * 1024 * 1024 + + /** How far into a file to look for the NUL that says it is not text. */ + private const val SNIFF_BYTES: Int = 8 * 1024 + + sealed interface Result { + /** Decoded, BOM-stripped, newline-normalised markdown. */ + data class Text(val markdown: String) : Result + + /** Not decodable as UTF-8, or carrying a NUL. */ + data object NotText : Result + + /** Over [MAX_FILE_BYTES]. */ + data object TooLarge : Result + } + + fun decode(bytes: ByteArray): Result { + if (bytes.size > MAX_FILE_BYTES) return Result.TooLarge + + // Sniffed before decoding, not after. A file of UTF-16 text decodes as + // invalid UTF-8 and would be refused anyway, but a file of mostly-ASCII + // binary -- a zip's local header, a PDF's preamble -- can decode + // cleanly and only reads as prose if nobody looks for the NUL. + if (hasNul(bytes)) return Result.NotText + + // Strict, so an invalid sequence is refused rather than replaced. A + // lenient decode substitutes U+FFFD, which is invisible in a text + // field, is signed into the chapter's `original_text` tag, and is then + // part of the group's record of the work for good. + val decoded = runCatching { + bytes.decodeToString(throwOnInvalidSequence = true) + }.getOrElse { return Result.NotText } + + return Result.Text(normalise(decoded)) + } + + /** + * The form of a chapter's markdown that is safe to hash. + * + * Two rewrites, both of which change the text's bytes without changing a + * word of it, which is exactly why they belong here rather than at a call + * site: + * + * **The byte-order mark goes.** A leading `U+FEFF` is invisible in the form + * and in the chapter, and it is part of the first chunk's text -- so the + * same opening paragraph saved by two editors is two different chunk ids, + * and a translation of one does not match the other. + * + * **CRLF becomes LF.** [Markdown]'s paragraph rule already tolerates `\r\n`, + * so splitting is unaffected; what is affected is the `original_text` tag, + * which would carry the carriage returns. The same chapter uploaded from + * Windows and from Linux would be two different events over identical + * prose. A lone `\r` -- classic Mac line endings, and what some converters + * still emit -- is normalised too, for the same reason. + * + * Public because the duplicate check on an upload compares chapters by + * their text, and it has to compare the form that will be signed. + */ + fun normalise(text: String): String = + text.removePrefix("") + .replace("\r\n", "\n") + .replace("\r", "\n") + + private fun hasNul(bytes: ByteArray): Boolean { + val end = minOf(bytes.size, SNIFF_BYTES) + for (index in 0 until end) { + if (bytes[index] == 0.toByte()) return true + } + return false + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/Kibibytes.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/Kibibytes.kt new file mode 100644 index 00000000..fdbea5a9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/Kibibytes.kt @@ -0,0 +1,24 @@ +package press.mantra.compose.text + +/** + * A byte count as the number part of "19.9 KiB". + * + * The unit itself is a string resource, so this returns only the figure. + * + * One decimal place, by integer arithmetic rather than through a float format. + * Two reasons, and the first is the one that matters: the figures this renders + * sit next to each other -- what a chapter weighs, and what the group can carry + * -- and whole kibibytes would print a 20,473-byte chapter and its 21,226-byte + * limit as the same "19 KiB", which reads as a form refusing something it says + * is within the limit. The second is that there is no common-code decimal + * formatter to reach for, and the three platforms' own would each punctuate it + * differently. + * + * Truncating rather than rounding, for the same reason: a figure shown as under + * the limit has to be under it. + */ +internal fun kibibytes(bytes: Int): String { + val whole = bytes / 1024 + val tenths = (bytes % 1024) * 10 / 1024 + return "$whole.$tenths" +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/Markdown.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/Markdown.kt index 5771c2dd..30a5cc86 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/Markdown.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/Markdown.kt @@ -9,8 +9,6 @@ package press.mantra.compose.text object Markdown { private val WHITESPACE = Regex("\\s+") - /** A paragraph break is one or more blank lines. */ - private val PARAGRAPH_BREAK = Regex("(?:\\r?\\n){2,}") fun wordCount(text: String): Int { val trimmed = text.trim() @@ -20,11 +18,30 @@ object Markdown { fun characterCount(text: String): Int = text.length /** - * Split markdown into paragraphs (blocks separated by blank lines), - * trimmed and with empties removed. Each paragraph becomes a chunk. + * The blocks a chapter's markdown splits into, one chunk each. + * + * A chunk is a **leaf** block's inline text with its block marker removed: + * the words a translator is given and the words the group signs. Containers + * -- lists, blockquotes -- contribute nothing of their own; a thematic + * break, a code block and a link reference definition contribute nothing at + * all. + * + * This is markdown-it's block token stream, which is what mantra.press + * translates against, with the three departures recorded in + * `docs/chapter-uploads.md`. It replaced a blank-line split, and the two + * disagreed on ten cases in eleven -- on the text as well as the + * boundaries, because markdown-it strips the marker, so `# Chapter one` is + * `"Chapter one"`. Inline markup is kept verbatim: emphasis and links are + * the translator's to carry, where a `#` mistyped into a translation breaks + * the document. + * + * Its output is hashed into chunk ids a quorum signs, so it is pinned by + * `MarkdownBlockFixtures` -- generated from markdown-it itself -- rather + * than by this comment. See [MarkdownBlocks] for the subset it recognises. */ - fun splitParagraphs(text: String): List = - text.split(PARAGRAPH_BREAK) - .map { it.trim() } - .filter { it.isNotEmpty() } + fun splitBlocks(text: String): List = + MarkdownBlocks.segment(text).mapNotNull { it.block } + + /** Just the words of each chunk, which is all an event carries. */ + fun splitChunks(text: String): List = splitBlocks(text).map { it.text } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/MarkdownBlocks.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/MarkdownBlocks.kt new file mode 100644 index 00000000..99751009 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/MarkdownBlocks.kt @@ -0,0 +1,337 @@ +package press.mantra.compose.text + +/** + * What kind of leaf block a chunk came out of. + * + * Carried so [MarkdownProducer] can put the block's marker back when the + * chunk's words have been replaced by a translation. Nothing else reads it. + */ +enum class BlockKind { + Paragraph, + Heading, + SetextHeading, + ListItem, + Quote, + + /** + * A whole table, as its own source. + * + * One of the three deliberate departures from markdown-it, which gives a + * chunk per cell. See `docs/chapter-uploads.md`. + */ + Table, +} + +/** + * One chunk, as the words and the marker that were in front of them. + * + * [text] is what a translator is given and what the group signs: the leaf + * block's inline source with the **block** marker removed -- no `#`, no `> `, + * no `- `, no setext underline -- while inline markup (`*italic*`, a link's + * brackets) is kept verbatim. The first is structure a translation should not + * be able to break by mistyping; the second is the translator's to carry. + * + * [markup] is the marker that was removed, kept so a document can be put back + * together. For an ATX heading it is the run of `#`; for a list item the + * bullet or the ordinal; for a blockquote `>`. It is not stored on the chunk + * event -- it is recoverable from the chapter's own `original_text`, which is + * where [MarkdownProducer] reads it from. + */ +data class MarkdownBlock( + val kind: BlockKind, + val markup: String, + val text: String, + /** The indent the block sat at, so a nested list item goes back where it was. */ + val indent: String = "", +) + +/** + * A run of lines, and the chunk it is -- or nothing, if it is not prose. + * + * A segmentation rather than a split, because two things need it. [block] being + * null is a fenced code block, a thematic break, a link reference definition or + * a blank line: material that carries no chunk and still has to be in the + * document afterwards. + * + * [lines] is the exact source, so a document with no translations applied is + * reassembled byte for byte by joining every segment's lines with `\n`. + */ +data class MarkdownSegment( + val lines: List, + val block: MarkdownBlock?, +) + +/** + * The block structure of a chapter's markdown. + * + * A documented **subset** of CommonMark, and hand-rolled rather than taken from + * a library on purpose: its output is hashed into chunk ids that a quorum + * signs, so a dependency bump that moved one boundary would silently re-split + * every chapter proposed afterwards and nothing in the app would notice. What + * pins it instead is `MarkdownBlockFixtures`, generated from markdown-it by + * `docs/scripts/markdown-block-fixtures.js`. + * + * What it recognises: ATX and setext headings, paragraphs with lazy + * continuation, blockquotes, bullet and ordered list items including one level + * of nesting, fenced and indented code, thematic breaks, tables, and link + * reference definitions. + * + * What it does not, each of which has a fixture saying what happens instead: + * HTML blocks (markdown-it's default is `html: false`, so a `
` is a + * paragraph on both sides), footnotes, and blockquotes containing lists. + */ +internal object MarkdownBlocks { + + private val ATX = Regex("^ {0,3}(#{1,6})(?:[ \t]+(.*?))?[ \t]*#*[ \t]*$") + private val SETEXT_UNDERLINE = Regex("^ {0,3}(=+|-+)[ \t]*$") + private val THEMATIC_BREAK = Regex("^ {0,3}((\\*[ \t]*){3,}|(-[ \t]*){3,}|(_[ \t]*){3,})$") + private val FENCE = Regex("^ {0,3}(`{3,}|~{3,})(.*)$") + private val BULLET = Regex("^( {0,3})([-*+])([ \t]+)(.*)$") + private val ORDERED = Regex("^( {0,3})(\\d{1,9}[.)])([ \t]+)(.*)$") + private val QUOTE = Regex("^ {0,3}>[ \t]?(.*)$") + private val REFERENCE_DEFINITION = Regex("^ {0,3}\\[[^\\]]+\\]:[ \t]*\\S.*$") + private val INDENTED_CODE = Regex("^ {4,}\\S.*$") + private val TABLE_DELIMITER = Regex("^ {0,3}\\|?[ \t]*:?-{1,}:?[ \t]*(\\|[ \t]*:?-{1,}:?[ \t]*)*\\|?[ \t]*$") + + fun segment(text: String): List { + val lines = text.split("\n") + val segments = mutableListOf() + var i = 0 + + while (i < lines.size) { + val line = lines[i] + + when { + line.isBlank() -> { + segments += MarkdownSegment(listOf(line), null) + i++ + } + + FENCE.matches(line) -> { + val fence = FENCE.find(line)!!.groupValues[1] + val taken = mutableListOf(lines[i]) + i++ + while (i < lines.size) { + taken += lines[i] + val closing = FENCE.find(lines[i])?.groupValues?.get(1) + i++ + if (closing != null && closing.length >= fence.length) break + } + // Departure 1: code is not prose, so no chunk. + segments += MarkdownSegment(taken, null) + } + + THEMATIC_BREAK.matches(line) -> { + segments += MarkdownSegment(listOf(line), null) + i++ + } + + REFERENCE_DEFINITION.matches(line) -> { + // Departure 3, which is markdown-it's own behaviour with the + // `reference` rule enabled: a definition is not prose. + segments += MarkdownSegment(listOf(line), null) + i++ + } + + INDENTED_CODE.matches(line) -> { + val taken = mutableListOf() + while (i < lines.size && (INDENTED_CODE.matches(lines[i]) || lines[i].isBlank())) { + // A blank line only belongs to the code block if more code + // follows it; otherwise it is the separator after it. + if (lines[i].isBlank() && + lines.drop(i + 1).firstOrNull { it.isNotBlank() } + ?.let { !INDENTED_CODE.matches(it) } != false + ) { + break + } + taken += lines[i] + i++ + } + segments += MarkdownSegment(taken, null) + } + + ATX.matches(line) -> { + val match = ATX.find(line)!! + segments += MarkdownSegment( + lines = listOf(line), + block = MarkdownBlock( + kind = BlockKind.Heading, + markup = match.groupValues[1], + text = match.groupValues[2].trim(), + ), + ) + i++ + } + + isTableStart(lines, i) -> { + val taken = mutableListOf() + while (i < lines.size && lines[i].isNotBlank() && lines[i].contains('|')) { + taken += lines[i] + i++ + } + // Departure 2: one chunk of the whole table's source. + segments += MarkdownSegment( + lines = taken, + block = MarkdownBlock( + kind = BlockKind.Table, + markup = "", + text = taken.joinToString("\n"), + ), + ) + } + + QUOTE.matches(line) -> { + val taken = mutableListOf() + val inner = mutableListOf() + while (i < lines.size && QUOTE.matches(lines[i])) { + taken += lines[i] + inner += QUOTE.find(lines[i])!!.groupValues[1] + i++ + } + // A `>` on its own splits one blockquote into two paragraphs, + // which markdown-it reads as two inline runs and so two chunks. + val paragraphs = inner.splitOnBlank() + if (paragraphs.size <= 1) { + segments += MarkdownSegment( + lines = taken, + block = MarkdownBlock( + kind = BlockKind.Quote, + markup = ">", + text = inner.joinToString("\n").trim(), + ), + ) + } else { + // Re-segmented per inner paragraph, so each is its own + // chunk and the source still reassembles exactly. + var consumed = 0 + paragraphs.forEachIndexed { index, paragraph -> + val span = paragraph.size + if (index < paragraphs.size - 1) 1 else 0 + segments += MarkdownSegment( + lines = taken.subList(consumed, consumed + span).toList(), + block = MarkdownBlock( + kind = BlockKind.Quote, + markup = ">", + text = paragraph.joinToString("\n").trim(), + ), + ) + consumed += span + } + } + } + + BULLET.matches(line) || ORDERED.matches(line) -> { + val match = (BULLET.find(line) ?: ORDERED.find(line))!! + val indent = match.groupValues[1] + val marker = match.groupValues[2] + val gap = match.groupValues[3] + val taken = mutableListOf(line) + val content = mutableListOf(match.groupValues[4]) + i++ + // Lazy continuation: a plain line under an item belongs to it. + // A new marker, at any indent, is a new item -- which is what + // makes a nested list two chunks rather than one. + while (i < lines.size && isLazyContinuation(lines[i])) { + taken += lines[i] + content += lines[i].trim() + i++ + } + segments += MarkdownSegment( + lines = taken, + block = MarkdownBlock( + kind = BlockKind.ListItem, + markup = marker + gap, + text = content.joinToString("\n").trim(), + indent = indent, + ), + ) + } + + else -> { + // A paragraph, unless the line after it underlines it. + val taken = mutableListOf(line) + i++ + var setext: String? = null + while (i < lines.size) { + val next = lines[i] + if (SETEXT_UNDERLINE.matches(next) && !THEMATIC_BREAK.matches(next)) { + setext = SETEXT_UNDERLINE.find(next)!!.groupValues[1] + taken += next + i++ + break + } + if (!isLazyContinuation(next)) break + taken += next + i++ + } + + segments += if (setext != null) { + MarkdownSegment( + lines = taken, + block = MarkdownBlock( + kind = BlockKind.SetextHeading, + markup = setext, + text = taken.dropLast(1).joinToString("\n").trim(), + ), + ) + } else { + MarkdownSegment( + lines = taken, + block = MarkdownBlock( + kind = BlockKind.Paragraph, + markup = "", + text = taken.joinToString("\n").trim(), + ), + ) + } + } + } + } + + return segments + } + + /** + * Whether [line] continues the block above it rather than starting one. + * + * The list is every block start this scanner recognises. A line that starts + * none of them, and is not blank, is prose belonging to whatever came + * before -- which is what markdown-it calls a lazy continuation and is why + * `First line.\nSecond line.` is one chunk and not two. + */ + private fun isLazyContinuation(line: String): Boolean = when { + line.isBlank() -> false + ATX.matches(line) -> false + THEMATIC_BREAK.matches(line) -> false + FENCE.matches(line) -> false + QUOTE.matches(line) -> false + BULLET.matches(line) -> false + ORDERED.matches(line) -> false + REFERENCE_DEFINITION.matches(line) -> false + else -> true + } + + /** A table is a row of cells with a delimiter row under it. */ + private fun isTableStart(lines: List, at: Int): Boolean = + lines[at].contains('|') && + at + 1 < lines.size && + lines[at + 1].contains('-') && + TABLE_DELIMITER.matches(lines[at + 1]) + + /** Splits a blockquote's inner lines into its paragraphs. */ + private fun List.splitOnBlank(): List> { + val out = mutableListOf>() + var current = mutableListOf() + forEach { line -> + if (line.isBlank()) { + if (current.isNotEmpty()) { + out += current + current = mutableListOf() + } + } else { + current += line + } + } + if (current.isNotEmpty()) out += current + return out + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/MarkdownProducer.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/MarkdownProducer.kt new file mode 100644 index 00000000..155345b7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/MarkdownProducer.kt @@ -0,0 +1,86 @@ +package press.mantra.compose.text + +/** + * A chapter's markdown with translations in place of its words. + * + * The other half of [Markdown.splitBlocks], and the piece that makes stripping + * block markers safe. A chunk carries `"Chapter one"` rather than + * `"# Chapter one"` so that a translator cannot break the document by mistyping + * the `#` -- which is only defensible if something can put the `#` back. Before + * this, nothing in the app could: there was no equivalent of mantra.press's + * `lib/markdown-producer/index.js` at all. + * + * **Substitution is by index, not by text**, and that is strictly better than + * the thing it mirrors. mantra.press matches with + * `translationChunks.find(tc => tc.text == token.content)`, so a chapter with + * two identical paragraphs puts the first one's translation into both of them. + * A chunk here already carries an `IndexTag`, so position is available and + * exact. + */ +object MarkdownProducer { + + /** + * [originalText] rebuilt with [translations] substituted by chunk index. + * + * A chunk with no translation keeps its own source **verbatim** -- not + * re-rendered from its parsed form -- so a chapter with no translations at + * all comes back byte for byte. That is the property worth having: it means + * every markup-reassembly bug is one failing assertion away rather than a + * difference somebody has to notice in a rendered document. + * + * A translated chunk is rendered from its [MarkdownBlock.kind]: the marker + * goes back in front, and for a blockquote in front of every line, so a + * translation that grew a line is still quoted. + */ + fun produce(originalText: String, translations: Map): String { + var chunkIndex = 0 + + return MarkdownBlocks.segment(originalText).flatMap { segment -> + val block = segment.block ?: return@flatMap segment.lines + + val translation = translations[chunkIndex] + chunkIndex++ + + if (translation.isNullOrEmpty()) segment.lines else render(block, translation) + }.joinToString("\n") + } + + /** How many chunks [originalText] has, which is what a caller indexes against. */ + fun chunkCount(originalText: String): Int = Markdown.splitBlocks(originalText).size + + private fun render(block: MarkdownBlock, translation: String): List = + when (block.kind) { + BlockKind.Paragraph -> translation.lines() + + BlockKind.Heading -> listOf("${block.markup} $translation") + + // The underline has to be as long as the text, or it stops being an + // underline -- a one-character `=` under a translated title is a + // setext heading in CommonMark but reads as a typo. + BlockKind.SetextHeading -> { + // The underline matches the line immediately above it, which is + // the last line of the translation rather than its first: a `=` + // shorter than the text it underlines is still a setext heading + // in CommonMark but reads as a typo. + val lines = translation.lines() + lines + block.markup.first().toString() + .repeat(lines.last().length.coerceAtLeast(1)) + } + + BlockKind.ListItem -> { + val lines = translation.lines() + listOf("${block.indent}${block.markup}${lines.first()}") + + // A continuation line is indented under the marker, so the + // item stays one item rather than becoming a paragraph after + // it. + lines.drop(1).map { "${block.indent}${" ".repeat(block.markup.length)}$it" } + } + + BlockKind.Quote -> translation.lines().map { "> $it" } + + // A table's chunk is its own source, so its translation is too: there + // is no marker to put back, and re-deriving the pipes from a + // translated table would be guessing at its columns. + BlockKind.Table -> translation.lines() + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt index be902063..00999643 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt @@ -1,6 +1,7 @@ package press.mantra.compose.text import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.managers.SharedKeyDerivation import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent @@ -8,6 +9,9 @@ import press.mantra.compose.nostr.nip30303.ArtifactEvent import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent import press.mantra.compose.nostr.nip30303.ChapterEvent import press.mantra.compose.nostr.nip30303.DialectEvent +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationChapterEvent import press.mantra.compose.nostr.nip30303.TranslationChunkEvent @@ -31,7 +35,52 @@ object ProposedEvent { */ data class Summary(val label: String, val detail: String) - fun summarize(event: Event): Summary = when (event.kind) { + /** + * [chatRoomId] is the room the proposal is in, where one is known. + * + * Only one branch reads it, and only to leave something out: a project owned + * by the room it is in is the ordinary case, and saying "owned by this group" + * on every project trains a member not to read the line that matters when + * somebody else is on it. Null simply says everything. + * + * [resolve] turns a public key or an event id into something a person can + * read, or null when it cannot. It is synchronous on purpose -- this is + * called from composables as well as view models -- so a caller with a + * database behind it resolves what it needs first and passes a lookup into + * the map. + * + * The default resolves nothing, which is honest rather than ideal. A + * membership event carries two 64-character ids and nothing else, so without + * a resolver it can only be described by a truncated hash. That is tolerable + * inside the batch that founds a project, where the project's own line names + * what is happening and the memberships read as its continuation -- and in + * that batch the project id is unresolvable anyway, since nothing has + * applied it yet. It is not tolerable for a membership proposed on its own, + * which is the whole of what a member is being asked to agree to. + * + * [chunkCount] is how many chunks travel with a chapter in the same batch, + * which only a caller holding the batch can know. + * + * It used to be derived here, by re-splitting the chapter's own text. That + * was wrong twice over. It is a *count of the batch* dressed as a fact about + * the text, so across a splitter change the number a member reads in the + * approval gate disagrees with the chunks they are actually approving -- the + * batch was built by the proposer's splitter and counted by the reader's. + * And it re-split the whole markdown of every chapter on every emission of + * the room's proposal list, which at twenty open proposals is a few hundred + * kilobytes of prose re-scanned to produce a number already sitting in the + * items beside it. + * + * Null where the caller has no batch -- a transcript line, a signed event on + * its own -- and the summary then says nothing about chunks rather than + * guessing. + */ + fun summarize( + event: Event, + chatRoomId: String? = null, + resolve: (HexKey) -> String? = { null }, + chunkCount: Int? = null, + ): Summary = when (event.kind) { DialectEvent.KIND -> Summary( label = "New dialect", detail = DialectEvent( @@ -74,8 +123,6 @@ object ProposedEvent { // is a whole chapter, so its size stands in for it here. The chunk // count says how many of the batch's other items came out of this // text, which is the rest of what is being signed. - val chunkCount = chapter.originalText()?.let { Markdown.splitParagraphs(it).size } - listOfNotNull( chapter.name(), chapter.wordCount()?.let { "$it words" }, @@ -125,6 +172,56 @@ object ProposedEvent { } ) + ProjectEvent.KIND -> Summary( + label = "New project", + detail = ProjectEvent( + event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig + ).let { project -> + listOfNotNull( + project.name(), + event.content.takeIf { it.isNotBlank() }, + // Shown because signing this is what makes it the group's + // statement that X started it. A member who cannot see the + // claim cannot be said to have agreed to it -- and nothing + // else checks it, since the creator signs nothing here. + project.creator()?.let { "started by ${describe(it, resolve)}" }, + // Only the owners who are not this room. See [chatRoomId]. + project.owners() + .filter { it != chatRoomId } + .takeIf { it.isNotEmpty() } + ?.joinToString(", ") { describe(it, resolve) } + ?.let { "owned by $it" }, + ).joinToString(" · ") + } + ) + + ProjectArtifactVersionEvent.KIND -> Summary( + label = "A source version joins the project", + detail = ProjectArtifactVersionEvent( + event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig + ).let { membership -> + // The version first: in a founding batch the project is being + // signed two lines up and its id resolves to nothing, because + // nothing has applied it yet. + listOfNotNull( + membership.artifactVersionId()?.let { describe(it, resolve) }, + membership.projectId()?.let { resolve(it) }?.let { "to $it" }, + ).joinToString(" · ").ifBlank { "Unknown version" } + } + ) + + ProjectTranslationArtifactVersionEvent.KIND -> Summary( + label = "A translation joins the project", + detail = ProjectTranslationArtifactVersionEvent( + event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig + ).let { membership -> + listOfNotNull( + membership.translationArtifactVersionId()?.let { describe(it, resolve) }, + membership.projectId()?.let { resolve(it) }?.let { "to $it" }, + ).joinToString(" · ").ifBlank { "Unknown translation" } + } + ) + // The one thing a group signs that is about the group rather than about // its work, and the only one a member sees before the room has done // anything. Shown as the path and the ceremony rather than as the key: @@ -189,4 +286,14 @@ object ProposedEvent { else -> Summary(label = "Event of kind ${event.kind}", detail = event.content) } + + /** + * [id] as a name if [resolve] has one, and as a short hash otherwise. + * + * Twelve characters because that is what the rest of the signing screens + * truncate to. A short hash is a poor thing to put in front of somebody + * deciding whether to sign, and saying so is the point: see [summarize]. + */ + private fun describe(id: HexKey, resolve: (HexKey) -> String?): String = + resolve(id) ?: id.take(12) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt index f4e4807b..7c796e6d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt @@ -6,12 +6,15 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.DriveFileRenameOutline +import androidx.compose.material.icons.filled.UploadFile import androidx.compose.material3.BottomAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -19,10 +22,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface +import androidx.compose.material3.TextButton import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign @@ -36,7 +41,12 @@ import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.files.rememberFilePicker +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 +import press.mantra.compose.text.kibibytes import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute import press.mantra.compose.ui.composable.navigation.routes.Route @@ -55,8 +65,19 @@ import mantra.composeapp.generated.resources.paste_the_chapter_s_markdown_blank_ import mantra.composeapp.generated.resources.propose_chapter import mantra.composeapp.generated.resources.this_artifact_has_no_version_for_a_chapter import mantra.composeapp.generated.resources.add_chapter_to +import mantra.composeapp.generated.resources.choose_chapter_files +import mantra.composeapp.generated.resources.d_chunks_is_more_than_the_group_can_sign +import mantra.composeapp.generated.resources.s_kib +import mantra.composeapp.generated.resources.s_of_s +import mantra.composeapp.generated.resources.this_chapter_is_too_long_to_propose +import mantra.composeapp.generated.resources.this_group_has_no_shared_key_for_a_chapter +import mantra.composeapp.generated.resources.words_characters_size_chunks +import mantra.composeapp.generated.resources.load_from_file +import mantra.composeapp.generated.resources.that_file_is_too_large +import mantra.composeapp.generated.resources.that_file_isn_t_text import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.rememberNotifier import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.composable.widgets.NavigateBackButton @@ -107,11 +128,48 @@ fun AddChapterScreen( val nameFieldState = rememberTextFieldState() val originalTextFieldState = rememberTextFieldState() + // Read above the handler, not inside it: `rememberNotifier` and + // `stringResource` are composable and a click lambda is not. + val notify = rememberNotifier(rememberCoroutineScope()) + val notText = stringResource(Res.string.that_file_isn_t_text) + val tooLarge = stringResource(Res.string.that_file_is_too_large) + + // One file, into the form that already works. Picking several + // chapters at once is the upload screen's job; here the point is + // to stop a chapter having to be pasted through a keyboard. + val picker = rememberFilePicker( + title = stringResource(Res.string.choose_chapter_files) + ) { files -> + val file = files.firstOrNull() ?: return@rememberFilePicker + + when (val decoded = ChapterText.decode(file.bytes)) { + is ChapterText.Result.Text -> { + originalTextFieldState.setTextAndPlaceCursorAtEnd(decoded.markdown) + // The filename only fills a name nobody has typed. + // Overwriting one that was typed would make loading a + // file a destructive act on a field beside it. + if (nameFieldState.text.isBlank()) { + nameFieldState.setTextAndPlaceCursorAtEnd( + ChapterNaming.forFile(file.name, decoded.markdown) + ) + } + // No snackbar on success. The text arriving in the + // field below is the feedback, and a message over + // the top of it would say less than the thing + // itself. + } + + ChapterText.Result.NotText -> notify(notText) + ChapterText.Result.TooLarge -> notify(tooLarge) + } + } + // Live counts / paragraph (chunk) preview from the markdown text. val originalText = originalTextFieldState.text.toString() val wordCount = Markdown.wordCount(originalText) val characterCount = Markdown.characterCount(originalText) - val paragraphCount = Markdown.splitParagraphs(originalText).size + val chunks = Markdown.splitChunks(originalText) + val chunkCount = chunks.size // A chapter hangs off a version, and the group has to be able to // sign; without both there is nothing this screen can propose. @@ -121,12 +179,21 @@ fun AddChapterScreen( // and a session signs a bounded number of events. Past that the // chapter has to be split in two, which is worth saying while there // is still a cursor in the text rather than after a failed propose. - val tooManyChunks = paragraphCount > AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER + val tooManyChunks = chunkCount > AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER + + // The other cap, and the one that had been enforced nowhere: a + // proposal carries the chapter whole and every chunk cut from + // it, so it weighs a little over twice the prose plus an + // envelope per event. Past what a group event holds, the session + // used to go out and stall. + val proposalBytes = ChapterProposal.proposalBytes(originalText, chunks) + val tooLargeToPropose = !tooManyChunks && proposalBytes > ChapterProposal.MAX_PROPOSAL_BYTES val canProposeChapter = artifactVersion != null && addChapterUIState.canSign && - paragraphCount > 0 && - !tooManyChunks + chunkCount > 0 && + !tooManyChunks && + !tooLargeToPropose // imePadding: this screen has a text field, and without it the software // keyboard covers whatever is being typed into. @@ -187,8 +254,7 @@ fun AddChapterScreen( ) { if (!addChapterUIState.canSign) { Text( - text = "This group has no shared key, so it cannot sign a " + - "chapter into existence. Run a shared key ceremony first.", + text = stringResource(Res.string.this_group_has_no_shared_key_for_a_chapter), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error ) @@ -200,9 +266,25 @@ fun AddChapterScreen( ) } else if (tooManyChunks) { Text( - text = "$paragraphCount chunks is more than the group can sign in " + - "one go. Split the chapter so that no part of it is over " + - "${AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER} paragraphs.", + text = stringResource( + Res.string.d_chunks_is_more_than_the_group_can_sign, + chunkCount, + AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } else if (tooLargeToPropose) { + Text( + text = stringResource( + Res.string.this_chapter_is_too_long_to_propose, + chunkCount, + stringResource(Res.string.s_kib, kibibytes(proposalBytes)), + stringResource( + Res.string.s_kib, + kibibytes(ChapterProposal.MAX_PROPOSAL_BYTES), + ), + ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error ) @@ -221,6 +303,15 @@ fun AddChapterScreen( placeholder = { Text(stringResource(Res.string.eg_chapter_1_the_beginning)) }, ) + TextButton(onClick = { picker.launch(multiple = false) }) { + Icon( + Icons.Default.UploadFile, + contentDescription = "Load the chapter from a file" + ) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + Text(stringResource(Res.string.load_from_file)) + } + OutlinedTextField( modifier = Modifier.fillMaxWidth().weight(1f), state = originalTextFieldState, @@ -229,14 +320,28 @@ fun AddChapterScreen( ) Text( - // The chunk count is what the group is asked to sign - // alongside the chapter, so it is a count of the work - // being proposed rather than a curiosity about the text. - text = "$wordCount words · $characterCount characters · " + - "$paragraphCount ${if (paragraphCount == 1) "chunk" else "chunks"} " + - "of ${AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER}", + // Both caps, as the text is typed. The chunk count and + // the proposal's size are what the group is asked to + // carry, so they are a measure of the work being + // proposed rather than a curiosity about the text -- + // and either of them can be the one that binds. + text = stringResource( + Res.string.words_characters_size_chunks, + wordCount, + characterCount, + stringResource( + Res.string.s_of_s, + stringResource(Res.string.s_kib, kibibytes(proposalBytes)), + stringResource( + Res.string.s_kib, + kibibytes(ChapterProposal.MAX_PROPOSAL_BYTES), + ), + ), + chunkCount, + AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER, + ), style = MaterialTheme.typography.labelMedium, - color = if (tooManyChunks) { + color = if (tooManyChunks || tooLargeToPropose) { MaterialTheme.colorScheme.error } else { MaterialTheme.typography.labelMedium.color diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddProjectScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddProjectScreen.kt new file mode 100644 index 00000000..14fd1b98 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddProjectScreen.kt @@ -0,0 +1,503 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Article +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.DriveFileRenameOutline +import androidx.compose.material.icons.filled.Notes +import androidx.compose.material.icons.filled.Translate +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.BottomAppBarDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +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_an_owner +import mantra.composeapp.generated.resources.an_owner_is_a_64_character_public_key +import mantra.composeapp.generated.resources.describe_the_project +import mantra.composeapp.generated.resources.eg_the_four_gospels_into_sesotho +import mantra.composeapp.generated.resources.name_of_project +import mantra.composeapp.generated.resources.owned_by_this_group +import mantra.composeapp.generated.resources.owner_public_key +import mantra.composeapp.generated.resources.owners +import mantra.composeapp.generated.resources.project_detail +import mantra.composeapp.generated.resources.propose_project +import mantra.composeapp.generated.resources.remove_owner +import mantra.composeapp.generated.resources.source_versions +import mantra.composeapp.generated.resources.start_a_project_in_this_group +import mantra.composeapp.generated.resources.this_group_has_no_library_to_build_a_project_from +import mantra.composeapp.generated.resources.translations +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.extensions.shortened +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute +import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.Decorative +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.buttons.ExtendedFab +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.AddProjectViewModel +import press.mantra.compose.ui.view.state.AddProjectUIState + +/** + * Proposing a project: a name, what it is, who owns it, and what is in it. + * + * The creator is not a field. It is whoever is filling the form in, the view + * model already knows it, and a picker would only let somebody credit the wrong + * person for work the group is about to put its signature to. + * + * The owners are a field, pre-filled with this group. Most projects never touch + * it, and that is the point of pre-filling rather than hiding: a group making + * something for somebody else needs the question asked, and a group that is not + * needs to see the answer it is giving. + */ +@OptIn( + ExperimentalMaterial3ExpressiveApi::class, + ExperimentalMaterial3Api::class, + ExperimentalLayoutApi::class +) +@Composable +fun AddProjectScreen( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + initialAddProjectUIState: AddProjectUIState = AddProjectUIState.Loading, + nostrRepository: NostrRepository, + chatRepository: ChatRepository, + mantraRepository: MantraRepository, + frostSigningRepository: FrostSigningRepository, + onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, + onNavigateToRoute: (Route) -> Unit, + onNavigateBack: () -> Unit, +) { + val addProjectViewModel: AddProjectViewModel = viewModel( + factory = AddProjectViewModel.factory( + chatRoomId = chatRoomId, + relayHint = relayHint, + initialAddProjectUIState = initialAddProjectUIState, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + activeUserPublicKey = activeUserPublicKey, + frostSigningRepository = frostSigningRepository, + mantraRepository = mantraRepository, + ) + ) + + ScreenStateTransition(addProjectViewModel.addProjectUIState) { uiState -> + when (val addProjectUIState = uiState) { + is AddProjectUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) + Text(text = addProjectUIState.message) + } + } + + is AddProjectUIState.Loaded -> { + val nameFieldState = rememberTextFieldState() + val descriptionFieldState = rememberTextFieldState() + val ownerFieldState = rememberTextFieldState() + + // Pre-filled with the room, which is the common answer and a real + // value rather than an absence: "owned by us" and "owner unstated" + // must not be the same event. + val owners: SnapshotStateList = remember { + mutableStateListOf(chatRoomId) + } + val selectedSourceVersions = remember { mutableStateListOf() } + val selectedTranslations = remember { mutableStateListOf() } + + var nameEntered by remember { mutableStateOf(false) } + LaunchedEffect(nameFieldState.text) { + nameEntered = nameFieldState.text.isNotBlank() + } + + // A key that is not 64 hex characters is dropped by + // ContributorTag.parse, so it would vanish between this form and + // the signature. Caught on entry instead. + val typedOwner = ownerFieldState.text.toString().trim() + val canAddOwner = typedOwner.length == OWNER_KEY_LENGTH && + typedOwner !in owners + + val canAddProject = nameEntered && + owners.isNotEmpty() && + addProjectUIState.canSign + + // imePadding: this screen has text fields, and without it the + // software keyboard covers whatever is being typed into. + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + modifier = Modifier.imePadding(), + topBar = { + TopAppBar( + title = { + addProjectUIState.localChatRoom.RenderChatRoomTitleText(activeUserPublicKey) + }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) + }, + bottomBar = { + BottomAppBar( + actions = {}, + floatingActionButton = { + ExtendedFab( + label = stringResource(Res.string.propose_project), + icon = { + Icon(Icons.Default.Add, contentDescription = Decorative) + }, + enabled = canAddProject, + onClick = { + addProjectViewModel.addProject( + localChatRoom = addProjectUIState.localChatRoom, + nameField = nameFieldState, + descriptionField = descriptionFieldState, + ownerPublicKeys = owners.toSet(), + sourceVersionIds = selectedSourceVersions.toList(), + translationIds = selectedTranslations.toList(), + onSuccess = { sessionId -> + // Onto the session rather than to + // the project. Nothing exists yet + // -- it appears when enough members + // sign -- so a detail screen for a + // row that is not there would read + // as a failure. + onNavigateToRouteAndPopUpInclusive.invoke( + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + ) + }, + onFailure = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Failed project") + ) + } + ) + } + ) + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .readableContent() + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy( + MaterialTheme.spacing.space125 + ), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(stringResource(Res.string.start_a_project_in_this_group)) + + if (!addProjectUIState.canSign) { + Text( + text = "This group has no shared key, so it cannot sign a " + + "project into being. Run a shared key ceremony first.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .background(BottomAppBarDefaults.containerColor), + state = nameFieldState, + colors = transparentOutline(), + leadingIcon = { + Icon( + Icons.Default.DriveFileRenameOutline, + contentDescription = Decorative + ) + }, + label = { Text(stringResource(Res.string.name_of_project)) }, + placeholder = { + Text(stringResource(Res.string.eg_the_four_gospels_into_sesotho)) + }, + ) + + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .background(BottomAppBarDefaults.containerColor), + state = descriptionFieldState, + colors = transparentOutline(), + leadingIcon = { + Icon(Icons.Default.Notes, contentDescription = Decorative) + }, + label = { Text(stringResource(Res.string.describe_the_project)) }, + ) + + // --- owners --- + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(Res.string.owners), + style = MaterialTheme.typography.labelMedium + ) + + val thisGroup = stringResource(Res.string.owned_by_this_group) + + owners.forEach { owner -> + ListItem( + headlineContent = { + Text( + if (owner == chatRoomId) thisGroup else owner.shortened() + ) + }, + trailingContent = { + // The last one cannot go: ProjectEvent.build + // refuses an empty set, and a button that + // throws is worse than one that is off. + IconButton( + enabled = owners.size > 1, + onClick = { owners.remove(owner) } + ) { + Icon( + Icons.Default.Close, + contentDescription = stringResource( + Res.string.remove_owner + ) + ) + } + } + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + modifier = Modifier + .weight(1f) + .background(BottomAppBarDefaults.containerColor), + state = ownerFieldState, + colors = transparentOutline(), + label = { Text(stringResource(Res.string.owner_public_key)) }, + isError = typedOwner.isNotEmpty() && !canAddOwner, + supportingText = { + if (typedOwner.isNotEmpty() && !canAddOwner) { + Text( + stringResource( + Res.string.an_owner_is_a_64_character_public_key + ) + ) + } + }, + ) + + IconButton( + enabled = canAddOwner, + onClick = { + owners.add(typedOwner) + ownerFieldState.clearText() + } + ) { + Icon( + Icons.Default.Add, + contentDescription = stringResource(Res.string.add_an_owner) + ) + } + } + + // --- what is in it --- + + if (addProjectUIState.sourceVersions.isEmpty()) { + Text( + text = stringResource( + Res.string.this_group_has_no_library_to_build_a_project_from + ), + style = MaterialTheme.typography.bodySmall + ) + } else { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(Res.string.source_versions), + style = MaterialTheme.typography.labelMedium + ) + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + MaterialTheme.spacing.itemGap + ) + ) { + addProjectUIState.sourceVersions.forEach { version -> + FilterChip( + selected = version.id in selectedSourceVersions, + onClick = { + if (version.id in selectedSourceVersions) { + selectedSourceVersions.remove(version.id) + } else { + selectedSourceVersions.add(version.id) + } + }, + leadingIcon = { + Icon( + Icons.Default.Article, + contentDescription = Decorative + ) + }, + label = { Text(version.versionLabel) } + ) + } + } + } + + if (addProjectUIState.translations.isNotEmpty()) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(Res.string.translations), + style = MaterialTheme.typography.labelMedium + ) + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + MaterialTheme.spacing.itemGap + ) + ) { + addProjectUIState.translations.forEach { translation -> + FilterChip( + selected = translation.id in selectedTranslations, + onClick = { + if (translation.id in selectedTranslations) { + selectedTranslations.remove(translation.id) + } else { + selectedTranslations.add(translation.id) + } + }, + leadingIcon = { + Icon( + Icons.Default.Translate, + contentDescription = Decorative + ) + }, + label = { Text(translation.name) } + ) + } + } + } + } + } + } + + AddProjectUIState.Loading -> { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(Res.string.project_detail), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + LoadingDataIndicator(fillScreen = false) + Spacer(modifier = Modifier.weight(2f)) + } + } + } + } + + LaunchedEffect(true) { + if (initialAddProjectUIState == AddProjectUIState.Loading) { + addProjectViewModel.initiateAddProject() + } + } +} + +/** What `ContributorTag.parse` demands of an owner's value. */ +private const val OWNER_KEY_LENGTH = 64 + +/** The borderless field the rest of this family's forms use. */ +@Composable +private fun transparentOutline() = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent +) + +@ConformancePreviews +@Composable +private fun AddProjectScreenPreview() { + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + AddProjectScreen( + activeUserPublicKey = "a".repeat(64), + chatRoomId = "b".repeat(64), + relayHint = null, + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + onNavigateToRouteAndPopUpInclusive = {}, + onNavigateToRoute = {}, + onNavigateBack = {}, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddToProjectScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddToProjectScreen.kt new file mode 100644 index 00000000..0d760299 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddToProjectScreen.kt @@ -0,0 +1,302 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +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.Add +import androidx.compose.material.icons.filled.Article +import androidx.compose.material.icons.filled.Translate +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +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_source_version_to_project +import mantra.composeapp.generated.resources.add_translation_to_project +import mantra.composeapp.generated.resources.every_translation_is_already_in_this_project +import mantra.composeapp.generated.resources.everything_in_the_library_is_already_in_this_project +import mantra.composeapp.generated.resources.project_detail +import mantra.composeapp.generated.resources.propose_adding_to_the_project +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute +import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.Decorative +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.buttons.ExtendedFab +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.AddToProjectViewModel +import press.mantra.compose.ui.view.state.AddToProjectUIState + +/** + * One more piece of work into a project that already exists. + * + * Unlike founding a project, this proposes a single membership -- so the whole + * of what the group is asked to agree to is two ids. That is why + * `FrostSigningViewModel` resolves names for them: without it the other members + * are shown a truncated hash and asked to sign it, which is not a question + * anybody can answer. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun AddToProjectScreen( + activeUserPublicKey: HexKey, + projectId: String, + chatRoomId: String, + translations: Boolean, + relayHint: String?, + initialAddToProjectUIState: AddToProjectUIState = AddToProjectUIState.Loading, + chatRepository: ChatRepository, + mantraRepository: MantraRepository, + frostSigningRepository: FrostSigningRepository, + onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, + onNavigateToRoute: (Route) -> Unit, + onNavigateBack: () -> Unit, +) { + val addToProjectViewModel: AddToProjectViewModel = viewModel( + factory = AddToProjectViewModel.factory( + activeUserPublicKey = activeUserPublicKey, + projectId = projectId, + chatRoomId = chatRoomId, + translations = translations, + relayHint = relayHint, + initialAddToProjectUIState = initialAddToProjectUIState, + chatRepository = chatRepository, + mantraRepository = mantraRepository, + frostSigningRepository = frostSigningRepository, + ) + ) + + ScreenStateTransition(addToProjectViewModel.addToProjectUIState) { uiState -> + when (val addToProjectUIState = uiState) { + is AddToProjectUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) + Text(text = addToProjectUIState.message) + } + } + + is AddToProjectUIState.Loaded -> { + var selectedId: HexKey? by remember { mutableStateOf(null) } + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { + Text( + if (translations) { + stringResource(Res.string.add_translation_to_project) + } else { + stringResource(Res.string.add_source_version_to_project) + } + ) + }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) + }, + bottomBar = { + BottomAppBar( + actions = {}, + floatingActionButton = { + ExtendedFab( + label = stringResource( + Res.string.propose_adding_to_the_project + ), + icon = { + Icon(Icons.Default.Add, contentDescription = Decorative) + }, + enabled = selectedId != null && addToProjectUIState.canSign, + onClick = { + val chosen = selectedId ?: return@ExtendedFab + + addToProjectViewModel.addToProject( + localChatRoom = addToProjectUIState.localChatRoom, + selectedId = chosen, + onSuccess = { sessionId -> + onNavigateToRouteAndPopUpInclusive.invoke( + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + ) + }, + onFailure = { + onNavigateToRoute.invoke( + ImplementationPendingRoute( + "Failed project membership" + ) + ) + } + ) + } + ) + } + ) + } + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .padding(innerPadding) + .readableContent() + .fillMaxSize() + .padding(MaterialTheme.spacing.space250), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + item { Text(addToProjectUIState.project.name) } + + if (!addToProjectUIState.canSign) { + item { + Text( + text = "This group has no shared key, so it cannot sign " + + "anything into this project. Run a shared key " + + "ceremony first.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } + + if (addToProjectUIState.candidates.isEmpty()) { + item { + Text( + if (translations) { + stringResource( + Res.string + .every_translation_is_already_in_this_project + ) + } else { + stringResource( + Res.string + .everything_in_the_library_is_already_in_this_project + ) + } + ) + } + } else { + items( + items = addToProjectUIState.candidates, + key = { candidate -> candidate.id } + ) { candidate -> + Card(onClick = { selectedId = candidate.id }) { + ListItem( + leadingContent = { + Icon( + if (translations) { + Icons.Default.Translate + } else { + Icons.Default.Article + }, + contentDescription = Decorative + ) + }, + trailingContent = { + // One at a time: the proposal is one + // event, and a quorum per thing added + // is the grain the group signs at. + RadioButton( + selected = selectedId == candidate.id, + onClick = { selectedId = candidate.id } + ) + }, + headlineContent = { Text(candidate.label) }, + supportingContent = candidate.detail?.let { detail -> + { Text(detail) } + } + ) + } + } + } + } + } + } + + AddToProjectUIState.Loading -> { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(Res.string.project_detail), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + LoadingDataIndicator(fillScreen = false) + Spacer(modifier = Modifier.weight(2f)) + } + } + } + } + + LaunchedEffect(true) { + if (initialAddToProjectUIState == AddToProjectUIState.Loading) { + addToProjectViewModel.initiateAddToProject() + } + } +} + +@ConformancePreviews +@Composable +private fun AddToProjectScreenPreview() { + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + AddToProjectScreen( + activeUserPublicKey = "a".repeat(64), + projectId = "p".repeat(64), + chatRoomId = "b".repeat(64), + translations = false, + relayHint = null, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + onNavigateToRouteAndPopUpInclusive = {}, + onNavigateToRoute = {}, + onNavigateBack = {}, + ) + } + } +} 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/BroadcastGroupSignedEventScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt index 82c4b37b..c297788a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt @@ -188,7 +188,10 @@ fun BroadcastGroupSignedEventScreen( // what goes on the wire, because the point of copying it is to // hand somebody something they can verify. val json = signedEvent.toEvent().toJson() - val summary = ProposedEvent.summarize(signedEvent.toEvent()) + val summary = ProposedEvent.summarize( + event = signedEvent.toEvent(), + chatRoomId = signedEvent.chatRoomId, + ) val canSend = !isPending diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index a26ca421..b36991f6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -84,8 +84,10 @@ import press.mantra.compose.ui.view.model.ChatRoomDetailViewModel import press.mantra.compose.ui.view.state.ChatRoomDetailUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute +import press.mantra.compose.ui.composable.navigation.routes.AddProjectRoute import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute +import press.mantra.compose.ui.composable.navigation.routes.ProjectDetailRoute import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.run_by_members import mantra.composeapp.generated.resources.parent_group @@ -280,15 +282,40 @@ fun ChatRoomDetailScreen( // later. They are a set rather than a path -- neither // is "the" parent and the order says nothing about // which came first -- so each gets an equal row. - chatRoomDetailUIState.parentChatRoomIds.forEach { parentChatRoomId -> - item(key = "parent-$parentChatRoomId") { + chatRoomDetailUIState.parents.forEach { parent -> + item(key = "parent-${parent.chatRoomId}") { + // The parent's own name, which only its room + // can give: a certificate names the child and + // never the certifier, so a member of the + // subgroup who is not also in the parent has + // the id and nothing else to call it by. + val name = parent.room + ?.chatRoom + ?.subject + ?.takeIf { it.isNotBlank() } + + // Shown either way -- as the headline where + // there is no name, and under it where there + // is, since it is the part that can be + // compared against what the lineage says. + val identifier: @Composable () -> Unit = { + Text( + text = parent.chatRoomId + .take(16) + .inComparableGroups(), + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis + ) + } + Card( modifier = Modifier.fillMaxWidth(), onClick = { onNavigateToRoute.invoke( ChatRoomDetailRoute( activeUserPublicKey = activeUserPublicKey, - chatRoomId = parentChatRoomId, + chatRoomId = parent.chatRoomId, relayHint = relayHint ) ) @@ -307,19 +334,25 @@ fun ChatRoomDetailScreen( contentDescription = Decorative ) }, - headlineContent = { + // The label keeps its place above the + // name rather than being replaced by + // it: a card carrying a group's name + // and nothing else says nothing about + // which way the lineage runs, and the + // signing key's card -- same icon + // column, same shape -- sits directly + // above this one. + overlineContent = { Text(text = stringResource(Res.string.parent_group)) }, - supportingContent = { - Text( - text = parentChatRoomId - .take(16) - .inComparableGroups(), - fontFamily = FontFamily.Monospace, - maxLines = 1, - overflow = TextOverflow.MiddleEllipsis - ) - } + headlineContent = { + if (name != null) { + Text(text = name) + } else { + identifier() + } + }, + supportingContent = identifier.takeIf { name != null } ) } } @@ -582,35 +615,74 @@ fun ChatRoomDetailScreen( HorizontalDivider() } - // TODO: Add projects... - item { - // Artifacts + // Projects -- what the library above is for Text( text = stringResource(Res.string.projects), style = MaterialTheme.typography.labelMedium ) } - item { - Text(stringResource(Res.string.no_projects_exists_in_this_group)) + if (chatRoomDetailUIState.projects.isEmpty()) { + item { + Text(stringResource(Res.string.no_projects_exists_in_this_group)) + } + } else { + items( + items = chatRoomDetailUIState.projects, + key = { project -> project.id } + ) { project -> + Card( + onClick = { + onNavigateToRoute.invoke( + ProjectDetailRoute( + activeUserPublicKey = activeUserPublicKey, + projectId = project.id, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) { + ListItem( + leadingContent = { + Icon( + Icons.Default.Schema, + contentDescription = Decorative + ) + }, + trailingContent = { + Icon( + Icons.Default.ChevronRight, + contentDescription = Decorative + ) + }, + headlineContent = { + Text(text = project.name) + }, + supportingContent = { + Text(text = project.description) + } + ) + } + } } item { TextButton( onClick = { - // Navigate to the invite user screen... onNavigateToRoute.invoke( - ImplementationPendingRoute( - "Add new project" + AddProjectRoute( + chatRoomId = chatRoomId, + activeUserPublicKey = activeUserPublicKey, + relayHint = relayHint ) ) } ) { - Icon( Icons.Default.Schema, - contentDescription = "create new projec" + contentDescription = Decorative ) Spacer( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt index e601a120..444a1f92 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt @@ -48,6 +48,9 @@ import press.mantra.compose.database.model.types.FrostSigningStage import press.mantra.compose.managers.FrostSigningManager import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.nostr.nip30303.ChunkEvent +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.repository.NostrRepository import press.mantra.compose.text.ProposedEvent import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar import press.mantra.compose.ui.theme.MantraTheme @@ -93,6 +96,8 @@ fun FrostSigningScreen( initialFrostSigningUIState: FrostSigningUIState = FrostSigningUIState.Loading, chatRepository: ChatRepository, frostSigningRepository: FrostSigningRepository, + mantraRepository: MantraRepository, + nostrRepository: NostrRepository, onNavigateBack: () -> Unit, ) { val frostSigningViewModel: FrostSigningViewModel = viewModel( @@ -102,7 +107,9 @@ fun FrostSigningScreen( activeUserPublicKey = activeUserPublicKey, initialFrostSigningUIState = initialFrostSigningUIState, chatRepository = chatRepository, - frostSigningRepository = frostSigningRepository + frostSigningRepository = frostSigningRepository, + mantraRepository = mantraRepository, + nostrRepository = nostrRepository ) ) @@ -165,7 +172,12 @@ fun FrostSigningScreen( .padding(MaterialTheme.spacing.space250), verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space200) ) { - WhatIsBeingSigned(proposed, state.items.size) + WhatIsBeingSigned( + events = proposed, + expected = state.items.size, + chatRoomId = state.localChatRoom.chatRoom.id, + names = state.names, + ) HorizontalDivider() @@ -335,7 +347,12 @@ private fun Loading(padding: androidx.compose.foundation.layout.PaddingValues) { * sign something they cannot check. */ @Composable -private fun WhatIsBeingSigned(events: List, expected: Int) { +private fun WhatIsBeingSigned( + events: List, + expected: Int, + chatRoomId: String, + names: Map, +) { if (expected == 0 || events.size != expected) { Text( text = if (expected <= 1) { @@ -358,10 +375,14 @@ private fun WhatIsBeingSigned(events: List, expected: Int) { ) } + // The chunk count a chapter reports is a count of this batch, so it + // comes from here rather than from re-reading the chapter's text. + val chunkCount = events.count { it.kind == ChunkEvent.KIND } + events.forEachIndexed { index, event -> if (index > 0) HorizontalDivider() - OneThingBeingSigned(event) + OneThingBeingSigned(event, chatRoomId, names, chunkCount) } Text( @@ -376,8 +397,22 @@ private fun WhatIsBeingSigned(events: List, expected: Int) { /** One event of the batch, described as the thing it is. */ @Composable -private fun OneThingBeingSigned(event: Event) { - val summary = ProposedEvent.summarize(event) +private fun OneThingBeingSigned( + event: Event, + chatRoomId: String, + names: Map, + chunkCount: Int, +) { + // The room, so a project owned by this group does not say so on every line; + // the names, so what is being signed is a thing rather than a hash; and the + // chunk count, because a chapter's is a count of this batch rather than + // something to be re-derived from its text. + val summary = ProposedEvent.summarize( + event = event, + chatRoomId = chatRoomId, + resolve = names::get, + chunkCount = chunkCount, + ) Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space50)) { Text(text = summary.label, style = MaterialTheme.typography.labelMedium) @@ -422,6 +457,8 @@ private fun FrostSigningScreenPreview() { ), chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, onNavigateBack = {} ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt index 9cf1ec42..638c6497 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt @@ -80,6 +80,7 @@ import mantra.composeapp.generated.resources.messages_need_the_secret_key_this_p import mantra.composeapp.generated.resources.sign_in_with_the_nsec import press.mantra.compose.ui.composable.widgets.LocalCanSign import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.ViewModelStoreScopedTo import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab import press.mantra.compose.ui.theme.ConformancePreviews @@ -242,7 +243,18 @@ fun HomeScreen( key(true) { chatRoomListViewModel.RenderFeed( - onNavigateToDirectMessageDetail = onOpenChatRoom + onNavigateToDirectMessageDetail = onOpenChatRoom, + // Only where there is a pane to be selected + // into. The selection outlives a window + // that narrows -- it is `rememberSaveable`, + // and a tablet turned to portrait keeps it + // -- so this is read from the layout rather + // than from whether a room is selected. + isSelected = if (listPaneWidth == null) { + null + } else { + { id -> id == selectedChatRoomId } + }, ) } @@ -311,10 +323,17 @@ fun HomeScreen( message = stringResource(Res.string.pick_a_conversation_to_read_it_here) ) } else { - // Keyed on the room, so switching rooms rebuilds the - // screen's view models rather than feeding a new id to - // ones already subscribed to another room's relay. + // Two things, and the pane needs both. `key` rebuilds + // the composition -- the composer's draft text does not + // follow you into the next room, and the effect that + // loads a room runs again. `ViewModelStoreScopedTo` + // rebuilds the view models, which `key` cannot: they + // hang off the store, and on this screen the store is + // `HomeRoute`'s and never changes, so without it the + // pane kept answering with the first room it was given + // however many were tapped after it. key(chatRoomId) { + ViewModelStoreScopedTo(chatRoomId) { ChatRoomMessagingScreen( activeUserPublicKey = activeUserPublicKey, chatRoomId = chatRoomId, @@ -333,6 +352,7 @@ fun HomeScreen( onNavigateBack = null, ) } + } } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProjectDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProjectDetailScreen.kt new file mode 100644 index 00000000..6f03a595 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProjectDetailScreen.kt @@ -0,0 +1,367 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.height +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.Article +import androidx.compose.material.icons.filled.PostAdd +import androidx.compose.material.icons.filled.Translate +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +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_source_version_to_project +import mantra.composeapp.generated.resources.add_translation_to_project +import mantra.composeapp.generated.resources.created_by +import mantra.composeapp.generated.resources.details +import mantra.composeapp.generated.resources.no_source_versions_in_this_project +import mantra.composeapp.generated.resources.no_translations_in_this_project +import mantra.composeapp.generated.resources.owned_by_this_group +import mantra.composeapp.generated.resources.owners +import mantra.composeapp.generated.resources.project_detail +import mantra.composeapp.generated.resources.source_versions +import mantra.composeapp.generated.resources.translations +import mantra.composeapp.generated.resources.version +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.database.model.Profile +import press.mantra.compose.extensions.shortened +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.navigation.routes.AddToProjectRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.navigation.routes.TranslationArtifactVersionDetailRoute +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.DetailRow +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.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.ProjectDetailViewModel +import press.mantra.compose.ui.view.state.ProjectDetailUIState + +/** + * What a body of work is, and who it belongs to. + * + * The two people rows are not decoration. A project's creator and owners are + * claims the group put its signature to -- nobody else checks them, and the + * event is the only place they exist -- so this screen is where a member reads + * what was agreed after the fact. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProjectDetailScreen( + activeUserPublicKey: HexKey, + projectId: String, + chatRoomId: String, + relayHint: String?, + initialProjectDetailUIState: ProjectDetailUIState = ProjectDetailUIState.Loading, + mantraRepository: MantraRepository, + nostrRepository: NostrRepository, + frostSigningRepository: FrostSigningRepository, + onNavigateToRoute: (Route) -> Unit, + onNavigateBack: () -> Unit, +) { + val projectDetailViewModel: ProjectDetailViewModel = viewModel( + factory = ProjectDetailViewModel.factory( + activeUserPublicKey = activeUserPublicKey, + projectId = projectId, + chatRoomId = chatRoomId, + relayHint = relayHint, + initialProjectDetailUIState = initialProjectDetailUIState, + mantraRepository = mantraRepository, + nostrRepository = nostrRepository, + frostSigningRepository = frostSigningRepository, + ) + ) + + ScreenStateTransition(projectDetailViewModel.projectDetailUIState) { uiState -> + when (val projectDetailUIState = uiState) { + is ProjectDetailUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) + Text(text = projectDetailUIState.message) + } + } + + is ProjectDetailUIState.Loaded -> { + val project = projectDetailUIState.project + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { Text(project.name) }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) + } + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .padding(innerPadding) + .readableContent() + .fillMaxSize() + .padding(MaterialTheme.spacing.space250), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + if (project.description.isNotBlank()) { + item { Text(project.description) } + item { HorizontalDivider() } + } + + // Source versions + item { + Text( + text = stringResource(Res.string.source_versions), + style = MaterialTheme.typography.labelMedium + ) + } + if (projectDetailUIState.sourceVersions.isEmpty()) { + item { + Text(stringResource(Res.string.no_source_versions_in_this_project)) + } + } else { + items( + items = projectDetailUIState.sourceVersions, + key = { version -> version.id } + ) { version -> + Card { + ListItem( + leadingContent = { + Icon( + Icons.Default.Article, + contentDescription = Decorative + ) + }, + overlineContent = { + Text(stringResource(Res.string.version)) + }, + headlineContent = { Text(version.versionLabel) } + ) + } + } + } + item { + TextButton( + enabled = projectDetailUIState.canSign, + onClick = { + onNavigateToRoute.invoke( + AddToProjectRoute( + activeUserPublicKey = activeUserPublicKey, + projectId = project.id, + chatRoomId = chatRoomId, + translations = false, + relayHint = relayHint, + ) + ) + } + ) { + Icon(Icons.Default.PostAdd, contentDescription = Decorative) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + Text(stringResource(Res.string.add_source_version_to_project)) + } + } + + item { HorizontalDivider() } + + // Translations + item { + Text( + text = stringResource(Res.string.translations), + style = MaterialTheme.typography.labelMedium + ) + } + if (projectDetailUIState.translations.isEmpty()) { + item { + Text(stringResource(Res.string.no_translations_in_this_project)) + } + } else { + items( + items = projectDetailUIState.translations, + key = { translation -> translation.id } + ) { translation -> + Card( + onClick = { + onNavigateToRoute.invoke( + TranslationArtifactVersionDetailRoute( + activeUserPublicKey = activeUserPublicKey, + translationArtifactVersionId = translation.id, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) { + ListItem( + leadingContent = { + Icon( + Icons.Default.Translate, + contentDescription = Decorative + ) + }, + headlineContent = { Text(translation.name) } + ) + } + } + } + item { + TextButton( + enabled = projectDetailUIState.canSign, + onClick = { + onNavigateToRoute.invoke( + AddToProjectRoute( + activeUserPublicKey = activeUserPublicKey, + projectId = project.id, + chatRoomId = chatRoomId, + translations = true, + relayHint = relayHint, + ) + ) + } + ) { + Icon(Icons.Default.Translate, contentDescription = Decorative) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + Text(stringResource(Res.string.add_translation_to_project)) + } + } + + item { HorizontalDivider() } + + // Who it belongs to + item { + Text( + text = stringResource(Res.string.details), + style = MaterialTheme.typography.labelMedium + ) + } + item { + val thisGroup = stringResource(Res.string.owned_by_this_group) + + Card { + DetailRow( + label = stringResource(Res.string.created_by), + value = describe( + publicKey = project.creatorPublicKey, + people = projectDetailUIState.people, + chatRoomId = chatRoomId, + thisGroup = thisGroup, + ) + ) + + // The room first, so the ordinary single-owner + // case is one unremarkable line and an outside + // owner is the one that stands out. + project.ownerPublicKeySet() + .sortedBy { if (it == chatRoomId) 0 else 1 } + .forEach { owner -> + DetailRow( + label = stringResource(Res.string.owners), + value = describe( + publicKey = owner, + people = projectDetailUIState.people, + chatRoomId = chatRoomId, + thisGroup = thisGroup, + ) + ) + } + } + } + } + } + } + + ProjectDetailUIState.Loading -> { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(Res.string.project_detail), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + LoadingDataIndicator(fillScreen = false) + Spacer(modifier = Modifier.weight(2f)) + } + } + } + } + + LaunchedEffect(true) { + if (initialProjectDetailUIState == ProjectDetailUIState.Loading) { + projectDetailViewModel.initiateProjectDetail() + } + } +} + +/** + * A key as a person, as this group, or as a short hash -- in that order. + * + * The room's own key reads as the group rather than as its id: a member + * standing inside the room does not need it spelled back at them, and "this + * group" is what the owner actually is. + */ +private fun describe( + publicKey: HexKey, + people: Map, + chatRoomId: String, + thisGroup: String, +): String = when { + publicKey == chatRoomId -> thisGroup + else -> people[publicKey]?.humanReadableNameOrPubkey() ?: publicKey.shortened() +} + +@ConformancePreviews +@Composable +private fun ProjectDetailScreenPreview() { + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + ProjectDetailScreen( + activeUserPublicKey = "a".repeat(64), + projectId = "p".repeat(64), + chatRoomId = "b".repeat(64), + relayHint = null, + mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + onNavigateToRoute = {}, + onNavigateBack = {}, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectAdoptingGroupScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectAdoptingGroupScreen.kt index 16313ef2..76fe9e43 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectAdoptingGroupScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectAdoptingGroupScreen.kt @@ -26,7 +26,6 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey -import kotlinx.coroutines.launch import mantra.composeapp.generated.resources.Res import mantra.composeapp.generated.resources.asked_that_group_to_adopt_this_one import mantra.composeapp.generated.resources.no_group_here_can_adopt_this_one @@ -40,6 +39,7 @@ import press.mantra.compose.ui.composable.widgets.ErrorState 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.rememberNotifier import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.theme.spacing @@ -52,8 +52,15 @@ import press.mantra.compose.ui.view.state.SelectAdoptingGroupUIState * Tapping a row asks that group's quorum to certify this room as its child. That * is an **offer** and nothing more: this room becomes their subgroup only once * its own quorum re-announces its key state naming them, which is a card back on - * the room's own screen. So this hands back rather than going anywhere new, and - * the snackbar says what was asked rather than what was done. + * the room's own screen. So the snackbar says what was *asked* rather than what + * was done. + * + * **It does not navigate away on success**, and that is the fix for two things at + * once. A message launched here while the screen was being popped was racing its + * own scope's cancellation; and the list is the better confirmation anyway -- the + * row that was just asked leaves it, because a certificate now stands for the + * pair and `refuseAdoption` says so. The way out is the back button, which a + * pushed screen has to have regardless. * * The list is the profile's own rooms -- see `SelectAdoptingGroupViewModel` for * why it can be nothing else -- so empty is an ordinary answer and the empty @@ -80,12 +87,10 @@ fun SelectAdoptingGroupScreen( LaunchedEffect(chatRoomId) { viewModel.initiate() } val snackbarHostState = LocalSnackbarHostState.current - val scope = rememberCoroutineScope() - // Read outside the handler: both of these are composable and an onClick - // lambda is not. The notifier takes the caller's scope because the message is - // shown as this screen navigates away, and one launched in the departing - // composable's scope would be cancelled with it. + // Both read outside the handler, because both are composable and an onClick + // lambda is not. + val notify = rememberNotifier(rememberCoroutineScope()) val asked = stringResource(Res.string.asked_that_group_to_adopt_this_one) ScreenStateTransition(viewModel.uiState) { uiState -> @@ -145,13 +150,11 @@ fun SelectAdoptingGroupScreen( Card( modifier = Modifier.fillMaxWidth(), onClick = { - scope.launch { - viewModel.offerTo(candidate.chatRoom.id) { refusal -> - scope.launch { - snackbarHostState.showSnackbar(refusal ?: asked) - } - if (refusal == null) onNavigateBack() - } + // `offerTo` is not suspending and calls back + // on the main thread, so there is nothing to + // launch around it. + viewModel.offerTo(candidate.chatRoom.id) { message -> + notify(message ?: asked) } } ) { 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..357538c0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UploadChaptersScreen.kt @@ -0,0 +1,556 @@ +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", + subject = "Message title", + description = "See something. Say somethin" + ), + ), + 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 5ef7f7ad..a55be873 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 @@ -138,20 +138,28 @@ import kotlinx.coroutines.launch import press.mantra.compose.database.repository.DatabaseMantraRepository import press.mantra.compose.ui.composable.AddArtifactScreen import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute +import press.mantra.compose.ui.composable.navigation.routes.AddProjectRoute +import press.mantra.compose.ui.composable.navigation.routes.AddToProjectRoute import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.DkgRitualListRoute 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.ProjectDetailRoute import press.mantra.compose.ui.composable.navigation.routes.ChapterDetailRoute import press.mantra.compose.ui.composable.navigation.routes.TranslateChunkRoute import press.mantra.compose.ui.composable.navigation.routes.TranslationChapterRoute import press.mantra.compose.ui.composable.navigation.routes.TranslationArtifactVersionDetailRoute +import press.mantra.compose.ui.composable.AddProjectScreen +import press.mantra.compose.ui.composable.AddToProjectScreen import press.mantra.compose.ui.composable.ArtifactDetailScreen +import press.mantra.compose.ui.composable.ProjectDetailScreen 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 @@ -1111,6 +1119,71 @@ fun MantraNavHost( }, ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + AddToProjectScreen( + activeUserPublicKey = route.activeUserPublicKey, + projectId = route.projectId, + chatRoomId = route.chatRoomId, + translations = route.translations, + relayHint = route.relayHint, + chatRepository = databaseChatRepository, + mantraRepository = databaseMantraRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRouteAndPopUpInclusive = { signingRoute -> + // Replace this picker so back returns to the project + // rather than to a form whose proposal has gone out. + navController.navigate( + route = signingRoute + ) { + popUpTo(route) { + inclusive = true + } + } + }, + onNavigateToRoute = { actionRoute -> + navController.navigate( + route = actionRoute + ) + }, + onNavigateBack = { + navController.popBackStack() + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + AddProjectScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + nostrRepository = databaseNostrRepository, + chatRepository = databaseChatRepository, + mantraRepository = databaseMantraRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRouteAndPopUpInclusive = { signingRoute -> + // Replace this add screen so back returns to the group rather + // than to a form whose proposal has already gone out. + navController.navigate( + route = signingRoute + ) { + popUpTo(route) { + inclusive = true + } + } + }, + onNavigateToRoute = { actionRoute -> + navController.navigate( + route = actionRoute + ) + }, + onNavigateBack = { + navController.popBackStack() + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() @@ -1200,6 +1273,8 @@ fun MantraNavHost( sessionId = route.sessionId, chatRepository = databaseChatRepository, frostSigningRepository = databaseFrostSigningRepository, + mantraRepository = databaseMantraRepository, + nostrRepository = databaseNostrRepository, onNavigateBack = { navController.popBackStack() } @@ -1256,6 +1331,27 @@ fun MantraNavHost( } ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + ProjectDetailScreen( + activeUserPublicKey = route.activeUserPublicKey, + projectId = route.projectId, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + mantraRepository = databaseMantraRepository, + nostrRepository = databaseNostrRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRoute = { actionRoute -> + navController.navigate( + route = actionRoute + ) + }, + onNavigateBack = { + navController.popBackStack() + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() @@ -1371,6 +1467,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/AddProjectRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddProjectRoute.kt new file mode 100644 index 00000000..080b33bf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddProjectRoute.kt @@ -0,0 +1,10 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +@Serializable +data class AddProjectRoute( + val chatRoomId: String, + val activeUserPublicKey: String, + val relayHint: String? +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddToProjectRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddToProjectRoute.kt new file mode 100644 index 00000000..d4e48a3b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddToProjectRoute.kt @@ -0,0 +1,20 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** + * [translations] picks which half of a project this adds to: its translations + * when true, its source versions when false. + * + * One route rather than two because the screen is the same screen -- a list to + * pick from and one proposal -- and the two differ only in which list and which + * kind. Two routes would be two view models with one body between them. + */ +@Serializable +data class AddToProjectRoute( + val activeUserPublicKey: String, + val projectId: String, + val chatRoomId: String, + val translations: Boolean, + val relayHint: String? +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProjectDetailRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProjectDetailRoute.kt new file mode 100644 index 00000000..ac483695 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProjectDetailRoute.kt @@ -0,0 +1,11 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +@Serializable +data class ProjectDetailRoute( + val activeUserPublicKey: String, + val projectId: String, + val chatRoomId: String, + val relayHint: String? +): Route() 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/composable/widgets/ViewModelStoreScopedTo.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/ViewModelStoreScopedTo.kt new file mode 100644 index 00000000..5577b429 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/ViewModelStoreScopedTo.kt @@ -0,0 +1,51 @@ +package press.mantra.compose.ui.composable.widgets + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner + +/** + * Gives [content] a `ViewModelStore` of its own, cleared whenever [scopeKey] changes. + * + * **`key(...)` does not do this, which is the whole reason this exists.** A composition + * key replaces a group -- `remember`s are forgotten, `LaunchedEffect`s restart -- but + * `viewModel()` is not composition state. It resolves against + * `LocalViewModelStoreOwner.current` under a key derived from the *class*, so two + * compositions of one screen under one owner get the same instance no matter how they are + * keyed, and the factory is never invoked for the second. Being inside `key(someId)` buys + * nothing: the store is the navigation entry's, and it outlives every group in it. + * + * That is only a problem where one screen is composed for a succession of subjects without + * the navigation entry changing, which in this app means a list-detail pane: the home + * screen stays on `HomeRoute` while the detail pane is asked for one chat room after + * another. A pushed route has no such problem, because each `NavBackStackEntry` is already + * a store of its own -- which is why this only wraps the pane. + * + * The store is cleared on the way out rather than left to the parent, so the room just + * left does not keep its relay subscriptions and database collectors running. That is the + * other half of the job: keying the `viewModel()` calls by room id instead would fix what + * is on screen and quietly accumulate a live view model per room visited. + * + * Nothing in this app puts a `SavedStateHandle` in a view model, so a bare + * `ViewModelStoreOwner` is the whole contract a scope needs here; it deliberately does not + * implement `HasDefaultViewModelProviderFactory`, because every call inside passes its own + * factory. + */ +@Composable +fun ViewModelStoreScopedTo(scopeKey: Any?, content: @Composable () -> Unit) { + val owner = remember(scopeKey) { + object : ViewModelStoreOwner { + override val viewModelStore = ViewModelStore() + } + } + + DisposableEffect(owner) { + onDispose { owner.viewModelStore.clear() } + } + + CompositionLocalProvider(LocalViewModelStoreOwner provides owner, content = content) +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt index bbe3e9d7..8bde3330 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt @@ -18,9 +18,11 @@ import kotlinx.coroutines.IO import kotlinx.coroutines.launch import press.mantra.compose.database.model.MantraArtifactVersion import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.managers.ChapterIndexing +import press.mantra.compose.managers.ChapterScaffolding import press.mantra.compose.managers.FrostSigningManager -import press.mantra.compose.managers.TranslationScaffold import press.mantra.compose.nostr.nip30303.ChapterEvent +import press.mantra.compose.nostr.nip30303.ChapterProposal import press.mantra.compose.nostr.nip30303.ChunkEvent import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.FrostSigningRepository @@ -100,12 +102,21 @@ class AddChapterViewModel( ) { val name = nameField.text.toString() val originalText = originalTextField.text.toString() - val paragraphs = Markdown.splitParagraphs(originalText) + val chunks = Markdown.splitChunks(originalText) - // The cap is the group's, not this screen's, and proposing past it - // throws rather than failing softly. The form says so as it is typed; - // this is the check that has to hold when it does not. - if (name.isBlank() || paragraphs.isEmpty() || paragraphs.size > MAX_CHUNKS_PER_CHAPTER) { + // Both caps are the group's, not this screen's, and proposing past + // either throws rather than failing softly. The form says so as it is + // typed; these are the checks that have to hold when it does not. + // + // Two caps rather than one because they bound different things: how many + // events a session signs, and how many bytes the proposal carrying them + // weighs. A chapter of sixty-three one-word paragraphs passes the second + // and fails the first; one of three very long ones does the reverse. + if (name.isBlank() || + chunks.isEmpty() || + chunks.size > MAX_CHUNKS_PER_CHAPTER || + !ChapterProposal.fits(originalText, chunks) + ) { onFailure.invoke() return } @@ -121,7 +132,17 @@ class AddChapterViewModel( // may have added to in a different order. The scaffolding proposed // below is placed at the same one, so a translation chapter agrees // with the chapter it stands for. - val chapterIndex = mantraRepository.getChaptersForArtifactVersion(artifactVersion.id).size + // + // Counting the room's open proposals as well as its rows, because a + // chapter proposed a second ago is not a row yet and reading the + // table alone would hand it this chapter's number -- see + // [ChapterIndexing.nextIndex]. + val chapterIndex = ChapterIndexing.nextIndex( + mantraRepository = mantraRepository, + frostSigningRepository = frostSigningRepository, + chatRoomId = chatRoomId, + artifactVersionId = artifactVersion.id, + ) val chapterEventTemplate = ChapterEvent.build( artifactVersionId = artifactVersion.id, @@ -177,36 +198,18 @@ class AddChapterViewModel( /** * Asks the group to scaffold this chapter into every translation of the - * version, in sessions of their own. + * version, in a session of its own. * - * Its own session rather than more items on the chapter's, because the two - * would otherwise compete for one batch: a chapter is capped at - * [MAX_CHUNKS_PER_CHAPTER] paragraphs, and every dialect the group works in - * would take one of those places away. How long a chapter may be and how - * many languages it is read in have nothing to do with each other, and a - * shared cap would make the first move whenever somebody else did the - * second. + * The reasoning and the failure policy are [ChapterScaffolding]'s; this is + * the one-chapter call into it. Sharing that code with the upload screen is + * the point -- there is one answer to what a chapter's scaffolding is, and + * two copies of a cross product would be two chances to disagree about what + * a translation covers. * - * Sessions run independently -- every message carries the session it - * belongs to, and each mints its own nonce material -- so a second one - * opened here costs the admins another approval and nothing else. - * - * **What a separate session gives up.** A batch is all-or-nothing; two - * sessions are not. If the chapter fails to reach a quorum while this one - * succeeds, these are valid signatures over rows naming a chapter nobody - * has: they fail a foreign key on the way in, are logged by - * `applySignedEvent`, and never become rows. Harmless, and not recoverable - * either -- a re-proposed chapter is a different id -- so the catch-up on - * the translation is what fills the gap afterwards. - * - * The chapter is named as the group will author it: [chapterSessionId]'s - * first item is the unsigned chapter, whose id is the hash its signers are - * putting their shares behind, and it is settled at proposal time rather - * than when the quorum arrives. - * - * Failure here is logged and swallowed. The chapter has already been - * proposed and is the thing that was asked for; losing it because its - * scaffolding could not be opened would be the wrong way round. + * Called before navigating, not after: the route this screen sits on is + * popped on success, which clears this view model and takes + * `viewModelScope` with it, so work launched afterwards would be cancelled + * somewhere in the middle. */ private suspend fun scaffoldIntoTranslations( localChatRoom: LocalChatRoom, @@ -214,40 +217,19 @@ class AddChapterViewModel( chapterIndex: Int, chapterSessionId: String, ) { - runCatching { - val translations = mantraRepository - .getTranslationsForArtifactVersion(artifactVersion.id) - .ifEmpty { return@runCatching } - - val chapter = frostSigningRepository - .unsignedEvents(frostSigningRepository.getItems(chapterSessionId)) - .firstOrNull() ?: return@runCatching - - // One session per MAX_BATCH_SIZE translations. A group with more - // dialects than a batch holds is not a group this should refuse; - // it is one that answers twice. - translations.chunked(FrostSigningManager.MAX_BATCH_SIZE).forEach { batch -> - frostSigningRepository.proposeSigningBatch( - localChatRoom = localChatRoom, - userPublicKey = activeUserPublicKey, - events = TranslationScaffold.chaptersOf( - translationArtifactVersionIds = batch.map { it.id }, - chapters = listOf( - TranslationScaffold.SourceChapter( - id = chapter.id, - index = chapterIndex, - ) - ), - createdAt = chapter.createdAt, - ) + ChapterScaffolding.propose( + mantraRepository = mantraRepository, + frostSigningRepository = frostSigningRepository, + localChatRoom = localChatRoom, + userPublicKey = activeUserPublicKey, + artifactVersionId = artifactVersion.id, + chapters = listOf( + ChapterScaffolding.ProposedChapter( + sessionId = chapterSessionId, + index = chapterIndex, ) - } - }.onFailure { error -> - logger.e( - "Proposed the chapter but could not ask for it in this version's translations", - error - ) - } + ), + ) } companion object { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddProjectViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddProjectViewModel.kt new file mode 100644 index 00000000..2f3e6488 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddProjectViewModel.kt @@ -0,0 +1,206 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +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 com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.AddProjectUIState + +class AddProjectViewModel( + val chatRoomId: String, + val activeUserPublicKey: HexKey, + val relayHint: String?, + initialAddProjectUIState: AddProjectUIState, + val nostrRepository: NostrRepository, + val chatRepository: ChatRepository, + val mantraRepository: MantraRepository, + val frostSigningRepository: FrostSigningRepository, +): ViewModel() { + + var addProjectUIState: AddProjectUIState by mutableStateOf(initialAddProjectUIState) + private set + + private val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + fun initiateAddProject() { + logger.d("initiateAddProject: $chatRoomId") + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + + addProjectUIState = if (localChatRoom == null) { + AddProjectUIState.Error("Couldn't find the chat room") + } else { + AddProjectUIState.Loaded( + localChatRoom = localChatRoom, + sourceVersions = sourceVersionsOf(localChatRoom.chatRoom.id), + translations = translationsOf(localChatRoom.chatRoom.id), + canSign = frostSigningRepository.canSign(chatRoomId), + ) + } + } + } + + /** + * Every version in the room's library, walked down from its artifacts. + * + * There is no by-room query for versions -- a version hangs off an artifact, + * which is the same reason the chronicle walks the tree rather than querying + * per kind. A room's library is small and this runs once when the form + * opens. + */ + private suspend fun sourceVersionsOf(chatRoomId: String) = + mantraRepository.getArtifacts(chatRoomId) + .flatMap { mantraRepository.getArtifactVersions(it.id) } + + private suspend fun translationsOf(chatRoomId: String) = + sourceVersionsOf(chatRoomId) + .flatMap { mantraRepository.getTranslationsForArtifactVersion(it.id) } + + /** + * Asks the group to sign a project into being, with whatever it starts with. + * + * The project is not created here and does not exist yet, the way an + * artifact does not: what goes out is a proposal, and the project appears on + * every member's device at once -- authored by the room's own key -- when + * enough members have signed. + * + * One session over the project and its first contents. The memberships + * cannot be built by this screen because each carries the project's id, + * which is a hash over the group's key at the room's derivation path: it + * does not exist until the proposal is made. So the project leads and the + * memberships are derived from it, the same shape an artifact and its first + * version use. + * + * All-or-nothing, which is right. A project is a statement about which work + * belongs together, and half of that statement signed is not a smaller + * version of it. + * + * [creatorPublicKey] is this member and is not asked for. [ownerPublicKeys] + * is asked for and defaults to the room, and must not be empty -- the event + * builder refuses one, and the form keeps the last entry from being removed + * rather than letting it get that far. + */ + fun addProject( + localChatRoom: LocalChatRoom, + nameField: TextFieldState, + descriptionField: TextFieldState, + ownerPublicKeys: Set, + sourceVersionIds: List, + translationIds: List, + onSuccess: (sessionId: String) -> Unit, + onFailure: () -> Unit + ) { + val name = nameField.text.toString() + val description = descriptionField.text.toString() + + if (name.isBlank() || ownerPublicKeys.isEmpty()) { + onFailure.invoke() + return + } + + // Guard against double submits from repeated FAB taps. + if (isActionPending.value) return + isActionPending.value = true + + viewModelScope.launch(Dispatchers.IO) { + val projectEventTemplate = ProjectEvent.build( + name = name, + description = description, + creatorPublicKey = activeUserPublicKey, + ownerPublicKeys = ownerPublicKeys, + ) + + val session = runCatching { + frostSigningRepository.proposeSigningBatch( + localChatRoom = localChatRoom, + userPublicKey = activeUserPublicKey, + lead = projectEventTemplate, + dependents = { project -> + buildList> { + addAll( + ProjectArtifactVersionEvent.membershipsOf( + project = project, + artifactVersionIds = sourceVersionIds, + ) + ) + addAll( + ProjectTranslationArtifactVersionEvent.membershipsOf( + project = project, + translationArtifactVersionIds = translationIds, + ) + ) + } + } + ) + }.onFailure { error -> + logger.e("Failed to propose a project for signing", error) + }.getOrNull() + + if (session != null) { + nameField.clearText() + descriptionField.clearText() + + viewModelScope.launch(Dispatchers.Main) { + onSuccess.invoke(session.id) + } + } else { + viewModelScope.launch(Dispatchers.Main) { + onFailure.invoke() + } + } + + isActionPending.value = false + } + } + + companion object { + private const val TAG = "AddProjectViewModel" + + fun factory( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + initialAddProjectUIState: AddProjectUIState = AddProjectUIState.Loading, + nostrRepository: NostrRepository, + chatRepository: ChatRepository, + mantraRepository: MantraRepository, + frostSigningRepository: FrostSigningRepository + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + AddProjectViewModel( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + initialAddProjectUIState = initialAddProjectUIState, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + mantraRepository = mantraRepository, + frostSigningRepository = frostSigningRepository + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddToProjectViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddToProjectViewModel.kt new file mode 100644 index 00000000..579e233f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddToProjectViewModel.kt @@ -0,0 +1,190 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +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.launch +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.ui.view.state.AddToProjectUIState + +/** + * Adding one more piece of work to a project that already exists. + * + * The other half of `AddProjectViewModel`, and the simpler one: the project's + * id is known, so there is no batch and no lead -- one event, one session. + */ +class AddToProjectViewModel( + val projectId: String, + val chatRoomId: String, + val activeUserPublicKey: HexKey, + val translations: Boolean, + val relayHint: String?, + initialAddToProjectUIState: AddToProjectUIState, + val chatRepository: ChatRepository, + val mantraRepository: MantraRepository, + val frostSigningRepository: FrostSigningRepository, +): ViewModel() { + + var addToProjectUIState: AddToProjectUIState by mutableStateOf(initialAddToProjectUIState) + private set + + private val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + fun initiateAddToProject() { + logger.d("initiateAddToProject: $projectId translations=$translations") + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + val project = mantraRepository.getProject(projectId) + + addToProjectUIState = when { + localChatRoom == null -> AddToProjectUIState.Error("Couldn't find the chat room") + project == null -> AddToProjectUIState.Error("Couldn't find the project") + else -> AddToProjectUIState.Loaded( + localChatRoom = localChatRoom, + project = project, + candidates = candidatesFor(localChatRoom.chatRoom.id), + canSign = frostSigningRepository.canSign(chatRoomId), + ) + } + } + } + + /** + * What the room has that the project does not already hold. + * + * Filtered rather than shown and refused: a duplicate membership is a + * second event with its own id, so nothing downstream would stop it -- it + * would just cost a quorum to say what the group has already said. + */ + private suspend fun candidatesFor(chatRoomId: String): List { + val versions = mantraRepository.getArtifacts(chatRoomId) + .flatMap { mantraRepository.getArtifactVersions(it.id) } + + return if (translations) { + val already = mantraRepository.getProjectTranslationArtifactVersions(projectId) + .map { it.id } + .toSet() + + versions + .flatMap { mantraRepository.getTranslationsForArtifactVersion(it.id) } + .filterNot { it.id in already } + .map { + AddToProjectUIState.Candidate( + id = it.id, + label = it.name, + detail = null, + ) + } + } else { + val already = mantraRepository.getProjectArtifactVersions(projectId) + .map { it.id } + .toSet() + + versions + .filterNot { it.id in already } + .map { version -> + AddToProjectUIState.Candidate( + id = version.id, + label = version.versionLabel, + // The artifact, because a version label on its own is + // "1.0" and a room has several of those. + detail = mantraRepository.getArtifact(version.artifactId)?.name, + ) + } + } + } + + /** + * Asks the group to sign one thing into the project. + * + * One event, so [FrostSigningRepository.proposeSigning]'s flat batch rather + * than the lead-and-dependents form: this screen knows the project's id + * because the project exists. + */ + fun addToProject( + localChatRoom: LocalChatRoom, + selectedId: HexKey, + onSuccess: (sessionId: String) -> Unit, + onFailure: () -> Unit + ) { + if (isActionPending.value) return + isActionPending.value = true + + viewModelScope.launch(Dispatchers.IO) { + val template = if (translations) { + ProjectTranslationArtifactVersionEvent.build( + projectId = projectId, + translationArtifactVersionId = selectedId, + ) + } else { + ProjectArtifactVersionEvent.build( + projectId = projectId, + artifactVersionId = selectedId, + ) + } + + val session = runCatching { + frostSigningRepository.proposeSigningBatch( + localChatRoom = localChatRoom, + userPublicKey = activeUserPublicKey, + events = listOf(template), + ) + }.onFailure { error -> + logger.e("Failed to propose adding to project $projectId", error) + }.getOrNull() + + viewModelScope.launch(Dispatchers.Main) { + if (session != null) onSuccess.invoke(session.id) else onFailure.invoke() + } + + isActionPending.value = false + } + } + + companion object { + private const val TAG = "AddToProjectViewModel" + + fun factory( + activeUserPublicKey: HexKey, + projectId: String, + chatRoomId: String, + translations: Boolean, + relayHint: String?, + initialAddToProjectUIState: AddToProjectUIState = AddToProjectUIState.Loading, + chatRepository: ChatRepository, + mantraRepository: MantraRepository, + frostSigningRepository: FrostSigningRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + AddToProjectViewModel( + activeUserPublicKey = activeUserPublicKey, + projectId = projectId, + chatRoomId = chatRoomId, + translations = translations, + relayHint = relayHint, + initialAddToProjectUIState = initialAddToProjectUIState, + chatRepository = chatRepository, + mantraRepository = mantraRepository, + frostSigningRepository = frostSigningRepository, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt index 93b278f4..952e1db6 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt @@ -255,7 +255,12 @@ class ChatMessageListViewModel( // something, and the notice should say what. lead = local.items.firstNotNullOfOrNull { item -> Event.fromJsonOrNull(item.unsignedEventJson) - ?.let { ProposedEvent.summarize(it) } + ?.let { + ProposedEvent.summarize( + event = it, + chatRoomId = localChatRoom.chatRoom.id, + ) + } }, eventCount = local.items.size ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt index df5d26da..138c1aeb 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import press.mantra.compose.database.model.types.MarmotReindexReport class ChatRoomDetailViewModel( @@ -78,10 +79,11 @@ class ChatRoomDetailViewModel( localChatRoom = localChatRoom, artifacts = mantraRepository.getArtifacts(chatRoomId), dialects = mantraRepository.getDialects(chatRoomId), + projects = mantraRepository.getProjects(chatRoomId), groupKeyState = chatRepository.groupKeyState(chatRoomId), signedGroupKeyStateEvent = chatRepository.signedGroupKeyStateEvent(chatRoomId), subgroups = chatRepository.subgroupsOf(chatRoomId), - parentChatRoomIds = chatRepository.parentsOf(chatRoomId), + parents = chatRepository.parentsOf(chatRoomId), canAddSubgroup = localChatRoom.mlsGroupStateOf(activeUserPublicKey) != null && chatRepository.canSign(chatRoomId), adoptionOffers = chatRepository.adoptionOffersFor(chatRoomId), @@ -118,7 +120,13 @@ class ChatRoomDetailViewModel( if (!accepted) { logger.e("Failed to ask $chatRoomId to accept $parentChatRoomId") - onDone("Couldn't ask this group to accept that parent. Please try again.") + // On the main thread, because a caller's callback may touch + // things an IO coroutine must not -- navigation most of all. See + // `SelectAdoptingGroupViewModel.finish`, where calling back from + // IO crashed outright. + withContext(Dispatchers.Main) { + onDone("Couldn't ask this group to accept that parent. Please try again.") + } return@launch } @@ -126,7 +134,7 @@ class ChatRoomDetailViewModel( // which is a quorum away -- so this reloads rather than crossing it // off, and the card stays until the group has actually agreed. initiateChatRoomDetail() - onDone(null) + withContext(Dispatchers.Main) { onDone(null) } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt index ce21e254..d08bc3cf 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme @@ -20,6 +21,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel @@ -144,7 +147,20 @@ class ChatRoomListViewModel( @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun RenderFeed( - onNavigateToDirectMessageDetail: (press.mantra.compose.ui.composable.navigation.routes.Route) -> Unit + onNavigateToDirectMessageDetail: (press.mantra.compose.ui.composable.navigation.routes.Route) -> Unit, + /** + * Which room the detail pane beside this list is showing, or `null` where this + * list is the whole screen. + * + * A predicate rather than an id because the two layouts disagree about whether + * selection is a concept at all, not merely about its value. Where tapping a room + * navigates to it, no room is "the selected one" -- the list is about to be gone -- + * and a highlight left behind on the way out, or a reader told "not selected" + * about every room in the list, would both be describing a pane that is not there. + * `null` says that; a predicate that answers false for every room would not, and + * is also what a two-pane layout looks like before the first tap. + */ + isSelected: ((String) -> Boolean)?, ) { Column( modifier = Modifier.fillMaxWidth(), @@ -174,8 +190,30 @@ class ChatRoomListViewModel( items = chatRoomListUIState.chatRoomList, key = { it.chatRoom.id } ) { localChatRoom -> + // Null where there is no pane, so the row says nothing + // about selection at all rather than saying "no". + val selected = isSelected?.invoke(localChatRoom.chatRoom.id) + Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .then( + if (selected == null) Modifier + else Modifier.semantics { this.selected = selected } + ), + // M3's container for a selected list item. The + // colour is the only signal a reader scanning the + // list gets about which room the pane is answering + // with -- the pane's own app bar repeats the title, + // but that is the half of the window they are not + // looking at while choosing the next room. + colors = if (selected == true) { + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + } else { + CardDefaults.cardColors() + }, onClick = { onNavigateToDirectMessageDetail.invoke( press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt index feb4316f..5f6858ac 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt @@ -114,9 +114,6 @@ class DkgRitualViewModel( /** Watches the current session's messages; replaced whenever the session changes. */ private var messageObserver: Job? = null - /** Watches the room's signing sessions, which is where the key state is agreed. */ - private var keyStateObserver: Job? = null - /** Watches the parent certifying this subgroup; re-pointed as the purpose is learned. */ private var certificateObserver: Job? = null @@ -240,8 +237,6 @@ class DkgRitualViewModel( .defaultQuorum(ChillDkgRitualManager.memberPublicKeys(localChatRoom).size) .coerceIn(quorumRange()) - observeKeyState() - launch { roomSessions.collect { newest -> updateLoaded { current -> @@ -323,43 +318,6 @@ class DkgRitualViewModel( } } - /** - * Follows the group agreeing what its admin room will sign with. - * - * Nothing here watches a [GroupKeyState] row, because there is no room yet - * for one to belong to -- being able to follow this before the room exists - * is the point. What is watched instead is the session, for how the signing - * is going, and the signed event, for whether it finished. - * - * The room a ceremony runs in signs nothing else, so the newest session in - * it is the key state's -- but it is matched on the kind it carries anyway, - * so a room that one day signs something else does not confuse the two. - */ - private fun observeKeyState() { - keyStateObserver?.cancel() - keyStateObserver = viewModelScope.launch(Dispatchers.IO) { - // Two flows because they answer two questions off two tables. How - // the signing is going is the session row; whether it finished is - // the signed event, and only that second one can be asked at all - // before there is a room -- which is the whole point of asking it. - launch { - dkgRepository.observeSigningSessions(chatRoomId).collect { sessions -> - val keyState = sessions.firstOrNull { local -> - local.items.any { item -> - runCatching { - Event.fromJson(item.unsignedEventJson).kind - }.getOrNull() == GroupKeyStateEvent.KIND - } - }?.session - - updateLoaded { it.copy(keyStateSession = keyState) } - } - } - - - } - } - /** * Follows one ceremony: whether its key state has been signed, and -- for a * subgroup -- whether its parent has certified it. @@ -379,12 +337,50 @@ class DkgRitualViewModel( if (dkgSessionId == null) { updateLoaded { - it.copy(isKeyStateSigned = false, isCertified = false, certificateSession = null) + it.copy( + isKeyStateSigned = false, + keyStateSession = null, + isCertified = false, + certificateSession = null + ) } return } certificateObserver = viewModelScope.launch(Dispatchers.IO) { + // How the agreement is going, as opposed to whether it finished -- + // two questions off two tables, and only the second can be asked + // before there is a room at all, which is the point of asking it. + // + // By the ceremony the proposal names, not by kind in this room. The + // room a ceremony runs in used to sign nothing else, and that is + // what this read assumed; a parent's Marmot room now hosts the + // ceremony of every subgroup the group makes, so it holds one of + // these per subgroup. Matching on kind alone handed the screen the + // newest of them -- which is some other subgroup's -- and with it a + // progress bar for a signing this ceremony is not in and a button + // disabled on the strength of it. Permanently, once that other one + // completed: the guard only reopens on a failure. + // + // `FrostKeyTag` is on the proposal from the moment it is made, so + // this works before the ceremony has a key and before anybody signs. + launch { + dkgRepository.observeSigningSessions(chatRoomId).collect { sessions -> + val keyState = sessions.firstOrNull { local -> + local.items.any { item -> + val event = runCatching { + Event.fromJson(item.unsignedEventJson) + }.getOrNull() ?: return@any false + + event.kind == GroupKeyStateEvent.KIND && + GroupKeyStateEvent.parseDkgSessionId(event.tags) == dkgSessionId + } + }?.session + + updateLoaded { it.copy(keyStateSession = keyState) } + } + } + // Whether the group has agreed what the room this ceremony's key // derives will sign with. Asked by ceremony rather than by room: the // room holds however many ceremonies it holds, and the answer is @@ -408,14 +404,43 @@ class DkgRitualViewModel( } launch { + // The child this ceremony is making, which is what a certificate + // names. Resolved per emission rather than once, because the + // ceremony has no key until it finishes and this flow is running + // before it does -- the same reason `observeBirthCertificate` + // resolves it per emission. + // + // Scoped to that child, not to the kind. A parent certifies every + // subgroup it ever makes from the same room, so by kind alone this + // found the newest certificate signing in the parent -- some other + // child's -- and read its progress as this one's. Worse, the rung + // disables "ask the group to certify" while a certificate session + // is running and un-failed, so once a parent had certified one + // subgroup it could never be asked to certify another. dkgRepository.observeSigningSessions(parentChatRoomId).collect { sessions -> - val certificate = sessions.firstOrNull { local -> - local.items.any { item -> - runCatching { - Event.fromJson(item.unsignedEventJson).kind - }.getOrNull() == SubgroupBirthCertificateEvent.KIND - } - }?.session + val subgroupChatRoomId = dkgRepository.roomDerivedBy(dkgSessionId) + + val certificate = subgroupChatRoomId?.let { child -> + sessions.firstOrNull { local -> + local.items.any { item -> + val event = runCatching { + Event.fromJson(item.unsignedEventJson) + }.getOrNull() ?: return@any false + + // `addresses`, not `certifies`: the thing being + // matched is a proposal, and `certifies` ends on + // a signature by the parent's quorum, which is + // precisely what has not happened yet. Whether + // the parent actually agreed is `isCertified`, + // which does check it. + SubgroupBirthCertificateEvent.addresses( + event = event, + subgroupChatRoomId = child, + parentChatRoomId = parentChatRoomId, + ) + } + }?.session + } updateLoaded { it.copy(certificateSession = certificate) } } @@ -736,7 +761,6 @@ class DkgRitualViewModel( override fun onCleared() { messageObserver?.cancel() - keyStateObserver?.cancel() certificateObserver?.cancel() super.onCleared() } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt index 3dc63459..e533a1e3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt @@ -19,8 +19,13 @@ import kotlinx.coroutines.launch import press.mantra.compose.database.model.FrostSigningItem import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.nostr.frost.FrostSigningEvents +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.view.state.FrostSigningUIState class FrostSigningViewModel( @@ -30,6 +35,8 @@ class FrostSigningViewModel( initialFrostSigningUIState: FrostSigningUIState, val chatRepository: ChatRepository, val frostSigningRepository: FrostSigningRepository, + val mantraRepository: MantraRepository, + val nostrRepository: NostrRepository, ): ViewModel() { var frostSigningUIState: FrostSigningUIState by mutableStateOf(initialFrostSigningUIState) @@ -72,12 +79,83 @@ class FrostSigningViewModel( signed = messages .filter { it.kind == FrostSigningEvents.PARTIAL_SIGNATURE } .map { it.signerPublicKey } - .toSet() + .toSet(), + names = namesIn(proposedEvents(items)) ) } } } + /** + * Whatever the proposed events refer to, as words. + * + * Only the kinds that need it, and only what this device already holds. A + * key with no profile and an id with no row are both left out, and + * `ProposedEvent.summarize` falls back to a short hash -- which is truthful + * rather than a gap: the device has not seen the thing. + * + * A project's own id resolves to nothing while it is being proposed, + * because nothing has applied it yet. That is why a membership says the + * version first. + */ + private suspend fun namesIn(events: List): Map = buildMap { + suspend fun person(publicKey: HexKey) { + if (containsKey(publicKey)) return + nostrRepository.getProfileWithPublicKey(publicKey)?.let { + put(publicKey, it.humanReadableNameOrPubkey()) + } + } + + suspend fun project(projectId: HexKey?) { + if (projectId == null || containsKey(projectId)) return + mantraRepository.getProject(projectId)?.let { put(projectId, it.name) } + } + + events.forEach { event -> + when (event.kind) { + ProjectEvent.KIND -> { + val proposed = ProjectEvent( + event.id, event.pubKey, event.createdAt, + event.tags, event.content, event.sig + ) + proposed.creator()?.let { person(it) } + proposed.owners().forEach { person(it) } + } + + ProjectArtifactVersionEvent.KIND -> { + val membership = ProjectArtifactVersionEvent( + event.id, event.pubKey, event.createdAt, + event.tags, event.content, event.sig + ) + membership.artifactVersionId()?.let { versionId -> + mantraRepository.getArtifactVersion(versionId)?.let { version -> + val artifact = mantraRepository.getArtifact(version.artifactId) + put( + versionId, + listOfNotNull(artifact?.name, version.versionLabel) + .joinToString(" ") + ) + } + } + project(membership.projectId()) + } + + ProjectTranslationArtifactVersionEvent.KIND -> { + val membership = ProjectTranslationArtifactVersionEvent( + event.id, event.pubKey, event.createdAt, + event.tags, event.content, event.sig + ) + membership.translationArtifactVersionId()?.let { translationId -> + mantraRepository.getTranslation(translationId)?.let { + put(translationId, it.name) + } + } + project(membership.projectId()) + } + } + } + } + /** The events the group is being asked to sign, for showing them before they agree. */ fun proposedEvents(items: List): List = items.mapNotNull { Event.fromJsonOrNull(it.unsignedEventJson) } @@ -133,6 +211,8 @@ class FrostSigningViewModel( initialFrostSigningUIState: FrostSigningUIState = FrostSigningUIState.Loading, chatRepository: ChatRepository, frostSigningRepository: FrostSigningRepository, + mantraRepository: MantraRepository, + nostrRepository: NostrRepository, ): ViewModelProvider.Factory = viewModelFactory { initializer { FrostSigningViewModel( @@ -141,7 +221,9 @@ class FrostSigningViewModel( activeUserPublicKey = activeUserPublicKey, initialFrostSigningUIState = initialFrostSigningUIState, chatRepository = chatRepository, - frostSigningRepository = frostSigningRepository + frostSigningRepository = frostSigningRepository, + mantraRepository = mantraRepository, + nostrRepository = nostrRepository ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProjectDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProjectDetailViewModel.kt new file mode 100644 index 00000000..616a2cf3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProjectDetailViewModel.kt @@ -0,0 +1,94 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +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.launch +import press.mantra.compose.database.model.Profile +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.ProjectDetailUIState + +class ProjectDetailViewModel( + val projectId: String, + val chatRoomId: String, + val activeUserPublicKey: HexKey, + val relayHint: String?, + initialProjectDetailUIState: ProjectDetailUIState, + val mantraRepository: MantraRepository, + val nostrRepository: NostrRepository, + val frostSigningRepository: FrostSigningRepository, +): ViewModel() { + + var projectDetailUIState: ProjectDetailUIState by mutableStateOf(initialProjectDetailUIState) + private set + + private val logger = Logger.withTag(TAG) + + fun initiateProjectDetail() { + logger.d("initiateProjectDetail: $projectId") + viewModelScope.launch(Dispatchers.IO) { + val project = mantraRepository.getProject(projectId) + + projectDetailUIState = if (project == null) { + ProjectDetailUIState.Error("Couldn't find the project") + } else { + ProjectDetailUIState.Loaded( + project = project, + sourceVersions = mantraRepository.getProjectArtifactVersions(projectId), + translations = mantraRepository.getProjectTranslationArtifactVersions(projectId), + // The creator and the owners, resolved once here. A screen + // cannot await a database per row, and these are a handful + // of keys read together. + people = peopleBehind( + project.ownerPublicKeySet() + project.creatorPublicKey + ), + canSign = frostSigningRepository.canSign(chatRoomId), + ) + } + } + } + + private suspend fun peopleBehind(publicKeys: Set): Map = + publicKeys.mapNotNull { publicKey -> + nostrRepository.getProfileWithPublicKey(publicKey)?.let { publicKey to it } + }.toMap() + + companion object { + private const val TAG = "ProjectDetailViewModel" + + fun factory( + activeUserPublicKey: HexKey, + projectId: String, + chatRoomId: String, + relayHint: String?, + initialProjectDetailUIState: ProjectDetailUIState = ProjectDetailUIState.Loading, + mantraRepository: MantraRepository, + nostrRepository: NostrRepository, + frostSigningRepository: FrostSigningRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + ProjectDetailViewModel( + activeUserPublicKey = activeUserPublicKey, + projectId = projectId, + chatRoomId = chatRoomId, + relayHint = relayHint, + initialProjectDetailUIState = initialProjectDetailUIState, + mantraRepository = mantraRepository, + nostrRepository = nostrRepository, + frostSigningRepository = frostSigningRepository, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProposalListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProposalListViewModel.kt index 938d3d83..5519c6ed 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProposalListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProposalListViewModel.kt @@ -18,6 +18,7 @@ import press.mantra.compose.extensions.shortened import press.mantra.compose.managers.FrostSigningManager import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.nostr.nip30303.ChunkEvent import press.mantra.compose.text.ProposedEvent import press.mantra.compose.ui.view.state.ProposalListUIState @@ -86,7 +87,16 @@ class ProposalListViewModel( else -> local.proposer?.humanReadableNameOrPubkey() ?: local.session.coordinatorPublicKey.shortened() }, - signs = events.map { ProposedEvent.summarize(it) }, + // The chunk count is counted from the batch rather than derived + // from the chapter's text -- the number a member reads has to be the + // number of events they are approving. See ProposedEvent.summarize. + signs = events.map { event -> + ProposedEvent.summarize( + event = event, + chatRoomId = chatRoomId, + chunkCount = events.count { it.kind == ChunkEvent.KIND }, + ) + }, unreadable = items.size - events.size, awaitsYou = FrostSigningManager.isAwaitingApproval(local.session, items) ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectAdoptingGroupViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectAdoptingGroupViewModel.kt index a46ef417..d63d1fff 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectAdoptingGroupViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectAdoptingGroupViewModel.kt @@ -14,6 +14,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import press.mantra.compose.repository.ChatRepository import press.mantra.compose.ui.view.state.SelectAdoptingGroupUIState @@ -94,7 +95,7 @@ class SelectAdoptingGroupViewModel( if (refusal != null) { isActionPending.value = false - onDone(refusal) + finish(refusal, onDone) return@launch } @@ -108,14 +109,34 @@ class SelectAdoptingGroupViewModel( if (!proposed) { logger.e("Failed to ask $adoptingChatRoomId to adopt $chatRoomId") - onDone("Couldn't ask that group to adopt this one. Please try again.") + finish("Couldn't ask that group to adopt this one. Please try again.", onDone) return@launch } - onDone(null) + // Reload before handing back, so the row that was just asked leaves + // the list: a certificate now stands for the pair and + // `refuseAdoption` says so. That is the confirmation, and it is a + // better one than a message -- the list is the state. + initiate() + finish(null, onDone) } } + /** + * Hands [message] back **on the main thread**. + * + * The work runs on `Dispatchers.IO` and a caller's callback routinely touches + * things that may not be touched from there -- the first version of this + * screen called `popBackStack()` straight out of the IO coroutine and crashed + * on `Method setCurrentState must be called on the main thread`. The + * dispatcher is this function's business rather than every caller's, which is + * the shape `AddArtifactViewModel` and `SelectSubgroupAdminsViewModel` already + * use for the same handoff. + */ + private suspend fun finish(message: String?, onDone: (String?) -> Unit) { + withContext(Dispatchers.Main) { onDone(message) } + } + companion object { private const val TAG = "SelectAdoptingGroupViewModel" 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..a32f752f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UploadChaptersViewModel.kt @@ -0,0 +1,365 @@ +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.ChapterScaffolding +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 + + // A pass already running will call [onDone] itself when it finishes; + // firing it here as well would announce one pass twice. + if (loaded.isProposing) return + + val queue = loaded.proposable.map { it.id } + if (queue.isEmpty()) { + // Nothing to do still has to be said. The FAB is disabled in this + // case so the screen never reaches it, but a callback that + // sometimes never fires is a trap for the next caller -- and it is + // what a test of a second tap found. + onDone(0, 0) + return + } + + updateLoaded { it.copy(isProposing = true) } + + viewModelScope.launch(Dispatchers.IO) { + var index = nextIndex(artifactVersion) + var proposed = 0 + var failed = 0 + val scaffolded = mutableListOf() + + 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) + scaffolded += ChapterScaffolding.ProposedChapter( + sessionId = session.id, + index = index, + ) + proposed++ + index++ + } else { + markFailed(id) + failed++ + } + } + + // One batch for every chapter that went, not one per chapter. Twelve + // chapters in a three-dialect group is thirty-six join rows, which + // fit in a single session; the per-chapter shape would have opened + // twelve of them to carry three rows each. + // + // After the loop rather than inside it, so a chapter that failed to + // propose contributes no scaffolding and the ones that succeeded + // still get theirs. + ChapterScaffolding.propose( + mantraRepository = mantraRepository, + frostSigningRepository = frostSigningRepository, + localChatRoom = localChatRoom, + userPublicKey = activeUserPublicKey, + artifactVersionId = artifactVersion.id, + chapters = scaffolded, + ) + + 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/AddProjectUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddProjectUIState.kt new file mode 100644 index 00000000..df06b932 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddProjectUIState.kt @@ -0,0 +1,32 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.MantraArtifactVersion +import press.mantra.compose.database.model.MantraTranslationArtifactVersion +import press.mantra.compose.database.model.intermdiate.LocalChatRoom + +sealed interface AddProjectUIState { + data class Loaded( + val localChatRoom: LocalChatRoom, + + /** Everything in the room's library a project could be about. */ + val sourceVersions: List = emptyList(), + + /** And everything being translated. */ + val translations: List = emptyList(), + + /** + * Whether this device holds a share of the group's key. + * + * A project is proposed, not created, so a device that cannot sign + * cannot start one. Said on the form rather than discovered on the + * button. + */ + val canSign: Boolean = false, + ): AddProjectUIState + + data class Error( + val message: String + ): AddProjectUIState + + data object Loading: AddProjectUIState +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddToProjectUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddToProjectUIState.kt new file mode 100644 index 00000000..edb3862f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddToProjectUIState.kt @@ -0,0 +1,32 @@ +package press.mantra.compose.ui.view.state + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.database.model.MantraProject +import press.mantra.compose.database.model.intermdiate.LocalChatRoom + +sealed interface AddToProjectUIState { + /** + * [candidates] is whatever is not already in the project: the room's source + * versions or its translations, depending on which half this screen is for. + * + * Already-included work is filtered out rather than shown and refused. A + * membership that duplicates one the group has already signed is a second + * event with a different id, so nothing would stop it -- it would simply + * cost a quorum to say what the group has already said. + */ + data class Loaded( + val localChatRoom: LocalChatRoom, + val project: MantraProject, + val candidates: List = emptyList(), + val canSign: Boolean = false, + ): AddToProjectUIState + + /** One thing that could be added, named the way the picker shows it. */ + data class Candidate(val id: HexKey, val label: String, val detail: String?) + + data class Error( + val message: String + ): AddToProjectUIState + + data object Loading: AddToProjectUIState +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt index d357797e..319aae57 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt @@ -6,6 +6,7 @@ import press.mantra.compose.managers.SubgroupManager import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.MantraArtifact import press.mantra.compose.database.model.MantraDialect +import press.mantra.compose.database.model.MantraProject import press.mantra.compose.database.model.intermdiate.LocalChatRoom sealed interface ChatRoomDetailUIState { @@ -13,6 +14,9 @@ sealed interface ChatRoomDetailUIState { val localChatRoom: LocalChatRoom, val artifacts: List = emptyList(), val dialects: List = emptyList(), + + /** What the room's library is being used for. */ + val projects: List = emptyList(), /** * What the group has said this room signs with, or null while it has * said nothing. @@ -41,15 +45,18 @@ sealed interface ChatRoomDetailUIState { */ val subgroups: List = emptyList(), /** - * The group this room is a subgroup of, or null when it is nobody's + * The groups this room is a subgroup of, empty when it is nobody's * child. * * Verified: it comes off the room's key state, which only carries a * parent whose certificate was checked. A member welcomed in after the * founding holds no state and so sees nothing here, even in a room that * really is a subgroup. + * + * Each carries the parent's room where this device holds one, so the + * card can name the group rather than only identify it. */ - val parentChatRoomIds: List = emptyList(), + val parents: List = emptyList(), /** * Whether this device could open a subgroup here at all. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/FrostSigningUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/FrostSigningUIState.kt index 515a603f..519d1efa 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/FrostSigningUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/FrostSigningUIState.kt @@ -23,6 +23,22 @@ sealed interface FrostSigningUIState { /** Who has signed their part. */ val signed: Set = emptySet(), + + /** + * Names for the keys and ids the proposed events refer to. + * + * `ProposedEvent.summarize` is a pure function with no database -- two + * call sites show these words and two copies of the mapping would drift + * -- so whatever can be named is resolved here and handed in as a + * lookup. + * + * It matters most for a membership, which carries two 64-character ids + * and nothing else: unresolved, a member is asked to agree to "sign + * a4f1be55d0c3". Empty is the honest default, and what a batch founding + * a project sees for the project's own id, since nothing has applied it + * yet. + */ + val names: Map = emptyMap(), ): FrostSigningUIState data class Error( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProjectDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProjectDetailUIState.kt new file mode 100644 index 00000000..bb21e6c2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProjectDetailUIState.kt @@ -0,0 +1,41 @@ +package press.mantra.compose.ui.view.state + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.database.model.MantraArtifactVersion +import press.mantra.compose.database.model.MantraProject +import press.mantra.compose.database.model.MantraTranslationArtifactVersion +import press.mantra.compose.database.model.Profile + +sealed interface ProjectDetailUIState { + data class Loaded( + val project: MantraProject, + val sourceVersions: List = emptyList(), + val translations: List = emptyList(), + + /** + * The profiles behind the creator and the owners, where this device has + * them. + * + * Resolved in the view model rather than looked up as each row draws: + * the screen shows a handful of keys and a composable cannot await a + * database. A key with no profile here is shown shortened, which is what + * the rest of the app does with a stranger. + */ + val people: Map = emptyMap(), + + /** + * Whether this device holds a share of the group's key. + * + * Adding to a project is a proposal, and a device that cannot sign + * cannot open one -- so the buttons say so rather than failing when + * pressed. + */ + val canSign: Boolean = false, + ): ProjectDetailUIState + + data class Error( + val message: String + ): ProjectDetailUIState + + data object Loading: ProjectDetailUIState +} 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..931d5b3d --- /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.splitChunks(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/database/model/RumorIdAgreementTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/RumorIdAgreementTest.kt index eb5572f0..be17783a 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/RumorIdAgreementTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/RumorIdAgreementTest.kt @@ -10,6 +10,9 @@ import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent import press.mantra.compose.nostr.nip30303.ChapterEvent import press.mantra.compose.nostr.nip30303.ChunkEvent import press.mantra.compose.nostr.nip30303.DialectEvent +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.SubmissionEvent import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag @@ -141,6 +144,49 @@ class RumorIdAgreementTest { assertEquals(rumorIdOf(template), entity?.id) } + @Test + fun `a project's row and its rumor agree`() { + val template = ProjectEvent.build( + name = "Gospel of John into Sesotho", + description = "The four chapters.", + creatorPublicKey = author, + ownerPublicKeys = setOf(other, "c".repeat(64)) + ) + + val entity = MantraProject.fromProjectEventTemplate( + projectEventTemplate = template, + chatRoomId = chatRoomId, + userPublicKey = author + ) + + assertEquals(rumorIdOf(template), entity?.id) + } + + @Test + fun `a project membership's row and its rumor agree`() { + val source = ProjectArtifactVersionEvent.build( + projectId = other, + artifactVersionId = "c".repeat(64) + ) + val translation = ProjectTranslationArtifactVersionEvent.build( + projectId = other, + translationArtifactVersionId = "c".repeat(64) + ) + + assertEquals( + rumorIdOf(source), + MantraProjectArtifactVersion + .fromProjectArtifactVersionEventTemplate(source, chatRoomId, author)?.id + ) + assertEquals( + rumorIdOf(translation), + MantraProjectTranslationArtifactVersion + .fromProjectTranslationArtifactVersionEventTemplate( + translation, chatRoomId, author + )?.id + ) + } + @Test fun `the submission names the id the row was written under`() { // The end of the chain the rest of this file checks a link of: what a diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/TranscriptRequestStateTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/TranscriptRequestStateTest.kt index 4dbf4788..3905f187 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/TranscriptRequestStateTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/TranscriptRequestStateTest.kt @@ -182,4 +182,42 @@ class TranscriptRequestStateTest { assertEquals(setOf(hostKey.id), ChatMessage.answeredRequests(transcript)) } + + @Test + fun `twelve open proposals each keep their own summons`() { + // An upload of twelve files opens twelve sessions at once, interleaved in + // one stretch of transcript. The clock fallback would have the first + // answer drop every later request, which is the failure two concurrent + // sessions already had once -- and twelve is where a path that still + // falls back to the clock would show. + val requests = (1..12).map { n -> + line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 100L + n, session = "session-" + n) + } + + // Answered in the opposite order to the asking, so neither the clock nor + // the list order could produce the right answer by accident. + val answers = listOf(12, 7, 1).map { n -> + line(ChatMessage.TYPE_FROST_NONCE, at = 200L + n, session = "session-" + n) + } + + val answered = ChatMessage.answeredRequests(requests + answers) + + assertEquals( + setOf(requests[11].id, requests[6].id, requests[0].id), + answered, + ) + } + + @Test + fun `one proposal failing settles only itself`() { + val requests = (1..12).map { n -> + line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 100L + n, session = "session-" + n) + } + val failure = line(ChatMessage.TYPE_FROST_FAILED, at = 300, session = "session-5") + + // The other eleven chapters are still in front of the group. A batch is + // all-or-nothing within a session and nothing across them, which is the + // whole reason an upload is N sessions rather than one. + assertEquals(setOf(requests[4].id), ChatMessage.settledRequests(requests + listOf(failure))) + } } diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEventTest.kt index ee16573b..0bb7fa12 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEventTest.kt @@ -19,6 +19,9 @@ import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent import press.mantra.compose.nostr.nip30303.ChapterEvent import press.mantra.compose.nostr.nip30303.ChunkEvent import press.mantra.compose.nostr.nip30303.DialectEvent +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.SubmissionEvent import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionContributorListEvent import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent @@ -190,9 +193,56 @@ class ChronicleEventTest { TranslationArtifactVersionEvent.KIND, TranslationChapterEvent.KIND, TranslationChunkEvent.KIND, + ProjectEvent.KIND, + ProjectArtifactVersionEvent.KIND, + ProjectTranslationArtifactVersionEvent.KIND, ).forEach { assertTrue(ChronicleEvent.isChroniclable(it), "kind $it should be chroniclable") } - assertEquals(8, ChronicleEvent.CHRONICLABLE_KINDS.size) + assertEquals(11, ChronicleEvent.CHRONICLABLE_KINDS.size) + } + + @Test + fun `a membership applies after the project and the version it names`() { + // The only thing APPLY_ORDER has to get right about projects. A + // membership is a foreign key into both, so a page that applied one + // first would have the insert refused and the membership lost -- on the + // one device that most needs it, since a chronicle is what a member who + // joined late is given instead of having been there. + val project = ChronicleEvent.applyRank(ProjectEvent.KIND)!! + val source = ChronicleEvent.applyRank(ProjectArtifactVersionEvent.KIND)!! + val translation = ChronicleEvent.applyRank(ProjectTranslationArtifactVersionEvent.KIND)!! + + assertTrue(source > project, "a source membership must apply after its project") + assertTrue(translation > project, "a translation membership must apply after its project") + + assertTrue( + source > ChronicleEvent.applyRank(ArtifactVersionEvent.KIND)!!, + "a source membership must apply after the version it names" + ) + assertTrue( + translation > ChronicleEvent.applyRank(TranslationArtifactVersionEvent.KIND)!!, + "a translation membership must apply after the translation it names" + ) + } + + @Test + fun `a page of a whole project sorts into an order that will apply`() { + val shuffled = listOf( + payload(ProjectArtifactVersionEvent.KIND), + payload(ProjectEvent.KIND), + payload(ArtifactVersionEvent.KIND), + payload(ArtifactEvent.KIND), + ) + + assertEquals( + listOf( + ArtifactEvent.KIND, + ArtifactVersionEvent.KIND, + ProjectEvent.KIND, + ProjectArtifactVersionEvent.KIND, + ), + ChronicleEvent.inApplyOrder(shuffled).map { it.kind } + ) } @Test diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleRoundTripTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleRoundTripTest.kt index 7d53214a..e626d223 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleRoundTripTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleRoundTripTest.kt @@ -34,6 +34,9 @@ import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent import press.mantra.compose.nostr.nip30303.ChapterEvent import press.mantra.compose.nostr.nip30303.ChunkEvent import press.mantra.compose.nostr.nip30303.DialectEvent +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationChapterEvent import press.mantra.compose.nostr.nip30303.TranslationChunkEvent @@ -408,15 +411,45 @@ class ChronicleRoundTripTest { } } + /** + * The kinds that reach a chronicle only off the record, never by rebuild. + * + * Everything else here has two sources, because a room whose work predates + * `GroupSignedEvent` at schema v13 has rows and no events, and the rebuild is + * what makes such a room whole. Projects arrive at v21, so no project row + * anywhere is in that position and a `toProjectEvent` would be new code for + * a mechanism with a removal checklist already written against it -- see + * `MantraProject` and docs/member-chronicle.md. + * + * The cost is real and is asserted rather than assumed: + * `ChronicleAssemblyJvmTest.a project is only ever chronicled off the record` + * is what happens when the record is missing. + */ + private val notRebuildable = setOf( + ProjectEvent.KIND, + ProjectArtifactVersionEvent.KIND, + ProjectTranslationArtifactVersionEvent.KIND, + ) + @Test fun `every chroniclable kind has a round-trip test above`() { // The list and this file have to move together. A kind added to the // allowlist without a case here is one nobody has checked can be rebuilt, // and the failure is silent: the receiver drops it as a forgery. + // + // Unless it is a kind with no rebuild at all, which is a decision rather + // than an omission -- see [notRebuildable]. Those are excluded here and + // pinned in `ChronicleAssemblyJvmTest` instead, so adding one is still a + // thing somebody has to write down. assertEquals( 8, - ChronicleEvent.CHRONICLABLE_KINDS.size, + (ChronicleEvent.CHRONICLABLE_KINDS - notRebuildable).size, "a chroniclable kind was added or removed; add or remove its round-trip case" ) + + assertTrue( + notRebuildable.all { it in ChronicleEvent.CHRONICLABLE_KINDS }, + "a kind cannot be exempted from the rebuild unless it is chroniclable at all" + ) } } diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ChapterChunkSplitTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ChapterChunkSplitTest.kt index e897939e..303a03c4 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ChapterChunkSplitTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ChapterChunkSplitTest.kt @@ -168,7 +168,7 @@ class ChapterChunkSplitTest { val text = "One.\n\nTwo.\n\n\nThree.\n\n \n\nFour." assertEquals( - Markdown.splitParagraphs(text).size, + Markdown.splitChunks(text).size, ChunkEvent.splitOf(unsignedChapter(originalText = text)).size ) } diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ChapterProposalSizeTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ChapterProposalSizeTest.kt new file mode 100644 index 00000000..ec1080fe --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ChapterProposalSizeTest.kt @@ -0,0 +1,197 @@ +package press.mantra.compose.nostr.nip30303 + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import press.mantra.compose.managers.FrostSigningManager +import press.mantra.compose.nostr.frost.FrostSigningEvents +import press.mantra.compose.text.Markdown + +/** + * [ChapterProposal] estimates what the manager measures, and the only property + * worth asserting is that it never estimates low. + * + * The form reads the estimate as the text is typed; `proposeSigningBatch` + * encodes the batch and measures it for real. If the estimate were ever under + * the truth, the form would offer a chapter that the manager then refuses with + * an `IllegalArgumentException` -- or, before that check existed, that went out + * and stalled the session with nothing to blame. + * + * So these tests build the same events the manager would and compare. A tag + * added to either kind fails here rather than in a room. + */ +class ChapterProposalSizeTest { + private val groupKey = "a".repeat(64) + private val artifactVersionId = "b".repeat(64) + + private fun unsigned(template: EventTemplate<*>) = Event( + id = EventHasher.hashId( + pubKey = groupKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + ), + pubKey = groupKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = "", + ) + + /** The batch as `proposeSigningBatch` would build it: the chapter, then its chunks. */ + private fun batchFor(originalText: String, name: String = "Chapter 1"): List { + val chapter = unsigned( + ChapterEvent.build( + artifactVersionId = artifactVersionId, + name = name, + originalText = originalText, + index = 0, + wordCount = Markdown.wordCount(originalText), + characterCount = Markdown.characterCount(originalText), + createdAt = 1_700_000_000, + ) + ) + return listOf(chapter) + ChunkEvent.splitOf(chapter).map(::unsigned) + } + + private fun encodedBytes(originalText: String): Int = + FrostSigningEvents.encodeProposal(batchFor(originalText)).encodeToByteArray().size + + /** Markdown of roughly [bytes] bytes, in [paragraphs] paragraphs. */ + private fun markdownOf(bytes: Int, paragraphs: Int): String { + val perParagraph = (bytes / paragraphs).coerceAtLeast(4) + return (0 until paragraphs).joinToString("\n\n") { "w ".repeat(perParagraph / 2).trim() } + } + + @Test + fun `the estimate is never under the real encoding`() { + // The grid the overhead constant was measured over. Both ends matter: at + // one chunk the text is counted twice exactly and the per-event cost is + // at its highest, which is where an estimate is most likely to fall + // short; at sixty-three the envelopes dominate. + val shapes = listOf(1, 2, 5, 10, 20, 40, 63) + val sizes = listOf(1, 4, 8, 16, 20, 24) + + for (paragraphs in shapes) { + for (kib in sizes) { + val text = markdownOf(kib * 1024, paragraphs) + val chunks = Markdown.splitChunks(text) + val estimated = ChapterProposal.proposalBytes(text, chunks) + val actual = encodedBytes(text) + + assertTrue( + estimated >= actual, + "estimate $estimated under the real $actual for $paragraphs " + + "paragraphs of ${kib} KiB", + ) + } + } + } + + @Test + fun `the estimate is close enough to be worth showing`() { + // Not merely conservative -- useful. An estimate twice the truth would + // pass the test above and refuse half of every legal chapter. + val text = markdownOf(16 * 1024, 40) + val chunks = Markdown.splitChunks(text) + + val estimated = ChapterProposal.proposalBytes(text, chunks) + val actual = encodedBytes(text) + + assertTrue( + estimated <= actual + actual / 10, + "estimate $estimated is more than a tenth over the real $actual", + ) + } + + @Test + fun `a chapter the estimate accepts really does encode under the cap`() { + // The property the form's offer rests on, stated as a round trip rather + // than as arithmetic: find the longest text the estimate allows at the + // worst chunk count, and check the manager would carry it. + val chunkCount = AddChapterMaxChunks + val allowance = ChapterProposal.maxOriginalTextBytes(chunkCount) + val text = markdownOf(allowance, chunkCount) + val chunks = Markdown.splitChunks(text) + + assertEquals(chunkCount, chunks.size) + assertTrue(ChapterProposal.fits(text, chunks)) + assertTrue( + encodedBytes(text) <= FrostSigningManager.MAX_PROPOSAL_BYTES, + "a chapter the form accepts encoded to ${encodedBytes(text)} bytes, over the cap", + ) + } + + @Test + fun `the cap binds inside the range of a real chapter`() { + // Why any of this exists. Before the check, a chapter of this size was + // offered by the form, proposed, and stalled. 28 KiB over forty + // paragraphs is a long chapter of a real book, not a pathological one -- + // and measured, the fit runs out at about 24 KiB for that shape. + val text = markdownOf(28 * 1024, 40) + val chunks = Markdown.splitChunks(text) + + assertTrue( + encodedBytes(text) > FrostSigningManager.MAX_PROPOSAL_BYTES, + "expected ${encodedBytes(text)} bytes to exceed the cap", + ) + assertTrue(!ChapterProposal.fits(text, chunks)) + } + + @Test + fun `where the fit runs out, for a chapter-shaped chapter`() { + // Pinned so the number is on the record rather than in a comment, and so + // that a change to either event's tags shows up as a moved boundary + // instead of as a quorum that never arrives. Forty paragraphs is the + // shape; the figure is the largest whole KiB of markdown that encodes + // under the cap. + val fitting = (1..64).last { kib -> + encodedBytes(markdownOf(kib * 1024, 40)) <= FrostSigningManager.MAX_PROPOSAL_BYTES + } + + assertEquals(24, fitting) + } + + @Test + fun `an ordinary chapter is nowhere near either cap`() { + // The other half of the claim: this is a limit on very long chapters, + // not a tax on normal ones. Twelve paragraphs of a few hundred words. + val text = markdownOf(8 * 1024, 12) + val chunks = Markdown.splitChunks(text) + + assertTrue(ChapterProposal.fits(text, chunks)) + assertTrue(encodedBytes(text) < FrostSigningManager.MAX_PROPOSAL_BYTES / 2) + } + + @Test + fun `the text is measured in bytes, not in characters`() { + // The languages this app exists for are full of characters that are one + // UTF-16 unit and three bytes. Measuring by length would under-count a + // Setswana or Amharic chapter and the cap would stop binding exactly + // where it matters most. + val ascii = "w ".repeat(512).trim() + val amharic = "ሰ ".repeat(512).trim() + + assertEquals(ascii.length, amharic.length) + assertTrue( + ChapterProposal.proposalBytes(amharic, listOf(amharic)) > + ChapterProposal.proposalBytes(ascii, listOf(ascii)), + ) + } + + /** + * Named here rather than imported from the view model, so this file depends + * on the protocol and not on a screen. Asserted equal below. + */ + private val AddChapterMaxChunks: Int get() = FrostSigningManager.MAX_BATCH_SIZE - 1 + + @Test + fun `the chunk cap is one less than the batch, because the chapter takes a place`() { + assertEquals(FrostSigningManager.MAX_BATCH_SIZE - 1, AddChapterMaxChunks) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ProjectEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ProjectEventTest.kt new file mode 100644 index 00000000..e0c2fea9 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ProjectEventTest.kt @@ -0,0 +1,207 @@ +package press.mantra.compose.nostr.nip30303 + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import press.mantra.compose.nostr.nip30303.tags.ContributorTag +import press.mantra.compose.nostr.nip30303.tags.CreatorTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A project names people two different ways, and both ways fail quietly. + * + * The creator is a [CreatorTag] and the owners are [ContributorTag]s with + * [ContributorTag.ROLE_OWNER]. Each has a failure mode that produces a valid, + * verifiable event carrying nobody: + * + * - a role missing from `ContributorTag`'s known set parses as null, so every + * owner vanishes while the role string stays inside the signed id; + * - `addUnique` keeps one tag per *name*, so three owners emitted with it + * become one, which reads like a bug in whatever collected them. + * + * Neither logs anything. These tests are the only thing that would say. + */ +class ProjectEventTest { + private val creator = "a".repeat(64) + private val owner1 = "1".repeat(64) + private val owner2 = "2".repeat(64) + private val owner3 = "3".repeat(64) + + private fun EventTemplate.asEvent() = ProjectEvent( + id = "e".repeat(64), + pubKey = creator, + createdAt = createdAt, + tags = tags, + content = content, + sig = "" + ) + + private fun project(owners: Set) = ProjectEvent.build( + name = "Gospel of John into Sesotho", + description = "The four chapters, with the Setswana back-translation.", + creatorPublicKey = creator, + ownerPublicKeys = owners + ).asEvent() + + // --- the role has to be known, or there are no owners at all --- + + @Test + fun `the owner role survives a round trip through ContributorTag`() { + val parsed = ContributorTag.parse( + ContributorTag.assemble(owner1, ContributorTag.ROLE_OWNER) + ) + + assertEquals(owner1, parsed?.pubKey) + // If ROLE_OWNER is ever dropped from KNOWN_ROLES this is the only place + // that says so. Everything downstream just sees a project with no owners. + assertEquals(ContributorTag.ROLE_OWNER, parsed?.role) + } + + // --- the creator tag --- + + @Test + fun `a creator tag round trips`() { + assertEquals(creator, CreatorTag.parse(CreatorTag.assemble(creator))?.pubKey) + } + + @Test + fun `a creator tag whose value is not a key is refused`() { + assertNull(CreatorTag.parse(arrayOf(CreatorTag.TAG_NAME, "not-a-key"))) + assertNull(CreatorTag.parse(arrayOf(CreatorTag.TAG_NAME))) + assertNull(CreatorTag.parse(arrayOf("p", creator))) + } + + // --- owners are a set, and all of them survive --- + + @Test + fun `three owners come back, sorted`() { + val event = project(setOf(owner3, owner1, owner2)) + + // `addUnique` here would leave one. Sorted because the id is a hash over + // the tags as emitted and a form hands its selection over in no + // particular order. + assertEquals(listOf(owner1, owner2, owner3), event.owners()) + } + + @Test + fun `an owner listed twice appears once`() { + assertEquals(listOf(owner1), project(setOf(owner1)).owners()) + } + + @Test + fun `a project with no owners cannot be built`() { + assertFailsWith { + ProjectEvent.build( + name = "Nobody's", + description = "", + creatorPublicKey = creator, + ownerPublicKeys = emptySet() + ) + } + } + + @Test + fun `owners ignores a contributor tag that is not an owner`() { + val event = ProjectEvent.build( + name = "Gospel of John into Sesotho", + description = "", + creatorPublicKey = creator, + ownerPublicKeys = setOf(owner1) + ) { + addUniqueValueIfNew( + ContributorTag.assemble(owner2, ContributorTag.ROLE_EDITOR) + ) + }.asEvent() + + assertEquals(listOf(owner1), event.owners()) + } + + // --- the creator and the owners are different tags, so one can be both --- + + @Test + fun `the creator may also be an owner`() { + val event = project(setOf(creator, owner1)) + + assertEquals(creator, event.creator()) + assertTrue(creator in event.owners()) + assertTrue(owner1 in event.owners()) + } + + @Test + fun `the creator is not read off the owner tags`() { + val event = project(setOf(owner1)) + + // Selecting the creator by position among p-tags would make these agree, + // which reads as working. + assertEquals(creator, event.creator()) + assertEquals(listOf(owner1), event.owners()) + } + + @Test + fun `name and description land where they are read from`() { + val event = project(setOf(owner1)) + + assertEquals("Gospel of John into Sesotho", event.name()) + assertTrue(event.content.startsWith("The four chapters")) + } + + // --- memberships borrow the project's id and timestamp --- + + @Test + fun `memberships name the project and share its timestamp`() { + val signedProject: Event = project(setOf(owner1)) + val versionId = "f".repeat(64) + + val sources = ProjectArtifactVersionEvent.membershipsOf( + project = signedProject, + artifactVersionIds = listOf(versionId) + ) + val translations = ProjectTranslationArtifactVersionEvent.membershipsOf( + project = signedProject, + translationArtifactVersionIds = listOf(versionId) + ) + + val source = ProjectArtifactVersionEvent( + id = "0".repeat(64), + pubKey = creator, + createdAt = sources.single().createdAt, + tags = sources.single().tags, + content = sources.single().content, + sig = "" + ) + + assertEquals(signedProject.id, source.projectId()) + assertEquals(versionId, source.artifactVersionId()) + assertEquals(signedProject.createdAt, source.createdAt) + + val translation = ProjectTranslationArtifactVersionEvent( + id = "0".repeat(64), + pubKey = creator, + createdAt = translations.single().createdAt, + tags = translations.single().tags, + content = translations.single().content, + sig = "" + ) + + assertEquals(signedProject.id, translation.projectId()) + assertEquals(versionId, translation.translationArtifactVersionId()) + assertEquals(signedProject.createdAt, translation.createdAt) + } + + @Test + fun `a project founded with no contents has no memberships`() { + val signedProject: Event = project(setOf(owner1)) + + assertTrue( + ProjectArtifactVersionEvent.membershipsOf(signedProject, emptyList()).isEmpty() + ) + assertTrue( + ProjectTranslationArtifactVersionEvent + .membershipsOf(signedProject, emptyList()) + .isEmpty() + ) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt index a625bb39..ada04fb2 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt @@ -436,6 +436,152 @@ class SubgroupBirthCertificateEventTest { assertNull(SubgroupManager.classify(subgroupChatRoomId, aboutSomebodyElse)) } + // ---- `addresses`: the half that can be asked of a proposal ------------ + // + // The ceremony screen has to tell one *in-flight* certificate signing from + // another in the parent's room, and a parent certifies every subgroup it + // ever makes from that one room. `certifies` cannot answer it: it ends on a + // signature by the parent's quorum, which is exactly what the screen is + // waiting for. `addresses` is the structural half, and the only thing worth + // asserting about it is where it stops. + + @Test + fun `an unsigned proposal addresses its child, and certifies nothing`() { + // What the ceremony screen is actually holding: the event as it was + // assembled, before anybody signed it. Any string in `sig` serves -- + // the point is that no quorum has put one there. + val proposal = certificate(signature = "0".repeat(128)) + + assertTrue( + SubgroupBirthCertificateEvent.addresses( + event = proposal, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ), + "the screen could not tell which subgroup its own proposal is about" + ) + + assertFalse( + SubgroupBirthCertificateEvent.certifies( + event = proposal, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ), + "an unsigned proposal is being read as a parent's certificate" + ) + } + + @Test + fun `a forgery by another group addresses the pair it names, and certifies nothing`() { + // The reason `addresses` must never decide a lineage: anybody can author + // an event naming any pair, and this says yes to it. The guard is that + // nothing downstream of a lineage asks `addresses` -- only the screen + // deciding which of its own room's signings to draw does. + val forged = certificate(author = childMaterial) + + assertTrue( + SubgroupBirthCertificateEvent.addresses( + event = forged, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ) + ) + + assertFalse( + SubgroupBirthCertificateEvent.certifies( + event = forged, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ) + ) + } + + @Test + fun `a sibling's proposal is not addressed to this child`() { + // Two subgroups of one parent, which is the case the screen exists to + // tell apart. The sibling's proposal is a real, well-formed certificate + // request; it is simply about somebody else. + val siblingRoomId = SharedKeyDerivation + .marmotGroupId(strangerMaterial.thresholdPublicKey.value.toHex(), path) + + val sibling = certificate( + content = siblingRoomId, + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = siblingRoomId, + parentChatRoomId = parentChatRoomId, + thresholdPublicKey = strangerMaterial.thresholdPublicKey.value.toHex(), + adminPublicKeys = admins, + name = "Another team", + path = path + ) + ) + + assertFalse( + SubgroupBirthCertificateEvent.addresses( + event = sibling, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ), + "one subgroup's rung is following a sibling's proposal" + ) + + // And it is addressed to the sibling, so the sibling's own screen finds + // it -- the two are told apart rather than both being refused. + assertTrue( + SubgroupBirthCertificateEvent.addresses( + event = sibling, + subgroupChatRoomId = siblingRoomId, + parentChatRoomId = parentChatRoomId + ) + ) + } + + @Test + fun `a proposal naming another parent is not addressed by this one`() { + val fromElsewhere = certificate( + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = SharedKeyDerivation + .marmotGroupId(strangerMaterial.thresholdPublicKey.value.toHex(), path), + thresholdPublicKey = childKey, + adminPublicKeys = admins, + name = name, + path = path + ) + ) + + assertFalse( + SubgroupBirthCertificateEvent.addresses( + event = fromElsewhere, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ) + ) + } + + @Test + fun `everything certifies addresses, so the weaker one cannot be the stricter`() { + // `certifies` calls `addresses` and adds to it, and this is what keeps + // that true: a change that let one pass where the other refuses would + // mean two different answers to "which child is this about". + val valid = certificate() + + assertTrue( + SubgroupBirthCertificateEvent.certifies( + event = valid, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ) + ) + assertTrue( + SubgroupBirthCertificateEvent.addresses( + event = valid, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ) + ) + } + private fun certificate( content: String = subgroupChatRoomId, tags: Array> = SubgroupBirthCertificateEvent.assembleTags( diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ChapterNamingTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ChapterNamingTest.kt new file mode 100644 index 00000000..6bd643d3 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ChapterNamingTest.kt @@ -0,0 +1,68 @@ +package press.mantra.compose.text + +import kotlin.test.Test +import kotlin.test.assertEquals + +class ChapterNamingTest { + + private val body = "The first paragraph.\n\nThe second.\n" + + @Test + fun `a filename somebody chose is the name`() { + assertEquals( + "The Wreck of the Hesperus", + ChapterNaming.forFile("The Wreck of the Hesperus.md", body), + ) + } + + @Test + fun `an ordinal prefix is dropped`() { + assertEquals("The Wreck", ChapterNaming.forFile("03 - The Wreck.md", body)) + assertEquals("The Wreck", ChapterNaming.forFile("1. The Wreck.markdown", body)) + } + + @Test + fun `underscores read as spaces`() { + assertEquals("the wreck", ChapterNaming.forFile("the_wreck.txt", body)) + } + + @Test + fun `a filename that is only an ordinal falls through to the text`() { + assertEquals("The first paragraph.", ChapterNaming.forFile("01.md", body)) + } + + @Test + fun `a heading in the text beats the first paragraph`() { + assertEquals( + "Chapter one", + ChapterNaming.forFile("1.md", "# Chapter one\n\nThe first paragraph.\n"), + ) + } + + @Test + fun `a heading glued to its paragraph is still found`() { + // The shape mantra.press's TeX converter emits: a heading and the + // paragraph under it separated by a single newline. + assertEquals( + "Chapter one", + ChapterNaming.forFile("1.md", "# Chapter one\nThe first paragraph.\n"), + ) + } + + @Test + fun `a derived name is cut to a title's length`() { + val long = "w ".repeat(200) + + assertEquals(80, ChapterNaming.forFile("1.md", long).length) + } + + @Test + fun `nothing to go on gives an empty name for the form to ask about`() { + assertEquals("", ChapterNaming.forFile("01.md", " \n\n \n")) + } + + @Test + fun `a file with no extension keeps its whole name`() { + assertEquals("CHAPTER ONE", ChapterNaming.forFile("CHAPTER ONE", body)) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ChapterTextTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ChapterTextTest.kt new file mode 100644 index 00000000..aa99eaed --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ChapterTextTest.kt @@ -0,0 +1,126 @@ +package press.mantra.compose.text + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * The four rules [ChapterText.decode] exists for, and the one property that + * makes them worth having: the same prose from two machines has to come out as + * the same bytes, because those bytes become a chunk id a quorum signs. + */ +class ChapterTextTest { + + private fun decoded(bytes: ByteArray): String { + val result = ChapterText.decode(bytes) + assertTrue(result is ChapterText.Result.Text, "expected text, got $result") + return result.markdown + } + + @Test + fun `plain utf8 markdown decodes unchanged`() { + val markdown = "# Chapter one\n\nThe first paragraph.\n" + assertEquals(markdown, decoded(markdown.encodeToByteArray())) + } + + @Test + fun `multi byte characters survive`() { + // Setswana and a few of the diacritics a translation will actually carry. + val markdown = "Ke a leboga.\n\nŠŌÜ — ôô.\n" + assertEquals(markdown, decoded(markdown.encodeToByteArray())) + } + + @Test + fun `an invalid utf8 sequence is refused rather than replaced`() { + // 0xFF is not a legal lead byte anywhere in UTF-8. + val bytes = byteArrayOf(0x41, 0xFF.toByte(), 0x42) + + assertEquals(ChapterText.Result.NotText, ChapterText.decode(bytes)) + } + + @Test + fun `a replacement character is never introduced`() { + val bytes = byteArrayOf(0x41, 0xFF.toByte(), 0x42) + val lenient = bytes.decodeToString() + + // The point of the rule: a lenient decode succeeds and hides the damage + // inside text that would then be signed. + assertTrue(lenient.contains('�')) + assertEquals(ChapterText.Result.NotText, ChapterText.decode(bytes)) + } + + @Test + fun `a byte order mark is stripped`() { + val withBom = byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte()) + + "First paragraph.".encodeToByteArray() + + assertEquals("First paragraph.", decoded(withBom)) + } + + @Test + fun `the same chapter from windows and from linux decodes identically`() { + val crlf = "# Chapter one\r\n\r\nThe first paragraph.\r\nIts second line.\r\n" + val lf = "# Chapter one\n\nThe first paragraph.\nIts second line.\n" + + assertEquals(decoded(lf.encodeToByteArray()), decoded(crlf.encodeToByteArray())) + // And the thing the rule is actually protecting: without it the two are + // different bytes over identical prose, so different chunk ids. + assertNotEquals(crlf, lf) + } + + @Test + fun `a lone carriage return is normalised too`() { + assertEquals("one\ntwo", decoded("one\rtwo".encodeToByteArray())) + } + + @Test + fun `splitting is unaffected by which newlines arrived`() { + val crlf = decoded("One.\r\n\r\nTwo.\r\n\r\nThree.".encodeToByteArray()) + + assertEquals(listOf("One.", "Two.", "Three."), Markdown.splitChunks(crlf)) + } + + @Test + fun `a nul byte means the file is not text`() { + val zipHeader = byteArrayOf(0x50, 0x4B, 0x03, 0x04, 0x00, 0x00) + + "docx-ish".encodeToByteArray() + + assertEquals(ChapterText.Result.NotText, ChapterText.decode(zipHeader)) + } + + @Test + fun `a nul past the sniff window is not looked for`() { + // Documents the limit rather than claiming more than it does: the sniff + // reads 8 KiB, and a file whose first 8 KiB are prose reads as prose. + val bytes = "a".repeat(9 * 1024).encodeToByteArray() + byteArrayOf(0x00) + + assertTrue(ChapterText.decode(bytes) is ChapterText.Result.Text) + } + + @Test + fun `utf16 is refused, because it is not supported rather than because it is binary`() { + // FF FE is a UTF-16LE byte order mark and an illegal UTF-8 lead byte, so + // this lands on NotText either way. Asserted so that a future lenient + // decode cannot quietly turn it into a chapter of interleaved NULs. + val utf16 = byteArrayOf(0xFF.toByte(), 0xFE.toByte(), 0x41, 0x00, 0x42, 0x00) + + assertEquals(ChapterText.Result.NotText, ChapterText.decode(utf16)) + } + + @Test + fun `a file at the cap decodes and one byte over does not`() { + val atCap = ByteArray(ChapterText.MAX_FILE_BYTES) { 'a'.code.toByte() } + val overCap = ByteArray(ChapterText.MAX_FILE_BYTES + 1) { 'a'.code.toByte() } + + assertTrue(ChapterText.decode(atCap) is ChapterText.Result.Text) + assertEquals(ChapterText.Result.TooLarge, ChapterText.decode(overCap)) + } + + @Test + fun `an empty file is text, not a failure`() { + // Whether an empty chapter may be proposed is the form's decision; decode + // only says what the bytes were. + assertEquals("", decoded(ByteArray(0))) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/KibibytesTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/KibibytesTest.kt new file mode 100644 index 00000000..195b9c1f --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/KibibytesTest.kt @@ -0,0 +1,50 @@ +package press.mantra.compose.text + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The figure in "19.9 KiB", which two screens show next to each other. + * + * Whole kibibytes would print a 20,473-byte chapter and its 21,226-byte limit + * as the same "19 KiB", which reads as a form refusing something it says is + * within the limit. And truncating rather than rounding is the whole point: a + * figure shown as under the limit has to be under it. + */ +class KibibytesTest { + + @Test + fun `whole kibibytes`() { + assertEquals("0.0", kibibytes(0)) + assertEquals("1.0", kibibytes(1024)) + assertEquals("64.0", kibibytes(64 * 1024)) + } + + @Test + fun `one decimal place`() { + assertEquals("1.5", kibibytes(1024 + 512)) + assertEquals("19.9", kibibytes(20473)) + assertEquals("20.7", kibibytes(21226)) + } + + @Test + fun `two figures a form shows together do not collapse into one`() { + // The reason this is not whole kibibytes. Both of these round to 19. + assertEquals(true, kibibytes(20473) != kibibytes(21226)) + } + + @Test + fun `it truncates rather than rounds`() { + // 1023 bytes is 0.999 KiB. Rounding would print "1.0", which is a figure + // over the value it describes -- and when the value is being compared to + // a cap, that is the direction that lies. + assertEquals("0.9", kibibytes(1023)) + assertEquals("63.9", kibibytes(64 * 1024 - 1)) + } + + @Test + fun `under a tenth of a kibibyte reads as zero point zero`() { + // Honest rather than pretty: a 50-byte chapter is not 0.1 KiB. + assertEquals("0.0", kibibytes(50)) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownBlockFixtures.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownBlockFixtures.kt new file mode 100644 index 00000000..6b9f38c7 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownBlockFixtures.kt @@ -0,0 +1,258 @@ +package press.mantra.compose.text + +// GENERATED by docs/scripts/markdown-block-fixtures.js -- do not edit by hand. +// +// The expected output of [Markdown.splitBlocks] for 30 inputs, taken from +// markdown-it with the three departures recorded in docs/chapter-uploads.md +// applied by the generator. Regenerate with: +// +// node docs/scripts/markdown-block-fixtures.js \ +// --markdown-it /node_modules/markdown-it +// +// A change here is a change to what a chunk is, and so to the ids a quorum +// signs. It should arrive with a reason. + +data class Fixture( + val name: String, + val markdown: String, + val chunks: List, +) + +val MARKDOWN_BLOCK_FIXTURES: List = listOf( + Fixture( + name = "one paragraph", + markdown = "A single paragraph.\n", + chunks = listOf( + "A single paragraph.", + ), + ), + Fixture( + name = "two paragraphs", + markdown = "First paragraph.\n\nSecond paragraph.\n", + chunks = listOf( + "First paragraph.", + "Second paragraph.", + ), + ), + Fixture( + name = "lazy continuation", + markdown = "First line.\nSecond line of the same paragraph.\n", + chunks = listOf( + "First line.\nSecond line of the same paragraph.", + ), + ), + Fixture( + name = "atx heading apart", + markdown = "# Chapter one\n\nThe first paragraph.\n", + chunks = listOf( + "Chapter one", + "The first paragraph.", + ), + ), + Fixture( + name = "atx heading glued", + markdown = "# Chapter one\nThe first paragraph.\n", + chunks = listOf( + "Chapter one", + "The first paragraph.", + ), + ), + Fixture( + name = "deep atx heading", + markdown = "#### A fourth-level heading\n\nBody.\n", + chunks = listOf( + "A fourth-level heading", + "Body.", + ), + ), + Fixture( + name = "converter shape", + markdown = "# Chapter one\nFirst paragraph here.\n\nSecond paragraph here.\n", + chunks = listOf( + "Chapter one", + "First paragraph here.", + "Second paragraph here.", + ), + ), + Fixture( + name = "setext h1", + markdown = "Title\n=====\n\nBody.\n", + chunks = listOf( + "Title", + "Body.", + ), + ), + Fixture( + name = "setext h2", + markdown = "Subtitle\n--------\n\nBody.\n", + chunks = listOf( + "Subtitle", + "Body.", + ), + ), + Fixture( + name = "tight bullet list", + markdown = "Shopping:\n\n- milk\n- bread\n- eggs\n", + chunks = listOf( + "Shopping:", + "milk", + "bread", + "eggs", + ), + ), + Fixture( + name = "ordered list", + markdown = "1. first\n2. second\n", + chunks = listOf( + "first", + "second", + ), + ), + Fixture( + name = "nested list", + markdown = "- outer\n - inner\n", + chunks = listOf( + "outer", + "inner", + ), + ), + Fixture( + name = "blockquote two lines", + markdown = "> line one\n> line two\n", + chunks = listOf( + "line one\nline two", + ), + ), + Fixture( + name = "two blockquotes", + markdown = "> first\n\n> second\n", + chunks = listOf( + "first", + "second", + ), + ), + Fixture( + name = "thematic break", + markdown = "One.\n\n---\n\nTwo.\n", + chunks = listOf( + "One.", + "Two.", + ), + ), + Fixture( + name = "fenced code", + markdown = "Text.\n\n```\ncode();\n```\n\nMore.\n", + chunks = listOf( + "Text.", + "More.", + ), + ), + Fixture( + name = "fenced code with info", + markdown = "Text.\n\n```kotlin\nval x = 1\n```\n", + chunks = listOf( + "Text.", + ), + ), + Fixture( + name = "indented code", + markdown = "Text.\n\n indented code\n\nMore.\n", + chunks = listOf( + "Text.", + "More.", + ), + ), + Fixture( + name = "table", + markdown = "| a | b |\n|---|---|\n| 1 | 2 |\n", + chunks = listOf( + "| a | b |\n|---|---|\n| 1 | 2 |", + ), + ), + Fixture( + name = "table with prose", + markdown = "Before.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\nAfter.\n", + chunks = listOf( + "Before.", + "| a | b |\n|---|---|\n| 1 | 2 |", + "After.", + ), + ), + Fixture( + name = "link reference definition", + markdown = "See [it][ref].\n\n[ref]: https://example.com\n", + chunks = listOf( + "See [it][ref].", + ), + ), + Fixture( + name = "inline markup survives", + markdown = "A *word* and a [link](https://example.com) and `code`.\n", + chunks = listOf( + "A *word* and a [link](https://example.com) and `code`.", + ), + ), + Fixture( + name = "trailing hard break", + markdown = "Line one \nLine two\n", + chunks = listOf( + "Line one \nLine two", + ), + ), + Fixture( + name = "no trailing newline", + markdown = "No newline at the end.", + chunks = listOf( + "No newline at the end.", + ), + ), + Fixture( + name = "leading blank lines", + markdown = "\n\nA paragraph after blank lines.\n", + chunks = listOf( + "A paragraph after blank lines.", + ), + ), + Fixture( + name = "crlf already normalised", + markdown = "One.\n\nTwo.\n", + chunks = listOf( + "One.", + "Two.", + ), + ), + Fixture( + name = "html block falls back to a paragraph", + markdown = "
html block
\n", + chunks = listOf( + "
html block
", + ), + ), + Fixture( + name = "heading then list then quote", + markdown = "# Title\n\n- one\n- two\n\n> quoted\n\nEnd.\n", + chunks = listOf( + "Title", + "one", + "two", + "quoted", + "End.", + ), + ), + Fixture( + name = "multi paragraph blockquote", + markdown = "> first para\n>\n> second para\n", + chunks = listOf( + "first para", + "second para", + ), + ), + Fixture( + name = "unicode", + markdown = "Ke a leboga.\n\nሰላም ለዓለም።\n", + chunks = listOf( + "Ke a leboga.", + "ሰላም ለዓለም።", + ), + ), +) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownBlockTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownBlockTest.kt new file mode 100644 index 00000000..d272bad3 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownBlockTest.kt @@ -0,0 +1,36 @@ +package press.mantra.compose.text + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * [Markdown.splitBlocks] against markdown-it, which is the parser mantra.press + * translates with. + * + * The fixtures are generated rather than written: `splitBlocks` is hand-rolled, + * its output is hashed into chunk ids a quorum signs, and "it looks right" is + * not a standard that survives a tag being added to an event. See + * `docs/scripts/markdown-block-fixtures.js`, which also encodes the three + * deliberate departures so that this file is a plain equality check. + */ +class MarkdownBlockTest { + + @Test + fun `every fixture splits as markdown-it does`() { + val wrong = MARKDOWN_BLOCK_FIXTURES.mapNotNull { fixture -> + val actual = Markdown.splitChunks(fixture.markdown) + if (actual == fixture.chunks) { + null + } else { + "${fixture.name}:\n expected ${fixture.chunks}\n actual $actual" + } + } + + assertEquals(emptyList(), wrong, "\n" + wrong.joinToString("\n")) + } + + @Test + fun `the corpus is not empty, so a generator that wrote nothing fails`() { + assertEquals(true, MARKDOWN_BLOCK_FIXTURES.size >= 25) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownProducerTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownProducerTest.kt new file mode 100644 index 00000000..455ed8a1 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownProducerTest.kt @@ -0,0 +1,155 @@ +package press.mantra.compose.text + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Putting a chapter back together with translations in it. + * + * The first test is the one that matters and is the reason stripping block + * markers is safe at all: a chapter with no translations comes back byte for + * byte. Every markup-reassembly bug is then one failing assertion rather than a + * difference somebody has to notice in a rendered document. + */ +class MarkdownProducerTest { + + @Test + fun `every fixture round-trips byte for byte with nothing translated`() { + val wrong = MARKDOWN_BLOCK_FIXTURES.mapNotNull { fixture -> + val out = MarkdownProducer.produce(fixture.markdown, emptyMap()) + if (out == fixture.markdown) null else "${fixture.name}: ${out.encodeToByteArray().size} vs ${fixture.markdown.encodeToByteArray().size}\n got ${out.replace("\n", "\\n")}\n was ${fixture.markdown.replace("\n", "\\n")}" + } + + assertEquals(emptyList(), wrong, "\n" + wrong.joinToString("\n")) + } + + @Test + fun `a translated paragraph replaces its own words`() { + val source = "First paragraph.\n\nSecond paragraph.\n" + + assertEquals( + "First paragraph.\n\nDie tweede paragraaf.\n", + MarkdownProducer.produce(source, mapOf(1 to "Die tweede paragraaf.")), + ) + } + + @Test + fun `a heading keeps its hashes`() { + val source = "# Chapter one\n\nBody.\n" + + assertEquals( + "# Kapitel een\n\nBody.\n", + MarkdownProducer.produce(source, mapOf(0 to "Kapitel een")), + ) + } + + @Test + fun `a heading glued to its paragraph stays glued`() { + // The converter's shape. Under the old blank-line split these were one + // chunk, so the heading could not be translated apart at all. + val source = "# Chapter one\nFirst paragraph.\n" + + assertEquals( + "# Kapitel een\nDie eerste paragraaf.\n", + MarkdownProducer.produce(source, mapOf(0 to "Kapitel een", 1 to "Die eerste paragraaf.")), + ) + } + + @Test + fun `a list item keeps its marker and its indent`() { + val source = "- outer\n - inner\n" + + assertEquals( + "- buitenste\n - binneste\n", + MarkdownProducer.produce(source, mapOf(0 to "buitenste", 1 to "binneste")), + ) + } + + @Test + fun `an ordered list keeps its numbers`() { + val source = "1. first\n2. second\n" + + assertEquals( + "1. eerste\n2. tweede\n", + MarkdownProducer.produce(source, mapOf(0 to "eerste", 1 to "tweede")), + ) + } + + @Test + fun `a blockquote is quoted again, line by line`() { + val source = "> line one\n> line two\n" + + // Two lines in, three out: the marker goes in front of every line, so a + // translation that grew a line is still quoted. + assertEquals( + "> een\n> twee\n> drie\n", + MarkdownProducer.produce(source, mapOf(0 to "een\ntwee\ndrie")), + ) + } + + @Test + fun `a setext heading is underlined to the length of its translation`() { + val source = "Title\n=====\n\nBody.\n" + + assertEquals( + "Titel hier\n==========\n\nBody.\n", + MarkdownProducer.produce(source, mapOf(0 to "Titel hier")), + ) + } + + @Test + fun `code is passed through untranslated and uncounted`() { + val source = "Text.\n\n```\ncode();\n```\n\nMore.\n" + + // The fence carries no chunk, so index 1 is the paragraph after it -- + // which is the whole point of code not being a chunk. + assertEquals( + "Teks.\n\n```\ncode();\n```\n\nMeer.\n", + MarkdownProducer.produce(source, mapOf(0 to "Teks.", 1 to "Meer.")), + ) + } + + @Test + fun `a thematic break and a reference definition are passed through`() { + val source = "One.\n\n---\n\nSee [it][ref].\n\n[ref]: https://example.com\n" + + assertEquals( + "Een.\n\n---\n\nSien [dit][ref].\n\n[ref]: https://example.com\n", + MarkdownProducer.produce(source, mapOf(0 to "Een.", 1 to "Sien [dit][ref].")), + ) + } + + @Test + fun `two identical paragraphs translated differently keep their own words`() { + // The bug in the thing this mirrors. mantra.press matches with + // `translationChunks.find(tc => tc.text == token.content)`, so the first + // translation goes into both. Chunks here carry an index, so position is + // available and exact. + val source = "Same words.\n\nSame words.\n" + + assertEquals( + "Eerste.\n\nTweede.\n", + MarkdownProducer.produce(source, mapOf(0 to "Eerste.", 1 to "Tweede.")), + ) + } + + @Test + fun `an empty translation leaves the original alone`() { + // A translation chunk exists before it has words in it, so this is the + // ordinary case for most of a chapter's life, not an edge one. + val source = "First.\n\nSecond.\n" + + assertEquals(source, MarkdownProducer.produce(source, mapOf(0 to "", 1 to ""))) + } + + @Test + fun `the chunk count agrees with the splitter`() { + MARKDOWN_BLOCK_FIXTURES.forEach { fixture -> + assertEquals( + fixture.chunks.size, + MarkdownProducer.chunkCount(fixture.markdown), + fixture.name, + ) + } + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt index e8ff4ecf..1915ed16 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt @@ -1,8 +1,14 @@ package press.mantra.compose.text import com.vitorpamplona.quartz.nip01Core.core.Event +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue +import press.mantra.compose.nostr.nip30303.ChapterEvent +import press.mantra.compose.nostr.nip30303.ChunkEvent import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent import press.mantra.compose.nostr.subgroup.SubgroupLineage @@ -27,6 +33,233 @@ class ProposedEventTest { assertEquals("An article", summary.detail) } + /** + * A chapter is the longest thing a group is asked to sign, and the one whose + * summary cannot be the thing itself. + * + * So the summary stands in for it: the name, how many words, and how many + * chunks came out of the text -- which is the rest of what the batch + * contains and so the rest of what is being agreed to. + */ + @Test + fun `a chapter is named, measured, and counted`() { + val summary = ProposedEvent.summarize(chapter(name = "The wreck"), chunkCount = 4) + + assertEquals("New chapter", summary.label) + assertEquals("The wreck · 12 words · 4 chunks", summary.detail) + } + + @Test + fun `one chunk is said in the singular`() { + val summary = ProposedEvent.summarize(chapter(), chunkCount = 1) + + assertTrue(summary.detail.endsWith("1 chunk"), summary.detail) + } + + /** + * The count comes from the batch, not from the text. + * + * It used to be derived by re-splitting the chapter's own markdown, which is + * a count of the batch dressed as a fact about the text: across a splitter + * change the figure a member reads would disagree with the events they are + * approving, because the batch was built by the proposer's splitter and + * counted by the reader's. This asserts the summary reports what it was + * handed -- the text here splits into two, and the batch says five. + */ + @Test + fun `the chunk count is the batch's, not a re-reading of the text`() { + val summary = ProposedEvent.summarize( + chapter(originalText = "One.\n\nTwo."), + chunkCount = 5, + ) + + assertTrue(summary.detail.endsWith("5 chunks"), summary.detail) + } + + @Test + fun `with no batch to count, a chapter says nothing about chunks`() { + // A transcript line, or a signed event on its own: there is no batch to + // ask, and guessing is what this parameter exists to stop. + val summary = ProposedEvent.summarize(chapter(name = "The wreck")) + + assertEquals("The wreck · 12 words", summary.detail) + } + + @Test + fun `a chunk is said as its own words, in its own place`() { + // The chunks travel in the same batch as their chapter and are read + // under it, so each has to be checkable on its own. + val summary = ProposedEvent.summarize( + ChunkEvent.build( + chapterId = "d".repeat(64), + text = "It began badly.", + index = 2, + wordCount = 3, + characterCount = 15, + ).let { template -> + Event( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = "", + ) + } + ) + + assertTrue(summary.detail.contains("It began badly."), summary.detail) + } + + /** A chapter as the group would be asked to sign it. */ + private fun chapter( + name: String = "The wreck", + originalText: String = "One two three.\n\nFour five six.\n\nSeven eight nine.\n\nTen eleven twelve.", + ): Event { + val template = ChapterEvent.build( + artifactVersionId = "e".repeat(64), + name = name, + originalText = originalText, + index = 0, + wordCount = Markdown.wordCount(originalText), + characterCount = Markdown.characterCount(originalText), + ) + return Event( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = "", + ) + } + + // --- projects --- + // + // A project says who started it and who owns it, and both are claims the + // group is being asked to put its signature to rather than facts anybody + // checks. The creator signs nothing; the owners sign nothing. So the only + // thing standing between "the group attested that X started this" and "the + // proposer typed X" is whether the member signing could see the claim. That + // is what these pin. + + private val roomId = "r".repeat(64) + private val creator = "c".repeat(64) + private val outsider = "d".repeat(64) + + private fun project(owners: Set) = ProjectEvent.build( + name = "Gospel of John", + description = "The four chapters.", + creatorPublicKey = creator, + ownerPublicKeys = owners, + createdAt = 1_700_000_000, + ).let { + Event("a".repeat(64), roomId, it.createdAt, it.kind, it.tags, it.content, "") + } + + @Test + fun `a project names itself and whoever started it`() { + val summary = ProposedEvent.summarize(project(setOf(roomId)), chatRoomId = roomId) + + assertEquals("New project", summary.label) + assertTrue(summary.detail.contains("Gospel of John")) + assertTrue(summary.detail.contains("The four chapters.")) + assertTrue(summary.detail.contains("started by ${creator.take(12)}")) + } + + @Test + fun `a project owned by the room it is in says nothing about owners`() { + // The ordinary case. Saying "owned by this group" every time is how a + // member learns to stop reading the line. + val summary = ProposedEvent.summarize(project(setOf(roomId)), chatRoomId = roomId) + + assertTrue(!summary.detail.contains("owned by"), summary.detail) + } + + @Test + fun `a project owned by somebody else says who, and not the room`() { + val summary = ProposedEvent.summarize( + project(setOf(roomId, outsider)), + chatRoomId = roomId + ) + + assertTrue(summary.detail.contains("owned by"), summary.detail) + assertTrue(summary.detail.contains(outsider.take(12)), summary.detail) + assertTrue(!summary.detail.contains(roomId.take(12)), summary.detail) + } + + @Test + fun `with no room to compare against, every owner is named`() { + val summary = ProposedEvent.summarize(project(setOf(roomId, outsider))) + + assertTrue(summary.detail.contains(roomId.take(12)), summary.detail) + assertTrue(summary.detail.contains(outsider.take(12)), summary.detail) + } + + @Test + fun `a resolver turns the keys into names`() { + val names = mapOf(creator to "Kgothatso", outsider to "Exonumia") + + val summary = ProposedEvent.summarize( + project(setOf(roomId, outsider)), + chatRoomId = roomId, + resolve = names::get + ) + + assertTrue(summary.detail.contains("started by Kgothatso"), summary.detail) + assertTrue(summary.detail.contains("owned by Exonumia"), summary.detail) + } + + @Test + fun `a membership is a hash without a resolver and a name with one`() { + val versionId = "f".repeat(64) + val projectId = "e".repeat(64) + + val source = ProjectArtifactVersionEvent.build( + projectId = projectId, + artifactVersionId = versionId, + createdAt = 1_700_000_000, + ).let { + Event("a".repeat(64), roomId, it.createdAt, it.kind, it.tags, it.content, "") + } + + // What a member sees in a founding batch, where the project is being + // signed on the line above and resolves to nothing because nothing has + // applied it yet. + val bare = ProposedEvent.summarize(source, chatRoomId = roomId) + assertEquals("A source version joins the project", bare.label) + assertEquals(versionId.take(12), bare.detail) + + // And what it has to look like before a membership is proposed on its + // own, which is the whole of what is being agreed to there. + val named = ProposedEvent.summarize( + source, + chatRoomId = roomId, + resolve = mapOf(versionId to "To Kill a Mockingbird 1.0", projectId to "Gospel of John")::get + ) + assertEquals("To Kill a Mockingbird 1.0 · to Gospel of John", named.detail) + } + + @Test + fun `a translation joining a project is named as a translation`() { + val translationId = "f".repeat(64) + + val membership = ProjectTranslationArtifactVersionEvent.build( + projectId = "e".repeat(64), + translationArtifactVersionId = translationId, + createdAt = 1_700_000_000, + ).let { + Event("a".repeat(64), roomId, it.createdAt, it.kind, it.tags, it.content, "") + } + + val summary = ProposedEvent.summarize(membership, chatRoomId = roomId) + + assertEquals("A translation joins the project", summary.label) + assertEquals(translationId.take(12), summary.detail) + } + @Test fun `a key state with no lineage reads as the key alone`() { // Every group made before subgroups, and every top-level group made diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/UploadChaptersStateTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/UploadChaptersStateTest.kt new file mode 100644 index 00000000..f889f879 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/UploadChaptersStateTest.kt @@ -0,0 +1,165 @@ +package press.mantra.compose.ui.view.state + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +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 + +/** + * What the upload screen's button is allowed to offer. + * + * `canPropose` is read only by the FAB, which is exactly why it is worth a test + * of its own: there is no other caller to notice if one of its four conditions + * goes missing, and each of them exists because offering the action without it + * would ask the group for something that cannot happen. + */ +class UploadChaptersStateTest { + + private val room = LocalChatRoom( + chatRoom = ChatRoom( + id = "room", + subject = "#Ekklesia", + description = "" + ) + ) + + private val artifact = MantraArtifact( + id = "artifactId", + publicKey = "author", + name = "To Kill a Mockingbird", + url = "https://example.test/bird", + visibility = "private", + dialectId = "dialectId", + license = "cc", + chatRoomId = "room", + signature = "", + ) + + private val version = MantraArtifactVersion( + id = "artifactVersionId", + artifactId = "artifactId", + publicKey = "author", + versionLabel = "1.0", + chatRoomId = "room", + signature = "", + ) + + private fun row( + id: String, + problem: UploadProblem? = null, + sessionId: String? = null, + ) = PickedChapter( + id = id, + fileName = "$id.md", + name = id, + markdown = "A paragraph.", + chunks = listOf("A paragraph."), + wordCount = 2, + characterCount = 12, + proposalBytes = 1000, + problem = problem, + sessionId = sessionId, + ) + + private fun state( + chapters: List = listOf(row("one")), + canSign: Boolean = true, + artifactVersion: MantraArtifactVersion? = version, + isProposing: Boolean = false, + ) = UploadChaptersUIState.Loaded( + localChatRoom = room, + artifact = artifact, + artifactVersion = artifactVersion, + canSign = canSign, + chapters = chapters, + isProposing = isProposing, + ) + + @Test + fun `a clean row with a version and a key can be proposed`() { + assertTrue(state().canPropose) + } + + @Test + fun `nothing picked is nothing to propose`() { + assertFalse(state(chapters = emptyList()).canPropose) + } + + @Test + fun `a group with no shared key cannot sign a chapter into existence`() { + assertFalse(state(canSign = false).canPropose) + } + + @Test + fun `an artifact with no version has nothing for a chapter to hang off`() { + assertFalse(state(artifactVersion = null).canPropose) + } + + @Test + fun `a pass already running is not offered a second time`() { + // The guard against a double tap proposing every chapter twice, which + // nothing could retract. + assertFalse(state(isProposing = true).canPropose) + } + + @Test + fun `a blocking problem takes its own row out and leaves the others`() { + val chapters = listOf( + row("good"), + row("binary", problem = UploadProblem.NotText), + row("also-good"), + ) + + assertEquals(listOf("good", "also-good"), state(chapters).proposable.map { it.id }) + assertTrue(state(chapters).canPropose) + } + + @Test + fun `every row blocked is nothing to propose`() { + val chapters = listOf( + row("binary", problem = UploadProblem.NotText), + row("empty", problem = UploadProblem.Empty), + ) + + assertEquals(emptyList(), state(chapters).proposable) + assertFalse(state(chapters).canPropose) + } + + @Test + fun `a warning is not a block`() { + // A book can repeat a page, so a duplicate is said and not refused. + val chapters = listOf(row("copy", problem = UploadProblem.Duplicate("The Wreck"))) + + assertEquals(listOf("copy"), state(chapters).proposable.map { it.id }) + assertTrue(state(chapters).canPropose) + } + + @Test + fun `a failed proposal is still proposable, because a retry is a new proposal`() { + val chapters = listOf(row("tried", problem = UploadProblem.ProposalFailed)) + + assertEquals(listOf("tried"), state(chapters).proposable.map { it.id }) + assertTrue(state(chapters).canPropose) + } + + @Test + fun `a row already proposed is neither proposable nor forgotten`() { + val chapters = listOf(row("sent", sessionId = "session"), row("waiting")) + val loaded = state(chapters) + + assertEquals(listOf("waiting"), loaded.proposable.map { it.id }) + assertEquals(listOf("sent"), loaded.proposed.map { it.id }) + } + + @Test + fun `everything proposed leaves nothing to offer`() { + val chapters = listOf(row("sent", sessionId = "session")) + + assertEquals(emptyList(), state(chapters).proposable) + assertFalse(state(chapters).canPropose) + } +} 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..d910d905 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/UploadReviewTest.kt @@ -0,0 +1,183 @@ +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) + // The heading is its own chunk and carries no `#`: a chunk is a leaf + // block's words with the block marker taken off, so a translator cannot + // break the document by mistyping it. See Markdown.splitBlocks. + 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) + } +} diff --git a/composeApp/src/iosMain/kotlin/press/mantra/compose/files/FilePicker.ios.kt b/composeApp/src/iosMain/kotlin/press/mantra/compose/files/FilePicker.ios.kt new file mode 100644 index 00000000..a1412fc4 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/press/mantra/compose/files/FilePicker.ios.kt @@ -0,0 +1,135 @@ +package press.mantra.compose.files + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import co.touchlab.kermit.Logger +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import platform.Foundation.NSData +import platform.Foundation.NSURL +import platform.Foundation.dataWithContentsOfURL +import platform.UIKit.UIApplication +import platform.UIKit.UIDocumentPickerDelegateProtocol +import platform.UIKit.UIDocumentPickerViewController +import platform.UIKit.UIViewController +import platform.UniformTypeIdentifiers.UTTypeData +import platform.UniformTypeIdentifiers.UTTypePlainText +import platform.darwin.NSObject +import press.mantra.compose.text.ChapterText + +/** + * `UIDocumentPickerViewController`, presented over whatever is on screen. + * + * **Unverified on the host this was written on.** The two ios targets are + * declared only on macos -- see the gate in `composeApp/build.gradle.kts` and + * the reason in `docs/jvm-target.md` -- so this file is not compiled on linux + * and has never been run. It is written to the same shape as the other eight + * `.ios.kt` actuals in this source set and should be read on a mac before it is + * trusted. The alternative, a stub that hands back no files, would be worse: + * it reads as a working picker that the reader's chapters vanish into. + * + * `forOpeningContentTypes` rather than the deprecated `initWithDocumentTypes`, + * which takes UTI strings and was removed from the iOS 14 API surface in all + * but name. + * + * **The types are `UTTypePlainText` and `UTTypeData`**, for the reason android's + * are loose: markdown has no registered UTI, so `.md` resolves to + * `public.plain-text` on some devices and to `public.data` on others depending + * on what else is installed. Offering both means the files this exists for are + * selectable; `ChapterText.decode` is what decides whether they are chapters. + * + * **The delegate is held by the composition, not by the view controller.** UIKit + * holds its delegate weakly, so a delegate created inline is collected before + * the reader finishes choosing and the callback never fires -- the classic + * version of this bug. `remember` is what keeps it alive, and is why it is + * built here rather than inside `launch`. + */ +@OptIn(ExperimentalForeignApi::class) +@Composable +actual fun rememberFilePicker( + title: String, + onPicked: (List) -> Unit, +): FilePicker { + val delegate = remember(onPicked) { PickerDelegate(onPicked) } + + return remember(delegate) { + FilePicker { multiple -> + val picker = UIDocumentPickerViewController( + forOpeningContentTypes = listOf(UTTypePlainText, UTTypeData), + asCopy = true, + ) + picker.allowsMultipleSelection = multiple + picker.delegate = delegate + + val presenter = UIApplication.sharedApplication.keyWindow?.rootViewController + if (presenter == null) { + Logger.withTag(TAG).e("No root view controller to present the document picker from") + onPicked(emptyList()) + } else { + presenter.topmost().presentViewController(picker, animated = true, completion = null) + } + } + } +} + +/** + * `asCopy = true` above puts the chosen files in the app's own temporary + * directory, which is what makes reading them afterwards legal without + * `startAccessingSecurityScopedResource`. It is also why [PickedFile] carries + * bytes rather than a url: the copy is the app's to read now and the system's + * to delete whenever it likes. + */ +@OptIn(ExperimentalForeignApi::class) +private class PickerDelegate( + private val onPicked: (List) -> Unit, +) : NSObject(), UIDocumentPickerDelegateProtocol { + + override fun documentPicker( + controller: UIDocumentPickerViewController, + didPickDocumentsAtURLs: List<*>, + ) { + onPicked(didPickDocumentsAtURLs.filterIsInstance().mapNotNull(::readPickedFile)) + } + + override fun documentPickerWasCancelled(controller: UIDocumentPickerViewController) { + onPicked(emptyList()) + } +} + +/** As on the other two platforms: one byte past the cap, so over-size reads as over-size. */ +@OptIn(ExperimentalForeignApi::class) +private fun readPickedFile(url: NSURL): PickedFile? = + runCatching { + val data = NSData.dataWithContentsOfURL(url) ?: return null + PickedFile( + name = url.lastPathComponent ?: "", + bytes = data.toByteArray(limit = ChapterText.MAX_FILE_BYTES + 1), + ) + }.onFailure { error -> + Logger.withTag(TAG).e("Could not read the picked document $url", error) + }.getOrNull() + +@OptIn(ExperimentalForeignApi::class) +private fun NSData.toByteArray(limit: Int): ByteArray { + val size = minOf(length.toInt(), limit) + if (size == 0) return ByteArray(0) + val out = ByteArray(size) + out.usePinned { pinned -> + platform.posix.memcpy(pinned.addressOf(0), bytes, size.toULong()) + } + return out +} + +/** + * The controller actually on screen. + * + * Presenting from the root controller while a sheet is already up throws, and + * this app reaches the add-chapter form through navigation that may have put one + * there. Walking `presentedViewController` is the documented way to find the + * one that can present. + */ +private fun UIViewController.topmost(): UIViewController = + presentedViewController?.topmost() ?: this + +private const val TAG = "FilePicker" diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/compose/files/FilePicker.jvm.kt b/composeApp/src/jvmMain/kotlin/press/mantra/compose/files/FilePicker.jvm.kt new file mode 100644 index 00000000..e1e87242 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/press/mantra/compose/files/FilePicker.jvm.kt @@ -0,0 +1,91 @@ +package press.mantra.compose.files + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import co.touchlab.kermit.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import press.mantra.compose.text.ChapterText +import java.awt.FileDialog +import java.awt.Frame +import java.io.File +import java.io.InputStream + +/** + * AWT's `FileDialog`, which is the native chooser on all three desktops. + * + * `JFileChooser` was the alternative and is worse here: it is Swing's own + * dialog, so it looks like Swing rather than like the host, and on macos it is + * notably not the file panel every other application shows. `FileDialog` maps + * to the platform's own in each case. + * + * **It blocks the calling thread**, which on desktop Compose is the UI thread, + * so showing it from a click handler freezes the window until it closes -- and + * reading the files afterwards would freeze it again. Both go to + * [Dispatchers.IO] and the result comes back through [onPicked] on the main + * dispatcher, which is the same shape the android launcher has by construction. + * + * The scope is the composition's. A dialog whose composable has left is a + * dialog whose result has nowhere to go, and cancelling the read is the right + * behaviour rather than a leak to avoid. + */ +@Composable +actual fun rememberFilePicker( + title: String, + onPicked: (List) -> Unit, +): FilePicker { + val scope = rememberCoroutineScope() + + return remember(scope, title, onPicked) { + FilePicker { multiple -> + scope.launch { + val picked = withContext(Dispatchers.IO) { chooseFiles(title, multiple) } + onPicked(picked) + } + } + } +} + +private fun chooseFiles(title: String, multiple: Boolean): List { + // A null parent is allowed and gives an unowned dialog. It is what a Compose + // desktop window gets us without reaching for the AWT window behind it, + // which `ComposeWindow` exposes but only from a `FrameWindowScope` that the + // app's navigation graph does not thread through. + val dialog = object : FileDialog(null as Frame?, title, LOAD) {} + dialog.isMultipleMode = multiple + dialog.isVisible = true + + val chosen = if (multiple) { + dialog.files?.toList().orEmpty() + } else { + listOfNotNull(dialog.directory?.let { dir -> dialog.file?.let { File(dir, it) } }) + } + + return chosen.mapNotNull(::readPickedFile) +} + +/** As on android: one byte past the cap, so an over-size file reads as over-size. */ +private fun readPickedFile(file: File): PickedFile? = + runCatching { + PickedFile( + name = file.name, + bytes = file.inputStream().use { it.readAtMost(ChapterText.MAX_FILE_BYTES + 1) }, + ) + }.onFailure { error -> + Logger.withTag(TAG).e("Could not read the picked file ${file.path}", error) + }.getOrNull() + +private fun InputStream.readAtMost(limit: Int): ByteArray { + val buffer = ByteArray(limit) + var filled = 0 + while (filled < limit) { + val read = read(buffer, filled, limit - filled) + if (read < 0) break + filled += read + } + return buffer.copyOf(filled) +} + +private const val TAG = "FilePicker" diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MantraProjectDaoJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MantraProjectDaoJvmTest.kt new file mode 100644 index 00000000..5ebfeb8c --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MantraProjectDaoJvmTest.kt @@ -0,0 +1,387 @@ +package press.mantra.compose.database.dao + +import androidx.room3.Room +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +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.MantraDialect +import press.mantra.compose.database.model.MantraProject +import press.mantra.compose.database.model.MantraProjectArtifactVersion +import press.mantra.compose.database.model.MantraProjectTranslationArtifactVersion +import press.mantra.compose.database.model.MantraTranslationArtifactVersion +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.Profile +import press.mantra.compose.nostr.nip30303.ProjectEvent +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * The three tables, and the one guarantee the schema makes about them. + * + * A project's contents are foreign keys into this room's own rows. That is the + * scope decision -- one room, for now -- and it is load-bearing rather than + * decorative: it is what makes a membership naming a version this device does + * not hold fail at insert, loudly, instead of landing as a row pointing at + * nothing that every later read has to be defensive about. When projects learn + * to span rooms the keys go away, and this test is what has to be rewritten + * rather than quietly starting to pass for a different reason. + * + * The other thing here is that the owners are a string. A set stored as text is + * a shape that rots -- an empty entry, a stray space, the order changing between + * two devices -- so `ownerPublicKeySet` is tested against each of those rather + * than against the happy path it obviously handles. + */ +class MantraProjectDaoJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + + @AfterTest + fun closeDb() = db.close() + + private val roomId = "b".repeat(64) + private val otherRoomId = "f".repeat(64) + private val signature = "0".repeat(128) + + private val creator = "a".repeat(64) + private val ownerOne = "1".repeat(64) + private val ownerTwo = "2".repeat(64) + + private val artifactVersionId = "3".repeat(64) + private val translationId = "5".repeat(64) + + /** The room, an artifact version and a translation: the keys in the way. */ + private suspend fun seed() { + val nostrEventId = "e".repeat(64) + db.nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = creator, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = signature, + ) + ) + db.profileDao().upsert(Profile(publicKey = creator, nostrEventId = nostrEventId)) + + listOf(roomId, otherRoomId).forEach { id -> + db.chatRoomDao().upsert( + ChatRoom( + id = id, + subject = "a translation room", + description = null + ) + ) + } + + val dialectId = "1".repeat(64) + db.mantraDialectDao().upsert( + MantraDialect( + id = dialectId, + publicKey = roomId, + name = "Sesotho", + country = "ZA", + language = "st", + signature = signature, + chatRoomId = roomId, + ) + ) + + val artifactId = "2".repeat(64) + db.mantraArtifactDao().upsert( + MantraArtifact( + id = artifactId, + publicKey = roomId, + name = "To Kill a Mockingbird", + url = "https://harper.com/2-kill-Bird", + visibility = "private", + dialectId = dialectId, + license = "cc", + chatRoomId = roomId, + signature = signature, + ) + ) + + db.mantraArtifactVersionDao().upsert( + MantraArtifactVersion( + id = artifactVersionId, + artifactId = artifactId, + publicKey = roomId, + versionLabel = "1.0", + signature = signature, + chatRoomId = roomId, + ) + ) + + db.mantraTranslationArtifactVersionDao().upsert( + MantraTranslationArtifactVersion( + id = translationId, + publicKey = roomId, + artifactVersionId = artifactVersionId, + dialectId = dialectId, + name = "Sesotho", + visibility = "private", + license = "cc", + signature = signature, + chatRoomId = roomId, + ) + ) + } + + private suspend fun project( + id: String, + name: String = "Gospel of John", + chatRoomId: String = roomId, + owners: String = ownerOne, + ) = MantraProject( + id = id, + publicKey = chatRoomId, + name = name, + description = "The four chapters.", + creatorPublicKey = creator, + ownerPublicKeys = owners, + chatRoomId = chatRoomId, + signature = signature, + ).also { db.mantraProjectDao().upsert(it) } + + // --- the project table --- + + @Test + fun `a project round trips, owners and creator intact`() = runBlocking { + seed() + project(id = "p".repeat(64), owners = "$ownerOne,$ownerTwo") + + val stored = db.mantraProjectDao().getProjectById("p".repeat(64)) + ?: fail("the project was not stored") + + assertEquals(creator, stored.creatorPublicKey) + assertEquals(setOf(ownerOne, ownerTwo), stored.ownerPublicKeySet()) + assertTrue(stored.isOwnedBy(ownerTwo)) + assertTrue(!stored.isOwnedBy(creator)) + } + + @Test + fun `projects are listed per room, by name`() = runBlocking { + seed() + project(id = "p2".padEnd(64, '0'), name = "Zechariah") + project(id = "p1".padEnd(64, '0'), name = "Amos") + project(id = "p3".padEnd(64, '0'), name = "Elsewhere", chatRoomId = otherRoomId) + + val names = db.mantraProjectDao().getProjectsByChatRoomId(roomId).map { it.name } + + assertEquals(listOf("Amos", "Zechariah"), names) + } + + // --- ownerPublicKeySet, against the ways a delimited string goes wrong --- + + @Test + fun `the owner set survives spacing, empties and a single entry`() { + fun owners(stored: String) = MantraProject( + id = "p".repeat(64), + publicKey = roomId, + name = "n", + description = "", + creatorPublicKey = creator, + ownerPublicKeys = stored, + chatRoomId = roomId, + signature = signature, + ).ownerPublicKeySet() + + assertEquals(setOf(ownerOne), owners(ownerOne)) + assertEquals(setOf(ownerOne, ownerTwo), owners(" $ownerOne , $ownerTwo ")) + assertEquals(setOf(ownerOne), owners("$ownerOne,,")) + assertEquals(emptySet(), owners("")) + } + + @Test + fun `two rows built from the same owners in different orders agree`() { + val event = { owners: Set -> + ProjectEvent.build( + name = "Gospel of John", + description = "", + creatorPublicKey = creator, + ownerPublicKeys = owners, + createdAt = 1_000, + ) + } + + val one = MantraProject.fromProjectEventTemplate( + event(linkedSetOf(ownerTwo, ownerOne)), roomId, roomId + ) + val other = MantraProject.fromProjectEventTemplate( + event(linkedSetOf(ownerOne, ownerTwo)), roomId, roomId + ) + + assertEquals(one?.ownerPublicKeys, other?.ownerPublicKeys) + // And the ids too, since the event sorts before hashing. + assertEquals(one?.id, other?.id) + } + + // --- the memberships, and the keys that make a bad one fail --- + + @Test + fun `a project's contents come back resolved`() = runBlocking { + seed() + val projectId = "p".repeat(64) + project(id = projectId) + + db.mantraProjectArtifactVersionDao().upsert( + MantraProjectArtifactVersion( + id = "m1".padEnd(64, '0'), + publicKey = roomId, + projectId = projectId, + artifactVersionId = artifactVersionId, + chatRoomId = roomId, + signature = signature, + ) + ) + db.mantraProjectTranslationArtifactVersionDao().upsert( + MantraProjectTranslationArtifactVersion( + id = "m2".padEnd(64, '0'), + publicKey = roomId, + projectId = projectId, + translationArtifactVersionId = translationId, + chatRoomId = roomId, + signature = signature, + ) + ) + + val sources = db.mantraProjectArtifactVersionDao() + .getArtifactVersionsByProjectId(projectId) + val translations = db.mantraProjectTranslationArtifactVersionDao() + .getTranslationArtifactVersionsByProjectId(projectId) + + assertEquals(listOf("1.0"), sources.map { it.versionLabel }) + assertEquals(listOf("Sesotho"), translations.map { it.name }) + } + + @Test + fun `an artifact version says which projects it is in`() = runBlocking { + seed() + val projectId = "p".repeat(64) + project(id = projectId, name = "Gospel of John") + + db.mantraProjectArtifactVersionDao().upsert( + MantraProjectArtifactVersion( + id = "m1".padEnd(64, '0'), + publicKey = roomId, + projectId = projectId, + artifactVersionId = artifactVersionId, + chatRoomId = roomId, + signature = signature, + ) + ) + + assertEquals( + listOf("Gospel of John"), + db.mantraProjectArtifactVersionDao() + .getProjectsByArtifactVersionId(artifactVersionId) + .map { it.name } + ) + } + + @Test + fun `a translation says which projects it is in`() = runBlocking { + seed() + val projectId = "p".repeat(64) + project(id = projectId, name = "Gospel of John") + + db.mantraProjectTranslationArtifactVersionDao().upsert( + MantraProjectTranslationArtifactVersion( + id = "m2".padEnd(64, '0'), + publicKey = roomId, + projectId = projectId, + translationArtifactVersionId = translationId, + chatRoomId = roomId, + signature = signature, + ) + ) + + // The artifact-side twin of this had a test from the start and the + // translation side did not, which is the kind of asymmetry a copied + // query hides: both joins are three lines and one of them could have + // named the wrong column without anything noticing. + assertEquals( + listOf("Gospel of John"), + db.mantraProjectTranslationArtifactVersionDao() + .getProjectsByTranslationArtifactVersionId(translationId) + .map { it.name } + ) + + // And it is specific to the translation rather than to the project. + assertTrue( + db.mantraProjectTranslationArtifactVersionDao() + .getProjectsByTranslationArtifactVersionId("9".repeat(64)) + .isEmpty() + ) + } + + @Test + fun `a membership naming a version this room does not hold is refused`() = runBlocking { + seed() + val projectId = "p".repeat(64) + project(id = projectId) + + val absent = "9".repeat(64) + + val failure = runCatching { + db.mantraProjectArtifactVersionDao().upsert( + MantraProjectArtifactVersion( + id = "m1".padEnd(64, '0'), + publicKey = roomId, + projectId = projectId, + artifactVersionId = absent, + chatRoomId = roomId, + signature = signature, + ) + ) + }.exceptionOrNull() + + // The foreign key, not a check in the apply arm. When Phase 9 takes it + // away this is the assertion that has to be rewritten rather than left + // to start passing because nothing writes memberships any more. + assertTrue( + failure != null, + "a membership pointing at an absent version was accepted" + ) + assertNull(db.mantraProjectArtifactVersionDao().getById("m1".padEnd(64, '0'))) + } + + @Test + fun `deleting the room takes the project and its memberships with it`() = runBlocking { + seed() + val projectId = "p".repeat(64) + project(id = projectId) + + db.mantraProjectArtifactVersionDao().upsert( + MantraProjectArtifactVersion( + id = "m1".padEnd(64, '0'), + publicKey = roomId, + projectId = projectId, + artifactVersionId = artifactVersionId, + chatRoomId = roomId, + signature = signature, + ) + ) + + db.chatRoomDao().delete( + ChatRoom( + id = roomId, + subject = "a translation room", + description = null + ) + ) + + assertNull(db.mantraProjectDao().getProjectById(projectId)) + assertTrue(db.mantraProjectArtifactVersionDao().getByProjectId(projectId).isEmpty()) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/ChatRoomMembershipMigrationJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/ChatRoomMembershipMigrationJvmTest.kt index d67e301b..726913ba 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/ChatRoomMembershipMigrationJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/ChatRoomMembershipMigrationJvmTest.kt @@ -10,13 +10,13 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue /** - * The v22 -> v23 column mapping, on a bare connection. + * The v23 -> v24 column mapping, on a bare connection. * * What moves where, and that the group keeps only its own facts. Run with foreign keys * **off**, which is not a convenience -- it is the condition the migration actually runs * under, because Room disables them around a migration and `ChatRoom` is the parent of - * nineteen `ON DELETE CASCADE` children that no rebuild could survive otherwise. See - * `MIGRATION_22_23` for why there is no alternative, and + * twenty-two `ON DELETE CASCADE` children that no rebuild could survive otherwise. See + * `MIGRATION_23_24` for why there is no alternative, and * [ChatRoomSplitThroughRoomJvmTest] for the test that proves the condition holds rather * than assuming it: that one runs the real Room path with children seeded, and is where * the cascade question is settled. @@ -35,7 +35,13 @@ class ChatRoomMembershipMigrationJvmTest { private val holder = "0a".repeat(32) private val mlsState = "a1".repeat(32) - /** v22's `ChatRoom`, its `Profile` parent, and four of its nineteen children. */ + /** + * `ChatRoom` as it stands before the split, its `Profile` parent, and four of its + * twenty-two children. + * + * The room's shape is the same at v22 and v23 -- the ritual keying in between did not + * touch it -- so this fixture is the pre-split room whichever of the two it came from. + */ private fun createV22() { // Off, as Room leaves them around a migration. See the note on the class. connection.execSQL("PRAGMA foreign_keys = OFF") @@ -64,7 +70,7 @@ class ChatRoomMembershipMigrationJvmTest { "ON UPDATE NO ACTION ON DELETE CASCADE )" ) - // Four of the nineteen, standing in for the lot: a message, a participant, a + // Four of the twenty-two, standing in for the lot: a message, a participant, a // ceremony and a piece of the group's signed work. CHILDREN.forEach { (table, extra) -> connection.execSQL( @@ -108,7 +114,7 @@ class ChatRoomMembershipMigrationJvmTest { fun `the group keeps its own facts and loses the rest`() = runBlocking { createV22() - MIGRATION_22_23.migrate(connection) + MIGRATION_23_24.migrate(connection) assertEquals( listOf("id", "subject", "description", "createdAt", "updatedAt", "savedAt"), @@ -133,7 +139,7 @@ class ChatRoomMembershipMigrationJvmTest { fun `every room becomes exactly one membership, carrying the profile's half`() = runBlocking { createV22() - MIGRATION_22_23.migrate(connection) + MIGRATION_23_24.migrate(connection) assertEquals(1, count("ChatRoomMembership")) assertEquals( @@ -162,7 +168,7 @@ class ChatRoomMembershipMigrationJvmTest { fun `the upgraded schema takes a second profile's membership`() = runBlocking { createV22() - MIGRATION_22_23.migrate(connection) + MIGRATION_23_24.migrate(connection) val second = "0b".repeat(32) connection.execSQL("INSERT INTO `Profile` VALUES ('$second')") @@ -176,7 +182,7 @@ class ChatRoomMembershipMigrationJvmTest { assertEquals( 1, count("ChatRoom"), - "and still one room -- which is what the nineteen children point at", + "and still one room -- which is what the twenty-two children point at", ) assertTrue( one("SELECT `mlsGroupState` FROM `ChatRoomMembership` WHERE `userPublicKey` = '$holder'") { @@ -191,7 +197,7 @@ class ChatRoomMembershipMigrationJvmTest { fun `the renamed table is gone`() = runBlocking { createV22() - MIGRATION_22_23.migrate(connection) + MIGRATION_23_24.migrate(connection) assertEquals( emptyList(), @@ -203,7 +209,7 @@ class ChatRoomMembershipMigrationJvmTest { private companion object { /** - * Four of `ChatRoom`'s nineteen children, one per shape that matters: a message, + * Four of `ChatRoom`'s twenty-two children, one per shape that matters: a message, * the roster, a ceremony and a piece of the group's signed work. They are all the * same single-column cascade onto the same parent, so they fail or survive * together. diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/ChatRoomSplitThroughRoomJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/ChatRoomSplitThroughRoomJvmTest.kt index cd320cd9..ada39b4b 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/ChatRoomSplitThroughRoomJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/ChatRoomSplitThroughRoomJvmTest.kt @@ -18,10 +18,14 @@ import press.mantra.compose.database.MantraDatabase import press.mantra.compose.database.builder.getRoomDatabase /** - * The v22 -> v23 split as a real device performs it, which is the only setting that + * The membership split as a real device performs it, which is the only setting that * answers the question this migration turns on. * - * `ChatRoom` is the parent of nineteen `ON DELETE CASCADE` children -- the messages, the + * Starts from a v22 file and lets Room take it all the way to the current version, so it + * runs 22 -> 23 (the ritual keying) and 23 -> 24 (this split) in one upgrade, which is what + * a device coming off the projects release will actually do. + * + * `ChatRoom` is the parent of twenty-two `ON DELETE CASCADE` children -- the messages, the * participants, the ceremonies, the signing sessions, the key states and the whole * translation domain -- and splitting the membership off means rebuilding that table. * There is no way to rebuild it that survives foreign keys being enforced: @@ -29,7 +33,7 @@ import press.mantra.compose.database.builder.getRoomDatabase * - `DROP TABLE` performs an implicit `DELETE FROM`, so the cascades fire and every child * row goes; * - `ALTER TABLE ... RENAME TO` rewrites the children's foreign-key clauses to follow the - * rename, pointing all nineteen at a table about to be dropped. `PRAGMA + * rename, pointing every one of them at a table about to be dropped. `PRAGMA * legacy_alter_table` does **not** prevent that -- measured, not assumed: it reads back * as set and the clause is rewritten anyway; * - `ALTER TABLE ... DROP COLUMN` cannot remove `userPublicKey` or @@ -37,14 +41,14 @@ import press.mantra.compose.database.builder.getRoomDatabase * - and a migration cannot turn foreign keys off itself, because `PRAGMA foreign_keys` is * a no-op inside a transaction and Room runs migrations in one. * - * Backing up all nineteen children and their own twenty-two dependents is the only + * Backing up all twenty-two children and their own dependents is the only * alternative, and it is worse than what it protects against. * * So this migration depends on Room disabling foreign keys around the migration it runs, * which is what makes the ordinary rebuild-a-parent-table migration possible at all. That * dependency is exactly the kind `MIGRATION_9_10` warns against taking on trust -- so it * is not taken on trust. It is asserted here, against a real file, through - * `getRoomDatabase`, with children seeded in four of the nineteen tables. + * `getRoomDatabase`, with children seeded in four of the twenty-two tables. * * If this test ever fails, the migration is unsafe and no amount of reordering its * statements will fix it; the children have to be copied out and back. @@ -95,8 +99,8 @@ class ChatRoomSplitThroughRoomJvmTest { * A v22 file holding one room, its membership-to-be, and work in four of the tables * that cascade off it. * - * From the committed export rather than from DDL restated here: fifty-five tables is - * too many to copy, and the export is what a v22 device actually holds. + * From the committed export rather than from DDL restated here: fifty-eight tables is too + * many to copy, and the export is what a v22 device actually holds. */ private fun createV22Database() { val (identity, statements) = export(22) @@ -129,7 +133,7 @@ class ChatRoomSplitThroughRoomJvmTest { "'$mlsState', 2000, 1000, 1000, 1000)" ) - // Four of the nineteen, one per shape: the roster, a transcript line, a + // Four of the twenty-two, one per shape: the roster, a transcript line, a // ceremony and a piece of the group's signed work. They cascade identically, // so they are lost or kept together. connection.execSQL( @@ -178,7 +182,7 @@ class ChatRoomSplitThroughRoomJvmTest { * left against the entities -- and the group's history is still there. */ @Test - fun `Room splits the membership off without taking the room's nineteen children`() = runBlocking { + fun `Room splits the membership off without taking the room's children`() = runBlocking { createV22Database() val db = openThroughRoom() diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/DkgSessionMigrationThroughRoomJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/DkgSessionMigrationThroughRoomJvmTest.kt index cb8a8657..4852548b 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/DkgSessionMigrationThroughRoomJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/DkgSessionMigrationThroughRoomJvmTest.kt @@ -22,7 +22,7 @@ import press.mantra.compose.database.builder.getRoomDatabase /** * The half of a migration a bare-connection test cannot reach: Room's own wiring. * - * [DkgSessionPerProfileMigrationJvmTest] drives `MIGRATION_21_22`'s SQL directly, + * [DkgSessionPerProfileMigrationJvmTest] drives `MIGRATION_22_23`'s SQL directly, * which covers the copy and the shape it leaves behind. What it cannot cover is * whether that shape is the one Room expects. On opening a file at an older version * Room runs the migrations and then compares every table against its entity, failing diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/DkgSessionPerProfileMigrationJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/DkgSessionPerProfileMigrationJvmTest.kt index a09c2f33..d6cd0b23 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/DkgSessionPerProfileMigrationJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/DkgSessionPerProfileMigrationJvmTest.kt @@ -137,7 +137,7 @@ class DkgSessionPerProfileMigrationJvmTest { createV21() insertV21Session() - MIGRATION_21_22.migrate(connection) + MIGRATION_22_23.migrate(connection) assertEquals(listOf("id", "userPublicKey"), primaryKey("DkgSession")) assertEquals( @@ -178,7 +178,7 @@ class DkgSessionPerProfileMigrationJvmTest { insertV21Message(participant = bob) insertV21Message(participant = alice) - MIGRATION_21_22.migrate(connection) + MIGRATION_22_23.migrate(connection) assertEquals(1, count("DkgSession")) assertEquals(2, count("DkgParticipantMessage"), "the cascade must not have fired") @@ -213,7 +213,7 @@ class DkgSessionPerProfileMigrationJvmTest { insertV21Message(sessionId = "alices", participant = bob) insertV21Message(sessionId = "bobs", participant = alice) - MIGRATION_21_22.migrate(connection) + MIGRATION_22_23.migrate(connection) assertEquals( listOf("alices" to alice, "bobs" to bob), @@ -234,7 +234,7 @@ class DkgSessionPerProfileMigrationJvmTest { insertV21Session() insertV21Message() - MIGRATION_21_22.migrate(connection) + MIGRATION_22_23.migrate(connection) connection.execSQL("DELETE FROM `ChatRoom` WHERE `id` = 'room'") assertEquals(0, count("DkgSession")) @@ -259,7 +259,7 @@ class DkgSessionPerProfileMigrationJvmTest { insertV21Message(sessionId = "no-such-ceremony", participant = alice) connection.execSQL("PRAGMA foreign_keys = ON") - MIGRATION_21_22.migrate(connection) + MIGRATION_22_23.migrate(connection) assertEquals(1, count("DkgParticipantMessage")) assertTrue( @@ -274,7 +274,7 @@ class DkgSessionPerProfileMigrationJvmTest { createV21() insertV21Session() - MIGRATION_21_22.migrate(connection) + MIGRATION_22_23.migrate(connection) assertEquals( emptyList(), diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/model/ProjectApplyJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/model/ProjectApplyJvmTest.kt new file mode 100644 index 00000000..a7ccfc05 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/model/ProjectApplyJvmTest.kt @@ -0,0 +1,402 @@ +package press.mantra.compose.database.model + +import androidx.room3.Room +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip31Alts.AltTag +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.tags.ContributorTag +import press.mantra.compose.nostr.nip30303.tags.CreatorTag +import press.mantra.compose.nostr.nip30303.tags.NameTag +import press.mantra.compose.nostr.nip30303.tags.ProjectIdTag +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.fail +import kotlin.time.Instant + +/** + * What a signed project turns into, and the three ways it is allowed not to. + * + * The apply arm is the only place a project row is written -- `FrostSigningManager` + * reuses this dispatch rather than repeating it -- so it is also the only place + * the event's claims about people become columns. Two of those claims are + * required and the arm refuses the event rather than inventing them, which + * matters more than it looks: the whole reason the creator and the owners are + * tags is that they are inside the id the quorum signed, and a row that could + * have been filled in from local knowledge would be worth less than no row. + * + * The third refusal is the owners disappearing while the event stays valid. + * That is what `ContributorTag.ROLE_OWNER` missing from `KNOWN_ROLES` looks + * like from here -- the role parses as null and `owners()` comes back empty -- + * and the only thing standing between that and a silent regression is this + * test, since nothing logs and the signature still verifies. + */ +class ProjectApplyJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + + @AfterTest + fun closeDb() = db.close() + + private val roomId = "b".repeat(64) + private val signature = "0".repeat(128) + + private val creator = "a".repeat(64) + private val ownerOne = "1".repeat(64) + private val ownerTwo = "2".repeat(64) + private val ownerThree = "4".repeat(64) + + private val artifactVersionId = "3".repeat(64) + private val translationId = "5".repeat(64) + + private suspend fun seed() { + val nostrEventId = "e".repeat(64) + db.nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = creator, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = signature, + ) + ) + db.profileDao().upsert(Profile(publicKey = creator, nostrEventId = nostrEventId)) + db.chatRoomDao().upsert( + ChatRoom( + id = roomId, + subject = "a translation room", + description = null + ) + ) + + val dialectId = "1".repeat(64) + db.mantraDialectDao().upsert( + MantraDialect( + id = dialectId, + publicKey = roomId, + name = "Sesotho", + country = "ZA", + language = "st", + signature = signature, + chatRoomId = roomId, + ) + ) + + val artifactId = "2".repeat(64) + db.mantraArtifactDao().upsert( + MantraArtifact( + id = artifactId, + publicKey = roomId, + name = "To Kill a Mockingbird", + url = "https://harper.com/2-kill-Bird", + visibility = "private", + dialectId = dialectId, + license = "cc", + chatRoomId = roomId, + signature = signature, + ) + ) + + db.mantraArtifactVersionDao().upsert( + MantraArtifactVersion( + id = artifactVersionId, + artifactId = artifactId, + publicKey = roomId, + versionLabel = "1.0", + signature = signature, + chatRoomId = roomId, + ) + ) + + db.mantraTranslationArtifactVersionDao().upsert( + MantraTranslationArtifactVersion( + id = translationId, + publicKey = roomId, + artifactVersionId = artifactVersionId, + dialectId = dialectId, + name = "Sesotho", + visibility = "private", + license = "cc", + signature = signature, + chatRoomId = roomId, + ) + ) + } + + /** The event as the group signed it: authored by the room, id over its tags. */ + private fun signed(template: EventTemplate<*>): Event = Event( + id = EventHasher.hashId( + pubKey = roomId, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + ), + pubKey = roomId, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = signature, + ) + + private suspend fun apply(event: Event) = ChatMessage.applyInnerEvent( + database = db, + groupId = roomId, + event = event, + marmotGroupEventId = null, + marmotInnerEventId = null, + senderPublicKey = roomId, + isUserMessage = false, + createdAt = Instant.fromEpochSeconds(event.createdAt), + ) + + private fun projectTemplate(owners: Set = setOf(ownerOne)) = ProjectEvent.build( + name = "Gospel of John into Sesotho", + description = "The four chapters.", + creatorPublicKey = creator, + ownerPublicKeys = owners, + createdAt = 1_000, + ) + + // --- the project --- + + @Test + fun `a signed project becomes a row carrying its creator and every owner`() = runBlocking { + seed() + val project = signed(projectTemplate(setOf(ownerThree, ownerOne, ownerTwo))) + + val line = apply(project) ?: fail("a project writes a chat line") + + val stored = db.mantraProjectDao().getProjectById(project.id) + ?: fail("the project was not stored") + + assertEquals("Gospel of John into Sesotho", stored.name) + assertEquals("The four chapters.", stored.description) + assertEquals(creator, stored.creatorPublicKey) + assertEquals(setOf(ownerOne, ownerTwo, ownerThree), stored.ownerPublicKeySet()) + // The room authored it; the creator is a claim it carries, not its author. + assertEquals(roomId, stored.publicKey) + + assertEquals("project", line.messageType) + assertTrue(line.content.contains("Gospel of John into Sesotho")) + } + + @Test + fun `a project with no creator tag writes nothing`() = runBlocking { + seed() + + // Everything a project has except the creator, with the tag order the + // builder uses so this is a plausible event rather than a mangled one. + val template = ProjectEvent.build( + name = "Gospel of John into Sesotho", + description = "The four chapters.", + creatorPublicKey = creator, + ownerPublicKeys = setOf(ownerOne), + createdAt = 1_000, + ) + val withoutCreator = Event( + id = "c".repeat(64), + pubKey = roomId, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags.filterNot { it.firstOrNull() == CreatorTag.TAG_NAME } + .toTypedArray(), + content = template.content, + sig = signature, + ) + + assertNull(apply(withoutCreator)) + assertNull(db.mantraProjectDao().getProjectById(withoutCreator.id)) + } + + /** + * The regression this file exists for. + * + * An unregistered role is dropped by `ContributorTag.parse`, so the owner + * tags are still there, the event still verifies, and `owners()` returns + * nothing. Built by hand with a role that is deliberately not in + * `KNOWN_ROLES`, because the point is to reproduce what `ROLE_OWNER` would + * look like if somebody removed it. + */ + @Test + fun `a project whose owner role is not a known one writes nothing`() = runBlocking { + seed() + + val tags = TagArrayBuilder() + .addUnique(AltTag.assemble(ProjectEvent.ALT_DESCRIPTION)) + .addUnique(NameTag.assemble("Gospel of John into Sesotho")) + .addUnique(CreatorTag.assemble(creator)) + .addUniqueValueIfNew(ContributorTag.assemble(ownerOne, "proprietor")) + .build() + + val event = Event( + id = "d".repeat(64), + pubKey = roomId, + createdAt = 1_000, + kind = ProjectEvent.KIND, + tags = tags, + content = "The four chapters.", + sig = signature, + ) + + assertNull(apply(event)) + assertNull(db.mantraProjectDao().getProjectById(event.id)) + } + + // --- the memberships --- + + @Test + fun `memberships are stored and write no line of their own`() = runBlocking { + seed() + val project = signed(projectTemplate()) + apply(project) + + val source = signed( + ProjectArtifactVersionEvent.membershipsOf(project, listOf(artifactVersionId)).single() + ) + val translation = signed( + ProjectTranslationArtifactVersionEvent + .membershipsOf(project, listOf(translationId)) + .single() + ) + + // Null, the way a version writes none beside its artifact: the project's + // own line has already said what happened. + assertNull(apply(source)) + assertNull(apply(translation)) + + assertEquals( + listOf("1.0"), + db.mantraProjectArtifactVersionDao() + .getArtifactVersionsByProjectId(project.id) + .map { it.versionLabel } + ) + assertEquals( + listOf("Sesotho"), + db.mantraProjectTranslationArtifactVersionDao() + .getTranslationArtifactVersionsByProjectId(project.id) + .map { it.name } + ) + } + + /** + * The refusal is **allowed out**, and that is the whole of why it matters. + * + * `ChronicleManager.applyPage` learns that a payload could not be applied + * only by catching the exception: it counts the payload failed, and `sweep` + * runs the pages again until failures stop falling. That loop is what + * delivers a membership whose project arrived in a *later* page. Swallow the + * violation here and the payload counts as applied, the sweep terminates, + * and the membership is lost silently -- on the one device that has nothing + * to fall back on. + * + * It was swallowed here for one commit, on the reasoning that + * `FrostSigningManager.applySignedEvent` must not report a succeeded session + * as failed. That reasoning was already satisfied -- it has its own + * try/catch -- and no sibling arm catches, so catching made this the only + * kind a chronicle could not retry. + */ + @Test + fun `a membership naming a version this device lacks throws, and spares the project`() = + runBlocking { + seed() + val project = signed(projectTemplate()) + apply(project) + + val absent = "9".repeat(64) + val source = signed( + ProjectArtifactVersionEvent.membershipsOf(project, listOf(absent)).single() + ) + + assertFails { apply(source) } + + assertTrue( + db.mantraProjectArtifactVersionDao().getByProjectId(project.id).isEmpty() + ) + assertEquals( + "Gospel of John into Sesotho", + db.mantraProjectDao().getProjectById(project.id)?.name + ) + } + + @Test + fun `a membership naming a project this device lacks throws the same way`() = + runBlocking { + seed() + val absentProject = "8".repeat(64) + + val template = ProjectArtifactVersionEvent.build( + projectId = absentProject, + artifactVersionId = artifactVersionId, + createdAt = 1_000, + ) + + assertFails { apply(signed(template)) } + assertTrue( + db.mantraProjectArtifactVersionDao().getByProjectId(absentProject).isEmpty() + ) + } + + @Test + fun `a membership that arrives after its project applies cleanly`() = runBlocking { + seed() + val project = signed(projectTemplate()) + val source = signed( + ProjectArtifactVersionEvent.membershipsOf(project, listOf(artifactVersionId)).single() + ) + + // The retry the sweep performs, in miniature: refused while the project + // is missing, accepted once it is there. Same event both times. + assertFails { apply(source) } + apply(project) + apply(source) + + assertEquals( + listOf("1.0"), + db.mantraProjectArtifactVersionDao() + .getArtifactVersionsByProjectId(project.id) + .map { it.versionLabel } + ) + } + + @Test + fun `a membership missing the project it belongs to writes nothing`() = runBlocking { + seed() + val project = signed(projectTemplate()) + apply(project) + + val template = ProjectArtifactVersionEvent.build( + projectId = project.id, + artifactVersionId = artifactVersionId, + createdAt = 1_000, + ) + val withoutProject = Event( + id = "7".repeat(64), + pubKey = roomId, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags.filterNot { it.firstOrNull() == ProjectIdTag.TAG_NAME } + .toTypedArray(), + content = template.content, + sig = signature, + ) + + assertNull(apply(withoutProject)) + assertNull(db.mantraProjectArtifactVersionDao().getById(withoutProject.id)) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterIndexingJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterIndexingJvmTest.kt new file mode 100644 index 00000000..cc55aab9 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterIndexingJvmTest.kt @@ -0,0 +1,304 @@ +package press.mantra.compose.managers + +import androidx.room3.Room +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.crypto.frost.Frost +import fr.acinq.bitcoin.crypto.frost.KeyMaterial +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.DkgParticipantMessage +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.MantraArtifact +import press.mantra.compose.database.model.MantraArtifactVersion +import press.mantra.compose.database.model.MantraChapter +import press.mantra.compose.database.model.MantraDialect +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.database.repository.DatabaseFrostSigningRepository +import press.mantra.compose.database.repository.DatabaseMantraRepository +import press.mantra.compose.extensions.toHex +import press.mantra.compose.nostr.dkg.DkgRitualEvents +import press.mantra.compose.nostr.nip30303.ChapterEvent +import press.mantra.compose.nostr.nip30303.ChunkEvent +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.text.Markdown + +/** + * A chapter's index has to be right when it is proposed, because it is signed + * into the event and cannot be recounted on arrival. + * + * The test that matters here is the third one, and it fails against the old + * `getChaptersForArtifactVersion(...).size`: three chapters proposed back to + * back all read the same empty table and all get index 0. At one chapter at a + * time that is a race nobody hits. An upload of twelve files hits it every + * time, which is why this is a fix rather than a feature. + */ +class ChapterIndexingJvmTest { + private val participants = 3 + private val threshold = 2 + + private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1") + ), + nParticipants = participants, + threshold = threshold, + ) + + private val thresholdPublicKey = keyMaterial.thresholdPublicKey.value.toHex() + private val roomId = SharedKeyDerivation.marmotGroupId(thresholdPublicKey) + + private val ceremonyId = "ceremony".padEnd(64, '0') + private val members = listOf("a", "b", "c").map { it.repeat(64) } + private val hostKeys = listOf("2a", "2b", "2c").map { it.padEnd(66, '0') } + + private val proposer = members.first() + private val dialectId = "d".repeat(64) + private val artifactId = "a1".padEnd(64, '0') + private val artifactVersionId = "b".repeat(64) + private val otherVersionId = "c".repeat(64) + + private lateinit var db: MantraDatabase + private lateinit var room: LocalChatRoom + private lateinit var mantraRepository: MantraRepository + private lateinit var frostSigningRepository: FrostSigningRepository + + @AfterTest + fun closeDatabase() { + if (::db.isInitialized) db.close() + } + + private suspend fun openDevice() { + db = getRoomDatabase(Room.inMemoryDatabaseBuilder()) + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + mantraRepository = DatabaseMantraRepository(db, scope) + frostSigningRepository = DatabaseFrostSigningRepository(db, scope) + + val nostrEventId = "e$proposer".take(64) + db.nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = proposer, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = "0".repeat(128), + ) + ) + db.profileDao().upsert(Profile(publicKey = proposer, nostrEventId = nostrEventId)) + + val chatRoom = ChatRoom( + id = roomId, + subject = "#Ekklesia", + description = SharedKeyDerivation.describe("Admins of the group.") + ) + db.chatRoomDao().upsert(chatRoom) + + db.dkgSessionDao().upsert( + DkgSession( + id = ceremonyId, + chatRoomId = roomId, + coordinatorPublicKey = proposer, + userPublicKey = proposer, + threshold = threshold, + participantCount = participants, + stage = DkgRitualStage.COMPLETE, + hostPublicKey = hostKeys[0], + round1Random = "1".repeat(64), + round2AuxRandom = "2".repeat(64), + thresholdPublicKey = thresholdPublicKey, + secretShare = keyMaterial.secretShares[0].value.toHex(), + publicShares = keyMaterial.publicShares.joinToString(",") { it.value.toHex() }, + ) + ) + members.forEachIndexed { index, member -> + db.dkgSessionDao().upsert( + DkgParticipantMessage( + sessionId = ceremonyId, + userPublicKey = proposer, + participantPublicKey = member, + kind = DkgRitualEvents.HOST_KEY, + payload = hostKeys[index], + ) + ) + } + + // The artifact a chapter hangs off, and a second version of it, so that + // "chapters of this version" can be told from "chapters in this room". + db.mantraDialectDao().upsert( + MantraDialect( + id = dialectId, + publicKey = roomId, + name = "Setswana", + country = "BW", + language = "tn", + signature = "", + chatRoomId = roomId, + ) + ) + db.mantraArtifactDao().upsert( + MantraArtifact( + id = artifactId, + publicKey = roomId, + name = "To Kill a Mockingbird", + url = "https://example.test/mockingbird", + visibility = "private", + dialectId = dialectId, + license = "cc", + chatRoomId = roomId, + signature = "", + ) + ) + listOf(artifactVersionId to "1.0", otherVersionId to "2.0").forEach { (id, label) -> + db.mantraArtifactVersionDao().upsert( + MantraArtifactVersion( + id = id, + artifactId = artifactId, + publicKey = roomId, + versionLabel = label, + chatRoomId = roomId, + signature = "", + ) + ) + } + + room = LocalChatRoom(chatRoom = chatRoom) + } + + private suspend fun insertChapter(index: Int, versionId: String = artifactVersionId) { + db.mantraChapterDao().upsert( + MantraChapter( + id = "${versionId.take(8)}chapter$index".padEnd(64, '0'), + artifactVersionId = versionId, + publicKey = roomId, + name = "Chapter $index", + originalText = "Signed already.", + index = index, + wordCount = 2, + characterCount = 15, + signature = "", + chatRoomId = roomId, + ) + ) + } + + /** Exactly what `AddChapterViewModel.addChapter` proposes, at a given index. */ + private suspend fun proposeChapterAt(index: Int, versionId: String = artifactVersionId) { + val originalText = "Paragraph one.\n\nParagraph two." + FrostSigningManager.proposeSigningBatch( + database = db, + localChatRoom = room, + userPublicKey = proposer, + lead = ChapterEvent.build( + artifactVersionId = versionId, + name = "Chapter $index", + originalText = originalText, + index = index, + wordCount = Markdown.wordCount(originalText), + characterCount = Markdown.characterCount(originalText), + ), + dependents = ChunkEvent::splitOf, + ) + } + + private suspend fun nextIndex(versionId: String = artifactVersionId): Int = + ChapterIndexing.nextIndex( + mantraRepository = mantraRepository, + frostSigningRepository = frostSigningRepository, + chatRoomId = roomId, + artifactVersionId = versionId, + ) + + @Test + fun `an empty version starts at zero`() = runBlocking { + openDevice() + + assertEquals(0, nextIndex()) + } + + @Test + fun `signed chapters are counted`() = runBlocking { + openDevice() + insertChapter(0) + insertChapter(1) + + assertEquals(2, nextIndex()) + } + + @Test + fun `three chapters proposed back to back take three different indices`() = runBlocking { + openDevice() + + // The whole point. Nothing is signed, so the table is empty throughout + // and the old `.size` answered 0 three times. + val allocated = (1..3).map { + val index = nextIndex() + proposeChapterAt(index) + index + } + + assertEquals(listOf(0, 1, 2), allocated) + } + + @Test + fun `a proposal counts alongside the rows`() = runBlocking { + openDevice() + insertChapter(0) + proposeChapterAt(1) + + assertEquals(2, nextIndex()) + } + + @Test + fun `one past the highest, not a count, so a gap is stepped over`() = runBlocking { + openDevice() + insertChapter(0) + insertChapter(5) + + // A chapter that failed to reach a quorum spent its index: a re-proposed + // chapter is a different event. Counting rows would hand out 2 and + // collide with nothing, but it would also renumber a version whose + // chapters are 0 and 5 into one whose next chapter is 2. + assertEquals(6, nextIndex()) + } + + @Test + fun `another version's chapters are not counted`() = runBlocking { + openDevice() + insertChapter(0, otherVersionId) + insertChapter(1, otherVersionId) + proposeChapterAt(7, otherVersionId) + + // Rows are already scoped by the query; proposals are not -- every + // session in the room comes back together -- so the version tag is what + // separates them, and getting it wrong would push every version's + // numbering up by the whole room's. + assertEquals(0, nextIndex()) + assertEquals(8, nextIndex(otherVersionId)) + } + + @Test + fun `the chunks beside a chapter are not mistaken for chapters`() = runBlocking { + openDevice() + + // A chapter proposal is a batch: one chapter and a chunk per paragraph. + // A chunk carries an index too -- its position in the chapter -- and + // reading those as chapter indices would have the second chapter of a + // two-paragraph first chapter land at 2 instead of 1. + proposeChapterAt(0) + + assertEquals(1, nextIndex()) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterScaffoldingJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterScaffoldingJvmTest.kt new file mode 100644 index 00000000..e1dbea39 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterScaffoldingJvmTest.kt @@ -0,0 +1,329 @@ +package press.mantra.compose.managers + +import androidx.room3.Room +import com.vitorpamplona.quartz.nip01Core.core.Event +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.crypto.frost.Frost +import fr.acinq.bitcoin.crypto.frost.KeyMaterial +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.DkgParticipantMessage +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.MantraArtifact +import press.mantra.compose.database.model.MantraArtifactVersion +import press.mantra.compose.database.model.MantraDialect +import press.mantra.compose.database.model.MantraTranslationArtifactVersion +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.database.repository.DatabaseFrostSigningRepository +import press.mantra.compose.database.repository.DatabaseMantraRepository +import press.mantra.compose.extensions.toHex +import press.mantra.compose.nostr.dkg.DkgRitualEvents +import press.mantra.compose.nostr.nip30303.ChapterEvent +import press.mantra.compose.nostr.nip30303.ChunkEvent +import press.mantra.compose.nostr.nip30303.TranslationChapterEvent +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.text.Markdown + +/** + * How many sessions a group is asked for when chapters arrive together. + * + * The scaffolding has to be its own session -- a chapter's paragraph budget + * should not be spent on the group's dialects -- but it does not have to be one + * session *per chapter*, and that only becomes visible above one chapter. The + * first test here is the arithmetic the upload rests on: twelve chapters and + * three dialects is thirty-six join rows, which is one batch rather than + * twelve. + */ +class ChapterScaffoldingJvmTest { + private val participants = 3 + private val threshold = 2 + + private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1") + ), + nParticipants = participants, + threshold = threshold, + ) + + private val thresholdPublicKey = keyMaterial.thresholdPublicKey.value.toHex() + private val roomId = SharedKeyDerivation.marmotGroupId(thresholdPublicKey) + + private val ceremonyId = "ceremony".padEnd(64, '0') + private val members = listOf("a", "b", "c").map { it.repeat(64) } + private val hostKeys = listOf("2a", "2b", "2c").map { it.padEnd(66, '0') } + + private val proposer = members.first() + private val dialectId = "d".repeat(64) + private val artifactId = "a1".padEnd(64, '0') + private val artifactVersionId = "b".repeat(64) + + private lateinit var db: MantraDatabase + private lateinit var room: LocalChatRoom + private lateinit var mantraRepository: MantraRepository + private lateinit var frostSigningRepository: FrostSigningRepository + + @AfterTest + fun closeDatabase() { + if (::db.isInitialized) db.close() + } + + private suspend fun openDevice() { + db = getRoomDatabase(Room.inMemoryDatabaseBuilder()) + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + mantraRepository = DatabaseMantraRepository(db, scope) + frostSigningRepository = DatabaseFrostSigningRepository(db, scope) + + val nostrEventId = "e$proposer".take(64) + db.nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = proposer, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = "0".repeat(128), + ) + ) + db.profileDao().upsert(Profile(publicKey = proposer, nostrEventId = nostrEventId)) + + val chatRoom = ChatRoom( + id = roomId, + subject = "#Ekklesia", + description = SharedKeyDerivation.describe("Admins of the group.") + ) + db.chatRoomDao().upsert(chatRoom) + + db.dkgSessionDao().upsert( + DkgSession( + id = ceremonyId, + chatRoomId = roomId, + coordinatorPublicKey = proposer, + userPublicKey = proposer, + threshold = threshold, + participantCount = participants, + stage = DkgRitualStage.COMPLETE, + hostPublicKey = hostKeys[0], + round1Random = "1".repeat(64), + round2AuxRandom = "2".repeat(64), + thresholdPublicKey = thresholdPublicKey, + secretShare = keyMaterial.secretShares[0].value.toHex(), + publicShares = keyMaterial.publicShares.joinToString(",") { it.value.toHex() }, + ) + ) + members.forEachIndexed { index, member -> + db.dkgSessionDao().upsert( + DkgParticipantMessage( + sessionId = ceremonyId, + userPublicKey = proposer, + participantPublicKey = member, + kind = DkgRitualEvents.HOST_KEY, + payload = hostKeys[index], + ) + ) + } + + db.mantraDialectDao().upsert( + MantraDialect( + id = dialectId, + publicKey = roomId, + name = "Setswana", + country = "BW", + language = "tn", + signature = "", + chatRoomId = roomId, + ) + ) + db.mantraArtifactDao().upsert( + MantraArtifact( + id = artifactId, + publicKey = roomId, + name = "To Kill a Mockingbird", + url = "https://example.test/mockingbird", + visibility = "private", + dialectId = dialectId, + license = "cc", + chatRoomId = roomId, + signature = "", + ) + ) + db.mantraArtifactVersionDao().upsert( + MantraArtifactVersion( + id = artifactVersionId, + artifactId = artifactId, + publicKey = roomId, + versionLabel = "1.0", + chatRoomId = roomId, + signature = "", + ) + ) + + room = LocalChatRoom(chatRoom = chatRoom) + } + + private suspend fun addTranslations(count: Int) { + (0 until count).forEach { ordinal -> + db.mantraTranslationArtifactVersionDao().upsert( + MantraTranslationArtifactVersion( + id = "translation$ordinal".padEnd(64, '0'), + publicKey = roomId, + artifactVersionId = artifactVersionId, + dialectId = dialectId, + name = "Translation $ordinal", + visibility = "private", + license = "cc", + signature = "", + chatRoomId = roomId, + ) + ) + } + } + + /** Proposes [count] chapters the way the upload screen does, in index order. */ + private suspend fun proposeChapters(count: Int): List = + (0 until count).map { index -> + val originalText = "Paragraph one.\n\nParagraph two." + val session = FrostSigningManager.proposeSigningBatch( + database = db, + localChatRoom = room, + userPublicKey = proposer, + lead = ChapterEvent.build( + artifactVersionId = artifactVersionId, + name = "Chapter $index", + originalText = originalText, + index = index, + wordCount = Markdown.wordCount(originalText), + characterCount = Markdown.characterCount(originalText), + ), + dependents = ChunkEvent::splitOf, + ) + ChapterScaffolding.ProposedChapter(sessionId = session.id, index = index) + } + + private suspend fun scaffold(chapters: List) = + ChapterScaffolding.propose( + mantraRepository = mantraRepository, + frostSigningRepository = frostSigningRepository, + localChatRoom = room, + userPublicKey = proposer, + artifactVersionId = artifactVersionId, + chapters = chapters, + ) + + private suspend fun itemEvents(sessionId: String): List = + db.frostSigningSessionDao().getItems(sessionId).map { Event.fromJson(it.unsignedEventJson) } + + @Test + fun `twelve chapters and three dialects is one scaffolding session`() = runBlocking { + openDevice() + addTranslations(3) + + val sessions = scaffold(proposeChapters(12)) + + // The arithmetic the upload rests on. Thirty-six join rows fit in one + // batch; a session per chapter would have been twelve of them carrying + // three rows each. + assertEquals(1, sessions.size) + assertEquals(36, itemEvents(sessions.single()).size) + } + + @Test + fun `every row is a translation chapter naming a chapter the group will author`() = + runBlocking { + openDevice() + addTranslations(2) + + val chapters = proposeChapters(2) + val sessions = scaffold(chapters) + val rows = itemEvents(sessions.single()) + + assertTrue(rows.all { it.kind == TranslationChapterEvent.KIND }) + + // The ids come from the chapters' own sessions, which is the only + // place they exist before a quorum signs: a hash over the group's + // key at the room's path. + val chapterIds = chapters.map { proposed -> + itemEvents(proposed.sessionId).first().id + } + val named = rows.map { row -> + TranslationChapterEvent( + row.id, row.pubKey, row.createdAt, row.tags, row.content, row.sig + ).chapterId() + } + + assertEquals(chapterIds.toSet(), named.toSet()) + } + + @Test + fun `a chapter keeps its own position, not the list's`() = runBlocking { + openDevice() + addTranslations(1) + + // A catch-up proposes a subset, so counting the list would renumber the + // chapters it happens to be filling in. + val sessions = scaffold( + listOf( + ChapterScaffolding.ProposedChapter( + sessionId = proposeChapters(1).single().sessionId, + index = 7, + ) + ) + ) + + val row = itemEvents(sessions.single()).single() + assertEquals( + 7, + TranslationChapterEvent( + row.id, row.pubKey, row.createdAt, row.tags, row.content, row.sig + ).index(), + ) + } + + @Test + fun `the cross product is what gets chunked, not the translations`() = runBlocking { + openDevice() + addTranslations(3) + + // 25 chapters x 3 dialects is 75 rows, which is two batches. Chunking + // the translations instead would have put all 75 in one and thrown -- + // which is the bug this generalisation fixes, and it only appears above + // one chapter. + val sessions = scaffold(proposeChapters(25)) + + assertEquals(2, sessions.size) + assertEquals( + FrostSigningManager.MAX_BATCH_SIZE, + itemEvents(sessions.first()).size, + ) + assertEquals(75 - FrostSigningManager.MAX_BATCH_SIZE, itemEvents(sessions.last()).size) + } + + @Test + fun `a version with no translations is asked for nothing`() = runBlocking { + openDevice() + + assertEquals(emptyList(), scaffold(proposeChapters(3))) + } + + @Test + fun `no chapters is asked for nothing`() = runBlocking { + openDevice() + addTranslations(3) + + assertEquals(emptyList(), scaffold(emptyList())) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterUploadJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterUploadJvmTest.kt new file mode 100644 index 00000000..57634b5a --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterUploadJvmTest.kt @@ -0,0 +1,432 @@ +package press.mantra.compose.managers + +import androidx.room3.Room +import com.vitorpamplona.quartz.nip01Core.core.Event +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.crypto.frost.Frost +import fr.acinq.bitcoin.crypto.frost.KeyMaterial +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.DkgParticipantMessage +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.MantraArtifact +import press.mantra.compose.database.model.MantraArtifactVersion +import press.mantra.compose.database.model.MantraDialect +import press.mantra.compose.database.model.MantraTranslationArtifactVersion +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.database.repository.DatabaseChatRepository +import press.mantra.compose.database.repository.DatabaseFrostSigningRepository +import press.mantra.compose.database.repository.DatabaseMantraRepository +import press.mantra.compose.extensions.toHex +import press.mantra.compose.files.PickedFile +import press.mantra.compose.nostr.dkg.DkgRitualEvents +import press.mantra.compose.nostr.nip30303.ChapterEvent +import press.mantra.compose.nostr.nip30303.ChunkEvent +import press.mantra.compose.nostr.nip30303.TranslationChapterEvent +import press.mantra.compose.text.Markdown +import press.mantra.compose.ui.view.model.UploadChaptersViewModel +import press.mantra.compose.ui.view.state.UploadChaptersUIState +import press.mantra.compose.ui.view.state.UploadProblem + +/** + * An upload, end to end, over a real database. + * + * `UploadReviewTest` covers what a file is offered as, which is pure. This is + * the orchestration: how many sessions a group is asked for, what each one + * signs, what index each chapter takes, and -- the part with no second chance -- + * what is left behind when a pass only half works. + * + * Nothing can retract a proposal, so partial success is the expected outcome + * rather than an error, and the only thing that makes it recoverable is the + * screen keeping an honest list of what went and what did not. + */ +class ChapterUploadJvmTest { + private val participants = 3 + private val threshold = 2 + + private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1") + ), + nParticipants = participants, + threshold = threshold, + ) + + private val thresholdPublicKey = keyMaterial.thresholdPublicKey.value.toHex() + private val roomId = SharedKeyDerivation.marmotGroupId(thresholdPublicKey) + + private val ceremonyId = "ceremony".padEnd(64, '0') + private val members = listOf("a", "b", "c").map { it.repeat(64) } + private val hostKeys = listOf("2a", "2b", "2c").map { it.padEnd(66, '0') } + + private val proposer = members.first() + private val dialectId = "d".repeat(64) + private val artifactId = "a1".padEnd(64, '0') + private val artifactVersionId = "b".repeat(64) + + private lateinit var db: MantraDatabase + private lateinit var viewModel: UploadChaptersViewModel + + @BeforeTest + fun setUpMain() { + // viewModelScope dispatches on Dispatchers.Main, which a jvm test has + // none of. Unconfined rather than a test scheduler: the view model does + // its work on Dispatchers.IO and uses Main only to hand a result back, + // so there is nothing here to advance -- the test waits on the callback. + Dispatchers.setMain(Dispatchers.Unconfined) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + if (::db.isInitialized) db.close() + } + + /** Opens a device, with the group's shared key present only if [withKey]. */ + private suspend fun openDevice(withKey: Boolean = true, translations: Int = 0) { + db = getRoomDatabase(Room.inMemoryDatabaseBuilder()) + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + val nostrEventId = "e$proposer".take(64) + db.nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = proposer, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = "0".repeat(128), + ) + ) + db.profileDao().upsert(Profile(publicKey = proposer, nostrEventId = nostrEventId)) + + db.chatRoomDao().upsert( + ChatRoom( + id = roomId, + subject = "#Ekklesia", + description = SharedKeyDerivation.describe("Admins of the group.") + ) + ) + + if (withKey) giveTheGroupItsKey() + + db.mantraDialectDao().upsert( + MantraDialect( + id = dialectId, + publicKey = roomId, + name = "Setswana", + country = "BW", + language = "tn", + signature = "", + chatRoomId = roomId, + ) + ) + db.mantraArtifactDao().upsert( + MantraArtifact( + id = artifactId, + publicKey = roomId, + name = "To Kill a Mockingbird", + url = "https://example.test/mockingbird", + visibility = "private", + dialectId = dialectId, + license = "cc", + chatRoomId = roomId, + signature = "", + ) + ) + db.mantraArtifactVersionDao().upsert( + MantraArtifactVersion( + id = artifactVersionId, + artifactId = artifactId, + publicKey = roomId, + versionLabel = "1.0", + chatRoomId = roomId, + signature = "", + ) + ) + (0 until translations).forEach { ordinal -> + db.mantraTranslationArtifactVersionDao().upsert( + MantraTranslationArtifactVersion( + id = "translation$ordinal".padEnd(64, '0'), + publicKey = roomId, + artifactVersionId = artifactVersionId, + dialectId = dialectId, + name = "Translation $ordinal", + visibility = "private", + license = "cc", + signature = "", + chatRoomId = roomId, + ) + ) + } + + viewModel = UploadChaptersViewModel( + artifactId = artifactId, + chatRoomId = roomId, + activeUserPublicKey = proposer, + relayHint = null, + initialUIState = UploadChaptersUIState.Loading, + mantraRepository = DatabaseMantraRepository(db, scope), + chatRepository = DatabaseChatRepository(db, scope), + frostSigningRepository = DatabaseFrostSigningRepository(db, scope), + ) + viewModel.initiate() + awaitLoaded() + } + + private suspend fun giveTheGroupItsKey() { + db.dkgSessionDao().upsert( + DkgSession( + id = ceremonyId, + chatRoomId = roomId, + coordinatorPublicKey = proposer, + userPublicKey = proposer, + threshold = threshold, + participantCount = participants, + stage = DkgRitualStage.COMPLETE, + hostPublicKey = hostKeys[0], + round1Random = "1".repeat(64), + round2AuxRandom = "2".repeat(64), + thresholdPublicKey = thresholdPublicKey, + secretShare = keyMaterial.secretShares[0].value.toHex(), + publicShares = keyMaterial.publicShares.joinToString(",") { it.value.toHex() }, + ) + ) + members.forEachIndexed { index, member -> + db.dkgSessionDao().upsert( + DkgParticipantMessage( + sessionId = ceremonyId, + userPublicKey = proposer, + participantPublicKey = member, + kind = DkgRitualEvents.HOST_KEY, + payload = hostKeys[index], + ) + ) + } + } + + private suspend fun awaitLoaded(): UploadChaptersUIState.Loaded = + withTimeout(10_000) { + var state = viewModel.uiState.value + while (state !is UploadChaptersUIState.Loaded) { + kotlinx.coroutines.delay(10) + state = viewModel.uiState.value + } + state + } + + private val loaded: UploadChaptersUIState.Loaded + get() = viewModel.uiState.value as UploadChaptersUIState.Loaded + + private fun file(name: String, text: String) = + PickedFile(name = name, bytes = text.encodeToByteArray()) + + /** A chapter of [paragraphs] paragraphs, as a file would hold it. */ + private fun chapterFile(ordinal: Int, paragraphs: Int = 2) = file( + name = "chapter-$ordinal.md", + text = (1..paragraphs).joinToString("\n\n") { "Paragraph $it of chapter $ordinal." }, + ) + + /** Proposes, and waits for the pass to finish. */ + private suspend fun propose(): Pair { + val done = CompletableDeferred>() + viewModel.propose( + localChatRoom = loaded.localChatRoom, + artifactVersion = loaded.artifactVersion!!, + ) { proposed, failed -> done.complete(proposed to failed) } + return withTimeout(30_000) { done.await() } + } + + private suspend fun itemEvents(sessionId: String): List = + db.frostSigningSessionDao().getItems(sessionId).map { Event.fromJson(it.unsignedEventJson) } + + private suspend fun sessionsInRoom(): List = + db.frostSigningSessionDao().getSessionsForChatRoom(roomId).map { it.id } + + @Test + fun `five files become five sessions, one chapter each, numbered in order`() = runBlocking { + openDevice() + + viewModel.addFiles((1..5).map { chapterFile(it, paragraphs = 3) }) + assertEquals(5, loaded.chapters.size) + + val (proposed, failed) = propose() + + assertEquals(5 to 0, proposed to failed) + + val chapterSessions = loaded.chapters.map { it.sessionId!! } + assertEquals(5, chapterSessions.toSet().size) + + // Each session is one chapter leading its own chunks -- never two + // chapters sharing a batch, which is the whole shape of this feature. + val chapters = chapterSessions.map { session -> + val events = itemEvents(session) + assertEquals(ChapterEvent.KIND, events.first().kind) + assertTrue(events.drop(1).all { it.kind == ChunkEvent.KIND }) + assertEquals(4, events.size) // the chapter and its three chunks + ChapterEvent( + events.first().id, + events.first().pubKey, + events.first().createdAt, + events.first().tags, + events.first().content, + events.first().sig, + ) + } + + // Contiguous from zero, which the old `.size` numbering could not do: + // nothing is signed, so the table is empty for all five. + assertEquals(listOf(0, 1, 2, 3, 4), chapters.map { it.index() }) + assertEquals( + (1..5).map { "chapter-$it" }, + chapters.map { it.name() }, + ) + } + + @Test + fun `each chapter's chunks are the blocks of its own file`() = runBlocking { + openDevice() + + val text = "# A heading\n\nFirst paragraph.\n\n- one\n- two\n" + viewModel.addFiles(listOf(file("ch.md", text))) + propose() + + val events = itemEvents(loaded.chapters.single().sessionId!!) + val chunks = events.drop(1).map { it.content } + + assertEquals(Markdown.splitChunks(text), chunks) + // And the block markers are off, which is what Phase 8 changed. + assertEquals(listOf("A heading", "First paragraph.", "one", "two"), chunks) + } + + @Test + fun `the whole upload asks for one scaffolding session, not one per chapter`() = runBlocking { + openDevice(translations = 3) + + viewModel.addFiles((1..4).map { chapterFile(it) }) + propose() + + val chapterSessions = loaded.chapters.map { it.sessionId!! }.toSet() + val scaffolding = sessionsInRoom().filterNot { it in chapterSessions } + + assertEquals(1, scaffolding.size) + + val rows = itemEvents(scaffolding.single()) + assertEquals(12, rows.size) // four chapters x three dialects + assertTrue(rows.all { it.kind == TranslationChapterEvent.KIND }) + } + + @Test + fun `a pass that fails leaves every row listed with its reason and retryable`() = runBlocking { + // No shared key, so every proposal throws inside the view model. This is + // the reachable way to half-fail a pass: the caps are checked in the + // form, so no input gets past the review and then fails the manager. + openDevice(withKey = false) + + viewModel.addFiles((1..3).map { chapterFile(it) }) + + val (proposed, failed) = propose() + assertEquals(0 to 3, proposed to failed) + + // Nothing proposed, nothing lost, and each row says why. + assertEquals(emptyList(), sessionsInRoom()) + assertTrue(loaded.chapters.all { it.sessionId == null }) + assertTrue(loaded.chapters.all { it.problem == UploadProblem.ProposalFailed }) + + // ProposalFailed does not block, because a retry is a new proposal -- + // which is the only correct way to retry a FROST session. + assertEquals(3, loaded.proposable.size) + } + + @Test + fun `retrying after a failure proposes the rest and numbers them from zero`() = runBlocking { + openDevice(withKey = false) + + viewModel.addFiles((1..3).map { chapterFile(it) }) + propose() + + giveTheGroupItsKey() + + val (proposed, failed) = propose() + + assertEquals(3 to 0, proposed to failed) + assertTrue(loaded.chapters.all { it.problem == null }) + + val indices = loaded.chapters.map { row -> + val lead = itemEvents(row.sessionId!!).first() + ChapterEvent( + lead.id, lead.pubKey, lead.createdAt, lead.tags, lead.content, lead.sig + ).index() + } + + // The failed pass spent no indices, because it created nothing. + assertEquals(listOf(0, 1, 2), indices) + } + + @Test + fun `a row already proposed is not proposed again`() = runBlocking { + openDevice() + + viewModel.addFiles(listOf(chapterFile(1))) + propose() + val first = loaded.chapters.single().sessionId + + // Nothing left to do, so a second tap proposes nothing rather than + // asking the group to sign the same chapter twice. + val (proposed, failed) = propose() + + assertEquals(0 to 0, proposed to failed) + assertEquals(first, loaded.chapters.single().sessionId) + assertEquals(1, sessionsInRoom().size) + } + + @Test + fun `a file that is not text is never proposed and does not stop the others`() = runBlocking { + openDevice() + + viewModel.addFiles( + listOf( + chapterFile(1), + PickedFile("cover.png", byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x00)), + chapterFile(2), + ) + ) + + val (proposed, failed) = propose() + + // Two proposed, and the third is not a failure -- it was never offered. + assertEquals(2 to 0, proposed to failed) + assertEquals(UploadProblem.NotText, loaded.chapters[1].problem) + assertNull(loaded.chapters[1].sessionId) + assertEquals(2, loaded.chapters.count { it.sessionId != null }) + } + + @Test + fun `the soft cap is on the upload, not on the artifact`() = runBlocking { + openDevice() + + viewModel.addFiles((1..UploadChaptersViewModel.MAX_FILES_PER_UPLOAD + 5).map { chapterFile(it) }) + + // The extra files are dropped at the door rather than listed and + // refused: a limit on how much of the room's attention one tap spends. + assertEquals(UploadChaptersViewModel.MAX_FILES_PER_UPLOAD, loaded.chapters.size) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleApplyJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleApplyJvmTest.kt index 8849518b..01742509 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleApplyJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleApplyJvmTest.kt @@ -28,6 +28,7 @@ import press.mantra.compose.database.builder.getRoomDatabase import press.mantra.compose.database.model.ChatMessage import press.mantra.compose.database.model.ChatRoomMembership import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.MarmotInnerEvent import press.mantra.compose.database.model.MarmotKeyPackage import press.mantra.compose.database.model.NostrEvent @@ -45,6 +46,9 @@ import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent import press.mantra.compose.nostr.nip30303.ChapterEvent import press.mantra.compose.nostr.nip30303.ChunkEvent import press.mantra.compose.nostr.nip30303.DialectEvent +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationChapterEvent import press.mantra.compose.nostr.nip30303.TranslationChunkEvent @@ -312,6 +316,43 @@ class ChronicleApplyJvmTest { ) ) ) + + // What the work is for. Unlike everything above it, a project has to be + // *recorded* as well as applied to reach a chronicle: there is no + // `toProjectEvent`, so the deprecated rebuild cannot produce one, and + // the record is its only source. That is deliberate -- the rebuild + // serves rooms older than schema v13 and projects arrive at v21 -- and + // it is why `record` appears here and nowhere else in this seed. + val project = signed( + ProjectEvent.build( + name = "In Detention into isiZulu", + description = "The first chapter, with its back-translation.", + creatorPublicKey = oldMember, + ownerPublicKeys = setOf(chatRoomId), + createdAt = 1_700_000_070L, + ) + ).also { apply(sender, it) } + + val sourceMembership = signed( + ProjectArtifactVersionEvent.membershipsOf(project, listOf(version.id)).single() + ).also { apply(sender, it) } + + val translationMembership = signed( + ProjectTranslationArtifactVersionEvent + .membershipsOf(project, listOf(translationVersion.id)) + .single() + ).also { apply(sender, it) } + + sender.groupSignedEventDao().recordAll( + listOf(project, sourceMembership, translationMembership).map { + GroupSignedEvent.fromEvent( + event = it, + chatRoomId = chatRoomId, + derivationPath = SharedKeyDerivation.formatPath(), + frostSigningSessionId = "session-1", + ) + } + ) } /** @@ -378,6 +419,23 @@ class ChronicleApplyJvmTest { db.mantraTranslationChunkDao().getTranslationChunksByTranslationChapterId(it.id) } + // The projects, and what each one holds. + // + // Counted here rather than in a test of their own, because the test that + // matters most for them is `pages arriving out of order still converge` + // -- and that test compares exactly this map. A membership is a foreign + // key into both a project and a version, so delivering backwards is the + // scenario where one gets refused and has to be retried, and this map not + // looking at the project tables is why that test once passed against an + // apply arm that swallowed the refusal and lost the row. + val projects = db.mantraProjectDao().getProjectsByChatRoomId(chatRoomId) + val projectSources = projects.flatMap { + db.mantraProjectArtifactVersionDao().getByProjectId(it.id) + } + val projectTranslations = projects.flatMap { + db.mantraProjectTranslationArtifactVersionDao().getByProjectId(it.id) + } + return mapOf( "dialects" to dialects.size, "artifacts" to artifacts.size, @@ -387,6 +445,9 @@ class ChronicleApplyJvmTest { "translationVersions" to translationVersions.size, "translationChapters" to translationChapters.size, "translationChunks" to translationChunks.size, + "projects" to projects.size, + "projectSources" to projectSources.size, + "projectTranslations" to projectTranslations.size, ) } @@ -437,7 +498,8 @@ class ChronicleApplyJvmTest { mapOf( "dialects" to 0, "artifacts" to 0, "versions" to 0, "chapters" to 0, "chunks" to 0, "translationVersions" to 0, "translationChapters" to 0, - "translationChunks" to 0, + "translationChunks" to 0, "projects" to 0, "projectSources" to 0, + "projectTranslations" to 0, ), rowCounts(receiver), "the new member starts with nothing, which is the whole problem" @@ -695,7 +757,8 @@ class ChronicleApplyJvmTest { mapOf( "dialects" to 0, "artifacts" to 0, "versions" to 0, "chapters" to 0, "chunks" to 0, "translationVersions" to 0, "translationChapters" to 0, - "translationChunks" to 0, + "translationChunks" to 0, "projects" to 0, "projectSources" to 0, + "projectTranslations" to 0, ), rowCounts(receiver), "a bystander already holds the work; re-applying would only rewrite its provenance" diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleAssemblyJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleAssemblyJvmTest.kt index da433dc3..dd226f17 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleAssemblyJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleAssemblyJvmTest.kt @@ -37,6 +37,9 @@ import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent import press.mantra.compose.nostr.nip30303.ChapterEvent import press.mantra.compose.nostr.nip30303.ChunkEvent import press.mantra.compose.nostr.nip30303.DialectEvent +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationChapterEvent import press.mantra.compose.nostr.nip30303.TranslationChunkEvent @@ -295,8 +298,40 @@ class ChronicleAssemblyJvmTest { createdAt = 1_700_000_060L ) ) + + // What the work is for. Seeded here rather than in the tests that are + // about projects, because `a chronicle holds every kind the group + // signed` compares what this room produces against CHRONICLABLE_KINDS + // itself -- so a kind added to that set and not to this seed fails three + // tests at once, which is the point of writing it that way. + // + // Proposed the way the form will propose it: the project leading, its + // memberships taking the id and the timestamp it comes back with. + seededProject = apply( + ProjectEvent.build( + name = "In Detention into isiZulu", + description = "The first chapter, with its back-translation.", + creatorPublicKey = newMember, + ownerPublicKeys = setOf(chatRoomId), + createdAt = 1_700_000_070L, + ) + ) + + apply( + ProjectArtifactVersionEvent + .membershipsOf(seededProject!!, listOf(version.id)) + .single() + ) + apply( + ProjectTranslationArtifactVersionEvent + .membershipsOf(seededProject!!, listOf(translationVersion.id)) + .single() + ) } + /** The project [seedSignedWork] signed, for the tests that are about it. */ + private var seededProject: Event? = null + private suspend fun chronicledPayloads(): List = ChronicleManager.assemble(db, chatRoomId, newMember) .flatMap { assertNotNull(ChronicleEvent.decodePage(it.content)) } @@ -319,6 +354,12 @@ class ChronicleAssemblyJvmTest { @Test fun `a chronicle holds every kind the group signed, and each of them once`() = runBlocking { seedSignedWork() + // On file, which is what a room signing work today looks like. Without + // this the chronicle is assembled entirely by the deprecated rebuild, + // which reaches eight of the eleven kinds -- it has no `toProjectEvent` + // and deliberately never will. That path is still covered, by + // `work held both ways travels exactly once` and the two below it. + recordEverythingApplied() val byKind = chronicledPayloads().groupBy { it.kind } @@ -330,10 +371,33 @@ class ChronicleAssemblyJvmTest { assertEquals(1, byKind[TranslationArtifactVersionEvent.KIND]?.size) assertEquals(1, byKind[TranslationChapterEvent.KIND]?.size) assertEquals(1, byKind[TranslationChunkEvent.KIND]?.size) + assertEquals(1, byKind[ProjectEvent.KIND]?.size) + assertEquals(1, byKind[ProjectArtifactVersionEvent.KIND]?.size) + assertEquals(1, byKind[ProjectTranslationArtifactVersionEvent.KIND]?.size) assertEquals(ChronicleEvent.CHRONICLABLE_KINDS, byKind.keys) } + @Test + fun `the rebuild alone reaches everything but the projects`() = runBlocking { + // The same room with nothing on file, which is the deprecated path in + // isolation. Pinned rather than left implicit: if somebody adds a + // `toProjectEvent` this fails, and the comment on `MantraProject` says + // why they should not. + seedSignedWork() + + val kinds = chronicledPayloads().map { it.kind }.toSet() + + assertEquals( + ChronicleEvent.CHRONICLABLE_KINDS - setOf( + ProjectEvent.KIND, + ProjectArtifactVersionEvent.KIND, + ProjectTranslationArtifactVersionEvent.KIND, + ), + kinds + ) + } + @Test fun `a chronicle is in dependency order across its whole length`() = runBlocking { seedSignedWork() @@ -449,7 +513,24 @@ class ChronicleAssemblyJvmTest { // Half the batch on file, the other half only as rows -- which is what // a room that was upgraded partway through its life looks like. - record(*applied.take(applied.size / 2).toTypedArray()) + // + // The projects are on file whichever half they fall in, because the + // rebuild cannot reach them: there is no `toProjectEvent`, deliberately. + // That is not a gap in this scenario, it is the scenario being + // impossible for them -- the rebuild serves rooms whose work predates + // `GroupSignedEvent` at v13, and projects arrive at v21. See + // `a project is only ever chronicled off the record` for the edge that + // does follow from it. + val projectKinds = setOf( + ProjectEvent.KIND, + ProjectArtifactVersionEvent.KIND, + ProjectTranslationArtifactVersionEvent.KIND, + ) + val rebuildable = applied.filterNot { it.kind in projectKinds } + record( + *(rebuildable.take(rebuildable.size / 2) + + applied.filter { it.kind in projectKinds }).toTypedArray() + ) val payloads = chronicledPayloads() @@ -747,4 +828,98 @@ class ChronicleAssemblyJvmTest { chronicledTranslations().map { it.id }.toSet() ) } + + // ---- Projects --------------------------------------------------------- + + @Test + fun `a room's projects travel in its chronicle`() = runBlocking { + seedSignedWork() + recordEverythingApplied() + val project = assertNotNull(seededProject) + + val payloads = chronicledPayloads() + + assertTrue( + payloads.any { it.kind == ProjectEvent.KIND && it.id == project.id }, + "a late member should be told what the room's work is for" + ) + assertEquals( + 1, + payloads.count { it.kind == ProjectArtifactVersionEvent.KIND }, + "the project's source version should travel with it" + ) + assertEquals( + 1, + payloads.count { it.kind == ProjectTranslationArtifactVersionEvent.KIND }, + "the project's translation should travel with it" + ) + } + + @Test + fun `a chronicled project arrives in an order that applies`() = runBlocking { + seedSignedWork() + recordEverythingApplied() + + val kinds = chronicledPayloads().map { it.kind } + + // The assertion that matters, and the reason projects went on the end of + // APPLY_ORDER rather than anywhere tidier: both memberships are foreign + // keys into the project and into a version, so either arriving first + // would be refused -- silently, on the one device that has nothing else + // to fall back on. + val project = kinds.indexOf(ProjectEvent.KIND) + assertTrue(project >= 0, "the project is in the chronicle") + + assertTrue( + kinds.indexOf(ProjectArtifactVersionEvent.KIND) > project && + kinds.indexOf(ProjectTranslationArtifactVersionEvent.KIND) > project, + "memberships must come after their project" + ) + assertTrue( + kinds.indexOf(ArtifactVersionEvent.KIND) < + kinds.indexOf(ProjectArtifactVersionEvent.KIND), + "a membership must come after the version it names" + ) + assertTrue( + kinds.indexOf(TranslationArtifactVersionEvent.KIND) < + kinds.indexOf(ProjectTranslationArtifactVersionEvent.KIND), + "a membership must come after the translation it names" + ) + } + + /** + * The one thing "no `toProjectEvent`" costs, stated rather than discovered. + * + * Every other kind in a chronicle has two sources: the event as the group + * signed it, and a rebuild from the row when the signed event predates the + * table that keeps them. Projects have only the first, because that rebuild + * exists for rooms older than schema v13 and projects arrive at v21 -- there + * is no project row anywhere whose event was never recorded. + * + * Except by accident: `recordSignedEvents` catches its own failure and lets + * the session complete, correctly, since the signature is valid whether or + * not this device could write it down. A project applied through that gap is + * a row this device can show and cannot chronicle, and it will not come + * back -- which is worth knowing is possible rather than worth preventing, + * since the alternative is failing a ceremony that succeeded. + */ + @Test + fun `a project is only ever chronicled off the record`() = runBlocking { + seedSignedWork() + + // Everything on file except the project, which is the shape that gap + // leaves behind. + record(*applied.filterNot { it.kind == ProjectEvent.KIND }.toTypedArray()) + + val project = assertNotNull(seededProject) + + assertNotNull( + db.mantraProjectDao().getProjectById(project.id), + "the row is here either way" + ) + assertTrue( + chronicledPayloads().none { it.id == project.id }, + "and without its signed event there is nothing to chronicle" + ) + } } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt index 42652d7f..41e265b7 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt @@ -1213,6 +1213,169 @@ class SignedGroupKeyStateTest { assertEquals(before.map { it.nonceRandom }, other.items(session.id).map { it.nonceRandom }) } + /** + * The byte cap, on both sides, for the same reason the count cap has both. + * + * `MAX_BATCH_SIZE` bounds how many events a batch holds; `MAX_PROPOSAL_BYTES` + * bounds how large they are, and a batch can pass the first and fail the + * second -- sixty-four dialects are a few kilobytes and one chapter is + * eighty. Before the cap existed the failure was silent: the proposal went + * out, the group event was over size, and the session stalled with nothing + * to blame. + */ + @Test + fun `a proposal over the byte cap is refused when proposed`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + + // One event, so the count cap cannot be what refuses this. + assertFailsWith { + FrostSigningManager.proposeSigning( + database = creator.db, + localChatRoom = creator.room, + userPublicKey = creator.publicKey, + kind = 1, + tags = emptyArray(), + content = "w".repeat(FrostSigningManager.MAX_PROPOSAL_BYTES + 1) + ) + } + + assertEquals( + emptyList(), + creator.db.frostSigningSessionDao().getSessionsForChatRoom(adminRoomId), + "a refused proposal must leave no session behind" + ) + } + + /** + * And dropped on arrival, which is the half that matters. + * + * The proposal is the one place a remote party decides how much work + * everybody else does. A proposer who could hand every member an arbitrarily + * large batch would have them do unbounded native work and publish an + * unbounded group event from a single message. + * + * **The payload has to be valid in every other way**, or this passes for the + * wrong reason. `acceptProposal` rebuilds each event from its own fields + * under the room's path and drops the whole proposal if any id does not + * match what it claims -- so a hand-built event with an invented id is + * refused whatever its size, and a test using one would stay green with the + * cap deleted. That is how this was first written and what + * [authoredProposal] exists to fix: the events come from real proposals, so + * their ids are the ones the manager computes. + */ + @Test + fun `a proposal over the byte cap is dropped on arrival`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + val other = device(members[1], signerIndex = 1) + val sessionId = "f1".repeat(32) + + // Two events, each legitimately proposed at a third of the cap, so + // together they are over it and apart they are unremarkable. + val third = FrostSigningManager.MAX_PROPOSAL_BYTES / 3 + val oversize = authoredProposal(creator, "a".repeat(third), "b".repeat(third), "c".repeat(third)) + assertTrue( + FrostSigningEvents.encodeProposal(oversize).encodeToByteArray().size > + FrostSigningManager.MAX_PROPOSAL_BYTES, + "the fixture must actually exceed the cap" + ) + + deliverProposal(other, sessionId, oversize) + + assertNull(other.session(sessionId), "an over-size proposal must not open a session") + } + + /** + * The count cap on arrival too, which this file claimed and did not check. + * + * The propose-side test above says in its own comment that the cap "is + * checked on arrival and not only when proposing" -- which was true of the + * code and untrue of the tests. + */ + @Test + fun `a batch over the count cap is dropped on arrival`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + val other = device(members[1], signerIndex = 1) + val sessionId = "f2".repeat(32) + + // Small events, so the byte cap cannot be what refuses this. + val tooMany = authoredProposal( + creator, + *(0..FrostSigningManager.MAX_BATCH_SIZE).map { "event $it" }.toTypedArray() + ) + assertEquals(FrostSigningManager.MAX_BATCH_SIZE + 1, tooMany.size) + + deliverProposal(other, sessionId, tooMany) + + assertNull( + other.session(sessionId), + "a proposal of more events than the cap must not open a session" + ) + } + + /** + * The control for the two tests above. + * + * Both assert that no session is opened, and `acceptProposal` has four other + * reasons to open none -- an unknown key, a ceremony this device was not in, + * an unparseable payload, an id that does not match its fields. Without + * this, either could pass for a reason that has nothing to do with the cap + * it is named after, and the guard could be deleted with both still green. + * + * Which is not hypothetical: written with hand-built ids, both passed and + * this failed. + */ + @Test + fun `a well-formed proposal of the same shape does open a session`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + val other = device(members[1], signerIndex = 1) + val sessionId = "f3".repeat(32) + + deliverProposal(other, sessionId, authoredProposal(creator, "a dialect")) + + assertNotNull( + other.session(sessionId), + "a proposal that breaks no cap must open a session, or the cap tests prove nothing" + ) + } + + /** + * The unsigned events a real proposal produced, ids and all. + * + * Taken from the manager rather than built here, because the id is a hash + * over the group's key at the room's derivation path and the receiving + * device recomputes it. Anything else is refused before a cap is consulted. + */ + private suspend fun authoredProposal(device: Device, vararg contents: String): List = + contents.flatMap { content -> + val session = FrostSigningManager.proposeSigning( + database = device.db, + localChatRoom = device.room, + userPublicKey = device.publicKey, + kind = 1, + tags = emptyArray(), + content = content + ) + device.items(session.id).map { Event.fromJson(it.unsignedEventJson) } + } + + /** Hands [device] a proposal under [sessionId], as a dishonest coordinator would. */ + private suspend fun deliverProposal(device: Device, sessionId: String, events: List) { + FrostSigningManager.processSigningPayload( + database = device.db, + localChatRoom = device.room, + innerEvent = Event( + id = "4".repeat(64), + pubKey = members[0], + createdAt = 1, + kind = FrostSigningEvents.PROPOSAL, + tags = FrostSigningEvents.assembleTags(sessionId, dkgSessionId = ceremonyId), + content = FrostSigningEvents.encodeProposal(events), + sig = "" + ), + userPublicKey = device.publicKey + ) + } + /** Three dialects, distinct enough that an order bug shows up as a wrong name. */ private fun dialects() = listOf( Triple("Sepedi", "ZA", "nso"), diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt index ee8656a9..7deada53 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt @@ -778,6 +778,54 @@ class SubgroupManagerJvmTest { Unit } + @Test + fun `a parent this device is in is named by its room`() = runBlocking { + parentWith(members = listOf(alice, bob)) + childWithKeyState(parents = listOf(parentRoomId)) + + val parent = SubgroupManager.parentGroupsOf(db, childRoomId).single() + + assertEquals(parentRoomId, parent.chatRoomId) + // The room, and nothing else, is where a parent's name comes from: a + // certificate names the child and never the certifier. + assertEquals("Ekklesia", parent.room?.chatRoom?.subject) + } + + @Test + fun `a parent this device is not in is still listed, with nothing to name it`() = runBlocking { + // No parent room: the ordinary position of a subgroup member who is not + // also in the group above. The lineage is known -- it was checked + // against that group's own signature -- so the parent stays in the list + // and only its name is missing. + childWithKeyState(parents = listOf(parentRoomId)) + + val parent = SubgroupManager.parentGroupsOf(db, childRoomId).single() + + assertEquals(parentRoomId, parent.chatRoomId) + assertNull(parent.room) + } + + @Test + fun `two parents keep the lineage's own order, named or not`() = runBlocking { + parentWith(members = listOf(alice, bob)) + childWithKeyState(parents = listOf(parentRoomId, siblingRoomId)) + + val parents = SubgroupManager.parentGroupsOf(db, childRoomId) + + // The same set in the same order -- canonical, which is sorted by id and + // so says nothing about which parent came first. Asserted against + // `parentsOf` rather than against a written-out order, since the ids are + // derived from keys and nobody should have to know how two of them sort. + assertEquals( + SubgroupManager.parentsOf(db, childRoomId), + parents.map { it.chatRoomId } + ) + assertEquals( + mapOf(parentRoomId to "Ekklesia", siblingRoomId to null), + parents.associate { it.chatRoomId to it.room?.chatRoom?.subject } + ) + } + private suspend fun refuseAdoptionOf(adopting: LocalChatRoom): String? = SubgroupManager.refuseAdoption( database = db, diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/repository/NoOpRepositoryTotalityJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/repository/NoOpRepositoryTotalityJvmTest.kt new file mode 100644 index 00000000..d719af03 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/repository/NoOpRepositoryTotalityJvmTest.kt @@ -0,0 +1,138 @@ +package press.mantra.compose.repository + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Every member of every `NO_OP_*` repository answers its call. None is left stubbed out. + * + * Every one in `commonMain/.../repository/`, found by reading the directory rather than by + * naming files -- see [declaration]. + * + * The rule is worth a test of its own because breaking it does not fail where it is broken. + * A `TODO()` in one of these objects throws `NotImplementedError`, and the callers are view + * models that reach their repository from `viewModelScope.launch(Dispatchers.IO)` -- a + * coroutine nobody awaits. The error escapes to the platform handler, where + * kotlinx-coroutines-test's `ExceptionCollector` stashes it (`unprocessedExceptions`, because + * a `runBlocking` test has no `TestScope` registered to take it), and the *next* `runTest` to + * start drains the stash and rethrows it as `UncaughtExceptionsBeforeTest` -- under that + * test's name, for a call it never made. The real stack trace survives only as a suppressed + * cause several frames down. + * + * That cost a debugging session once: `queueNegentropySynchronizeRequest` was a `TODO()`, + * `CeremonyQueueRoutingJvmTest` reached it through `ChatMessageListViewModel.initiate()`, and + * `NavigationRoutingTest > an account already on the device does not stay on the loading + * screen` failed four times a run while the test that caused it passed. Seventeen test files + * build on `NO_OP_NOSTR_REPOSITORY` alone, so one stub is never one test's problem. + * + * So this is checked at the only altitude where it can be checked cheaply and totally: the + * source. Calling all sixty-odd members for real would mean a fixture for every parameter + * type, and a test that fails because a fixture was wrong tells you less than this one does. + * A reflective sweep has the same problem -- it cannot tell a body that threw from an argument + * it could not build. + * + * It fails under its own name, which is the whole point. + */ +class NoOpRepositoryTotalityJvmTest { + + /** + * Every file that declares one, found rather than listed. + * + * A list would be wrong the first time a ninth repository is added -- and was: the + * first version of this test named six files when the tree held eight, having missed + * `MantraRepository` and `CachingImportRepository` (both total, which is luck rather + * than cover). Discovery is the point of the test, so it discovers. + */ + private val declaration = Regex("""\bval\s+NO_OP_[A-Z0-9_]+""") + + /** + * A floor, in the spirit of `docs/scripts/m3-audit.sh`: the way this test breaks + * silently is by finding nothing to check -- a moved package, a renamed convention -- + * and then passing. Eight is the count on 2026-10-02. Raise it when one is added. + */ + private val knownSingletons = 8 + + @Test + fun `no no-op repository leaves a member unimplemented`() { + val directory = repositoryDirectory() + + val sources = (directory.listFiles() ?: emptyArray()) + .filter { it.isFile && it.name.endsWith(".kt") } + .map { it.name to it.readText() } + .filter { (_, source) -> declaration.containsMatchIn(source) } + .sortedBy { (name, _) -> name } + + assertTrue( + sources.size >= knownSingletons, + "Found ${sources.size} no-op repositories in ${directory.path}, expected at " + + "least $knownSingletons. Either one was removed -- lower the floor in the " + + "same change -- or this test has stopped finding them and is passing on " + + "nothing. Found: ${sources.map { it.first }}" + ) + + val offences = sources.flatMap { (name, source) -> stubbedMembers(name, source) } + + assertEquals( + emptyList(), + offences, + "A no-op repository member is stubbed out. Give it the answer a repository with " + + "nothing behind it would give -- null, an empty collection, flowOf(...) of " + + "one of those, Unit, 0, false -- and never TODO(). See the contract on " + + "NostrRepository.NO_OP_NOSTR_REPOSITORY. Stubs found:\n" + + offences.joinToString("\n") { " $it" } + ) + } + + /** + * `file:line member` for every `TODO(` in code, comments excluded -- the contract on + * `NO_OP_NOSTR_REPOSITORY` names `TODO()` in its prose, and a doc comment explaining the + * rule must not trip the check that enforces it. + */ + private fun stubbedMembers(name: String, source: String): List { + val found = mutableListOf() + var inBlockComment = false + var member = "(before the first declaration)" + + source.lines().forEachIndexed { index, raw -> + var line = raw + if (inBlockComment) { + val end = line.indexOf("*/") + if (end < 0) return@forEachIndexed + line = line.substring(end + 2) + inBlockComment = false + } + val blockStart = line.indexOf("/*") + if (blockStart >= 0) { + inBlockComment = !line.substring(blockStart).contains("*/") + line = line.substring(0, blockStart) + } + val lineComment = line.indexOf("//") + if (lineComment >= 0) line = line.substring(0, lineComment) + + if (line.contains("fun ")) member = line.trim() + if (line.contains("TODO(")) found += "$name:${index + 1} $member" + } + return found + } + + /** + * Found by walking up from the working directory rather than taken from it, because the + * one is the `composeApp` project directory under `:composeApp:jvmTest` and the other is + * the repository root when the same test is run from the root project. A test about + * things that fail silently does not get to pass because it could not find its input. + */ + private fun repositoryDirectory(): File { + val relative = "composeApp/src/commonMain/kotlin/press/mantra/compose/repository" + var directory: File? = File("").absoluteFile + while (directory != null) { + val candidate = File(directory, relative) + if (candidate.isDirectory) return candidate + directory = directory.parentFile + } + throw AssertionError( + "Could not find $relative above ${File("").absolutePath} -- this test read nothing." + ) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ChatPaneLayoutJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ChatPaneLayoutJvmTest.kt index ac4d48b7..5d9631f7 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ChatPaneLayoutJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ChatPaneLayoutJvmTest.kt @@ -2,13 +2,21 @@ package press.mantra.compose.ui.composable import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.assertWidthIsEqualTo +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isSelectable +import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick import androidx.compose.ui.test.runDesktopComposeUiTest import androidx.compose.ui.unit.dp +import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf +import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.NostrEvent import press.mantra.compose.database.model.Profile import press.mantra.compose.database.model.intermdiate.LocalChatRoom @@ -21,12 +29,14 @@ import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.state.HomeScreenUIState import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import androidx.compose.ui.test.ComposeUiTest import kotlin.test.Test +import kotlin.test.assertEquals /** * The chat list-detail split, measured in windows of the widths the phase names. * - * Three claims, and none of them can be read off the source: + * Five claims, and none of them can be read off the source: * * - below expanded the home screen is unchanged. That is the promise that let the split * land as one commit -- the chat room is a navigation destination reached from eleven @@ -35,7 +45,12 @@ import kotlin.test.Test * `calculatePaneScaffoldDirective` gives it, so a hand-built pair measures the same as * a `ListDetailPaneScaffold` would; * - the detail pane says what it is for while nothing is selected, rather than being an - * unexplained empty half of the window. + * unexplained empty half of the window; + * - a second tap in the list changes what the detail pane shows. That one is not a + * layout claim at all -- it is about view model scope -- but it is only reachable + * through this layout, and it is the way the split visibly failed on a tablet; + * - and the list says which room the pane is answering with, in the one layout where + * that question exists. * * The repositories are the no-op ones with the two reads this screen makes delegated to a * fixed answer. Kotlin's interface delegation makes that ten lines rather than a @@ -47,6 +62,15 @@ class ChatPaneLayoutJvmTest { private val publicKey = "de1a1e64d1c4e0d6bd97b0e73d4dfd0e1ec1cdd1e6d8d0e2b9a7f3c5d0e1a2b3" private val emptyDetail = "Pick a conversation to read it here." + private val roomOne = "Room one" + private val roomTwo = "Room two" + + /** + * Opening a room is a database read on another dispatcher, so the assertions wait + * rather than assume. Generous, because what it is waiting for is a thread hop and the + * cost of being wrong about it is a test that fails on a loaded machine. + */ + private val OpenTimeout = 5_000L @Test fun `a phone window is one pane, as it has always been`() = runDesktopComposeUiTest(400, 900) { @@ -81,8 +105,75 @@ class ChatPaneLayoutJvmTest { onNodeWithTag(ChatListPaneTag).assertWidthIsEqualTo(412.dp) } + @Test + fun `tapping a second room in the list changes what the detail pane shows`() = + runDesktopComposeUiTest(1000, 900) { + setContent { Home(chatRepository = TwoRooms) } + + // Each room's subject is drawn twice once it is the selection -- on its card in + // the list, and as the title of the detail pane's app bar -- so two nodes is + // "this is the room on the right" and one is "this room is merely listed". + onNodeWithText(roomOne).performClick() + waitUntil("room one opens", OpenTimeout) { nodesSaying(roomOne) == 2 } + + onNodeWithText(roomTwo).performClick() + + // Before the pane scoped its view models this timed out: `key(chatRoomId)` was + // rebuilding the composition around a `ChatRoomMessagingViewModel` that + // `viewModel()` kept resolving, by class, out of the home route's store -- so + // the pane answered with room one however many rooms were tapped after it. + waitUntil("room two replaces it", OpenTimeout) { + nodesSaying(roomTwo) == 2 && nodesSaying(roomOne) == 1 + } + assertEquals(2, nodesSaying(roomTwo), "room two is listed and shown") + assertEquals(1, nodesSaying(roomOne), "room one is listed, not shown") + } + + @Test + fun `the list marks the room the detail pane is showing`() = + runDesktopComposeUiTest(1000, 900) { + setContent { Home(chatRepository = TwoRooms) } + + // Selectable, and nothing selected. A pane that has been asked nothing yet is + // a different state from a room that is not the one being shown, and the list + // is allowed to say so. `card` only matches a selectable node, so finding both + // rooms through it is half of the claim. + card(roomOne).assertIsNotSelected() + card(roomTwo).assertIsNotSelected() + + card(roomOne).performClick() + waitUntil("room one opens", OpenTimeout) { nodesSaying(roomOne) == 2 } + card(roomOne).assertIsSelected() + card(roomTwo).assertIsNotSelected() + + card(roomTwo).performClick() + waitUntil("room two opens", OpenTimeout) { nodesSaying(roomTwo) == 2 } + card(roomTwo).assertIsSelected() + + // The mark moves rather than accumulating. Two highlighted rows beside one + // transcript would say the pane was showing both. + card(roomOne).assertIsNotSelected() + } + + @Test + fun `a one-pane list says nothing about selection, having nothing to select into`() = + runDesktopComposeUiTest(700, 900) { + setContent { Home(chatRepository = TwoRooms) } + + // Tapping navigates here, and the test's callback is a no-op, so the list is + // still on screen afterwards -- which is the only reason this can be asked at + // all. A row answering "not selected" would be describing a pane that is not + // there, and a highlight left behind would outlive the screen it belonged to. + onNodeWithText(roomOne).performClick() + + // Both halves, because either alone passes for the wrong reason: a list that + // never rendered has no selectable rows either. + onNodeWithText(roomOne).assertExists() + card(roomOne).assertDoesNotExist() + } + @androidx.compose.runtime.Composable - private fun Home() { + private fun Home(chatRepository: ChatRepository = NoRooms) { MantraTheme { // Required, and deliberately so: `LocalSnackbarHostState` throws rather than // defaulting, because a default would make every `notify` on a screen that @@ -95,7 +186,7 @@ class ChatPaneLayoutJvmTest { onNavigateToDirectMessageDetail = {}, onNavigateToChatRoomCreation = {}, nostrRepository = FixedProfile, - chatRepository = NoRooms, + chatRepository = chatRepository, frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY, ) @@ -128,9 +219,44 @@ class ChatPaneLayoutJvmTest { } /** The no-op repository, plus the one read the room list makes of it. */ - private val NoRooms = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY { - override suspend fun observeChatRoomListByPublicKey( - publicKey: String, - ): Flow> = flowOf(emptyList()) - } + private val NoRooms = rooms() + + /** Two rooms, told apart by their subject, which is what both panes draw as a title. */ + private val TwoRooms = rooms(room("1", roomOne), room("2", roomTwo)) + + /** + * The no-op repository, plus the two reads this pair of panes makes of it: the list + * observes every room, and the detail pane looks one up by id. + */ + private fun rooms(vararg rooms: LocalChatRoom) = + object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY { + override suspend fun observeChatRoomListByPublicKey( + publicKey: String, + ): Flow> = flowOf(rooms.toList()) + + override suspend fun getChatRoomByIdentifier(id: HexKey): LocalChatRoom? = + rooms.firstOrNull { it.chatRoom.id == id } + } + + private fun room(id: String, subject: String) = LocalChatRoom( + chatRoom = ChatRoom( + id = id, + subject = subject, + description = null + ), + ) + + private fun ComposeUiTest.nodesSaying(text: String) = + onAllNodesWithText(text).fetchSemanticsNodes().size + + /** + * The list's card for the room with this subject. + * + * `Card(onClick = ...)` merges its descendants, so one node carries both the subject + * and the selected state -- which is also why a selected room is two nodes saying its + * subject and a merely listed one is a single node. The detail pane's app bar says the + * same subject and is not selectable, so this stays unambiguous once a room is open. + */ + private fun ComposeUiTest.card(subject: String) = + onNode(isSelectable() and hasText(subject)) } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/UploadChaptersScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/UploadChaptersScreenJvmTest.kt new file mode 100644 index 00000000..fa4b03ad --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/UploadChaptersScreenJvmTest.kt @@ -0,0 +1,245 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.runDesktopComposeUiTest +import kotlin.test.Test +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.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.state.PickedChapter +import press.mantra.compose.ui.view.state.UploadChaptersUIState +import press.mantra.compose.ui.view.state.UploadProblem + +/** + * What the upload screen says before anything has been proposed. + * + * The screen is a review step, so its whole job is to be read: the cost of the + * upload in front of the picker, a row per file saying where it lands and what + * is wrong with it, and a button that offers only what can actually be + * proposed. None of that is checkable from the view model -- `canPropose` is + * read by the FAB and the per-row reasons are rendered from a `when` -- so it is + * checked here, against a real composition. + */ +@OptIn(ExperimentalTestApi::class) +class UploadChaptersScreenJvmTest { + + private val room = LocalChatRoom( + chatRoom = ChatRoom( + id = "room", + subject = "#Ekklesia", + description = "" + ) + ) + + private val artifact = MantraArtifact( + id = "artifactId", + publicKey = "author", + name = "To Kill a Mockingbird", + url = "https://example.test/bird", + visibility = "private", + dialectId = "dialectId", + license = "cc", + chatRoomId = "room", + signature = "", + ) + + private val version = MantraArtifactVersion( + id = "artifactVersionId", + artifactId = "artifactId", + publicKey = "author", + versionLabel = "1.0", + chatRoomId = "room", + signature = "", + ) + + private fun row( + id: String, + name: String = id, + problem: UploadProblem? = null, + sessionId: String? = null, + ) = PickedChapter( + id = id, + fileName = "$id.md", + name = name, + markdown = "A paragraph.", + chunks = listOf("A paragraph."), + wordCount = 2, + characterCount = 12, + proposalBytes = 1000, + problem = problem, + sessionId = sessionId, + ) + + @Test + fun `before any file is picked, the cost of an upload is already on screen`() = render( + state(chapters = emptyList()) + ) { + // 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 + // it. + onNodeWithText("Every file becomes its own proposal", substring = true).assertIsDisplayed() + onNodeWithText("At most 20 files in one upload.").assertIsDisplayed() + onNodeWithText("No files picked yet.").assertIsDisplayed() + onNodeWithText("Choose files").assertIsDisplayed() + } + + @Test + fun `a picked file shows where it lands and what it will be called`() = render( + state(chapters = listOf(row("the-wreck", name = "The wreck")), baseIndex = 3) + ) { + onNodeWithText("the-wreck.md").assertIsDisplayed() + // Numbered from the version's own next index, not from one. + onNodeWithText("Chapter 3 of the version").assertIsDisplayed() + onNodeWithText("The wreck").assertIsDisplayed() + } + + @Test + fun `the button counts what it would actually propose`() = render( + state( + chapters = listOf( + row("good"), + row("binary", problem = UploadProblem.NotText), + row("also-good"), + ) + ) + ) { + // Two, not three: a file that is not text was never offered. + onNodeWithText("Propose 2 chapters").assertIsEnabled() + onNodeWithText("Not text. A chapter is markdown or plain text.").assertIsDisplayed() + } + + @Test + fun `a group with no shared key is told, and offered nothing`() = render( + state(canSign = false) + ) { + onNodeWithText("This group has no shared key", substring = true).assertIsDisplayed() + onNodeWithText("Propose 1 chapters").assertIsNotEnabled() + } + + @Test + fun `an artifact with no version is told, and offered nothing`() = render( + state(artifactVersion = null) + ) { + onNodeWithText("This artifact has no version", substring = true).assertIsDisplayed() + onNodeWithText("Propose 1 chapters").assertIsNotEnabled() + } + + @Test + fun `every file blocked leaves the button offering nothing`() = render( + state( + chapters = listOf( + row("binary", problem = UploadProblem.NotText), + row("blank", problem = UploadProblem.Empty), + ) + ) + ) { + onNodeWithText("Propose 0 chapters").assertIsNotEnabled() + onNodeWithText("Nothing in it to translate.").assertIsDisplayed() + } + + @Test + fun `a duplicate is said without being refused`() = render( + state(chapters = listOf(row("copy", problem = UploadProblem.Duplicate("The wreck")))) + ) { + onNodeWithText("The same text as The wreck.", substring = true).assertIsDisplayed() + // A book can repeat a page, so it is a warning and the button still works. + onNodeWithText("Propose 1 chapters").assertIsEnabled() + } + + @Test + fun `a chapter too long for one session says how to fix it`() = render( + state(chapters = listOf(row("long", problem = UploadProblem.TooManyChunks(80)))) + ) { + onNodeWithText("80 chunks", substring = true).assertIsDisplayed() + onNodeWithText("Split this file.", substring = true).assertIsDisplayed() + } + + @Test + fun `a proposed row waits, keeps its name, and cannot be removed`() = render( + state(chapters = listOf(row("sent", sessionId = "session"), row("waiting"))) + ) { + onNodeWithText("Waiting for the group to sign").assertIsDisplayed() + // Its chapter is in front of the group: nothing about it is still a + // decision, so there is no way to take it out of the list. + onNodeWithContentDescription("Waiting for the group to sign").assertIsDisplayed() + + // And the button becomes a retry for what is left. + onNodeWithText("Propose the remaining 1").assertIsEnabled() + } + + @Test + fun `a failed row is listed with its reason and can be tried again`() = render( + state( + chapters = listOf( + row("sent", sessionId = "session"), + row("failed", problem = UploadProblem.ProposalFailed), + ) + ) + ) { + onNodeWithText("Proposing this one failed. Try again.").assertIsDisplayed() + // The only place a retry can be aimed from. + onNodeWithText("Propose the remaining 1").assertIsEnabled() + } + + private fun state( + chapters: List = listOf(row("one")), + canSign: Boolean = true, + artifactVersion: MantraArtifactVersion? = version, + baseIndex: Int = 0, + ) = UploadChaptersUIState.Loaded( + localChatRoom = room, + artifact = artifact, + artifactVersion = artifactVersion, + canSign = canSign, + chapters = chapters, + baseIndex = baseIndex, + ) + + /** + * Tall on purpose: the rows are cards in a `LazyColumn` and an assertion + * about one that has not been scrolled to is an assertion about nothing. + */ + private fun render( + uiState: UploadChaptersUIState, + assertions: ComposeUiTest.() -> Unit, + ) = runDesktopComposeUiTest(width = 420, height = 2400) { + setContent { + MantraTheme { + ProvideSnackbarHost { + Box(modifier = Modifier.fillMaxSize()) { + UploadChaptersScreen( + activeUserPublicKey = "a".repeat(64), + artifactId = "artifactId", + chatRoomId = "room", + relayHint = null, + initialUIState = uiState, + mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = + FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + onNavigateToRouteAndPopUpInclusive = {}, + onNavigateBack = {}, + ) + } + } + } + } + + assertions() + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AddProjectViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AddProjectViewModelJvmTest.kt new file mode 100644 index 00000000..115558e0 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AddProjectViewModelJvmTest.kt @@ -0,0 +1,299 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.room3.Room +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.repository.DatabaseChatRepository +import press.mantra.compose.database.repository.DatabaseMantraRepository +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.AddProjectUIState +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * What the form actually puts in front of the group. + * + * A project and its first contents go out as one batch whose memberships carry + * the project's id -- and that id is a hash over the group's key at the room's + * derivation path, so it does not exist until the proposal is made. The screen + * therefore hands over a *function* of the signed lead rather than a list, and + * nothing downstream would notice if that function returned the wrong thing: a + * membership naming the wrong project is a valid event, signs cleanly, and + * lands as a row pointing somewhere else. + * + * So these assert the batch's shape against a recording repository that signs + * the lead the way a finished session does. Three properties, each of which has + * a silent failure behind it: + * + * - the memberships name the project that was signed, not one worked out + * beforehand; + * - both halves are in the batch, since the two lists come from two different + * `membershipsOf` calls and dropping one is a dropped selection; + * - the creator on the event is the member filling the form in, because there + * is no field for it and nothing else to check it against. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class AddProjectViewModelJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + private val room = ProjectRoom(db) + + @BeforeTest + fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher()) + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + db.close() + } + + private val scope = CoroutineScope(UnconfinedTestDispatcher()) + + private fun viewModel(signing: RecordingFrostSigningRepository) = AddProjectViewModel( + chatRoomId = room.roomId, + activeUserPublicKey = room.member, + relayHint = null, + initialAddProjectUIState = AddProjectUIState.Loading, + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository = DatabaseChatRepository(db, scope), + mantraRepository = DatabaseMantraRepository(db, scope), + frostSigningRepository = signing, + ) + + /** Opens the form and waits for the room and library to land. */ + private suspend fun loaded(signing: RecordingFrostSigningRepository): Pair { + val viewModel = viewModel(signing) + viewModel.initiateAddProject() + return viewModel to awaitValue { + viewModel.addProjectUIState as? AddProjectUIState.Loaded + } + } + + /** + * Proposes, and waits for whichever callback the view model reaches. + * + * Both fire on `Dispatchers.Main` from inside a `Dispatchers.IO` launch, so + * `addProject` returns long before either runs. + */ + private suspend fun propose( + viewModel: AddProjectViewModel, + state: AddProjectUIState.Loaded, + name: String = "Gospel of John into Sesotho", + description: String = "The four chapters.", + owners: Set = setOf(room.roomId), + sourceVersionIds: List = emptyList(), + translationIds: List = emptyList(), + ): String? { + val outcome = CompletableDeferred() + + viewModel.addProject( + localChatRoom = state.localChatRoom, + nameField = TextFieldState(name), + descriptionField = TextFieldState(description), + ownerPublicKeys = owners, + sourceVersionIds = sourceVersionIds, + translationIds = translationIds, + onSuccess = { outcome.complete(it) }, + onFailure = { outcome.complete(null) } + ) + + return withTimeout(10_000) { outcome.await() } + } + + // --- what the form offers --- + + @Test + fun `the form offers every version in the library and every translation`() = runBlocking { + room.seed() + val signing = RecordingFrostSigningRepository(room.roomId) + val (_, state) = loaded(signing) + + // Versions have no by-room query -- they hang off an artifact -- so this + // is the walk down the tree, and a walk that stopped at the first + // artifact would look like a library with one version in it. + assertEquals( + setOf(room.versionId, room.secondVersionId), + state.sourceVersions.map { it.id }.toSet() + ) + assertEquals(listOf(room.translationId), state.translations.map { it.id }) + assertTrue(state.canSign) + } + + // --- what goes to the group --- + + @Test + fun `the batch is the project, then its sources, then its translations`() = runBlocking { + room.seed() + val signing = RecordingFrostSigningRepository(room.roomId) + val (viewModel, state) = loaded(signing) + + val sessionId = propose( + viewModel, state, + sourceVersionIds = listOf(room.versionId, room.secondVersionId), + translationIds = listOf(room.translationId), + ) + + assertEquals("session-1", sessionId) + + assertEquals( + listOf( + ProjectEvent.KIND, + ProjectArtifactVersionEvent.KIND, + ProjectArtifactVersionEvent.KIND, + ProjectTranslationArtifactVersionEvent.KIND, + ), + signing.proposed.map { it.kind }, + "the project leads, because the memberships are derived from its id" + ) + } + + @Test + fun `every membership names the project the session signed`() = runBlocking { + room.seed() + val signing = RecordingFrostSigningRepository(room.roomId) + val (viewModel, state) = loaded(signing) + + assertEquals( + "session-1", + propose( + viewModel, state, + description = "", + sourceVersionIds = listOf(room.versionId), + translationIds = listOf(room.translationId), + ) + ) + + val project = signing.proposed.first() + + // The point of the whole lead-and-dependents shape. Nothing else checks + // this: a membership naming some other id is a valid signed event that + // lands as a row pointing at nothing. + val source = signing.proposed.single { it.kind == ProjectArtifactVersionEvent.KIND } + .let { + ProjectArtifactVersionEvent( + it.id, it.pubKey, it.createdAt, it.tags, it.content, it.sig + ) + } + assertEquals(project.id, source.projectId()) + assertEquals(room.versionId, source.artifactVersionId()) + + val translation = signing.proposed + .single { it.kind == ProjectTranslationArtifactVersionEvent.KIND } + .let { + ProjectTranslationArtifactVersionEvent( + it.id, it.pubKey, it.createdAt, it.tags, it.content, it.sig + ) + } + assertEquals(project.id, translation.projectId()) + assertEquals(room.translationId, translation.translationArtifactVersionId()) + + // And they read as one act rather than four events sharing a session. + assertTrue(signing.proposed.all { it.createdAt == project.createdAt }) + } + + @Test + fun `the creator is the member filling the form in, and the owners are what was chosen`() = + runBlocking { + room.seed() + val signing = RecordingFrostSigningRepository(room.roomId) + val (viewModel, state) = loaded(signing) + + assertEquals( + "session-1", + propose(viewModel, state, owners = setOf(room.roomId, room.otherMember)), + "a project with no contents is still a project" + ) + + val project = signing.proposed.single().let { + ProjectEvent(it.id, it.pubKey, it.createdAt, it.tags, it.content, it.sig) + } + + // There is no creator field, so this is the only thing that says the + // right person is credited for what the group is about to sign. + assertEquals(room.member, project.creator()) + assertEquals(setOf(room.roomId, room.otherMember), project.owners().toSet()) + assertEquals("Gospel of John into Sesotho", project.name()) + assertEquals("The four chapters.", project.content) + + // The author is the room, not the creator: the group signs it. + assertEquals(room.roomId, signing.proposed.single().pubKey) + } + + @Test + fun `a project with no contents proposes one event`() = runBlocking { + room.seed() + val signing = RecordingFrostSigningRepository(room.roomId) + val (viewModel, state) = loaded(signing) + + assertEquals( + "session-1", + propose(viewModel, state, description = ""), + "an empty project is a name waiting for work" + ) + + assertEquals(listOf(ProjectEvent.KIND), signing.proposed.map { it.kind }) + } + + // --- what the form refuses to send --- + + @Test + fun `a blank name proposes nothing`() = runBlocking { + room.seed() + val signing = RecordingFrostSigningRepository(room.roomId) + val (viewModel, state) = loaded(signing) + + assertNull( + propose(viewModel, state, name = " "), + "a nameless project should not reach a quorum" + ) + assertTrue(signing.proposed.isEmpty()) + } + + @Test + fun `an empty owner set proposes nothing rather than throwing`() = runBlocking { + room.seed() + val signing = RecordingFrostSigningRepository(room.roomId) + val (viewModel, state) = loaded(signing) + + // `ProjectEvent.build` throws on an empty set, so the view model has to + // refuse first. The form disables the last remove, but the guard is what + // makes that a safety rather than the only thing standing in the way. + assertNull( + propose(viewModel, state, description = "", owners = emptySet()), + "a project with no owner cannot be built" + ) + assertTrue(signing.proposed.isEmpty()) + } + + @Test + fun `a room that cannot be found is an error rather than an empty form`() = runBlocking { + // Nothing seeded. + val viewModel = viewModel(RecordingFrostSigningRepository(room.roomId)) + + viewModel.initiateAddProject() + + val error = awaitValue { viewModel.addProjectUIState as? AddProjectUIState.Error } + + assertTrue(error.message.isNotBlank(), "the error should say something") + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AddToProjectViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AddToProjectViewModelJvmTest.kt new file mode 100644 index 00000000..e9e0d948 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AddToProjectViewModelJvmTest.kt @@ -0,0 +1,276 @@ +package press.mantra.compose.ui.view.model + +import androidx.room3.Room +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.MantraProjectArtifactVersion +import press.mantra.compose.database.model.MantraProjectTranslationArtifactVersion +import press.mantra.compose.database.repository.DatabaseChatRepository +import press.mantra.compose.database.repository.DatabaseMantraRepository +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent +import press.mantra.compose.ui.view.state.AddToProjectUIState +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Which work the picker offers, and what one pick becomes. + * + * The filtering is the part that matters and the part nothing else would catch. + * A membership that duplicates one the group has already signed is a *valid* + * event with its own id -- the foreign keys are satisfied, the signature is + * good, the apply arm writes the row -- so there is no layer below this that + * says no. If the filter were wrong the only symptom would be a quorum spent + * saying something the group has already said, which looks like the feature + * working. + * + * The other half is that one pick is one event. The add-to flow knows the + * project's id because the project exists, so it uses the flat batch rather + * than lead-and-dependents, and a membership built against the wrong id would + * land as a row pointing somewhere else. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class AddToProjectViewModelJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + private val room = ProjectRoom(db) + private val projectId = "p".repeat(64) + + @BeforeTest + fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher()) + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + db.close() + } + + private val scope = CoroutineScope(UnconfinedTestDispatcher()) + + private fun viewModel( + signing: RecordingFrostSigningRepository, + translations: Boolean, + ) = AddToProjectViewModel( + projectId = projectId, + chatRoomId = room.roomId, + activeUserPublicKey = room.member, + translations = translations, + relayHint = null, + initialAddToProjectUIState = AddToProjectUIState.Loading, + chatRepository = DatabaseChatRepository(db, scope), + mantraRepository = DatabaseMantraRepository(db, scope), + frostSigningRepository = signing, + ) + + private suspend fun loaded( + signing: RecordingFrostSigningRepository, + translations: Boolean, + ): Pair { + val viewModel = viewModel(signing, translations) + viewModel.initiateAddToProject() + return viewModel to awaitValue { + viewModel.addToProjectUIState as? AddToProjectUIState.Loaded + } + } + + private suspend fun propose( + viewModel: AddToProjectViewModel, + state: AddToProjectUIState.Loaded, + selectedId: String, + ): String? { + val outcome = CompletableDeferred() + + viewModel.addToProject( + localChatRoom = state.localChatRoom, + selectedId = selectedId, + onSuccess = { outcome.complete(it) }, + onFailure = { outcome.complete(null) } + ) + + return withTimeout(10_000) { outcome.await() } + } + + /** A membership already signed, so the picker has something to leave out. */ + private suspend fun alreadyHolds(versionId: String) = + db.mantraProjectArtifactVersionDao().upsert( + MantraProjectArtifactVersion( + id = "m1".padEnd(64, '0'), + publicKey = room.roomId, + projectId = projectId, + artifactVersionId = versionId, + chatRoomId = room.roomId, + signature = room.signature, + ) + ) + + // --- which work is offered --- + + @Test + fun `the source picker offers every version in the library`() = runBlocking { + room.seed() + room.project(id = projectId) + + val (_, state) = loaded(RecordingFrostSigningRepository(room.roomId), translations = false) + + assertEquals( + setOf(room.versionId, room.secondVersionId), + state.candidates.map { it.id }.toSet() + ) + assertTrue(state.canSign) + } + + @Test + fun `a version already in the project is not offered again`() = runBlocking { + room.seed() + room.project(id = projectId) + alreadyHolds(room.versionId) + + val (_, state) = loaded(RecordingFrostSigningRepository(room.roomId), translations = false) + + // The whole point. Nothing below this refuses a duplicate: it is a valid + // event with its own id, so it would cost a quorum to say what the group + // has already said. + assertEquals(listOf(room.secondVersionId), state.candidates.map { it.id }) + } + + @Test + fun `a version is named by its artifact as well as its label`() = runBlocking { + room.seed() + room.project(id = projectId) + + val (_, state) = loaded(RecordingFrostSigningRepository(room.roomId), translations = false) + + val candidate = state.candidates.single { it.id == room.versionId } + + // "1.0" on its own is not a choice -- a room has several of those. + assertEquals(room.versionLabel, candidate.label) + assertEquals(room.artifactName, candidate.detail) + } + + @Test + fun `the translation picker offers translations, not versions`() = runBlocking { + room.seed() + room.project(id = projectId) + + val (_, state) = loaded(RecordingFrostSigningRepository(room.roomId), translations = true) + + assertEquals(listOf(room.translationId), state.candidates.map { it.id }) + assertEquals(room.translationName, state.candidates.single().label) + } + + @Test + fun `a translation already in the project is not offered again`() = runBlocking { + room.seed() + room.project(id = projectId) + db.mantraProjectTranslationArtifactVersionDao().upsert( + MantraProjectTranslationArtifactVersion( + id = "m2".padEnd(64, '0'), + publicKey = room.roomId, + projectId = projectId, + translationArtifactVersionId = room.translationId, + chatRoomId = room.roomId, + signature = room.signature, + ) + ) + + val (_, state) = loaded(RecordingFrostSigningRepository(room.roomId), translations = true) + + assertTrue(state.candidates.isEmpty()) + } + + @Test + fun `the two halves filter independently`() = runBlocking { + room.seed() + room.project(id = projectId) + // A source membership must not hide a translation, and the translation + // this one names is of that very version -- which is what a filter + // written against the wrong table would get wrong. + alreadyHolds(room.versionId) + + val (_, translationState) = + loaded(RecordingFrostSigningRepository(room.roomId), translations = true) + + assertEquals(listOf(room.translationId), translationState.candidates.map { it.id }) + } + + // --- what one pick becomes --- + + @Test + fun `picking a source proposes one membership naming the project`() = runBlocking { + room.seed() + room.project(id = projectId) + val signing = RecordingFrostSigningRepository(room.roomId) + val (viewModel, state) = loaded(signing, translations = false) + + assertEquals("session-1", propose(viewModel, state, room.versionId)) + + val proposed = signing.proposed.single() + assertEquals(ProjectArtifactVersionEvent.KIND, proposed.kind) + + val membership = ProjectArtifactVersionEvent( + proposed.id, proposed.pubKey, proposed.createdAt, + proposed.tags, proposed.content, proposed.sig + ) + assertEquals(projectId, membership.projectId()) + assertEquals(room.versionId, membership.artifactVersionId()) + } + + @Test + fun `picking a translation proposes the translation kind`() = runBlocking { + room.seed() + room.project(id = projectId) + val signing = RecordingFrostSigningRepository(room.roomId) + val (viewModel, state) = loaded(signing, translations = true) + + assertEquals("session-1", propose(viewModel, state, room.translationId)) + + val proposed = signing.proposed.single() + assertEquals(ProjectTranslationArtifactVersionEvent.KIND, proposed.kind) + + val membership = ProjectTranslationArtifactVersionEvent( + proposed.id, proposed.pubKey, proposed.createdAt, + proposed.tags, proposed.content, proposed.sig + ) + assertEquals(projectId, membership.projectId()) + assertEquals(room.translationId, membership.translationArtifactVersionId()) + } + + @Test + fun `a session that will not open is reported rather than swallowed`() = runBlocking { + room.seed() + room.project(id = projectId) + val signing = RecordingFrostSigningRepository(room.roomId).also { it.session = null } + val (viewModel, state) = loaded(signing, translations = false) + + assertNull(propose(viewModel, state, room.versionId)) + } + + // --- what it refuses to open at all --- + + @Test + fun `a project this device does not hold is an error`() = runBlocking { + room.seed() + // The room is there, the project is not. + val viewModel = viewModel(RecordingFrostSigningRepository(room.roomId), translations = false) + viewModel.initiateAddToProject() + + val error = awaitValue { viewModel.addToProjectUIState as? AddToProjectUIState.Error } + + assertTrue(error.message.isNotBlank()) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CeremonyQueueRoutingJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CeremonyQueueRoutingJvmTest.kt new file mode 100644 index 00000000..2e4a0416 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CeremonyQueueRoutingJvmTest.kt @@ -0,0 +1,224 @@ +package press.mantra.compose.ui.view.model + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.delay +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.Participant +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.intermdiate.LocalParticipant +import press.mantra.compose.database.model.types.DkgApprovalStep +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.DkgRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.ChatMessageListUIState +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * When tapping one ceremony in the transcript is answering the whole question, + * and when it is answering a third of it. + * + * The transcript names one ceremony per line and its standing notice stands for + * however many are owed. Opening the named one is right while it is the only + * thing being asked; it stops being right the moment there are two, because the + * second is not on screen beside the first -- it may be pages up, or have + * arrived while the member was reading -- so answering the one they tapped and + * leaving would look exactly like being done. + * + * `hidesOtherDecisions` has made this call for proposals since before subgroups + * existed. [ChatMessageListViewModel.hidesOtherCeremonies] is its counterpart, + * and a room holds several ceremonies as a matter of course now that a + * subgroup's ChillDKG runs in its parent's room. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class CeremonyQueueRoutingJvmTest { + private val chatRoomId = "aa".repeat(32) + private val me = "11".repeat(32) + private val peer = "22".repeat(32) + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `one ceremony owed is answered by opening it`() = runBlocking { + val viewModel = awaiting( + sessions = listOf(session("only", DkgRitualStage.COLLECTING_ROUND_1)), + pending = mapOf("only" to DkgApprovalStep.ROUND_1), + expected = 1 + ) + + assertFalse( + viewModel.hidesOtherCeremonies("only"), + "the one ceremony owed was sent to a queue of itself" + ) + } + + @Test + fun `two ceremonies owed send the member to the list instead`() = runBlocking { + val viewModel = awaiting( + sessions = listOf( + session("ours", DkgRitualStage.COLLECTING_ROUND_1), + session("subgroup", DkgRitualStage.COLLECTING_HOST_KEYS) + .copy(parentChatRoomId = chatRoomId, subject = "Translators"), + ), + pending = mapOf( + "ours" to DkgApprovalStep.ROUND_1, + "subgroup" to DkgApprovalStep.HOST_KEY + ), + expected = 2 + ) + + // Either tap: the queue is the honest answer to both, because neither is + // the whole of what the room is waiting on this member for. + assertTrue(viewModel.hidesOtherCeremonies("ours")) + assertTrue(viewModel.hidesOtherCeremonies("subgroup")) + } + + /** + * The restraint, and the reason this is not just "does the room owe two". + * A line about a ceremony the group finished is history, and a reader who + * taps one is asking to see that -- not to be handed a queue of other + * people's business. + */ + @Test + fun `a finished ceremony is opened on its own even while others are owed`() = runBlocking { + val viewModel = awaiting( + sessions = listOf( + session("done", DkgRitualStage.COMPLETE), + session("ours", DkgRitualStage.COLLECTING_ROUND_1), + session("subgroup", DkgRitualStage.COLLECTING_HOST_KEYS), + ), + pending = mapOf( + "ours" to DkgApprovalStep.ROUND_1, + "subgroup" to DkgApprovalStep.HOST_KEY + ), + expected = 2 + ) + + assertFalse( + viewModel.hidesOtherCeremonies("done"), + "a finished ceremony's line was answered with somebody else's queue" + ) + } + + /** + * An abandoned one is not owed either, which matters because the room's + * watcher filters on it and a row that slipped through would put a dead + * ceremony at the top of the notice. + */ + @Test + fun `an abandoned ceremony is never counted as owed`() = runBlocking { + val viewModel = awaiting( + sessions = listOf(session("abandoned", DkgRitualStage.FAILED)), + // Says yes, and has to be ignored on a failure. + pending = mapOf("abandoned" to DkgApprovalStep.ROUND_1), + expected = 0 + ) + + assertTrue(viewModel.ceremoniesAwaitingYou.isEmpty()) + assertFalse(viewModel.hidesOtherCeremonies("abandoned")) + } + + /** Builds the view model and waits until its watcher has settled on [expected]. */ + private suspend fun awaiting( + sessions: List, + pending: Map, + expected: Int, + ): ChatMessageListViewModel { + val viewModel = ChatMessageListViewModel( + initialChatMessageListUIState = ChatMessageListUIState.Loading, + localChatRoom = room(), + // The bare no-op is safe again. `initiate` calls `scheduleSynchronization` + // on its second line, which launches on `Dispatchers.IO` and -- because + // `room()` gives every participant a null profile, so there is always + // somebody it cannot name -- reaches `queueNegentropySynchronizeRequest`. + // That member used to be one of forty-one `TODO()` bodies on + // NO_OP_NOSTR_REPOSITORY and threw into whichever `runTest` ran next, + // which is how this test failed `NavigationRoutingTest` four times a run + // while passing itself. The override that stood here is gone because the + // no-op is total now; `NoOpRepositoryTotalityJvmTest` keeps it that way. + activeUserPublicKey = me, + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + dkgRepository = object : DkgRepository by DkgRepository.NO_OP_DKG_REPOSITORY { + override fun observeSessionsForChatRoom( + chatRoomId: String + ): Flow> = flowOf(sessions) + + override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? = + pending[session.id] + } + ) + + viewModel.initiate() + + // The watcher writes once, so an empty expectation cannot be waited for + // by its own value -- it is indistinguishable from not having run. Wait + // for a non-empty one, and give an empty one a moment to be wrong in. + withTimeout(5_000) { + if (expected == 0) { + delay(200) + } else { + while (viewModel.ceremoniesAwaitingYou.size != expected) delay(5) + } + } + + assertEquals(expected, viewModel.ceremoniesAwaitingYou.size) + return viewModel + } + + private fun room() = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + subject = "Ekklesia", + description = null + ), + localParticipants = listOf(me, peer).map { publicKey -> + LocalParticipant( + participant = Participant( + participantPublicKey = publicKey, + chatRoomId = chatRoomId, + relayHint = null, + ), + profile = null, + ) + }, + ) + + private fun session(id: String, stage: DkgRitualStage) = DkgSession( + id = id, + chatRoomId = chatRoomId, + coordinatorPublicKey = peer, + userPublicKey = me, + threshold = 2, + participantCount = 2, + hostPublicKey = peer, + round1Random = "66".repeat(32), + round2AuxRandom = "77".repeat(32), + stage = stage, + ) +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualSecondSubgroupJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualSecondSubgroupJvmTest.kt new file mode 100644 index 00000000..1d1ab46a --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualSecondSubgroupJvmTest.kt @@ -0,0 +1,320 @@ +package press.mantra.compose.ui.view.model + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.FrostSigningItem +import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.Participant +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession +import press.mantra.compose.database.model.intermdiate.LocalParticipant +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.database.model.types.FrostSigningStage +import press.mantra.compose.nostr.frost.GroupKeyStateEvent +import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.DkgRepository +import press.mantra.compose.ui.view.state.DkgRitualUIState +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * A parent's room holds every one of its subgroups' signings, and the ceremony + * screen has to pick out the one it is showing. + * + * Since a subgroup's ChillDKG moved into its parent's Marmot room, both of the + * rungs after the ceremony run there too: the birth certificate is the parent's + * signature, and the key state is signed over the parent's room because the + * subgroup's own room does not exist yet. So a parent making a second subgroup + * has two of each in one room, told apart by nothing but the ceremony they + * belong to. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DkgRitualSecondSubgroupJvmTest { + private val parentChatRoomId = "aa".repeat(32) + private val me = "11".repeat(32) + private val peer = "22".repeat(32) + + // Real points, in the compressed form `SharedKeyDerivation` reads: it derives + // a room id off the curve, and a made-up 32 bytes is not on it. + private val firstKey = "02" + KeyPair().pubKey.toHexKey() + private val secondKey = "02" + KeyPair().pubKey.toHexKey() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `the certificate shown is this subgroup's, not the parent's newest`() = runBlocking { + val loaded = load( + signingSessions = listOf( + // Newest first, the way the query returns them. + certificateSigning("forSecond", subgroupOf(secondKey)), + certificateSigning("forFirst", subgroupOf(firstKey)), + ), + until = { it.certificateSession != null } + ) + + assertEquals( + "forFirst", + loaded.certificateSession?.id, + "the screen for one subgroup is following another subgroup's certificate" + ) + } + + @Test + fun `the key state shown is this ceremony's, not the room's newest`() = runBlocking { + val loaded = load( + signingSessions = listOf( + keyStateSigning("forSecond", dkgSessionId = "secondCeremony"), + keyStateSigning("forFirst", dkgSessionId = "firstCeremony"), + ), + until = { it.keyStateSession != null } + ) + + assertEquals( + "forFirst", + loaded.keyStateSession?.id, + "the screen for one ceremony is following another ceremony's key state" + ) + } + + /** + * The damaging half, and the reason this is a block rather than a cosmetic + * mix-up. Both rungs disable their button while a signing of that kind is + * running and un-failed, and a *completed* signing is un-failed forever -- + * so by kind alone, a parent that had certified one subgroup could never be + * asked to certify another, and the rung sat there disabled with no way out. + */ + @Test + fun `a sibling's finished signings leave this ceremony's rungs open`() = runBlocking { + // Driven in two emissions rather than one, so the assertion is about a + // watcher that demonstrably ran. Asserting "still null" against a single + // emission passes before the watcher has started, which is how a version + // of this test passed against the bug it was written for. + val signingSessions = MutableStateFlow( + listOf( + certificateSigning("mine", subgroupOf(firstKey)), + keyStateSigning("myKeyState", dkgSessionId = "firstCeremony"), + ) + ) + + val viewModel = viewModel(signingSessions) + viewModel.initiate() + + awaitLoaded(viewModel) { + it.certificateSession?.id == "mine" && it.keyStateSession?.id == "myKeyState" + } + + // Now the room holds only a sibling's, both finished -- which is every + // parent that has already made one subgroup and is making its second. + signingSessions.value = listOf( + certificateSigning( + "siblingCertificate", + subgroupOf(secondKey), + stage = FrostSigningStage.COMPLETE + ), + // A key state naming another ceremony: the sibling's, or this parent's + // own from an adoption, which is signed in this room too. + keyStateSigning( + "siblingKeyState", + dkgSessionId = "secondCeremony", + stage = FrostSigningStage.COMPLETE + ), + ) + + val loaded = awaitLoaded(viewModel) { + it.certificateSession == null && it.keyStateSession == null + } + + // Both rungs gate on these being null or failed, and a completed signing + // is never failed -- so a sibling left in either of them shuts the rung + // for good. + assertNull(loaded.certificateSession) + assertNull(loaded.keyStateSession) + } + + private suspend fun load( + signingSessions: List, + until: (DkgRitualUIState.Loaded) -> Boolean, + ): DkgRitualUIState.Loaded { + val viewModel = viewModel(MutableStateFlow(signingSessions)) + viewModel.initiate() + return awaitLoaded(viewModel, until) + } + + private suspend fun awaitLoaded( + viewModel: DkgRitualViewModel, + until: (DkgRitualUIState.Loaded) -> Boolean, + ): DkgRitualUIState.Loaded = withTimeout(5_000) { + viewModel.dkgRitualUIState.first { + (it as? DkgRitualUIState.Loaded)?.let(until) == true + } + } as DkgRitualUIState.Loaded + + /** The screen is always showing the FIRST subgroup's ceremony. */ + private fun viewModel( + signingSessions: MutableStateFlow> + ): DkgRitualViewModel { + val ceremony = DkgSession( + id = "firstCeremony", + chatRoomId = parentChatRoomId, + coordinatorPublicKey = me, + userPublicKey = me, + threshold = 2, + participantCount = 2, + hostPublicKey = me, + round1Random = "66".repeat(32), + round2AuxRandom = "77".repeat(32), + stage = DkgRitualStage.COMPLETE, + thresholdPublicKey = firstKey, + parentChatRoomId = parentChatRoomId, + subject = "Translators", + ) + + return DkgRitualViewModel( + chatRoomId = parentChatRoomId, + activeUserPublicKey = me, + dkgSessionId = ceremony.id, + initialDkgRitualUIState = DkgRitualUIState.Loading, + activeIdentityStateFlow = MutableStateFlow(null), + chatRepository = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY { + override suspend fun getChatRoomByIdentifier(id: HexKey): LocalChatRoom? = room() + }, + dkgRepository = object : DkgRepository by DkgRepository.NO_OP_DKG_REPOSITORY { + override suspend fun roomDerivedBy(dkgSessionId: String): String? = + if (dkgSessionId == ceremony.id) subgroupOf(firstKey) else null + + override fun observeSessionById(sessionId: String): Flow = + flowOf(ceremony) + + override fun observeLatestSessionForChatRoom( + chatRoomId: String, + parentChatRoomId: String?, + ): Flow = flowOf(ceremony) + + override fun observeSigningSessions( + chatRoomId: String + ): Flow> = signingSessions + } + ) + } + + /** The room id a ceremony's key derives, which is what a certificate names. */ + private fun subgroupOf(thresholdPublicKey: HexKey): String = + press.mantra.compose.managers.SharedKeyDerivation.marmotGroupId(thresholdPublicKey) + + private fun certificateSigning( + id: String, + subgroupChatRoomId: String, + stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES, + ) = signing( + id = id, + stage = stage, + event = Event( + id = "ee".repeat(32), + pubKey = me, + createdAt = 0, + kind = SubgroupBirthCertificateEvent.KIND, + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId, + thresholdPublicKey = firstKey, + adminPublicKeys = listOf(me, peer), + name = "Translators", + ), + content = subgroupChatRoomId, + sig = "ff".repeat(64), + ) + ) + + private fun keyStateSigning( + id: String, + dkgSessionId: String, + stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES, + ) = signing( + id = id, + stage = stage, + event = Event( + id = "ee".repeat(32), + pubKey = me, + createdAt = 0, + kind = GroupKeyStateEvent.KIND, + tags = GroupKeyStateEvent.assembleTags( + chatRoomId = subgroupOf(firstKey), + dkgSessionId = dkgSessionId, + ), + content = firstKey, + sig = "ff".repeat(64), + ) + ) + + private fun signing( + id: String, + event: Event, + stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES, + ) = LocalFrostSigningSession( + session = FrostSigningSession( + id = id, + stage = stage, + chatRoomId = parentChatRoomId, + coordinatorPublicKey = me, + userPublicKey = me, + dkgSessionId = "firstCeremony", + threshold = 2, + participantCount = 2, + signerId = 0, + ), + unorderedItems = listOf( + FrostSigningItem( + sessionId = id, + itemIndex = 0, + unsignedEventJson = event.toJson(), + eventId = event.id, + nonceRandom = "55".repeat(32), + ) + ), + ) + + private fun room() = LocalChatRoom( + chatRoom = ChatRoom( + id = parentChatRoomId, + subject = "Ekklesia", + description = null + ), + localParticipants = listOf(me, peer).map { publicKey -> + LocalParticipant( + participant = Participant( + participantPublicKey = publicKey, + chatRoomId = parentChatRoomId, + relayHint = null, + ), + profile = null, + ) + }, + ) +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualStartOfferJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualStartOfferJvmTest.kt index e5a0b54b..e535b8fd 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualStartOfferJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualStartOfferJvmTest.kt @@ -111,21 +111,30 @@ class DkgRitualStartOfferJvmTest { * * Every flow folding into this state used to read it, build a `copy` and * assign -- and the session watcher asks the database what this member owes - * in the middle of that. The key-state watcher writing during that pause had - * its change thrown away by the assignment that followed. The same pause in - * the other direction is what put `session` back to null and offered to - * start the ceremony on screen; this is the half of it that can be forced to - * happen rather than raced into. + * in the middle of that. Whoever wrote during that pause had their change + * thrown away by the assignment that followed. The same pause in the other + * direction is what put `session` back to null and offered to start the + * ceremony on screen; this is the half of it that can be forced to happen + * rather than raced into. + * + * The pair used here is the session watcher against the room watcher, which + * are the two that are genuinely concurrent: both are collecting before + * either has written, and neither waits on the other. `roomHasCeremony` is + * the field they fight over, and it is the one that decides the offer. */ @Test fun `a change written during a suspension survives the write that follows it`() = runBlocking { - val session = session(DkgRitualStage.COLLECTING_ROUND_1) - val signingSessions = MutableStateFlow>(emptyList()) + val live = session(DkgRitualStage.COLLECTING_ROUND_1) + val abandoned = session(DkgRitualStage.FAILED) - // Held open until the test has had the key-state watcher write. The - // seeding call runs before anything else is collecting, so it is let - // through; the session watcher's call is the one that waits. + val roomSessions = MutableStateFlow(live) + + // Opened once the test has had the room watcher write. The seeding call + // runs before anything else is collecting, so it is let through; the + // session watcher's call is the one that waits, and `parked` says when + // it is in there. val gate = CompletableDeferred() + val parked = CompletableDeferred() var calls = 0 val viewModel = viewModel( @@ -133,16 +142,16 @@ class DkgRitualStartOfferJvmTest { override fun observeLatestSessionForChatRoom( chatRoomId: String, parentChatRoomId: String?, - ): Flow = flowOf(session) + ): Flow = roomSessions - override fun observeSigningSessions( - chatRoomId: String - ): Flow> = signingSessions + override fun observeSessionById(sessionId: String): Flow = + flowOf(live) override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? { calls += 1 if (calls == 1) return null + parked.complete(Unit) gate.await() return DkgApprovalStep.ROUND_1 } @@ -151,33 +160,37 @@ class DkgRitualStartOfferJvmTest { viewModel.initiate() - withTimeout(5_000) { viewModel.dkgRitualUIState.first { it is DkgRitualUIState.Loaded } } + awaitLoaded(viewModel) { it.roomHasCeremony } - // The watcher is now parked inside `pendingApproval`, holding a copy of - // the state as it was before this line. - signingSessions.value = listOf(keyStateSigningSession()) - - withTimeout(5_000) { - viewModel.dkgRitualUIState.first { - (it as? DkgRitualUIState.Loaded)?.keyStateSession != null - } - } + // The session watcher is now inside `pendingApproval`, holding -- in the + // shape this used to be written in -- a copy of the state as it was + // before the next two lines. + withTimeout(5_000) { parked.await() } + roomSessions.value = abandoned + awaitLoaded(viewModel) { !it.roomHasCeremony } gate.complete(Unit) - val settled = withTimeout(5_000) { - viewModel.dkgRitualUIState.first { - (it as? DkgRitualUIState.Loaded)?.pendingApproval == DkgApprovalStep.ROUND_1 - } - } as DkgRitualUIState.Loaded + val settled = awaitLoaded(viewModel) { + it.pendingApproval == DkgApprovalStep.ROUND_1 + } - assertNotNull( - settled.keyStateSession, - "the session watcher wrote back a copy taken before the key state arrived" + assertFalse( + settled.roomHasCeremony, + "the session watcher wrote back a copy taken before the room watcher's change" ) - assertEquals(session.id, settled.session?.id) + assertEquals(live.id, settled.session?.id) } + private suspend fun awaitLoaded( + viewModel: DkgRitualViewModel, + until: (DkgRitualUIState.Loaded) -> Boolean, + ): DkgRitualUIState.Loaded = withTimeout(5_000) { + viewModel.dkgRitualUIState.first { + (it as? DkgRitualUIState.Loaded)?.let(until) == true + } + } as DkgRitualUIState.Loaded + private fun viewModel(dkgRepository: DkgRepository) = DkgRitualViewModel( chatRoomId = chatRoomId, activeUserPublicKey = me, diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/FrostSigningProjectNamesJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/FrostSigningProjectNamesJvmTest.kt new file mode 100644 index 00000000..8bfb2abb --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/FrostSigningProjectNamesJvmTest.kt @@ -0,0 +1,338 @@ +package press.mantra.compose.ui.view.model + +import androidx.room3.Room +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.FrostSigningItem +import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.FrostSignerMessage +import press.mantra.compose.database.repository.DatabaseChatRepository +import press.mantra.compose.database.repository.DatabaseMantraRepository +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.text.ProposedEvent +import press.mantra.compose.ui.view.state.FrostSigningUIState +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * What a member is actually shown before they sign something about a project. + * + * A membership event carries two 64-character ids and nothing else. Unresolved, + * the signing screen asks somebody to agree to "a4f1be55d0c3" -- which is not a + * question anybody can answer, and it is the whole reason + * `FrostSigningViewModel` resolves names before handing `ProposedEvent.summarize` + * a lookup. + * + * Nothing downstream notices if that resolution is wrong or missing. The summary + * still renders, the session still signs, and the member still taps approve. + * These tests are the only thing standing between the resolver and a screen that + * quietly goes back to showing hashes: + * + * - a membership resolves to the artifact and label a reader recognises; + * - a project's creator and outside owners resolve to people; + * - the room's own key is *not* named as an owner, because saying "this group" + * on every project teaches a member to skip the line that matters; + * - an id this device has never seen falls back to a short hash rather than + * inventing a name or dropping the line. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class FrostSigningProjectNamesJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + private val room = ProjectRoom(db) + private val projectId = "p".repeat(64) + private val sessionId = "session-1" + + @BeforeTest + fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher()) + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + db.close() + } + + private val scope = CoroutineScope(UnconfinedTestDispatcher()) + + /** A session whose items are [events], which is all this screen reads. */ + private class StubSigning( + private val chatRoomId: String, + private val events: List, + private val delegate: FrostSigningRepository = + FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + ) : FrostSigningRepository by delegate { + + override fun observeSessionById(sessionId: String): Flow = flowOf( + FrostSigningSession( + id = sessionId, + chatRoomId = chatRoomId, + coordinatorPublicKey = chatRoomId, + userPublicKey = chatRoomId, + dkgSessionId = "ceremony-1", + threshold = 2, + participantCount = 3, + signerId = 0, + ) + ) + + override fun observeMessages(sessionId: String): Flow> = + flowOf(emptyList()) + + override fun observeItems(sessionId: String): Flow> = flowOf( + events.mapIndexed { index, event -> + FrostSigningItem( + sessionId = sessionId, + itemIndex = index, + unsignedEventJson = event.toJson(), + eventId = event.id, + nonceRandom = "a".repeat(64), + ) + } + ) + } + + private fun signed(template: EventTemplate<*>) = Event( + id = EventHasher.hashId( + pubKey = room.roomId, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + ), + pubKey = room.roomId, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = "f".repeat(128), + ) + + /** + * The names the signing screen would be handed for [events]. + * + * The profile source is a real read of the seeded database rather than the + * no-op, whose `getProfileWithPublicKey` is a `TODO()` -- so a resolver that + * asks it about anybody throws on `Dispatchers.IO`, which surfaces as this + * helper timing out rather than as a failure anybody can read. Reading the + * database gives the honest answer for a stranger too: null. + */ + private suspend fun namesFor(events: List): Map { + val viewModel = FrostSigningViewModel( + chatRoomId = room.roomId, + sessionId = sessionId, + activeUserPublicKey = room.member, + initialFrostSigningUIState = FrostSigningUIState.Loading, + chatRepository = DatabaseChatRepository(db, scope), + frostSigningRepository = StubSigning(room.roomId, events), + mantraRepository = DatabaseMantraRepository(db, scope), + nostrRepository = ProfileReadingNostrRepository(db), + ) + + viewModel.initiate() + + return awaitValue { + (viewModel.frostSigningUIState as? FrostSigningUIState.Loaded) + ?.takeIf { it.items.isNotEmpty() } + ?.names + } + } + + private fun membership(versionId: String) = signed( + ProjectArtifactVersionEvent.build( + projectId = projectId, + artifactVersionId = versionId, + createdAt = 1_700_000_000, + ) + ) + + // --- a membership proposed on its own --- + + @Test + fun `a membership resolves to the artifact and the label`() = runBlocking { + room.seed() + room.project(id = projectId) + + val names = namesFor(listOf(membership(room.versionId))) + + assertEquals("${room.artifactName} ${room.versionLabel}", names[room.versionId]) + assertEquals("Gospel of John", names[projectId]) + } + + @Test + fun `and so the summary a member reads is words rather than a hash`() = runBlocking { + room.seed() + room.project(id = projectId) + + val event = membership(room.versionId) + val names = namesFor(listOf(event)) + + // The end the resolver exists for, asserted through the same call the + // signing screen makes. + val summary = ProposedEvent.summarize( + event = event, + chatRoomId = room.roomId, + resolve = names::get, + ) + + assertEquals("A source version joins the project", summary.label) + assertEquals("To Kill a Mockingbird 1.0 · to Gospel of John", summary.detail) + assertTrue( + !summary.detail.contains(room.versionId.take(12)), + "a resolved membership should not still be showing a hash" + ) + } + + @Test + fun `a translation membership resolves to the translation's name`() = runBlocking { + room.seed() + room.project(id = projectId) + + val event = signed( + ProjectTranslationArtifactVersionEvent.build( + projectId = projectId, + translationArtifactVersionId = room.translationId, + createdAt = 1_700_000_000, + ) + ) + + assertEquals(room.translationName, namesFor(listOf(event))[room.translationId]) + } + + // --- what cannot be resolved --- + + @Test + fun `a version this device has never seen is left to the hash`() = runBlocking { + room.seed() + room.project(id = projectId) + val absent = "9".repeat(64) + + val event = membership(absent) + val names = namesFor(listOf(event)) + + // Left out rather than guessed at. The device has not seen the thing, + // and a short hash says so; inventing a name would be worse than saying + // nothing. + assertTrue(absent !in names) + + val summary = ProposedEvent.summarize(event, room.roomId, names::get) + assertEquals(absent.take(12) + " · to Gospel of John", summary.detail) + } + + @Test + fun `a project being signed in the same batch resolves to nothing`() = runBlocking { + room.seed() + // No project row: this is the founding batch, where the project is on the + // line above and nothing has applied it yet. + val project = signed( + ProjectEvent.build( + name = "Gospel of John into Sesotho", + description = "", + creatorPublicKey = room.member, + ownerPublicKeys = setOf(room.roomId), + createdAt = 1_700_000_000, + ) + ) + val source = signed( + ProjectArtifactVersionEvent.build( + projectId = project.id, + artifactVersionId = room.versionId, + createdAt = 1_700_000_000, + ) + ) + + val names = namesFor(listOf(project, source)) + + assertTrue( + project.id !in names, + "nothing has applied the project yet, which is why a membership says the version first" + ) + // And the version still resolves, which is what makes the batch readable. + assertEquals("${room.artifactName} ${room.versionLabel}", names[room.versionId]) + } + + // --- the people on a project --- + + @Test + fun `a project's creator resolves to a profile`() = runBlocking { + room.seed() + + // NostrRepository is the no-op here, so prove the wiring with a stub + // that answers for the member and nobody else. + val names = namesFor( + listOf( + signed( + ProjectEvent.build( + name = "Gospel of John into Sesotho", + description = "", + creatorPublicKey = room.member, + ownerPublicKeys = setOf(room.roomId, room.otherMember), + createdAt = 1_700_000_000, + ) + ) + ) + ) + + assertEquals("kgothatso", names[room.member]) + } + + @Test + fun `an owner with no profile is left to the hash, and the room is left out of the line`() = + runBlocking { + room.seed() + + val project = signed( + ProjectEvent.build( + name = "Gospel of John into Sesotho", + description = "", + creatorPublicKey = room.member, + ownerPublicKeys = setOf(room.roomId, room.otherMember), + createdAt = 1_700_000_000, + ) + ) + val names = namesFor(listOf(project)) + + assertTrue(room.otherMember !in names, "a stranger has no name to show") + + val summary = ProposedEvent.summarize(project, room.roomId, names::get) + + assertTrue(summary.detail.contains("started by kgothatso"), summary.detail) + assertTrue( + summary.detail.contains("owned by ${room.otherMember.take(12)}"), + summary.detail + ) + assertTrue( + !summary.detail.contains(room.roomId.take(12)), + "the room owning its own work is the ordinary case and says nothing" + ) + } + + /** Only the one method the resolver reaches for. */ + private class ProfileReadingNostrRepository( + private val db: MantraDatabase, + private val delegate: NostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + ) : NostrRepository by delegate { + override suspend fun getProfileWithPublicKey(publicKey: String) = + db.profileDao().getProfileByPublicKey(publicKey) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/ProjectViewModelFixtures.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/ProjectViewModelFixtures.kt new file mode 100644 index 00000000..3bf0b626 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/ProjectViewModelFixtures.kt @@ -0,0 +1,233 @@ +package press.mantra.compose.ui.view.model + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.MantraArtifact +import press.mantra.compose.database.model.MantraArtifactVersion +import press.mantra.compose.database.model.MantraDialect +import press.mantra.compose.database.model.MantraProject +import press.mantra.compose.database.model.MantraTranslationArtifactVersion +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.repository.FrostSigningRepository +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout + +/** + * Waits for [read] to stop returning null, or gives up. + * + * These view models hold their state in `mutableStateOf` rather than a flow -- + * the convention the rest of this family follows -- so there is nothing to + * collect, and `initiate` launches on `Dispatchers.IO`, so the call returns + * before the work is done. Polling in real time for the same reason + * `NavigationIdentityRoutingJvmTest` does: `runTest` would advance a virtual + * clock past a real database read rather than waiting for it. + */ +suspend fun awaitValue(timeoutMillis: Long = 10_000, read: () -> T?): T = + withTimeout(timeoutMillis) { + var value = read() + while (value == null) { + delay(5) + value = read() + } + value + } + +/** + * The room, the library and the translation every project view model needs + * before it has anything to do. + * + * Shared rather than copied three times: the chain is five foreign keys deep and + * none of it is what any of these tests are about. + */ +class ProjectRoom(private val db: MantraDatabase) { + val roomId = "b".repeat(64) + val signature = "0".repeat(128) + + val member = "a".repeat(64) + val otherMember = "7".repeat(64) + + val dialectId = "1".repeat(64) + val artifactId = "2".repeat(64) + val versionId = "3".repeat(64) + val secondVersionId = "6".repeat(64) + val translationId = "5".repeat(64) + + val artifactName = "To Kill a Mockingbird" + val versionLabel = "1.0" + val translationName = "Sesotho" + + suspend fun seed() { + val nostrEventId = "e".repeat(64) + db.nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = member, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = signature, + ) + ) + db.profileDao().upsert( + Profile(publicKey = member, userName = "kgothatso", nostrEventId = nostrEventId) + ) + db.chatRoomDao().upsert( + ChatRoom( + id = roomId, + subject = "a translation room", + description = null + ) + ) + + db.mantraDialectDao().upsert( + MantraDialect( + id = dialectId, + publicKey = roomId, + name = "Sesotho", + country = "ZA", + language = "st", + signature = signature, + chatRoomId = roomId, + ) + ) + db.mantraArtifactDao().upsert( + MantraArtifact( + id = artifactId, + publicKey = roomId, + name = artifactName, + url = "https://harper.com/2-kill-Bird", + visibility = "private", + dialectId = dialectId, + license = "cc", + chatRoomId = roomId, + signature = signature, + ) + ) + listOf(versionId to versionLabel, secondVersionId to "2.0").forEach { (id, label) -> + db.mantraArtifactVersionDao().upsert( + MantraArtifactVersion( + id = id, + artifactId = artifactId, + publicKey = roomId, + versionLabel = label, + signature = signature, + chatRoomId = roomId, + ) + ) + } + db.mantraTranslationArtifactVersionDao().upsert( + MantraTranslationArtifactVersion( + id = translationId, + publicKey = roomId, + artifactVersionId = versionId, + dialectId = dialectId, + name = translationName, + visibility = "private", + license = "cc", + signature = signature, + chatRoomId = roomId, + ) + ) + } + + /** A project already signed into the room, for the add-to flow to add to. */ + suspend fun project( + id: String = "p".repeat(64), + name: String = "Gospel of John", + owners: Set = setOf(roomId), + ): MantraProject = MantraProject( + id = id, + publicKey = roomId, + name = name, + description = "The four chapters.", + creatorPublicKey = member, + ownerPublicKeys = owners.sorted().joinToString(","), + chatRoomId = roomId, + signature = signature, + ).also { db.mantraProjectDao().upsert(it) } + + suspend fun localChatRoom(): LocalChatRoom = + db.chatRoomDao().findChatRoomById(roomId) ?: error("the room was not seeded") +} + +/** + * A signing repository that records what it was asked to sign instead of asking + * a group. + * + * Delegates everything else to the no-op, so it keeps compiling when the + * interface grows and says nothing about methods these tests are not aiming at. + * + * The lead-and-dependents overload is emulated rather than stubbed, because that + * is the part worth checking: the real one signs the lead first -- resolving the + * room's key and so the lead's id -- and only then asks the caller what refers + * to it. A fake that handed back an unsigned lead, or never called `dependents`, + * would let a batch that cannot work pass. + */ +class RecordingFrostSigningRepository( + private val authorPublicKey: HexKey, + private val delegate: FrostSigningRepository = + FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, +) : FrostSigningRepository by delegate { + + /** Every event the session was opened over, lead first. */ + var proposed: List = emptyList() + private set + + /** Null to make the proposal fail, which is the screens' other path. */ + var session: FrostSigningSession? = FrostSigningSession( + id = "session-1", + chatRoomId = "b".repeat(64), + coordinatorPublicKey = authorPublicKey, + userPublicKey = authorPublicKey, + dkgSessionId = "ceremony-1", + threshold = 2, + participantCount = 3, + signerId = 0, + ) + + override suspend fun canSign(chatRoomId: String): Boolean = true + + override suspend fun proposeSigningBatch( + localChatRoom: LocalChatRoom, + userPublicKey: HexKey, + events: List> + ): FrostSigningSession? { + proposed = events.map { signed(it) } + return session + } + + override suspend fun proposeSigningBatch( + localChatRoom: LocalChatRoom, + userPublicKey: HexKey, + lead: EventTemplate<*>, + dependents: (lead: Event) -> List> + ): FrostSigningSession? { + val signedLead = signed(lead) + proposed = listOf(signedLead) + dependents(signedLead).map { signed(it) } + return session + } + + /** The template as a finished session leaves it: authored by the room. */ + private fun signed(template: EventTemplate<*>) = Event( + id = EventHasher.hashId( + pubKey = authorPublicKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + ), + pubKey = authorPublicKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = "f".repeat(128), + ) +} diff --git a/docs/README.md b/docs/README.md index 63ba242c..f47439a6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,8 @@ silent, or a decision that looked arbitrary and was not. | [multiple-profiles.md](./multiple-profiles.md) | several profiles on one device — why a profile is a credential and a seed is a wallet attached to one, why a switch is a restart rather than a swap, the two entrances the app lacks, and the inbox a switch would silently lose | | [jvm-target.md](./jvm-target.md) | what desktop support cost, phased — why the native chain was already done, why an empty source set in our phoenix fork was the real blocker, and why DAO tests need none of it | | [npub-profile-preview.md](./npub-profile-preview.md) | showing the person before a direct message is started from a pasted npub — why the preview is a screen keyed by public key, what "found" means when a row can be a placeholder, and the phase that makes the button honest about a key package that never comes | +| [chapter-uploads.md](./chapter-uploads.md) | picking files off a device and having the group sign each one in as a chapter — why N files can never be one signing session, and the four things the single-chapter path gets away with | +| [project-feature.md](./project-feature.md) | bringing the server schema's `project` and its two join models into a group that has no accounts and no server — a creator tag of its own and owners as a signed set of `p` tags, the role the parser has to be told about or every project lands ownerless, why a membership is its own signed event rather than a list, and the kind band that is already double-booked | | [material-design-conformance.md](./material-design-conformance.md) | what the M3 foundations actually require, measured against all 43 screens — the colour pairing that renders the app's own proposals invisible, and eight phases that put the decisions back in the theme | | [curated-to-mantra.md](./curated-to-mantra.md) | pulling the Curated fork's thirty-nine commits back under Mantra's names — which lines of work to take, the three decisions, and a measured way to replay a twice-rebranded history without touching seven hundred files by hand | | [curated-to-mantra-profiles.md](./curated-to-mantra-profiles.md) | the second pull: the fork's several-profiles line, ten commits and its first schema migration — which of it is a fix Mantra has today, who allocates a schema version number when two trees share one history, and the one conflict, which was Mantra's own | @@ -77,3 +79,21 @@ smallest of the plans: it changes the entrance to the direct-message flow and nothing past it, so read it with `StartDirectMessageToNpubOrNip05Dialog` and `ChatRoomMessagingViewModel.initiateNewChat` open, its four decisions before its four phases, and its table of where the build chose differently first. +The chapter uploads note is a phased plan that has been built, and is the +only one that starts from another codebase: it mirrors the mantra.press web app's +multi-file upload under this app's rule that the group, not the uploader, is the +author. Read the batch-signing note first — its three limits are the whole shape +of this one — and read its four-defects section even if the feature is never +built, because each of those is wrong in the single-chapter path today. Its two +splitter phases are separable from the rest and worth reading on their own: what +a chunk is, measured against the parser the web app translates with rather than +assumed. +The projects note is a phased plan that has been built as far as its seventh +phase, and is the only one +whose starting point is outside this repository: the platform's server schema, +`mantra-db-models`, has had the three models for years. It reads as the +translation domain's unanswered question — the app can say what a chapter is and +who translated it, and cannot say what any of it is for — so read it after the +nip30303 conventions are familiar, its decisions before its phases, and its scope +fork before either, because that one changes the shape of the rest, and its +table of where the build chose differently first. diff --git a/docs/chapter-uploads.md b/docs/chapter-uploads.md new file mode 100644 index 00000000..f69bd0b0 --- /dev/null +++ b/docs/chapter-uploads.md @@ -0,0 +1,1083 @@ +# Chapter uploads + +Picking files off a device and having a group sign each one into an artifact as a +`MantraChapter` with its chunks — the way +[mantra.press](https://code.sigidli.com/mantra/mantra.press) does it over one +HTTP POST, except that here nobody is authoring anything on their own. + +**Built**, all nine phases, one commit each, and the phases below are kept as +written — they are the reasoning, and the code is easier to read against the +argument it came from than against a summary of itself. Where the +implementation chose differently from the plan the section says so, and there +are four such places worth knowing about: the picker is three actuals rather +than FileKit, the byte cap is a function rather than a constant and lives on the +manager rather than the chapter, the scaffolding fix turned out to repair a +latent bug as well, and Phase 6 found all three of its worries already answered. + +The constraint that shapes every phase is stated first, because the web app's +shape — N files in, N chapters out, one transaction, one redirect — is the one +shape this app cannot have, and every phase below is a consequence of that +rather than a feature. + +--- + +## What mantra.press does + +One route, `POST /v/:id/chapters/upload`, in +`server/router/versions/index.js`. A multipart form with `multiple` on the file +input (`server/views/chapters-upload.pug`), and for each file: + +```js +const fileText = file.data.toString() +const chunks = md.parse(fileText).filter(t => t.content).map(t => t.content) +db.Chapter.create({ + creatorId: userId, + name: file.name, + originalText: fileText, + wordCount: chunks.reduce(wordCounter, 0), + characterCount: chunks.reduce(characterCounter, 0), + index: chapterIndex, + artifactVersionId: artifactVersion.id, + chunks: chunks.map((chunk, index) => ({ ... })), +}, { include: [{ association: db.Chapter.Chunks }] }) +``` + +Six decisions in there worth naming, because this plan either keeps or +deliberately drops each one: + +| mantra.press | here | +|---|---| +| the chapter's name is the **filename** | kept as the default, editable before proposing (§ Phase 4) | +| the index is `chapters.length + 1`, re-read from the version | allocated once for the whole upload, counting proposals as well as rows (§ Phase 3) | +| chunks are **markdown-it block tokens**, with the block marker stripped | adopted, with three deliberate departures (§ Phase 7, built in § Phase 8) | +| the uploader is the author (`creatorId`) | the **group** is the author, once a quorum signs | +| all files succeed or the request 500s | one session per chapter, each failing on its own (§ The constraint) | +| `file.data.toString()`, whatever the bytes are | UTF-8 decoded strictly, non-text refused (§ Phase 1) | + +The web app has two small bugs in that loop that are worth knowing about so they +are not faithfully reproduced. `request.files.chapters.length > 0` is `undefined` +for a single file, so one file always takes the `else` branch — harmless, but it +means the multi-file path has had less use than it looks. And +`Number(chapterIndex) + Number(index)` starts from a `chapterIndex` that is +already `length + 1`, so a second file lands at `length + 2` and the first +upload into an empty version starts at index 1 while the add-chapter form starts +at `length`. Indices here are 0-based (`ChapterEvent.build(index = …)`, written +by `AddChapterViewModel` as `getChaptersForArtifactVersion(...).size`), and this +plan keeps them 0-based. + +## What is already here + +Nearly all of it, for one chapter at a time. This plan adds no event kind, no +tag and no schema version to the chapter path. + +- [`ChapterEvent`](../composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChapterEvent.kt) + (30302) carries the whole markdown in an `original_text` tag; + [`ChunkEvent`](../composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChunkEvent.kt) + (30303) carries one paragraph, and `ChunkEvent.splitOf(chapter)` cuts the + chunks from a chapter *after* it is authored, which is the only place the + chapter id exists to be named. +- [`AddChapterViewModel.addChapter`](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt) + proposes the chapter and its chunks as one batch via + `proposeSigningBatch(lead = …, dependents = ChunkEvent::splitOf)`, then opens + a second session scaffolding the chapter into every translation of the + version. +- `ChatMessage.applySignedEvent` has both branches (`ChapterEvent.KIND`, + `ChunkEvent.KIND`), writes `groupSignedEventId` provenance, and + `GroupSignedEvent` keeps the event as the group signed it. +- [`ChapterBatchProposalJvmTest`](../composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterBatchProposalJvmTest.kt) + and `SignedChapterTest` already cover the single-chapter batch end to end. + +So the work is **not** "make a chapter group-signed". It is: get bytes off three +platforms, turn a pile of files into a reviewable list of proposals, and deal +with the four things a single hand-typed chapter gets away with and a twelve-file +upload does not. + +--- + +## The constraint + +A signing session is **one quorum, all-or-nothing, at most 64 items, and about +64 KiB on the wire**. + +Three numbers, from three places, and the plan is shaped by all three: + +- `FrostSigningManager.MAX_BATCH_SIZE = 64`, checked in `proposeSigningBatch` + *and* independently in `acceptProposal` — see + [frost-batch-signing.md](./frost-batch-signing.md). +- *All-or-nothing*: "a batch is only as available as its worst item. If any + event cannot be aggregated the session fails and none of them are applied." +- *Bytes*: the proposal carries `k` whole events, which is the term that binds. + `ChronicleEvent.MAX_PAGE_BYTES = 64 * 1024` is this codebase's measured proxy + for what a kind:445 clears "with room for the MLS framing and the NIP-44 + expansion that sit outside it". + +### The shape that follows: one session per chapter + +The web app's single transaction has no analogue. Twelve chapters in one batch +is wrong three times over: + +1. **It does not fit.** A chapter takes one item and its paragraphs take the + rest (`MAX_CHUNKS_PER_CHAPTER = 63`). Two chapters of thirty paragraphs each + already overflow. +2. **It should not fit.** A batch is only as available as its worst item, so one + chapter that fails to reach quorum would take the other eleven with it. A + book is precisely a set of things that do *not* need to arrive together. +3. **It is the wrong prompt.** Every member would be asked to read twelve + chapters' full text in one approval gate, and the gate's own rule is that it + "must show every event, not a count". + +So: **one upload is N sessions, one per chapter.** That is the honest cost and +it has to be in front of whoever is picking files, not discovered afterwards — +N proposals, each needing a quorum, arriving in the room at once. + +What it buys: a chapter that fails is re-proposable on its own, chapters land +and become readable as each quorum arrives rather than all at the end, and the +approval gate stays one chapter long. + +--- + +## Four things the single-chapter path gets away with + +Each is a real defect today, each is invisible at N = 1, and each is a phase. + +**The index is read from rows, and no chapter is a row until its quorum lands.** +`addChapter` does `getChaptersForArtifactVersion(artifactVersion.id).size` at +propose time. Propose twelve chapters back to back and all twelve read the same +size, so all twelve are signed with the same index. The same collision already +exists between two members adding a chapter at the same moment; an upload just +guarantees it. **Phase 3.** + +**There is no byte check at all.** The paragraph-count cap is enforced and shown +as you type; the size of the proposal is not checked anywhere. A proposal +carries the chapter (whole markdown) *plus* a chunk per paragraph (the same +words again), so it is roughly 2× the text before framing — which puts the +ceiling near 28–30 KiB of markdown, well inside the range of a real book +chapter. Today that is an `IllegalArgumentException`-free failure: the proposal +goes out, the group event is over size, and the session stalls. **Phase 2.** + +**There is no file picker in the app.** Nothing in `composeApp/src` references +`rememberLauncherForActivityResult`, `OpenDocument`, FileKit or any equivalent; +the only platform `expect` declarations are for the database builder, context, +theme and motion. Three targets need it: `androidTarget`, `jvm`, and the two ios +targets gated behind macOS in `composeApp/build.gradle.kts`. **Phase 1.** + +**Translation scaffolding fans out per chapter.** `addChapter` opens a second +session per chapter for the translation chapters. Twelve chapters in a group +with three dialects is twelve chapter sessions plus twelve scaffolding sessions += twenty-four proposals, where the scaffolding is 36 tiny events that fit in one +batch. `TranslationScaffold.chaptersOf` already takes a *list* of chapters. +**Phase 5.** + +--- + +## Phase 1 — a file in, as text + +**Two days, most of it the three actuals. No protocol change, no new event.** + +Ship the picker on its own, before anything knows about multiple chapters, and +prove it on all three platforms. It lands as a **"load from file" button on the +existing `AddChapterScreen`** that fills `originalTextFieldState` and the name +field: one file, straight into the form that already works, so the picker is +exercised and reviewable without the batching argument attached to it. + +### The API + +```kotlin +// commonMain +data class PickedFile(val name: String, val bytes: ByteArray) + +@Composable +expect fun rememberFilePicker(onPicked: (List) -> Unit): FilePicker + +interface FilePicker { fun launch(multiple: Boolean) } +``` + +Returned as bytes rather than a path or a stream. A path is meaningless on iOS +once the picker's security scope closes, and a stream would make the decode +decision below somebody else's problem on a background dispatcher. Chapters are +tens of kilobytes; this is not where to be clever. + +### The three actuals, or one dependency + +**Built with the three actuals, not FileKit.** The plan recommended the +dependency and the implementation went the other way, for three reasons that +only became clear with the tree open: this is the app's only dialog, the three +calls are a dozen lines each, and the project already keeps four platform seams +of exactly this shape (`PlatformContext`, `PlatformDatabaseBuilder`, +`platformReducedMotion`, `platformThemeContrast`) — so the seam was free and the +library's Compose-version coupling was not. The ios target cannot be compiled on +a linux host either way, so a dependency would not have bought verification +where verification is actually missing. + +**The three actuals**, which were a day's work and no mystery: + +| target | call | +|---|---| +| `androidMain` | `rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments())` | +| `jvmMain` | `java.awt.FileDialog` with `isMultipleMode = true` | +| `iosMain` | `UIDocumentPickerViewController(forOpeningContentTypes:)`, `allowsMultipleSelection = true` | + +Android needs no manifest permission for `OpenDocument` — it is a SAF handoff. +iOS needs the document types declared; `.md` has no registered UTI, so +`UTTypePlainText` plus `UTTypeData` and sniff afterwards. + +Three things the build added that the plan had not thought of: + +- **Both android contracts are registered**, `OpenDocument` and + `OpenMultipleDocuments`, and `launch(multiple)` picks. Registering only the + multiple one would offer a six-file picker to a form that keeps one file. +- **Every actual stops reading one byte past the cap**, not at it, so an + over-size file arrives over-size rather than silently truncated — a truncated + chapter decodes cleanly and would be proposed with its ending missing. +- **The dialog title is a parameter**, read from the catalogue by the caller, + because `stringResource` is composable and a `FileDialog` is not. Only desktop + shows it; android's and iOS's pickers title themselves. Taken on all three so + that the one platform showing text to a reader gets it from the catalogue like + every other string in the app. + +**The ios actual is unverified.** The two ios targets are declared only on macos, +so `FilePicker.ios.kt` is not compiled on a linux host and has never run. It is +written to the shape of the eight other `.ios.kt` actuals beside it and says so +in its own KDoc. A stub returning no files would have been worse: it reads as a +working picker that a reader's chapters vanish into. + +### Decoding, which is where the web app is wrong + +`file.data.toString()` on a `.docx` produces a chapter of zip header garbage, +and the group would be asked to sign it. Here: + +```kotlin +// commonMain +object ChapterText { + const val MAX_FILE_BYTES = 1 * 1024 * 1024 + + sealed interface Result { + data class Text(val markdown: String) : Result + data object NotText : Result + data object TooLarge : Result + } + + fun decode(bytes: ByteArray): Result +} +``` + +Four rules, each with a reason: + +- **Strict UTF-8.** `decodeToString(throwOnInvalidSequence = true)` and refuse on + failure. A replacement character signed into an `original_text` tag is a + permanent `U+FFFD` in the group's record. +- **Strip the BOM.** `U+FEFF` at the head of the first paragraph is invisible in + the form and changes the chunk's text, so it changes the chunk's id. +- **Normalise CRLF to LF before hashing.** `Markdown.PARAGRAPH_BREAK` is + `(?:\r?\n){2,}`, so splitting already tolerates CRLF — but the `original_text` + tag would carry the `\r`s, and the same chapter uploaded from Windows and from + Linux would be two different events. Normalise once, at the door. +- **Sniff, do not trust the extension.** A NUL byte in the first 8 KiB means + `NotText`. Cheap, catches every office format and every image. + +`MAX_FILE_BYTES` is a crash guard, not the real limit — the real one is Phase 2 +and is twenty times tighter. + +**Tests** (`commonTest`): a `ChapterTextTest` over valid UTF-8, invalid UTF-8, +BOM, CRLF-vs-LF producing identical text, a NUL-bearing payload, and the size +cap. All pure; no picker involved. 13 of them, plus 9 in a `ChapterNamingTest` +that the plan had folded into Phase 4 and which belongs here — the default name +is derived from the file, so it arrives with the file. + +`ChapterNaming` turned out to deserve its own object rather than a line in the +upload screen. The web app has two answers and uses a different one per route — +`file.name` on the upload, `chunks[0].trim()` on the paste form — and both are +right some of the time. The rule built here is: the filename with its extension +and ordinal prefix removed, falling through to the text's first heading and then +its first line when the filename is only an ordinal. `01.md` is not a chapter +title; `03 - The Wreck.md` is. + +--- + +## Phase 2 — the byte cap, in front of the user + +**Half a day. A fix to the path that exists, and a prerequisite for Phase 4.** + +A chapter whose markdown is over roughly 24 KiB cannot be proposed today and +nothing says so. Make the limit explicit and check it where the paragraph cap is +already checked. + +**Built differently, in three ways, and the measurement is why.** Encoding real +proposals gave a per-event overhead of 357–388 bytes across chapters from 1 KiB +to 24 KiB and from 1 to 63 chunks — the plan's ~370 estimate, arrived at for the +wrong reason: a proposal carries *unsigned* events, so there is no 128-character +signature in it, and the figure is instead an id, a pubkey, an empty `sig`, the +JSON structure and, on a chunk, the 64-character chapter id it points at. + +What that measurement changed: + +**One. A flat `MAX_ORIGINAL_TEXT_BYTES` is wrong by half.** Measured, a +63-chunk chapter fits about 20 KiB of markdown and a 10-chunk chapter fits about +30, because the envelopes are what varies. A single constant safe for the first +refuses a third of the second's text for nothing; one sized for the second +stalls sessions. The chunk count is known as the text is typed, so the limit may +as well be — `ChapterProposal.proposalBytes(text, chunks)` and +`maxOriginalTextBytes(chunkCount)` instead of a constant. + +**Two. The limit belongs to the manager, not to the chapter.** +`FrostSigningManager.MAX_PROPOSAL_BYTES = 64 * 1024`, beside `MAX_BATCH_SIZE`, +enforced in `proposeSigningBatch` against the real encoding and *independently* +in `acceptProposal` — before parsing, since that is the cheaper refusal. The +count cap already had exactly this two-sided treatment for exactly this reason: +the proposal is where a remote party decides how much work everybody else does, +and until now its *size* was unbounded. Both propose forms funnel into the +`lead`/`dependents` one, so a single check covers them. + +**Three. `ChapterProposal` estimates what the manager measures.** The exact +answer needs the chapter built and the chunks cut, which is one line from +opening a session — too late to tell somebody with a cursor in a text field. +So: a cheap estimate for the form at 448 bytes per event, rounded up from the +measured 388, and a test asserting the estimate never falls under the real +encoding. Over-estimating costs an author a slightly shorter chapter; +under-estimating costs the group a stalled session, so the rounding goes that +way on purpose. + +**The number is still provisional until Phase 8 lands.** Block splitting +produces more chunks than blank-line splitting, so the same prose carries more +envelopes; `ChapterProposalSizeTest` pins where the fit runs out (24 KiB over +forty paragraphs), so that phase will move a pinned figure rather than quietly +narrow the limit. + +Where it goes: + +- `AddChapterViewModel.addChapter` — beside the + `paragraphs.size > MAX_CHUNKS_PER_CHAPTER` guard, same early `onFailure`. +- `AddChapterScreen` — the counter line already reads + `"$wordCount words · $characterCount characters · $paragraphCount chunks of N"`; + it gains the proposal's size against the cap and turns `error`-coloured on the + same rule the chunk count does. The message compares like with like — what + proposing would send, against what a group message carries — rather than a + character count against a byte budget, which was the first draft of it and was + comparing UTF-16 units to bytes. +- The two existing hardcoded English literals on that screen (the no-shared-key + message and the too-many-chunks message) move into + `composeApp/src/commonMain/composeResources/values/strings.xml` while the file + is open. They are UI text in a composable and `CLAUDE.md`'s budget is zero + title-case strings with literals reported; a third one added by this work + would be the wrong direction. + +**Tests:** a `ChapterProposalSizeTest` in `commonTest` — pure, since the +estimate and the encoding are both pure — over the grid the overhead was +measured on, asserting the estimate never falls under the real encoding and +stays within a tenth of it. Plus the round trip the form's offer rests on: the +longest text the estimate allows at the worst chunk count really does encode +under the cap. Plus the boundary pinned as a number, so a tag added to either +kind moves a figure in a test rather than producing a quorum that never +arrives. And the byte-vs-character one, in Amharic, because an ASCII-only test +suite would never have caught the UTF-16 slip above. + +--- + +## Phase 3 — an index that counts proposals, not rows + +**A day. A fix to the path that exists.** + +The index is signed into the event and cannot be recounted on arrival — the +comment in `addChapter` says exactly why: "a device applying it cannot recount +from a version other members may have added to in a different order". So the +number has to be right at proposal time, and "rows in the table" is not it, +because a proposed chapter is not a row for as long as the quorum takes. + +The information is already available. `FrostSigningRepository` exposes +`observeSessionsForChatRoom`, `getItems(sessionId)` and `unsignedEvents(items)`, +and the last of these exists precisely for this: "A caller wanting an id … needs +it while the first session is still running, and the id is settled at proposal +time." + +**Built as `ChapterIndexing.nextIndex`**, an object beside `TranslationScaffold` +rather than a method on `MantraRepository`, because it needs both repositories — +the table for what is signed and the signing repository for what is proposed — +and a repository method that reaches into another repository is the wrong shape. +It takes both, which also makes it testable without a view model. + +Implemented as: rows for the version, plus the unsigned `ChapterEvent`s of every +session in the room whose `artifactVersionId()` matches, and `maxOf(index) + 1` +rather than a count — a count is wrong the moment a gap exists, and after a +chapter fails to reach quorum a gap is exactly what there is. + +Three details the build settled that the plan left open: + +- **Every session counts, whatever stage it is at.** Filtering to open ones + would trade a harmless gap (a failed session's index is spent, since a + re-proposed chapter is a different event) for a possible collision with a + stalled session that still completes. +- **Items are filtered by kind, which is not optional.** A chapter proposal is a + batch, and a chunk carries an `index` tag too — its position in the chapter. + Reading those as chapter indices would put the second chapter of a + two-paragraph first chapter at 2. There is a test for exactly that. +- **Proposals are not scoped by version and rows are.** The row query takes an + `artifactVersionId`; `observeSessionsForChatRoom` returns the whole room, so + the `artifactVersionId` tag is what separates one version's proposals from + another's. Getting it wrong would push every version's numbering up by the + whole room's. + +Failing soft on purpose: if the proposals cannot be read, the table's answer is +still better than nothing, and refusing to propose a chapter because its number +could not be checked would be the wrong trade. + +Then `AddChapterViewModel` calls it instead of `.size`, and the upload allocates +`base + i` for its i-th file **once**, before proposing anything, rather than +re-asking between sessions. Re-asking would work and is worse: it makes the +upload's numbering depend on how fast the sessions are written. + +**What this does not fix.** Two *devices* proposing at the same moment still +collide — neither sees the other's proposal until it arrives. That is a +distributed-allocation problem and the honest answer is that index collisions +are tolerable: the index orders reading, two chapters at index 4 sort +arbitrarily between themselves and nothing else breaks. Worth a sentence in the +code so the next reader does not take the local fix for a global one. + +**Tests** (`ChapterIndexingJvmTest`, beside `ChapterBatchProposalJvmTest`): +propose three chapters back to back without completing any session, and assert +the three indices are 0, 1, 2 — which is the test that fails today, since +nothing is signed so the table answers 0 three times. Plus the gap, the two +versions, and the chunk-vs-chapter confusion above. + +--- + +## Phase 4 — the upload screen + +**Three days. The feature.** + +`UploadChaptersRoute` / `UploadChaptersUIState` / `UploadChaptersViewModel` / +`UploadChaptersScreen`, registered in `MantraNavHost`, reached from +`ArtifactDetailScreen` next to the existing "add chapter" `TextButton` +(`ArtifactDetailScreen.kt:165`), with the same `enabled = versions.isNotEmpty()` +condition. + +### It is a review step, not a submit button + +This is the one place the plan departs visibly from the web app, and the reason +is that a signature is not a submission. mantra.press can afford to post twelve +files and redirect, because what it created is the uploader's and the uploader +can fix it. Here, twelve files become twelve requests for the group's own +signature, and anything wrong in them — a filename for a title, a file that +decoded to nothing, a chapter over the byte cap — is wrong in something a quorum +is about to put its name to. + +So picking files does not propose anything. It produces a list, one row per +file: + +| shown | from | +|---|---| +| name, **editable**, defaulting to the filename without its extension | `PickedFile.name` | +| the index it will take | `base + i` from Phase 3 | +| chunks, against `MAX_CHUNKS_PER_CHAPTER` | `Markdown.splitParagraphs(...).size` | +| words, characters, bytes against `MAX_ORIGINAL_TEXT_BYTES` | `Markdown`, Phase 2 | +| a per-row problem, when there is one | below | +| a remove action | — | + +Four per-row problems, each blocking that row only — a bad file should not +cost the other eleven: + +- not text, or not UTF-8 (`ChapterText.Result.NotText`) +- empty after trimming, which is a chapter with nothing to translate +- over the chunk cap, with the advice to split it +- over the byte cap +- **duplicate**: its normalised text matches an existing chapter of this version + or another row in this list. Not blocking — a book can repeat a page — but + warned, because 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. + +A per-file name field rather than one derived from the text, because the +filename is right often enough to be the default and wrong often enough +(`chapter-01.md`, `ch1_final_v2.md`) that it must be fixable before a quorum +sees it. Where it is empty, fall back to the first markdown heading in the text, +then to the first paragraph trimmed to a line — which is what the web app's +*other* route does (`name: chunks[0].trim()`), and is better than either +extreme. + +### Proposing + +The FAB — `ExtendedFab`, as every primary action in the app is — reads +"propose N chapters", is disabled when any included row has a blocking problem +or `canSign` is false, and on tap: + +``` +for each row, in index order: + propose(chapter + chunks) // the existing batch, one session per chapter + record the session id +then: + propose the scaffolding for all N chapters at once // Phase 5 +then: + navigate to the proposal list, not to a signing screen +``` + +**Sequentially, not concurrently.** Each `proposeSigningBatch` writes a session +and publishes group events; N at once is N interleaved MLS epochs, and +[mls-skipped-keys.md](./mls-skipped-keys.md) is about what a group event that +arrives a moment out of order costs. The upload is not in a hurry. + +**Partial success is the expected outcome, not an error.** If the fourth +proposal fails, the first three are real proposals that the group can still +sign, and nothing can retract them. So the screen does not roll back and does +not navigate away on a failure: it marks the rows that were proposed, leaves the +rest listed with their reason, and offers "propose the remaining". Phase 3's +`nextChapterIndex` means a retry re-reads the base and does not re-use the +indices already proposed. + +**It navigates to `ProposalListScreen`, not to `FrostSigningRoute`.** The single +chapter path goes to the signing screen because there is one session to watch, +and the reasoning in `addChapter` is sound for it ("landing on the list it is not +in would read as a failure"). At N there is no one session, and the list is the +screen that exists for "two proposals need a place that shows both". A snackbar +via `rememberNotifier` carries the count, read above the handler as `CLAUDE.md` +requires. + +### Scope, and what it must say out loud + +Before the picker opens, the screen says what an upload will cost the room: one +proposal per file, each needing a quorum. This is not a nicety. The app's +approval gate is deliberately per-session and deliberately shows every event, +and twelve of them arriving at once is a real imposition on every other member +that the person picking files is the only one in a position to weigh. + +A soft cap belongs here too — refuse to propose more than, say, 20 files in one +go, and say why. Not a protocol limit; a limit on how much of the room's +attention one tap may spend. + +### M3, per `CLAUDE.md` + +`NavigateBackButton` in the app bar's leading slot (it is a pushed screen); +`readableContent()` on the content root; all four states, with `EmptyState` +carrying its own message for "no files picked yet" and `ErrorState` taking a +real `onRetry`; `ScreenStateTransition` wrapping the `when` as the composable's +whole body; spacing from `MaterialTheme.spacing`; every string in `strings.xml` +in sentence case with `%1$s`/`%1$d` arguments; every icon with a decided +`contentDescription`. Finish with `./gradlew :composeApp:m3Audit`. + +--- + +## Phase 5 — scaffold once, not once per chapter + +**Half a day. A fix to the path that exists, and an N-fold saving.** + +`AddChapterViewModel.scaffoldIntoTranslations` is right about needing its own +session — a chapter's paragraph budget should not be spent on the group's +dialects — and wrong about needing one *per chapter*, which only ever happens +during an upload. + +`TranslationScaffold.chaptersOf(translationArtifactVersionIds, chapters, +createdAt)` already takes a list and already builds the full cross product in +translation-then-reading order. So lift the scaffolding out of the per-chapter +loop into `ChapterScaffolding.propose`, which both view models now call: +collect each chapter's `SourceChapter(id, index)` from its session's unsigned +lead event, and after the last chapter is proposed, propose the whole cross +product in `chunked(MAX_BATCH_SIZE)` batches. + +Twelve chapters × three dialects: from 12 scaffolding sessions to 1. + +**And it fixes a latent bug that one chapter could never show.** The old code +chunked the *translations* — right by coincidence at one chapter, where the +cross product and the translation list are the same length, and wrong at any +more: 25 chapters in a three-dialect group is 75 rows, which the translation +axis would have put in a single batch of 75 and `proposeSigningBatch` would have +thrown. Chunking the product is the generalisation, and the test for it is the +only one here that would fail against the per-chapter shape. + +Two smaller decisions the build had to make: + +- **One timestamp for the batch, the latest of the chapters' own.** + `chaptersOf` stamps every row with one `createdAt` so a scaffolding proposed + as one act reads as one; with N chapters there are N candidates. The latest is + the moment the act finished. Reading the clock instead would be a third time + unrelated to either end of it. +- **A session whose lead event cannot be read is left out, not guessed at.** A + join row naming the wrong chapter is worse than a missing one, because the + catch-up can find a missing one. + +Keep the existing failure policy verbatim, including its reasoning: scaffolding +failure is logged and swallowed, because the chapters are the thing that was +asked for, and a scaffolding row naming a chapter that never reached quorum +fails a foreign key, is logged by `applySignedEvent`, and never becomes a row. +The catch-up on the translation fills the gap afterwards. At N that argument gets +*stronger*, not weaker — one cross-product batch covering twelve chapters is +only as available as its worst item, and some of those chapters may genuinely +fail. + +`AddChapterViewModel` keeps its one-chapter call into the same helper, so there +is one piece of code deciding what a chapter's scaffolding is. The upload calls +it once, after its loop, with only the chapters that actually went — so a +chapter that failed to propose contributes no scaffolding and the ones that +succeeded still get theirs. + +**Tests:** `ChapterScaffoldingJvmTest`, six of them, over a real database and +real proposals. The arithmetic (12 × 3 = one session of 36 rows), the ids coming +from the chapters' own sessions, a chapter keeping its own index rather than the +list's, the cross-product chunking at 25 × 3 = 75, and the two empty cases. + +--- + +## Phase 6 — N proposals, in the room + +**A day. No new screens.** + +The transcript and the proposal list were built for two concurrent sessions — +`ChatMessage.frostSigningSessionId` exists because matching a request against +"any later line of the fulfilling type" broke the moment a chapter and its +scaffolding were open together. Twelve is the same problem at a scale that will +find whatever that fix missed. + +What to check rather than assume — and **all three already held**, so this +phase is its tests and this paragraph: + +- **The list already groups.** `ProposalListScreen` renders a "Waiting for you" + header over the sessions `FrostSigningManager.isAwaitingApproval` picks out and + an "Everything else" header under them, over a query that is already + `ORDER BY createdAt DESC`. Twelve proposals arrive sorted and the three that + need the reader are at the top. +- **The summary already names the chapter.** `ProposedEvent.summarize`'s chapter + branch is `name · N words · N chunks`, not "Chapter" — so twelve rows are + twelve distinguishable rows. +- **`isAbout` already matches on the session.** `answeredRequests` and + `settledRequests` compare `frostSigningSessionId` when both rows carry one and + fall back to the clock only when either does not. Now tested at twelve, with + the answers given in the opposite order to the asking so that neither the + clock nor the list order could produce the right answer by accident, plus one + failure settling only its own row. +- **Twelve approval gates, unbatched.** Unchanged by design: the gate shows every + event rather than a count, because "a member who cannot scroll the whole list + should not be able to approve it". + +No new `ChatMessage` type, no new set to add to. + +### One thing N does make worse, and it is Phase 8's to fix + +`observeSessionsForChatRoom` carries every session's items, and a chapter item's +`unsignedEventJson` holds the whole markdown. Twenty open chapter proposals is +roughly 900 KB of JSON parsed and summarised **on every emission** — and +`summarize` then re-splits each chapter's text to count its chunks, which is the +derived count Phase 8 replaces with the session's own item count. Fixing it +there removes the re-split; the parse is inherent to the query and is the price +of the list showing what each proposal signs without one query per row. + +--- + +## Phase 7 — the splitter, which is a decision about translation + +**Half a day, and it is a measurement rather than code.** + +mantra.press splits with markdown-it block tokens; this app splits on blank +lines. + +```js +// mantra.press: one chunk per block-level token that carries content +md.parse(text).filter(t => t.content).map(t => t.content) +``` + +```kotlin +// here: one chunk per blank-line-separated block +text.split(Regex("(?:\\r?\\n){2,}")).map { it.trim() }.filter { it.isNotEmpty() } +``` + +They were assumed to agree on ordinary prose and to disagree only on converted +files. Run side by side over a corpus — the reference parser as +mantra.press configures it, against the Kotlin rule — they disagree on **almost +everything**, and on two separate axes: + +| input | markdown-it | `splitParagraphs` | +|---|---|---| +| `# Chapter one` ⏎ `First paragraph.` (the converter's shape) | 3 chunks, heading apart | 2 chunks, heading glued to the prose | +| `# Chapter one` ⏎⏎ `First paragraph.` | `"Chapter one"` | `"# Chapter one"` | +| a tight three-item list | 4 chunks — the lead-in and each item | 2 — the lead-in and the whole list | +| `> line one` ⏎ `> line two` | `"line one\nline two"` | `"> line one\n> line two"` | +| a fenced code block | `"code();\n"`, fence markers gone | ``"```\ncode();\n```"`` | +| a 2 × 2 table | 4 chunks — one per cell | 1 — the table's source | +| `---` between two paragraphs | 2 chunks, the rule is not one | 3 — the rule becomes a chunk | +| `Title` ⏎ `=====` (setext) | `"Title"` | `"Title\n====="` | +| a nested list | 2 chunks, one per item | 1 | +| a paragraph with a lazy continuation line | 1 chunk | 1 chunk — agree | + +One row in eleven agrees. The table is reproducible in a dozen lines of node +against `mantra.press/node_modules/markdown-it`, and Phase 8 turns that into +checked-in fixtures. + +**Axis one is boundaries**: how many chunks, which is the obvious half. +**Axis two is the text itself**, which is the half that was missed: markdown-it's `content` +is the leaf block's inline source with the *block* marker removed — no `#`, no +`> `, no `- `, no fence, no setext underline — while inline markup (`*italic*`, +a link's brackets) is kept verbatim. So even the rows where the counts agree +produce different chunk text, and therefore different chunk ids. + +A chunk is the unit a translation is of and the thing the group signs. Axis two +is the one that matters to a translator: handed `"# Chapter one"` they can break +the document by mistyping the `#`, and handed `"Chapter one"` they cannot. + +**The decision: adopt markdown-it's block tokens, boundaries and stripped text +both, for one splitter shared by the upload and the paste form.** The +alternative — markdown-it semantics for uploaded files, blank lines for typed +ones — means the same text yields different chunks depending on how it arrived, +which is indefensible the moment two members do it differently. + +### Three places to diverge from mantra.press on purpose + +Faithful is not the goal; the right unit of translation is. Three of +markdown-it's answers are wrong here, and each should be decided now rather than +inherited. + +**Code blocks should not be chunks.** `fence` and `code_block` carry content, so +mantra.press turns `code();` into a chunk — with a trailing `\n` in its text — +and asks a translator to translate it and the group to sign it as prose. Emit no +chunk for either; the code stays in the chapter's `original_text` and the +producer passes it through untouched. + +**A table should be one chunk, not one per cell.** Four chunks for a 2 × 2 table +is markdown-it's honest reading — cells do contain prose — but each costs an +event with a 64-character id, a 64-character pubkey and a 128-character +signature to carry one word, and a table's alignment is structure that per-cell +translation cannot preserve anyway. One chunk of table source, and the +translator keeps the pipes. + +**A link reference definition should not be a chunk.** mantra.press's parser is +`markdown-it().disable(['link', 'reference'])`. `link` is an *inline* rule, so it +changes neither the block tokens nor an inline token's content; `reference` is a +*block* rule, and disabling it stops `[ref]: https://example.com` being consumed +as a definition, so it falls through to `paragraph` and becomes a translatable +chunk of a URL. That is a side effect of reaching for the wrong rule name, not a +decision. Recognise the form and emit nothing. + +### Why the change is safe to make at any time + +No device ever re-derives chunks from a chapter's text. `ChunkEvent.splitOf` is +called at proposal time only — `AddChapterViewModel` and tests, nowhere on the +apply path — and the chunks travel as their own signed events; +`ChatMessage.applySignedEvent` writes the rows it is given, and the chronicle's +fallback rebuilds them from rows via `toXEvent()` rather than by splitting +anything. So a device on an older splitter receiving a newer-split chapter +applies the chunks it was handed, and there is no skew to negotiate, no +migration, and nothing to re-sign. + +What does *not* change retroactively: chapters already signed keep the chunks +they were signed with. Re-splitting one later means re-proposing it as a new +chapter with new ids. So this lands before the first real upload, not after. + +--- + +## Phase 8 — a block splitter that agrees with markdown-it + +**Three days, and the fixtures are most of the value.** + +The implementation of Phase 7's decision. One new function, five call sites +moved together, one thing the app has never had, and an oracle instead of an +opinion. + +### The function + +```kotlin +// press.mantra.compose.text.Markdown +/** + * The blocks a chapter's markdown splits into, one chunk each. + * + * A chunk is a *leaf* block's inline text with its block marker removed: the + * words a translator is given and the words the group signs. Containers -- + * lists, blockquotes, tables -- contribute nothing of their own; a thematic + * break and a code block contribute nothing at all. + * + * This is markdown-it's block token stream, which is what mantra.press + * translates against, with the three deliberate departures in + * docs/chapter-uploads.md. Inline markup is kept verbatim, because emphasis and + * links are the translator's to carry; block markers are not, because a `#` + * mistyped into a translation breaks the document. + * + * Its output is hashed into event ids a quorum signs, so it is pinned by the + * golden fixtures in MarkdownBlockFixtures rather than by this comment. + */ +fun splitBlocks(text: String): List + +data class Block( + val kind: Kind, // Paragraph, Heading(level), ListItem, Quote, Table + /** The block marker, kept so a document can be put back together. */ + val markup: String, + /** The inline source, marker removed. This is the chunk's text. */ + val text: String, +) +``` + +A `Block` rather than a `String`, because Phase 7's stripping decision means the +`#` and the `- ` have to be kept *somewhere* for the producer below, and the +chapter's `original_text` is the wrong place to look them up from — matching +text back to its marker is the string-equality bug described two sections down. + +The block starts to recognise, which is markdown-it's block ruler minus the +rules Phase 7 drops: ATX heading, setext heading, fenced code (skipped), +indented code (skipped), blockquote, bullet and ordered list items including +nesting, thematic break (skipped), table (one block), link reference definition +(skipped), paragraph with lazy continuation. + +### Hand-rolled, with the reference implementation as the oracle + +`org.jetbrains:markdown` is multiplatform and CommonMark-compliant and would do +the parsing. It is the wrong dependency *here*, for one reason that is specific +to this function: its output is hashed into ids that a quorum signs, so a +version bump that moves one boundary silently re-splits every chapter proposed +afterwards, and nothing in the app would notice. A hand-rolled scanner over the +block starts above is a few hundred lines whose behaviour is ours to pin. + +That is only defensible with an oracle, so: + +**`MarkdownBlockFixtures` — input/expected pairs generated by markdown-it +itself, checked in as test data.** A script under `docs/scripts/` runs the +reference parser over a corpus (the converter's output, a hand-typed chapter, the +pathological cases in Phase 7's table) and emits the expected blocks; the +`commonTest` suite asserts `splitBlocks` reproduces them, with the three +departures listed as explicit exceptions rather than silently absent. Fidelity +is then measured against the implementation it claims to agree with, which is +the same standard `MAX_PAGE_BYTES` sets for itself. + +Being hand-rolled, it is a documented *subset* of CommonMark and should say so: +no HTML blocks (markdown-it's default is `html: false` and the `
` in the +corpus comes back as a paragraph, so this matches), no footnotes, no nested +blockquote-in-list beyond one level. Each omission is a fixture asserting what +happens instead, not a gap. + +### The producer, which the app has never had + +Stripping block markers from chunks is only safe if a translated chapter can be +put back together, and nothing in `composeApp/src` does that today — there is no +equivalent of mantra.press's `lib/markdown-producer/index.js`. It is part of this +phase, because Phase 7's decision is incomplete without it. + +```kotlin +object MarkdownProducer { + /** The chapter's markdown with each chunk's translation in place of its text. */ + fun produce(originalText: String, translations: Map): String +} +``` + +Walk `splitBlocks(originalText)`, substitute by **index**, and reassemble with +each `Block.markup`. Substituting by index rather than by text is the one place +this is strictly better than the thing it mirrors: mantra.press matches with +`translationChunks.find(tc => tc.text == token.content)`, so a chapter with two +identical paragraphs puts the first one's translation into both. Chunks here +already carry an `IndexTag`, so position is available and exact. + +Two outputs, as mantra.press has: markdown, and whatever the reader needs. Only +markdown is in scope here — the app renders chunks itself today — but the walk +is shared. + +### Five call sites, moved together + +`splitParagraphs` is called from five places and they have to agree, or the +count shown to a member differs from the chunks in the batch they are approving: + +| call site | change | +|---|---| +| `ChunkEvent.splitOf` | splits into `Block`s, takes `.text` for the chunk | +| `AddChapterViewModel.addChapter` | the cap check counts blocks | +| `AddChapterScreen` | the live counter counts blocks | +| `ProposedEvent.summarize` | **stops splitting** — see below | +| `ChapterChunkSplitTest` | its parity assertion still holds, against the new function | + +`splitParagraphs` goes rather than delegating. A second splitter left in the +codebase is a second answer to "what is a chunk", and the next caller will pick +whichever they find first. + +**`ProposedEvent.summarize` has a latent bug this phase should fix rather than +port.** It reports a proposed chapter's chunk count by re-splitting the +chapter's text (`ProposedEvent.kt:77`), so the number a member reads in the +approval gate is derived rather than observed — and across a splitter change it +is simply wrong, since the batch was built by the proposer's splitter and +counted by the reader's. The count is sitting right there: the session's items +minus the lead. Count those. + +### What it costs, and the phase it moves + +Block splitting produces **more** chunks than blank-line splitting — every +heading and every list item is its own now — so both of Phase 2's limits bind +sooner. A list-heavy chapter can double or triple its chunk count against +`MAX_CHUNKS_PER_CHAPTER = 63`, and every extra chunk adds an event envelope +(~370 bytes before it says anything) to the proposal. + +So **`MAX_ORIGINAL_TEXT_BYTES` must be measured after this lands, not before.** +Either take this phase ahead of Phase 2, or treat Phase 2's constant as +provisional and re-run its encoded-proposal measurement here. The second is +cheaper and the measurement is the same test either way. + +**Measured, and it did not move.** `ChapterProposalSizeTest`'s pinned boundary — +24 KiB of markdown over forty paragraphs — is identical before and after, for a +plain reason the warning above missed: prose separated by blank lines splits the +same way under both rules, so the fixture that pins the number is exactly the +shape the change does not touch. What *is* true is the narrower claim: a +list-heavy or heading-heavy chapter now reaches both caps sooner than the same +bytes of plain prose would, which is a real effect on real chapters and shows up +in the form's own counter rather than in a constant. + +### What else came out of it + +Three things the plan had not separated out: + +- **`splitBlocks` returns blocks, not strings.** The marker has to be kept + *somewhere* for the producer, and the chapter's `original_text` is the wrong + place to look it up from — matching text back to its marker is the + string-equality bug this phase fixes elsewhere. So `MarkdownBlock(kind, + markup, text, indent)`, and `splitChunks` for the callers that only want the + words. +- **The scanner segments the whole text, not just the chunks.** + `MarkdownSegment` covers every line, with `block = null` for a fence, a + thematic break, a reference definition or a blank line. That is what makes the + byte-for-byte round trip possible at all: an untranslated chunk is reassembled + from its own source lines rather than re-rendered from its parsed form, so the + round trip is exact by construction rather than by getting every markup rule + right. +- **A blockquote containing a blank `>` line is two chunks, not one.** + markdown-it reads it as two inline runs, so the scanner re-segments it per + inner paragraph — and the fixture for it is the one that would have caught + this being missed. + +### Tests + +`commonTest`, all of it pure: + +- `MarkdownBlockTest` against the generated fixtures — the bulk of it, and the + part worth reviewing. +- the three departures, asserted as departures: a fenced block and an indented + block yield no chunk, a table yields one, a reference definition yields none. +- inline markup survives: `*italic*` and `[text](url)` are in the chunk's text. +- `MarkdownProducerTest`: round-trip with no translations reproduces the input + byte for byte **over all thirty fixtures** (the assertion that catches every + markup-reassembly bug at once), and a chapter with two identical paragraphs + translated differently comes back with both translations in the right places — + the case mantra.press gets wrong. Plus the markers going back on: hashes, list + markers and indents, a blockquote re-quoted line by line so a translation that + grew a line is still quoted, and a setext underline re-cut to the length of + the translated title. +- `ChapterChunkSplitTest` keeps its existing round-trip and id-stability + assertions, now with the converter's output shape as a fixture. + +--- + +## Phase 9 — tests and rollout + +**A day, and the list is short because most of it is in the phases.** + +New, per phase: `ChapterTextTest` (decode), the byte-cap boundary and the +encoded-proposal measurement in `ChapterBatchProposalJvmTest`, the +three-proposals-in-flight index test, `MarkdownBlockTest` against the generated +fixtures and `MarkdownProducerTest`'s byte-for-byte round trip, and a +`ChapterUploadJvmTest` beside the +existing batch tests that drives the view model over a real in-memory database +and asserts the whole shape: N files in, N sessions out with one lead chapter +each, indices `base..base+N-1`, chunks matching each file's paragraphs, one +scaffolding session for the lot, and — the one that matters — that a failure on +file 4 leaves files 1–3 proposed and recoverable rather than rolled back. + +`jvmTest`, not `commonTest`, because it is Room-backed; `./gradlew +:composeApp:jvmTest`, per the project's build notes. + +**What the end-to-end test found**, which is the reason it was worth writing +rather than assuming the parts composed: `propose` with nothing proposable +returned without ever calling its callback. The FAB is disabled in that case so +the screen never reached it, but a callback that sometimes never fires is a trap +for the next caller. It now fires `onDone(0, 0)`. + +**And one thing it could not test, for a good reason.** The plan asked for "a +failure on file 4 leaves files 1–3 proposed and recoverable". That is +unreachable by *input*: both caps are checked in the form against the same +functions the manager enforces, and `ChapterProposalSizeTest` asserts the form's +estimate is never under the manager's measurement — so no file can pass the +review and then fail the proposal. It is reachable by *state*, which is what the +test does instead: a room with no shared key fails all three, every row says so +and stays retryable, and a second pass after the key arrives proposes all three +numbered from zero. The failed pass spent no indices, because it created +nothing. + +Total: 1030 tests, and the counts per phase are in each commit. + +### The five gaps a coverage pass found afterwards + +Counting tests per file rather than per feature turned up five things with none, +and one of them mattered: + +- **`MAX_PROPOSAL_BYTES` was enforced and never tested, on either side.** The + inbound check is the half that counts — the proposal is where a remote party + decides how much work everybody else does — and it had nothing. Now four + tests, propose and accept, bytes and count, in `SignedGroupKeyStateTest` where + the adversarial inbound tests live. The count cap's accept side was missing + too, although the propose-side test's own comment already claimed it. +- **Two of those tests passed vacuously when first written**, which is the part + worth remembering. `acceptProposal` rebuilds every event under the room's path + and drops the proposal if an id does not match its fields, so a hand-built + event with an invented id is refused whatever its size — and an `assertNull` + then passes with the cap deleted. The fix is `authoredProposal`, which takes + its events from real proposals so the ids are the ones the manager computes, + plus a control asserting that a well-formed proposal of the same shape *does* + open a session. The control failed on the first run, which is how this was + found; commenting the guard out now fails exactly the test named after it. +- **`UploadChaptersScreen` had no UI test**, against a project that has fifteen + `runDesktopComposeUiTest` files and tests other screens this way. Ten now: the + cost stated before the picker, the index and name per row, every per-row + reason rendering, and the FAB counting only what it would actually propose. +- **`canPropose`, `kibibytes` and the chapter summary** had none either, being + read only by composables. Eleven, five and five. + +The gap that stays open is the three `FilePicker` actuals, which need +instrumentation this project does not have — and on iOS a host that can compile +them. + +Rollout needs no flag. The chapter and chunk kinds, their tags and their apply +branches are unchanged, so an older build receives an uploaded chapter as the +chapter it is. Two things do change what a peer sees, and neither needs +negotiation: a room will see N proposals where it used to see one (which an +older build handles — it is N ordinary sessions), and Phases 7–8 change chunk +boundaries and chunk text for new chapters only — safely, because nothing +re-derives chunks from a chapter's text: `splitOf` runs at proposal time and the +chronicle rebuilds from rows. The byte cap of Phase 2 is the only +change that makes the app *refuse* something it used to attempt, and what it +used to attempt was a session that stalled. + +Order: 1 → 2 → 3 before 4; 5 and 6 after 4; 7 → 8 whenever, but before the +first real upload, since a chapter already signed keeps the chunks it was signed +with. Phases 2, 3, 5 and 8 are each shippable alone and each fix something that +is wrong today at N = 1 — the index collision, the unchecked proposal size, the +scaffolding fan-out and the splitter — which is the argument for landing them as +their own commits rather than inside the feature. + +--- + +## Appendix — considered and rejected + +**One session for the whole upload.** The three reasons are in § The constraint. +The size cap alone kills it at two chapters. + +**One chapter event per upload, with the files concatenated.** Fits, signs once, +and destroys the thing being modelled: a chapter is the unit of work assigned to +a translator, and `ChapterEvent` carries one index. + +**A manifest: sign one event committing to N chapter ids, derive the chapters +from it.** Cheapest option on this page, and already argued down in +[frost-batch-signing.md](./frost-batch-signing.md) — each chapter stops carrying +its own verifiable signature, and the point of +[shared-key-derivation.md](./shared-key-derivation.md) is that a reader holding +one signed thing can check it against the room it was found in without a lookup. +Chapters are read apart; they get their own signatures. + +**Derive the chunks on arrival instead of signing them.** This is what the app +did before batch signing, and it was deliberately changed: "a chunk that can be +checked on its own is worth more than one that can only be checked by +re-deriving it". It would double the number of uploadable paragraphs, which is +not worth it. Note the asymmetry, though — it *is* the right answer for the +translation scaffolding, which is why that is a separate session rather than +separate events. + +**A remote upload: post the files somewhere and have the group sign a URL.** No. +An artifact's `url` tag is a reference to a published work; the text the group +signs is the text, and a chapter whose words live behind a link is a chapter the +group has not actually agreed to. + +**An uploads table to make an interrupted upload resumable.** A schema version +for state that Phase 3 can read off the open sessions instead. The proposals are +the record of what was uploaded; `unsignedEvents` is how to read it. If resume +ever needs more than "re-pick the files and the duplicate check tells you what +already went", revisit — but it will need a reason that is not available today. + +**Mirroring mantra.press's xlsx import** (`POST /projects/:id/import`, +`server/router/projects/index.js:1565`) — a spreadsheet of translations rather +than chapter markdown. A different feature on the translation side of the +hierarchy, with the same batching argument and a different parser. Out of scope +here, and the plan above is the shape it would follow. diff --git a/docs/project-feature.md b/docs/project-feature.md new file mode 100644 index 00000000..8c06efc8 --- /dev/null +++ b/docs/project-feature.md @@ -0,0 +1,1077 @@ +# Projects + +Phases 0 through 7 are built. Phases 8 and 9 -- removing something from a +project, and projects that span rooms -- are not, and the reasoning for both is +below rather than anywhere else. + +**Where the build chose differently**, each of which is explained in the phase +it belongs to: + +| the plan said | the build did | why | +|---|---|---| +| a `creator` tag and an `owner` tag | a `creator` tag, and owners as `ContributorTag`s with `ROLE_OWNER` | a project has several owners, which is the cardinality `ContributorTag` is for | +| the room's project section is new | it replaced a stub | `ChatRoomDetailScreen` already had a heading, a permanent empty line and an `ImplementationPendingRoute`, under a `// TODO: Add projects...` | +| the `summarize` resolver is Phase 4 | the seam is Phase 4, the resolution is Phase 7c | two of its four call sites are composables and cannot await a database, so the names are pre-resolved in `FrostSigningViewModel` | +| three chronicle tests to add | three to add and four to fix | the existing suite compares a seeded room against `CHRONICLABLE_KINDS` itself, and one test asserts every chroniclable kind can also be *rebuilt* -- which projects deliberately cannot | +| around twenty new strings | eighteen, three of them already there | `projects`, `no_projects_exists_in_this_group` and `create_project` came with the stub | +| schema 21 | schema 22 | `mantra` took 21 for the subgroup parentage table while this branch was open; Phase 0 said to check for exactly this and checked the sibling fork rather than the base | +| the apply arms catch the foreign key violation | they let it out | `ChronicleManager.sweep` retries a page only on a *counted* failure, so catching it loses a membership that crosses a page boundary — and `applySignedEvent`, the reason for catching, already has its own try/catch | + +The one thing found while building that the plan did not anticipate: a project +reaches a chronicle only off the `GroupSignedEvent` record, because there is no +`toProjectEvent` rebuild. `recordSignedEvents` catches its own failure so that a +ceremony that succeeded is not reported as failed, which leaves a narrow window +where a project row exists and its signed event does not -- a project this +device can show and can never hand on. `ChronicleAssemblyJvmTest.a project is +only ever chronicled off the record` holds that case open rather than letting it +be discovered later. + +`mantra-db-models` — the platform's server-side Sequelize schema — has had three +models waiting for this app since before it existed: `project`, with a name and a +description, and the two join models that give it its contents, +`projectArtifactVersion` and `projectTranslationArtifactVersion`. A project is +how the platform says *this body of work*: a set of source versions to translate +from and a set of translations being produced against them, grouped under one +name so that funding, progress and membership have something to attach to. + +This app has the whole tree underneath a project already — eight nip30303 kinds +from `Artifact` (30300) down to `TranslationChunk` (30309), each one an event the +group signs with its own threshold key and a Room row derived from it. What it +has no way to say is that two of those translations and three of those source +versions are one piece of work. + +The server's schema is a relational description of a platform with accounts. This +app is a Marmot/MLS group with no accounts, no server and no authority but a +quorum of its own members. Every foreign key on `project` therefore has to be +re-answered, and saying how is most of the work this document does: one becomes a +tag of its own, one becomes a set of tags, one becomes the room, and one is out of +scope. The code to write is comparatively mechanical — the same eight-file shape +every nip30303 entity already has, three times over — and the one place it goes +quietly wrong is a role the parser has not been told about. + +## What the server's schema says, and what survives + +``` +project + id string, nanoid + name string, not null + description text, not null + creatorId -> user, not null + ownerId -> owner, not null, UNIQUE + campaign hasOne campaign + hasMany projectArtifactVersion + hasMany projectTranslationArtifactVersion + +projectArtifactVersion projectTranslationArtifactVersion + id id + creatorId -> user, NN creatorId -> user, NN + projectId -> project, NN projectId -> project, NN + artifactVersionId + -> artifactVersion, NN -> translationArtifactVersion, NN +``` + +Taking those fields one at a time: + +**`name` and `description` survive** as the substance of the thing. By this +family's convention the long text goes in the event's `content` and the short +label gets a tag of its own — `ArtifactEvent` puts the name in content because an +artifact has no longer field, `ChapterEvent` puts `originalText` there and the +name in a `NameTag`. A project has both, so it follows the chapter: `content` is +the description, and `NameTag` carries the name. + +**`creatorId` survives as a `CreatorTag` on the project event**, and the +difference between that and a column is the whole point. Every nip30303 entity a +member creates is now proposed for FROST signing rather than submitted, so the row +that lands is authored by *the room's own key* and carries no trace of who typed +it: `publicKey` is the room's id, and both Marmot columns are null on a +group-signed row. A `creatorPublicKey` column filled in from local knowledge would +be a field the signed event does not carry — which, under a signature, is the one +kind of mistake that is expensive. A `["creator", ""]` tag written into +the template *before* it is proposed is not that: it is inside the id the quorum +signs, so what lands is the group saying "this project was started by X" rather +than one device's note about who happened to insert a row. + +That is a stronger statement than the server's column, and it is worth being clear +about what kind of statement it is. The creator does not sign the project event — +the room does — so this is a **group attestation, not proof of authorship**. A +member could propose a project crediting somebody else, and a quorum that does not +read the proposal would sign it. The mitigation is not cryptographic and does not +need to be: it is that the signing screen must show the creator, which makes it one +of the things a member is agreeing to. See Phase 4. + +The proposer's identity is still recorded independently, on the +`FrostSigningSession` the proposal opened. The two can disagree, and when they do +the session is the fact about who acted while the tag is the group's claim about +whose work it is. Those are different questions and both are worth keeping. + +**`ownerId` survives as `ContributorTag`s with an owner role — plural**, and it +buys something the structural answer could not. The structural answer is real and +stays: a project is signed by the group's threshold key at the room's derivation +path, so it says which *group* it belongs to by being signed at all, and +`chatRoomId` — a foreign key to `ChatRoom` with `onDelete = CASCADE` — carries that +exactly as on `MantraArtifact` and `MantraDialect`. What it cannot say is that a +project is the group's work and somebody *else's* property, which is the whole +reason the server has an `owner` with an `ownerEntity` join behind it: an owner +there can be an individual or an organization, and need not be the people doing +the work. + +Pubkeys collapse that chain, because in this app an owner that is not a person is +a group, and a group is a pubkey — its room id. **A project can have several**, so +this is a set rather than a field: two organizations co-owning a translation, or a +publisher and the group that did the work, are ordinary cases, and the server's +`UNIQUE` on `project.ownerId` — one owner, one project, which looks like a +modelling slip on that side — is dropped rather than reproduced. + +**Always emit at least one, and default to the room's own public key.** Not an +empty set with the room as an implied default: a project with no owner tag and a +project owned by the room would then be the same project, and making them the same +*event* means no reader ever has to know the default. The room's id is 64 hex +characters, so it is a well-formed `ContributorTag` value, and the common case — +"we own what we are making" — comes out as a real entry rather than an absence. + +**`campaign` is out of scope.** There is no `Campaign` or `Pledge` anywhere in this +app — `grep -ril "campaign\|pledge\|satoshi"` over `commonMain` returns the +lightning wallet and `Zap` and nothing else — and nothing in this plan models one, +reads one or leaves a column for one. Funding is a separate feature that needs +projects to exist first. + +**The two join models survive as they are**, minus `creatorId` — see +[Phase 1](#phase-1--three-events-and-their-tags) for why a membership gets no +creator tag of its own — and they are the interesting half. + +## A creator tag, and owners on the `p` tag + +Two fields, two shapes, and the thing that decides which is cardinality. + +**One creator, so a tag of its own.** `CreatorTag`, tag name `creator`, a +single-value tag holding 64 hex characters, in `NameTag`'s shape. A project is +started by one person; there is no list to keep. + +**Many owners, so `ContributorTag` with an owner role.** That is exactly what +`ContributorTag` is for — an open-ended set of people in a named role, which is +also how the translation side uses it for translators, proofreaders and editors. +A project owned jointly is the case the server's `owner` → `ownerEntity` join +exists to express, and a `p` tag per owner expresses it without a second table of +events. + +Keeping them apart is worth a sentence because reusing `ContributorTag` for both +is the obvious move. It is wrong for the creator twice over: it would make a +single-valued field look like a list, and it would put two tags of the same name +on one event for no gain — see [the two traps](#the-two-traps-owners-still-have) +below, both of which the creator now simply avoids. + +### `ROLE_OWNER` has to reach `KNOWN_ROLES`, in the same commit + +```kotlin +private val KNOWN_ROLES = setOf(ROLE_TRANSLATOR, ROLE_PROOF_READER, ROLE_EDITOR) +… +val role = tag.getOrNull(2)?.takeIf { it in KNOWN_ROLES } +``` + +The filter is there for a good reason, which the file states: a stale relay hint +copied out of a kind:3 p-tag would otherwise render as `Role: wss://relay…` under +an author chip. The consequence for an unregistered role is specific and silent — +the tag parses, the role comes back `null`, the string stays inside the signed +event id, the event still verifies, and the project lands with no owners. Nothing +is logged, because nothing went wrong as far as the parser is concerned. + +So `ROLE_OWNER = "owner"` goes into the constants **and** into `KNOWN_ROLES`, in +the same commit as the first event that emits it. That is [Phase +1](#phase-1--three-events-and-their-tags), and it is the first line of it. Note +that the app's three existing roles already diverge from NIP-F4, whose +spec-defined set is host/cohost/editor; this continues that divergence rather than +starting it, and the comment above `KNOWN_ROLES` should say so. + +`ROLE_CREATOR` is **not** added. There is no creator role, because the creator is +not a contributor tag. + +### The two traps owners still have + +**`addUnique` keeps one tag per *name*, not per value.** + +```kotlin +fun addUnique(tag: Array): TagArrayBuilder { + if (tag.isEmpty() || tag[0].isEmpty()) return this + tagList[tag[0]] = mutableListOf(tag) // replaces the whole list + return this +} +``` + +Emitting three owners with `addUnique` leaves one. Use `addUniqueValueIfNew`, +which appends and dedupes on `tag[1]` — the pubkey — so a key listed twice by a +form appears once. For a *set* of owners that is the correct semantics; +`addUniqueValueIfNew` was the wrong call only back when creator and owner shared +the tag name and deduping by pubkey would have collapsed a person who was both. + +**Emit them sorted.** The id is a hash over the tag array as emitted, so the order +owners come out in is part of what the group signs. A multi-select hands them over +in selection order, which is nothing in particular. `DkgSession.participantPublicKeys` +already sorts for a version of this reason — "so two devices that assembled the +set in different orders store the same string" — and the same line makes a project +canonical, keeps the owner list on the detail screen stable, and costs a +`.sorted()`. + +**Selecting by role, not position**, is no longer a trap: with the creator gone +from the `p` tag, every `ContributorTag` on a project event is an owner. Filter on +the role anyway — `it.role == ROLE_OWNER` — because that assumption is one future +role away from being false, and the failure would be an owner list quietly +containing somebody else. + +### What the `p` tag costs the owners, and why it is acceptable here + +A `p` tag is not an inert field in nostr. NIP-01 indexes single-letter tag names +and makes them queryable as `#p`, so it means "this pubkey is involved": it is what +relays index, what `Nip17Filters` subscribes on, and what a notification system +reads as a mention. + +Inside a Marmot group none of that applies — these events are never on a relay as +themselves. But `BroadcastGroupSignedEventScreen` takes an event the group signed +and publishes it to public relays, and a broadcast project would put its owners' +keys into those relays' addressed-pubkey index, queryable, with a client free to +notify an owner about a project they were not told about. + +For owners that is tolerable and arguably right: an owner *is* involved, which is +what the tag means, and a project the group chose to publish is one it chose to +publish under their names. It is still worth the broadcast screen saying whose keys +are in what is about to leave, and that belongs on the list of things that screen +should say rather than in this feature. + +The creator escapes this entirely, which is a second small reason the split is the +right one: `creator` is multi-character, so it is not indexed, not queryable and +not an address, and a broadcast project carries the creator as data rather than as +a recipient. + +### One thing not to copy + +Six existing tags carry a stray `Tag` suffix in the *wire value* — `chapterIdTag`, +`chunkIdTag`, `dialectIdTag`, `translationIdTag`, `translationChapterIdTag`, +`translationChunkIdTag` — while `artifactId`, `artifactVersionId` and +`translationArtifactVersionId` do not. It is a copy-paste slip and it is +**unfixable**: those strings are inside event ids the group has already signed, so +renaming them would invalidate every signature over them. The new tag is plain +`creator`, and the slip is worth a comment where somebody would otherwise tidy it. + +## Three kinds, and where they can go + +The nip30303 band is full, and worse than full. + +``` +30300 Artifact 30310 TranslationContributorList / DKG proposal +30301 ArtifactVersion 30311 Translation / DKG host key +30302 Chapter 30312 Submission / DKG round 1 +30303 Chunk 30313 DKG coordinator round 1 +30304 Dialect 30314 DKG round 2 +30305 TranslationAVContributorList 30315 DKG certificate +30306 TranslationArtifactVersion 30316 DKG failure +30307 TranslationChapterContribLst 30320-30325 FROST signing +30308 TranslationChapter 30326 GroupKeyState +30309 TranslationChunk 30327-30328 Chronicle + 30329 SubgroupBirthCertificate +``` + +Three kinds are double-booked. `SubmissionEvent.KIND` is 30312 and +`DkgRitualEvents.ROUND_1` is 30312; 30310 and 30311 collide the same way. It has +been survivable because the two families travelled in different envelopes — a +ceremony's messages were NIP-17 gift wraps between participants and a submission +was a Marmot inner event — and since subgroups moved a child's ChillDKG into the +**parent's Marmot room** that separation is thinner than it was. None of the three +colliding nip30303 kinds has a producer (`TranslationEvent` has an inbound arm and +nothing that builds one; the contributor lists have neither), which is the only +reason this is a latent bug rather than a live one. + +So: do not extend the 303xx nip30303 band, and do not fill the 30317–30319 gap, +which is the ceremony family's growth room. Projects take the three slots after +the highest kind in use: + +``` +30330 ProjectEvent +30331 ProjectArtifactVersionEvent +30332 ProjectTranslationArtifactVersionEvent +``` + +Worth doing in passing, in Phase 0, because it costs a comment and this document +is the only place anyone will look: a note on `DkgRitualEvents` and on +`SubmissionEvent` saying the two bands overlap and which side has no producer. A +collision nobody has written down is one somebody resolves by moving the wrong +family. + +## One event per membership, not one list per project + +Two shapes are available for "the versions in this project". + +**A list event per project**, replaceable, carrying every member as a tag. One +event to sign when membership changes, and removal is free — the new list simply +omits it. + +**An event per membership**, which is what the server's schema describes and what +every other entity in this family is. Append-only; removal needs something extra. + +Take the second, for three reasons. The first is that every nip30303 entity here +is its own event and its own row, and the *only* list-shaped kinds in the family — +the three contributor lists, 30305/30307/30310 — are precisely the ones that were +never made to work. `applyInnerEvent` has no arm that writes a row for any of +them, `MantraTranslationArtifactVersionContributor` has a companion full of +`TODO`s assembling an event out of empty strings, and the chronicle leaves all +three out on the grounds that chronicling them "would cost bytes and restore +nothing". The list shape in this codebase is a shape that has failed twice. + +The second is batch signing. A project with its first contents is a natural batch +— the project as lead, its memberships as dependents, each carrying an id that is +a hash over the group's key at the room's path and so cannot be known by the +screen that proposes it. That is exactly `proposeSigningBatch(lead, dependents)`, +and exactly what an artifact and its initial version already do. A list event +would have to be signed *after* the project, in a second ceremony, because it has +to name the project's id. + +The third is that a membership is a thing a group decided, and one quorum per +decision is the grain this app signs at. A replaceable list makes "we added +Setswana to the project" and "we removed Sesotho from it" the same event, and the +room's record of the first becomes unreadable once the second lands. + +The cost is removal, and it is deferred to Phase 8 rather than solved now — +see [What this does not do](#what-this-does-not-do). + +## The scope fork: one room, or several + +This is the decision that changes the shape of the plan, and it should be made +before Phase 1 rather than discovered in Phase 5. + +Every foreign key on every `Mantra*` entity today points inside one room. A +translation names an artifact version in its room; a chapter names a version in +its room; nothing names anything a member of this room might not hold. A project +is the first entity with an obvious reason to cross that line: a group translating +into Setswana may well want the Sesotho translation that another group produced +counted as part of the same project. + +**Phase 1 through 7 assume one room.** `projectId`, `artifactVersionId` and +`translationArtifactVersionId` are real foreign keys with `onDelete = CASCADE`, +like every sibling. A proposal naming a version this room does not hold is +refused at apply time, which is the same refusal `MantraTranslationArtifactVersion` +already gets for free from its FK. + +**Cross-room is Phase 9, and it is not a small change.** The precedent is +`parentChatRoomId` on `ChatRoom` and `GroupKeyState`, which is deliberately *not* +a foreign key: a subgroup member need never have been in the parent, so a +dangling value is the normal state rather than an error. A cross-room project +membership is in the same position, and so it needs: + +- the FK dropped, and the apply arm's "do we hold it" check replaced with + something that stores the reference unresolved, +- a way to name a version in another room at all — a room id beside the event id, + since an event id alone is unresolvable, +- a UI that can show a member it cannot resolve, honestly, without looking broken, +- and an answer to what the group's signature means over a reference it cannot + check. It means the group asserts the membership; it does not vouch for the + thing named, which no amount of signing can. + +Do not design Phases 1–7 to be cross-room-ready. The FK is load-bearing in Phase +1 (it is what makes a bad proposal fail loudly) and taking it out later is one +migration, which is cheaper than carrying an unverifiable column through seven +phases of a feature that does not use it. + +## Phase 0 — the decisions, written down + +No code. Settle, in this document: + +- the three kind numbers, and the collision note described above; +- one-room or cross-room (recommended: one room, Phase 9 for the rest); +- that the creator is a `creator` tag of its own and the owners are + `ContributorTag`s with an owner role, rather than columns filled from local + knowledge, and that the owner set is never empty and defaults to the room's own + key; +- that `ROLE_OWNER` reaches `KNOWN_ROLES` in the same commit as the first event + that emits it — Phase 1's first line, and the one omission in this plan that + fails without a word anywhere; +- that campaign is out of scope entirely — no model, no column, no reader; +- **the schema version number.** The database is at v20 and the Curated fork + shares this history and allocates version numbers out of the same space — the + record of the last three pulls says v20 on both sides. Whoever lands first + takes 21; check the Curare clone before claiming it, because two trees that + both call their next migration 21 is a merge nobody can fix afterwards. + + **This is the one decision the build got wrong, and it checked the wrong + tree.** Projects took 21 on their own branch while `mantra` took 21 for the + subgroup parentage table (`eba97333`), which is a manual `MIGRATION_20_21` + rather than an auto-migration. The collision cost nothing because neither had + shipped — projects renumbered to **22** on the way in — but the lesson is that + the sibling fork was not the only tree to check. Check the base branch. + +## Phase 1 — three events and their tags + +New, under `press.mantra.compose.nostr.nip30303`: + +``` +ProjectEvent.kt 30330 +ProjectArtifactVersionEvent.kt 30331 +ProjectTranslationArtifactVersionEvent.kt 30332 +tags/ProjectIdTag.kt +tags/CreatorTag.kt "creator" +``` + +**First, and in this same commit**, `tags/ContributorTag.kt` learns the owner role: + +```kotlin +const val ROLE_OWNER = "owner" + +private val KNOWN_ROLES = setOf( + ROLE_TRANSLATOR, ROLE_PROOF_READER, ROLE_EDITOR, ROLE_OWNER, +) +``` + +Both halves. A role in the constants and not in the set parses back as `null` +while staying inside the signed event id, so the project lands with no owners and +nothing is logged — see [why that is +silent](#role_owner-has-to-reach-known_roles-in-the-same-commit). Extend the +comment above `KNOWN_ROLES` to say that the set has now diverged from NIP-F4's +host/cohost/editor by four rather than three. + +No `ROLE_CREATOR`: the creator is not a contributor tag. + +`CreatorTag` is `NameTag`'s shape with one extra check, since it holds a key +rather than free text: + +```kotlin +class CreatorTag(val pubKey: HexKey) { + companion object { + const val TAG_NAME = "creator" + + fun parse(tag: Array): CreatorTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + return CreatorTag(tag[1]) + } + + fun assemble(pubKey: HexKey): Array = arrayOf(TAG_NAME, pubKey) + } +} +``` + +The length check is the one guard that matters: without it a malformed value +parses, lands on the row, and names nobody. `ContributorTag` has the same check for +the same reason, which is what makes an owner's key safe to take from a form. + +`ProjectIdTag` is an `EventReference` tag, copied from `ArtifactIdTag` exactly — +the `ensure(tag.has(1))`, the `tag[1].length == 64`, the optional relay hint at +index 2 and pubkey at 3. `ArtifactVersionIdTag` and +`TranslationArtifactVersionIdTag` already exist and are what the two membership +events point with, so no other tag is new. `NameTag` already exists. + +`ProjectEvent`: + +```kotlin +const val KIND = 30330 +const val ALT_DESCRIPTION = "Project" + +fun name() = tags.firstNotNullOfOrNull(NameTag::parse)?.name + +fun creator() = tags.firstNotNullOfOrNull(CreatorTag::parse)?.pubKey + +fun owners() = tags.mapNotNull(ContributorTag::parse) + .filter { it.role == ContributorTag.ROLE_OWNER } + .map { it.pubKey } + +fun build( + name: String, + description: String, + creatorPublicKey: HexKey, + ownerPublicKeys: Set, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, +) = eventTemplate(KIND, description, createdAt) { + require(ownerPublicKeys.isNotEmpty()) { "A project has at least one owner" } + + alt(ALT_DESCRIPTION) + addUnique(NameTag.assemble(name)) + addUnique(CreatorTag.assemble(creatorPublicKey)) + // Sorted, because the id is a hash over the tags as emitted and a form hands + // its selection over in no particular order. addUniqueValueIfNew, not + // addUnique, which would keep one p-tag of the several. + ownerPublicKeys.sorted().forEach { + addUniqueValueIfNew(ContributorTag.assemble(it, ContributorTag.ROLE_OWNER)) + } + initializer() +} +``` + +Content is the description. Name and creator are single-value tags, so `addUnique` +as every sibling does; the owners are a set, so `addUniqueValueIfNew` in sorted +order. `owners()` filters on the role rather than taking every `p` tag, which is +true today and one future role away from not being. + +A `Set` rather than a `List`, and non-empty by contract: the caller always has an +answer, since the room's own key is the default, and an empty set would make "owned +by us" and "owner unstated" the same event. + +Tag order is fixed and starts with `alt`, because the id is a hash over the tags and +anything that rebuilds this event has to emit them in the same order or produce an +event the signature does not cover. For the owners that includes the order among +themselves, which the sort fixes. + +The two membership events carry two id tags and an empty content. `build` takes +both ids: + +```kotlin +fun build( + projectId: HexKey, + artifactVersionId: HexKey, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, +) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + addUnique(ProjectIdTag.assemble(projectId)) + addUnique(ArtifactVersionIdTag.assemble(artifactVersionId)) + initializer() +} +``` + +Note `TagArrayBuilder` — the real type. Three events +in this package take `TagArrayBuilder` by copy-paste, which is a +phantom generic that happens to compile; do not add a fourth. + +**No creator tag on a membership**, though the server's schema has `creatorId` on +both join models. Two reasons. A membership is an act rather than a work — "this +version is part of this project" is a fact about the project, and who moved it +there is a fact about process, which the `FrostSigningSession` already records. And +a membership proposed in the founding batch has the same creator as the project it +arrives with, so the tag would be redundant in the common case and ambiguous in the +other, where it would be the first place in the app two different people are named +as the creator of one decision. + +If it turns out to be wanted, the cost is a nullable column and one `add` — the +tag parses on an event that lacks it, and a row written without it reads back null. +Nothing in Phases 2 through 7 forecloses it. + +Also in this phase, and the reason it is a phase of its own: a `membershipsOf` +companion on each membership event, the counterpart to +`ArtifactVersionEvent.initialVersionOf`. It takes the signed `project` event and +the list of version ids the form collected, and returns the templates — borrowing +the project's own `createdAt`, so a project and its first contents read as one act +rather than four events that share a session. + +**Tests** (`commonTest`): + +- a `RumorIdAgreementTest` case per kind, in the shape the five existing ones have + — build the template, hash it the way `MantraDao.rumorOf` does, and assert the + row's id agrees; +- a `ContributorTag` case asserting `ROLE_OWNER` survives `parse`. **This is the + test that catches the silent drop**, it is three lines, and without it the only + symptom is projects with no owners; +- a `CreatorTag` case: a well-formed value parses, a value that is not 64 + characters does not; +- a `ProjectEvent` case with **three owners**, asserting all three come back and in + sorted order — the failure mode of `addUnique` is one owner, which looks like a + form bug rather than a builder bug; +- a `ProjectEvent` case where the creator is also an owner, asserting `creator()` + and `owners()` both name them. The two live on different tags now, so this is a + regression test against anyone folding them back together; +- a `ProjectEvent` case asserting `build` rejects an empty owner set. + +## Phase 2 — three rows, and schema 21 → 22 + +New, under `press.mantra.compose.database.model`: + +``` +MantraProject.kt +MantraProjectArtifactVersion.kt +MantraProjectTranslationArtifactVersion.kt +``` + +`MantraProject` is `MantraArtifact`'s shape with fewer fields: + +```kotlin +@Entity( + foreignKeys = [ + ForeignKey( + entity = ChatRoom::class, + parentColumns = ["id"], childColumns = ["chatRoomId"], + onDelete = ForeignKey.CASCADE, + ), + ], +) +data class MantraProject( + @PrimaryKey val id: HexKey, + val publicKey: HexKey, // the room's key on a signed row + val name: String, + val description: String, + val creatorPublicKey: HexKey, // off the creator tag, not local knowledge + /** The owner p-tags, comma-joined and sorted. See [ownerPublicKeySet]. */ + val ownerPublicKeys: String, + val chatRoomId: String, + val signature: String, // empty for a rumor + val relayHint: String? = null, + val marmotGroupEventId: HexKey? = null, + val groupSignedEventId: HexKey? = null, + override val nostrEventId: HexKey? = null, + override val createdAt: Instant = Clock.System.now(), + override val updatedAt: Instant = createdAt, +): OptionalNostrEventEntity, TimestampedEntity +``` + +with `fromProjectEvent(event, chatRoomId)` and +`fromProjectEventTemplate(template, chatRoomId, userPublicKey)`, the latter +hashing the id through `EventHasher.hashId` so the row on disk and the payload on +the wire are one event. + +`creatorPublicKey` and `ownerPublicKeys` are non-null and come **only** from +`event.creator()` and `event.owners()`. `fromProjectEvent` returns null when the +creator is missing or the owner list is empty, in the `?.let` chain +`MantraArtifact.fromArtifactEvent` already uses for its four required fields — an +event missing something it was built to carry has either been truncated or is not +what it claims, and a row that fills the gap from whatever this device happens to +know is the failure the tag exists to prevent. + +Never default `creatorPublicKey` to the submitter and never default the owners to +`chatRoomId`; the room's key is the *form's* default, which is a different thing +and belongs in Phase 7. An empty owner list here is the signature of `ROLE_OWNER` +missing from `KNOWN_ROLES`, so the refusal is also the alarm for that bug — which +is the one argument for refusing rather than tolerating it. + +**Owners are one column, not a fourth table.** `DkgSession.participantPublicKeys` +is the precedent: a comma-joined, sorted string with a set accessor beside it, and +the reason it sorts — "so two devices that assembled the set in different orders +store the same string" — holds here too. + +```kotlin +/** The project's owners, or an empty set on a row that somehow has none. */ +fun ownerPublicKeySet(): Set = ownerPublicKeys + .split(",") + .mapNotNull { key -> key.trim().takeIf { it.isNotEmpty() } } + .toSet() +``` + +A join table would buy querying by owner, which no screen does — Phase 6 says why +— and cost a table, a DAO, a cascade and three more lines of apply. Revisit it +when something needs "every project this key owns" across rooms, which is Phase 9's +territory anyway. + +The membership rows add the two foreign keys and nothing else. Both get an index +on `projectId`, because every read of them is "this project's contents" — the same +reason v16 added one on `ChatMessage.chatRoomId`. + +**No `toProjectEvent()`.** The eight `toXEvent()` methods exist for one purpose: +rebuilding events for a chronicle in rooms whose work predates `GroupSignedEvent` +(schema v13). They are deprecated, there is a removal checklist in +`docs/member-chronicle.md`, and each one drags a case of +`ChronicleRoundTripTest` behind it. Projects post-date v13 by seven schema +versions, so every project this app will ever hold is on file as a signed event +and `signedEventsOf` finds it. Adding three more rebuilds would be adding to a +mechanism being retired, to serve rows that cannot exist. + +**Schema.** Three tables, nothing existing changes shape, so this is an +`AutoMigration(from = 21, to = 22)` entry with a comment in the style of the other +nineteen, plus three entities in `@Database`, three `abstract fun`s on +`MantraDatabase`, and three DAOs. + +The DAOs are `MantraDialectDao`'s shape — an interface, `@Upsert`, and the queries +each screen needs: + +```kotlin +@Query("SELECT * FROM MantraProject WHERE chatRoomId = :chatRoomId ORDER BY name") +suspend fun getProjectsByChatRoomId(chatRoomId: String): List + +@Query("SELECT * FROM MantraProject WHERE id = :id") +suspend fun getProjectById(id: String): MantraProject? +``` + +and, on the membership DAOs, `getByProjectId` and the inverse +`getProjectsByArtifactVersionId` — the inverse is what lets an artifact's own +screen say which projects it is part of, in Phase 7. + +**Tests** (`jvmTest`): a `MantraProjectDaoJvmTest` in the shape of +`DkgSessionDaoJvmTest` — real in-memory Room over `BundledSQLiteDriver` — covering +upsert, the by-room query, and that a membership naming an absent version is +refused by the FK. That last one is the Phase 1 scope decision made testable. + +## Phase 3 — applying a signed project + +Three arms in `ChatMessage.applyInnerEvent`, which is where every nip30303 kind +lands whether it arrived over the wire or was signed locally — `FrostSigningManager` +reuses this dispatch rather than repeating it. + +`ProjectEvent.KIND` writes the row and a chat line: + +```kotlin +ProjectEvent.KIND -> { + MantraProject.fromProjectEvent( + projectEvent = ProjectEvent(event.id, event.pubKey, event.createdAt, + event.tags, event.content, event.sig), + chatRoomId = groupId, + )?.let { project -> + database.mantraProjectDao().upsert( + project.copy( + marmotGroupEventId = marmotGroupEventId, + groupSignedEventId = groupSignedEventId, + ) + ) + + ChatMessage( + giftWrapPayloadId = null, + messageType = "project", + marmotGroupEventId = marmotGroupEventId, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = isUserMessage, + chatRoomId = groupId, + createdAt = createdAt, + content = "Started the ${project.name} project", + ) + + // The line names the project and not its creator. `senderPublicKey` is + // the room's coordinator on a group-signed row, so a line saying "X + // started" would be the transcript's own guess; the creator and the + // owners are on the row, and the detail screen is where they are shown. + } +} +``` + +The two membership arms write their row and **return null** — no line of their +own. This is the convention the whole family follows: a version writes none +beside its artifact, a chunk none beside its chapter, because they arrive as the +later items of a batch whose lead has already said what happened. A membership +added on its own, later, is not silent either: `FrostSigningManager.announce` +already writes "The group signed …" when a session completes, which is the line +that covers it. Three new strings for a transcript the signing session is already +narrating would be three places for the same event to be described differently. + +**Both membership arms let the foreign key violation out, and must.** The +temptation is to catch it — `applySignedEvent` swallows throwables so a succeeded +session is not reported as failed — and that is what was built first. It was wrong +twice over. `applySignedEvent` already has its own try/catch, so the reasoning was +satisfied without doing anything; and `ChronicleManager.applyPage` learns a +payload could not be applied **only** by catching the exception, counts it +`failed`, and `sweep` re-runs the pages until failures stop falling. That loop is +the entire mechanism for a membership whose project arrived in a later page. +Swallowing it counts the payload as applied, the sweep terminates, and the row is +lost silently on the one device with nothing to fall back on. + +No sibling arm catches either — a translation chapter naming an absent chapter +throws today — so catching made this the only kind a chronicle could not retry. + +**Tests** (`jvmTest`): a `ProjectApplyJvmTest` in the shape of +`TranslationChunkApplyJvmTest` — apply a project with three owners and assert +`creatorPublicKey` and `ownerPublicKeySet()` are what the event carried; apply a +project event with the `creator` tag stripped and assert **no row is written**; +apply one whose owner tags all carry an unknown role and assert the same, since +that is what a missing `KNOWN_ROLES` entry looks like from here; apply a +membership, assert the row; apply a membership whose version is absent and assert +the project survives. + +## Phase 4 — asking the group + +`ProposedEvent.summarize` gets three branches, or members are asked to sign +"Event of kind 30330". + +```kotlin +ProjectEvent.KIND -> Summary( + label = "New project", + detail = ProjectEvent(/* … */).let { project -> + listOfNotNull( + project.name(), + event.content.takeIf { it.isNotBlank() }, + // Shown because signing this is what makes the group's attestation + // that X started it. A member who cannot see the claim cannot be + // said to have agreed to it -- see the creator note above. + project.creator()?.take(12)?.let { "started by $it" }, + // Only the owners that are not this room. "owned by us" is the + // common case and saying it every time trains members not to read + // the line that matters when somebody else is on it. + project.owners() + .filter { it != localChatRoom.chatRoom.id } + .takeIf { it.isNotEmpty() } + ?.let { others -> + "owned by " + others.joinToString(", ") { it.take(12) } + }, + ).joinToString(" · ") + } +) +``` + +The owner line needs the room's id, which `summarize` does not have: it is a pure +function over one event, by design, so that the signing screen and the proposal +list cannot drift into two names for one thing. Two ways out, and they are not +equally good. Threading the room id through `summarize` is three call sites and no +new concept, but it is the start of passing context into a function whose value is +that it has none. Omitting the comparison and listing every owner is honest and, +with the room always among them, noisy on every single project. + +Take the first, and take it *as* the resolver this phase already needs — the two +problems are one problem. A `summarize(event, resolve)` where `resolve` turns a +pubkey or an event id into a name answers the owner question, the creator question +(`started by a4f1be55d0c3` is barely better than nothing; `started by Kgothatso` +is the point) and the membership question below, all in one change. Both call +sites have a database behind them. Design it once, here, rather than three times. + +The two membership branches are the honest problem in this phase. `summarize` is a +pure function over one event with no database — deliberately, so that the signing +screen and the room's proposal list cannot drift into two names for one thing — +and a membership event carries two 64-character ids and nothing else. The best it +can do is: + +```kotlin +ProjectArtifactVersionEvent.KIND -> Summary( + label = "A source version joins the project", + detail = ProjectArtifactVersionEvent(/* … */) + .artifactVersionId()?.take(12) ?: "Unknown version" +) +``` + +which is a member being asked to sign a truncated hash. It is tolerable **only** +because of the batch: a project and its first contents are proposed together, the +project's own line names the project and says what it is, and the memberships read +as its continuation — the same way "Version of the artifact" reads as the second +line of a batch of two. + +It stops being tolerable in Phase 7's "add an existing version to an existing +project", where the membership is the whole of the proposal — "sign a4f1be55d0c3" +is a request no member can evaluate. The resolver described above is what fixes +this one too, and it is why that design belongs in this phase rather than a later +one: three summaries want the same lookup, and the owner comparison wants it on a +line where getting it wrong is a member agreeing to something they could not read. + +**Tests** (`commonTest`): `ProposedEventTest` cases per branch, plus one asserting +a project whose only owner *is* the room says nothing about owners, and one with +an outside co-owner that names them and not the room. The second is the case the +plural exists for, and the one a member most needs to read before signing. + +## Phase 5 — the chronicle + +Three entries appended to `ChronicleEvent.APPLY_ORDER`: + +```kotlin +private val APPLY_ORDER: List = listOf( + DialectEvent.KIND, + ArtifactEvent.KIND, + ArtifactVersionEvent.KIND, + ChapterEvent.KIND, + ChunkEvent.KIND, + TranslationArtifactVersionEvent.KIND, + TranslationChapterEvent.KIND, + TranslationChunkEvent.KIND, + ProjectEvent.KIND, // new + ProjectArtifactVersionEvent.KIND, // new + ProjectTranslationArtifactVersionEvent.KIND, // new +) +``` + +Order is the whole content of this phase. `APPLY_ORDER` is dependency order and a +page applies front to back, so a membership must sort after *both* the project and +the version it names. Appending gets that right: the memberships are last, and +everything they can name is already in the list. `CHRONICLABLE_KINDS`, +`applyRank`, `inApplyOrder` and `isChroniclable` are all derived from this one +list and need no change. + +Projects are chroniclable where the contributor lists are not, and for the +opposite of their reason: these have an apply arm that writes a row, and they are +signed rather than submitted, so a receiver can check them. A late member who +gets a room's chronicle and not its projects gets the whole library and no account +of what any of it is for. + +`rebuiltEventsOf` — the deprecated walk — is **not** touched, per Phase 2. + +**Tests** (`commonTest` / `jvmTest`): a `ChronicleEventTest` case asserting the +three ranks, specifically that each membership outranks the project and the +version kind it names; a `ChronicleAssemblyJvmTest` case asserting a room with a +project chronicles it. + +## Phase 6 — the repository + +`MantraRepository` gains, with `DatabaseMantraRepository` implementing and `NO_OP` +returning empty: + +```kotlin +suspend fun getProjects(chatRoomId: String): List +suspend fun getProject(id: String): MantraProject? +suspend fun getProjectArtifactVersions(projectId: String): List +suspend fun getProjectTranslationArtifactVersions(projectId: String): List +suspend fun getProjectsForArtifactVersion(artifactVersionId: String): List +``` + +No query by creator or owner. Both are columns on the row and a screen that wants +them has the row; a `getProjectsOwnedBy` would be a query with one caller and an +index to go with it, and the room's project list is short. Add it when a screen +asks, not before. + +The two contents queries return the *versions*, not the join rows: no screen wants +a join row, and resolving it in the repository is one query there instead of an N+1 +in a view model. Room does this with a join; both are indexed by Phase 2. + +**Nothing goes in `MantraDao`.** Its `addDialect` / `addArtifactVersion` / +`rumorOf` / `submitToGroup` path is the old submit flow, reached now only from +`MantraDaoJvmTest`, and every entity a member creates is proposed for signing +instead. An `addProject` there would be a second way to create a project that +bypasses the quorum, which is the thing this feature is for. + +## Phase 7 — the screens + +Five files per screen, in the shape every screen in this app has: a +`@Serializable data class` route, a sealed `UIState` (Loading / Error / Loaded), a +view model with a `factory`, the composable, and a `composable` entry in +`MantraNavHost`. + +**`ChatRoomDetailScreen` gets a projects section**, between the library and the +dialects, copying the artifacts section exactly: a `labelMedium` heading, an empty +line when there are none, a `Card` + `ListItem` per project navigating to +`ProjectDetailRoute`, and a `TextButton` to `AddProjectRoute`. +`ChatRoomDetailUIState.Loaded` gains `projects: List = emptyList()` +and the view model's `initiate` loads them. + +**`AddProjectScreen` / `AddProjectViewModel` / `AddProjectRoute`.** Name and +description fields, a multi-select of the room's artifact versions and a +multi-select of its translations, an owner, and one primary action. The view model +is `AddArtifactViewModel`'s shape, including the parts that are not obvious: + +- the **creator is `activeUserPublicKey`**, not a field. It is who is filling the + form in, the view model already has it, and a picker would only let somebody + credit the wrong person; +- the **owners are a list, pre-filled with the room's own id** and labelled as this + group, with an add control and a remove on each. Most projects never touch it, + and that is the point of pre-filling rather than hiding: a group that *is* making + something for somebody else needs the question asked, and a group that is not + needs to see the answer it is giving. Each entry takes a pubkey or an npub; + validate to 64 hex on entry, since `ContributorTag.parse` drops a tag whose value + is not that length and the owner would vanish between the form and the signature. + The list cannot be emptied — `build` requires one and the last remove is + disabled rather than allowed and then refused; +- `isActionPending`, guarding the double tap; +- `canSign` on `UIState.Loaded` from `frostSigningRepository.canSign`, with the + FAB borrowing `ButtonDefaults.buttonColors().disabled*` and + `Modifier.semantics { disabled() }` when it cannot; +- `proposeSigningBatch(lead = projectTemplate, dependents = …)`, where + `dependents` reads the signed project's id and returns both membership lists. + The project's id is a hash over the group's key at the room's path, which this + screen does not know and should not; +- on success, navigate to `FrostSigningRoute(session.id)` with + `popUpTo { inclusive = true }`. + +A project with no contents is allowed — unlike an artifact with no version, which +is inert because nothing can attach to it, an empty project is a name waiting for +work and Phase 7's second flow is how work reaches it. + +**`ProjectDetailScreen` / `ProjectDetailViewModel` / `ProjectDetailRoute`.** Name, +description, creator, owner, the source versions, the translations, and two add +buttons. Lists load once in `initiate()`; to reflect a just-signed change, navigate +to a fresh `ProjectDetailRoute` with `popUpTo { inclusive = true }`. +`DetailRow` is the shared label/value widget. + +Creator is a `DetailRow` and the owners are a short list, both showing the +**profile** rather than the hex — resolve through `ProfileDao` and fall back to a +truncated npub, which is what the rest of the app does with a key it has no profile +for. An owner entry that is the room's own key reads "this group" rather than +printing the room id back at a member standing inside it, and sorts first, so the +common single-owner case is one unremarkable line. + +**`AddToProjectScreen`** — pick a version, propose one membership. **This is the +flow that needs Phase 4's resolver**, because here the membership is the entire +proposal and a truncated hash is not a thing to sign. + +Each screen obeys `CLAUDE.md`, and the audit is not optional — +`docs/scripts/m3-audit.sh --check` runs inside `./gradlew check` and fails on a +budget exceeded: + +- spacing from `MaterialTheme.spacing`, never a `.dp` literal in a spacing + position (budget 0); +- colour from a role, never a `Color(0x…)` and effectively never `.copy(alpha=)` + (budget 0 outside `ui/theme/`); +- every string in `composeResources/values/strings.xml`, **sentence case** — + "Add project", "No projects in this group", not "Add Project" (budget 0 + title-case strings); +- `NavigateBackButton(onNavigateBack)` in the app bar of all four, since all four + are pushed and the desktop target has neither a system back nor an edge swipe; +- `Modifier.padding(innerPadding).readableContent()` on each content root; +- all four states said, with `ErrorState(onRetry = …)` and `EmptyState`'s message + given explicitly; +- `ScreenStateTransition(viewModel.uiState) { … }` wrapping the state `when`, and + only where the `when` is the composable's whole body; +- icons with a decided `contentDescription` — a real one, or `Decorative` where + the label is beside it, never a bare `null`. + +Around twenty new strings — the six beyond the screens themselves are the owners +field's label, its "this group" value, its add and remove actions, its validation +error, and the creator row's label. Check `grep -c " \ +// [--out composeApp/src/commonTest/kotlin/.../MarkdownBlockFixtures.kt] +// +// mantra.press has one: its node_modules/markdown-it. Pass that path. +// +// THE THREE DEPARTURES are applied here, in the oracle, so the Kotlin test is a +// plain equality check and the departures are visible in one place: +// +// 1. A fenced or indented code block yields no chunk. markdown-it's `fence` +// and `code_block` tokens carry content, so mantra.press turns `code();` +// into a chunk -- with a trailing newline in its text -- and asks a +// translator to translate it and the group to sign it as prose. +// 2. A table is one chunk of its own source, not one per cell. Four chunks +// for a 2x2 table is markdown-it's honest reading, but each costs an event +// envelope to carry one word and a table's alignment is structure that +// per-cell translation cannot preserve. +// 3. A link reference definition yields no chunk. This one is free: it is +// markdown-it's own behaviour with the `reference` block rule left +// enabled. mantra.press disables it -- reaching for the wrong rule name to +// stop links rendering -- which drops `[ref]: https://...` through to +// `paragraph` and makes a translatable chunk of a URL. +// +// `html: false` is markdown-it's default and is kept: an HTML block comes back +// as a paragraph, which is what the Kotlin subset does too. + +const fs = require('fs') +const path = require('path') + +function arg(name, fallback) { + const i = process.argv.indexOf(name) + return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback +} + +const mdPath = arg('--markdown-it', null) +if (!mdPath) { + console.error('--markdown-it is required') + process.exit(2) +} +const outPath = arg( + '--out', + 'composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownBlockFixtures.kt' +) + +const md = require(path.resolve(mdPath))() + +/** + * The corpus. Each entry is a shape that either appears in real chapters or has + * already caught something. The converter shapes come from mantra.press's own + * scripts/convert-btc-tex-to-markdown.js, which is what the files being + * uploaded are made by. + */ +const CORPUS = { + 'one paragraph': 'A single paragraph.\n', + 'two paragraphs': 'First paragraph.\n\nSecond paragraph.\n', + 'lazy continuation': 'First line.\nSecond line of the same paragraph.\n', + 'atx heading apart': '# Chapter one\n\nThe first paragraph.\n', + 'atx heading glued': '# Chapter one\nThe first paragraph.\n', + 'deep atx heading': '#### A fourth-level heading\n\nBody.\n', + 'converter shape': '# Chapter one\nFirst paragraph here.\n\nSecond paragraph here.\n', + 'setext h1': 'Title\n=====\n\nBody.\n', + 'setext h2': 'Subtitle\n--------\n\nBody.\n', + 'tight bullet list': 'Shopping:\n\n- milk\n- bread\n- eggs\n', + 'ordered list': '1. first\n2. second\n', + 'nested list': '- outer\n - inner\n', + 'blockquote two lines': '> line one\n> line two\n', + 'two blockquotes': '> first\n\n> second\n', + 'thematic break': 'One.\n\n---\n\nTwo.\n', + 'fenced code': 'Text.\n\n```\ncode();\n```\n\nMore.\n', + 'fenced code with info': 'Text.\n\n```kotlin\nval x = 1\n```\n', + 'indented code': 'Text.\n\n indented code\n\nMore.\n', + 'table': '| a | b |\n|---|---|\n| 1 | 2 |\n', + 'table with prose': 'Before.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\nAfter.\n', + 'link reference definition': 'See [it][ref].\n\n[ref]: https://example.com\n', + 'inline markup survives': 'A *word* and a [link](https://example.com) and `code`.\n', + 'trailing hard break': 'Line one \nLine two\n', + 'no trailing newline': 'No newline at the end.', + 'leading blank lines': '\n\nA paragraph after blank lines.\n', + 'crlf already normalised': 'One.\n\nTwo.\n', + 'html block falls back to a paragraph': '
html block
\n', + 'heading then list then quote': + '# Title\n\n- one\n- two\n\n> quoted\n\nEnd.\n', + 'multi paragraph blockquote': '> first para\n>\n> second para\n', + 'unicode': 'Ke a leboga.\n\nሰላም ለዓለም።\n', +} + +/** The expected chunks for one input, with the three departures applied. */ +function expected(source) { + const lines = source.split('\n') + const tokens = md.parse(source, {}) + const out = [] + + let tableDepth = 0 + + for (const token of tokens) { + if (token.type === 'table_open') { + // Departure 2: one chunk of the table's own source. + const [start, end] = token.map + out.push(lines.slice(start, end).join('\n')) + tableDepth++ + continue + } + if (token.type === 'table_close') { + tableDepth-- + continue + } + if (tableDepth > 0) continue + + // Departure 1: code carries content and is not prose. + if (token.type === 'fence' || token.type === 'code_block') continue + + // Departure 3 needs no code: with the `reference` rule enabled, a link + // reference definition produces no token at all. + + if (token.type === 'inline' && token.content) out.push(token.content) + } + + return out +} + +function kotlinString(value) { + return '"' + value + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\$/g, '\\$') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t') + '"' +} + +const entries = Object.entries(CORPUS).map(([name, source]) => { + const chunks = expected(source) + return ` Fixture( + name = ${kotlinString(name)}, + markdown = ${kotlinString(source)}, + chunks = listOf(${chunks.length === 0 ? '' : '\n' + chunks.map(c => ' ' + kotlinString(c)).join(',\n') + ',\n '}), + ),` +}).join('\n') + +const file = `package press.mantra.compose.text + +// GENERATED by docs/scripts/markdown-block-fixtures.js -- do not edit by hand. +// +// The expected output of [Markdown.splitBlocks] for ${Object.keys(CORPUS).length} inputs, taken from +// markdown-it with the three departures recorded in docs/chapter-uploads.md +// applied by the generator. Regenerate with: +// +// node docs/scripts/markdown-block-fixtures.js \\ +// --markdown-it /node_modules/markdown-it +// +// A change here is a change to what a chunk is, and so to the ids a quorum +// signs. It should arrive with a reason. + +data class Fixture( + val name: String, + val markdown: String, + val chunks: List, +) + +val MARKDOWN_BLOCK_FIXTURES: List = listOf( +${entries} +) +` + +fs.mkdirSync(path.dirname(outPath), { recursive: true }) +fs.writeFileSync(outPath, file) +console.log(`wrote ${Object.keys(CORPUS).length} fixtures to ${outPath}`)