diff --git a/.gitignore b/.gitignore index 8a62782e..e7ee90c3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ **/build/ xcuserdata !src/**/build/ +**/release/ local.properties .idea .DS_Store diff --git a/.gitmodules b/.gitmodules index 3c2343b8..0f775bbb 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,4 @@ [submodule "lightning-kmp-app"] path = lightning-kmp-app url = https://github.com/kngako/lightning-kmp-app.git + branch = master diff --git a/CLAUDE.md b/CLAUDE.md index 3c68fa57..de57265a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,6 +132,28 @@ ScreenStateTransition(viewModel.uiState) { uiState -> It only works where the `when` is the composable's whole body — `AnimatedContent` is a layout node, so wrapping one inside a `Column` takes its branches out of `ColumnScope`. +## A pushed screen has a way back + +```kotlin +TopAppBar( + title = { Text(stringResource(Res.string.add_post)) }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, // yes +) +TopAppBar(title = { … }) // no, on a pushed screen +``` + +A screen reached by `navController.navigate(...)` takes an `onNavigateBack: () -> Unit` +and puts `NavigateBackButton` in its app bar's leading slot; the host passes +`popBackStack()`. Android has a system back and iOS an edge swipe, but the desktop target +has neither, and a pushed screen with no button there is a dead end. Twenty-two were. + +The button is *only* for pushed screens. The three top-level destinations have the +navigation bar instead, and a screen that cleared the stack to get there -- onboarding, +the loading gate -- has nothing under it to pop to. Where one composable is reached both +ways, the callback is nullable and the caller decides: `ChatRoomMessagingScreen` is a +destination on a phone and the home screen's detail pane in a wide window, and in the +pane the list beside it is the way back. + ## Layout adapts to the window, not to the composable ```kotlin diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index cde561e0..65f10bea 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -170,7 +170,7 @@ android { minSdk = libs.versions.android.minSdk.get().toInt() targetSdk = libs.versions.android.targetSdk.get().toInt() versionCode = 2 - versionName = "0.1.1" + versionName = "0.1.2" } packaging { resources { diff --git a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/20.json b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/20.json new file mode 100644 index 00000000..8095b070 --- /dev/null +++ b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/20.json @@ -0,0 +1,5698 @@ +{ + "formatVersion": 1, + "database": { + "version": 20, + "identityHash": "4414c3734feef178b15710349b993246", + "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, `userPublicKey` TEXT NOT NULL, `subject` TEXT, `description` TEXT, `mlsGroupState` TEXT, `initialGiftWrapPayloadId` TEXT, `leftGroupAt` INTEGER, `joinedGroupAt` INTEGER, `chronicleRequestedAt` INTEGER, `parentChatRoomId` TEXT, `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", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "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": "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 + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "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, `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", + "columnName": "sessionId", + "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", + "participantPublicKey", + "kind" + ] + }, + "indices": [ + { + "name": "index_DkgParticipantMessage_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DkgParticipantMessage_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "DkgSession", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "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`), 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" + ] + }, + "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, `parentChatRoomId` TEXT, `birthCertificateJson` TEXT, `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": "parentChatRoomId", + "columnName": "parentChatRoomId", + "affinity": "TEXT" + }, + { + "fieldPath": "birthCertificateJson", + "columnName": "birthCertificateJson", + "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": [ + "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": "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": "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, '4414c3734feef178b15710349b993246')" + ] + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.android.kt b/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.android.kt index eaf692c1..35c8a017 100644 --- a/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.android.kt +++ b/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.android.kt @@ -1,33 +1,22 @@ package press.mantra.compose.ui.view.model import press.mantra.android.MantraApplication -import press.mantra.compose.AppVersion -import co.touchlab.kermit.Logger -import fr.acinq.bitcoin.MnemonicCode -import fr.acinq.lightning.crypto.LocalKeyManager -import fr.acinq.lightning.utils.toByteVector import fr.acinq.phoenix.PhoenixGlobal import fr.acinq.phoenix.android.BusinessManager import fr.acinq.phoenix.data.DecryptSeedResult -import fr.acinq.phoenix.data.ElectrumConfig import fr.acinq.phoenix.data.WalletId -import fr.acinq.phoenix.managers.DataStoreManager -import fr.acinq.phoenix.managers.NodeParamsManager import fr.acinq.phoenix.managers.SeedManager -import fr.acinq.phoenix.security.EncryptedSeed -import fr.acinq.phoenix.utils.preferences.GlobalPrefs import fr.acinq.phoenix.utils.preferences.UserWalletMetadata -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.launch actual fun updateBusinessActiveInUI(walletId: WalletId) { BusinessManager.updateBusinessActiveInUI(walletId) } +actual fun stopPlatformBusiness(walletId: WalletId) { + BusinessManager.stopBusiness(walletId) +} + actual fun loadAndDecryptSeed(phoenixGlobal: PhoenixGlobal): DecryptSeedResult { return SeedManager.loadAndDecrypt( phoenixGlobal @@ -59,81 +48,3 @@ actual suspend fun saveAvailableWalletMeta( isHidden = isHidden ) } - - -actual fun platformWriteSeed( - log: Logger, - phoenixGlobal: PhoenixGlobal, - globalPrefs: GlobalPrefs, - writingState: WritingSeedState, - viewModelScope: CoroutineScope, - mnemonics: List, - onWritingSeedError: (WritingSeedState.Error) -> Unit, - onWritingSeedStateWriting: (WritingSeedState.Writing) -> Unit, - isRestoringWallet: Boolean, - isTorEnabled: Boolean, - customElectrumServer: ElectrumConfig.Custom?, - onSeedWritten: (WalletId) -> Unit -) { - if (writingState !is WritingSeedState.Init) return - viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e -> - log.e("failed to write mnemonics to disk: ${e.message}") - onWritingSeedError.invoke( - WritingSeedState.Error.Generic(e) - ) - }) { - log.d("writing mnemonics to disk...") - - onWritingSeedStateWriting.invoke( - WritingSeedState.Writing(mnemonics) - ) - val existingSeeds = SeedManager.loadAndDecryptOrNull(phoenixGlobal)?.map { - it.key to it.value.words - }?.toMap() - - val seed = MnemonicCode.toSeed(mnemonics, "").toByteVector() - val keyManager = LocalKeyManager(seed, NodeParamsManager.chain, NodeParamsManager.remoteSwapInXpub) - val newWalletId = WalletId(keyManager.nodeKeys.nodeKey.publicKey) - - when { - existingSeeds == null -> { - log.e("could not load the existing seed map, aborting...") - onWritingSeedError.invoke( - WritingSeedState.Error.CannotLoadSeedMap - ) - return@launch - } - existingSeeds.containsKey(newWalletId) -> { - log.i("attempting to import a seed that already exists, aborting...") - onWritingSeedError.invoke( - WritingSeedState.Error.SeedAlreadyExists - ) - return@launch - } - else -> { - val newSeedMap = existingSeeds + (newWalletId to mnemonics) - val encrypted = EncryptedSeed.V2.encrypt(newSeedMap) - SeedManager.writeSeedToDisk(phoenixGlobal, encrypted, overwrite = true) - if (isRestoringWallet) { - log.i("successfully restored wallet=$newWalletId") - } else { - log.i("successfully created wallet=$newWalletId") - } - } - } - - globalPrefs.saveLastUsedAppCode(AppVersion.versionCode) - val dataStoreManager = DataStoreManager( - phoenixGlobal.ctx, - chain = NodeParamsManager.chain, - ) - val userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId = newWalletId) - userPrefs.saveIsTorEnabled(isTorEnabled) - userPrefs.saveElectrumServer(customElectrumServer) - - viewModelScope.launch(Dispatchers.Main) { - delay(1000) - onSeedWritten(newWalletId) - } - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index f9ecbcd6..45be57b2 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -11,7 +11,7 @@ actual 334 strings sat inside composables. The launcher label is a separate resource, in androidMain/res/values/strings.xml, - which is what AndroidManifest.xml points at. It already said "Mantra". + which is what AndroidManifest.xml points at. Both now say "Mantra". Externalising the 334 is the rest of phase 4 in docs/material-design-conformance.md. They land here. @@ -42,7 +42,6 @@ BIP39 seed with the standard BIP84 derivation path. The profile\'s nostr key comes off the same seed, so these 12 words restore both. Block user Cancel - Change account Chapter detail Chapter name Chapter translation @@ -86,12 +85,10 @@ eg. st eg. To Kill a Mocking Bird Emergency kit - end this ENDED Encrypt and back your recovery information up to your Google Drive or iCloud. Enter the name you want to use for your group Enter the name you want to use for your profile - Enter the nsec or npub (read only) that you want to sign in as Enter the translation for this chunk Events are indexed so that we can deliver a premium local first experience. Everything else @@ -168,7 +165,6 @@ No versions. Not now Nothing was created and no key exists. It is safe to run it again. - nsec, npub, nip-05 (static address) Open chat Original Original text @@ -211,7 +207,6 @@ Search member functionality Search message functionality will be here. See how deep the rabbit hole goes - Select a wallet Send message Share profile Shared key @@ -228,10 +223,6 @@ This device holds the group's reading of this, but not the signed event itself. Nothing here can be checked against a signature. Sign Sign in - Sign in is not yet available while Mantra is in alpha testing. - Sign in to nsec - Sign in to Mantra via nsec, or remote signer - Sign in with an npub Sign out Sign with the group's key Signed their part @@ -243,7 +234,6 @@ Source dialect Start Start a group chat - Start chat Start chat via npub or nip05 Start key ceremony Startup error @@ -255,7 +245,7 @@ The ceremony was abandoned. The group can hold one key together, split so that no single member holds it. Signing with it takes a quorum. The group has a shared key. - The recovery phrase (sometimes called a seed) is a list of 12 English words. It is the only way back to this profile: the key that signs as you, and the wallet that holds your coins, are both derived from it.\n\nOnly you have this phrase. Keep it private — nobody from mantra will ever ask you for it.\n\nDo not lose it. Write it down and keep it somewhere safe that is not this phone. If you lose both the phone and the phrase, this profile and its funds are gone for good. + The recovery phrase (sometimes called a seed) is a list of 12 English words. It is the only way back to this profile: the key that signs as you, and the wallet that holds your coins, are both derived from it.\n\nOnly you have this phrase. Keep it private — nobody from Mantra will ever ask you for it.\n\nDo not lose it. Write it down and keep it somewhere safe that is not this phone. If you lose both the phone and the phrase, this profile and its funds are gone for good. These are your keys. Keep them safe so they can keep unlocking this profile and its coins, even when you lose or change your phone. This artifact has no version for a translation to be of. This artifact has no version for a chapter to attach to. @@ -270,7 +260,13 @@ This will be shown when people open your profile. This will be the display name for this chat room. This will be the display name for your profile and also important for search. - This will give you read only access to the profile. + Read only + Messages need the secret key. This profile is read only: you can see it and the people it follows, but nothing here can be opened or sent. + Sign in with the nsec + Sign out of this profile? + Use a different key? + This device holds no key for %1$s, so there is nothing to lose. The profile stays on the relays, and you can sign in again any time. + Could not sign out. Please try again. This will give you write access to the profile. Mantra broadcasts what you publish to a distributed set of relays, so it stays decentralised. Translate chunk @@ -398,4 +394,101 @@ Check the combined result and confirm it The group has its key — finish setting it up Open + Signed by the group on %1$s + Relays + Relay url + eg. wss://relay.example.com + Add relay + That isn't a relay address. Relays start with wss:// and cannot be on this device. + That relay is already in this list. + + Broadcast + Sends the event to each relay below, exactly as the group signed it. Any member holding it can send it: the signature is the group's, not yours. + The relays this app publishes to are filled in. Add or remove relays before sending. + No relays to broadcast to. Add one above. + Add a relay to broadcast to. + Sending… + Accepted + Rejected: %1$s + Couldn't send: %1$s + No answer + Accepted by %1$s of %2$s relays. + Copy raw JSON + Copied the raw JSON + + A password-protected key (ncryptsec) is not supported yet. Decrypt it in the app it came from and paste the nsec. + Enter a recovery phrase or an nsec to sign in. + Paste + Recognised as a nostr secret key. No wallet comes with it. + Recognised as a recovery phrase. It also restores the wallet. + Recognised as a public key. You will see this profile and the people it follows; messages stay closed and nothing can be sent. Paste the nsec later to open it. + Recovery phrase, nsec or npub + Sign in with a recovery phrase, an nsec, or an npub to look around + Signed in + Signing you in… + That is not a recovery phrase or a nostr key. + That nostr secret key is not valid. + That recovery phrase is not valid. Check each word and the order. + The key could not be saved on this device. + Whatever you paste stays on this device. Mantra checks it, shows you the profile it opens, and writes nothing until you confirm. + This key is already on this device. + Twelve words, nsec1… or npub1… + Use a different key + You are signing in as + + Enter a name for the profile. + It may be new, or it may live on relays we do not know about. Ask again, or set one up now and publish it from here. + Set up a profile + We could not find a profile for this key on the relays we asked. + + Copied the nsec + Copy nsec + Could not forget the key. Please try again. + Could not unlock your key. Please try again. + Mantra will delete the nsec for %1$s from this device. Anything not yet published is lost. The profile stays on the relays, and the key can be signed in again. + Display nostr secret key + Forget + Forget this key + Forget this key? + I have saved my nostr secret key somewhere safe. + I understand that if I lose this phone and my nostr secret key, I lose this profile. + Keep this key safe.\nDo not share it. + Key forgotten + No profile is open on this device, so there is no key to show. + Nostr secret key + Remove the key from this device. The profile stays on the relays. + Store the nsec that signs as this profile. There is no phrase behind it: the key is the whole backup. + The nsec is the key that signs as you. This profile was signed in with it rather than made from a recovery phrase, so there is no phrase to write down and no wallet attached: the key is the whole of the backup.\n\nOnly you have it. Keep it private — nobody from Mantra will ever ask you for it.\n\nDo not lose it. Store it somewhere safe that is not this phone. If you lose both the phone and the key, this profile is gone for good. + This device holds no nostr secret key for the profile that is open. If it was made from a recovery phrase, the phrase is its backup. + Unlocking your key… + You have not backed up your nostr secret key + These are your keys. Keep them safe so they can keep unlocking this profile, even when you lose or change your phone. + You said you stored it + + New note + + Choose a profile + Could not load the profiles on this device + Decrypting… + Opening profile… + Preparing profiles… + Starting wallet… + Unlock to continue + + Loading profiles… + Switch profile + Wallet + + Create a new profile + Sign in with a key + Switch to it + The profile you are in stays on this device. + + Find profile + Looking for this profile on the relays. + Start new chat + 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. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/MantraApp.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/MantraApp.kt index bd2ce2d2..321545fa 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/MantraApp.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/MantraApp.kt @@ -7,7 +7,7 @@ import androidx.compose.ui.Modifier import androidx.navigation.NavHostController import press.mantra.compose.ui.composable.navigation.MantraNavHost import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import fr.acinq.phoenix.PhoenixGlobal @Composable @@ -16,7 +16,7 @@ fun MantraApp( mantraGlobal: MantraGlobal, phoenixGlobal: PhoenixGlobal ) { - TorchTheme { + MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { 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 39968228..dcd795cd 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt @@ -177,7 +177,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) UnsignedNostrEvent::class, Zap::class ], - version = 19, + version = 20, autoMigrations = [ // v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can // generate the migration itself — nothing existing changes shape. @@ -312,6 +312,15 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) // room, which is correct for them: a ceremony held in its own NIP-17 room // ran over exactly that room's members and was named after it. AutoMigration(from = 18, to = 19), + // v20 adds the nullable ownerPublicKey to SynchronizeNostrEventRequest and + // NegentropySynchronizeRequest: the identity that asked, so that only its + // pump answers. The two queues used to be the device's, drained by whoever + // was open with their key pair, and a gift wrap fetched for one profile + // while another was open was stored under the wrong key and never opened + // again. Rows written before this read back null, meaning "the device's", + // which is what they were, and any identity may drain them. The broadcast + // queue deliberately gets no owner: a signed event is anyone's to carry. + AutoMigration(from = 19, to = 20), ] ) @ColumnTypeConverters(MantraConverters::class) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GiftWrapMessageDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GiftWrapMessageDao.kt index 19ff15f2..7c68fd5b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GiftWrapMessageDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GiftWrapMessageDao.kt @@ -9,6 +9,18 @@ interface GiftWrapMessageDao { @Query("SELECT * FROM GiftWrapMessage") suspend fun getAllGiftWrapMessages(): List + /** + * Every wrap addressed to [receiverPublicKey] that the device stored and never + * opened -- no seal names it. Fetched under another identity's key, or under a + * read-only identity's none; the inbox sweep at activation opens them. + */ + @Query( + "SELECT * FROM GiftWrapMessage WHERE receiverPublicKey = :receiverPublicKey " + + "AND id NOT IN (SELECT giftWrapMessageId FROM GiftWrapSeal WHERE giftWrapMessageId IS NOT NULL) " + + "ORDER BY createdAt ASC" + ) + suspend fun getUnopenedAddressedTo(receiverPublicKey: String): List + @Upsert suspend fun upsert(giftWrapMessage: press.mantra.compose.database.model.GiftWrapMessage) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GiftWrapSealDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GiftWrapSealDao.kt index a551c40a..a04771a8 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GiftWrapSealDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GiftWrapSealDao.kt @@ -9,6 +9,9 @@ interface GiftWrapSealDao { @Query("SELECT * FROM GiftWrapSeal") suspend fun getAllGiftWrapSeals(): List + @Query("SELECT COUNT(*) FROM GiftWrapSeal WHERE giftWrapMessageId = :giftWrapMessageId") + suspend fun countForMessage(giftWrapMessageId: String): Int + @Upsert suspend fun upsert(giftWrapSeal: press.mantra.compose.database.model.GiftWrapSeal) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NegentropySynchronizeRequestDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NegentropySynchronizeRequestDao.kt index 04298523..d4e2fa51 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NegentropySynchronizeRequestDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NegentropySynchronizeRequestDao.kt @@ -14,6 +14,14 @@ interface NegentropySynchronizeRequestDao { @Query("SELECT * FROM NegentropySynchronizeRequest WHERE status = :status ORDER BY createdAt ASC, id ASC LIMIT 1") fun observeNegentropySynchronizeRequestsByStatus(status: String): Flow + /** The same head-of-queue, for one identity; see `SynchronizeNostrEventRequestDao.observeSynchronizeNostrEventRequestsByStatusFor`. */ + @Query( + "SELECT * FROM NegentropySynchronizeRequest " + + "WHERE status = :status AND (ownerPublicKey = :ownerPublicKey OR ownerPublicKey IS NULL) " + + "ORDER BY createdAt ASC, id ASC LIMIT 1" + ) + fun observeNegentropySynchronizeRequestsByStatusFor(status: String, ownerPublicKey: String): Flow + @Query("SELECT COUNT(*) FROM NegentropySynchronizeRequest WHERE purpose = :purpose AND status IN (:status)") fun observeNegentropySynchronizeRequestByPurposeAndStatusCount(purpose: String, status: List): Flow @@ -29,4 +37,7 @@ interface NegentropySynchronizeRequestDao { */ @Upsert suspend fun insert(negentropySynchronizeRequests: List) + + @Query("SELECT * FROM NegentropySynchronizeRequest") + suspend fun getAll(): List } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt index 4750536b..f2263e8a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt @@ -29,8 +29,10 @@ import press.mantra.compose.exceptions.MarmotMissingKeyPackageBundleException import press.mantra.compose.exceptions.MarmotMissingNostrGroupDataExtension import press.mantra.compose.exceptions.MarmotNotMemberOfChatGroupException import press.mantra.compose.exceptions.MarmotWelcomeEventMissingKeyPackageEventIdException +import press.mantra.compose.extensions.shortened import press.mantra.compose.extensions.toHex import press.mantra.compose.managers.ChillDkgRitualManager +import press.mantra.compose.nostr.Relays import press.mantra.compose.nostr.dkg.DkgRitualEvents import press.mantra.compose.nostr.frost.FrostSigningEvents import press.mantra.compose.managers.FrostSigningManager @@ -52,6 +54,7 @@ import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage import com.vitorpamplona.quartz.marmot.mls.tree.Credential 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 com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent @@ -146,6 +149,58 @@ abstract class NostrDao( } } + /** + * Opens every gift wrap addressed to [activeKeyPair] that the device stored and could + * not open at the time -- because another identity fetched it, or because this one + * held no key yet. Phase 6 of docs/multiple-profiles.md. + * + * [storeNostrEvent] never indexes an event it already holds, so a wrap that arrived + * under the wrong key would otherwise stay closed for good: the live subscription + * re-receives it, the DAO sees a known id, and returns. This runs the wrap through + * [indexNostrEvent] again, under the right key, one transaction per wrap so that a + * wrap that cannot be opened rolls back its own changes and the next is still tried. + * One pass: wraps do not depend on one another, so a wrap that cannot be opened now + * will not be helped by opening its neighbours. + * + * @return how many were opened. + */ + open suspend fun reopenInbox(activeKeyPair: KeyPair): Int { + if (activeKeyPair.privKey == null) return 0 + val me = activeKeyPair.pubKey.toHex() + val unopened = database.giftWrapMessageDao().getUnopenedAddressedTo(me) + if (unopened.isEmpty()) return 0 + logger.i("Reopening ${unopened.size} gift wrap(s) addressed to $me") + + var opened = 0 + for (wrap in unopened) { + val nostrEvent = database.nostrEventDao().getNostrEventById(wrap.nostrEventId) ?: continue + val relayURL = database.nostrEventRelayDao().getRelayUrls(nostrEvent.id).firstOrNull() + ?: Relays.DefaultDMRelayList.first().url + try { + reopenGiftWrap(nostrEvent, relayURL, activeKeyPair) + if (database.giftWrapSealDao().countForMessage(wrap.id) > 0) opened++ + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + logger.w("Gift wrap ${wrap.id} could not be opened on the second try", e) + } + } + logger.i("Reopened $opened of ${unopened.size} gift wrap(s) for $me") + return opened + } + + /** One wrap, indexed again, in a transaction of its own; see [reopenInbox]. */ + @Transaction + open suspend fun reopenGiftWrap(nostrEvent: NostrEvent, relayURL: String, activeKeyPair: KeyPair) { + indexNostrEvent( + nostrEvent = nostrEvent, + relayURL = relayURL, + synchronizationRelayURLs = listOf(relayURL), + level = 0, + activeKeyPair = activeKeyPair, + ) + } + @Transaction open suspend fun storeNostrEvent( nostrEvent: NostrEvent, @@ -445,6 +500,17 @@ abstract class NostrDao( return@let } + if (activeKeyPair.privKey == null) { + // Addressed to us, and we cannot open it: a read-only identity. Keep the + // event and the wrap, as the branch above does for wraps addressed to + // someone else; the key that opens this one may be signed in later. Without + // this, the unseal below would run with a key nobody has and throw, and + // the throw would take the event with it. + logger.d("GiftWrap ${nostrEvent.id} is addressed to us, but this identity holds no key") + + return@let + } + giftWrapMessage.decryptGiftWrapSeal( activeKeyPair ).let { giftWrapSeal -> @@ -851,6 +917,23 @@ abstract class NostrDao( // content = "Coming soon.", // TODO: Figure out what to do here... // ) // ) + } else if (localChatRoom.chatRoom.userPublicKey != activeKeyPair.pubKey.toHex()) { + // Another profile on this device already holds this + // group. ChatRoom is keyed by the group id alone, and + // every table that hangs off it -- the MLS state, the + // DKG sessions, the FROST shares -- is keyed by the + // room, so letting a second local member in would + // overwrite the first's state with the joiner's and + // break both. Refuse, and say so where the group is: + // in the room, under the profile that holds it. See + // docs/curated-to-mantra-profiles.md, the fourth + // decision. + refuseWelcomeForOtherProfile( + localChatRoom = localChatRoom, + inviteePublicKey = activeKeyPair.pubKey.toHex(), + inviterPublicKey = decryptedGiftWrapPayload.publicKey, + welcomePayloadId = decryptedGiftWrapPayload.id, + ) } else { logger.w("Chat Room already exists for $nostrGroupId") } @@ -1588,6 +1671,53 @@ abstract class NostrDao( * room needs. Creating a group locally sends nothing to anyone, which is why the * first arrival for a group is just as likely to be a key ceremony as a message. */ + /** + * A Welcome for a group this device already holds under another of its profiles, + * refused, and the refusal written into the room as a membership line. + * + * The line goes into the room that exists -- the other profile's -- because that is + * the only place on this device the group has, and the profile that holds it is the + * one who can do something about it: leave, or tell the inviter which profile to + * invite instead. The invitee's key package bundle is left unconsumed, as the + * "already exists" branch has always left it; nothing here changes what a second + * Welcome for the same package would do, which is arrive here again and write a + * second line. + * + * Content is a whole sentence with both names written in, the way the other + * membership lines are, so nothing prefixes an author to it: this is nobody's words. + */ + private suspend fun refuseWelcomeForOtherProfile( + localChatRoom: LocalChatRoom, + inviteePublicKey: HexKey, + inviterPublicKey: HexKey, + welcomePayloadId: HexKey, + ) { + val holder = localChatRoom.chatRoom.userPublicKey + logger.w( + "Refusing Welcome $welcomePayloadId for ${localChatRoom.chatRoom.id}: this device already holds the group " + + "as $holder, and $inviteePublicKey is another profile on it" + ) + + suspend fun nameOf(publicKey: HexKey): String = + database.profileDao().getProfileByPublicKey(publicKey)?.humanReadableNameOrPubkey() + ?: publicKey.shortened() + + database.chatMessageDao().upsert( + ChatMessage( + content = "${nameOf(inviterPublicKey)} invited ${nameOf(inviteePublicKey)}, another profile on this device, " + + "to this group. The invitation was not accepted: this device already holds the group as " + + "${nameOf(holder)}, and one device cannot hold a group twice.", + messageType = ChatMessage.TYPE_WELCOME_REFUSED_OTHER_PROFILE, + chatRoomId = localChatRoom.chatRoom.id, + senderPublicKey = holder, + isUserMessage = true, + giftWrapPayloadId = welcomePayloadId, + marmotGroupEventId = null, + marmotInnerEventId = null, + ) + ) + } + private suspend fun getOrCreateNip17ChatRoom( decryptedGiftWrapPayload: GiftWrapPayload, activeKeyPair: KeyPair, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventRelayDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventRelayDao.kt index 2078e514..a28dd942 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventRelayDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventRelayDao.kt @@ -1,6 +1,7 @@ package press.mantra.compose.database.dao import androidx.room3.Dao +import androidx.room3.Query import androidx.room3.Upsert @Dao @@ -9,4 +10,8 @@ interface NostrEventRelayDao { @Upsert suspend fun upsert(nostrEventRelay: press.mantra.compose.database.model.NostrEventRelay) + /** The relays an event was seen on, oldest first. */ + @Query("SELECT relayURL FROM NostrEventRelay WHERE nostrEventId = :nostrEventId ORDER BY createdAt ASC") + suspend fun getRelayUrls(nostrEventId: String): List + } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventRequestDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventRequestDao.kt index 8620b38d..d6645695 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventRequestDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventRequestDao.kt @@ -14,12 +14,36 @@ interface SynchronizeNostrEventRequestDao { @Query("SELECT * FROM SynchronizeNostrEventRequest WHERE status = :status ORDER BY createdAt ASC, id ASC LIMIT 1") fun observeSynchronizeNostrEventRequestsByStatus(status: String): Flow + /** + * The same head-of-queue, for one identity: its own requests and the ones nobody + * owns. A request another identity queued waits for that identity, because what it + * brings back is opened with that identity's key. + */ + @Query( + "SELECT * FROM SynchronizeNostrEventRequest " + + "WHERE status = :status AND (ownerPublicKey = :ownerPublicKey OR ownerPublicKey IS NULL) " + + "ORDER BY createdAt ASC, id ASC LIMIT 1" + ) + fun observeSynchronizeNostrEventRequestsByStatusFor(status: String, ownerPublicKey: String): Flow + @Query("SELECT COUNT(*) FROM SynchronizeNostrEventRequest WHERE purpose = :purpose AND status IN (:status)") fun observeSynchronizeNostrEventRequestByPurposeAndStatusCount(purpose: String, status: List): Flow @Upsert suspend fun upsert(synchronizeNostrEventRequest: press.mantra.compose.database.model.SynchronizeNostrEventRequest) + /** + * Records that the relay has said everything it has, *if* nothing arrived: a request + * an event has already moved to `processed` stays there. Conditional in SQL rather + * than read-then-write because the event handling that writes `processed` runs in + * its own coroutine and can land after the subscription has closed. + */ + @Query("UPDATE SynchronizeNostrEventRequest SET status = :status, updatedAt = :updatedAt WHERE id = :id AND status = 'sent'") + suspend fun markCompleteIfSent(id: String, status: String, updatedAt: kotlin.time.Instant): Int + @Insert suspend fun insert(synchronizeNostrEventRequests: List) + + @Query("SELECT * FROM SynchronizeNostrEventRequest") + suspend fun getAll(): List } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/UnsignedNostrEventDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/UnsignedNostrEventDao.kt index a6894d90..ba1e6cfe 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/UnsignedNostrEventDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/UnsignedNostrEventDao.kt @@ -23,6 +23,15 @@ interface UnsignedNostrEventDao { @Upsert suspend fun upsert(unsignedNostrEvent: UnsignedNostrEvent): Long + /** + * Every unsigned row of a key this device no longer holds: the kind 0 that makes it a + * local account, and anything queued that can now never be signed. The requests that + * hang off them cascade; published events and the profile cache do not, and stay as + * they would for anyone else's key. + */ + @Query("DELETE FROM UnsignedNostrEvent WHERE pubKey = :publicKey") + suspend fun deleteAllForPublicKey(publicKey: String): Int + @Insert suspend fun insert(unsignedNostrEvents: List) 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 f6db7723..efa2b704 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 @@ -437,6 +437,20 @@ data class ChatMessage( const val TYPE_MEMBER_INVITE_SENT = "memberInviteSent" const val TYPE_MEMBER_INVITE_FAILED = "memberInviteFailed" + /** + * A Welcome that arrived for another profile on this device, for a group this + * device already holds, and was refused. + * + * Written by the invitee's side, into the room the other profile holds, since + * that is the only room on the device the group has. ChatRoom is keyed by the + * group id alone and everything that hangs off it -- MLS state, ceremonies, + * shares -- by the room, so a second local member would overwrite the first. + * The inviter's device sees nothing of this and believes the invite landed, + * which is why the line names both profiles: the one who was invited and the + * one who holds the group. See docs/curated-to-mantra-profiles.md. + */ + const val TYPE_WELCOME_REFUSED_OTHER_PROFILE = "welcomeRefusedOtherProfile" + /** * Every membership line, for the one check the transcript dispatches on. * @@ -447,6 +461,7 @@ data class ChatMessage( TYPE_MEMBER_INVITED, TYPE_MEMBER_INVITE_SENT, TYPE_MEMBER_INVITE_FAILED, + TYPE_WELCOME_REFUSED_OTHER_PROFILE, ) const val TYPE_UNDECRYPTABLE_OUTER_LAYER = "undecryptableOuterLayer" diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt index a7b92840..abf3e438 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt @@ -137,7 +137,12 @@ data class GiftWrapMessage( content = giftWrapSeal.content, tags = giftWrapSeal.tags, createdAt = Instant.fromEpochSeconds(giftWrapSeal.createdAt), - signature = giftWrapSeal.sig + signature = giftWrapSeal.sig, + // The wrap this came out of. The column existed and was never + // written, so nothing could say which wraps had been opened; + // the inbox sweep asks exactly that. A seal from before this + // reads back null, is swept once, and comes back with the link. + giftWrapMessageId = id, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt index eaa2fffb..c1e70904 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt @@ -28,14 +28,20 @@ import press.mantra.compose.nostr.frost.GroupKeyStateEvent * * ### Why the whole event, and not a pointer to one * - * There is nothing to point at. A group-signed event never travels on the wire - * as itself -- the outbound pipeline re-authors rumors as their sender and would - * strip the signature off -- so it is not a `NostrEvent`, and it is not a - * `MarmotInnerEvent` either, since no member sent it. Every device derives it + * There is nothing to point at. A group-signed event never travels between + * members as itself -- the outbound pipeline re-authors rumors as their sender + * and would strip the signature off -- so it is not a `NostrEvent`, and it is not + * a `MarmotInnerEvent` either, since no member sent it. Every device derives it * from a signing session it took part in, or is handed it in a chronicle. The * columns are `NostrEvent`'s so that what is stored is an event rather than a * summary of one, which is what makes [verifies] answerable from the row alone. * + * The one way one reaches a relay is a member sending it there by hand -- from + * `BroadcastGroupSignedEventScreen`, or every accepted entry at once from the + * entries screen -- and that path serialises the row straight to the pool + * without ever making a `NostrEvent` of it -- see + * `BroadcastGroupSignedEventViewModel` for why a row would be the wrong shape. + * * ### The derivation path * * [publicKey] is the group's threshold key walked to [derivationPath], and for a diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/NegentropySynchronizeRequest.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/NegentropySynchronizeRequest.kt index bf31361c..fd6934fc 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/NegentropySynchronizeRequest.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/NegentropySynchronizeRequest.kt @@ -42,6 +42,9 @@ data class NegentropySynchronizeRequest( val synchronizationFilter: press.mantra.compose.database.model.types.SynchronizationFilter, + /** The identity that asked; null for rows from before, which any identity may drain. See [SynchronizeNostrEventRequest.ownerPublicKey]. */ + val ownerPublicKey: HexKey? = null, + override val nostrEventId: HexKey? = null, override val createdAt: Instant = Clock.System.now(), override val updatedAt: Instant = createdAt, @@ -64,6 +67,7 @@ data class NegentropySynchronizeRequest( ), relayURL = relayURL, level = level, + ownerPublicKey = ownerPublicKey, ) } companion object { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/Profile.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/Profile.kt index f6e7e758..ecaa861c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/Profile.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/Profile.kt @@ -23,7 +23,8 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar import press.mantra.compose.ui.composable.widgets.profile.ProfileColor -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.database.GENESIS_AT import kotlin.time.Clock import kotlin.time.Instant import press.mantra.compose.ui.theme.ConformancePreviews @@ -72,6 +73,14 @@ data class Profile( press.mantra.compose.database.model.traits.UserViewableEntity, press.mantra.compose.database.model.traits.BroadcastableEntity, press.mantra.compose.database.model.traits.SoftDeletableEntity { + /** + * Read off a kind 0, as against the "LOADING..." placeholder minted when a pubkey + * is first seen (`NostrDao.indexNostrEvent`), which is stamped [GENESIS_AT] so that + * the two can be told apart. A missing row and a placeholder row mean the same + * thing to a reader: nobody has read a kind 0 for that pubkey. + */ + fun isResolved(): Boolean = createdAt > GENESIS_AT + fun humanReadableNameOrPubkey(): String { return if (!displayName.isNullOrBlank()) { displayName @@ -180,7 +189,7 @@ fun ProfileRenderAsListItemPreview() { about = "I am the stone that the builder refused.", nostrEventId = "nostrEventId" ) - TorchTheme { + MantraTheme { Surface( modifier = Modifier.padding(20.dp) ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/SynchronizeNostrEventRequest.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/SynchronizeNostrEventRequest.kt index 822a6e2b..9305efac 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/SynchronizeNostrEventRequest.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/SynchronizeNostrEventRequest.kt @@ -43,6 +43,13 @@ data class SynchronizeNostrEventRequest( val synchronizationFilters: Array, + /** + * The identity that asked, so that only its pump answers: what a fetch brings back + * is opened with the fetcher's key. Null for rows written before there was one to + * record, which any identity may drain. See docs/multiple-profiles.md, Phase 6. + */ + val ownerPublicKey: HexKey? = null, + override val nostrEventId: HexKey? = null, override val unsignedNostrEventId: Long? = null, override val createdAt: Instant = Clock.System.now(), @@ -61,6 +68,7 @@ data class SynchronizeNostrEventRequest( if (status != other.status) return false if (relayURL != other.relayURL) return false if (!synchronizationFilters.contentEquals(other.synchronizationFilters)) return false + if (ownerPublicKey != other.ownerPublicKey) return false if (nostrEventId != other.nostrEventId) return false if (createdAt != other.createdAt) return false if (updatedAt != other.updatedAt) return false @@ -74,6 +82,7 @@ data class SynchronizeNostrEventRequest( result = 31 * result + status.hashCode() result = 31 * result + relayURL.hashCode() result = 31 * result + synchronizationFilters.contentHashCode() + result = 31 * result + (ownerPublicKey?.hashCode() ?: 0) result = 31 * result + (nostrEventId?.hashCode() ?: 0) result = 31 * result + createdAt.hashCode() result = 31 * result + updatedAt.hashCode() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalQuotedNostrEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalQuotedNostrEvent.kt index f016a4f1..a6c516a6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalQuotedNostrEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalQuotedNostrEvent.kt @@ -21,7 +21,7 @@ import press.mantra.compose.database.model.Profile import press.mantra.compose.database.model.QuotedRelation import press.mantra.compose.ui.composable.widgets.content.RichContent import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.theme.ConformancePreviews data class LocalQuotedNostrEvent( @@ -113,7 +113,7 @@ data class LocalQuotedNostrEvent( @ConformancePreviews @Composable private fun LocalQuotedNostrEventPreview() { - TorchTheme { + MantraTheme { Surface( modifier = Modifier.padding(20.dp) ) { 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 b931ad84..6b71b5e8 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 @@ -120,6 +120,13 @@ class DatabaseChatRepository( null } + override suspend fun groupSignedEvent(chatRoomId: String, id: HexKey): GroupSignedEvent? = try { + database.groupSignedEventDao().getById(id)?.takeIf { it.chatRoomId == chatRoomId } + } catch (e: Throwable) { + logger.e("Error reading the signed event $id of $chatRoomId", e) + null + } + override suspend fun canSign(chatRoomId: String): Boolean = try { FrostSigningManager.canSign(database, chatRoomId) } catch (e: Throwable) { @@ -150,6 +157,18 @@ class DatabaseChatRepository( override suspend fun getAllParticipantsWithPubKey(publicKey: HexKey): List { return database.participantDao().findParticipantByPublicKey(publicKey) } + + override suspend fun findDirectMessageRoom(userPublicKey: HexKey, peerPublicKey: HexKey): LocalChatRoom? { + val theTwoOfUs = setOf(userPublicKey, peerPublicKey) + + for (participant in database.participantDao().findParticipantByPublicKey(peerPublicKey)) { + val localChatRoom = database.chatRoomDao().findChatRoomById(participant.chatRoomId) ?: continue + val members = localChatRoom.localParticipants.map { it.participant.participantPublicKey }.toSet() + if (members == theTwoOfUs) return localChatRoom + } + + return null + } override suspend fun getChatMessageListByChatRoomId(chatRoomId: String): List { return database.chatMessageDao().getChatMessagesByChatRoomId(chatRoomId) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt index 2e64b2f5..a68abcd8 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt @@ -50,14 +50,25 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import press.mantra.compose.identity.Identity import kotlin.time.Clock import kotlin.time.Instant +/** + * @param activeIdentity whose requests the two fetch queues are stamped with. Every + * request queued through here is queued *as* the active identity -- a screen cannot + * queue anything as anyone else -- so the stamp is the repository's, not the call + * site's, and no call site can forget it. Defaults to nobody, which stamps nothing + * and is what a test that has no identity gets. + */ class DatabaseNostrRepository( private val database: press.mantra.compose.database.MantraDatabase, - private val scope: CoroutineScope + private val scope: CoroutineScope, + private val activeIdentity: StateFlow = MutableStateFlow(null), ): press.mantra.compose.repository.NostrRepository, press.mantra.compose.repository.RelayRepository { companion object { const val TAG = "DatabaseNostrRepository" @@ -108,12 +119,19 @@ class DatabaseNostrRepository( return database.broadcastNostrEventRequestDao().observeBroadcastNostrEventRequestsByStatus("pending") } - override suspend fun observePendingSynchronizeNostrEventRequests(): Flow { - return database.synchronizeNostrEventRequestDao().observeSynchronizeNostrEventRequestsByStatus("pending") + override suspend fun observePendingSynchronizeNostrEventRequests(ownerPublicKey: HexKey): Flow { + return database.synchronizeNostrEventRequestDao().observeSynchronizeNostrEventRequestsByStatusFor("pending", ownerPublicKey) } - override suspend fun observePendingNegentropySynchronizeRequests(): Flow { - return database.negentropySynchronizeRequestDao().observeNegentropySynchronizeRequestsByStatus("pending") + override suspend fun observePendingNegentropySynchronizeRequests(ownerPublicKey: HexKey): Flow { + return database.negentropySynchronizeRequestDao().observeNegentropySynchronizeRequestsByStatusFor("pending", ownerPublicKey) + } + + override suspend fun reopenInbox(activeKeyPair: KeyPair): Int { + // Under the same lock as every other write through indexNostrEvent. + return storeNostrEventMutex.withLock { + database.nostrDao().reopenInbox(activeKeyPair) + } } override suspend fun createNewProfile( @@ -124,6 +142,51 @@ class DatabaseNostrRepository( ) { logger.d("createNewProfile: $publicKey") + saveUnsignedNostrEvents(bootstrapUnsignedEvents(publicKey, name, biography)) + + onCompletion.invoke() + } + + override suspend fun setUpProfileForExistingKey( + publicKey: HexKey, + placeholderUnsignedNostrEventId: Long, + name: String?, + biography: String?, + ) { + logger.d("setUpProfileForExistingKey: $publicKey") + + val events = bootstrapUnsignedEvents(publicKey, name, biography) + val profile = events.first { it.kind == MetadataEvent.KIND } + + // The placeholder row is *replaced*, under its own id, rather than joined by a + // second kind 0: `getLocalAccounts` is every kind-0 row, and two for one pubkey + // would be two accounts disagreeing about which is this one. Its `signedAt` goes + // back to null so the notary signs it, and the sync requests that hang off the id + // stay where they are as the record of the search that found nothing. + database.unsignedNostrEventDao().upsert( + profile.copy( + id = placeholderUnsignedNostrEventId, + signedAt = null, + ) + ) + saveUnsignedNostrEvents(events.filterNot { it.kind == MetadataEvent.KIND }) + } + + override suspend fun forgetLocalAccount(publicKey: HexKey) { + logger.d("forgetLocalAccount: $publicKey") + database.unsignedNostrEventDao().deleteAllForPublicKey(publicKey) + } + + /** + * What a key gets when it first becomes a profile here: the kind 0, a contact list + * that follows itself, and the relay lists the app reads, all pointed at the defaults. + * The kind 0 is first. + */ + private fun bootstrapUnsignedEvents( + publicKey: HexKey, + name: String?, + biography: String?, + ): List { val profileEventTemplate = MetadataEvent.createNew( name = name, about = biography, @@ -207,14 +270,23 @@ class DatabaseNostrRepository( ) ) - saveUnsignedNostrEvents(unsignedNostrEvents) - - onCompletion.invoke() + return unsignedNostrEvents } override suspend fun signInToProfile( publicKey: HexKey ) { + // Idempotent by pubkey. Until the read-only kind, nothing called this twice for + // one key -- the writers refused a second sign-in before it got here. Pasting the + // nsec of a key held read-only is the first path that signs in a pubkey whose + // account already exists, and a second placeholder would be two kind-0 rows for + // one pubkey: two accounts disagreeing about which is this one, the failure + // `setUpProfileForExistingKey`'s own comment describes. + if (database.unsignedNostrEventDao().getLocalAccounts().any { it.unsignedNostrEvent?.pubKey == publicKey }) { + logger.d("signInToProfile: $publicKey already has an account here, nothing to plant") + return + } + val profileEventTemplate = MetadataEvent.createNew() saveUnsignedNostrEvent( @@ -495,6 +567,14 @@ class DatabaseNostrRepository( ) } + override suspend fun synchronizeNostrEventRequestCompleted(synchronizeNostrEventRequest: press.mantra.compose.database.model.SynchronizeNostrEventRequest) { + database.synchronizeNostrEventRequestDao().markCompleteIfSent( + id = synchronizeNostrEventRequest.id, + status = press.mantra.compose.nostr.SignInSync.STATUS_COMPLETE, + updatedAt = Clock.System.now(), + ) + } + override suspend fun negentropySynchronizeRequestProcessed(negentropySynchronizeRequest: press.mantra.compose.database.model.NegentropySynchronizeRequest) { database.negentropySynchronizeRequestDao().upsert( negentropySynchronizeRequest.copy( @@ -599,17 +679,23 @@ class DatabaseNostrRepository( } } + /** + * Stamped with the active identity where the caller stamped nothing. A request + * queued while nothing is active stays nobody's, which any identity may drain. + */ override suspend fun queueSynchronizeNostrEvent( synchronizeNostrEventRequests: List, ) { + val owner = activeIdentity.value?.nostrPublicKey database.synchronizeNostrEventRequestDao().insert( - synchronizeNostrEventRequests + synchronizeNostrEventRequests.map { it.copy(ownerPublicKey = it.ownerPublicKey ?: owner) } ) } override suspend fun queueNegentropySynchronizeRequest(negentropySynchronizeRequests: List) { + val owner = activeIdentity.value?.nostrPublicKey database.negentropySynchronizeRequestDao().insert( - negentropySynchronizeRequests + negentropySynchronizeRequests.map { it.copy(ownerPublicKey = it.ownerPublicKey ?: owner) } ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/extensions/WalletManagerExtension.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/extensions/WalletManagerExtension.kt deleted file mode 100644 index c6ab1442..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/extensions/WalletManagerExtension.kt +++ /dev/null @@ -1,12 +0,0 @@ -package press.mantra.compose.extensions - -import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair -import fr.acinq.lightning.crypto.LocalKeyManager -import fr.acinq.phoenix.managers.nostrPrivateKey - -fun LocalKeyManager.nostrPublicKey(): String { - return KeyPair( - privKey = nostrPrivateKey().value.toByteArray() - ).pubKey.toHexKey() -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/CredentialParser.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/CredentialParser.kt new file mode 100644 index 00000000..33866cb1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/CredentialParser.kt @@ -0,0 +1,169 @@ +package press.mantra.compose.identity + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.MnemonicCode +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.XonlyPublicKey +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.utils.MnemonicLanguage + +/** + * What a user pasted into the sign-in field, once it has been recognised. + * + * All three carry the nostr public key, because the confirm step shows it and the + * duplicate check compares it. None prints a secret. + */ +sealed interface SignInCredential { + val nostrPublicKey: HexKey + + /** Twelve (or more) words: a wallet, and the nostr key derived from it. */ + data class RecoveryPhrase( + val words: List, + override val nostrPublicKey: HexKey, + ) : SignInCredential { + override fun toString(): String = "RecoveryPhrase(npub=$nostrPublicKey, words=***)" + } + + /** A bare nostr secret. No wallet comes with it. */ + data class NostrSecret( + val privateKey: PrivateKey, + override val nostrPublicKey: HexKey, + ) : SignInCredential { + override fun toString(): String = "NostrSecret(npub=$nostrPublicKey, key=)" + } + + /** A bare nostr public key. Nothing can be signed with it: a profile to look at. */ + data class NostrPublicKey( + override val nostrPublicKey: HexKey, + ) : SignInCredential +} + +/** Why an input was not accepted. The screen turns each into a sentence. */ +enum class CredentialProblem { + /** Nothing was entered. */ + Empty, + + /** Not words, not an nsec, not hex: nothing this screen knows how to read. */ + NotRecognised, + + /** Words, but the checksum or the wordlist says no: a wrong word, or one out of order. */ + InvalidPhrase, + + /** An nsec or hex that does not decode to a valid secp256k1 secret, or an npub that does not decode to a point on the curve. */ + InvalidKey, + + /** An ncryptsec (NIP-49). Decrypting one is a follow-on; say so rather than fail opaquely. */ + EncryptedKey, + + /** An identity with this nostr public key is already on the device, as a wallet or a key. */ + AlreadyOnThisDevice, + + /** The secret could not be written to disk. */ + CouldNotWrite, +} + +/** + * Recognises a pasted credential. Pure, so it can be pinned row by row. + * + * A user does not choose an input type; they paste what they have. The three accepted + * shapes are unambiguous -- words have spaces, keys do not, and a bech32 prefix says + * which key -- and the one refused shape is named for what it is rather than lumped in + * with garbage, because a user who pasted an ncryptsec did something reasonable and + * should be told what to do instead. + * + * **Hex stays a secret.** A private key and an x-only public key are both thirty-two + * bytes, and sixty-four hex characters cannot say which. The confirm step shows the + * derived npub, so a public key pasted as hex is visible for what it is; guessing -- + * "not a valid secret, so try it as a public key" -- would never fire when it mattered, + * since an x coordinate is, with overwhelming probability, also a valid scalar. An npub + * has to arrive as an npub. + */ +object CredentialParser { + + sealed interface Result { + data class Recognised(val credential: SignInCredential) : Result + data class Refused(val problem: CredentialProblem) : Result + } + + private val hex64 = Regex("[0-9a-f]{64}") + + fun parse(raw: String): Result { + val text = raw.trim() + if (text.isEmpty()) return Result.Refused(CredentialProblem.Empty) + + // Whitespace anywhere inside means words. Not lower-cased: the wordlist already + // is, and a capitalised word is a paste artefact worth showing rather than fixing. + val words = text.split(Regex("\\s+")) + if (words.size > 1) return recoveryPhrase(words) + + // Bech32 is case-insensitive as a whole but rejects mixed case, and a `nostr:` + // prefix is how a key arrives from a link. + val token = text.removePrefix("nostr:").lowercase() + return when { + token.startsWith("npub1") -> npub(token) + token.startsWith("ncryptsec1") -> Result.Refused(CredentialProblem.EncryptedKey) + token.startsWith("nsec1") -> nsec(token) + hex64.matches(token) -> secret(runCatching { PrivateKey.fromHex(token) }.getOrNull()) + else -> Result.Refused(CredentialProblem.NotRecognised) + } + } + + private fun recoveryPhrase(words: List): Result = try { + MnemonicCode.validate(mnemonics = words, wordlist = MnemonicLanguage.English.wordlist()) + Result.Recognised(SignInCredential.RecoveryPhrase(words, StoredIdentity.nostrPublicKeyOf(words))) + } catch (e: Exception) { + Result.Refused(CredentialProblem.InvalidPhrase) + } + + private fun nsec(token: String): Result { + val bytes = runCatching { + val (hrp, data) = Bech32.decodeBytes(token) + require(hrp == "nsec") { "not an nsec" } + data + }.getOrNull() ?: return Result.Refused(CredentialProblem.InvalidKey) + return secret(runCatching { PrivateKey(bytes) }.getOrNull()) + } + + private fun secret(privateKey: PrivateKey?): Result { + if (privateKey == null || !privateKey.isValid()) return Result.Refused(CredentialProblem.InvalidKey) + return Result.Recognised(SignInCredential.NostrSecret(privateKey, privateKey.nostrPublicKeyHex())) + } + + /** + * The x-only key under an `npub1…`, with or without a `nostr:` prefix, or null. + * + * The one shape a message address can arrive in, for the field that takes one. Hex + * stays a secret here for the reason [parse] gives, and an nsec is null because it is + * not an address -- a field asking whom to write to must never take a secret for an + * answer. Same decode as the sign-in branch, so the two cannot disagree. + */ + fun npubOrNull(raw: String): HexKey? { + val token = raw.trim().removePrefix("nostr:").lowercase() + if (!token.startsWith("npub1")) return null + return decodeNpub(token) + } + + private fun npub(token: String): Result = + decodeNpub(token) + ?.let { Result.Recognised(SignInCredential.NostrPublicKey(it)) } + ?: Result.Refused(CredentialProblem.InvalidKey) + + /** + * Thirty-two bytes under the `npub` prefix that name a point on the curve -- the + * counterpart of the `isValid()` a secret is checked with, since not every x + * coordinate has a point above it. + */ + private fun decodeNpub(token: String): HexKey? { + val bytes = runCatching { + val (hrp, data) = Bech32.decodeBytes(token) + require(hrp == "npub") { "not an npub" } + require(data.size == 32) { "not an x-only key" } + data + }.getOrNull() ?: return null + val xOnly = XonlyPublicKey(ByteVector32(bytes)) + if (!xOnly.publicKey.isValid()) return null + return xOnly.value.toHex() + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/ForgetIdentity.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/ForgetIdentity.kt new file mode 100644 index 00000000..887b2572 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/ForgetIdentity.kt @@ -0,0 +1,50 @@ +package press.mantra.compose.identity + +import press.mantra.compose.repository.NostrRepository + +/** + * Takes an identity off the device: the credential out of the file, its preference + * files deleted, its metadata hidden, its account rows gone -- in that order, so a + * failure partway leaves the credential on disk rather than an identity the selector + * lists but nothing can open. The caller then re-lists and clears the active identity. + * + * Lifted out of `NostrSecretViewModel.forgetKey`, where it was written for an nsec, so + * that the two exits a read-only identity has -- sign out, and *use a different key* on + * the not-found screen -- run the same sequence rather than a copy of it. The first step + * is the same call for both credential kinds since the credentials file; only a profile + * with a wallet attached is refused, because removing a seed is a wallet question this + * does not answer, and because the seed would derive the key again. + * + * The two writers are injected as functions so the sequence can be pinned in a test + * without a key store, as the nsec view model's already is. + */ +object ForgetIdentity { + + sealed interface Outcome { + /** The device no longer holds anything for the identity. */ + data object Forgotten : Outcome + + /** A profile with a wallet attached: the seed derives its key, and this does not remove seeds. */ + data object WalletAttached : Outcome + + /** The credentials file could not be read; nothing was changed. */ + data object CannotForget : Outcome + } + + suspend fun forget( + identity: Identity, + nostrRepository: NostrRepository, + forgetNostrCredential: suspend (Identity) -> IdentityWriter.ForgetNostrCredentialResult, + hideIdentityMetadata: suspend (Identity) -> Unit, + ): Outcome = when (forgetNostrCredential(identity)) { + is IdentityWriter.ForgetNostrCredentialResult.Forgotten -> { + hideIdentityMetadata(identity) + // The kind 0 that made it a local account, and anything queued that can now + // never be signed. Published events and the profile cache stay. + nostrRepository.forgetLocalAccount(identity.nostrPublicKey) + Outcome.Forgotten + } + is IdentityWriter.ForgetNostrCredentialResult.WalletAttached -> Outcome.WalletAttached + is IdentityWriter.ForgetNostrCredentialResult.CannotLoadKeys -> Outcome.CannotForget + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt new file mode 100644 index 00000000..5741fb89 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt @@ -0,0 +1,171 @@ +package press.mantra.compose.identity + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import fr.acinq.bitcoin.Crypto +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.XonlyPublicKey +import fr.acinq.bitcoin.byteVector +import fr.acinq.phoenix.PhoenixBusiness +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.utils.preferences.InternalPrefs +import fr.acinq.phoenix.utils.preferences.UserPrefs + +/** + * What the device holds beside an identity's key, which decides what else it can have. + * + * The distinction is not cosmetic: the nostr key is a BIP32 leaf of the seed at + * `m/44'/1237'/0'/0/0`, and that derivation runs one way. Twelve words yield the + * key and a wallet; a bare key yields nothing further, so an identity made from + * one can never grow a node behind it; and a bare public key yields nothing at all, + * not even a signature. See docs/nsec-sign-in.md, docs/npub-sign-in.md and + * docs/multiple-profiles.md. + */ +enum class IdentityKind { + /** + * A key the device also holds a seed for. The key is a credential like any other + * -- it is read from the credentials file, not derived from the node -- and the seed + * is the wallet attached to it. The name records the attachment, not the source. + */ + Mnemonic, + + /** A bare nostr secret. Nothing else can be derived from it. */ + NostrSecret, + + /** A bare nostr public key. Nothing can be signed, opened or derived from it. */ + NostrPublic, +} + +/** + * The signing identity the app is running as -- or, for [IdentityKind.NostrPublic], + * the identity it is looking at. + * + * This is what every consumer of "the active user" reads, in place of reaching + * through the wallet -- `business.walletManager.keyManager.value.nostrPrivateKey()` + * -- for a key that only some identities derive that way. Everything downstream of + * the key is already independent of the seed: the notary and the sync pumps build + * a quartz `KeyPair` from its bytes, the ChillDKG host key is a hash of it, and the + * Marmot bundle password is `hash160` of it. None of them can tell where it came + * from, and after this type none of them need to. + * + * [nostrPublicKey] is a field rather than a derivation because a read-only identity + * has nothing to derive it from. The two checks in `init` keep the field and the key + * from disagreeing, which a data class would otherwise happily allow; [signing] and + * [readOnly] are the two ways to get one built without tripping them. + * + * @param id keys the per-identity preference files and the wallet metadata. For a + * [IdentityKind.Mnemonic] identity it is the hash160 of the node id, as it always + * was; for the two bare kinds it is the hash160 of the x-only public key + * ([toWalletId]) -- the *same* id whether the device holds the secret or only the + * public key, which is what lets a read-only identity become a signing one and keep + * its preferences. Same shape as a wallet's, so nothing downstream can tell the + * kinds apart by the id -- the kind is here, where it can be asked. + * @param nostrPrivateKey null only for [IdentityKind.NostrPublic]. + * @param business the running node. Non-null only for [IdentityKind.Mnemonic]. + */ +data class Identity( + val id: WalletId, + val kind: IdentityKind, + /** + * X-only, hex -- the form every nostr call site wants. + * + * Not `publicKey().toHex()`: that is the 33-byte compressed encoding, sixty-six + * hex characters, and it is what the library's own `nostrPublicKey()` returns. + * Nostr keys are the 32-byte x coordinate. + */ + val nostrPublicKey: HexKey, + val nostrPrivateKey: PrivateKey?, + val userPrefs: UserPrefs, + val internalPrefs: InternalPrefs, + val business: PhoenixBusiness?, +) { + /** + * What the notary, the pumps and every write entrance ask. + * + * A property of the identity rather than `nostrPrivateKey != null` repeated at each + * site, so that a signer that holds no local key -- a remote one, some day -- has + * one place to answer *true*. + */ + val canSign: Boolean get() = nostrPrivateKey != null + + init { + require((nostrPrivateKey == null) == (kind == IdentityKind.NostrPublic)) { + "identity kind $kind and its key disagree about whether there is one" + } + require(nostrPrivateKey == null || nostrPrivateKey.nostrPublicKeyHex() == nostrPublicKey) { + "identity key does not derive the public key it was given" + } + } + + /** The key must never reach a log. `data class` would print it. */ + override fun toString(): String = "Identity(id=$id, kind=$kind, key=)" + + companion object { + /** An identity that holds its secret: the public key is derived, never supplied. */ + fun signing( + id: WalletId, + kind: IdentityKind, + nostrPrivateKey: PrivateKey, + userPrefs: UserPrefs, + internalPrefs: InternalPrefs, + business: PhoenixBusiness?, + ): Identity { + require(kind != IdentityKind.NostrPublic) { "a read-only identity has no secret to sign with" } + return Identity( + id = id, + kind = kind, + nostrPublicKey = nostrPrivateKey.nostrPublicKeyHex(), + nostrPrivateKey = nostrPrivateKey, + userPrefs = userPrefs, + internalPrefs = internalPrefs, + business = business, + ) + } + + /** An identity the device holds only the public key of. No secret, no node. */ + fun readOnly( + id: WalletId, + nostrPublicKey: HexKey, + userPrefs: UserPrefs, + internalPrefs: InternalPrefs, + ): Identity = Identity( + id = id, + kind = IdentityKind.NostrPublic, + nostrPublicKey = nostrPublicKey, + nostrPrivateKey = null, + userPrefs = userPrefs, + internalPrefs = internalPrefs, + business = null, + ) + } +} + +/** + * The one place a quartz [KeyPair] is built from an identity. + * + * With a secret, quartz recomputes the public key from it and the second argument is a + * cross-check. Without one it is quartz's read-only constructor, which its own source + * comments as "this is a read-only account". What it must never be is + * `KeyPair(privKey = null)` with nothing else: that is quartz's *make me a new key*, and + * a read-only pair forwarded into it would try to open the user's gift wraps with a key + * nobody has, fail, and look in the logs like a decryption failure. This helper exists so + * that the trap has one place to be avoided. + */ +fun Identity.toKeyPair(): KeyPair = KeyPair( + privKey = nostrPrivateKey?.value?.toByteArray(), + pubKey = nostrPublicKey.hexToByteArray(), +) + +/** + * The [WalletId] of an identity that has no node id to hash. + * + * `WalletId(nodeId: PublicKey)` is the hash160 of the compressed node key; this is + * the hash160 of the x-only nostr key. The two are over different keys -- the node + * key sits at `m/50'/0'`, the nostr key at `m/44'/1237'/...` -- so a mnemonic wallet + * and its own nostr identity would never share an id even if both were registered. + * Which is exactly why a duplicate has to be caught by public key instead. + */ +fun XonlyPublicKey.toWalletId(): WalletId = + WalletId(Crypto.hash160(value).byteVector().toHex()) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt new file mode 100644 index 00000000..62ea1fd5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt @@ -0,0 +1,363 @@ +package press.mantra.compose.identity + +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.bitcoin.MnemonicCode +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.lightning.crypto.LocalKeyManager +import fr.acinq.lightning.utils.toByteVector +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.data.ElectrumConfig +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.managers.DataStoreManager +import fr.acinq.phoenix.managers.NodeParamsManager +import fr.acinq.phoenix.managers.NostrCredentialManager +import fr.acinq.phoenix.managers.SeedManager +import fr.acinq.phoenix.managers.nostrPrivateKey +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.security.EncryptedNostrCredentials +import fr.acinq.phoenix.security.EncryptedSeed +import fr.acinq.phoenix.security.NostrCredential +import fr.acinq.phoenix.utils.preferences.GlobalPrefs +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import press.mantra.compose.AppVersion +import press.mantra.compose.ui.view.model.WritingSeedState + +/** + * Puts a new identity on disk, one function per kind, and prepares the per-identity + * preferences every kind reads. + * + * [writeMnemonic] was an `expect` with three byte-identical actuals -- android, jvm, ios -- + * using nothing but commonMain: SeedManager, EncryptedSeed, LocalKeyManager, + * DataStoreManager, AppVersion. It was an expect because something once needed to + * differ and nothing does now, so it is one function here, and the second writer sits + * next to it rather than being triplicated in turn. + * + * All three refuse a duplicate **by nostr public key**, not only by id. A wallet and an + * imported key can be the same npub under different ids -- one is hash160 of the node + * key, the other hash160 of the nostr key -- and the database is keyed by pubkey, so two + * identities for one pubkey would share every row and disagree about which is active. + * + * With two exceptions, which are the point of the credentials file. A public key already + * here read-only is not a duplicate of the secret that signs as it: the device does not + * have that secret, and refusing it would be false. [writeNostrKey] replaces the entry + * in one write. And a bare secret already here is not a duplicate of the recovery phrase + * that derives it: the device does not have that wallet, and pasting the phrase is the + * profile acquiring the wallet that derives it. [writeMnemonic] writes the credential + * first and then the seed -- see docs/multiple-profiles.md, Phase 1 -- and every seed's + * key is a `Secret` entry in the credentials file, so that the file is the list of + * profiles and the seed is a wallet attached to one of them. + */ +object IdentityWriter { + + /** Each seed's nostr public key and the wallet id it is filed under, or null if the store could not be read. */ + private fun seedWalletIds(phoenixGlobal: PhoenixGlobal): Map? = + SeedManager.loadAndDecryptOrNull(phoenixGlobal) + ?.values + ?.associate { StoredIdentity.nostrPublicKeyOf(it.words) to it.walletId } + + /** + * Creates the preference files a new id needs and records the choices made before + * the id was known. Tor and a custom Electrum server mean nothing to an identity + * without a wallet; they are saved anyway, because `loadUserPrefsForWallet` is what + * creates the file and the recovery screens read it. + */ + private suspend fun prepareIdentity( + phoenixGlobal: PhoenixGlobal, + globalPrefs: GlobalPrefs, + id: WalletId, + isTorEnabled: Boolean, + customElectrumServer: ElectrumConfig.Custom?, + ) { + globalPrefs.saveLastUsedAppCode(AppVersion.versionCode) + val dataStoreManager = DataStoreManager( + phoenixGlobal.ctx, + chain = NodeParamsManager.chain, + ) + val userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId = id) + userPrefs.saveIsTorEnabled(isTorEnabled) + userPrefs.saveElectrumServer(customElectrumServer) + } + + /** + * Adds [mnemonics] to `seed.dat`. Callback-shaped, driving [WritingSeedState], because + * that is what the create flow and its view model were built around. + */ + fun writeMnemonic( + log: Logger, + phoenixGlobal: PhoenixGlobal, + globalPrefs: GlobalPrefs, + writingState: WritingSeedState, + viewModelScope: CoroutineScope, + mnemonics: List, + onWritingSeedError: (WritingSeedState.Error) -> Unit, + onWritingSeedStateWriting: (WritingSeedState.Writing) -> Unit, + isRestoringWallet: Boolean, + isTorEnabled: Boolean, + customElectrumServer: ElectrumConfig.Custom?, + onSeedWritten: (WalletId) -> Unit + ) { + if (writingState !is WritingSeedState.Init) return + viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e -> + log.e("failed to write mnemonics to disk: ${e.message}") + onWritingSeedError.invoke( + WritingSeedState.Error.Generic(e) + ) + }) { + log.d("writing mnemonics to disk...") + + onWritingSeedStateWriting.invoke( + WritingSeedState.Writing(mnemonics) + ) + val existingSeeds = SeedManager.loadAndDecryptOrNull(phoenixGlobal)?.map { + it.key to it.value.words + }?.toMap() + // Null when nostr-credentials.dat exists and cannot be read; an empty map when it is absent. + val existingCredentials = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) + + val seed = MnemonicCode.toSeed(mnemonics, "").toByteVector() + val keyManager = LocalKeyManager(seed, NodeParamsManager.chain, NodeParamsManager.remoteSwapInXpub) + val newWalletId = WalletId(keyManager.nodeKeys.nodeKey.publicKey) + val newNostrPublicKey = keyManager.nostrPrivateKey().nostrPublicKeyHex() + + when { + existingSeeds == null || existingCredentials == null -> { + log.e("could not load the existing seed map, aborting...") + onWritingSeedError.invoke( + WritingSeedState.Error.CannotLoadSeedMap + ) + return@launch + } + existingSeeds.containsKey(newWalletId) -> { + // The same seed, and so the same nostr key with a wallet already attached. + // Two different seeds cannot derive one nostr key, so this is the only + // way a profile with a wallet attached is offered its phrase again. The + // id goes with the refusal: from inside the app, "already here" has an + // obvious next step, which is to switch to it. + log.i("attempting to import a seed that already exists, aborting...") + onWritingSeedError.invoke( + WritingSeedState.Error.SeedAlreadyExists(newWalletId) + ) + return@launch + } + else -> { + // The credential first, then the seed. What the credentials file holds + // for this key beforehand decides what the write means, not whether it + // happens: nothing, and this is a new profile with a wallet; a public + // entry, and it is the read-only upgrade landing on a wallet; a secret, + // and it is a bare key acquiring the wallet that derives it, which used + // to be refused as a duplicate and is not one -- the device did not have + // the wallet. In every case the entry becomes the derived key. + // + // A crash between the two writes leaves a bare-key profile whose key + // the phrase derives: a valid thing to hold, and pasting the phrase + // again completes it. The reverse order would leave a seed with no + // credential, which the listing derives around and the repair fixes -- + // also recoverable, but this order says the model out loud: the profile + // exists, and then a wallet is attached to it. + val existingEntry = existingCredentials[newNostrPublicKey] + val bareKeyId = if (existingEntry != null) StoredIdentity.nostrPublic(newNostrPublicKey).id else null + when (existingEntry) { + null -> Unit + is NostrCredential.Public -> log.i("the seed's nostr key is here read-only; it becomes a profile with a wallet") + is NostrCredential.Secret -> log.i("the seed's nostr key is here as a bare key; attaching the wallet") + } + NostrCredentialManager.writeToDisk( + phoenixGlobal, + EncryptedNostrCredentials.encrypt( + existingCredentials + (newNostrPublicKey to NostrCredential.Secret(keyManager.nostrPrivateKey())) + ), + ) + val newSeedMap = existingSeeds + (newWalletId to mnemonics) + val encrypted = EncryptedSeed.V2.encrypt(newSeedMap) + SeedManager.writeSeedToDisk(phoenixGlobal, encrypted, overwrite = true) + if (isRestoringWallet) { + log.i("successfully restored wallet=$newWalletId") + } else { + log.i("successfully created wallet=$newWalletId") + } + if (bareKeyId != null) { + // The profile's id is its wallet's now, so its preferences are the + // wallet's -- fresh, as a new wallet's are, which is right: there is + // a new secret to back up. The files under the bare key's id would + // otherwise sit on the disk with nothing reading them. + DataStoreManager(phoenixGlobal.ctx, chain = NodeParamsManager.chain).deleteNodeUserPrefs(bareKeyId) + } + } + } + + prepareIdentity(phoenixGlobal, globalPrefs, newWalletId, isTorEnabled, customElectrumServer) + + viewModelScope.launch(Dispatchers.Main) { + delay(1000) + onSeedWritten(newWalletId) + } + } + } + + sealed class WriteNostrCredentialResult { + data class Written(val id: WalletId) : WriteNostrCredentialResult() + + /** + * An identity with this nostr public key is already on the device and holds at least + * what was pasted: a wallet or a secret, for either input; a public key, for an npub. + * [id] is the one it is listed under -- the wallet's where a seed derives the key -- + * so that the refusal can offer to switch to it. + */ + data class AlreadyExists(val id: WalletId) : WriteNostrCredentialResult() + + /** One of the two stores exists and could not be read; nothing was written. */ + data object CannotLoadKeys : WriteNostrCredentialResult() + } + + /** + * Adds [privateKey] to `nostr-credentials.dat` as a secret and prepares its + * preferences. Suspending and result-shaped rather than callback-shaped: nothing was + * built around a state machine for it, and the caller is a coroutine already. Throws + * on an I/O or key store failure during the write, as [writeMnemonic]'s handler would + * have caught. + * + * A public entry for the same key is the one thing that is not a duplicate: the entry + * becomes a secret one, in a single write, under the id it already had. + */ + suspend fun writeNostrKey( + log: Logger, + phoenixGlobal: PhoenixGlobal, + globalPrefs: GlobalPrefs, + privateKey: PrivateKey, + isTorEnabled: Boolean, + customElectrumServer: ElectrumConfig.Custom?, + ): WriteNostrCredentialResult { + log.d("writing nostr key to disk...") + val stored = StoredIdentity.nostrSecret(privateKey) + + val seeds = seedWalletIds(phoenixGlobal) + val existing = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) + if (seeds == null || existing == null) { + log.e("could not load the existing keys, aborting...") + return WriteNostrCredentialResult.CannotLoadKeys + } + seeds[stored.nostrPublicKey]?.let { walletId -> + log.i("attempting to import the nostr key of a wallet that is already here, aborting...") + return WriteNostrCredentialResult.AlreadyExists(walletId) + } + if (existing[stored.nostrPublicKey] is NostrCredential.Secret) { + log.i("attempting to import a nostr key that is already here, aborting...") + return WriteNostrCredentialResult.AlreadyExists(stored.id) + } + val upgrading = existing[stored.nostrPublicKey] is NostrCredential.Public + + val encrypted = EncryptedNostrCredentials.encrypt( + existing + (stored.nostrPublicKey to NostrCredential.Secret(privateKey)) + ) + NostrCredentialManager.writeToDisk(phoenixGlobal, encrypted) + if (upgrading) { + log.i("read-only identity=${stored.id} now holds its key") + } else { + log.i("successfully imported nostr key for identity=${stored.id}") + } + + prepareIdentity(phoenixGlobal, globalPrefs, stored.id, isTorEnabled, customElectrumServer) + + return WriteNostrCredentialResult.Written(stored.id) + } + + /** + * Adds [nostrPublicKey] to `nostr-credentials.dat` as a public entry -- an identity + * the device can look at and not sign as -- and prepares its preferences. Refused if + * the device already holds anything at all for the key, since anything is more than + * this. + */ + suspend fun writeNostrPublicKey( + log: Logger, + phoenixGlobal: PhoenixGlobal, + globalPrefs: GlobalPrefs, + nostrPublicKey: HexKey, + isTorEnabled: Boolean, + customElectrumServer: ElectrumConfig.Custom?, + ): WriteNostrCredentialResult { + log.d("writing nostr public key to disk...") + val stored = StoredIdentity.nostrPublic(nostrPublicKey) + + val seeds = seedWalletIds(phoenixGlobal) + val existing = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) + if (seeds == null || existing == null) { + log.e("could not load the existing keys, aborting...") + return WriteNostrCredentialResult.CannotLoadKeys + } + seeds[stored.nostrPublicKey]?.let { walletId -> + log.i("attempting to add the public key of a wallet that is already here, aborting...") + return WriteNostrCredentialResult.AlreadyExists(walletId) + } + if (existing.containsKey(stored.nostrPublicKey)) { + log.i("attempting to add a public key the device already holds something for, aborting...") + return WriteNostrCredentialResult.AlreadyExists(stored.id) + } + + NostrCredentialManager.writeToDisk( + phoenixGlobal, + EncryptedNostrCredentials.encrypt(existing + (stored.nostrPublicKey to NostrCredential.Public)), + ) + log.i("added read-only identity=${stored.id}") + + prepareIdentity(phoenixGlobal, globalPrefs, stored.id, isTorEnabled, customElectrumServer) + + return WriteNostrCredentialResult.Written(stored.id) + } + + sealed class ForgetNostrCredentialResult { + data object Forgotten : ForgetNostrCredentialResult() + + /** + * A seed derives the key: the profile has a wallet attached, and removing a seed is + * a wallet question this does not answer. Also the answer for a key that is not in + * `nostr-credentials.dat` at all, which since the repair can only be a seed's key + * the repair could not write -- and either way, taking the entry would not take the + * key: the seed would derive it again and the next listing would put it back. + */ + data object WalletAttached : ForgetNostrCredentialResult() + + data object CannotLoadKeys : ForgetNostrCredentialResult() + } + + /** + * The inverse of [writeNostrKey] and [writeNostrPublicKey] alike: removes the entry, + * whichever kind it is, from `nostr-credentials.dat` and deletes the identity's + * preference files. The profile stays on the relays, and the same key can be signed + * in again. Only for a profile with no wallet attached -- see + * [ForgetNostrCredentialResult.WalletAttached]. + */ + suspend fun forgetNostrCredential( + log: Logger, + phoenixGlobal: PhoenixGlobal, + id: WalletId, + nostrPublicKey: HexKey, + ): ForgetNostrCredentialResult { + val seeds = seedWalletIds(phoenixGlobal) + val existing = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) + if (seeds == null || existing == null) { + log.e("could not load the existing keys, aborting...") + return ForgetNostrCredentialResult.CannotLoadKeys + } + if (seeds.containsKey(nostrPublicKey)) { + log.i("asked to forget a key a seed derives; the wallet has to go first") + return ForgetNostrCredentialResult.WalletAttached + } + if (!existing.containsKey(nostrPublicKey)) { + log.i("asked to forget a key that is not a credential on this device") + return ForgetNostrCredentialResult.WalletAttached + } + + NostrCredentialManager.writeToDisk(phoenixGlobal, EncryptedNostrCredentials.encrypt(existing - nostrPublicKey)) + log.i("forgot nostr credential for identity=$id") + + DataStoreManager(phoenixGlobal.ctx, chain = NodeParamsManager.chain).deleteNodeUserPrefs(id) + + return ForgetNostrCredentialResult.Forgotten + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/NewProfileWriter.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/NewProfileWriter.kt new file mode 100644 index 00000000..fb8df175 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/NewProfileWriter.kt @@ -0,0 +1,24 @@ +package press.mantra.compose.identity + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.phoenix.data.WalletId + +/** What a [NewProfileWriter] made: the key the new profile signs as, and the id it is listed under. */ +data class NewProfile(val nostrPublicKey: HexKey, val id: WalletId) + +/** + * Makes the secret a new profile signs with and puts it on disk. + * + * Two live in the app, and the create screen does not know which it was given. One + * generates twelve words and writes a seed -- for Landing, where the first profile on a + * device is the one the wallet will belong to. The other generates a bare key and + * writes a credential -- for the switcher, where a user who wants another profile has + * not asked for another Lightning node, another set of channels, or a second phrase + * with funds behind it. Under docs/multiple-profiles.md the two differ by exactly one + * thing, whether a wallet is attached to the profile they make, which is what the model + * was for. + */ +fun interface NewProfileWriter { + /** Generates and writes the secret. A failure is a write that did not happen; nothing is half-made. */ + suspend fun write(): Result +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/SeedCredentials.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/SeedCredentials.kt new file mode 100644 index 00000000..11f1af26 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/SeedCredentials.kt @@ -0,0 +1,77 @@ +package press.mantra.compose.identity + +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.data.UserWallet +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.managers.NostrCredentialManager +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.security.EncryptedNostrCredentials +import fr.acinq.phoenix.security.NostrCredential + +/** + * The credential a seed always implied. + * + * Until docs/multiple-profiles.md, a seed's nostr key was never written to the + * credentials file: it was derived from the words at listing and from the running node + * at activation, and the writers kept one key out of two files. That put the wallet + * where the profile should be. Now the credentials file is the list of profiles and a + * seed is a wallet attached to one, so every seed's key has to be in it -- written by + * `IdentityWriter.writeMnemonic` for a seed created or restored from here on, and by + * this for every seed that was already on the device. + * + * A named, idempotent write that runs from `SovereignWalletViewModel.listIdentities`, + * beside `NostrCredentialManager.migrateFromNostrKeys` and for the same reason: a + * listing that writes is a surprise, so the write is a step with a name, a result and + * a test, rather than a side effect of reading. On a device with no seeds, or one + * already repaired, it writes nothing. + */ +object SeedCredentials { + + sealed interface Result { + /** The credentials the listing should use: repaired where the write succeeded, as read where it did not. */ + val credentials: Map + + /** [count] seeds had no secret credential, and now do. One write. */ + data class Written(val count: Int, override val credentials: Map) : Result + + /** Every seed's key was already a secret credential. Nothing was written. */ + data class NotNeeded(override val credentials: Map) : Result + + /** + * The write failed; the file is as it was and [credentials] is what was read. The + * listing goes on: `StoredIdentity.merge` derives the key of a seed that has no + * credential, so a seed this could not repair is still listed. A failed write must + * never hide a wallet, which is why this is a result and not a throw. + */ + data class Failed(val cause: Throwable, override val credentials: Map) : Result + } + + /** + * Writes a `Secret` for every seed in [wallets] whose derived key is not one in + * [credentials], replacing a `Public` entry for that key where there is one -- the + * read-only upgrade landing on the wallet that was here all along. + */ + fun reconcile( + log: Logger, + phoenixGlobal: PhoenixGlobal, + wallets: Map, + credentials: Map, + ): Result { + val missing = wallets.values + .map { StoredIdentity.nostrPrivateKeyOf(it.words) } + .filter { credentials[it.nostrPublicKeyHex()] !is NostrCredential.Secret } + if (missing.isEmpty()) return Result.NotNeeded(credentials) + + val repaired = credentials + missing.associate { it.nostrPublicKeyHex() to NostrCredential.Secret(it) } + return try { + NostrCredentialManager.writeToDisk(phoenixGlobal, EncryptedNostrCredentials.encrypt(repaired)) + log.i { "wrote the credential for ${missing.size} seed(s) that had none" } + Result.Written(missing.size, repaired) + } catch (e: Exception) { + log.e("could not write the credentials of ${missing.size} seed(s); listing them by derivation", e) + Result.Failed(e, credentials) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/StoredIdentity.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/StoredIdentity.kt new file mode 100644 index 00000000..9390f081 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/StoredIdentity.kt @@ -0,0 +1,166 @@ +package press.mantra.compose.identity + +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.MnemonicCode +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.XonlyPublicKey +import fr.acinq.bitcoin.byteVector +import fr.acinq.lightning.crypto.LocalKeyManager +import fr.acinq.phoenix.data.UserWallet +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.managers.NodeParamsManager +import fr.acinq.phoenix.managers.nostrPrivateKey +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.security.NostrCredential + +/** + * A profile this device holds something for, as listed before any of them is started. + * + * The credentials file is the list of profiles: a `Secret` entry for every key the + * device can sign as, a `Public` one for every key it can only look at. `seed.dat` is + * the list of wallets, and a wallet is *attached* to the profile whose key it derives + * -- matched by public key, since a seed's key is written to the credentials file when + * the seed is (`IdentityWriter.writeMnemonic`) and repaired into it for every seed + * already here (`SeedCredentials.reconcile`). See docs/multiple-profiles.md, Phase 1. + * The nostr public key is on every kind because it is the one thing they have in + * common and the one thing that attaches a wallet to a profile or refuses a duplicate. + * + * On what this holds: `SovereignWalletViewModel.availableWallets` has always carried + * decrypted words for the view model's life, because starting the node needs them. + * [NostrSecret] and [Mnemonic] carry their key for the same reason and are no worse; + * decrypting at activation instead would be an improvement for every kind and belongs + * to all of them. + */ +sealed interface StoredIdentity { + val id: WalletId + val kind: IdentityKind + val nostrPublicKey: HexKey + + /** + * A key the device also holds a seed for. The key is the credential's, so that + * activating this profile does not depend on the node to learn who it is; the seed + * is the wallet attached to it, and its id is the wallet's -- `hash160(nodeId)`, as + * it has always been -- because the library keys the node's preferences and + * metadata by that id and nothing has to move. + */ + data class Mnemonic( + val userWallet: UserWallet, + val privateKey: PrivateKey, + ) : StoredIdentity { + override val id: WalletId get() = userWallet.walletId + override val kind: IdentityKind get() = IdentityKind.Mnemonic + override val nostrPublicKey: HexKey = privateKey.nostrPublicKeyHex() + override fun toString(): String = "StoredIdentity.Mnemonic(id=$id, npub=$nostrPublicKey, key=)" + } + + data class NostrSecret( + override val id: WalletId, + override val nostrPublicKey: HexKey, + val privateKey: PrivateKey, + ) : StoredIdentity { + override val kind: IdentityKind get() = IdentityKind.NostrSecret + override fun toString(): String = "StoredIdentity.NostrSecret(id=$id, npub=$nostrPublicKey, key=)" + } + + /** + * A public key the device holds nothing else for. Its id is the one its secret would + * have, so that pasting the nsec later keeps the preferences and metadata. + */ + data class NostrPublic( + override val id: WalletId, + override val nostrPublicKey: HexKey, + ) : StoredIdentity { + override val kind: IdentityKind get() = IdentityKind.NostrPublic + } + + companion object { + /** + * The nostr key a wallet's words derive, at the NIP-06 path. `SeedManager.loadAndDecrypt` + * has already built exactly this key manager to learn the node id, so this is the + * second derivation of it, not a new cost. It is what attaches a seed to its + * credential, and what the credential is repaired from when a seed has none. + */ + fun nostrPrivateKeyOf(words: List): PrivateKey = LocalKeyManager( + seed = MnemonicCode.toSeed(words, "").byteVector(), + chain = NodeParamsManager.chain, + remoteSwapInExtendedPublicKey = NodeParamsManager.remoteSwapInXpub, + ).nostrPrivateKey() + + /** The x-only public key of [nostrPrivateKeyOf]. */ + fun nostrPublicKeyOf(words: List): HexKey = nostrPrivateKeyOf(words).nostrPublicKeyHex() + + /** A wallet whose credential is missing: the key is derived from the words, as it always was. */ + fun mnemonic(userWallet: UserWallet): Mnemonic = + Mnemonic(userWallet, nostrPrivateKeyOf(userWallet.words)) + + fun nostrSecret(privateKey: PrivateKey): NostrSecret { + val xOnly = privateKey.publicKey().xOnly() + return NostrSecret( + id = xOnly.toWalletId(), + nostrPublicKey = xOnly.value.toHex(), + privateKey = privateKey, + ) + } + + fun nostrPublic(publicKeyHex: HexKey): NostrPublic = NostrPublic( + id = publicKeyHex.toXonlyPublicKey().toWalletId(), + nostrPublicKey = publicKeyHex, + ) + + /** + * One map from the two stores: the credentials are the list, and the seeds are + * attached to it. [credentials] is keyed by x-only public key hex, as + * `nostr-credentials.dat` is; a secret's id is derived from its own key, so a + * stored secret whose map key disagrees with its public key is dropped here too, + * though `EncryptedNostrCredentials` refuses such a file before it gets this far. + * + * A `Secret` whose key a seed derives is that seed's profile, listed once, as a + * [Mnemonic] under the wallet's id and carrying the credential's key. A `Public` + * for such a key is skipped with a log line: `IdentityWriter.writeMnemonic` replaces + * it with a secret before it writes the seed, and `SeedCredentials.reconcile` does + * the same for one left behind, so it is a state the next listing no longer sees. + * + * A seed with no credential at all -- the repair failed to write, or a crash fell + * between the seed writer's two files -- is listed as it always was, with its key + * derived from the words. A failed write must never hide a wallet. + */ + fun merge( + wallets: Map, + credentials: Map, + ): Map { + val walletsByPublicKey = wallets.values.associateBy { nostrPublicKeyOf(it.words) } + val merged = LinkedHashMap() + credentials.forEach { (publicKeyHex, credential) -> + when (credential) { + is NostrCredential.Secret -> { + val wallet = walletsByPublicKey[publicKeyHex] + val stored = if (wallet != null) Mnemonic(wallet, credential.privateKey) else nostrSecret(credential.privateKey) + if (stored.nostrPublicKey == publicKeyHex) merged[stored.id] = stored + } + is NostrCredential.Public -> { + if (publicKeyHex in walletsByPublicKey) { + log.w { "public credential for a key a seed derives; the next repair upgrades it" } + } else { + val stored = runCatching { nostrPublic(publicKeyHex) }.getOrNull() + if (stored != null) merged[stored.id] = stored + } + } + } + } + walletsByPublicKey.values.forEach { wallet -> + if (wallet.walletId !in merged) { + log.w { "wallet=${wallet.walletId} has no credential for its nostr key; deriving it" } + merged[wallet.walletId] = mnemonic(wallet) + } + } + return merged + } + + private val log = Logger.withTag("StoredIdentity") + } +} + +/** The x-only key as bitcoin-kmp types it, from the hex nostr carries it as. */ +fun HexKey.toXonlyPublicKey(): XonlyPublicKey = XonlyPublicKey(ByteVector32(this)) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt index 5e2a9c6a..2f80f727 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt @@ -91,6 +91,12 @@ object ChillDkgRitualManager { * be the nostr identity key: reusing one secret across two protocols means a * flaw in either can reach the other. Deriving it from the same seed keeps it * recoverable from the wallet backup without being the same key. + * + * The `mantra/` prefix is not a brand string and must never follow one. It is an + * input to the hash, so renaming it derives a different host key from the same + * seed -- which loses the outputs of every DKG session already run, since ChillDKG + * needs the host secret key to recover them. The Curated fork of this app keeps it + * byte for byte. Renaming it is a protocol fork. */ private const val HOST_KEY_DERIVATION_TAG = "mantra/chilldkg/host-key/v1" 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 bf049bee..5dfa9ce4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt @@ -925,9 +925,11 @@ object FrostSigningManager { // signature by now, so each applies the result itself rather than // waiting to be sent something it can already build -- the same // reasoning the transcript lines are written on. Nothing goes on the - // wire: a signed event authored by the threshold key cannot travel as - // an inner event anyway, because the outbound pipeline re-authors - // rumors as their sender and would strip the group's signature off. + // wire from here: a signed event authored by the threshold key cannot + // travel as an inner event anyway, because the outbound pipeline + // re-authors rumors as their sender and would strip the group's + // signature off. Sending one to relays is a member's separate act, + // from `BroadcastGroupSignedEventScreen`, off the recorded row. signedEvents.forEach { applySignedEvent(database, session, it, recorded) } announce( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SharedKeyDerivation.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SharedKeyDerivation.kt index 8589f4eb..265bb79f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SharedKeyDerivation.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SharedKeyDerivation.kt @@ -61,6 +61,16 @@ object SharedKeyDerivation { * Domain separation for the tweak scalars. Versioned so a future scheme can * coexist with keys already derived under this one, and distinct from any other * use of the same threshold key. + * + * The `mantra/` prefix is not a brand string and must never follow one. It is an + * input to the derivation, so changing it changes every key derived under it, + * for the same reason [MARMOT_ADMIN_GROUP_PATH] has to stay put: every room + * already created would be orphaned, and a device on the old string and a device + * on the new one would derive different keys from the same threshold key, so + * their partial signatures would aggregate to nothing that verifies. The Curated + * fork of this app keeps this string byte for byte for exactly that reason -- a + * Mantra member and a Curated member of one group sign under one key. Renaming + * it is a protocol fork, and belongs in a `v2` tag if it is ever wanted. */ private const val TWEAK_TAG = "mantra/shared-key/tweak/v1" diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/UserAgent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/UserAgent.kt index 87744cfd..557b63dc 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/UserAgent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/UserAgent.kt @@ -1,8 +1,8 @@ package press.mantra.compose.network object UserAgent { - const val APP_NAME = "Torch" - const val CLIENT_NAME = "Torch" + const val APP_NAME = "Mantra" + const val CLIENT_NAME = "Mantra" } fun String.asClientTag(): Array = arrayOf( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/EventPublishTransport.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/EventPublishTransport.kt new file mode 100644 index 00000000..a15fd5db --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/EventPublishTransport.kt @@ -0,0 +1,32 @@ +package press.mantra.compose.network.relays + +import kotlinx.coroutines.flow.Flow +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.network.dto.RelayDTO + +/** + * The slice of the relay layer a one-off publish needs. + * + * The same cut [LiveSubscriptionTransport] makes and for the same reason: + * [RelaysSocketManager] starts collecting relay lists in its `init` and cannot be + * stood up in a test, so a screen that hands it an event would otherwise have no + * way to check what it does with the answers. + * + * Everything the app publishes as *itself* goes through the + * `BroadcastNostrEventRequest` queue instead, which is durable and retried. This + * exists for the one thing that must not: an event the group signed. The queue + * hangs off a `NostrEvent` row, and a row for the group's event would put its post + * into the home feed and offer it to every relay the sync loop reconciles with -- + * see `BroadcastGroupSignedEventViewModel` for the whole of that argument. + */ +interface EventPublishTransport { + + /** + * Sends [nostrEvent] to each of [relays], opening a socket to any it does not + * already hold, and answers once per relay: the relay's OK, its rejection, or + * the error that stopped it being asked. + * + * @see RelayPool.publishEvent + */ + suspend fun publishEvent(nostrEvent: NostrEvent, relays: Set): Flow +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt index d901402a..c79e7f7c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt @@ -2,25 +2,22 @@ package press.mantra.compose.network.relays import press.mantra.compose.network.dto.mapToRelayDTO import co.touchlab.kermit.Logger -import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import press.mantra.compose.extensions.nostrPublicKey /** @@ -29,13 +26,14 @@ import press.mantra.compose.extensions.nostrPublicKey * https://github.com/PrimalHQ/primal-android-app/blob/6db2e6862239335dede4162338d7c4f10ad35031/app/src/main/kotlin/net/primal/android/networking/relays/RelaysSocketManager.kt */ class RelaysSocketManager( - private val activeWalletStateFlow: StateFlow, + private val activeIdentityStateFlow: StateFlow, private val nostrSocketClientFactory: press.mantra.compose.network.sockets.NostrSocketClientFactory, private val cachingImportRepository: press.mantra.compose.repository.CachingImportRepository, private val relayRepository: press.mantra.compose.repository.RelayRepository, -) : LiveSubscriptionTransport { + /** Injectable so that a test can drive the identity observer on a dispatcher it controls. */ + private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO), +) : LiveSubscriptionTransport, EventPublishTransport { val logger = Logger.withTag("RelaysSocketManager") - private val scope = CoroutineScope(Dispatchers.IO) private val relayPoolsMutex = Mutex() /** @@ -57,36 +55,36 @@ class RelaysSocketManager( observeActiveUserId() } - private val observeRelayJobs = mutableMapOf() - + /** + * Follows the active identity's relay list into the pool, as a child of `collectLatest` + * so that the next identity cancels it -- the shape the pumps and the notary already + * have. + * + * It used to be a job per pubkey on [scope], replaced only when the *same* pubkey came + * round again, which meant a switch left the previous identity's observer running: + * two observers feeding [updateRelayPools], and the one pool following whichever relay + * list emitted last. A null identity closes nothing -- the pool is shared by pumps a + * null identity has already cancelled -- and the next identity's list replaces it + * through `changeRelays`, as it always did. + */ private fun observeActiveUserId() = scope.launch { - activeWalletStateFlow.collectLatest { activeWallet -> - if (activeWallet == null) { - // TODO: Cancel all pending jobs? - } - activeWallet?.business?.walletManager?.keyManager?.collectLatest { keyManager -> - keyManager?.nostrPublicKey()?.let { pubkey -> - observeRelayJobs[pubkey]?.cancel() - observeRelayJobs[pubkey] = observeRelays(pubkey) + activeIdentityStateFlow.collectLatest { identity -> + val publicKey = identity?.nostrPublicKey ?: return@collectLatest + try { + relayRepository.observePublicKeyRelays(publicKey = publicKey).collect { relays -> + val userRelays = relays.filter { it.type == "user" }.map { it.mapToRelayDTO() } + updateRelayPools(regularRelays = userRelays) } + } catch (error: CancellationException) { + logger.d { "Relay observation for $publicKey cancelled" } + throw error } } - - } - private fun observeRelays(publicKey: String): Job = - scope.launch { - try { - relayRepository.observePublicKeyRelays(publicKey = publicKey).collect { relays -> - val userRelays = relays.filter { it.type == "user" }.map { it.mapToRelayDTO() } - updateRelayPools(regularRelays = userRelays) - } - } catch (error: CancellationException) { - logger.w(throwable = error) { "Relay observation cancelled" } - } - } + /** The urls the pool currently holds. For the switch test; nothing in the app reads it. */ + val relayUrls: Set get() = relayPool.relays.map { it.url }.toSet() private suspend fun updateRelayPools(regularRelays: List?) { relayPoolsMutex.withLock { @@ -118,7 +116,7 @@ class RelaysSocketManager( } @Throws(press.mantra.compose.exceptions.NostrPublishException::class) - suspend fun publishEvent(nostrEvent: press.mantra.compose.database.model.NostrEvent, relays: Set): Flow { + override suspend fun publishEvent(nostrEvent: press.mantra.compose.database.model.NostrEvent, relays: Set): Flow { logger.d("publishEvent: $nostrEvent") return relayPool.publishEvent( nostrEvent = nostrEvent, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/DirectMessagePeerSync.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/DirectMessagePeerSync.kt new file mode 100644 index 00000000..a77c4819 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/DirectMessagePeerSync.kt @@ -0,0 +1,96 @@ +package press.mantra.compose.nostr + +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import press.mantra.compose.database.model.NegentropySynchronizeRequest +import press.mantra.compose.database.model.SynchronizeNostrEventRequest +import press.mantra.compose.database.model.types.SynchronizationFilter + +/** + * Asking the relays for what a direct message with one person needs. + * + * Two asks, because two sets of relays hold two different things. The DM relays + * hold the key package and the DM relay list, and the kind 0 of anyone who has + * published one here; the indexer relays hold everyone's kind 0 and nothing else a + * chat needs. A three-kind filter to the indexers comes back with the kind 0 alone; + * a kind-0-only filter to our relay finds a person who has never opened Mantra + * nowhere. + * + * Both at level 0, for the reason `MemberProfileSync` gives: it marks a request as + * one somebody is waiting on, and a kind 0 arriving at level 0 over a placeholder + * queues the rest of that person's profile kinds off the back of it + * (`NostrDao.indexNostrEvent`). From an indexer that follow-up asks the indexer for + * key packages it does not hold, which is harmless and is why [requests] is queued + * explicitly rather than relied on. + * + * See docs/npub-profile-preview.md. + */ +object DirectMessagePeerSync { + /** The purpose the chat screen has queued under since it was written; kept so nothing reading the queue by purpose changes. */ + const val PURPOSE = "initiate-chat" + + /** The kind 0 on its own, for the preview that shows a person before a chat is started. */ + const val PROFILE_PURPOSE = "profile-preview" + + /** + * What a chat needs: the peer's metadata, their MLS key package and their DM + * relay list. + * + * No `limit` on the filter built from these: it is ignored on the negentropy path + * but honoured on the REQ fallback, where `limit = 1` returned a single newest + * event across all three kinds -- almost always the kind 0 -- so the key package + * never arrived. + */ + val KINDS = arrayOf( + MetadataEvent.KIND, + KeyPackageEvent.KIND, + ChatMessageRelayListEvent.KIND, + ) + + /** + * One negentropy request per DM relay for the three kinds, authored by + * [peerPublicKey]. The id is `computeId`'s, bucketed by minute, so a retry inside + * the same minute re-arms the row rather than adding one. + */ + fun requests(peerPublicKey: HexKey): List { + val synchronizationFilter = SynchronizationFilter( + kinds = KINDS, + authors = arrayOf(peerPublicKey), + ) + + return Relays.DefaultDMRelayList.map { relay -> + NegentropySynchronizeRequest( + id = NegentropySynchronizeRequest.computeId( + relayURL = relay.url, + synchronizationFilter = synchronizationFilter, + ), + purpose = PURPOSE, + synchronizationFilter = synchronizationFilter, + relayURL = relay.url, + level = 0, + ) + } + } + + /** + * The kind 0 alone, as a REQ to `SignInSync.bootstrapRelays` -- the indexers plus + * our own -- which is where a key pasted from elsewhere has lived. + */ + fun profileRequests(peerPublicKey: HexKey): List { + val synchronizationFilter = SynchronizationFilter( + kinds = arrayOf(MetadataEvent.KIND), + authors = arrayOf(peerPublicKey), + ) + + return SignInSync.bootstrapRelays.map { relay -> + SynchronizeNostrEventRequest( + purpose = PROFILE_PURPOSE, + synchronizationFilters = arrayOf(synchronizationFilter), + relayURL = relay.url, + level = 0, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MemberProfileSync.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MemberProfileSync.kt index f245e9a6..01b40b9d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MemberProfileSync.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MemberProfileSync.kt @@ -2,7 +2,6 @@ package press.mantra.compose.nostr import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import press.mantra.compose.database.GENESIS_AT import press.mantra.compose.database.model.NegentropySynchronizeRequest import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.types.SynchronizationFilter @@ -11,7 +10,7 @@ import press.mantra.compose.database.model.types.SynchronizationFilter * Asking the relays who a room's members are, for the members this device cannot name. * * A pubkey gets a Profile row the moment it is first seen, and that row is a - * placeholder: the "LOADING..." display name, stamped [GENESIS_AT] so it is + * placeholder: the "LOADING..." display name, stamped `GENESIS_AT` so it is * distinguishable from a profile actually read off a kind:0. What replaces it is a * kind:0, and every path that mints a placeholder asks for one -- exactly once, on * whichever relay that pubkey happened to arrive on. @@ -55,7 +54,7 @@ object MemberProfileSync { ): List = rooms .flatMap { it.localParticipants } - .filter { participant -> participant.profile?.let { it.createdAt > GENESIS_AT } != true } + .filter { participant -> participant.profile?.isResolved() != true } .map { it.participant.participantPublicKey } .filter { it != activeUserPublicKey } .distinct() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/Relays.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/Relays.kt index 6007748b..12b68c84 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/Relays.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/Relays.kt @@ -6,6 +6,10 @@ import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType object Relays { + // A running relay, not a brand string: it is in the bootstrap, publish, finder and + // DM sets, so pointing it at a name nobody has stood up would take the app off the + // network. The Curated fork of this app still talks to this same host, so the two + // apps' users find each other here. val ephemeral = RelayUrlNormalizer.normalize("wss://ephemeral.mantra.press") val nos = RelayUrlNormalizer.normalize("wss://nos.lol") diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/SignInSync.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/SignInSync.kt new file mode 100644 index 00000000..93e62645 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/SignInSync.kt @@ -0,0 +1,91 @@ +package press.mantra.compose.nostr + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent +import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import press.mantra.compose.database.model.SynchronizeNostrEventRequest +import press.mantra.compose.database.model.types.SynchronizationFilter + +/** + * The sync that finds an existing identity's profile: what it asks for, whom it asks, + * and the one hop it follows. + * + * Three call sites want the same request -- the screen that first queues it, the + * "try again" on the screen that waits for it, and the hop below -- so it is built here + * once. See docs/nsec-sign-in.md, Phase 5. + */ +object SignInSync { + const val PURPOSE = "sign-in" + + /** A request has left the queue but no relay has finished answering it. */ + val IN_FLIGHT_STATUSES = setOf("pending", "sent") + + /** Set once a relay has said everything it has -- EOSE, CLOSED or the bounded timeout. */ + const val STATUS_COMPLETE = "complete" + + /** The profile, the contacts, and every relay list the app reads. */ + val KINDS = arrayOf( + MetadataEvent.KIND, + ContactListEvent.KIND, + AdvertisedRelayListEvent.KIND, + ChatMessageRelayListEvent.KIND, + SearchRelayListEvent.KIND, + IndexerRelayListEvent.KIND, + ChannelListEvent.KIND, + RelayFeedsListEvent.KIND, + ) + + /** + * Whom to ask first. Indexer relays exist to hold everyone's kinds 0, 3 and 10002 -- + * that is the whole of their purpose, and it is exactly the list above -- so they are + * where an identity that has lived elsewhere for years will be found. Our own relay + * stays in the set so a profile created here is found here. This used to be our relay + * alone, which is the right answer only for a profile this app made. + */ + val bootstrapRelays: List = + (Relays.DefaultIndexerRelayList + Relays.ephemeral).distinct() + + fun requests( + unsignedNostrEventId: Long, + publicKey: HexKey, + relays: Collection, + level: Int, + ): List = relays.map { relay -> + SynchronizeNostrEventRequest( + purpose = PURPOSE, + synchronizationFilters = arrayOf( + SynchronizationFilter( + authors = arrayOf(publicKey), + kinds = KINDS, + ) + ), + unsignedNostrEventId = unsignedNostrEventId, + relayURL = relay.url, + level = level, + ) + } + + /** + * The relays a kind 10002 says its author writes to -- `r` tags with no marker or a + * `write` marker -- less the ones already asked. This is the outbox model doing what + * it is for: the indexers know *where* the user publishes, and the user's own relays + * are where the rest of their lists are authoritative. Bounded at one hop by the + * caller, which only follows from a level-0 request; anything past that is the feed, + * not the profile. + */ + fun writeRelaysOf(tags: Array>, alreadyAsked: Collection): List = + tags.asSequence() + .filter { it.size >= 2 && it[0] == "r" && (it.getOrNull(2).isNullOrBlank() || it[2] == "write") } + .mapNotNull { RelayUrlNormalizer.normalizeOrNull(it[1]) } + .distinct() + .filterNot { it in alreadyAsked } + .toList() +} 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 8fbfa95e..42794c40 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -79,6 +79,17 @@ interface ChatRepository { */ suspend fun parentOf(chatRoomId: String): HexKey? + /** + * One event the group signed, as the group signed it, or null when this + * device does not hold it or it was signed in some other room. + * + * Scoped to the room rather than looked up by id alone, because the id + * arrives as a navigation argument and the screen it opens names a room in + * its title: an event from one room shown under another room's name would + * be a lie the signature check cannot catch, since both rooms' events verify. + */ + suspend fun groupSignedEvent(chatRoomId: String, id: HexKey): GroupSignedEvent? + /** * Whether this device holds a share of the group's key, and so could take * part in signing for it. @@ -105,6 +116,16 @@ interface ChatRepository { suspend fun getAllParticipantsWithPubKey(publicKey: HexKey): List + /** + * The room that is a conversation between [userPublicKey] and [peerPublicKey] + * alone, if there is one: its members are exactly the two of them, or the one of + * them when the two keys are the same. A group of two is not told apart from a + * direct message here -- the schema does not record which a room is -- so this is + * the rule `ChatRoomMessagingViewModel.initiateNewChat` has applied since it was + * written, in one place with its name on it. See docs/npub-profile-preview.md. + */ + suspend fun findDirectMessageRoom(userPublicKey: HexKey, peerPublicKey: HexKey): LocalChatRoom? + suspend fun getChatMessageListByChatRoomId(chatRoomId: String): List suspend fun observeChatMessageListByChatRoomId(chatRoomId: String): Flow> @@ -260,6 +281,11 @@ interface ChatRepository { override suspend fun parentOf(chatRoomId: String): HexKey? = null + override suspend fun groupSignedEvent( + chatRoomId: String, + id: HexKey + ): GroupSignedEvent? = null + override suspend fun canSign(chatRoomId: String): Boolean = false override suspend fun refuseSubgroup( @@ -284,6 +310,10 @@ interface ChatRepository { TODO("Not yet implemented") } + override suspend fun findDirectMessageRoom(userPublicKey: HexKey, peerPublicKey: HexKey): LocalChatRoom? { + TODO("Not yet implemented") + } + override suspend fun getChatMessageListByChatRoomId(chatRoomId: String): List { return emptyList() } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrNotaryRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrNotaryRepository.kt index 5195690d..c56aa42a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrNotaryRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrNotaryRepository.kt @@ -4,8 +4,8 @@ import press.mantra.compose.cryptography.signOrThrow import press.mantra.compose.network.asClientTag import com.vitorpamplona.quartz.nip19Bech32.toNsec import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent -import fr.acinq.phoenix.managers.WalletManager -import fr.acinq.phoenix.managers.nostrPrivateKey +import kotlinx.coroutines.flow.StateFlow +import press.mantra.compose.identity.Identity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel @@ -23,7 +23,7 @@ import kotlinx.coroutines.sync.withLock */ class NostrNotaryRepository( private val nostrRepository: NostrRepository, - private val walletManager: WalletManager, + private val activeIdentityStateFlow: StateFlow, ) { private val scope = CoroutineScope(Dispatchers.Main) @@ -67,7 +67,7 @@ class NostrNotaryRepository( private fun findNsecOrThrow(activeUserPublicKey: String): String = runCatching { - walletManager.keyManager.value?.nostrPrivateKey()?.value?.toByteArray()?.toNsec() + activeIdentityStateFlow.value?.nostrPrivateKey?.value?.toByteArray()?.toNsec() }.getOrNull() ?: throw press.mantra.compose.exceptions.SigningKeyNotFoundException() private fun signNostrEvent(publicKey: String, event: press.mantra.compose.database.model.UnsignedNostrEvent): press.mantra.compose.database.model.NostrEvent { 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 5372e220..4e8a9805 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt @@ -45,9 +45,17 @@ interface NostrRepository { suspend fun observePendingBroadcastNostrEventRequests(): Flow - suspend fun observePendingSynchronizeNostrEventRequests(): Flow + /** The head of the fetch queue for one identity: its own requests and the ones nobody owns. */ + suspend fun observePendingSynchronizeNostrEventRequests(ownerPublicKey: HexKey): Flow - suspend fun observePendingNegentropySynchronizeRequests(): Flow + suspend fun observePendingNegentropySynchronizeRequests(ownerPublicKey: HexKey): Flow + + /** + * Opens every gift wrap addressed to [activeKeyPair] that the device stored and could + * not open at the time. Run once per activation, before the live inbox is opened. + * Returns how many were opened. + */ + suspend fun reopenInbox(activeKeyPair: KeyPair): Int suspend fun createNewProfile( publicKey: HexKey, @@ -60,6 +68,26 @@ interface NostrRepository { publicKey: HexKey ) + /** + * The exit from a sign-in whose search found nothing: the key already exists, only + * the events do not. Gives [publicKey] the same bootstrap [createNewProfile] gives a + * fresh key, with the kind 0 written *over* the placeholder [signInToProfile] planted + * -- see the implementation for why over rather than beside. + */ + suspend fun setUpProfileForExistingKey( + publicKey: HexKey, + placeholderUnsignedNostrEventId: Long, + name: String?, + biography: String?, + ) + + /** + * Stops treating [publicKey] as one of this device's own: its unsigned rows go, and + * with them the requests that hang off them. What it published, and its profile, stay + * cached as anyone else's would. + */ + suspend fun forgetLocalAccount(publicKey: HexKey) + suspend fun createNewTextNote( publicKey: HexKey, mentionedPublicKeys: List, @@ -93,6 +121,13 @@ interface NostrRepository { suspend fun synchronizeNostrEventRequestProcessed(synchronizeNostrEventRequest: SynchronizeNostrEventRequest) + /** + * The relay has finished answering [synchronizeNostrEventRequest] -- EOSE, CLOSED or + * the bounded timeout -- and the request is still `sent`, meaning nothing arrived. Until + * this existed "still searching" and "searched, found nothing" were the same row. + */ + suspend fun synchronizeNostrEventRequestCompleted(synchronizeNostrEventRequest: SynchronizeNostrEventRequest) + suspend fun negentropySynchronizeRequestProcessed(negentropySynchronizeRequest: NegentropySynchronizeRequest) suspend fun saveBroadcastReceipt(broadcastNostrEventReceipt: BroadcastNostrEventReceipt) @@ -210,11 +245,13 @@ interface NostrRepository { TODO("Not yet implemented") } - override suspend fun observePendingSynchronizeNostrEventRequests(): Flow { + override suspend fun reopenInbox(activeKeyPair: KeyPair): Int = 0 + + override suspend fun observePendingSynchronizeNostrEventRequests(ownerPublicKey: HexKey): Flow { TODO("Not yet implemented") } - override suspend fun observePendingNegentropySynchronizeRequests(): Flow { + override suspend fun observePendingNegentropySynchronizeRequests(ownerPublicKey: HexKey): Flow { TODO("Not yet implemented") } @@ -231,6 +268,19 @@ interface NostrRepository { TODO("Not yet implemented") } + override suspend fun setUpProfileForExistingKey( + publicKey: HexKey, + placeholderUnsignedNostrEventId: Long, + name: String?, + biography: String?, + ) { + TODO("Not yet implemented") + } + + override suspend fun forgetLocalAccount(publicKey: HexKey) { + TODO("Not yet implemented") + } + override suspend fun createNewTextNote( publicKey: HexKey, mentionedPublicKeys: List, @@ -278,6 +328,10 @@ interface NostrRepository { TODO("Not yet implemented") } + override suspend fun synchronizeNostrEventRequestCompleted(synchronizeNostrEventRequest: SynchronizeNostrEventRequest) { + TODO("Not yet implemented") + } + override suspend fun negentropySynchronizeRequestProcessed(negentropySynchronizeRequest: NegentropySynchronizeRequest) { TODO("Not yet implemented") } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ProfilesOf.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ProfilesOf.kt new file mode 100644 index 00000000..990ac668 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ProfilesOf.kt @@ -0,0 +1,24 @@ +package press.mantra.compose.repository + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import press.mantra.compose.database.model.Profile + +/** + * The nostr profile of each of [publicKeys], as one map that moves when any of them + * does. For the two lists of the device's profiles -- the switcher and the startup + * selector -- which show a name and a picture where the device has a kind 0 and the + * npub where it does not. + */ +fun NostrRepository.observeProfilesOf(publicKeys: Collection): Flow> { + val keys = publicKeys.distinct() + if (keys.isEmpty()) return flowOf(emptyMap()) + val flows = keys.map { publicKey -> + flow { emitAll(observeProfileWithPublicKey(publicKey)) } + } + return combine(flows) { profiles -> keys.zip(profiles.toList()).toMap() } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt index 3f90c02c..4b67bc7d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Bento import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Hub @@ -23,7 +22,6 @@ import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface @@ -52,10 +50,10 @@ import press.mantra.compose.ui.view.model.ActiveProfileViewModel import press.mantra.compose.ui.view.state.ActiveProfileUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import press.mantra.compose.ui.composable.widgets.LocalCanSign import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource -import mantra.composeapp.generated.resources.change_account import mantra.composeapp.generated.resources.edit_profile import mantra.composeapp.generated.resources.key_package_management import mantra.composeapp.generated.resources.network_relays @@ -63,6 +61,18 @@ import mantra.composeapp.generated.resources.profile import mantra.composeapp.generated.resources.key_recovery import mantra.composeapp.generated.resources.share_profile import mantra.composeapp.generated.resources.sign_out +import mantra.composeapp.generated.resources.switch_profile +import press.mantra.compose.ui.composable.navigation.routes.ProfilesRoute +import mantra.composeapp.generated.resources.sign_out_of_this_profile_question +import mantra.composeapp.generated.resources.could_not_sign_out_please_try_again +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.IdentityWriter +import press.mantra.compose.ui.composable.widgets.dialogs.SignOutOfReadOnlyDialog +import press.mantra.compose.ui.composable.widgets.rememberNotifier +import press.mantra.compose.ui.view.model.SignOutViewModel +import kotlinx.coroutines.flow.StateFlow +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.rememberCoroutineScope import mantra.composeapp.generated.resources.something_went_wrong import mantra.composeapp.generated.resources.we_couldn_t_find_the_local_profile_please import press.mantra.compose.ui.composable.widgets.ErrorState @@ -72,6 +82,7 @@ import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.ButtonDefaults import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @@ -83,8 +94,42 @@ fun ActiveProfileScreen( onNavigateBack: () -> Unit, onNavigateToRoute: (Route) -> Unit, nostrRepository: NostrRepository, + // Sign out is real for a read-only identity only -- there is nothing on the device + // to lose -- and these are what it takes. Null where the screen is composed without + // an identity to sign out of (previews, tests), in which case the button keeps its + // pending route for every kind. + signOut: SignOutDependencies? = null, ) { + // Whether this identity can sign, which decides which rows below exist. + val canSign = LocalCanSign.current + + val signOutViewModel: SignOutViewModel? = signOut?.let { + viewModel( + factory = SignOutViewModel.factory( + activeIdentityStateFlow = it.activeIdentityStateFlow, + nostrRepository = nostrRepository, + forgetNostrCredential = it.forgetNostrCredential, + hideIdentityMetadata = it.hideIdentityMetadata, + ), + ) + } + val signOutState = signOutViewModel?.state?.collectAsState()?.value + if (signOutViewModel != null && signOut != null && signOutState is SignOutViewModel.State.Confirming) { + SignOutOfReadOnlyDialog( + title = Res.string.sign_out_of_this_profile_question, + nostrPublicKey = signOutViewModel.nostrPublicKey, + onConfirm = { signOutViewModel.signOut(onSignedOut = signOut.onSignedOut) }, + onDismiss = { signOutViewModel.cancel() }, + ) + } + // The failure is short and needs no action: the button is still there to try again. + val notify = rememberNotifier(rememberCoroutineScope()) + val couldNotSignOut = stringResource(Res.string.could_not_sign_out_please_try_again) + LaunchedEffect(signOutState) { + if (signOutState is SignOutViewModel.State.Failed) notify(couldNotSignOut) + } + val activeProfileViewModel: ActiveProfileViewModel = viewModel( factory = ActiveProfileViewModel.factory( nostrEventId = nostrEventId, @@ -109,18 +154,7 @@ fun ActiveProfileScreen( text = stringResource(Res.string.profile) ) }, - navigationIcon = { - IconButton( - onClick = { - onNavigateBack.invoke() - } - ) { - Icon( - Icons.Default.ArrowBack, - contentDescription = "Back" - ) - } - } + navigationIcon = { NavigateBackButton(onNavigateBack) } ) } ) { innerPadding -> @@ -238,7 +272,10 @@ fun ActiveProfileScreen( } } - item { + // Key packages are signed, and there is nothing to recover for + // an identity the device holds no secret for: neither row for a + // read-only identity. + if (canSign) item { TextButton( onClick = { onNavigateToRoute.invoke( @@ -266,18 +303,18 @@ fun ActiveProfileScreen( } + // For every kind: a read-only identity can leave for another + // profile the same way a signing one can. item { TextButton( onClick = { - onNavigateToRoute.invoke( - ImplementationPendingRoute("Change account") - ) + onNavigateToRoute.invoke(ProfilesRoute) } ) { Icon( Icons.Default.ImportExport, - contentDescription = "Change profile" + contentDescription = Decorative ) Spacer( @@ -285,13 +322,13 @@ fun ActiveProfileScreen( ) Text( - stringResource(Res.string.change_account) + stringResource(Res.string.switch_profile) ) } } - item { + if (canSign) item { TextButton( onClick = { onNavigateToRoute.invoke( @@ -344,14 +381,26 @@ fun ActiveProfileScreen( // destructive action. It now matches how leaving and // deleting a group are already treated in // ChatRoomDetailScreen: a TextButton in the error colour. + // + // For a read-only identity it does what its colour has been + // promising: a confirmation naming the npub, then the + // identity leaves the device. For the other two kinds it + // keeps routing to the pending screen -- removing a seed is + // a wallet question, and an nsec has its own forget under + // key recovery. TextButton( colors = ButtonDefaults.textButtonColors( contentColor = MaterialTheme.colorScheme.error ), + enabled = signOutState !is SignOutViewModel.State.InProgress, onClick = { - onNavigateToRoute.invoke( - ImplementationPendingRoute("Sign out") - ) + if (!canSign && signOutViewModel != null) { + signOutViewModel.askToSignOut() + } else { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Sign out") + ) + } } ) { Icon( @@ -392,10 +441,23 @@ fun ActiveProfileScreen( } } +/** + * What signing out of a read-only identity needs, bundled so that the screen's parameter + * list gains one line rather than four, and so a caller that has none of them passes + * nothing rather than three lambdas that throw. + */ +class SignOutDependencies( + val activeIdentityStateFlow: StateFlow, + val forgetNostrCredential: suspend (Identity) -> IdentityWriter.ForgetNostrCredentialResult, + val hideIdentityMetadata: suspend (Identity) -> Unit, + /** The nav host's tail: re-list, and clear the active identity so startup shows the selector or Landing. */ + val onSignedOut: () -> Unit, +) + @ConformancePreviews @Composable private fun UnannouncedProfileScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt index c3b1df70..7a085e53 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt @@ -21,12 +21,9 @@ import androidx.compose.material.icons.filled.LocalOffer import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.BottomAppBar import androidx.compose.material3.BottomAppBarDefaults -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilterChip -import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -36,7 +33,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar -import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -46,8 +42,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.semantics.disabled -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -59,7 +53,7 @@ import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.state.ChatRoomMessagingUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.repository.MantraRepository @@ -84,8 +78,10 @@ import mantra.composeapp.generated.resources.source_dialect import mantra.composeapp.generated.resources.url import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @@ -101,6 +97,7 @@ fun AddArtifactScreen( frostSigningRepository: FrostSigningRepository, onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, onNavigateToRoute: (Route) -> Unit, + onNavigateBack: () -> Unit, ) { val addArtifactViewModel: AddArtifactViewModel = viewModel( factory = AddArtifactViewModel.factory( @@ -146,10 +143,6 @@ fun AddArtifactScreen( // about afterwards, and the button says so. val canAddArtifact = selectedDialectId != null && addArtifactUIState.canSign - // M3 gives a FAB no `enabled`, so borrow the disabled colours every - // other button in the app uses rather than inventing a shade here. - val buttonColors = ButtonDefaults.buttonColors() - // imePadding: this screen has a text field, and without it the software // keyboard covers whatever is being typed into. Scaffold( @@ -160,37 +153,18 @@ fun AddArtifactScreen( title = { addArtifactUIState.localChatRoom.RenderChatRoomTitleText() }, - actions = { - - } + navigationIcon = { NavigateBackButton(onNavigateBack) }, ) }, bottomBar = { BottomAppBar( actions = {}, floatingActionButton = { - ExtendedFloatingActionButton( - modifier = if (canAddArtifact) { - Modifier - } else { - // Looking unavailable is not being unavailable: - // without this a screen reader still announces - // a button it is happy to press. - Modifier.semantics { disabled() } - }, - containerColor = if (canAddArtifact) { - FloatingActionButtonDefaults.containerColor - } else { - buttonColors.disabledContainerColor - }, - contentColor = if (canAddArtifact) { - contentColorFor(FloatingActionButtonDefaults.containerColor) - } else { - buttonColors.disabledContentColor - }, + ExtendedFab( + label = stringResource(Res.string.propose_artifact), + icon = { Icon(Icons.Default.Add, contentDescription = "Propose artifact") }, + enabled = canAddArtifact, onClick = { - if (!canAddArtifact) return@ExtendedFloatingActionButton - addArtifactViewModel.addArtifact( localChatRoom = addArtifactUIState.localChatRoom, nameField = nameFieldState, @@ -220,13 +194,7 @@ fun AddArtifactScreen( ) } - ) { - Icon( - Icons.Default.Add, - contentDescription = "Propose artifact" - ) - Text(stringResource(Res.string.propose_artifact)) - } + ) } ) } @@ -405,7 +373,7 @@ fun AddArtifactScreen( @ConformancePreviews @Composable private fun AddArtifactScreenPreview() { - TorchTheme { + MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { @@ -430,7 +398,8 @@ private fun AddArtifactScreenPreview() { mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, onNavigateToRouteAndPopUpInclusive = {}, - onNavigateToRoute = {} + onNavigateToRoute = {}, + onNavigateBack = {}, ) } } 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 d2627f34..ccc6a166 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 @@ -10,29 +10,21 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.DriveFileRenameOutline import androidx.compose.material3.BottomAppBar -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.FloatingActionButtonDefaults 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.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar -import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.disabled -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -49,7 +41,7 @@ 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.LoadingDataIndicator -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.AddChapterViewModel import press.mantra.compose.ui.view.state.AddChapterUIState import press.mantra.compose.ui.theme.spacing @@ -67,6 +59,8 @@ import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.NavigateBackButton +import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3Api::class) @@ -134,10 +128,6 @@ fun AddChapterScreen( paragraphCount > 0 && !tooManyChunks - // M3 gives a FAB no `enabled`, so borrow the disabled colours every - // other button in the app uses rather than inventing a shade here. - val buttonColors = ButtonDefaults.buttonColors() - // imePadding: this screen has a text field, and without it the software // keyboard covers whatever is being typed into. Scaffold( @@ -146,41 +136,19 @@ fun AddChapterScreen( topBar = { TopAppBar( title = { Text(stringResource(Res.string.add_chapter_to, addChapterUIState.artifact.name)) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, ) }, bottomBar = { BottomAppBar( actions = {}, floatingActionButton = { - ExtendedFloatingActionButton( - modifier = if (canProposeChapter) { - Modifier - } else { - // Looking unavailable is not being unavailable. - Modifier.semantics { disabled() } - }, - containerColor = if (canProposeChapter) { - FloatingActionButtonDefaults.containerColor - } else { - buttonColors.disabledContainerColor - }, - contentColor = if (canProposeChapter) { - contentColorFor(FloatingActionButtonDefaults.containerColor) - } else { - buttonColors.disabledContentColor - }, + ExtendedFab( + label = stringResource(Res.string.propose_chapter), + icon = { Icon(Icons.Default.Add, contentDescription = "Propose chapter") }, + enabled = canProposeChapter, onClick = { - if (artifactVersion == null || !canProposeChapter) { - return@ExtendedFloatingActionButton - } + if (artifactVersion == null) return@ExtendedFab addChapterViewModel.addChapter( localChatRoom = addChapterUIState.localChatRoom, @@ -208,13 +176,7 @@ fun AddChapterScreen( } ) } - ) { - Icon( - Icons.Default.Add, - contentDescription = "Propose chapter" - ) - Text(stringResource(Res.string.propose_chapter)) - } + ) } ) } @@ -313,7 +275,7 @@ fun AddChapterScreen( @ConformancePreviews @Composable private fun AddChapterScreenPreview() { - TorchTheme { + MantraTheme { Surface(modifier = Modifier.fillMaxSize()) { AddChapterScreen( activeUserPublicKey = "", diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt index ee088cd2..97e8e4d7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt @@ -17,11 +17,8 @@ import androidx.compose.material.icons.filled.Title import androidx.compose.material.icons.filled.Translate import androidx.compose.material3.BottomAppBar import androidx.compose.material3.BottomAppBarDefaults -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -30,14 +27,11 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar -import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.semantics.disabled -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -51,7 +45,7 @@ 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.LoadingDataIndicator -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.AddDialectViewModel import press.mantra.compose.ui.view.state.AddDialectUIState import press.mantra.compose.ui.theme.spacing @@ -68,8 +62,10 @@ import mantra.composeapp.generated.resources.language import mantra.composeapp.generated.resources.propose_dialect import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @@ -84,6 +80,7 @@ fun AddDialectScreen( frostSigningRepository: FrostSigningRepository, onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, onNavigateToRoute: (Route) -> Unit, + onNavigateBack: () -> Unit, ) { val addDialectViewModel: AddDialectViewModel = viewModel( factory = AddDialectViewModel.factory( @@ -117,10 +114,6 @@ fun AddDialectScreen( val countryFieldState = rememberTextFieldState() val languageFieldState = rememberTextFieldState() - // M3 gives a FAB no `enabled`, so borrow the disabled colours every - // other button in the app uses rather than inventing a shade here. - val buttonColors = ButtonDefaults.buttonColors() - // imePadding: this screen has a text field, and without it the software // keyboard covers whatever is being typed into. Scaffold( @@ -131,35 +124,18 @@ fun AddDialectScreen( title = { addDialectUIState.localChatRoom.RenderChatRoomTitleText() }, - actions = { - - } + navigationIcon = { NavigateBackButton(onNavigateBack) }, ) }, bottomBar = { BottomAppBar( actions = {}, floatingActionButton = { - ExtendedFloatingActionButton( - modifier = if (addDialectUIState.canSign) { - Modifier - } else { - // Looking unavailable is not being unavailable. - Modifier.semantics { disabled() } - }, - containerColor = if (addDialectUIState.canSign) { - FloatingActionButtonDefaults.containerColor - } else { - buttonColors.disabledContainerColor - }, - contentColor = if (addDialectUIState.canSign) { - contentColorFor(FloatingActionButtonDefaults.containerColor) - } else { - buttonColors.disabledContentColor - }, + ExtendedFab( + label = stringResource(Res.string.propose_dialect), + icon = { Icon(Icons.Default.Add, contentDescription = "Propose dialect") }, + enabled = addDialectUIState.canSign, onClick = { - if (!addDialectUIState.canSign) return@ExtendedFloatingActionButton - addDialectViewModel.addDialect( localChatRoom = addDialectUIState.localChatRoom, nameField = nameFieldState, @@ -186,13 +162,7 @@ fun AddDialectScreen( } ) } - ) { - Icon( - Icons.Default.Add, - contentDescription = "Propose dialect" - ) - Text(stringResource(Res.string.propose_dialect)) - } + ) } ) } @@ -340,7 +310,7 @@ fun AddDialectScreen( @ConformancePreviews @Composable private fun AddDialectScreenPreview() { - TorchTheme { + MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { @@ -364,7 +334,8 @@ private fun AddDialectScreenPreview() { chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, onNavigateToRouteAndPopUpInclusive = {}, - onNavigateToRoute = {} + onNavigateToRoute = {}, + onNavigateBack = {}, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddMemberToChatRoomConfirmationScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddMemberToChatRoomConfirmationScreen.kt index 4c6cca36..6f08fd13 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddMemberToChatRoomConfirmationScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddMemberToChatRoomConfirmationScreen.kt @@ -12,12 +12,12 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material3.BottomAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold 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.ui.Alignment @@ -32,7 +32,7 @@ import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.AddMemberToChatRoomConfirmationViewModel import press.mantra.compose.ui.view.state.AddMemberToChatRoomConfirmationUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -43,12 +43,15 @@ import mantra.composeapp.generated.resources.direct_message_detail import mantra.composeapp.generated.resources.direct_message_functionality_will_be_here import mantra.composeapp.generated.resources.invite import mantra.composeapp.generated.resources.invite_2 +import mantra.composeapp.generated.resources.invite_new_member import mantra.composeapp.generated.resources.once_invited_will_be_able_to_receive_and import mantra.composeapp.generated.resources.to_join_the_chat_room import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @@ -62,6 +65,7 @@ fun AddMemberToChatRoomConfirmationScreen( nostrRepository: NostrRepository, chatRepository: ChatRepository, onInviteSent: () -> Unit, + onNavigateBack: () -> Unit, ) { val addMemberToChatRoomConfirmationViewModel: AddMemberToChatRoomConfirmationViewModel = viewModel( factory = AddMemberToChatRoomConfirmationViewModel.factory( @@ -94,26 +98,23 @@ fun AddMemberToChatRoomConfirmationScreen( is AddMemberToChatRoomConfirmationUIState.Loaded -> { Scaffold( snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - // topBar = { - // TopAppBar( - // title = { - // val title = if (addMemberToChatRoomConfirmationUIState.localChatRoom.chatRoom.subject != null) { - // "Invite new member to ${addMemberToChatRoomConfirmationUIState.localChatRoom.chatRoom.subject}" - // } else { - // "Invite new member to chat" - // } - // Text( - // text = title, - // ) - // }, - // actions = { - // } - // ) - // } + topBar = { + // The body already names who is being invited to which room, + // so the bar says only what the screen is for. + TopAppBar( + title = { Text(stringResource(Res.string.invite_new_member)) }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) + }, bottomBar = { BottomAppBar( floatingActionButton = { - ExtendedFloatingActionButton( + ExtendedFab( + label = stringResource( + Res.string.invite_2, + addMemberToChatRoomConfirmationUIState.profile.humanReadableNameOrPubkey() + ), + icon = { Icon(Icons.Default.Add, contentDescription = "Invite new member") }, onClick = { addMemberToChatRoomConfirmationViewModel.inviteToChatRoom( localChatRoom = addMemberToChatRoomConfirmationUIState.localChatRoom, @@ -122,14 +123,7 @@ fun AddMemberToChatRoomConfirmationScreen( onInviteSent = onInviteSent ) } - ) { - Icon( - Icons.Default.Add, - contentDescription = "Invite new member" - ) - - Text(stringResource(Res.string.invite_2, addMemberToChatRoomConfirmationUIState.profile.humanReadableNameOrPubkey())) - } + ) }, actions = { @@ -217,7 +211,7 @@ fun AddMemberToChatRoomConfirmationScreen( @ConformancePreviews @Composable private fun ChatRoomDetailScreenPreview() { - TorchTheme { + MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { @@ -227,7 +221,7 @@ private fun ChatRoomDetailScreenPreview() { chatRoomId = "publicKey", relayHint = null, initialChatRoomDetailUIState = -// AddMemberToChatRoomConfirmationUIState.Error("Profile missing key package event.\n\nTry again later or tell Person to use Torch."), +// AddMemberToChatRoomConfirmationUIState.Error("Profile missing key package event.\n\nTry again later or tell Person to use Mantra."), AddMemberToChatRoomConfirmationUIState.Loaded( localChatRoom = LocalChatRoom( chatRoom = ChatRoom( @@ -252,7 +246,8 @@ private fun ChatRoomDetailScreenPreview() { ), nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - onInviteSent = {} + onInviteSent = {}, + onNavigateBack = {}, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt index 366a627a..dbd2592a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt @@ -10,22 +10,16 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Translate import androidx.compose.material3.BottomAppBar -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilterChip -import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar -import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -34,8 +28,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.disabled -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -53,7 +45,7 @@ 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.LoadingDataIndicator -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.AddTranslationArtifactVersionViewModel import press.mantra.compose.ui.view.state.AddTranslationArtifactVersionUIState import press.mantra.compose.ui.theme.spacing @@ -68,6 +60,8 @@ import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.NavigateBackButton +import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @@ -136,50 +130,24 @@ fun AddTranslationArtifactVersionScreen( selectedDialect != null && addTranslationUIState.canSign - // M3 gives a FAB no `enabled`, so borrow the disabled colours every - // other button in the app uses rather than inventing a shade here. - val buttonColors = ButtonDefaults.buttonColors() - Scaffold( snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, topBar = { TopAppBar( title = { Text(stringResource(Res.string.translate, addTranslationUIState.artifact.name)) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, ) }, bottomBar = { BottomAppBar( actions = {}, floatingActionButton = { - ExtendedFloatingActionButton( - modifier = if (canProposeTranslation) { - Modifier - } else { - // Looking unavailable is not being unavailable. - Modifier.semantics { disabled() } - }, - containerColor = if (canProposeTranslation) { - FloatingActionButtonDefaults.containerColor - } else { - buttonColors.disabledContainerColor - }, - contentColor = if (canProposeTranslation) { - contentColorFor(FloatingActionButtonDefaults.containerColor) - } else { - buttonColors.disabledContentColor - }, + ExtendedFab( + label = stringResource(Res.string.propose_translation), + icon = { Icon(Icons.Default.Translate, contentDescription = "Propose translation") }, + enabled = canProposeTranslation, onClick = { - if (artifactVersion == null || selectedDialect == null || !canProposeTranslation) { - return@ExtendedFloatingActionButton - } + if (artifactVersion == null || selectedDialect == null) return@ExtendedFab addTranslationArtifactVersionViewModel.addTranslation( localChatRoom = addTranslationUIState.localChatRoom, @@ -209,13 +177,7 @@ fun AddTranslationArtifactVersionScreen( } ) } - ) { - Icon( - Icons.Default.Translate, - contentDescription = "Propose translation" - ) - Text(stringResource(Res.string.propose_translation)) - } + ) } ) } @@ -317,7 +279,7 @@ fun AddTranslationArtifactVersionScreen( @ConformancePreviews @Composable private fun AddTranslationArtifactVersionScreenPreview() { - TorchTheme { + MantraTheme { Surface(modifier = Modifier.fillMaxSize()) { AddTranslationArtifactVersionScreen( activeUserPublicKey = "", 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 627f7b97..b8fd9da9 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 @@ -11,7 +11,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Article import androidx.compose.material.icons.filled.PostAdd import androidx.compose.material.icons.filled.Translate @@ -19,7 +18,6 @@ import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -44,7 +42,7 @@ import press.mantra.compose.ui.composable.navigation.routes.ChapterDetailRoute 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.LoadingDataIndicator -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.ArtifactDetailViewModel import press.mantra.compose.ui.view.state.ArtifactDetailUIState import press.mantra.compose.ui.theme.spacing @@ -67,6 +65,7 @@ import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3Api::class) @@ -111,14 +110,7 @@ fun ArtifactDetailScreen( topBar = { TopAppBar( title = { Text(artifact.name) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, ) } ) { innerPadding -> @@ -333,7 +325,7 @@ fun ArtifactDetailScreen( @ConformancePreviews @Composable private fun ArtifactDetailScreenPreview() { - TorchTheme { + MantraTheme { Surface(modifier = Modifier.fillMaxSize()) { ArtifactDetailScreen( activeUserPublicKey = "", 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 new file mode 100644 index 00000000..99fe9a19 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt @@ -0,0 +1,575 @@ +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.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.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.CellTower +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.BottomAppBarDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +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.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.accepted_by_n_of_m_relays +import mantra.composeapp.generated.resources.add_a_relay_to_broadcast_to +import mantra.composeapp.generated.resources.add_relay +import mantra.composeapp.generated.resources.broadcast +import mantra.composeapp.generated.resources.copied_the_raw_json +import mantra.composeapp.generated.resources.copy_raw_json +import mantra.composeapp.generated.resources.eg_wss_relay_example_com +import mantra.composeapp.generated.resources.no_relays_to_broadcast_to +import mantra.composeapp.generated.resources.relay_url +import mantra.composeapp.generated.resources.relays +import mantra.composeapp.generated.resources.sends_the_event_exactly_as_the_group_signed_it +import mantra.composeapp.generated.resources.signed_by_the_group_on +import mantra.composeapp.generated.resources.that_is_not_a_relay_address +import mantra.composeapp.generated.resources.that_relay_is_already_in_this_list +import mantra.composeapp.generated.resources.the_signed_event +import mantra.composeapp.generated.resources.the_relays_this_app_publishes_to_are_filled_in +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.network.relays.EventPublishTransport +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.text.ProposedEvent +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.EmptyState +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.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.label +import press.mantra.compose.ui.composable.widgets.rememberNotifier +import press.mantra.compose.ui.composable.widgets.NavigateBackButton +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.BroadcastGroupSignedEventViewModel +import press.mantra.compose.ui.view.model.BroadcastGroupSignedEventViewModel.RelayOutcome +import press.mantra.compose.ui.view.state.BroadcastGroupSignedEventUIState +import press.mantra.compose.extensions.toFormattedTimeAndDateString +import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlin.time.Instant + +/** + * One event the group signed, and the relays to send it to. + * + * The group's screen shows what the group has signed; this is where one of those + * leaves the device. The list starts at the app's own publish set and the member + * can change it, the button sends to every relay in it at once, and each row + * then says what its relay answered -- accepted, rejected in the relay's own + * words, or nothing. The raw JSON is at the bottom for anyone who would rather + * hand it to some other tool. + * + * **The button sends rather than proposes.** Every screen that opens a signing + * session makes a statement by the group. This one moves a statement the group + * has already made, which any member holding it may do: the signature is the + * group's, and no share of the key is needed to repeat it. See + * `BroadcastGroupSignedEventViewModel` for why the send goes to the pool + * directly rather than through the app's broadcast queue -- and for the fact + * that, at present, nothing in the app opens this screen. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun BroadcastGroupSignedEventScreen( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + signedEventId: HexKey, + initialBroadcastGroupSignedEventUIState: BroadcastGroupSignedEventUIState = + BroadcastGroupSignedEventUIState.Loading, + chatRepository: ChatRepository, + publishTransport: EventPublishTransport, + onNavigateBack: () -> Unit, +) { + val broadcastGroupSignedEventViewModel: BroadcastGroupSignedEventViewModel = viewModel( + factory = BroadcastGroupSignedEventViewModel.factory( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + signedEventId = signedEventId, + initialBroadcastGroupSignedEventUIState = initialBroadcastGroupSignedEventUIState, + chatRepository = chatRepository, + publishTransport = publishTransport, + ) + ) + + val clipboardManager = LocalClipboardManager.current + + // Read out here rather than in a click handler: both are composable and an + // onClick lambda is not. + val notify = rememberNotifier(rememberCoroutineScope()) + val noRelays = stringResource(Res.string.add_a_relay_to_broadcast_to) + val copied = stringResource(Res.string.copied_the_raw_json) + + ScreenStateTransition(broadcastGroupSignedEventViewModel.broadcastGroupSignedEventUIState) { uiState -> + when (val broadcastGroupSignedEventUIState = uiState) { + is BroadcastGroupSignedEventUIState.Error -> { + // Nothing to retry: the room and the event came from navigation + // arguments, and reading them again with the same ones fails the + // same way. + ErrorState(message = broadcastGroupSignedEventUIState.message, onRetry = null) + } + + is BroadcastGroupSignedEventUIState.Loaded -> { + val signedEvent = broadcastGroupSignedEventUIState.signedEvent + val urlFieldState = rememberTextFieldState() + val isPending = broadcastGroupSignedEventViewModel.isActionPending.value + val relays = broadcastGroupSignedEventViewModel.relays + + // The list comes from the state rather than from whatever loaded + // it, so a screen handed a loaded state -- a preview, a layout + // test -- fills in the same as the app does. Once only, so a list + // the member emptied stays empty. + LaunchedEffect(broadcastGroupSignedEventUIState) { + broadcastGroupSignedEventViewModel.seedRelays(kind = signedEvent.kind) + } + + // A refusal is about what is in the field, so it goes the moment + // the field changes rather than sitting under a URL that has since + // been corrected. + LaunchedEffect(urlFieldState) { + snapshotFlow { urlFieldState.text.toString() } + .collect { broadcastGroupSignedEventViewModel.clearAddFailure() } + } + + // As the group signed it, byte for byte: what is copied is exactly + // 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 canSend = !isPending + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + modifier = Modifier.imePadding(), + topBar = { + TopAppBar( + title = { + broadcastGroupSignedEventUIState.localChatRoom.RenderChatRoomTitleText() + }, + navigationIcon = { NavigateBackButton(onNavigateBack) } + ) + }, + bottomBar = { + BottomAppBar( + actions = {}, + floatingActionButton = { + ExtendedFab( + label = stringResource(Res.string.broadcast), + icon = { Icon(Icons.Default.CellTower, contentDescription = "Broadcast to these relays") }, + enabled = canSend, + onClick = { + broadcastGroupSignedEventViewModel.broadcast( + signedEvent = signedEvent, + // Stays on the screen: an empty list is + // a question rather than a mistake. + onNoRelays = { notify(noRelays) } + ) + } + ) + } + ) + } + ) { innerPadding -> + LazyColumn( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() + .padding(horizontal = MaterialTheme.spacing.screenMargin), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap) + ) { + // What is being sent, in the words the signing screen used + // for it: a member deciding where a post goes is deciding + // about a post, and "kind 1" answers a question nobody asked. + item { + Column( + modifier = Modifier.fillMaxWidth() + .padding(top = MaterialTheme.spacing.containerPadding), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) + ) { + Text( + text = summary.label, + style = MaterialTheme.typography.titleMedium + ) + + Text( + text = summary.detail, + style = MaterialTheme.typography.bodyMedium, + maxLines = 4 + ) + + Text( + text = stringResource( + Res.string.signed_by_the_group_on, + signedEvent.createdAt.toFormattedTimeAndDateString() + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + item { + Text( + text = stringResource( + Res.string.sends_the_event_exactly_as_the_group_signed_it + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + item { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.itemGap)) + + Text( + text = stringResource(Res.string.relays), + style = MaterialTheme.typography.titleSmall + ) + + Text( + text = stringResource( + Res.string.the_relays_this_app_publishes_to_are_filled_in + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + item { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + modifier = Modifier.weight(1f) + .background(BottomAppBarDefaults.containerColor), + state = urlFieldState, + lineLimits = TextFieldLineLimits.SingleLine, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent + ), + leadingIcon = { + Icon(Icons.Default.Dns, contentDescription = Decorative) + }, + label = { Text(stringResource(Res.string.relay_url)) }, + placeholder = { + Text(stringResource(Res.string.eg_wss_relay_example_com)) + }, + isError = broadcastGroupSignedEventViewModel.addFailure != null + ) + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.itemGap)) + + IconButton( + onClick = { + if ( + broadcastGroupSignedEventViewModel.addRelay( + urlFieldState.text.toString() + ) + ) { + urlFieldState.clearText() + } + } + ) { + Icon( + Icons.Default.Add, + contentDescription = stringResource(Res.string.add_relay) + ) + } + } + + // Named rather than silent. An add button that does + // nothing for a URL it refuses is indistinguishable from + // a missed tap. + broadcastGroupSignedEventViewModel.addFailure?.let { failure -> + Text( + text = when (failure) { + BroadcastGroupSignedEventViewModel.AddFailure.NotARelay -> + stringResource(Res.string.that_is_not_a_relay_address) + BroadcastGroupSignedEventViewModel.AddFailure.AlreadyListed -> + stringResource( + Res.string.that_relay_is_already_in_this_list + ) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + } + + // The last press, summed up, above the rows that break it + // down. Persistent rather than a snackbar, because it is + // the answer the member came for and a snackbar would be + // gone before the last relay had been read. + broadcastGroupSignedEventViewModel.lastBroadcast?.let { last -> + item { + Text( + text = stringResource( + Res.string.accepted_by_n_of_m_relays, + last.accepted, + last.asked + ), + style = MaterialTheme.typography.bodyMedium, + color = if (last.accepted == last.asked) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.error + } + ) + } + } + + if (relays.isEmpty()) { + item { + EmptyState( + message = stringResource(Res.string.no_relays_to_broadcast_to), + icon = Icons.Default.Dns + ) + } + } else { + items(items = relays, key = { it.url }) { relay -> + BroadcastRelayRow( + relay = relay, + outcome = broadcastGroupSignedEventViewModel.outcomes[relay], + // A row cannot leave while its relay is being + // asked: the answer would arrive for a relay + // that is no longer there to show it. + canRemove = !isPending, + onRemove = { + broadcastGroupSignedEventViewModel.removeRelay(relay) + } + ) + } + } + + item { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.sectionGap)) + + Text( + text = stringResource(Res.string.the_signed_event), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(MaterialTheme.spacing.relatedGap)) + + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small + ) { + Text( + text = json, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(MaterialTheme.spacing.containerPadding) + ) + } + + Spacer(modifier = Modifier.height(MaterialTheme.spacing.itemGap)) + + FilledTonalButton( + onClick = { + clipboardManager.setText(AnnotatedString(json)) + notify(copied) + }, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Default.ContentCopy, contentDescription = Decorative) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space100)) + Text(text = stringResource(Res.string.copy_raw_json)) + } + + Spacer(modifier = Modifier.height(MaterialTheme.spacing.sectionGap)) + } + } + } + } + + BroadcastGroupSignedEventUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.screenMargin), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap) + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap)) + + Text( + text = stringResource(Res.string.broadcast), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + + LoadingDataIndicator(fillScreen = false) + } + } + } + } + + LaunchedEffect(true) { + if (initialBroadcastGroupSignedEventUIState == BroadcastGroupSignedEventUIState.Loading) { + broadcastGroupSignedEventViewModel.initiateBroadcastGroupSignedEvent() + } + } +} + +/** + * One relay to send to, and what it said the last time it was asked. + * + * The answer is on a second line under the host rather than in an icon, because + * the answers that matter are the relay's own words -- "blocked: not on the + * allow list" is something a member can act on, and a red icon is not. + */ +@Composable +private fun BroadcastRelayRow( + relay: NormalizedRelayUrl, + outcome: RelayOutcome?, + canRemove: Boolean, + onRemove: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = MaterialTheme.spacing.relatedGap), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) + ) { + Text( + // The host rather than the whole URL: every relay here starts + // `wss://` -- nothing else is allowed in -- so the scheme is four + // characters of nothing in front of the part that differs. + text = relay.displayUrl(), + style = MaterialTheme.typography.bodyMedium + ) + + outcome?.let { + Text( + text = it.label(), + style = MaterialTheme.typography.bodySmall, + color = when (it) { + RelayOutcome.Accepted -> MaterialTheme.colorScheme.primary + is RelayOutcome.Rejected, is RelayOutcome.Failed -> + MaterialTheme.colorScheme.error + RelayOutcome.Sending, RelayOutcome.NoAnswer -> + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } + } + + IconButton(onClick = onRemove, enabled = canRemove) { + Icon( + Icons.Default.Delete, + contentDescription = "Remove relay", + // The button's own disabled colour while a send is out, rather + // than a red that promises a tap will do something. + tint = if (canRemove) MaterialTheme.colorScheme.error else LocalContentColor.current + ) + } + } +} + +@ConformancePreviews +@Composable +private fun BroadcastGroupSignedEventScreenPreview() { + val chatRoomId = "d4c3b2a1".repeat(8) + + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + BroadcastGroupSignedEventScreen( + activeUserPublicKey = "", + chatRoomId = chatRoomId, + relayHint = null, + signedEventId = "b".repeat(64), + initialBroadcastGroupSignedEventUIState = BroadcastGroupSignedEventUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = "", + subject = "Translation room", + description = "A group translating hard books.", + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = "state" + ), + ), + signedEvent = GroupSignedEvent( + id = "b".repeat(64), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = TextNoteEvent.KIND, + tags = emptyArray(), + content = "The first chapter is out.", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000) + ), + ), + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + publishTransport = object : EventPublishTransport { + override suspend fun publishEvent( + nostrEvent: press.mantra.compose.database.model.NostrEvent, + relays: Set + ): Flow = emptyFlow() + }, + onNavigateBack = {} + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChapterDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChapterDetailScreen.kt index f4c91cfa..fd5feb3b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChapterDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChapterDetailScreen.kt @@ -10,13 +10,11 @@ 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.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Segment import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -34,7 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.database.model.MantraChapter import press.mantra.compose.repository.MantraRepository import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.ChapterDetailViewModel import press.mantra.compose.ui.view.state.ChapterDetailUIState import press.mantra.compose.ui.theme.spacing @@ -51,6 +49,7 @@ import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3Api::class) @@ -94,14 +93,7 @@ fun ChapterDetailScreen( topBar = { TopAppBar( title = { Text(chapter.name) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, ) } ) { innerPadding -> @@ -199,7 +191,7 @@ fun ChapterDetailScreen( @ConformancePreviews @Composable private fun ChapterDetailScreenPreview() { - TorchTheme { + MantraTheme { Surface(modifier = Modifier.fillMaxSize()) { ChapterDetailScreen( activeUserPublicKey = "", diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomCreationScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomCreationScreen.kt index 6f938a16..2027e905 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomCreationScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomCreationScreen.kt @@ -12,12 +12,14 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Badge import androidx.compose.material.icons.filled.Description import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextField +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -33,19 +35,23 @@ import org.jetbrains.compose.resources.stringResource import mantra.composeapp.generated.resources.choose_who_to_chat_with import mantra.composeapp.generated.resources.enter_the_name_you_want_to_use_for_your import mantra.composeapp.generated.resources.name_eg_group_discussions +import mantra.composeapp.generated.resources.new_chat import mantra.composeapp.generated.resources.this_will_be_shown_when_people_open_the_chat import mantra.composeapp.generated.resources.this_will_be_the_display_name_for_this_chat import mantra.composeapp.generated.resources.what_should_people_know_about_you import mantra.composeapp.generated.resources.what_will_be_discussed_in_this_chat_room import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.theme.ConformancePreviews +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatRoomCreationScreen( activeUserPublicKey: HexKey, - onNavigateToRoute: (Route) -> Unit + onNavigateToRoute: (Route) -> Unit, + onNavigateBack: () -> Unit, ) { val chatRoomCreationViewModel: ChatRoomCreationViewModel = viewModel ( factory = ChatRoomCreationViewModel.factory() @@ -55,7 +61,16 @@ fun ChatRoomCreationScreen( // keyboard covers whatever is being typed into. Scaffold( snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - modifier = Modifier.imePadding()) { innerPadding -> + modifier = Modifier.imePadding(), + topBar = { + // Named after the button that opens it, which is the first of three steps; + // the next two name themselves after the chat this one is creating. + TopAppBar( + title = { Text(stringResource(Res.string.new_chat)) }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) + }, + ) { innerPadding -> Column( modifier = Modifier.padding(innerPadding).readableContent().fillMaxWidth() ) { @@ -175,13 +190,14 @@ fun ChatRoomCreationScreen( @ConformancePreviews @Composable private fun LoadingScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { ChatRoomCreationScreen( activeUserPublicKey = "", - onNavigateToRoute = {} + onNavigateToRoute = {}, + onNavigateBack = {}, ) } } 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 70340da0..0689fb34 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 @@ -67,6 +67,7 @@ import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.MantraRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute +import press.mantra.compose.ui.composable.navigation.routes.ProfilePreviewRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute @@ -77,7 +78,7 @@ import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.dialogs.ModalBottomSheet import press.mantra.compose.ui.composable.widgets.rememberNotifier import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.ChatRoomDetailViewModel import press.mantra.compose.ui.view.state.ChatRoomDetailUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -133,6 +134,7 @@ import mantra.composeapp.generated.resources.recovered_of_event_s import mantra.composeapp.generated.resources.recovered_of_still_unreadable import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.theme.ConformancePreviews @@ -149,7 +151,8 @@ fun ChatRoomDetailScreen( chatRepository: ChatRepository, mantraRepository: MantraRepository, onNavigateToRoute: (Route) -> Unit, - onPopBackToRoute: (Route) -> Unit + onPopBackToRoute: (Route) -> Unit, + onNavigateBack: () -> Unit, ) { val chatRoomDetailViewModel: ChatRoomDetailViewModel = viewModel( factory = ChatRoomDetailViewModel.factory( @@ -186,6 +189,7 @@ fun ChatRoomDetailScreen( title = { chatRoomDetailUIState.localChatRoom.RenderChatRoomTitleText() }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, ) } ) { innerPadding -> @@ -690,11 +694,19 @@ fun ChatRoomDetailScreen( key = { localParticipant -> localParticipant.participant.participantPublicKey} ) { localParticipant -> + // The preview, not the profile detail: the detail is + // keyed by a kind 0's event id, and a member whose + // profile has not arrived yet -- the "LOADING..." row + // the sync exists for -- has none. The preview is + // keyed by the public key, which is the one thing + // every row here has, and it shows the cached + // profile at once while the relays refresh it. Card( onClick = { onNavigateToRoute.invoke( - ImplementationPendingRoute( - "Profile detail" + ProfilePreviewRoute( + activeUserPublicKey = activeUserPublicKey, + profilePublicKey = localParticipant.participant.participantPublicKey ) ) } @@ -1222,7 +1234,7 @@ private fun ReindexMarmotGroupEventsButton( @ConformancePreviews @Composable private fun ChatRoomMessagingScreenPreview() { - TorchTheme { + MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { @@ -1272,7 +1284,8 @@ private fun ChatRoomMessagingScreenPreview() { chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, onNavigateToRoute = {}, - onPopBackToRoute = {} + onPopBackToRoute = {}, + onNavigateBack = {}, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt index 657dfab7..e678a623 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt @@ -52,7 +52,7 @@ import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.ChatRoomMessagingViewModel import press.mantra.compose.ui.view.state.ChatRoomMessagingUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -69,6 +69,7 @@ import mantra.composeapp.generated.resources.private_message_to import mantra.composeapp.generated.resources.private_to import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.theme.ConformancePreviews @@ -86,6 +87,13 @@ fun ChatRoomMessagingScreen( dkgRepository: DkgRepository, onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, onNavigateToRoute: (Route) -> Unit, + /** + * `null` when this is the home screen's detail pane rather than a pushed route. In + * the pane the room list is beside the transcript and there is nothing to pop, so + * the bar draws no back button -- M3's list-detail has the list do that job. On a + * phone the same screen is a destination, and the button is what leaves it. + */ + onNavigateBack: (() -> Unit)?, ) { val chatRoomMessagingViewModel: ChatRoomMessagingViewModel = viewModel( factory = ChatRoomMessagingViewModel.factory( @@ -133,6 +141,9 @@ fun ChatRoomMessagingScreen( title = { chatRoomDetailUIState.localChatRoom.RenderChatRoomTitleText() }, + navigationIcon = { + if (onNavigateBack != null) NavigateBackButton(onNavigateBack) + }, actions = { IconButton( onClick = { @@ -466,7 +477,7 @@ fun ChatRoomMessagingScreen( @ConformancePreviews @Composable private fun ChatRoomMessagingScreenPreview() { - TorchTheme { + MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { @@ -491,7 +502,8 @@ private fun ChatRoomMessagingScreenPreview() { frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY, onNavigateToRouteAndPopUpInclusive = {}, - onNavigateToRoute = {} + onNavigateToRoute = {}, + onNavigateBack = {}, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CreateProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CreateProfileScreen.kt index 4720d7f8..13991e6c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CreateProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CreateProfileScreen.kt @@ -14,8 +14,8 @@ import androidx.compose.material.icons.filled.Badge import androidx.compose.material.icons.filled.Description import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme @@ -23,6 +23,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextField +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -32,6 +33,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.repository.MarmotRepository +import press.mantra.compose.identity.NewProfileWriter +import fr.acinq.phoenix.data.WalletId import press.mantra.compose.ui.theme.LocalExtendedColors import press.mantra.compose.ui.view.model.CreateProfileViewModel import press.mantra.compose.ui.view.state.CreateProfileUIState @@ -41,7 +44,6 @@ import org.jetbrains.compose.resources.stringResource import mantra.composeapp.generated.resources.after_this_there_is_no_turning_back import mantra.composeapp.generated.resources.bio import mantra.composeapp.generated.resources.create_profile -import mantra.composeapp.generated.resources.end_this import mantra.composeapp.generated.resources.enter_the_name_you_want_to_use_for_your_2 import mantra.composeapp.generated.resources.introduce_yourself import mantra.composeapp.generated.resources.it_is_cryptographical_secure_and @@ -60,17 +62,21 @@ import mantra.composeapp.generated.resources.you_are_about_to_create_a_nostr_pro import mantra.composeapp.generated.resources.you_will_be_in_full_control_of_this_profile import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.theme.ConformancePreviews -@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable fun CreateProfileScreen( initialCreateProfileUIState: CreateProfileUIState = CreateProfileUIState.Declaration, - onNavigateToEndThis: () -> Unit, + onNavigateBack: () -> Unit, nostrRepository: press.mantra.compose.repository.NostrRepository, marmotRepository: MarmotRepository, - writeSeed: (mnemonics: List, onSeedWritten: () -> Unit, onSeedWriteError: () -> Unit) -> Unit + /** Which secret the profile is made of -- a seed from Landing, a bare key from the switcher. The screen does not know which. */ + newProfile: NewProfileWriter, + /** The nav host's tail: re-list, select, go to startup. */ + onProfileCreated: (WalletId) -> Unit, ) { val createProfileViewModel: CreateProfileViewModel = viewModel ( factory = CreateProfileViewModel.factory( @@ -83,7 +89,18 @@ fun CreateProfileScreen( // keyboard covers whatever is being typed into. Scaffold( snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - modifier = Modifier.imePadding()) { innerPadding -> + modifier = Modifier.imePadding(), + topBar = { + // The name is in the bar, as it is on the sign-in screen beside this one on + // the landing page, and the steps below no longer repeat it as a headline. + // Back pops to the landing page; nothing is written until the last step + // confirms, so there is nothing to lose by leaving. + TopAppBar( + title = { Text(stringResource(Res.string.create_profile)) }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) + }, + ) { innerPadding -> Column( modifier = Modifier.padding(innerPadding) .readableContent() @@ -99,12 +116,6 @@ fun CreateProfileScreen( when (val createAccountUIState = createProfileViewModel.createProfileUIState.value) { is CreateProfileUIState.Declaration -> { - - Text( - stringResource(Res.string.create_profile), - style = MaterialTheme.typography.headlineSmall - ) - Text( stringResource(Res.string.you_are_about_to_create_a_nostr_profile), style = MaterialTheme.typography.bodyMedium, @@ -133,11 +144,6 @@ fun CreateProfileScreen( } } is CreateProfileUIState.InputPrompt -> { - Text( - stringResource(Res.string.create_profile), - style = MaterialTheme.typography.headlineSmall - ) - TextField( modifier = Modifier.fillMaxWidth(), state = createProfileViewModel.createProfileFormState.nameField.textFieldState, @@ -305,7 +311,7 @@ fun CreateProfileScreen( } else { Button( onClick = { - createProfileViewModel.createAccount(writeSeed) + createProfileViewModel.createAccount(newProfile, onProfileCreated) } ) { Text( @@ -329,11 +335,16 @@ fun CreateProfileScreen( ) } is CreateProfileUIState.ProfileReady -> { - // Both pills come from the extended families rather than from a - // literal paired with Color.White/Color.DarkGray by eye. The old - // pairings read 4.57:1 and 2.90:1 against their containers, and - // RedPill carried alpha 0.749, so composited over the surface the - // first was really 3.50:1. Both are below the 4.5:1 floor. + // The pill comes from the extended family rather than from a + // literal paired with Color.White by eye: the old pairing read + // 4.57:1 against its container, and with alpha 0.749 composited + // over the surface was really 3.50:1, below the 4.5:1 floor. + // + // The blue pill that stood beside it -- "end this" -- is gone. It + // wiped the database: every profile's rooms, MLS state, key + // packages and queues, from a screen whose purpose is to add a + // profile. It was only ever a development exit, and with several + // profiles on a device it was a way to lose the others. val extendedColors = LocalExtendedColors.current Spacer( @@ -355,34 +366,13 @@ fun CreateProfileScreen( contentColor = extendedColors.redPill.onColor ), onClick = { - createProfileViewModel.createAccount(writeSeed) + createProfileViewModel.createAccount(newProfile, onProfileCreated) } ) { Text( text = stringResource(Res.string.see_how_deep_the_rabbit_hole_goes) ) } - - HorizontalDivider( - modifier = Modifier.padding(MaterialTheme.spacing.space250) - ) - - Button( - colors = ButtonDefaults.buttonColors( - containerColor = extendedColors.bluePill.color, - contentColor = extendedColors.bluePill.onColor - ), - onClick = { - // TODO: Delete everything and close the app - createProfileViewModel.wipeDatabase { - onNavigateToEndThis.invoke() - } - } - ) { - Text( - text = stringResource(Res.string.end_this) - ) - } Spacer( modifier = Modifier.weight(2f) ) @@ -396,7 +386,7 @@ fun CreateProfileScreen( @ConformancePreviews @Composable fun CreateAccountScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { @@ -437,8 +427,9 @@ fun CreateAccountScreenPreview() { // about = "Polyglot: Math... is my middle name, computer scientist. cryptographer, and gold hider." // ) // ), - onNavigateToEndThis = {}, - writeSeed = { _, _, _ -> }, + onNavigateBack = {}, + newProfile = NewProfileWriter { Result.failure(IllegalStateException("a preview")) }, + onProfileCreated = {}, nostrRepository = press.mantra.compose.repository.NostrRepository.NO_OP_NOSTR_REPOSITORY, marmotRepository = MarmotRepository.NO_OP_MARMOT_KEY_PACKAGE_BUNDLE ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt index e89709cd..cc358aa5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt @@ -35,7 +35,7 @@ import press.mantra.compose.repository.DkgRepository import press.mantra.compose.ui.view.model.DkgRitualViewModel import press.mantra.compose.ui.view.state.DkgRitualUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity import kotlinx.coroutines.flow.StateFlow import press.mantra.compose.ui.theme.spacing import press.mantra.compose.ui.composable.widgets.Decorative @@ -45,6 +45,7 @@ import mantra.composeapp.generated.resources.back import mantra.composeapp.generated.resources.not_now import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent /** @@ -65,7 +66,7 @@ internal fun DkgApprovalScaffold( activeUserPublicKey: HexKey, chatRoomId: String, dkgSessionId: String? = null, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, chatRepository: ChatRepository, dkgRepository: DkgRepository, onDone: () -> Unit, @@ -76,7 +77,7 @@ internal fun DkgApprovalScaffold( chatRoomId = chatRoomId, dkgSessionId = dkgSessionId, activeUserPublicKey = activeUserPublicKey, - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, chatRepository = chatRepository, dkgRepository = dkgRepository ) @@ -92,7 +93,10 @@ internal fun DkgApprovalScaffold( snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, topBar = { TopAppBar( - title = { Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis) } + title = { Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + // The same leave as "Not now" below, reachable from the loading and error + // states too -- which have no other way off the screen on desktop. + navigationIcon = { NavigateBackButton(onDone) }, ) } ) { padding -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgJoinApprovalScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgJoinApprovalScreen.kt index a1c320ea..44b28938 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgJoinApprovalScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgJoinApprovalScreen.kt @@ -12,7 +12,7 @@ import press.mantra.compose.database.model.types.DkgApprovalStep import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.DkgRepository import com.vitorpamplona.quartz.nip01Core.core.HexKey -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity import kotlinx.coroutines.flow.StateFlow import press.mantra.compose.ui.theme.spacing @@ -33,7 +33,7 @@ fun DkgJoinApprovalScreen( activeUserPublicKey: HexKey, chatRoomId: String, dkgSessionId: String? = null, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, chatRepository: ChatRepository, dkgRepository: DkgRepository, onDone: () -> Unit, @@ -45,7 +45,7 @@ fun DkgJoinApprovalScreen( activeUserPublicKey = activeUserPublicKey, chatRoomId = chatRoomId, dkgSessionId = dkgSessionId, - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, chatRepository = chatRepository, dkgRepository = dkgRepository, onDone = onDone diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt index 75c07645..f669285c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt @@ -28,7 +28,6 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator @@ -63,7 +62,7 @@ import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.DkgRepository import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.profile.ProfileColor -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.database.model.types.DkgApprovalStep import press.mantra.compose.ui.composable.navigation.routes.DkgJoinApprovalRoute import press.mantra.compose.ui.composable.navigation.routes.DkgRound1ApprovalRoute @@ -73,7 +72,7 @@ import press.mantra.compose.ui.view.model.DkgRitualViewModel import press.mantra.compose.ui.view.state.DkgRitualUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.database.model.FrostSigningSession -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import press.mantra.compose.ui.theme.spacing @@ -114,8 +113,10 @@ import mantra.composeapp.generated.resources.of_members_will_be_needed_to_sign_w import mantra.composeapp.generated.resources.shared_key_for import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab import press.mantra.compose.ui.theme.ConformancePreviews /** @@ -135,10 +136,11 @@ fun DkgRitualScreen( /** Which of the room's ceremonies to show; null takes the newest. */ dkgSessionId: String? = null, initialDkgRitualUIState: DkgRitualUIState = DkgRitualUIState.Loading, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, chatRepository: ChatRepository, dkgRepository: DkgRepository, onNavigateToRoute: (Route) -> Unit = {}, + onNavigateBack: () -> Unit, ) { val dkgRitualViewModel: DkgRitualViewModel = viewModel( factory = DkgRitualViewModel.factory( @@ -146,7 +148,7 @@ fun DkgRitualScreen( activeUserPublicKey = activeUserPublicKey, dkgSessionId = dkgSessionId, initialDkgRitualUIState = initialDkgRitualUIState, - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, chatRepository = chatRepository, dkgRepository = dkgRepository ) @@ -179,7 +181,8 @@ fun DkgRitualScreen( maxLines = 1, overflow = TextOverflow.Ellipsis ) - } + }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, ) }, bottomBar = { @@ -191,7 +194,13 @@ fun DkgRitualScreen( if (pending != null) { BottomAppBar( floatingActionButton = { - ExtendedFloatingActionButton( + ExtendedFab( + label = when (pending) { + DkgApprovalStep.HOST_KEY -> stringResource(Res.string.review_and_join) + DkgApprovalStep.ROUND_1 -> stringResource(Res.string.review_and_contribute) + DkgApprovalStep.ROUND_2 -> stringResource(Res.string.review_and_confirm) + }, + icon = { Icon(Icons.Default.Key, contentDescription = Decorative) }, onClick = { onNavigateToRoute( when (pending) { @@ -213,16 +222,7 @@ fun DkgRitualScreen( } ) } - ) { - Icon(Icons.Default.Key, contentDescription = Decorative) - Text( - text = when (pending) { - DkgApprovalStep.HOST_KEY -> stringResource(Res.string.review_and_join) - DkgApprovalStep.ROUND_1 -> stringResource(Res.string.review_and_contribute) - DkgApprovalStep.ROUND_2 -> stringResource(Res.string.review_and_confirm) - } - ) - } + ) }, actions = {} ) @@ -235,19 +235,21 @@ fun DkgRitualScreen( if (canStartRitual) { BottomAppBar( floatingActionButton = { - ExtendedFloatingActionButton( - onClick = { dkgRitualViewModel.startRitual() } - ) { - if (isActionPending) { - CircularProgressIndicator(modifier = Modifier.size(20.dp)) + ExtendedFab( + label = if (session == null) { + stringResource(Res.string.start_key_ceremony) } else { - Icon(Icons.Default.Key, contentDescription = "Start") - } - - Text( - text = if (session == null) stringResource(Res.string.start_key_ceremony) else stringResource(Res.string.try_again) - ) - } + stringResource(Res.string.try_again) + }, + icon = { + if (isActionPending) { + CircularProgressIndicator(modifier = Modifier.size(20.dp)) + } else { + Icon(Icons.Default.Key, contentDescription = "Start") + } + }, + onClick = { dkgRitualViewModel.startRitual() } + ) }, actions = { Text( @@ -865,7 +867,7 @@ private fun QuorumStepper( @ConformancePreviews @Composable private fun DkgRitualScreenPreview() { - TorchTheme { + MantraTheme { Surface(modifier = Modifier.fillMaxSize()) { DkgRitualScreen( activeUserPublicKey = "", @@ -882,9 +884,10 @@ private fun DkgRitualScreenPreview() { ), ) ), - activeWalletStateFlow = MutableStateFlow(null), + activeIdentityStateFlow = MutableStateFlow(null), chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY + dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY, + onNavigateBack = {}, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRound1ApprovalScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRound1ApprovalScreen.kt index 24970df6..ef72e22b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRound1ApprovalScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRound1ApprovalScreen.kt @@ -12,7 +12,7 @@ import press.mantra.compose.database.model.types.DkgApprovalStep import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.DkgRepository import com.vitorpamplona.quartz.nip01Core.core.HexKey -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity import kotlinx.coroutines.flow.StateFlow import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.Res @@ -37,7 +37,7 @@ fun DkgRound1ApprovalScreen( activeUserPublicKey: HexKey, chatRoomId: String, dkgSessionId: String? = null, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, chatRepository: ChatRepository, dkgRepository: DkgRepository, onDone: () -> Unit, @@ -49,7 +49,7 @@ fun DkgRound1ApprovalScreen( activeUserPublicKey = activeUserPublicKey, chatRoomId = chatRoomId, dkgSessionId = dkgSessionId, - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, chatRepository = chatRepository, dkgRepository = dkgRepository, onDone = onDone diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRound2ApprovalScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRound2ApprovalScreen.kt index 7edd9f7a..f66d38f4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRound2ApprovalScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRound2ApprovalScreen.kt @@ -12,7 +12,7 @@ import press.mantra.compose.database.model.types.DkgApprovalStep import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.DkgRepository import com.vitorpamplona.quartz.nip01Core.core.HexKey -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity import kotlinx.coroutines.flow.StateFlow import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.Res @@ -38,7 +38,7 @@ fun DkgRound2ApprovalScreen( activeUserPublicKey: HexKey, chatRoomId: String, dkgSessionId: String? = null, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, chatRepository: ChatRepository, dkgRepository: DkgRepository, onDone: () -> Unit, @@ -50,7 +50,7 @@ fun DkgRound2ApprovalScreen( activeUserPublicKey = activeUserPublicKey, chatRoomId = chatRoomId, dkgSessionId = dkgSessionId, - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, chatRepository = chatRepository, dkgRepository = dkgRepository, onDone = onDone 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 00efaa4f..fbecea82 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 @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Draw import androidx.compose.material.icons.filled.ErrorOutline @@ -25,7 +24,6 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -52,7 +50,7 @@ import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.text.ProposedEvent import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.FrostSigningViewModel import press.mantra.compose.ui.view.state.FrostSigningUIState import press.mantra.compose.ui.theme.spacing @@ -70,6 +68,7 @@ import mantra.composeapp.generated.resources.signed_their_part import mantra.composeapp.generated.resources.events_signed_together import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.theme.ConformancePreviews @@ -125,11 +124,7 @@ fun FrostSigningScreen( overflow = TextOverflow.Ellipsis ) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - } + navigationIcon = { NavigateBackButton(onNavigateBack) } ) } ) { padding -> @@ -410,7 +405,7 @@ private fun statusOf(session: FrostSigningSession): String = when (session.stage @ConformancePreviews @Composable private fun FrostSigningScreenPreview() { - TorchTheme { + MantraTheme { Surface(modifier = Modifier.fillMaxSize()) { FrostSigningScreen( activeUserPublicKey = "", 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 cd40abc7..9cf1ec42 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 @@ -13,10 +13,10 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Lock import androidx.compose.material3.Icon import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.PrimaryScrollableTabRow @@ -24,6 +24,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Tab import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -45,10 +46,11 @@ import press.mantra.compose.database.model.intermdiate.LocalProfileWithFollowing import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.navigation.routes.SignInRoute import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.dialogs.NewChatBottomSheetDialog import press.mantra.compose.ui.composable.widgets.dialogs.StartDirectMessageToNpubOrNip05Dialog -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.ChatRoomListViewModel import press.mantra.compose.ui.view.model.HomeScreenType import press.mantra.compose.ui.view.model.HomeViewModel @@ -74,7 +76,11 @@ import press.mantra.compose.ui.theme.breakpoint import press.mantra.compose.ui.theme.listPaneWidthFor import press.mantra.compose.ui.theme.readableContent import mantra.composeapp.generated.resources.pick_a_conversation_to_read_it_here +import mantra.composeapp.generated.resources.messages_need_the_secret_key_this_profile +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.buttons.ExtendedFab import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @@ -120,6 +126,9 @@ fun HomeScreen( } } + // Whether this identity can sign: the one thing the read-only branch below turns on. + val canSign = LocalCanSign.current + val homeScreenViewModel: HomeViewModel = viewModel( factory = HomeViewModel.factory( activeUserPublicKey = activeUserPublicKey, @@ -155,10 +164,12 @@ fun HomeScreen( title = { // "Mantra". The launcher label, the desktop window title, the // landing screen and the package name all say so; this bar was - // the last place still saying "Torch". `UserAgent.APP_NAME` is - // the other, and is left alone -- it goes on the wire to relay - // operators, so it is a network identity question rather than - // a content one. + // the last place still saying "Torch", the brand before this + // one, and `UserAgent.CLIENT_NAME` was the other. That one went + // last, because it goes on the wire: it is the `client` tag on + // every relay list this app publishes. It could move because it + // is attribution and nothing derives from it -- unlike the two + // `mantra/` hash tags, which must never move at all. Text(stringResource(Res.string.mantra)) }, // No navigation icon and no actions. The avatar in the leading @@ -176,7 +187,11 @@ fun HomeScreen( // the bottom-right of the *window*, which is on top of the // transcript's send button; M3 puts a list-detail layout's primary // action in the list pane, and so does the branch below. - if (listPaneWidth == null) NewChatButton { showBottomSheet = true } + // And only for an identity that can sign: a new chat is a key + // package, a welcome and a gift wrap, none of which a read-only + // identity can produce. Hidden, not disabled -- the empty state + // beside it says why. + if (listPaneWidth == null && canSign) NewChatButton { showBottomSheet = true } } ) { innerPadding -> @Composable @@ -240,7 +255,21 @@ fun HomeScreen( } } - if (listPaneWidth == null) { + if (!canSign) { + // One column at every width. The two-pane layout is a list beside + // a detail, and there is no list: every room is MLS or a gift wrap, + // encrypted to the key this identity does not hold. The empty state + // is the only thing on the screen, which is the one case + // readableContent()'s centring is for -- and its action is where + // the upgrade lives. ChatRoomListViewModel is not composed, so the + // inbox sync and the MLS negentropy it would queue are not queued. + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize(), + verticalArrangement = Arrangement.Center, + ) { + ReadOnlyInbox(onSignInWithTheNsec = { onNavigateToRoute(SignInRoute) }) + } + } else if (listPaneWidth == null) { // Compact and medium: the list is the screen, and a room is a route. // Byte for byte the layout this screen has always had. ChatRoomListPane( @@ -300,6 +329,8 @@ fun HomeScreen( // created and has an id at last. onNavigateToRouteAndPopUpInclusive = onOpenChatRoom, onNavigateToRoute = onNavigateToRoute, + // The list is beside it; there is nothing to pop. + onNavigateBack = null, ) } } @@ -307,7 +338,10 @@ fun HomeScreen( } } - if (showBottomSheet) { + // Neither can open without the button above, and the button is hidden for + // an identity that cannot sign; the guard here is so that the two cannot + // drift apart. + if (showBottomSheet && canSign) { NewChatBottomSheetDialog( scope = scope, sheetState = sheetState, @@ -323,7 +357,7 @@ fun HomeScreen( } when { - openNpubDialog.value -> { + openNpubDialog.value && canSign -> { StartDirectMessageToNpubOrNip05Dialog( activeUserPublicKey = activeUserPublicKey, scope = scope, @@ -350,7 +384,7 @@ fun HomeScreen( @ConformancePreviews @Composable private fun HomeScreenPreview() { - TorchTheme { + MantraTheme { Surface( modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { @@ -390,6 +424,28 @@ It has survived not only five centuries, but also the leap into electronic types } } +/** + * What a read-only identity sees where its rooms would be, and the one thing that fills + * it. + * + * `EmptyState`'s message is required for exactly this reason -- an absence has to say + * which absence it is -- and its action slot is where the upgrade lives: the nsec pasted + * on the sign-in screen hits the credentials file's upgrade rule, and startup takes the + * user back here with rooms in it. + */ +@Composable +private fun ReadOnlyInbox(onSignInWithTheNsec: () -> Unit) { + EmptyState( + message = stringResource(Res.string.messages_need_the_secret_key_this_profile), + icon = Icons.Default.Lock, + action = { + TextButton(onClick = onSignInWithTheNsec) { + Text(stringResource(Res.string.sign_in_with_the_nsec)) + } + }, + ) +} + /** * The one action the chat list offers, in whichever slot the layout has for it. * @@ -400,8 +456,8 @@ It has survived not only five centuries, but also the leap into electronic types */ @Composable private fun NewChatButton(onClick: () -> Unit) { - ExtendedFloatingActionButton( - onClick = onClick, + ExtendedFab( + label = stringResource(Res.string.new_chat), icon = { Icon( Icons.Default.Add, @@ -409,7 +465,7 @@ private fun NewChatButton(onClick: () -> Unit) { contentDescription = Decorative, ) }, - text = { Text(stringResource(Res.string.new_chat)) }, + onClick = onClick, ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ImplementationPendingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ImplementationPendingScreen.kt index 364a5477..98216fc5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ImplementationPendingScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ImplementationPendingScreen.kt @@ -4,10 +4,12 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -18,15 +20,35 @@ import org.jetbrains.compose.resources.stringResource import mantra.composeapp.generated.resources.functionality_coming_soon_2 import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.theme.ConformancePreviews +/** + * @param onNavigateBack `null` when this screen is the root of the stack, which it is + * when the navigation machine lands on an error and clears everything under it. Pushed + * from a "learn more" or an "edit profile", there is a screen to return to and this is + * how. Only the host knows which, so it decides. + */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ImplementationPendingScreen( - text: String + text: String, + onNavigateBack: (() -> Unit)?, ) { - Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding -> + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + // The name of the thing that is not built yet, which is what was tapped. + title = { Text(text) }, + navigationIcon = { + if (onNavigateBack != null) NavigateBackButton(onNavigateBack) + }, + ) + }, + ) { innerPadding -> Column( modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() ) { @@ -47,12 +69,13 @@ fun ImplementationPendingScreen( @ConformancePreviews @Composable private fun ImplementationPendingScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { ImplementationPendingScreen( - "Sign in" + text = "Sign in", + onNavigateBack = {}, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyPackageManagementScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyPackageManagementScreen.kt index 67c481f0..b45367e9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyPackageManagementScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyPackageManagementScreen.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.DoneAll import androidx.compose.material.icons.filled.LockReset import androidx.compose.material.icons.filled.Unpublished @@ -59,6 +58,7 @@ import mantra.composeapp.generated.resources.key_package_published import mantra.composeapp.generated.resources.key_package_rotated import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @@ -102,18 +102,7 @@ fun KeyPackageManagementScreen( text = stringResource(Res.string.key_package_management) ) }, - navigationIcon = { - IconButton( - onClick = { - onNavigateBack.invoke() - } - ) { - Icon( - Icons.Default.ArrowBack, - contentDescription = "Back" - ) - } - } + navigationIcon = { NavigateBackButton(onNavigateBack) } ) } ) { innerPadding -> @@ -246,7 +235,7 @@ fun KeyPackageManagementScreen( @ConformancePreviews @Composable private fun UnannouncedProfileScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt index 5e2e387b..551b594b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt @@ -13,9 +13,9 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AddToDrive -import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.LocalHospital +import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.Spellcheck import androidx.compose.material.icons.filled.VolunteerActivism import androidx.compose.material.icons.filled.Warning @@ -24,7 +24,6 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface @@ -41,7 +40,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.vitorpamplona.quartz.nip01Core.core.HexKey -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.IdentityKind import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOf @@ -52,7 +52,11 @@ import mantra.composeapp.generated.resources.emergency_kit import mantra.composeapp.generated.resources.encrypt_and_back_your_recovery_information import mantra.composeapp.generated.resources.key_recovery import mantra.composeapp.generated.resources.not_backed_up_yet +import mantra.composeapp.generated.resources.nostr_secret_key import mantra.composeapp.generated.resources.recovery_phrase +import mantra.composeapp.generated.resources.store_the_nsec_that_signs_as_this_profile +import mantra.composeapp.generated.resources.these_are_your_keys_keep_them_safe_no_wallet +import mantra.composeapp.generated.resources.you_said_you_stored_it import mantra.composeapp.generated.resources.these_are_your_keys_keep_them_safe_so_they import mantra.composeapp.generated.resources.write_down_and_secure_the_12_word_phrase import mantra.composeapp.generated.resources.yolo @@ -61,9 +65,11 @@ import mantra.composeapp.generated.resources.you_said_you_wrote_it_down import org.jetbrains.compose.resources.stringResource import press.mantra.compose.extensions.hexToNpubHrp import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute +import press.mantra.compose.ui.composable.navigation.routes.NostrSecretRoute import press.mantra.compose.ui.composable.navigation.routes.RecoveryPhraseRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.ConformancePreviews import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.theme.spacing @@ -71,26 +77,27 @@ import press.mantra.compose.ui.theme.spacing /** * Everything that can put this profile back on another phone. * - * A mantra profile is its key: the npub that signs, and the wallet that holds - * coins, both come out of one seed that never leaves the device. There is no - * account to reset, so this screen is the only thing standing between a lost - * phone and a lost identity -- it exists to get the user to take a backup while - * they still can. + * A Mantra profile is its key: the npub that signs and, for a wallet, the coins + * it holds, both come out of one secret that never leaves the device -- twelve + * words, or a bare nostr key that was signed in with. There is no account to + * reset, so this screen is the only thing standing between a lost phone and a + * lost identity -- it exists to get the user to take a backup while they still + * can. */ @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable fun KeyRecoveryScreen( activeUserPublicKey: HexKey, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, onNavigateBack: () -> Unit, onNavigateToRoute: (Route) -> Unit, ) { - val activeWallet by activeWalletStateFlow.collectAsState() + val activeIdentity by activeIdentityStateFlow.collectAsState() // The same preference the recovery phrase screen writes, so the state of the backup is // visible before the user goes looking for it. - val showBackupNotice by remember(activeWallet) { - activeWallet?.internalPrefs?.showSeedBackupNotice ?: flowOf(false) + val showBackupNotice by remember(activeIdentity) { + activeIdentity?.internalPrefs?.showSeedBackupNotice ?: flowOf(false) }.collectAsState(initial = false) Scaffold( @@ -101,18 +108,7 @@ fun KeyRecoveryScreen( text = stringResource(Res.string.key_recovery) ) }, - navigationIcon = { - IconButton( - onClick = { - onNavigateBack.invoke() - } - ) { - Icon( - Icons.Default.ArrowBack, - contentDescription = "Back" - ) - } - } + navigationIcon = { NavigateBackButton(onNavigateBack) } ) } ) { innerPadding -> @@ -133,7 +129,18 @@ fun KeyRecoveryScreen( horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = stringResource(Res.string.these_are_your_keys_keep_them_safe_so_they), + // A bare-key identity has no coins to speak of. Exhaustive rather than + // `else`: the `else` here used to mean "mnemonic", and a kind added later + // would have fallen into it and been promised coins. A read-only identity + // is never offered this screen (docs/npub-sign-in.md, Phase 5); it reads + // as the bare-key kind so that, if it ever is, the sentence is at least + // not about a wallet. + text = when (activeIdentity?.kind) { + IdentityKind.NostrSecret, + IdentityKind.NostrPublic -> stringResource(Res.string.these_are_your_keys_keep_them_safe_no_wallet) + IdentityKind.Mnemonic, + null -> stringResource(Res.string.these_are_your_keys_keep_them_safe_so_they) + }, style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center ) @@ -147,27 +154,62 @@ fun KeyRecoveryScreen( } item { - KeyRecoveryOption( - icon = Icons.Default.Spellcheck, - title = stringResource(Res.string.recovery_phrase), - description = stringResource(Res.string.write_down_and_secure_the_12_word_phrase), - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - status = if (showBackupNotice) { - KeyRecoveryStatus( - icon = Icons.Default.Warning, - text = stringResource(Res.string.not_backed_up_yet) - ) - } else { - KeyRecoveryStatus( - icon = Icons.Default.CheckCircle, - text = stringResource(Res.string.you_said_you_wrote_it_down) - ) - }, - onClick = { - onNavigateToRoute.invoke(RecoveryPhraseRoute) - } - ) + // The backup an identity has depends on what it was made from. Twelve words + // for a wallet; for a bare key there is no phrase, and the key is the whole + // backup. Same flags underneath -- they are per identity and mean "this + // identity's secret is not backed up" for whichever secret it is. + when (activeIdentity?.kind) { + IdentityKind.NostrSecret -> KeyRecoveryOption( + icon = Icons.Default.Key, + title = stringResource(Res.string.nostr_secret_key), + description = stringResource(Res.string.store_the_nsec_that_signs_as_this_profile), + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + status = if (showBackupNotice) { + KeyRecoveryStatus( + icon = Icons.Default.Warning, + text = stringResource(Res.string.not_backed_up_yet) + ) + } else { + KeyRecoveryStatus( + icon = Icons.Default.CheckCircle, + text = stringResource(Res.string.you_said_you_stored_it) + ) + }, + onClick = { + onNavigateToRoute.invoke(NostrSecretRoute) + } + ) + + // Nothing to recover: the device holds no secret for this identity. The + // row that leads here is hidden for the kind, so this arm is not reached; + // it exists so that the `when` says so rather than handing a read-only + // identity the phrase option through an `else`. + IdentityKind.NostrPublic -> Unit + + IdentityKind.Mnemonic, + null -> KeyRecoveryOption( + icon = Icons.Default.Spellcheck, + title = stringResource(Res.string.recovery_phrase), + description = stringResource(Res.string.write_down_and_secure_the_12_word_phrase), + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + status = if (showBackupNotice) { + KeyRecoveryStatus( + icon = Icons.Default.Warning, + text = stringResource(Res.string.not_backed_up_yet) + ) + } else { + KeyRecoveryStatus( + icon = Icons.Default.CheckCircle, + text = stringResource(Res.string.you_said_you_wrote_it_down) + ) + }, + onClick = { + onNavigateToRoute.invoke(RecoveryPhraseRoute) + } + ) + } } item { @@ -299,13 +341,13 @@ private fun KeyRecoveryOption( @ConformancePreviews @Composable private fun KeyRecoveryScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { KeyRecoveryScreen( activeUserPublicKey = "84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240", - activeWalletStateFlow = MutableStateFlow(null), + activeIdentityStateFlow = MutableStateFlow(null), onNavigateBack = {}, onNavigateToRoute = {} ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LandingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LandingScreen.kt index 4be7fa3d..f061ccc5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LandingScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LandingScreen.kt @@ -27,7 +27,7 @@ import mantra.composeapp.generated.resources.keep_the_feed_alive import mantra.composeapp.generated.resources.learn_more import mantra.composeapp.generated.resources.mantra import mantra.composeapp.generated.resources.sign_in -import mantra.composeapp.generated.resources.sign_in_to_torch_via_nsec_or_remote_signer +import mantra.composeapp.generated.resources.sign_in_with_a_recovery_phrase_an_nsec_or import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent @@ -82,7 +82,7 @@ fun LandingScreen( } Text( - text = stringResource(Res.string.sign_in_to_torch_via_nsec_or_remote_signer), + text = stringResource(Res.string.sign_in_with_a_recovery_phrase_an_nsec_or), modifier = Modifier.padding(MaterialTheme.spacing.space125), style = MaterialTheme.typography.labelSmall, textAlign = TextAlign.Center @@ -115,7 +115,7 @@ fun LandingScreen( @ConformancePreviews @Composable private fun LandingScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LoadingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LoadingScreen.kt index 47e1115c..4d44721a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LoadingScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LoadingScreen.kt @@ -31,7 +31,7 @@ fun LoadingScreen( @ConformancePreviews @Composable private fun LoadingScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrEventDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrEventDetailScreen.kt index 1483e70b..f141fb24 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrEventDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrEventDetailScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold 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.ui.Alignment @@ -23,7 +24,7 @@ import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.detail.MetadataEventDetail import press.mantra.compose.ui.composable.widgets.detail.TextNoteEventDetail -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.NostrEventDetailViewModel import press.mantra.compose.ui.view.state.NostrEventDetailUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -34,12 +35,14 @@ import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource import mantra.composeapp.generated.resources.functionality_coming_soon +import mantra.composeapp.generated.resources.post import mantra.composeapp.generated.resources.post_functionality_coming_soon import mantra.composeapp.generated.resources.something_went_wrong import mantra.composeapp.generated.resources.we_couldn_t_find_your_nostr_event_please_try import press.mantra.compose.ui.composable.widgets.ErrorState import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.theme.ConformancePreviews @@ -95,20 +98,41 @@ fun NostrEventDetailScreen( nostrRepository = nostrRepository ) } + // The two kinds with a detail of their own draw their bar inside it. The + // rest are a placeholder, and a placeholder pushed onto the stack still + // needs the way off it. RepostEvent.KIND -> { // TODO: RePost... - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text(feedListUIState.localNostrEvent.nostrEvent.content) + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { Text(stringResource(Res.string.post)) }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(feedListUIState.localNostrEvent.nostrEvent.content) - Text(stringResource(Res.string.post_functionality_coming_soon)) + Text(stringResource(Res.string.post_functionality_coming_soon)) + } } } else -> { - Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding -> + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { Text(stringResource(Res.string.post)) }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) + }, + ) { innerPadding -> Column( modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() ) { @@ -149,7 +173,7 @@ fun NostrEventDetailScreen( @ConformancePreviews @Composable private fun NostrEventDetailScreenPreview() { - TorchTheme { + MantraTheme { Surface( modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrSecretScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrSecretScreen.kt new file mode 100644 index 00000000..705261a7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrSecretScreen.kt @@ -0,0 +1,481 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +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.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.lifecycle.viewmodel.compose.viewModel +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.backup_confirmation +import mantra.composeapp.generated.resources.cancel +import mantra.composeapp.generated.resources.copied_the_nsec +import mantra.composeapp.generated.resources.copy_nsec +import mantra.composeapp.generated.resources.could_not_forget_the_key_please_try_again +import mantra.composeapp.generated.resources.could_not_unlock_your_key_please_try +import mantra.composeapp.generated.resources.mantra_will_delete_the_nsec_for_s_from_this +import mantra.composeapp.generated.resources.display_nostr_secret_key +import mantra.composeapp.generated.resources.forget +import mantra.composeapp.generated.resources.forget_this_key +import mantra.composeapp.generated.resources.forget_this_key_question +import mantra.composeapp.generated.resources.hide +import mantra.composeapp.generated.resources.i_have_saved_my_nostr_secret_key_somewhere +import mantra.composeapp.generated.resources.i_understand_that_if_i_lose_this_phone_and_my_key +import mantra.composeapp.generated.resources.keep_this_key_safe_do_not_share_it +import mantra.composeapp.generated.resources.key_forgotten +import mantra.composeapp.generated.resources.loading_preferences +import mantra.composeapp.generated.resources.lose_this_phone_before_you_do_and_the +import mantra.composeapp.generated.resources.no_identity_is_open_on_this_device_so_there +import mantra.composeapp.generated.resources.nostr_secret_key +import mantra.composeapp.generated.resources.remove_the_key_from_this_device_the_profile +import mantra.composeapp.generated.resources.the_nsec_is_the_key_that_signs_as_you_this +import mantra.composeapp.generated.resources.this_device_holds_no_key_for_the_profile +import mantra.composeapp.generated.resources.unlocking_your_key +import mantra.composeapp.generated.resources.you_have_not_backed_up_your_nostr_secret_key +import fr.acinq.phoenix.PhoenixGlobal +import kotlinx.coroutines.flow.StateFlow +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.extensions.hexToNpubHrp +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.IdentityWriter +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.widgets.Decorative +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.rememberNotifier +import press.mantra.compose.ui.composable.widgets.NavigateBackButton +import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.NostrSecretViewModel +import press.mantra.compose.ui.view.state.NostrSecretUIState + +/** + * The nostr secret key behind a bare-key identity, shown once and on request. + * + * `RecoveryPhraseScreen` with the word grid replaced by the nsec, for the identity kind + * that has a key and no phrase. The reveal is a separate step from opening the screen + * for the same reason: reading the key goes to the keystore, and nobody should walk past + * a phone that is quietly displaying it. Below the backup section is the one thing this + * screen has that the phrase screen does not: the way to take the key off the device. + */ +@Composable +fun NostrSecretScreen( + phoenixGlobal: PhoenixGlobal, + activeIdentityStateFlow: StateFlow, + nostrRepository: NostrRepository, + forgetNostrCredential: suspend (Identity) -> IdentityWriter.ForgetNostrCredentialResult, + hideIdentityMetadata: suspend (Identity) -> Unit, + onNavigateBack: () -> Unit, + onForgotten: () -> Unit, +) { + val viewModel: NostrSecretViewModel = viewModel( + factory = NostrSecretViewModel.factory( + phoenixGlobal = phoenixGlobal, + activeIdentityStateFlow = activeIdentityStateFlow, + nostrRepository = nostrRepository, + forgetNostrCredential = forgetNostrCredential, + hideIdentityMetadata = hideIdentityMetadata, + ) + ) + + val uiState by viewModel.uiState.collectAsState() + val forgetting by viewModel.forgetting.collectAsState() + val isBareKeyIdentity by viewModel.isBareKeyIdentity.collectAsState() + val isBackupDone by viewModel.isBackupDone.collectAsState() + val isDisclaimerRead by viewModel.isDisclaimerRead.collectAsState() + val showBackupNotice by viewModel.showBackupNotice.collectAsState() + val activeIdentity by activeIdentityStateFlow.collectAsState() + + // Both composable, and the handlers that show these are not: read above them. + val notify = rememberNotifier(rememberCoroutineScope()) + val keyForgotten = stringResource(Res.string.key_forgotten) + + NostrSecretContent( + uiState = uiState, + forgetting = forgetting, + npub = activeIdentity?.nostrPublicKey?.hexToNpubHrp(), + canForget = isBareKeyIdentity, + isBackupDone = isBackupDone, + isDisclaimerRead = isDisclaimerRead, + showBackupNotice = showBackupNotice, + onReveal = viewModel::revealNostrSecret, + onHide = viewModel::hideNostrSecret, + onBackupDoneChange = viewModel::setBackupDone, + onDisclaimerReadChange = viewModel::setDisclaimerRead, + onAskToForget = viewModel::askToForgetKey, + onCancelForget = viewModel::cancelForgetKey, + onForget = { + viewModel.forgetKey { + notify(keyForgotten) + onForgotten() + } + }, + onNavigateBack = onNavigateBack, + ) +} + +/** + * The screen once the state and the preferences have been read. Everything the view + * model owns arrives as a value or a callback, so a preview can render any branch + * without a key on disk. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +private fun NostrSecretContent( + uiState: NostrSecretUIState, + forgetting: NostrSecretUIState.Forgetting, + npub: String?, + canForget: Boolean, + isBackupDone: Boolean?, + isDisclaimerRead: Boolean?, + showBackupNotice: Boolean, + onReveal: () -> Unit, + onHide: () -> Unit, + onBackupDoneChange: (Boolean) -> Unit, + onDisclaimerReadChange: (Boolean) -> Unit, + onAskToForget: () -> Unit, + onCancelForget: () -> Unit, + onForget: () -> Unit, + onNavigateBack: () -> Unit, +) { + if (forgetting is NostrSecretUIState.Forgetting.Confirming) { + AlertDialog( + onDismissRequest = onCancelForget, + icon = { Icon(Icons.Default.DeleteForever, contentDescription = Decorative) }, + title = { Text(stringResource(Res.string.forget_this_key_question)) }, + text = { Text(stringResource(Res.string.mantra_will_delete_the_nsec_for_s_from_this, npub ?: "")) }, + confirmButton = { + TextButton(onClick = onForget) { Text(stringResource(Res.string.forget)) } + }, + dismissButton = { + TextButton(onClick = onCancelForget) { Text(stringResource(Res.string.cancel)) } + }, + ) + } + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { Text(text = stringResource(Res.string.nostr_secret_key)) }, + navigationIcon = { NavigateBackButton(onNavigateBack) } + ) + } + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .padding(innerPadding) + .readableContent() + .padding(horizontal = MaterialTheme.spacing.screenMargin), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap), + horizontalAlignment = Alignment.CenterHorizontally + ) { + item { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = MaterialTheme.spacing.containerPadding), + text = stringResource(Res.string.the_nsec_is_the_key_that_signs_as_you_this), + style = MaterialTheme.typography.bodyMedium + ) + } + + if (showBackupNotice) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(MaterialTheme.spacing.containerPadding), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.Warning, contentDescription = Decorative) + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + + Column { + Text( + text = stringResource(Res.string.you_have_not_backed_up_your_nostr_secret_key), + style = MaterialTheme.typography.titleSmall + ) + + Text( + text = stringResource(Res.string.lose_this_phone_before_you_do_and_the), + style = MaterialTheme.typography.bodySmall + ) + } + } + } + } + } + + item { + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(MaterialTheme.spacing.containerPadding), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap), + horizontalAlignment = Alignment.CenterHorizontally + ) { + when (uiState) { + is NostrSecretUIState.Hidden -> { + Button(onClick = onReveal) { + Icon(Icons.Default.Visibility, contentDescription = Decorative) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + Text(text = stringResource(Res.string.display_nostr_secret_key)) + } + } + + is NostrSecretUIState.Revealing -> { + LoadingDataIndicator( + fillScreen = false, + text = stringResource(Res.string.unlocking_your_key) + ) + } + + is NostrSecretUIState.Revealed -> { + RevealedNsec(nsec = uiState.nsec) + + TextButton(onClick = onHide) { + Icon(Icons.Default.VisibilityOff, contentDescription = Decorative) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + Text(text = stringResource(Res.string.hide)) + } + } + + is NostrSecretUIState.Error -> { + ErrorState( + message = when (uiState) { + is NostrSecretUIState.Error.NoActiveIdentity -> + stringResource(Res.string.no_identity_is_open_on_this_device_so_there) + is NostrSecretUIState.Error.NoKeyForThisIdentity -> + stringResource(Res.string.this_device_holds_no_key_for_the_profile) + is NostrSecretUIState.Error.KeysUnreadable -> + stringResource(Res.string.could_not_unlock_your_key_please_try) + }, + // Retrying without an identity, or for one whose key is in + // the seed rather than here, cannot help. + onRetry = when (uiState) { + is NostrSecretUIState.Error.KeysUnreadable -> onReveal + else -> null + } + ) + } + } + } + } + } + + item { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(Res.string.backup_confirmation), + style = MaterialTheme.typography.titleMedium + ) + } + + item { + Card(modifier = Modifier.fillMaxWidth()) { + if (isBackupDone == null || isDisclaimerRead == null) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(MaterialTheme.spacing.containerPadding), + text = stringResource(Res.string.loading_preferences), + style = MaterialTheme.typography.bodySmall + ) + } else { + BackupCheckbox( + checked = isBackupDone, + text = stringResource(Res.string.i_have_saved_my_nostr_secret_key_somewhere), + onCheckedChange = onBackupDoneChange + ) + + BackupCheckbox( + checked = isDisclaimerRead, + text = stringResource(Res.string.i_understand_that_if_i_lose_this_phone_and_my_key), + onCheckedChange = onDisclaimerReadChange + ) + } + } + } + + if (canForget) { + item { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.sectionGap)) + Card( + modifier = Modifier.fillMaxWidth(), + onClick = onAskToForget, + enabled = forgetting !is NostrSecretUIState.Forgetting.InProgress, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(MaterialTheme.spacing.containerPadding), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.DeleteForever, contentDescription = Decorative) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + Text( + text = stringResource(Res.string.forget_this_key), + style = MaterialTheme.typography.titleMedium + ) + } + Text( + text = when (forgetting) { + is NostrSecretUIState.Forgetting.Failed -> + stringResource(Res.string.could_not_forget_the_key_please_try_again) + else -> stringResource(Res.string.remove_the_key_from_this_device_the_profile) + }, + style = MaterialTheme.typography.bodySmall + ) + } + } + } + } + + item { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.containerPadding)) + } + } + } +} + +/** The key itself, selectable and with a copy action, since nobody transcribes sixty-three characters by hand. */ +@Composable +private fun RevealedNsec(nsec: String) { + val clipboardManager = LocalClipboardManager.current + val notify = rememberNotifier(rememberCoroutineScope()) + val copied = stringResource(Res.string.copied_the_nsec) + + Text( + text = stringResource(Res.string.keep_this_key_safe_do_not_share_it), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center + ) + + SelectionContainer { + Text( + text = nsec, + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + textAlign = TextAlign.Center + ) + } + + TextButton( + onClick = { + clipboardManager.setText(AnnotatedString(nsec)) + notify(copied) + } + ) { + Icon(Icons.Default.ContentCopy, contentDescription = Decorative) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + Text(text = stringResource(Res.string.copy_nsec)) + } +} + +@Composable +private fun BackupCheckbox( + checked: Boolean, + text: String, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + // The whole row toggles, so the row is the target and has to carry the 48dp + // the Checkbox alone would. + .minimumInteractiveComponentSize() + .clickable { onCheckedChange(!checked) } + .padding( + horizontal = MaterialTheme.spacing.containerPadding, + vertical = MaterialTheme.spacing.space150 + ), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox(checked = checked, onCheckedChange = onCheckedChange) + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + + Text(text = text, style = MaterialTheme.typography.bodyMedium) + } +} + +@ConformancePreviews +@Composable +private fun NostrSecretScreenPreview() { + press.mantra.compose.ui.theme.MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + NostrSecretContent( + uiState = NostrSecretUIState.Revealed("nsec1" + "q".repeat(58)), + forgetting = NostrSecretUIState.Forgetting.Idle, + npub = "npub1" + "q".repeat(58), + canForget = true, + isBackupDone = false, + isDisclaimerRead = true, + showBackupNotice = true, + onReveal = {}, + onHide = {}, + onBackupDoneChange = {}, + onDisclaimerReadChange = {}, + onAskToForget = {}, + onCancelForget = {}, + onForget = {}, + onNavigateBack = {}, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreen.kt new file mode 100644 index 00000000..ea5d41da --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreen.kt @@ -0,0 +1,384 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Mail +import androidx.compose.material.icons.filled.PersonSearch +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.Icon +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.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.checking_whether_s_can_receive_messages_here +import mantra.composeapp.generated.resources.looking_for_this_profile_on_the_relays +import mantra.composeapp.generated.resources.open_chat +import mantra.composeapp.generated.resources.profile +import mantra.composeapp.generated.resources.s_hasn_t_set_up_messaging_on_mantra_yet +import mantra.composeapp.generated.resources.start_new_chat +import mantra.composeapp.generated.resources.try_again +import mantra.composeapp.generated.resources.we_could_not_find_a_profile_for_this_key +import mantra.composeapp.generated.resources.you_already_have_a_chat_with_s +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.database.model.Profile +import press.mantra.compose.extensions.hexToNpubHrp +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.EmptyState +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.LocalCanSign +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.composable.widgets.profile.ProfileAvatar +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.ProfilePreviewViewModel +import press.mantra.compose.ui.view.state.ProfilePreviewUIState +import press.mantra.compose.ui.view.state.ProfilePreviewUIState.Readiness + +/** + * The person behind a pasted npub, and the one thing to do about them. + * + * Reached from the new-chat dialog by public key, before anything is created: the + * profile block is the confirmation the flow never had, and "Start new chat" hands + * over to `ChatRoomMessagingRoute` exactly as the dialog used to -- the room is made + * where it has always been made, and this screen is popped under it, as the profile + * detail is under a chat started from it. + * + * The action is the app's extended FAB, in the bottom bar where every screen's + * primary action goes, and it reads the state's readiness: a chat that exists opens, + * one that can be started starts, and one that cannot yet is disabled with the + * reason in the body above it rather than twenty seconds into the next screen. Hidden + * altogether, bar and all, for a read-only identity: a message is a gift wrap sealed + * with the sender's key, and there is nothing such an identity can do here but look. + * See docs/npub-profile-preview.md. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ProfilePreviewScreen( + activeUserPublicKey: HexKey, + profilePublicKey: HexKey, + initialProfilePreviewUIState: ProfilePreviewUIState = ProfilePreviewUIState.Loading, + nostrRepository: NostrRepository, + chatRepository: ChatRepository, + onStartChat: (Route) -> Unit, + onNavigateBack: () -> Unit, +) { + val profilePreviewViewModel: ProfilePreviewViewModel = viewModel( + factory = ProfilePreviewViewModel.factory( + activeUserPublicKey = activeUserPublicKey, + profilePublicKey = profilePublicKey, + initialProfilePreviewUIState = initialProfilePreviewUIState, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + ), + ) + + // Sixty-three characters of bech32, remembered once: they are what the user + // pasted, and the block is not a confirmation without them. + val npub = remember(profilePublicKey) { profilePublicKey.hexToNpubHrp() } + + val canSign = LocalCanSign.current + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { Text(stringResource(Res.string.profile)) }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) + }, + bottomBar = { + // Read straight off the view model rather than inside the transition below: + // the bar is a slot of the scaffold, not of the content, and it exists only + // once there is a person to act on. + val state = profilePreviewViewModel.profilePreviewUIState + if (canSign && state is ProfilePreviewUIState.Loaded) { + BottomAppBar( + floatingActionButton = { + ChatAction( + readiness = state.readiness, + onOpenChat = { chatRoomId, relayHint -> + onStartChat( + ChatRoomMessagingRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + ) + ) + }, + onStartChat = { + onStartChat( + ChatRoomMessagingRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = profilePublicKey, + relayHint = null, + ) + ) + }, + ) + }, + actions = {}, + ) + } + }, + ) { innerPadding -> + // The bars are constant, so the transition wraps the content alone. None of the + // branches needs `ColumnScope`, which is the one thing that costs. + ScreenStateTransition(profilePreviewViewModel.profilePreviewUIState) { uiState -> + when (val state = uiState) { + ProfilePreviewUIState.Loading -> { + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() + .padding(MaterialTheme.spacing.screenMargin), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap), + ) { + Npub(npub) + LoadingDataIndicator( + fillScreen = false, + text = stringResource(Res.string.looking_for_this_profile_on_the_relays), + ) + } + } + + is ProfilePreviewUIState.Loaded -> { + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(MaterialTheme.spacing.screenMargin), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap), + ) { + ProfileBlock(profile = state.profile, publicKey = profilePublicKey, npub = npub) + + // Why the button in the bar below is or is not pressable. Nothing + // for a read-only identity: there is no button to explain. + if (canSign) { + ReadinessLine( + name = state.profile.humanReadableNameOrPubkey(), + readiness = state.readiness, + onRetry = { profilePreviewViewModel.retry() }, + ) + } + } + } + + ProfilePreviewUIState.NotFound -> { + // Nothing went wrong: the search came back empty, and that is the one + // absence this screen has to name. Asking again is the action. + EmptyState( + modifier = Modifier.padding(innerPadding).readableContent(), + message = stringResource(Res.string.we_could_not_find_a_profile_for_this_key), + icon = Icons.Default.PersonSearch, + action = { + TextButton(onClick = { profilePreviewViewModel.retry() }) { + Text(stringResource(Res.string.try_again)) + } + }, + ) + } + + ProfilePreviewUIState.Error -> { + // The queue refused, so nothing was asked; asking again is the retry. + ErrorState( + modifier = Modifier.padding(innerPadding).readableContent(), + onRetry = { profilePreviewViewModel.retry() }, + ) + } + } + } + } + + LaunchedEffect(true) { + if (initialProfilePreviewUIState == ProfilePreviewUIState.Loading) { + profilePreviewViewModel.initiate() + } + } +} + +/** + * Who this is: leading-aligned, because rows with an avatar align to a leading edge + * and centring is for a block that is the only thing on the screen, which this is + * not once the button is under it. + */ +@Composable +private fun ProfileBlock( + profile: Profile, + publicKey: HexKey, + npub: String, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap), + ) { + // 75dp is the avatar's size, a dimension and not a spacing, and is what the + // profile detail draws. + ProfileAvatar( + size = 75.dp, + publicKey = publicKey, + profile = profile, + ) + + Text( + text = profile.humanReadableNameOrPubkey(), + style = MaterialTheme.typography.titleLarge, + ) + + // The nip05 or the lightning address: the thing a user compares against what + // they were told. + profile.staticIdentifier()?.let { staticIdentifier -> + Text( + text = staticIdentifier, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // The whole of it. This is the one screen whose job is to let the user read it. + profile.about?.takeIf { it.isNotBlank() }?.let { about -> + Text( + text = about, + style = MaterialTheme.typography.bodyMedium, + ) + } + + Npub(npub) + } +} + +@Composable +private fun Npub(npub: String) { + Text( + text = npub, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +/** + * The one thing to do about the person, and whether it can be done yet. + * + * The app's extended FAB, so that this screen's primary action is shaped where every + * other screen's is. Its `enabled` is what the readiness decides, and while the key + * package is still owed the icon's slot holds the indicator, as the two screens with an + * action out on the relays do. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun ChatAction( + readiness: Readiness, + onOpenChat: (chatRoomId: String, relayHint: String?) -> Unit, + onStartChat: () -> Unit, +) { + ExtendedFab( + label = stringResource( + if (readiness is Readiness.ExistingChat) Res.string.open_chat else Res.string.start_new_chat + ), + icon = { + if (readiness == Readiness.Checking) { + LoadingIndicator() + } else { + Icon( + Icons.Default.Mail, + // The label is beside it. + contentDescription = Decorative, + ) + } + }, + enabled = readiness is Readiness.ExistingChat || readiness == Readiness.CanStartChat, + onClick = { + when (readiness) { + is Readiness.ExistingChat -> onOpenChat(readiness.chatRoomId, readiness.relayHint) + else -> onStartChat() + } + }, + ) +} + +/** + * The reason the button below is or is not pressable: a supporting line, not a second + * message, and nothing at all when the answer is simply yes. + */ +@Composable +private fun ReadinessLine( + name: String, + readiness: Readiness, + onRetry: () -> Unit, +) { + when (readiness) { + is Readiness.ExistingChat -> SupportingLine(stringResource(Res.string.you_already_have_a_chat_with_s, name)) + Readiness.CanStartChat -> Unit + Readiness.Checking -> SupportingLine(stringResource(Res.string.checking_whether_s_can_receive_messages_here, name)) + Readiness.NotYetOnMantra -> { + Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)) { + SupportingLine(stringResource(Res.string.s_hasn_t_set_up_messaging_on_mantra_yet, name)) + TextButton(onClick = onRetry) { + Text(stringResource(Res.string.try_again)) + } + } + } + } +} + +@Composable +private fun SupportingLine(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@ConformancePreviews +@Composable +private fun ProfilePreviewScreenPreview() { + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + ProfilePreviewScreen( + activeUserPublicKey = "a".repeat(64), + profilePublicKey = "b".repeat(64), + initialProfilePreviewUIState = ProfilePreviewUIState.Loaded( + profile = Profile( + publicKey = "b".repeat(64), + displayName = "John Doe", + nip05 = "john@doe.com", + about = "I am the stone that the builder refused.", + nostrEventId = "nostrEventId", + ), + readiness = Readiness.CanStartChat, + ), + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + onStartChat = {}, + onNavigateBack = {}, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilesScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilesScreen.kt new file mode 100644 index 00000000..9efbac71 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilesScreen.kt @@ -0,0 +1,96 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.loading_profiles +import mantra.composeapp.generated.resources.profiles +import fr.acinq.phoenix.data.WalletId +import org.jetbrains.compose.resources.stringResource +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.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.wallet.AddProfileRows +import press.mantra.compose.ui.composable.widgets.wallet.WalletsSelector +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.ProfilesViewModel +import press.mantra.compose.ui.view.state.ProfilesUIState + +/** + * The profiles on this device -- the one that is open above a divider, the others + * below -- and the way from one to another. Phase 4 of docs/multiple-profiles.md. + * + * Tapping another row is [onSwitch] and nothing else: the navigation observer restarts + * the app as that identity, through its lock gate, so this screen is never around to + * see the result. Tapping the current row does nothing. A pushed screen behind one + * deliberate tap from the profile tab, and never a global control, because a switch + * clears the back stack from wherever the user is. + * + * One column at every width: a list of a handful of rows has no detail to pair with. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProfilesScreen( + viewModel: ProfilesViewModel, + onNavigateBack: () -> Unit, + onSwitch: (WalletId) -> Unit, + /** Re-lists. An unreadable key file is exactly the failure a retry can answer. */ + onRetry: () -> Unit, + /** The two ways to add a profile, under the list. Both push a screen with a back button. */ + onSignIn: () -> Unit, + onCreateProfile: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsState() + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { Text(stringResource(Res.string.profiles)) }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .readableContent(), + ) { + ScreenStateTransition(uiState) { state -> + when (state) { + is ProfilesUIState.Loading -> LoadingDataIndicator( + text = stringResource(Res.string.loading_profiles), + ) + is ProfilesUIState.Error -> ErrorState(onRetry = onRetry) + is ProfilesUIState.Loaded -> WalletsSelector( + modifier = Modifier.fillMaxSize().padding(horizontal = MaterialTheme.spacing.screenMargin), + wallets = state.identities, + walletsMetadata = state.walletsMetadata, + activeWalletId = state.activeId, + canEdit = false, + onWalletClick = { onSwitch(it.id) }, + profiles = state.profiles, + bottomContent = { + AddProfileRows(onSignIn = onSignIn, onCreateProfile = onCreateProfile, caption = true) + }, + ) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt index 0ad4346f..a330a4c3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt @@ -11,7 +11,6 @@ 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.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Draw @@ -22,7 +21,6 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme @@ -50,7 +48,7 @@ import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.text.ProposedEvent import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.Route -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.ProposalListViewModel import press.mantra.compose.ui.view.state.ProposalListUIState import press.mantra.compose.ui.theme.spacing @@ -64,6 +62,7 @@ import mantra.composeapp.generated.resources.waiting_for_you import mantra.composeapp.generated.resources.of_them_could_not_be_read import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.theme.ConformancePreviews @@ -119,11 +118,7 @@ fun ProposalListScreen( overflow = TextOverflow.Ellipsis ) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - } + navigationIcon = { NavigateBackButton(onNavigateBack) } ) } ) { padding -> @@ -418,7 +413,7 @@ private fun ProposalListScreenPreview() { signerId = 1 ) - TorchTheme { + MantraTheme { Surface(modifier = Modifier.fillMaxSize()) { ProposalListScreen( activeUserPublicKey = "u".repeat(64), diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/RecoveryPhraseScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/RecoveryPhraseScreen.kt index 9dae5c95..543a75ca 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/RecoveryPhraseScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/RecoveryPhraseScreen.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.Warning @@ -23,7 +22,6 @@ import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface @@ -42,7 +40,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import fr.acinq.phoenix.PhoenixGlobal -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity import kotlinx.coroutines.flow.StateFlow import mantra.composeapp.generated.resources.Res import mantra.composeapp.generated.resources.backup_confirmation @@ -66,6 +64,7 @@ import org.jetbrains.compose.resources.stringResource import press.mantra.compose.ui.composable.widgets.Decorative import press.mantra.compose.ui.composable.widgets.ErrorState import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.ConformancePreviews import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.theme.spacing @@ -82,13 +81,13 @@ import press.mantra.compose.ui.view.state.RecoveryPhraseUIState @Composable fun RecoveryPhraseScreen( phoenixGlobal: PhoenixGlobal, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, onNavigateBack: () -> Unit, ) { val recoveryPhraseViewModel: RecoveryPhraseViewModel = viewModel( factory = RecoveryPhraseViewModel.factory( phoenixGlobal = phoenixGlobal, - activeWalletStateFlow = activeWalletStateFlow + activeIdentityStateFlow = activeIdentityStateFlow ) ) @@ -136,18 +135,7 @@ private fun RecoveryPhraseContent( text = stringResource(Res.string.recovery_phrase) ) }, - navigationIcon = { - IconButton( - onClick = { - onNavigateBack.invoke() - } - ) { - Icon( - Icons.Default.ArrowBack, - contentDescription = "Back" - ) - } - } + navigationIcon = { NavigateBackButton(onNavigateBack) } ) } ) { innerPadding -> @@ -460,7 +448,7 @@ private fun RecoveryPhraseCheckbox( @ConformancePreviews @Composable private fun RecoveryPhraseScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { @@ -487,7 +475,7 @@ private fun RecoveryPhraseScreenPreview() { @ConformancePreviews @Composable private fun RecoveryPhraseScreenHiddenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchMemberToAddToChatRoomScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchMemberToAddToChatRoomScreen.kt index f0215c77..ea39c367 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchMemberToAddToChatRoomScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchMemberToAddToChatRoomScreen.kt @@ -35,7 +35,7 @@ import press.mantra.compose.ui.composable.navigation.routes.AddMemberToChatRoomC import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.SearchMemberToAddToChatRoomViewModel import press.mantra.compose.ui.view.state.SearchMemberToAddToChatRoomUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -47,6 +47,7 @@ import mantra.composeapp.generated.resources.search_member_functionality import mantra.composeapp.generated.resources.search_message_functionality_will_be_here import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.theme.ConformancePreviews @@ -61,6 +62,7 @@ fun SearchMemberToAddToChatRoomScreen( nostrRepository: NostrRepository, chatRepository: ChatRepository, onNavigateToRoute: (Route) -> Unit, + onNavigateBack: () -> Unit, ) { val searchMemberToAddToChatRoomViewModel: SearchMemberToAddToChatRoomViewModel = viewModel( factory = SearchMemberToAddToChatRoomViewModel.factory( @@ -102,7 +104,8 @@ fun SearchMemberToAddToChatRoomScreen( Text( text = title, ) - } + }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, ) } ) { innerPadding -> @@ -226,7 +229,7 @@ fun SearchMemberToAddToChatRoomScreen( @ConformancePreviews @Composable private fun ChatRoomDetailScreenPreview() { - TorchTheme { + MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { @@ -256,7 +259,8 @@ private fun ChatRoomDetailScreenPreview() { ), nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - onNavigateToRoute = {} + onNavigateToRoute = {}, + onNavigateBack = {}, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchResultScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchResultScreen.kt index 0e6d0793..83afd9f3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchResultScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchResultScreen.kt @@ -284,7 +284,7 @@ fun SearchResultScreen( @ConformancePreviews @Composable private fun SearchResultScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchScreen.kt index 295fb2c6..02d31c75 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchScreen.kt @@ -58,7 +58,7 @@ import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.navigation.routes.SearchResultRoute import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar import press.mantra.compose.ui.composable.widgets.profile.ProfileColor -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.SearchViewModel import press.mantra.compose.ui.view.state.SearchUIState import press.mantra.compose.ui.theme.spacing @@ -426,7 +426,7 @@ fun SearchScreen( @ConformancePreviews @Composable private fun SearchScreenPreview() { - TorchTheme { + MantraTheme { Surface( modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt index 4fb9af0a..c9e888fc 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.material3.Card import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme @@ -37,7 +36,7 @@ import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.SelectChatRoomMembersViewModel import press.mantra.compose.ui.view.state.SelectChatRoomMembersUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -54,8 +53,10 @@ import mantra.composeapp.generated.resources.next_with import mantra.composeapp.generated.resources.selected import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab import press.mantra.compose.ui.theme.ConformancePreviews /** @@ -74,6 +75,7 @@ fun SelectChatRoomMembersScreen( initialSelectChatRoomMembersUIState: SelectChatRoomMembersUIState = SelectChatRoomMembersUIState.Loading, nostrRepository: NostrRepository, onNavigateToRoute: (Route) -> Unit, + onNavigateBack: () -> Unit, ) { val selectChatRoomMembersViewModel: SelectChatRoomMembersViewModel = viewModel( factory = SelectChatRoomMembersViewModel.factory( @@ -114,32 +116,28 @@ fun SelectChatRoomMembersScreen( maxLines = 1, overflow = TextOverflow.Ellipsis ) - } + }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, ) }, bottomBar = { BottomAppBar( floatingActionButton = { - ExtendedFloatingActionButton( + ExtendedFab( + label = if (selectedCount > 0) { + stringResource(Res.string.next_with, selectedCount) + } else { + stringResource(Res.string.next) + }, + icon = { + Icon(Icons.AutoMirrored.Filled.NavigateNext, contentDescription = "Next") + }, onClick = { selectChatRoomMembersViewModel.selectChatRoomType( onNavigateToRoute = onNavigateToRoute ) } - ) { - Icon( - Icons.AutoMirrored.Filled.NavigateNext, - contentDescription = "Next" - ) - - Text( - text = if (selectedCount > 0) { - stringResource(Res.string.next_with, selectedCount) - } else { - stringResource(Res.string.next) - } - ) - } + ) }, actions = { Text( @@ -283,7 +281,7 @@ fun SelectChatRoomMembersScreen( @ConformancePreviews @Composable private fun SelectChatRoomMembersScreenPreview() { - TorchTheme { + MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { @@ -302,7 +300,8 @@ private fun SelectChatRoomMembersScreenPreview() { ) ), nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, - onNavigateToRoute = {} + onNavigateToRoute = {}, + onNavigateBack = {}, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt index 9f58557e..c0705763 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt @@ -23,7 +23,6 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -47,11 +46,11 @@ import press.mantra.compose.repository.DkgRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.SelectChatRoomTypeViewModel import press.mantra.compose.ui.view.state.SelectChatRoomTypeUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import press.mantra.compose.ui.theme.spacing @@ -71,8 +70,10 @@ import mantra.composeapp.generated.resources.was_created_but_its_shared_key_cere import mantra.composeapp.generated.resources.you_and_others import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab import press.mantra.compose.ui.theme.ConformancePreviews /** @@ -87,11 +88,12 @@ fun SelectChatRoomTypeScreen( description: String?, memberPublicKeys: List, initialSelectChatRoomTypeUIState: SelectChatRoomTypeUIState = SelectChatRoomTypeUIState.Loading, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, nostrRepository: NostrRepository, chatRepository: ChatRepository, dkgRepository: DkgRepository, onNavigateToRoute: (Route) -> Unit, + onNavigateBack: () -> Unit, ) { val selectChatRoomTypeViewModel: SelectChatRoomTypeViewModel = viewModel( factory = SelectChatRoomTypeViewModel.factory( @@ -100,7 +102,7 @@ fun SelectChatRoomTypeScreen( description = description, memberPublicKeys = memberPublicKeys, initialSelectChatRoomTypeUIState = initialSelectChatRoomTypeUIState, - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, nostrRepository = nostrRepository, chatRepository = chatRepository, dkgRepository = dkgRepository @@ -142,38 +144,32 @@ fun SelectChatRoomTypeScreen( maxLines = 1, overflow = TextOverflow.Ellipsis ) - } + }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, ) }, bottomBar = { BottomAppBar( floatingActionButton = { - ExtendedFloatingActionButton( + ExtendedFab( + label = if (selectChatRoomTypeViewModel.createdChatRoomId.value != null) { + stringResource(Res.string.open_chat) + } else { + stringResource(Res.string.create_chat) + }, + icon = { + if (isActionPending) { + CircularProgressIndicator(modifier = Modifier.size(20.dp)) + } else { + Icon(Icons.Default.Check, contentDescription = "Create chat") + } + }, onClick = { selectChatRoomTypeViewModel.createChatRoom( onNavigateToRoute = onNavigateToRoute ) } - ) { - if (isActionPending) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp) - ) - } else { - Icon( - Icons.Default.Check, - contentDescription = "Create chat" - ) - } - - Text( - text = if (selectChatRoomTypeViewModel.createdChatRoomId.value != null) { - stringResource(Res.string.open_chat) - } else { - stringResource(Res.string.create_chat) - } - ) - } + ) }, actions = { Text( @@ -465,7 +461,7 @@ private fun QuorumPicker( @ConformancePreviews @Composable private fun SelectChatRoomTypeScreenPreview() { - TorchTheme { + MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { @@ -490,11 +486,12 @@ private fun SelectChatRoomTypeScreenPreview() { ) ) ), - activeWalletStateFlow = MutableStateFlow(null), + activeIdentityStateFlow = MutableStateFlow(null), nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY, - onNavigateToRoute = {} + onNavigateToRoute = {}, + onNavigateBack = {}, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt index c2baba8c..a104bc1e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.material3.Card import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.ListItem @@ -35,7 +34,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import mantra.composeapp.generated.resources.Res @@ -59,10 +58,12 @@ import press.mantra.compose.ui.composable.widgets.Decorative 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.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar import press.mantra.compose.ui.theme.ConformancePreviews -import press.mantra.compose.ui.theme.TorchTheme +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.SelectSubgroupAdminsViewModel @@ -81,17 +82,18 @@ fun SelectSubgroupAdminsScreen( activeUserPublicKey: HexKey, parentChatRoomId: String, initialUIState: SelectSubgroupAdminsUIState = SelectSubgroupAdminsUIState.Loading, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, chatRepository: ChatRepository, dkgRepository: DkgRepository, - onNavigateToRoute: (Route) -> Unit + onNavigateToRoute: (Route) -> Unit, + onNavigateBack: () -> Unit, ) { val viewModel: SelectSubgroupAdminsViewModel = viewModel( factory = SelectSubgroupAdminsViewModel.factory( activeUserPublicKey = activeUserPublicKey, parentChatRoomId = parentChatRoomId, initialUIState = initialUIState, - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, chatRepository = chatRepository, dkgRepository = dkgRepository ) @@ -115,10 +117,15 @@ fun SelectSubgroupAdminsScreen( is SelectSubgroupAdminsUIState.Loaded -> Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { - TopAppBar(title = { Text(stringResource(Res.string.new_subgroup)) }) + TopAppBar( + title = { Text(stringResource(Res.string.new_subgroup)) }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) }, floatingActionButton = { - ExtendedFloatingActionButton( + ExtendedFab( + label = stringResource(Res.string.start_the_key_ceremony), + icon = { Icon(Icons.Default.AccountTree, contentDescription = Decorative) }, onClick = { scope.launch { // The refusal is recomputed rather than cached on @@ -132,9 +139,7 @@ fun SelectSubgroupAdminsScreen( viewModel.confirm(onNavigateToRoute) } } - }, - icon = { Icon(Icons.Default.AccountTree, contentDescription = Decorative) }, - text = { Text(stringResource(Res.string.start_the_key_ceremony)) } + } ) } ) { innerPadding -> @@ -326,15 +331,16 @@ private fun QuorumStepper( @ConformancePreviews @Composable private fun SelectSubgroupAdminsScreenPreview() { - TorchTheme { + MantraTheme { Surface(modifier = Modifier.fillMaxSize()) { SelectSubgroupAdminsScreen( activeUserPublicKey = "a".repeat(64), parentChatRoomId = "b".repeat(64), - activeWalletStateFlow = kotlinx.coroutines.flow.MutableStateFlow(null), + activeIdentityStateFlow = kotlinx.coroutines.flow.MutableStateFlow(null), chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY, - onNavigateToRoute = {} + onNavigateToRoute = {}, + onNavigateBack = {}, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ShareProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ShareProfileScreen.kt index eeb75341..c559cac0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ShareProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ShareProfileScreen.kt @@ -10,13 +10,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface @@ -43,6 +41,7 @@ import press.mantra.compose.ui.view.model.ShareProfileViewModel import press.mantra.compose.ui.view.state.ShareProfileUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import press.mantra.compose.ui.composable.widgets.LocalCanSign import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource @@ -55,6 +54,7 @@ import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @@ -92,18 +92,7 @@ fun ShareProfileScreen( text = stringResource(Res.string.profile) ) }, - navigationIcon = { - IconButton( - onClick = { - onNavigateBack.invoke() - } - ) { - Icon( - Icons.Default.ArrowBack, - contentDescription = "Back" - ) - } - } + navigationIcon = { NavigateBackButton(onNavigateBack) } ) } ) { innerPadding -> @@ -188,7 +177,11 @@ fun ShareProfileScreen( } } - Row( + // Nothing is signed by a re-broadcast, but a copy of the kind 0 is + // queued for the finder relays, and a read-only identity puts nothing + // on a relay -- not even a copy. The broadcast pump is not running for + // it to carry the request anyway. + if (LocalCanSign.current) Row( modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), horizontalArrangement = Arrangement.Center ) { @@ -230,7 +223,7 @@ fun ShareProfileScreen( @ConformancePreviews @Composable private fun UnannouncedProfileScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt index ae53143f..a809c665 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt @@ -3,53 +3,340 @@ package press.mantra.compose.ui.composable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column 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.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentPaste +import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.Spellcheck +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton 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.TextField +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import press.mantra.compose.ui.theme.spacing +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.lifecycle.viewmodel.compose.viewModel import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.a_password_protected_key_ncryptsec_is_not +import mantra.composeapp.generated.resources.enter_a_recovery_phrase_or_an_nsec_to_sign_in +import mantra.composeapp.generated.resources.next +import mantra.composeapp.generated.resources.paste +import mantra.composeapp.generated.resources.recognised_as_a_nostr_secret_key_no_wallet +import mantra.composeapp.generated.resources.recognised_as_a_public_key_you_will_see +import mantra.composeapp.generated.resources.recognised_as_a_recovery_phrase_it_also +import mantra.composeapp.generated.resources.recovery_phrase_nsec_or_npub +import mantra.composeapp.generated.resources.sign_in +import mantra.composeapp.generated.resources.signed_in +import mantra.composeapp.generated.resources.switch_to_it +import mantra.composeapp.generated.resources.signing_you_in +import mantra.composeapp.generated.resources.that_is_not_a_recovery_phrase_or_a_nostr_key +import mantra.composeapp.generated.resources.that_nostr_secret_key_is_not_valid +import mantra.composeapp.generated.resources.that_recovery_phrase_is_not_valid_check +import mantra.composeapp.generated.resources.the_key_could_not_be_saved_on_this_device +import mantra.composeapp.generated.resources.the_words_or_the_key_never_leave_this_device +import mantra.composeapp.generated.resources.this_key_is_already_on_this_device +import mantra.composeapp.generated.resources.twelve_words_nsec1_or_npub1 +import mantra.composeapp.generated.resources.use_a_different_key +import mantra.composeapp.generated.resources.you_are_signing_in_as +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.phoenix.data.WalletId import org.jetbrains.compose.resources.stringResource -import mantra.composeapp.generated.resources.sign_in_is_not_yet_available_while_mantra_is +import press.mantra.compose.extensions.hexToNpubHrp +import press.mantra.compose.identity.CredentialProblem +import press.mantra.compose.identity.IdentityWriter +import press.mantra.compose.identity.SignInCredential +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.widgets.Decorative +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.theme.readableContent +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.rememberNotifier +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.SignInToProfileViewModel +import press.mantra.compose.ui.view.model.WritingSeedState +import press.mantra.compose.ui.view.state.SignInToProfileUIState -// The message is the whole screen while sign in is held back, so it is centred in -// the window rather than given the leading edge the readable column normally keeps. +/** + * Signing in with a secret the user already has. + * + * One field, two things it accepts -- a recovery phrase or an nsec -- because a user + * does not choose an input type, they paste what they have. What was recognised, and + * the npub it signs as, is shown before anything is written; that is the step that + * catches the wrong key. Problems with the input stay on the field; problems after + * confirming are a state of the screen. + */ +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun SignInToProfileScreen() { - Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding -> - Column( - modifier = Modifier.fillMaxSize().padding(innerPadding) - .readableContent() - .padding(MaterialTheme.spacing.screenMargin), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Text( - text = stringResource(Res.string.sign_in_is_not_yet_available_while_mantra_is), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center +fun SignInToProfileScreen( + nostrRepository: NostrRepository, + writeNostrKey: suspend (PrivateKey) -> IdentityWriter.WriteNostrCredentialResult, + writeNostrPublicKey: suspend (HexKey) -> IdentityWriter.WriteNostrCredentialResult, + writeRecoveryPhrase: ( + words: List, + onWritten: (WalletId) -> Unit, + onError: (WritingSeedState.Error) -> Unit, + ) -> Unit, + onNavigateBack: () -> Unit, + /** + * The tail: re-list, select, go to startup. Also what "switch to it" does when the key + * pasted is already here -- the same tail with the id it is here under, which makes a + * duplicate paste the fastest switch in the app. + */ + onSignedIn: (WalletId) -> Unit, +) { + val viewModel: SignInToProfileViewModel = viewModel( + factory = SignInToProfileViewModel.factory( + nostrRepository = nostrRepository, + writeNostrKey = writeNostrKey, + writeNostrPublicKey = writeNostrPublicKey, + writeRecoveryPhrase = writeRecoveryPhrase, + ) + ) + + // Both composable, and the handler that shows this is not: read above it. + val notify = rememberNotifier(rememberCoroutineScope()) + val signedIn = stringResource(Res.string.signed_in) + + // imePadding: there is a text field, and without it the keyboard covers it. + Scaffold( + modifier = Modifier.imePadding(), + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { Text(stringResource(Res.string.sign_in)) }, + navigationIcon = { NavigateBackButton(onNavigateBack) } ) } + ) { innerPadding -> + ScreenStateTransition( + state = viewModel.uiState.value, + modifier = Modifier.fillMaxSize().padding(innerPadding), + label = "sign in", + ) { uiState -> + when (val state = uiState) { + is SignInToProfileUIState.InputPrompt -> InputPrompt(viewModel) + + is SignInToProfileUIState.Confirm -> Confirm( + credential = state.credential, + onConfirm = { + viewModel.confirm(state.credential) { id -> + notify(signedIn) + onSignedIn(id) + } + }, + onUseADifferentKey = { viewModel.useADifferentKey() }, + ) + + is SignInToProfileUIState.Committing -> LoadingDataIndicator( + text = stringResource(Res.string.signing_you_in) + ) + + is SignInToProfileUIState.Error -> Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ErrorState( + message = problemMessage(state.problem), + onRetry = { viewModel.retry() }, + ) + // "Already on this device" is a sentence with an obvious next step. + state.alreadyHere?.let { id -> + TextButton(onClick = { onSignedIn(id) }) { + Text(stringResource(Res.string.switch_to_it)) + } + } + } + } + } } } +@Composable +private fun InputPrompt(viewModel: SignInToProfileViewModel) { + val field = viewModel.signInToProfileFormState.textFieldForm.textFieldState + val problem = viewModel.inputProblem.value + val clipboardManager = LocalClipboardManager.current + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .readableContent() + .padding(MaterialTheme.spacing.screenMargin), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap), + ) { + Text( + text = stringResource(Res.string.the_words_or_the_key_never_leave_this_device), + style = MaterialTheme.typography.bodyMedium, + ) + + TextField( + modifier = Modifier.fillMaxWidth(), + state = field, + isError = problem != null, + supportingText = problem?.let { { Text(text = problemMessage(it)) } }, + // Two lines shows a whole phrase without scrolling and an nsec on one; four is + // room for a phrase pasted with line breaks. + lineLimits = TextFieldLineLimits.MultiLine(minHeightInLines = 2, maxHeightInLines = 4), + // A key is not prose: no capitalisation, no autocorrect, no suggestions rewriting + // a word of the phrase. Monospace so an nsec can be read back character by character. + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Ascii, + ), + textStyle = MaterialTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace), + label = { Text(text = stringResource(Res.string.recovery_phrase_nsec_or_npub), maxLines = 1) }, + placeholder = { Text(text = stringResource(Res.string.twelve_words_nsec1_or_npub1), maxLines = 1) }, + trailingIcon = { + IconButton( + onClick = { + clipboardManager.getText()?.text?.let { field.setTextAndPlaceCursorAtEnd(it) } + } + ) { + Icon( + Icons.Default.ContentPaste, + contentDescription = stringResource(Res.string.paste) + ) + } + }, + ) + + Button( + modifier = Modifier.align(Alignment.End), + onClick = { viewModel.recognise() }, + ) { + Text(text = stringResource(Res.string.next)) + } + } +} + +@Composable +private fun Confirm( + credential: SignInCredential, + onConfirm: () -> Unit, + onUseADifferentKey: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .readableContent() + .padding(MaterialTheme.spacing.screenMargin), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap), + ) { + Text( + text = stringResource(Res.string.you_are_signing_in_as), + style = MaterialTheme.typography.bodyMedium, + ) + + // In full, so a wrong paste can be seen for what it is. The secret is never echoed. + Text( + text = credential.nostrPublicKey.hexToNpubHrp(), + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + ) + + Column( + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap), + ) { + // The label beside each icon says what it is; the icon only decorates it. + when (credential) { + is SignInCredential.RecoveryPhrase -> { + Icon(Icons.Default.Spellcheck, contentDescription = Decorative) + Text( + text = stringResource(Res.string.recognised_as_a_recovery_phrase_it_also), + style = MaterialTheme.typography.bodyMedium, + ) + } + is SignInCredential.NostrSecret -> { + Icon(Icons.Default.Key, contentDescription = Decorative) + Text( + text = stringResource(Res.string.recognised_as_a_nostr_secret_key_no_wallet), + style = MaterialTheme.typography.bodyMedium, + ) + } + is SignInCredential.NostrPublicKey -> { + // What the user is about to get and not get, said here and nowhere + // else: this is the one step before the choice is made. + Icon(Icons.Default.Visibility, contentDescription = Decorative) + Text( + text = stringResource(Res.string.recognised_as_a_public_key_you_will_see), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap), + ) { + Button(onClick = onConfirm) { + Text(text = stringResource(Res.string.sign_in)) + } + TextButton(onClick = onUseADifferentKey) { + Text(text = stringResource(Res.string.use_a_different_key)) + } + } + } +} + +/** Each refusal is a sentence that says what to do instead, not a code. */ +@Composable +private fun problemMessage(problem: CredentialProblem): String = stringResource( + when (problem) { + CredentialProblem.Empty -> Res.string.enter_a_recovery_phrase_or_an_nsec_to_sign_in + CredentialProblem.NotRecognised -> Res.string.that_is_not_a_recovery_phrase_or_a_nostr_key + CredentialProblem.InvalidPhrase -> Res.string.that_recovery_phrase_is_not_valid_check + CredentialProblem.InvalidKey -> Res.string.that_nostr_secret_key_is_not_valid + CredentialProblem.EncryptedKey -> Res.string.a_password_protected_key_ncryptsec_is_not + CredentialProblem.AlreadyOnThisDevice -> Res.string.this_key_is_already_on_this_device + CredentialProblem.CouldNotWrite -> Res.string.the_key_could_not_be_saved_on_this_device + } +) + @ConformancePreviews @Composable private fun SignInToProfileScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { - SignInToProfileScreen() + SignInToProfileScreen( + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + writeNostrKey = { IdentityWriter.WriteNostrCredentialResult.CannotLoadKeys }, + writeNostrPublicKey = { IdentityWriter.WriteNostrCredentialResult.CannotLoadKeys }, + writeRecoveryPhrase = { _, _, onError -> onError(WritingSeedState.Error.CannotLoadSeedMap) }, + onNavigateBack = {}, + onSignedIn = {}, + ) } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SocialPreconditionScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SocialPreconditionScreen.kt index 4d310826..b221d50a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SocialPreconditionScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SocialPreconditionScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.SkipNext import androidx.compose.material3.BottomAppBar import androidx.compose.material3.Button -import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -21,6 +20,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import press.mantra.compose.ui.composable.widgets.LocalCanSign import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource @@ -32,6 +32,7 @@ import mantra.composeapp.generated.resources.view_invites import mantra.composeapp.generated.resources.who_will_you_be_passing_the_aux_to import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.theme.ConformancePreviews @@ -50,17 +51,9 @@ fun SocialPreconditionScreen( }, floatingActionButton = { - ExtendedFloatingActionButton( - modifier = Modifier, - icon = { - Icon( - Icons.Default.SkipNext, - contentDescription = "Skip for now" - ) - }, - text = { - Text(stringResource(Res.string.skip_for_now)) - }, + ExtendedFab( + label = stringResource(Res.string.skip_for_now), + icon = { Icon(Icons.Default.SkipNext, contentDescription = "Skip for now") }, onClick = { onNavigateToSkipForNow.invoke() } @@ -88,36 +81,40 @@ fun SocialPreconditionScreen( modifier = Modifier.weight(1f) ) - Text( - stringResource(Res.string.tell_friends_to_join_you_so_your_feed_stays), - textAlign = TextAlign.Center - ) - - Button( - onClick = { - onNavigateToInviteFriend.invoke() - } - ) { + // Both pending today, and both writes when they exist: an invite is sent, + // an acceptance signed. A read-only identity is offered only the way past. + if (LocalCanSign.current) { Text( - stringResource(Res.string.invite_a_friend) + stringResource(Res.string.tell_friends_to_join_you_so_your_feed_stays), + textAlign = TextAlign.Center ) - } - Spacer( - modifier = Modifier.weight(1f) - ) - - Text( - stringResource(Res.string.view_and_accept_invites_you_may_have), - textAlign = TextAlign.Center - ) - - Button( - onClick = { - onNavigateToViewInvites.invoke() + Button( + onClick = { + onNavigateToInviteFriend.invoke() + } + ) { + Text( + stringResource(Res.string.invite_a_friend) + ) + } + + Spacer( + modifier = Modifier.weight(1f) + ) + + Text( + stringResource(Res.string.view_and_accept_invites_you_may_have), + textAlign = TextAlign.Center + ) + + Button( + onClick = { + onNavigateToViewInvites.invoke() + } + ) { + Text(stringResource(Res.string.view_invites)) } - ) { - Text(stringResource(Res.string.view_invites)) } } } @@ -127,7 +124,7 @@ fun SocialPreconditionScreen( @ConformancePreviews @Composable private fun SocialPreconditionScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt index 02d664d9..d0e04ab7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt @@ -18,7 +18,6 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import fr.acinq.phoenix.data.ListWalletState -import fr.acinq.phoenix.data.UserWallet import fr.acinq.phoenix.data.WalletId import fr.acinq.phoenix.managers.DataStoreManager import fr.acinq.phoenix.managers.NodeParamsManager @@ -26,18 +25,37 @@ import fr.acinq.phoenix.utils.preferences.UserPrefs import fr.acinq.phoenix.utils.preferences.UserWalletMetadata import fr.acinq.phoenix.utils.preferences.getByWalletIdOrDefault import kotlinx.coroutines.flow.first +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.IdentityKind +import press.mantra.compose.identity.StoredIdentity +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.widgets.wallet.AddProfileRows +import press.mantra.compose.ui.composable.widgets.wallet.rememberProfilesOf import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.choose_a_profile +import mantra.composeapp.generated.resources.could_not_load_the_profiles_on_this_device +import mantra.composeapp.generated.resources.decrypting +import mantra.composeapp.generated.resources.loading_preferences import mantra.composeapp.generated.resources.lock_prompt_coming_soon -import mantra.composeapp.generated.resources.select_a_wallet +import mantra.composeapp.generated.resources.opening_profile +import mantra.composeapp.generated.resources.preparing_profiles +import mantra.composeapp.generated.resources.starting_wallet import mantra.composeapp.generated.resources.startup_error +import mantra.composeapp.generated.resources.unlock_to_continue +import press.mantra.compose.ui.view.state.StartupChoice @Composable fun SovereignWalletStartupScreen( sovereignWalletViewModel: press.mantra.compose.ui.view.model.SovereignWalletViewModel, + /** For the selector's rows: the nostr profile of each listed key, where the device has one. */ + nostrRepository: NostrRepository, onNavigateToWalletLandingPage: () -> Unit, onSuccessfulStartup: () -> Unit, + /** The two ways to add a profile from the selector; a device with one profile can be given a second without opening the first. */ + onNavigateToSignIn: () -> Unit, + onNavigateToCreateProfile: () -> Unit, forceWalletId: WalletId?, ) { val sovereignWalletStartupViewModel = viewModel( @@ -49,8 +67,8 @@ fun SovereignWalletStartupScreen( // Deliberately no intro gate. `getShowIntro` defaults to true and nothing in this app ever // writes it back to false, so gating on it navigated away from this screen on every cold boot // — and a screen that has been left behind never gets to start the wallet. Startup has to run - // to completion here: everything downstream reads the node's key manager. An intro screen has - // to be a step inside this flow, not a detour around it. + // to completion here: everything downstream reads the active identity, and nothing sets one + // but this screen. An intro screen has to be a step inside this flow, not a detour around it. val listWalletState by sovereignWalletViewModel.listWalletState.collectAsState() @@ -63,18 +81,20 @@ fun SovereignWalletStartupScreen( when (listWalletState) { is ListWalletState.Init -> { LoadingDataIndicator( - text = "Decrypting..." + text = stringResource(Res.string.decrypting) ) } is ListWalletState.Error -> { ImplementationPendingScreen( - "Failed to load wallet data", + text = stringResource(Res.string.could_not_load_the_profiles_on_this_device), + // Drawn inside the startup gate, which is the root of the stack. + onNavigateBack = null, ) } is ListWalletState.Success -> { val globalPrefs = sovereignWalletViewModel.getGlobalPrefs() - val availableWallets by sovereignWalletViewModel.availableWallets.collectAsState() + val availableIdentities by sovereignWalletViewModel.availableIdentities.collectAsState() val defaultWallet = globalPrefs.getDefaultWallet.collectAsState(null) val startWalletImmediately by sovereignWalletViewModel.startWalletImmediately.collectAsState() @@ -84,70 +104,81 @@ fun SovereignWalletStartupScreen( val desiredWalletIdFlow = sovereignWalletViewModel.desiredWalletId.collectAsState() val desiredWalletId = desiredWalletIdFlow.value - val activeWalletFlow = sovereignWalletViewModel.activeWalletInUI.collectAsState() - val activeWallet = activeWalletFlow.value + val activeIdentityFlow = sovereignWalletViewModel.activeIdentity.collectAsState() + val activeIdentity = activeIdentityFlow.value when { - availableWallets.isEmpty() -> { + availableIdentities.isEmpty() -> { LaunchedEffect(Unit) { onNavigateToWalletLandingPage.invoke() } LoadingDataIndicator( - text = "Initializing..." + text = stringResource(Res.string.preparing_profiles) ) } availableWalletMetadata == null || defaultWallet.value == null -> { LoadingDataIndicator( - text = "Preparing wallet..." + text = stringResource(Res.string.preparing_profiles) ) } - activeWallet != null -> { + activeIdentity != null -> { LoadingDataIndicator( - text = "Opening wallet" + text = stringResource(Res.string.opening_profile) ) LaunchedEffect(Unit) { - sovereignWalletViewModel.loadSovereignData(activeWallet.id) + sovereignWalletViewModel.loadSovereignData(activeIdentity.id) onSuccessfulStartup.invoke() } } else -> { when (val startupState = sovereignWalletStartupViewModel.state.value) { is press.mantra.compose.ui.view.model.StartupViewState.Init -> { - var loadingWallet by remember { + // The table is StartupChoice's, where it can be read and + // tested; the one thing decided here is that the saved default + // is only a default when it names a wallet. + var loadingIdentity by remember { mutableStateOf( - when { - forceWalletId != null -> availableWallets[forceWalletId] - !startWalletImmediately -> null - availableWallets.size == 1 -> availableWallets.entries.firstOrNull()?.value - desiredWalletId != null -> availableWallets[desiredWalletId] - startWalletImmediately -> availableWallets[defaultWallet.value] - else -> null - } + StartupChoice.resolve( + force = forceWalletId, + desired = desiredWalletId, + startImmediately = startWalletImmediately, + identities = availableIdentities, + default = defaultWallet.value as? WalletId, + ) ) } - when (val wallet = loadingWallet) { + when (val stored = loadingIdentity) { null -> { + val profiles by rememberProfilesOf( + publicKeys = availableIdentities.values.map { it.nostrPublicKey }, + nostrRepository = nostrRepository, + ) press.mantra.compose.ui.composable.widgets.wallet.WalletsSelector( - wallets = availableWallets, - globalPrefs = globalPrefs, + wallets = availableIdentities, walletsMetadata = availableWalletMetadata, activeWalletId = null, + profiles = profiles, onWalletClick = { - sovereignWalletViewModel.switchToWallet( - it.walletId - ); loadingWallet = it + sovereignWalletViewModel.switchToIdentity( + it.id + ); loadingIdentity = it }, canEdit = false, modifier = Modifier.padding(horizontal = MaterialTheme.spacing.space300), topContent = { Spacer(Modifier.height(MaterialTheme.spacing.space800)) Text( - text = stringResource(Res.string.select_a_wallet), + text = stringResource(Res.string.choose_a_profile), style = MaterialTheme.typography.headlineSmall ) Spacer(Modifier.height(MaterialTheme.spacing.space200)) }, bottomContent = { + AddProfileRows( + onSignIn = onNavigateToSignIn, + onCreateProfile = onNavigateToCreateProfile, + caption = false, + ) // m3-spacing-exempt: room to scroll the - // last wallet clear of the bottom of the + // last row clear of the bottom of the // window, not a step in the spacing // rhythm. The scale tops out at 72dp and // rounding to it would put the last row @@ -157,39 +188,84 @@ fun SovereignWalletStartupScreen( ) } else -> { - val metadata = remember { availableWalletMetadata.getByWalletIdOrDefault(wallet.walletId) } + val metadata = remember { availableWalletMetadata.getByWalletIdOrDefault(stored.id) } val dataStoreManager = DataStoreManager( ctx = sovereignWalletViewModel.phoenixGlobal.ctx, chain = NodeParamsManager.chain ) + // The screen-lock gate wraps both kinds. It only ever needed the + // id, to read the lock preferences, so it sits outside the branch + // and a future lock is not something to remember to add twice. LoadWallet( - userWallet = wallet, + identity = stored, metadata = metadata, - userPrefs = dataStoreManager.loadUserPrefsForWallet(wallet.walletId), + userPrefs = dataStoreManager.loadUserPrefsForWallet(stored.id), promptScreenLockImmediately = startWalletImmediately, - doLoadWallet = { userWallet -> - sovereignWalletStartupViewModel.startupNode(walletId = userWallet.walletId, words = userWallet.words, onStartupSuccess = { - sovereignWalletViewModel.setActiveWallet(walletId = userWallet.walletId, business = it) - onSuccessfulStartup.invoke() - }) - loadingWallet = null + doLoadWallet = { identity -> + when (identity) { + is StoredIdentity.Mnemonic -> { + // The node starts for the wallet attached to this profile -- + // its preferences, its metadata, the channel watcher -- and no + // longer for the key, which the identity reads from the + // credential the node is handed to cross-check. + sovereignWalletStartupViewModel.startupNode(walletId = identity.id, words = identity.userWallet.words, onStartupSuccess = { + sovereignWalletViewModel.setActiveWallet(stored = identity, business = it) + onSuccessfulStartup.invoke() + }) + } + is StoredIdentity.NostrSecret -> { + // No node to start, so no platformStartupLogic, no + // schedulePlatformLogic and no StartupViewState: the + // identity is active the moment it is read. Setting it + // recomposes into the `activeIdentity != null` branch + // above, which is what reports the startup. + sovereignWalletViewModel.setActiveIdentity( + Identity.signing( + id = identity.id, + kind = IdentityKind.NostrSecret, + nostrPrivateKey = identity.privateKey, + userPrefs = dataStoreManager.loadUserPrefsForWallet(identity.id), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(identity.id), + business = null, + ) + ) + } + is StoredIdentity.NostrPublic -> { + // Nothing to start and nothing to decrypt: the + // public key is the whole of what the device + // holds, and the identity is active the moment + // it is read, as the nsec branch above is. + sovereignWalletViewModel.setActiveIdentity( + Identity.readOnly( + id = identity.id, + nostrPublicKey = identity.nostrPublicKey, + userPrefs = dataStoreManager.loadUserPrefsForWallet(identity.id), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(identity.id), + ) + ) + } + } + loadingIdentity = null }, - // only show back-to-selector button if there's more than one wallet - goToWalletSelector = availableWallets.takeIf { it.size > 1 }?.let { - { loadingWallet = null; sovereignWalletViewModel.startWalletImmediately.value = false } + // only show back-to-selector button if there's more than one identity + goToWalletSelector = availableIdentities.takeIf { it.size > 1 }?.let { + { loadingIdentity = null; sovereignWalletViewModel.startWalletImmediately.value = false } } ) } } } is press.mantra.compose.ui.view.model.StartupViewState.StartingBusiness -> { + // The one place the word "wallet" stays: only the branch with + // a wallet attached reaches this state, and what is starting is + // a Lightning node. A bare-key profile never sees it. LoadingDataIndicator( - text = "Starting wallet" + text = stringResource(Res.string.starting_wallet) ) } is press.mantra.compose.ui.view.model.StartupViewState.BusinessActive -> { LoadingDataIndicator( - text = "Opening wallet" + text = stringResource(Res.string.opening_profile) ) } is press.mantra.compose.ui.view.model.StartupViewState.Error -> { @@ -233,15 +309,15 @@ fun SovereignWalletStartupScreen( @Composable private fun BoxScope.LoadWallet( - userWallet: UserWallet, + identity: StoredIdentity, metadata: UserWalletMetadata, userPrefs: UserPrefs, promptScreenLockImmediately: Boolean, - doLoadWallet: (UserWallet) -> Unit, + doLoadWallet: (StoredIdentity) -> Unit, goToWalletSelector: (() -> Unit)? ) { - val isScreenLockRequired = produceState(initialValue = null, key1 = userWallet) { + val isScreenLockRequired = produceState(initialValue = null, key1 = identity) { val biometricLockEnabled = userPrefs.getLockBiometricsEnabled.first() val customPinLockEnabled = userPrefs.getLockPinEnabled.first() @@ -250,24 +326,24 @@ private fun BoxScope.LoadWallet( when (isScreenLockRequired.value) { null -> { - LoadingDataIndicator(text = "Loading preferences...") + LoadingDataIndicator(text = stringResource(Res.string.loading_preferences)) } true -> { - LoadingDataIndicator(text = "Unlock to continue") + LoadingDataIndicator(text = stringResource(Res.string.unlock_to_continue)) ScreenLockPrompt( - walletId = userWallet.walletId, + walletId = identity.id, walletName = metadata.nameOrDefault(), promptScreenLockImmediately = promptScreenLockImmediately, - onUnlock = { doLoadWallet(userWallet) }, + onUnlock = { doLoadWallet(identity) }, onLock = { }, userPrefs = userPrefs, goToWalletSelector = goToWalletSelector, ) } false -> { - LoadingDataIndicator(text = "Starting wallet...") + LoadingDataIndicator(text = stringResource(Res.string.opening_profile)) LaunchedEffect(Unit) { - doLoadWallet(userWallet) + doLoadWallet(identity) } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslateChunkScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslateChunkScreen.kt index 72ec6024..6af370c2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslateChunkScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslateChunkScreen.kt @@ -12,29 +12,21 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material3.BottomAppBar -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.FloatingActionButtonDefaults 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.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar -import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.disabled -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -49,7 +41,7 @@ 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.LoadingDataIndicator -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.TranslateChunkViewModel import press.mantra.compose.ui.view.state.TranslateChunkUIState import press.mantra.compose.ui.theme.spacing @@ -65,6 +57,8 @@ import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.NavigateBackButton +import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3Api::class) @@ -112,10 +106,6 @@ fun TranslateChunkScreen( is TranslateChunkUIState.Loaded -> { val translationFieldState = rememberTextFieldState(translateChunkUIState.existingTranslationText) - // M3 gives a FAB no `enabled`, so borrow the disabled colours every - // other button in the app uses rather than inventing a shade here. - val buttonColors = ButtonDefaults.buttonColors() - // imePadding: this screen has a text field, and without it the software // keyboard covers whatever is being typed into. Scaffold( @@ -124,42 +114,18 @@ fun TranslateChunkScreen( topBar = { TopAppBar( title = { Text(stringResource(Res.string.translate_chunk)) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, ) }, bottomBar = { BottomAppBar( actions = {}, floatingActionButton = { - ExtendedFloatingActionButton( - modifier = if (translateChunkUIState.canSign) { - Modifier - } else { - // Looking unavailable is not being unavailable: - // without this a screen reader still announces - // a button it is happy to press. - Modifier.semantics { disabled() } - }, - containerColor = if (translateChunkUIState.canSign) { - FloatingActionButtonDefaults.containerColor - } else { - buttonColors.disabledContainerColor - }, - contentColor = if (translateChunkUIState.canSign) { - contentColorFor(FloatingActionButtonDefaults.containerColor) - } else { - buttonColors.disabledContentColor - }, + ExtendedFab( + label = stringResource(Res.string.propose_translation), + icon = { Icon(Icons.Default.Add, contentDescription = "Propose translation") }, + enabled = translateChunkUIState.canSign, onClick = { - if (!translateChunkUIState.canSign) return@ExtendedFloatingActionButton - translateChunkViewModel.proposeTranslation( localChatRoom = translateChunkUIState.localChatRoom, originalChunk = translateChunkUIState.originalChunk, @@ -186,13 +152,7 @@ fun TranslateChunkScreen( } ) } - ) { - Icon( - Icons.Default.Add, - contentDescription = "Propose translation" - ) - Text(stringResource(Res.string.propose_translation)) - } + ) } ) } @@ -270,7 +230,7 @@ fun TranslateChunkScreen( @ConformancePreviews @Composable private fun TranslateChunkScreenPreview() { - TorchTheme { + MantraTheme { Surface(modifier = Modifier.fillMaxSize()) { TranslateChunkScreen( activeUserPublicKey = "", diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt index 52a86552..732a9f32 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt @@ -10,14 +10,12 @@ 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.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Article import androidx.compose.material.icons.filled.PlaylistAdd import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -42,7 +40,7 @@ import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.navigation.routes.TranslationChapterRoute import press.mantra.compose.ui.composable.widgets.DetailRow import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.TranslationArtifactVersionDetailViewModel import press.mantra.compose.ui.view.state.TranslationArtifactVersionDetailUIState import press.mantra.compose.ui.theme.spacing @@ -60,6 +58,7 @@ import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3Api::class) @@ -108,14 +107,7 @@ fun TranslationArtifactVersionDetailScreen( topBar = { TopAppBar( title = { Text(translation.name) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, ) } ) { innerPadding -> @@ -287,7 +279,7 @@ fun TranslationArtifactVersionDetailScreen( @ConformancePreviews @Composable private fun TranslationArtifactVersionDetailScreenPreview() { - TorchTheme { + MantraTheme { Surface(modifier = Modifier.fillMaxSize()) { TranslationArtifactVersionDetailScreen( activeUserPublicKey = "", diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationChapterScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationChapterScreen.kt index 79f3a62a..c4805f6f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationChapterScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationChapterScreen.kt @@ -15,12 +15,10 @@ 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.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface @@ -42,7 +40,7 @@ import press.mantra.compose.repository.MantraRepository import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.navigation.routes.TranslateChunkRoute import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.TranslationChapterViewModel import press.mantra.compose.ui.view.state.ChunkTranslationPair import press.mantra.compose.ui.view.state.TranslationChapterUIState @@ -55,6 +53,7 @@ import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3Api::class) @@ -98,14 +97,7 @@ fun TranslationChapterScreen( topBar = { TopAppBar( title = { Text(stringResource(Res.string.chapter_translation)) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, ) } ) { innerPadding -> @@ -245,7 +237,7 @@ private fun TableRow( @ConformancePreviews @Composable private fun TranslationChapterScreenPreview() { - TorchTheme { + MantraTheme { Surface(modifier = Modifier.fillMaxSize()) { TranslationChapterScreen( activeUserPublicKey = "", diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnannouncedProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnannouncedProfileScreen.kt index 8a03a051..2b33e12e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnannouncedProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnannouncedProfileScreen.kt @@ -79,7 +79,7 @@ fun UnannouncedProfileScreen() { @ConformancePreviews @Composable private fun UnannouncedProfileScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnindexedProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnindexedProfileScreen.kt index 44bae335..cb63f8b2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnindexedProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnindexedProfileScreen.kt @@ -79,7 +79,7 @@ fun UnindexedProfileScreen() { @ConformancePreviews @Composable private fun UnannouncedProfileScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnqueuedProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnqueuedProfileScreen.kt index 96f918ce..e3b1c6b2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnqueuedProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnqueuedProfileScreen.kt @@ -79,7 +79,7 @@ fun UnqueuedProfileScreen() { @ConformancePreviews @Composable private fun UnannouncedProfileScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnqueuedProfileSynchronizationScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnqueuedProfileSynchronizationScreen.kt index 6bbcbb9f..f45a39d6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnqueuedProfileSynchronizationScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnqueuedProfileSynchronizationScreen.kt @@ -91,7 +91,7 @@ fun UnqueuedProfileSynchronizationScreen( @ConformancePreviews @Composable private fun UnqueuedProfileSynchronizationScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsignedProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsignedProfileScreen.kt index fdafc1d0..a6b1a699 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsignedProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsignedProfileScreen.kt @@ -73,7 +73,7 @@ fun UnsignedProfileScreen() { @ConformancePreviews @Composable private fun UnsignedProfileScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt index f59d26ea..bb9c4025 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt @@ -5,83 +5,314 @@ 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.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Badge +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.PersonSearch +import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon 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.TextField import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import press.mantra.compose.ui.theme.spacing +import androidx.lifecycle.viewmodel.compose.viewModel import mantra.composeapp.generated.resources.Res -import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.enter_a_name_for_the_profile +import mantra.composeapp.generated.resources.enter_the_name_you_want_to_use_for_your_2 +import mantra.composeapp.generated.resources.introduce_yourself +import mantra.composeapp.generated.resources.it_may_be_new_or_it_may_live_on_relays_we +import mantra.composeapp.generated.resources.name_eg_alan_turing +import mantra.composeapp.generated.resources.set_up_a_profile +import mantra.composeapp.generated.resources.try_again +import mantra.composeapp.generated.resources.use_a_different_key +import mantra.composeapp.generated.resources.use_a_different_key_question +import mantra.composeapp.generated.resources.switch_profile +import mantra.composeapp.generated.resources.could_not_sign_out_please_try_again +import press.mantra.compose.ui.composable.widgets.dialogs.SignOutOfReadOnlyDialog +import press.mantra.compose.ui.composable.widgets.rememberNotifier +import press.mantra.compose.ui.view.model.SignOutViewModel +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope import mantra.composeapp.generated.resources.we_are_looking_for_your_profile_on_as_many import mantra.composeapp.generated.resources.we_are_searching_the_internet_to_find_your -import androidx.compose.material3.SnackbarHost +import mantra.composeapp.generated.resources.we_could_not_find_a_profile_for_this_key +import mantra.composeapp.generated.resources.what_should_people_know_about_you +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.EmptyState +import press.mantra.compose.ui.composable.widgets.LocalCanSign +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState -import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.UnsyncedProfileViewModel +import press.mantra.compose.ui.view.state.UnsyncedProfileUIState +/** + * Waiting for a sign-in sync -- and, since Phase 5 of docs/nsec-sign-in.md, able to say + * when the wait is over with nothing to show for it. Before that the spinner below was + * the whole screen and had no exit: a key made in a client that never wrote a kind 0 + * sat under "searching" for good. + */ @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun UnsyncedProfileScreen( - unsyncedProfilePublicKey: String + unsyncedProfilePublicKey: String, + nostrRepository: NostrRepository, + // The not-found state's exit for an identity that cannot set a profile up: the same + // act as signing out, reached from the other end of the identity's life. Null where + // there is no identity to leave (previews), in which case that kind has no exit here. + signOut: SignOutDependencies? = null, + // The third exit, for every kind: another profile on the device to go to instead. + // Null when there is none, in which case the row is not drawn. The identity that was + // not found stays listed; forgetting it remains a separate decision. + onSwitchProfile: (() -> Unit)? = null, ) { - Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding) - .readableContent() - ) { + val viewModel: UnsyncedProfileViewModel = viewModel( + factory = UnsyncedProfileViewModel.factory( + publicKey = unsyncedProfilePublicKey, + nostrRepository = nostrRepository, + ) + ) + val uiState by viewModel.uiState.collectAsState() - Column( - modifier = Modifier.fillMaxWidth().padding( - MaterialTheme.spacing.space125 - ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) - ) { + val signOutViewModel: SignOutViewModel? = signOut?.let { + viewModel( + factory = SignOutViewModel.factory( + activeIdentityStateFlow = it.activeIdentityStateFlow, + nostrRepository = nostrRepository, + forgetNostrCredential = it.forgetNostrCredential, + hideIdentityMetadata = it.hideIdentityMetadata, + ), + ) + } + val signOutState = signOutViewModel?.state?.collectAsState()?.value + if (signOutViewModel != null && signOut != null && signOutState is SignOutViewModel.State.Confirming) { + SignOutOfReadOnlyDialog( + title = Res.string.use_a_different_key_question, + nostrPublicKey = signOutViewModel.nostrPublicKey, + onConfirm = { signOutViewModel.signOut(onSignedOut = signOut.onSignedOut) }, + onDismiss = { signOutViewModel.cancel() }, + ) + } + val notify = rememberNotifier(rememberCoroutineScope()) + val couldNotSignOut = stringResource(Res.string.could_not_sign_out_please_try_again) + LaunchedEffect(signOutState) { + if (signOutState is SignOutViewModel.State.Failed) notify(couldNotSignOut) + } - Spacer( - modifier = Modifier.weight(1f) - ) - Text( - text = stringResource(Res.string.we_are_searching_the_internet_to_find_your), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) - - Text( - text = stringResource(Res.string.we_are_looking_for_your_profile_on_as_many), - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center - ) - - press.mantra.compose.ui.composable.widgets.LoadingDataIndicator( - fillScreen = false - ) - - Spacer( - modifier = Modifier.weight(2f) + // imePadding for the not-found form's fields. + Scaffold( + modifier = Modifier.imePadding(), + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + ) { innerPadding -> + ScreenStateTransition( + state = uiState, + modifier = Modifier.fillMaxSize().padding(innerPadding), + label = "unsynced profile", + ) { state -> + when (state) { + is UnsyncedProfileUIState.Searching -> Searching() + is UnsyncedProfileUIState.NotFound -> NotFound( + viewModel = viewModel, + unsignedNostrEventId = state.unsignedNostrEventId, + onUseADifferentKey = signOutViewModel?.let { { it.askToSignOut() } }, + onSwitchProfile = onSwitchProfile, ) } } } } +@Composable +private fun Searching() { + Column( + modifier = Modifier + .fillMaxSize() + .readableContent() + ) { + Column( + modifier = Modifier.fillMaxWidth().padding( + MaterialTheme.spacing.space125 + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { + + Spacer( + modifier = Modifier.weight(1f) + ) + Text( + text = stringResource(Res.string.we_are_searching_the_internet_to_find_your), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + + Text( + text = stringResource(Res.string.we_are_looking_for_your_profile_on_as_many), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + + LoadingDataIndicator( + fillScreen = false + ) + + Spacer( + modifier = Modifier.weight(2f) + ) + } + } +} + +/** + * Every relay asked has answered and none had a profile. Two ways out: ask again, or + * give the key that is already on this device a profile -- the six-event bootstrap a + * fresh key gets, without a fresh key. + */ +@Composable +private fun NotFound( + viewModel: UnsyncedProfileViewModel, + unsignedNostrEventId: Long, + /** Shown only where the set-up form is not; null when there is no identity to leave. */ + onUseADifferentKey: (() -> Unit)?, + /** Shown for every kind, when there is another profile to go to. */ + onSwitchProfile: (() -> Unit)?, +) { + val nameField = viewModel.formState.nameField.textFieldState + val biographyField = viewModel.formState.biographyField.textFieldState + val nameMissing = viewModel.nameMissing.value + val isActionPending = viewModel.isActionPending.value + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .readableContent() + .padding(MaterialTheme.spacing.screenMargin), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap), + ) { + EmptyState( + message = stringResource(Res.string.we_could_not_find_a_profile_for_this_key), + icon = Icons.Default.PersonSearch, + action = { + TextButton( + enabled = !isActionPending, + onClick = { viewModel.tryAgain(unsignedNostrEventId) }, + ) { + Text(stringResource(Res.string.try_again)) + } + }, + ) + + Text( + text = stringResource(Res.string.it_may_be_new_or_it_may_live_on_relays_we), + style = MaterialTheme.typography.bodyMedium, + ) + + // With other profiles on the device, a way to one of them -- for every kind, since + // the wait is the same whichever kind is waiting. See docs/multiple-profiles.md, + // Phase 7. + if (onSwitchProfile != null) { + TextButton( + modifier = Modifier.align(Alignment.End), + enabled = !isActionPending, + onClick = onSwitchProfile, + ) { + Text(text = stringResource(Res.string.switch_profile)) + } + } + + // Setting a profile up is signing a kind 0, which a read-only identity cannot do. + // The form is not offered to it -- and then this state would have no exit: the + // profile tab is not reachable before ProfileLoaded, so a user whose npub was not + // found on any relay could try again for ever. So, for that kind only, the third + // action: the identity leaves the device, and the way back is the selector or + // Landing. + if (!LocalCanSign.current) { + if (onUseADifferentKey != null) { + TextButton( + modifier = Modifier.align(Alignment.End), + enabled = !isActionPending, + onClick = onUseADifferentKey, + ) { + Text(text = stringResource(Res.string.use_a_different_key)) + } + } + return@Column + } + + Text( + text = stringResource(Res.string.set_up_a_profile), + style = MaterialTheme.typography.titleMedium, + ) + + TextField( + modifier = Modifier.fillMaxWidth(), + state = nameField, + isError = nameMissing, + supportingText = if (nameMissing) { + { Text(text = stringResource(Res.string.enter_a_name_for_the_profile)) } + } else null, + lineLimits = TextFieldLineLimits.SingleLine, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), + label = { Text(text = stringResource(Res.string.name_eg_alan_turing), maxLines = 1) }, + placeholder = { Text(text = stringResource(Res.string.enter_the_name_you_want_to_use_for_your_2), maxLines = 1) }, + // The label names the field; the icon only decorates it. + leadingIcon = { Icon(Icons.Default.Badge, contentDescription = Decorative) }, + ) + + TextField( + modifier = Modifier.fillMaxWidth(), + state = biographyField, + lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 4), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), + label = { Text(text = stringResource(Res.string.introduce_yourself), maxLines = 1) }, + placeholder = { Text(text = stringResource(Res.string.what_should_people_know_about_you), maxLines = 1) }, + leadingIcon = { Icon(Icons.Default.Description, contentDescription = Decorative) }, + ) + + Button( + modifier = Modifier.align(Alignment.End), + enabled = !isActionPending, + onClick = { viewModel.setUpProfile(unsignedNostrEventId) }, + ) { + Text(text = stringResource(Res.string.set_up_a_profile)) + } + } +} + @ConformancePreviews @Composable private fun UnsyncedProfileScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { UnsyncedProfileScreen( - unsyncedProfilePublicKey = "npub is for living." + unsyncedProfilePublicKey = "a".repeat(64), + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, ) } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/WriteNewNoteScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/WriteNewNoteScreen.kt index 00bce1e0..4efd8f31 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/WriteNewNoteScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/WriteNewNoteScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -29,6 +30,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextField +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.key @@ -46,6 +48,7 @@ import kotlin.time.Clock import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.new_note import mantra.composeapp.generated.resources.next import mantra.composeapp.generated.resources.something_went_wrong_and_we_were_unable_to_3 import mantra.composeapp.generated.resources.the_above_will_be_your_new_note @@ -57,10 +60,11 @@ import mantra.composeapp.generated.resources.what_vibrations_do_you_want_to_send import mantra.composeapp.generated.resources.replying_to import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.theme.ConformancePreviews -@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class) +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun WriteNewNoteScreen( activeUserPublicKey: HexKey, @@ -69,6 +73,7 @@ fun WriteNewNoteScreen( quotedNostrEventId: HexKey?, onNostrEventPublished: () -> Unit, onNavigateToNostrEvent: (HexKey) -> Unit, + onNavigateBack: () -> Unit, nostrRepository: press.mantra.compose.repository.NostrRepository ) { val writeNewNoteViewModel: press.mantra.compose.ui.view.model.WriteNewNoteViewModel = viewModel ( @@ -84,7 +89,17 @@ fun WriteNewNoteScreen( // keyboard covers whatever is being typed into. Scaffold( snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - modifier = Modifier.imePadding()) { innerPadding -> + modifier = Modifier.imePadding(), + topBar = { + // "New note" whether it is a reply, a quote or neither: the body says which + // above the field. Back pops the route and drops the draft, which is what + // the platform's own back does on this screen already. + TopAppBar( + title = { Text(stringResource(Res.string.new_note)) }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) + }, + ) { innerPadding -> Column( modifier = Modifier.padding(innerPadding) .readableContent() @@ -468,7 +483,7 @@ fun WriteNewNoteScreen( @ConformancePreviews @Composable private fun WriteNewNoteScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { @@ -522,6 +537,7 @@ It has survived not only five centuries, but also the leap into electronic types ), onNostrEventPublished = {}, onNavigateToNostrEvent = {}, + onNavigateBack = {}, nostrRepository = press.mantra.compose.repository.NostrRepository.NO_OP_NOSTR_REPOSITORY ) } 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 2cdc39de..92f022d0 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 @@ -6,6 +6,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -40,6 +41,7 @@ import press.mantra.compose.ui.composable.DkgJoinApprovalScreen import press.mantra.compose.ui.composable.DkgRitualScreen import press.mantra.compose.ui.composable.DkgRound1ApprovalScreen import press.mantra.compose.ui.composable.DkgRound2ApprovalScreen +import press.mantra.compose.ui.composable.BroadcastGroupSignedEventScreen import press.mantra.compose.ui.composable.HomeScreen import press.mantra.compose.ui.composable.ImplementationPendingScreen import press.mantra.compose.ui.composable.KeyPackageManagementScreen @@ -47,6 +49,7 @@ import press.mantra.compose.ui.composable.KeyRecoveryScreen import press.mantra.compose.ui.composable.LandingScreen import press.mantra.compose.ui.composable.LoadingScreen import press.mantra.compose.ui.composable.NostrEventDetailScreen +import press.mantra.compose.ui.composable.NostrSecretScreen import press.mantra.compose.ui.composable.RecoveryPhraseScreen import press.mantra.compose.ui.composable.SearchMemberToAddToChatRoomScreen import press.mantra.compose.ui.composable.SearchResultScreen @@ -82,7 +85,16 @@ import press.mantra.compose.ui.composable.navigation.routes.KeyRecoveryRoute import press.mantra.compose.ui.composable.navigation.routes.LandingRoute import press.mantra.compose.ui.composable.navigation.routes.LoadingRoute import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute +import press.mantra.compose.ui.composable.navigation.routes.NostrSecretRoute +import press.mantra.compose.ui.composable.navigation.routes.ProfilePreviewRoute +import press.mantra.compose.ui.composable.navigation.routes.ProfilesRoute import press.mantra.compose.ui.composable.navigation.routes.RecoveryPhraseRoute +import press.mantra.compose.ui.composable.ProfilePreviewScreen +import press.mantra.compose.ui.composable.ProfilesScreen +import press.mantra.compose.ui.view.model.ProfilesViewModel +import press.mantra.compose.ui.view.model.getAvailableWalletsMeta +import press.mantra.compose.ui.view.model.bareKeyProfileWriter +import press.mantra.compose.ui.view.model.seedProfileWriter import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToChatRoomRoute import press.mantra.compose.ui.composable.navigation.routes.SearchResultRoute import press.mantra.compose.ui.composable.navigation.routes.SearchRoute @@ -109,6 +121,8 @@ import press.mantra.compose.ui.view.state.NavigationUIState import press.mantra.compose.ui.view.state.NostrEventDetailUIState import press.mantra.compose.ui.view.state.SearchUIState import press.mantra.compose.ui.theme.NavigationMotion +import press.mantra.compose.ui.composable.SignOutDependencies +import press.mantra.compose.ui.composable.widgets.ProvideSigningCapability import press.mantra.compose.ui.theme.breakpoint import co.touchlab.kermit.Logger import fr.acinq.phoenix.PhoenixGlobal @@ -122,6 +136,7 @@ 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.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.ProposalListRoute import press.mantra.compose.ui.composable.navigation.routes.AddChapterRoute @@ -165,10 +180,19 @@ fun MantraNavHost( AuxDatabaseManager(mantraGlobal) } + // Before the repositories: the nostr repository stamps every fetch request it queues + // with the active identity, and takes the flow at construction. + val sovereignWalletViewModel: SovereignWalletViewModel = viewModel( + factory = SovereignWalletViewModel.factory( + phoenixGlobal = phoenixGlobal + ) + ) + val databaseNostrRepository = remember { DatabaseNostrRepository( database = auxDatabaseManager.auxDatabase, - applicationIOScope + applicationIOScope, + activeIdentity = sovereignWalletViewModel.activeIdentity, ) } @@ -212,15 +236,9 @@ fun MantraNavHost( ) } - val sovereignWalletViewModel: SovereignWalletViewModel = viewModel( - factory = SovereignWalletViewModel.factory( - phoenixGlobal = phoenixGlobal - ) - ) - val navigationViewModel: NavigationViewModel = viewModel ( factory = NavigationViewModel.factory( - activeWallet = sovereignWalletViewModel.activeWalletInUI, + activeIdentity = sovereignWalletViewModel.activeIdentity, initialNavigationUIState = NavigationUIState.Loading(), nostrRepository = databaseNostrRepository, scope = applicationIOScope @@ -229,7 +247,7 @@ fun MantraNavHost( val notaryViewModel: NotaryViewModel = viewModel( factory = NotaryViewModel.factory( - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, nostrRepository = databaseNostrRepository, chatRepository = databaseChatRepository, marmotRepository = databaseMarmotRepository, @@ -240,7 +258,7 @@ fun MantraNavHost( // TODO: Produce a notary UI Element... val synchronizationViewModel: SynchronizationViewModel = viewModel( factory = SynchronizationViewModel.factory( - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, nostrRepository = databaseNostrRepository, chatRepository = databaseChatRepository, relayRepository = databaseNostrRepository, @@ -402,6 +420,28 @@ fun MantraNavHost( } } + // What signing out of a read-only identity takes, for the two screens that offer it. + // The tail is the nsec forget's: the device no longer holds anything for the + // identity, so re-list -- the selector drops it -- then clear the active identity, + // and the navigation observer sends a null identity to startup, which shows the + // selector or, if this was the last one, Landing. + val signOutOfReadOnlyIdentity = remember(sovereignWalletViewModel) { + SignOutDependencies( + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, + forgetNostrCredential = { identity -> sovereignWalletViewModel.forgetNostrCredential(identity) }, + hideIdentityMetadata = { identity -> sovereignWalletViewModel.hideIdentityMetadata(identity) }, + onSignedOut = { + sovereignWalletViewModel.forgetDefaultIdentity() + sovereignWalletViewModel.listIdentities { + sovereignWalletViewModel.resetToSelector() + } + }, + ) + } + + // Once, above every screen: what a read-only identity may not be offered is decided + // where the control is drawn, and this is how the control finds out. + ProvideSigningCapability(sovereignWalletViewModel.activeIdentity) { MantraNavigationSuite( navController = navController, breakpoint = MaterialTheme.breakpoint, @@ -427,6 +467,7 @@ fun MantraNavHost( SovereignWalletStartupScreen( sovereignWalletViewModel = sovereignWalletViewModel, + nostrRepository = databaseNostrRepository, // No seed on this device, so there is no wallet to start: the user has to make or // restore one, and that lives behind the landing screen. This used to park them on // a loading screen with nothing left to load. @@ -442,6 +483,12 @@ fun MantraNavHost( navigationViewModel.loadNostrProfile(route) } }, + onNavigateToSignIn = { + navController.navigate(route = SignInRoute) + }, + onNavigateToCreateProfile = { + navController.navigate(route = CreateProfileRoute(withWallet = false)) + }, forceWalletId = null, ) } @@ -466,44 +513,41 @@ fun MantraNavHost( route = SignInRoute ) }, + // The first profile on a device is the one the wallet will belong to. onNavigateToCreateProfile = { navController.navigate( - route = CreateProfileRoute() + route = CreateProfileRoute(withWallet = true) ) } ) } - composable { + composable { backStackEntry -> + val route = backStackEntry.toRoute() CreateProfileScreen( - onNavigateToEndThis = { - navController.navigate( - route = BlankRoute - ) { - popUpTo(0) - } + onNavigateBack = { + navController.popBackStack() }, nostrRepository = databaseNostrRepository, marmotRepository = databaseMarmotRepository, - writeSeed = { words, onSeedWritten, onSeedWriteError -> - sovereignWalletViewModel.writeSeed( - words, - isRestoringWallet = false, - onSeedWritten = { walletId -> - onSeedWritten() - - sovereignWalletViewModel.loadSovereignData(walletId) - sovereignWalletViewModel.listAvailableWallets { - sovereignWalletViewModel.switchToWallet(walletId) - navController.navigate( - route = SovereignWalletStartupRoute - ) - } - }, - onSeedWriteError = { onSeedWriteError() } - ) - } + // A seed from Landing, a bare key from inside: the screen does not know + // which. See docs/multiple-profiles.md, Phase 5. + newProfile = if (route.withWallet) sovereignWalletViewModel.seedProfileWriter() else sovereignWalletViewModel.bareKeyProfileWriter(), + // The sign-in tail, with the popUpTo(0) it always had there: back from the + // new profile's startup must not return to a form whose secret is already + // on disk. The account rows are in the database before this runs, so the + // machine finds them the moment the identity is activated. + onProfileCreated = { walletId -> + sovereignWalletViewModel.loadSovereignData(walletId) + sovereignWalletViewModel.listIdentities { + sovereignWalletViewModel.switchToIdentity(walletId) + navController.navigate( + route = SovereignWalletStartupRoute + ) { + popUpTo(0) + } + } + }, ) - } composable { backStackEntry -> val route = backStackEntry.toRoute() @@ -514,6 +558,9 @@ fun MantraNavHost( navController.navigate( selectMembersRoute ) + }, + onNavigateBack = { + navController.popBackStack() } ) } @@ -529,6 +576,9 @@ fun MantraNavHost( navController.navigate( selectChatRoomTypeRoute ) + }, + onNavigateBack = { + navController.popBackStack() } ) } @@ -538,11 +588,14 @@ fun MantraNavHost( SelectSubgroupAdminsScreen( activeUserPublicKey = route.activeUserPublicKey, parentChatRoomId = route.parentChatRoomId, - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, chatRepository = databaseChatRepository, dkgRepository = databaseDkgRepository, onNavigateToRoute = { nextRoute -> navController.navigate(route = nextRoute) + }, + onNavigateBack = { + navController.popBackStack() } ) } @@ -553,11 +606,14 @@ fun MantraNavHost( activeUserPublicKey = route.activeUserPublicKey, chatRoomId = route.chatRoomId, dkgSessionId = route.dkgSessionId, - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, chatRepository = databaseChatRepository, dkgRepository = databaseDkgRepository, onNavigateToRoute = { approvalRoute -> navController.navigate(route = approvalRoute) + }, + onNavigateBack = { + navController.popBackStack() } ) } @@ -568,7 +624,7 @@ fun MantraNavHost( activeUserPublicKey = route.activeUserPublicKey, chatRoomId = route.chatRoomId, dkgSessionId = route.dkgSessionId, - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, chatRepository = databaseChatRepository, dkgRepository = databaseDkgRepository, onDone = { navController.popBackStack() } @@ -581,7 +637,7 @@ fun MantraNavHost( activeUserPublicKey = route.activeUserPublicKey, chatRoomId = route.chatRoomId, dkgSessionId = route.dkgSessionId, - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, chatRepository = databaseChatRepository, dkgRepository = databaseDkgRepository, onDone = { navController.popBackStack() } @@ -594,7 +650,7 @@ fun MantraNavHost( activeUserPublicKey = route.activeUserPublicKey, chatRoomId = route.chatRoomId, dkgSessionId = route.dkgSessionId, - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, chatRepository = databaseChatRepository, dkgRepository = databaseDkgRepository, onDone = { navController.popBackStack() } @@ -608,7 +664,7 @@ fun MantraNavHost( name = route.name, description = route.description, memberPublicKeys = route.memberPublicKeys, - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, nostrRepository = databaseNostrRepository, chatRepository = databaseChatRepository, dkgRepository = databaseDkgRepository, @@ -626,6 +682,9 @@ fun MantraNavHost( inclusive = true } } + }, + onNavigateBack = { + navController.popBackStack() } ) } @@ -648,11 +707,51 @@ fun MantraNavHost( ) ) }, + onNavigateBack = { + navController.popBackStack() + }, nostrRepository = databaseNostrRepository ) } composable { - SignInToProfileScreen() + SignInToProfileScreen( + nostrRepository = databaseNostrRepository, + writeNostrKey = { privateKey -> sovereignWalletViewModel.writeNostrKey(privateKey) }, + writeNostrPublicKey = { publicKey -> sovereignWalletViewModel.writeNostrPublicKey(publicKey) }, + writeRecoveryPhrase = { words, onWritten, onError -> + sovereignWalletViewModel.writeSeed( + words, + isRestoringWallet = true, + onSeedWritten = onWritten, + onSeedWriteError = onError, + ) + }, + onNavigateBack = { + navController.popBackStack() + }, + // The tail the create flow uses after writeSeed: re-list, select, go to + // startup. Startup finds the new identity, activates it, and the navigation + // machine takes it from the placeholder account the sign-in planted. The + // form is popped so that back does not return to a field holding a secret. + // + // The navigate here looks redundant with the observer's -- switching clears + // the identity, and a null identity is StartupPhoenix, which the observer + // above navigates to with the same popUpTo(0). It is not: the navigation + // state is a StateFlow, and an update to a state equal to the current one + // emits nothing. This is what guarantees the stack moves even on the day + // the state does not. Leave both. + onSignedIn = { walletId -> + sovereignWalletViewModel.loadSovereignData(walletId) + sovereignWalletViewModel.listIdentities { + sovereignWalletViewModel.switchToIdentity(walletId) + navController.navigate( + route = SovereignWalletStartupRoute + ) { + popUpTo(0) + } + } + }, + ) } composable { backStackEntry -> backStackEntry.toRoute() @@ -661,8 +760,16 @@ fun MantraNavHost( } composable { backStackEntry -> val route = backStackEntry.toRoute() + val identities by sovereignWalletViewModel.availableIdentities.collectAsState() UnsyncedProfileScreen( - unsyncedProfilePublicKey = route.publicKey + unsyncedProfilePublicKey = route.publicKey, + nostrRepository = databaseNostrRepository, + signOut = signOutOfReadOnlyIdentity, + // Another profile to go to, when there is one; the switcher has a back + // button, so this screen is still there if the user changes their mind. + onSwitchProfile = if (identities.size > 1) { + { navController.navigate(route = ProfilesRoute) } + } else null, ) } composable { backStackEntry -> @@ -763,7 +870,8 @@ fun MantraNavHost( navController.navigate( route = route ) - } + }, + signOut = signOutOfReadOnlyIdentity, ) } composable { backStackEntry -> @@ -800,12 +908,43 @@ fun MantraNavHost( } ) } + composable { + val profilesViewModel: ProfilesViewModel = viewModel( + factory = ProfilesViewModel.factory( + listWalletState = sovereignWalletViewModel.listWalletState, + availableIdentities = sovereignWalletViewModel.availableIdentities, + activeIdentity = sovereignWalletViewModel.activeIdentity, + walletsMetadata = getAvailableWalletsMeta(phoenixGlobal), + nostrRepository = databaseNostrRepository, + ), + ) + ProfilesScreen( + viewModel = profilesViewModel, + onNavigateBack = { + navController.popBackStack() + }, + // The switch is the whole of it: the observer above sends the null + // identity to startup, and startup opens the one named. + onSwitch = { walletId -> + sovereignWalletViewModel.switchToIdentity(walletId) + }, + onRetry = { + sovereignWalletViewModel.listIdentities {} + }, + onSignIn = { + navController.navigate(route = SignInRoute) + }, + onCreateProfile = { + navController.navigate(route = CreateProfileRoute(withWallet = false)) + }, + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() KeyRecoveryScreen( activeUserPublicKey = route.activeUserPublicKey, - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, onNavigateBack = { navController.popBackStack() }, @@ -819,12 +958,34 @@ fun MantraNavHost( composable { RecoveryPhraseScreen( phoenixGlobal = phoenixGlobal, - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, onNavigateBack = { navController.popBackStack() } ) } + composable { + NostrSecretScreen( + phoenixGlobal = phoenixGlobal, + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, + nostrRepository = databaseNostrRepository, + forgetNostrCredential = { identity -> sovereignWalletViewModel.forgetNostrCredential(identity) }, + hideIdentityMetadata = { identity -> sovereignWalletViewModel.hideIdentityMetadata(identity) }, + onNavigateBack = { + navController.popBackStack() + }, + // The device no longer holds the key. Re-list so the selector drops it, + // then clear the active identity: the navigation observer sends a null + // identity to startup, which shows the selector -- or Landing, if this + // was the last one. + onForgotten = { + sovereignWalletViewModel.forgetDefaultIdentity() + sovereignWalletViewModel.listIdentities { + sovereignWalletViewModel.resetToSelector() + } + }, + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() @@ -849,6 +1010,11 @@ fun MantraNavHost( navController.navigate( route = actionRoute ) + }, + // A pushed route, unlike the same screen inside the home screen's + // detail pane, so there is something to pop. + onNavigateBack = { + navController.popBackStack() } ) } @@ -872,6 +1038,9 @@ fun MantraNavHost( route = popRoute, inclusive = false ) + }, + onNavigateBack = { + navController.popBackStack() } ) } @@ -889,6 +1058,9 @@ fun MantraNavHost( route = chatRoomDetailRoute ) }, + onNavigateBack = { + navController.popBackStack() + }, ) } composable { backStackEntry -> @@ -911,6 +1083,11 @@ fun MantraNavHost( inclusive = true ) }, + // Back is to the search, not past it: a member who picked the wrong + // person wants the list again, not the room. + onNavigateBack = { + navController.popBackStack() + }, ) } composable { backStackEntry -> @@ -939,6 +1116,9 @@ fun MantraNavHost( navController.navigate( route = actionRoute ) + }, + onNavigateBack = { + navController.popBackStack() } ) } @@ -965,6 +1145,28 @@ fun MantraNavHost( navController.navigate( route = actionRoute ) + }, + onNavigateBack = { + navController.popBackStack() + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + BroadcastGroupSignedEventScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + signedEventId = route.signedEventId, + chatRepository = databaseChatRepository, + // The one screen handed the pool itself rather than a queue + // to write to: a group-signed event must not become a + // `NostrEvent` row, and the queue hangs off one. See + // `BroadcastGroupSignedEventViewModel`. + publishTransport = synchronizationViewModel.relaysSocketManager, + onNavigateBack = { + navController.popBackStack() } ) } @@ -1250,13 +1452,54 @@ fun MantraNavHost( }, ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + ProfilePreviewScreen( + activeUserPublicKey = route.activeUserPublicKey, + profilePublicKey = route.profilePublicKey, + nostrRepository = databaseNostrRepository, + chatRepository = databaseChatRepository, + // The room replaces the preview, as it replaces the profile detail + // above: back from a chat is the list, not the person it was started + // from. + onStartChat = { chatRoom -> + navController.navigate( + route = chatRoom + ) { + popUpTo(route) { + inclusive = true + } + } + }, + onNavigateBack = { + navController.popBackStack() + }, + ) + } composable { backStackEntry -> val route: ImplementationPendingRoute = backStackEntry.toRoute() + + // Pushed from a "learn more" or an "edit profile", and also where the + // navigation observer above lands with `popUpTo(0)` on an error. Whether + // there is anything under this entry is what decides the back button, and + // it is remembered because the answer is read again while the screen is + // leaving, when the stack has already moved on. + val hasSomethingToReturnTo = remember { + navController.previousBackStackEntry != null + } + ImplementationPendingScreen( - route.name + text = route.name, + onNavigateBack = if (hasSomethingToReturnTo) { + { navController.popBackStack() } + } else { + null + } ) } } } + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/BroadcastGroupSignedEventRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/BroadcastGroupSignedEventRoute.kt new file mode 100644 index 00000000..6e4cbaa1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/BroadcastGroupSignedEventRoute.kt @@ -0,0 +1,12 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +@Serializable +data class BroadcastGroupSignedEventRoute( + val activeUserPublicKey: String, + val chatRoomId: String, // TODO: have this as a publicKey + val relayHint: String?, + /** The `GroupSignedEvent` to send, which is also the event id the group signed. */ + val signedEventId: String, +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/CreateProfileRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/CreateProfileRoute.kt index b0ea6406..b05b9442 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/CreateProfileRoute.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/CreateProfileRoute.kt @@ -2,7 +2,13 @@ package press.mantra.compose.ui.composable.navigation.routes import kotlinx.serialization.Serializable +/** + * @param withWallet whether the profile is made of a seed, with a wallet attached, or of + * a bare key. True from Landing, where the first profile on a device is the one the + * wallet will belong to; false from the switcher, where wanting another profile is not + * wanting another wallet. See docs/multiple-profiles.md, Phase 5. + */ @Serializable data class CreateProfileRoute( - val nostrEventId: String? = null, -): Route() \ No newline at end of file + val withWallet: Boolean, +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/NostrSecretRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/NostrSecretRoute.kt new file mode 100644 index 00000000..c7bceaca --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/NostrSecretRoute.kt @@ -0,0 +1,7 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** The nostr secret key behind a bare-key identity: shown on request, and the way to forget it. */ +@Serializable +data object NostrSecretRoute : Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProfilePreviewRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProfilePreviewRoute.kt new file mode 100644 index 00000000..3b7a1dac --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProfilePreviewRoute.kt @@ -0,0 +1,18 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.serialization.Serializable + +/** + * The person behind a pasted npub, before a chat with them is started. + * + * Keyed by public key rather than by a kind 0's event id, because that is all the + * entrance has -- and it is why the same screen will serve a scanned code or a + * `nostr:npub…` link. No relay hint: the dialog has none to give. + * See docs/npub-profile-preview.md. + */ +@Serializable +data class ProfilePreviewRoute( + val activeUserPublicKey: String, + val profilePublicKey: HexKey, +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProfilesRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProfilesRoute.kt new file mode 100644 index 00000000..eacf0902 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProfilesRoute.kt @@ -0,0 +1,11 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** + * The profiles on this device: the one that is open, the others, and the two ways to + * add one. Pushed from the profile tab; no arguments, since everything it shows is the + * device's rather than a route's. See docs/multiple-profiles.md, Phase 4. + */ +@Serializable +object ProfilesRoute : Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/LoadingDataIndicator.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/LoadingDataIndicator.kt index 98737303..6c36e7f4 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/LoadingDataIndicator.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/LoadingDataIndicator.kt @@ -69,7 +69,7 @@ fun LoadingDataIndicator( @ConformancePreviews @Composable private fun TransferHistoryScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface(modifier = Modifier.fillMaxSize()) { LoadingDataIndicator( text = "Starting up" diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/NavigateBackButton.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/NavigateBackButton.kt new file mode 100644 index 00000000..4f43fcf2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/NavigateBackButton.kt @@ -0,0 +1,50 @@ +package press.mantra.compose.ui.composable.widgets + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.back +import org.jetbrains.compose.resources.stringResource + +/** + * The button in a pushed screen's leading slot, the one M3 calls the navigation icon. + * + * Before this file, 18 screens drew it and 22 did not, and the 18 spelled it four ways: + * `ArrowBack`, the auto-mirrored `ArrowBack`, `ArrowBackIosNew`, and a `"Back"` literal + * against a `stringResource`. A screen that is reached by pushing a route and has no way + * to pop it is a dead end wherever the platform gives no back gesture -- which is the + * desktop target, where a window has neither a back button nor an edge to swipe from. + * + * Two of the four spellings are decisions this component makes once: + * + * - **The icon is the auto-mirrored one.** "Back" points at the leading edge, and the + * leading edge is on the right in an RTL locale. `Icons.Filled.ArrowBack` is + * deprecated for exactly this reason, and the navigation transitions already mirror + * through `slideIntoContainer`; an arrow that did not would point the wrong way at the + * screen that was sliding the right way. + * - **The description is the catalogue's.** "Back" is what a screen reader says, and it + * is text, so it lives in `strings.xml` like every other word the app says. + * + * `IconButton` reserves the 48dp target itself, so there is no modifier to remember. + * + * Put it in `TopAppBar(navigationIcon = …)`, and only on a screen the user pushed onto + * the stack. A top-level destination has the navigation bar instead, and a screen that + * replaced the whole stack -- onboarding, the loading gate -- has nothing under it to + * return to; a back button there would pop to an empty host. + * + * @param onNavigateBack what popping means for the screen, which is nearly always the + * host's `popBackStack()`. Hoisted rather than read from a controller here so the + * screen stays previewable and testable without one. + */ +@Composable +fun NavigateBackButton(onNavigateBack: () -> Unit) { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.back), + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/RelayOutcomeLabel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/RelayOutcomeLabel.kt new file mode 100644 index 00000000..b756ac6c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/RelayOutcomeLabel.kt @@ -0,0 +1,30 @@ +package press.mantra.compose.ui.composable.widgets + +import androidx.compose.runtime.Composable +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.relay_accepted +import mantra.composeapp.generated.resources.relay_could_not_send +import mantra.composeapp.generated.resources.relay_no_answer +import mantra.composeapp.generated.resources.relay_rejected +import mantra.composeapp.generated.resources.relay_sending +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.ui.view.model.BroadcastGroupSignedEventViewModel.RelayOutcome + +/** + * One relay's answer to a broadcast, in the words both screens that show one + * use for it: the row under a relay on `BroadcastGroupSignedEventScreen`, and + * the line under an entry that "Broadcast all" sent. + * + * A rejection and a failure carry the relay's own reason, because "blocked: + * not on the allow list" is something a member can act on and a red icon is + * not. One spelling for both screens, so a relay's answer reads the same + * whichever button sent to it. + */ +@Composable +fun RelayOutcome.label(): String = when (this) { + RelayOutcome.Sending -> stringResource(Res.string.relay_sending) + RelayOutcome.Accepted -> stringResource(Res.string.relay_accepted) + is RelayOutcome.Rejected -> stringResource(Res.string.relay_rejected, reason) + is RelayOutcome.Failed -> stringResource(Res.string.relay_could_not_send, reason) + RelayOutcome.NoAnswer -> stringResource(Res.string.relay_no_answer) +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/SigningCapability.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/SigningCapability.kt new file mode 100644 index 00000000..96127bc5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/SigningCapability.kt @@ -0,0 +1,47 @@ +package press.mantra.compose.ui.composable.widgets + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.staticCompositionLocalOf +import kotlinx.coroutines.flow.StateFlow +import press.mantra.compose.identity.Identity + +/** + * Whether the identity the app is running as can sign -- the one question every write + * entrance asks before it is drawn. + * + * A composition local rather than a parameter for the snackbar host's reason: screens + * do not receive the identity, `MetadataEventDetail` -- where *follow* and *send message* + * live -- is several composables below anything that could be handed more, and a + * parameter threaded through twenty-six lists is forgotten in the twenty-seventh. + * [ProvideSigningCapability] sits once, above every screen, in the nav host. + * + * The default is `true` rather than an error, and that is the one way this differs from + * [LocalSnackbarHostState]: the provider cannot be forgotten per screen, so the only + * things composed outside it are previews and tests, and those should render as they + * always have. + * + * A capability, not a kind. A screen asks "can this identity sign?", not "is this an + * npub?", because the answer is what it needs -- and because a remote signer, which can + * sign and holds no local key, would otherwise be a fourth value in every `when`. See + * docs/npub-sign-in.md, Phase 5. + */ +val LocalCanSign: ProvidableCompositionLocal = staticCompositionLocalOf { true } + +/** Provides [LocalCanSign] from the active identity for everything inside [content]. */ +@Composable +fun ProvideSigningCapability( + activeIdentity: StateFlow, + content: @Composable () -> Unit, +) { + val identity by activeIdentity.collectAsState() + // No identity yet -- startup, landing, sign-in -- is not read-only; it is nobody. Those + // screens have no write entrances to hide, and `true` keeps them drawn as they were. + val canSign = identity?.canSign ?: true + CompositionLocalProvider(LocalCanSign provides canSign) { + content() + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/buttons/BroadcastButton.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/buttons/BroadcastButton.kt new file mode 100644 index 00000000..33464200 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/buttons/BroadcastButton.kt @@ -0,0 +1,39 @@ +package press.mantra.compose.ui.composable.widgets.buttons + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CellTower +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.broadcast +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.theme.spacing + +/** + * The way into `BroadcastGroupSignedEventScreen`, to be drawn the same under + * every event the group signed so that it reads as one action on whichever + * screen it sits. Nothing draws it at present -- the screens that did have + * gone -- and it is kept, with the screen it opens, for whatever next does. + * + * Behind no gate anywhere it appears: sending what the group has already signed + * takes no share of the key, since the signature on it is the group's whoever + * repeats it. Only *making* a statement is gated, and that is the caller's + * concern. The icon is decorative because the label is beside it. + */ +@Composable +fun BroadcastButton(onClick: () -> Unit) { + TextButton(onClick = onClick) { + Icon(Icons.Default.CellTower, contentDescription = Decorative) + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + + Text(stringResource(Res.string.broadcast)) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/buttons/ExtendedFab.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/buttons/ExtendedFab.kt new file mode 100644 index 00000000..b2ffc81b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/buttons/ExtendedFab.kt @@ -0,0 +1,80 @@ +package press.mantra.compose.ui.composable.widgets.buttons + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.semantics +import press.mantra.compose.ui.theme.spacing + +/** + * A screen's one primary action, as an extended FAB with a label beside its icon. + * + * Every bottom bar in the app ends in one of these -- propose, broadcast, next, + * create -- and this is the one place their shape is decided. Two decisions: + * + * - **The gap between icon and label is drawn here.** M3's `ExtendedFloatingActionButton` + * comes in two overloads. The `text`/`icon` one spaces them 12dp apart, but wraps the + * label in `clearAndSetSemantics`, so a screen reader is told only what the icon says + * and a test cannot find the button by its label; the content-lambda one keeps the + * label in the tree and puts nothing between the two. This uses the second and adds + * the first's gap, `space150`, which is the 12dp M3 names `ExtendedFabEndIconPadding`. + * The label is what the button says, so it has to be what the button *announces*. + * - **A FAB has no `enabled`, so unavailable is a look and a refusal, not a state.** + * M3 gives the FAB no such parameter. Here [enabled] false borrows the disabled + * colours every other button in the app uses rather than inventing a shade, marks the + * node disabled so a screen reader does not announce a button it is happy to press, + * and drops the press -- looking unavailable is not being unavailable, and each of + * those three has to be done for the look to be honest. + * + * @param label what the button says. Text, so from the catalogue; drawn after the icon. + * @param icon what sits before the label. Usually an `Icon` -- whose description is the + * caller's decision, `Decorative` when the label says it all and a real one when a test + * reaches for the button by it -- and sometimes a progress indicator in its place while + * the action it started is out. + * @param enabled whether a press does anything. False also changes the look, as above. + */ +@Composable +fun ExtendedFab( + label: String, + icon: @Composable () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + val buttonColors = ButtonDefaults.buttonColors() + + ExtendedFloatingActionButton( + modifier = if (enabled) { + modifier + } else { + modifier.semantics { disabled() } + }, + containerColor = if (enabled) { + FloatingActionButtonDefaults.containerColor + } else { + buttonColors.disabledContainerColor + }, + contentColor = if (enabled) { + contentColorFor(FloatingActionButtonDefaults.containerColor) + } else { + buttonColors.disabledContentColor + }, + onClick = { + if (enabled) onClick() + } + ) { + icon() + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space150)) + + Text(label) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt index 75db2d21..ca9c4bc6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt @@ -39,6 +39,7 @@ import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Groups import androidx.compose.material.icons.filled.PanTool import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material.icons.filled.PersonOff import androidx.compose.material.icons.filled.Upload import androidx.compose.material.icons.filled.WorkspacePremium import androidx.compose.material.icons.filled.Info @@ -815,6 +816,9 @@ private fun RitualNotice( ChatMessage.TYPE_MEMBER_INVITED -> Icons.Default.PersonAdd ChatMessage.TYPE_MEMBER_INVITE_SENT -> Icons.Default.Upload ChatMessage.TYPE_MEMBER_INVITE_FAILED -> Icons.Default.ErrorOutline + // Somebody on this device was kept out of a group it already holds; the + // person, not the invite, is what the line is about. + ChatMessage.TYPE_WELCOME_REFUSED_OTHER_PROFILE -> Icons.Default.PersonOff else -> Icons.Default.PanTool } @@ -835,7 +839,8 @@ private fun RitualNotice( val tint = when { chatMessage.messageType == ChatMessage.TYPE_DKG_FAILED || chatMessage.messageType == ChatMessage.TYPE_FROST_FAILED || - chatMessage.messageType == ChatMessage.TYPE_MEMBER_INVITE_FAILED -> + chatMessage.messageType == ChatMessage.TYPE_MEMBER_INVITE_FAILED || + chatMessage.messageType == ChatMessage.TYPE_WELCOME_REFUSED_OTHER_PROFILE -> MaterialTheme.colorScheme.error isRequest -> MaterialTheme.colorScheme.primary else -> MaterialTheme.colorScheme.onSurfaceVariant diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/QuotedNote.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/QuotedNote.kt index 597b51ff..551187ef 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/QuotedNote.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/QuotedNote.kt @@ -120,7 +120,7 @@ fun QuotedNote( @ConformancePreviews @Composable private fun QuotedNotePreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/RichContent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/RichContent.kt index 40f4ab4b..73f0903f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/RichContent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/RichContent.kt @@ -401,7 +401,7 @@ fun RichContent( @ConformancePreviews @Composable private fun RichContentPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt index ccadaa71..424efa41 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBackIosNew import androidx.compose.material.icons.filled.Block import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Error @@ -22,7 +21,6 @@ import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme @@ -50,12 +48,13 @@ import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRou 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.profile.ProfileAvatar -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.MetadataEventDetailType import press.mantra.compose.ui.view.model.MetadataEventDetailViewModel import press.mantra.compose.ui.view.state.MetadataEventDetailUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import press.mantra.compose.ui.composable.widgets.LocalCanSign import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource @@ -68,6 +67,7 @@ import mantra.composeapp.generated.resources.send_message import mantra.composeapp.generated.resources.unfollow import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.theme.ConformancePreviews @@ -84,6 +84,9 @@ fun MetadataEventDetail( ) { val scope = rememberCoroutineScope() + // Whether this identity can sign, which decides which of the controls below exist. + val canSign = LocalCanSign.current + val profile = localNostrEvent.profile val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) @@ -101,16 +104,7 @@ fun MetadataEventDetail( modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { LargeTopAppBar( - navigationIcon = { - IconButton( - onClick = onNavigateBack - ) { - Icon( - Icons.Default.ArrowBackIosNew, - contentDescription = "Navigate back" - ) - } - }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, title = { Column( modifier = Modifier, @@ -188,6 +182,12 @@ fun MetadataEventDetail( if (metadataEventDetailViewModel.isActionPending.value) { LoadingIndicator() + } else if (!canSign) { + // Edit profile is a kind 0, follow and unfollow a + // kind 3: none of them can be signed by a read-only + // identity, so none is offered. The "follows you" + // state below still shows; it is a fact, not an + // action. } else { if (metadataEventDetailUIState.isActiveUser) { Button( @@ -290,7 +290,9 @@ fun MetadataEventDetail( } } - item { + // A message is a gift wrap, sealed with the sender's key. Not for a + // read-only identity. + if (canSign) item { Button( onClick = { onNavigateToChatRoom.invoke( @@ -326,7 +328,7 @@ fun MetadataEventDetail( @Composable private fun MetadataEventEventDetailPreview() { - TorchTheme { + MantraTheme { Surface( modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/TextNoteEventDetail.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/TextNoteEventDetail.kt index 34927ce1..180c2475 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/TextNoteEventDetail.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/TextNoteEventDetail.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AddReaction -import androidx.compose.material.icons.filled.ArrowBackIosNew import androidx.compose.material.icons.filled.ChatBubble import androidx.compose.material.icons.filled.CurrencyBitcoin import androidx.compose.material.icons.filled.MoreVert @@ -57,6 +56,7 @@ import mantra.composeapp.generated.resources.reply_to import press.mantra.compose.ui.composable.widgets.ErrorState import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.theme.ConformancePreviews @@ -87,16 +87,7 @@ fun TextNoteEventDetail( modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { TopAppBar( - navigationIcon = { - IconButton( - onClick = onNavigateBack - ) { - Icon( - Icons.Default.ArrowBackIosNew, - contentDescription = "Navigate back" - ) - } - }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, title = { Text(stringResource(Res.string.post)) }, @@ -393,7 +384,7 @@ fun TextNoteEventDetail( @Composable private fun TextNoteEventDetailPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/SignOutOfReadOnlyDialog.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/SignOutOfReadOnlyDialog.kt new file mode 100644 index 00000000..7c0fdd7e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/SignOutOfReadOnlyDialog.kt @@ -0,0 +1,43 @@ +package press.mantra.compose.ui.composable.widgets.dialogs + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Logout +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.cancel +import mantra.composeapp.generated.resources.sign_out +import mantra.composeapp.generated.resources.this_device_holds_no_key_for_s_so_there +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.extensions.hexToNpubHrp +import press.mantra.compose.ui.composable.widgets.Decorative + +/** + * The confirmation before a read-only identity leaves the device, naming the npub so + * the user sees which one. Shared by the profile tab's *sign out* and the not-found + * screen's *use a different key*, which differ only in what the title calls it. + */ +@Composable +fun SignOutOfReadOnlyDialog( + title: StringResource, + nostrPublicKey: String?, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { Icon(Icons.Default.Logout, contentDescription = Decorative) }, + title = { Text(stringResource(title)) }, + text = { Text(stringResource(Res.string.this_device_holds_no_key_for_s_so_there, nostrPublicKey?.hexToNpubHrp() ?: "")) }, + confirmButton = { + TextButton(onClick = onConfirm) { Text(stringResource(Res.string.sign_out)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } + }, + ) +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/StartDirectMessageToNpubOrNip05Dialog.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/StartDirectMessageToNpubOrNip05Dialog.kt index ca97f935..521d0a9e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/StartDirectMessageToNpubOrNip05Dialog.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/StartDirectMessageToNpubOrNip05Dialog.kt @@ -14,7 +14,7 @@ import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Start +import androidx.compose.material.icons.filled.PersonSearch import androidx.compose.material3.Card import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -32,11 +32,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog -import press.mantra.compose.extensions.bech32ToHexOrNull -import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute +import press.mantra.compose.identity.CredentialParser import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute +import press.mantra.compose.ui.composable.navigation.routes.ProfilePreviewRoute import press.mantra.compose.ui.composable.navigation.routes.Route -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import com.vitorpamplona.quartz.nip01Core.core.HexKey import fr.acinq.phoenix.utils.Parser import kotlinx.coroutines.CoroutineScope @@ -47,11 +47,22 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.find_profile import mantra.composeapp.generated.resources.input_npub_or_nip05 -import mantra.composeapp.generated.resources.start_chat import mantra.composeapp.generated.resources.start_chat_via_npub_or_nip05 +import press.mantra.compose.ui.composable.widgets.Decorative import press.mantra.compose.ui.theme.ConformancePreviews +/** + * Whom to write to, as an npub or a nip05. + * + * An npub no longer starts a chat from here: it opens the person's profile, and the + * chat is started from there once the user has seen who it is. The field keeps what + * it parsed rather than whether it parsed -- the enabled button and the route it + * pushes read the same decoded key, which is what closed the gap where `nostr:npub1…` + * passed the check and then failed the decode in silence. A nip05 still lands on the + * placeholder; resolving one is out of scope. See docs/npub-profile-preview.md. + */ @Composable fun StartDirectMessageToNpubOrNip05Dialog( activeUserPublicKey: HexKey, @@ -65,7 +76,10 @@ fun StartDirectMessageToNpubOrNip05Dialog( } ) { - val startChatButtonEnabled = remember { mutableStateOf(false) } + // The key under a pasted npub, or null while the field holds anything else. + val parsedPublicKey = remember { mutableStateOf(null) } + // A nip05 is recognised by shape alone; nothing resolves it yet. + val emailLikeAddressEntered = remember { mutableStateOf(false) } Card( modifier = Modifier @@ -123,63 +137,52 @@ fun StartDirectMessageToNpubOrNip05Dialog( }, lineLimits = TextFieldLineLimits.SingleLine, inputTransformation = { - val input = asCharSequence().toString().lowercase() + val input = asCharSequence().toString() - if (input.startsWith("npub1") || input.startsWith("nostr:npub1")) { - // Check for valid npub - startChatButtonEnabled.value = true - } else if (Parser.parseEmailLikeAddress(input) != null) { - // Check for valid npub - startChatButtonEnabled.value = true - } else { - startChatButtonEnabled.value = false - } + parsedPublicKey.value = CredentialParser.npubOrNull(input) + emailLikeAddressEntered.value = parsedPublicKey.value == null && + Parser.parseEmailLikeAddress(input.lowercase()) != null } ) TextButton( - enabled = startChatButtonEnabled.value, + enabled = parsedPublicKey.value != null || emailLikeAddressEntered.value, onClick = { + // Read before the dialog closes: the state lives in it. + val publicKey = parsedPublicKey.value + val input = textFieldState.text.toString() + scope.launch { toggleOpenDialogSetting.invoke(false) }.invokeOnCompletion { - val input = textFieldState.text.toString() - - val emailLikeAddress = Parser.parseEmailLikeAddress(input) - - if (emailLikeAddress != null) { - // TODO: Get public Key from hex... + if (publicKey != null) { onNavigateToRoute.invoke( - ImplementationPendingRoute( - "Direct Message to ${textFieldState.text}" + ProfilePreviewRoute( + activeUserPublicKey = activeUserPublicKey, + profilePublicKey = publicKey, ) ) } else { - val publicKey = input.bech32ToHexOrNull() - - if (publicKey != null) { - onNavigateToRoute.invoke( - ChatRoomMessagingRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = publicKey, - relayHint = null - ) + // TODO: Resolve the nip05 to a public key, then the same route. + onNavigateToRoute.invoke( + ImplementationPendingRoute( + "Direct Message to $input" ) - } + ) } - } } ) { - Text(stringResource(Res.string.start_chat)) + Text(stringResource(Res.string.find_profile)) Spacer( modifier = Modifier.width(MaterialTheme.spacing.space125) ) Icon( - Icons.Default.Start, - contentDescription = "Start chat" + Icons.Default.PersonSearch, + // The label is beside it. + contentDescription = Decorative ) } } @@ -190,7 +193,7 @@ fun StartDirectMessageToNpubOrNip05Dialog( @ConformancePreviews @Composable private fun StartDirectMessageToNpubOrNip05DialogPreview() { - TorchTheme { + MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/feed/EventListView.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/feed/EventListView.kt index b74c6f2e..d825d9a3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/feed/EventListView.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/feed/EventListView.kt @@ -129,7 +129,7 @@ fun press.mantra.compose.database.model.intermdiate.LocalNostrEvent.ListView( @ConformancePreviews @Composable private fun EventListViewRepostedPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { @@ -180,7 +180,7 @@ private fun EventListViewRepostedPreview() { @ConformancePreviews @Composable private fun EventListViewInReplyPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/feed/TextNoteFeedItem.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/feed/TextNoteFeedItem.kt index bbe77197..5cbbfa60 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/feed/TextNoteFeedItem.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/feed/TextNoteFeedItem.kt @@ -187,7 +187,7 @@ internal fun TextNoteFeedItem( @ConformancePreviews @Composable private fun TextNoteFeedItemPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { @@ -218,7 +218,7 @@ private fun TextNoteFeedItemPreview() { @ConformancePreviews @Composable private fun TextNoteFeedItemInReplyToPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { @@ -272,7 +272,7 @@ private fun TextNoteFeedItemInReplyToPreview() { @ConformancePreviews @Composable private fun TextNoteFeedItemQuoteNostrEventPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/profile/ProfileAvatar.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/profile/ProfileAvatar.kt index 24cd82b4..d0b1a198 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/profile/ProfileAvatar.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/profile/ProfileAvatar.kt @@ -93,7 +93,7 @@ fun ProfileAvatar( @ConformancePreviews @Composable fun ProfileAvatarPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/AddProfileRows.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/AddProfileRows.kt new file mode 100644 index 00000000..c21e969b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/AddProfileRows.kt @@ -0,0 +1,75 @@ +package press.mantra.compose.ui.composable.widgets.wallet + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.create_a_new_profile +import mantra.composeapp.generated.resources.sign_in_with_a_key +import mantra.composeapp.generated.resources.the_profile_you_are_in_stays_on_this_device +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.theme.spacing + +/** + * The two ways to add a profile, under the list of the ones the device has -- on the + * switcher and on the startup selector alike. Phase 5 of docs/multiple-profiles.md. + * + * Both push a screen that has a back button. Landing is not one of them: it has no back + * button and clears the stack on its way out, because it is the first-run screen, and + * pushing it from inside would make it a pushed screen on some days and a root on + * others. + * + * @param caption said under the rows where there is a profile to say it of -- the + * switcher -- and omitted on the startup selector, where nothing is open. + */ +@Composable +fun AddProfileRows( + onSignIn: () -> Unit, + onCreateProfile: () -> Unit, + caption: Boolean, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(MaterialTheme.spacing.itemGap)) + HorizontalDivider() + Spacer(Modifier.height(MaterialTheme.spacing.itemGap)) + TextButton(onClick = onSignIn) { + Icon(Icons.Default.Key, contentDescription = Decorative) + Spacer(Modifier.width(MaterialTheme.spacing.relatedGap)) + Text(stringResource(Res.string.sign_in_with_a_key)) + } + TextButton(onClick = onCreateProfile) { + Icon(Icons.Default.PersonAdd, contentDescription = Decorative) + Spacer(Modifier.width(MaterialTheme.spacing.relatedGap)) + Text(stringResource(Res.string.create_a_new_profile)) + } + if (caption) { + Text( + text = stringResource(Res.string.the_profile_you_are_in_stays_on_this_device), + modifier = Modifier.padding(MaterialTheme.spacing.compactPadding), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/RememberProfilesOf.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/RememberProfilesOf.kt new file mode 100644 index 00000000..e0a10aee --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/RememberProfilesOf.kt @@ -0,0 +1,23 @@ +package press.mantra.compose.ui.composable.widgets.wallet + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.produceState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.database.model.Profile +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.repository.observeProfilesOf + +/** + * The nostr profiles of [publicKeys], for a screen that has no view model to hold them + * -- the startup screen, whose own state machine is the wallet's. The switcher gets the + * same map from `ProfilesViewModel`; both draw `WalletsSelector`, and both lists should + * look the same. + */ +@Composable +fun rememberProfilesOf(publicKeys: Collection, nostrRepository: NostrRepository): State> { + val keys = publicKeys.toSet() + return produceState>(initialValue = emptyMap(), keys, nostrRepository) { + nostrRepository.observeProfilesOf(keys).collect { value = it } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt index b21b735d..d0fabe47 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt @@ -44,22 +44,37 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import press.mantra.compose.ui.composable.widgets.buttons.Clickable -import fr.acinq.phoenix.data.UserWallet +import com.vitorpamplona.quartz.nip01Core.core.HexKey import fr.acinq.phoenix.data.WalletId -import fr.acinq.phoenix.utils.preferences.GlobalPrefs import fr.acinq.phoenix.utils.preferences.UserWalletMetadata import fr.acinq.phoenix.utils.preferences.getByWalletIdOrDefault +import press.mantra.compose.extensions.hexToNpubHrp +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.read_only +import mantra.composeapp.generated.resources.wallet +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.database.model.Profile +import press.mantra.compose.identity.StoredIdentity +import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar import press.mantra.compose.ui.theme.spacing +/** + * The device's profiles as a list: the open one above a divider, the others below, + * each with its nostr name and picture where the device holds a kind 0 for it and its + * npub where it does not. + * + * @param profiles the nostr profile of each listed key, by public key; a key with no + * entry, or a null one, is shown by its npub over the metadata's emoji. + */ @Composable fun WalletsSelector( modifier: Modifier = Modifier, - globalPrefs: GlobalPrefs, - wallets: Map, + wallets: Map, walletsMetadata: Map, activeWalletId: WalletId?, canEdit: Boolean, - onWalletClick: (UserWallet) -> Unit, + onWalletClick: (StoredIdentity) -> Unit, + profiles: Map = emptyMap(), verticalArrangement: Arrangement.Vertical = Arrangement.Top, horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, topContent: @Composable (() -> Unit)? = null, @@ -76,9 +91,9 @@ fun WalletsSelector( if (currentWallet != null) { item { AvailableWalletView( - userWallet = currentWallet, - globalPrefs = globalPrefs, - metadata = walletsMetadata.getByWalletIdOrDefault(currentWallet.walletId), + identity = currentWallet, + profile = profiles[currentWallet.nostrPublicKey], + metadata = walletsMetadata.getByWalletIdOrDefault(currentWallet.id), isCurrent = true, canEdit = canEdit, onClick = { onWalletClick(currentWallet) } @@ -88,14 +103,14 @@ fun WalletsSelector( Spacer(Modifier.height(MaterialTheme.spacing.space100)) } } - items(items = otherWalletsList) { (walletId, userWallet) -> + items(items = otherWalletsList) { (walletId, identity) -> AvailableWalletView( - userWallet = userWallet, - globalPrefs = globalPrefs, + identity = identity, + profile = profiles[identity.nostrPublicKey], metadata = walletsMetadata.getByWalletIdOrDefault(walletId), isCurrent = false, canEdit = canEdit, - onClick = { onWalletClick(userWallet) } + onClick = { onWalletClick(identity) } ) Spacer(Modifier.height(MaterialTheme.spacing.space100)) } @@ -105,11 +120,24 @@ fun WalletsSelector( } } +/** + * One profile. The name and picture are the nostr profile's -- the widgets the profile + * tab draws itself with -- and the npub is the second line, the identifier the rest of + * the app shows and the one a user might actually recognise. A key the device has no + * kind 0 for yet, one just created or a read-only one never found, is the npub over the + * metadata's emoji. It used to be "Default name" over a random emoji for every row, + * because nothing in this app writes the wallet metadata's name. + * + * What the device holds for the profile is said before the tap -- *wallet* for a seed + * attached, *read only* for a bare public key, nothing for the plain case of a bare + * key -- because it is the difference between a profile that can be signed out of and + * one whose sign-out is a wallet question, and between two profiles with the same name. + */ @Composable private fun AvailableWalletView( modifier: Modifier = Modifier, - globalPrefs: GlobalPrefs, - userWallet: UserWallet, + identity: StoredIdentity, + profile: Profile?, metadata: UserWalletMetadata, isCurrent: Boolean, canEdit: Boolean, @@ -122,7 +150,7 @@ private fun AvailableWalletView( ) // EditWalletDialog( // onDismiss = { showWalletEditDialog = false }, -// walletId = userWallet.walletId, +// walletId = identity.id, // globalPrefs = globalPrefs, // metadata = metadata, // ) @@ -134,7 +162,7 @@ private fun AvailableWalletView( if (isCurrent) { if (canEdit) showWalletEditDialog = true else return@Clickable } else { - onClick(userWallet.walletId) + onClick(identity.id) } }, shape = RoundedCornerShape(16.dp), @@ -147,12 +175,37 @@ private fun AvailableWalletView( .weight(1f), verticalAlignment = Alignment.CenterVertically ) { - WalletAvatar(avatar = metadata.avatar, backgroundColor = Color.Transparent, internalPadding = PaddingValues(MaterialTheme.spacing.space50)) + if (profile != null) { + ProfileAvatar(size = AvatarSize, publicKey = identity.nostrPublicKey, profile = profile) + } else { + WalletAvatar(avatar = metadata.avatar, backgroundColor = Color.Transparent, internalPadding = PaddingValues(MaterialTheme.spacing.space50)) + } Spacer(Modifier.width(MaterialTheme.spacing.space150)) Column { - Text(text = metadata.nameOrDefault(), modifier = Modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium) - Spacer(Modifier.height(MaterialTheme.spacing.space25)) - Text(text = userWallet.nodeId, modifier = Modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.displayMedium.copy(fontFamily = FontFamily.Monospace, fontSize = 12.sp)) + val npub = identity.nostrPublicKey.hexToNpubHrp() + Text( + text = profile?.humanReadableNameOrPubkey() ?: npub, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) + if (profile != null) { + Spacer(Modifier.height(MaterialTheme.spacing.space25)) + Text(text = npub, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.displayMedium.copy(fontFamily = FontFamily.Monospace, fontSize = 12.sp)) + } + val held = when (identity) { + is StoredIdentity.Mnemonic -> Res.string.wallet + is StoredIdentity.NostrPublic -> Res.string.read_only + is StoredIdentity.NostrSecret -> null + } + if (held != null) { + Spacer(Modifier.height(MaterialTheme.spacing.space25)) + Text( + text = stringResource(held), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } if (isCurrent && canEdit) { @@ -163,3 +216,6 @@ private fun AvailableWalletView( } } } + +/** A dimension, not a spacing: the picture is the same size the emoji avatar renders at. */ +private val AvatarSize = 44.dp diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Breakpoint.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Breakpoint.kt index 5d210bf2..ee87c588 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Breakpoint.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Breakpoint.kt @@ -59,7 +59,7 @@ enum class Breakpoint(val minWidth: Dp) { * them. * * Total, including for a width below [Compact.minWidth]. This is called from - * [TorchTheme] on every composition, and a desktop window reports a zero size for + * [MantraTheme] on every composition, and a desktop window reports a zero size for * the frame before its first layout pass; throwing there would take the app down * on a resize rather than on anything a user did. */ @@ -71,7 +71,7 @@ enum class Breakpoint(val minWidth: Dp) { /** * The breakpoint the current window is in. * - * Provided by [TorchTheme] as [LocalBreakpoint], so screens read + * Provided by [MantraTheme] as [LocalBreakpoint], so screens read * `MaterialTheme.breakpoint` rather than calling this. It is public because the two * entry points that compose above the theme -- the desktop passphrase gate, and any * future splash -- have nowhere else to get it. @@ -79,7 +79,7 @@ enum class Breakpoint(val minWidth: Dp) { * **Why the window size and not `currentWindowAdaptiveInfo()`.** The latter also computes * a [androidx.compose.material3.adaptive.Posture] from the platform's fold state, which * on android means reaching for `WindowInfoTracker` and an `Activity`. This is called - * from [TorchTheme], which wraps every `@Preview` in the tree, and a preview context is + * from [MantraTheme], which wraps every `@Preview` in the tree, and a preview context is * not an activity. `currentWindowDpSize()` is `LocalWindowInfo` and `LocalDensity` and * nothing else, so it answers everywhere. The pane scaffolds ask for posture themselves, * where a fold genuinely changes the answer. @@ -90,7 +90,7 @@ fun currentBreakpoint(): Breakpoint = Breakpoint.ofWidth(currentWindowDpSize().w /** * Defaults to [Breakpoint.Compact] rather than throwing, because that is the layout every - * screen in this app was written against: a composable that never reaches a [TorchTheme] + * screen in this app was written against: a composable that never reaches a [MantraTheme] * should render as it did before breakpoints existed, not fail. */ val LocalBreakpoint = staticCompositionLocalOf { Breakpoint.Compact } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/ConformancePreviews.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/ConformancePreviews.kt index d1e56707..28d9e553 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/ConformancePreviews.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/ConformancePreviews.kt @@ -21,7 +21,7 @@ import androidx.compose.ui.tooling.preview.Preview * is right in the default scheme is right in the high-contrast one by construction. A * per-screen high-contrast preview would be 51 more renders checking something already * proved, and there is no `@Preview` parameter for it anyway -- it needs - * `TorchTheme(contrast = …)` in the body. [ThemeGallery] covers the schemes once, over + * `MantraTheme(contrast = …)` in the body. [ThemeGallery] covers the schemes once, over * the components rather than over the screens. * * @see ThemeGallery diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Motion.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Motion.kt index 6022a524..2b612736 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Motion.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Motion.kt @@ -17,7 +17,7 @@ import androidx.navigation.NavBackStackEntry /** * Whether the platform has been asked to keep motion to a minimum. * - * Provided by [TorchTheme] as [LocalReducedMotion]; the transitions below read it, so a + * Provided by [MantraTheme] as [LocalReducedMotion]; the transitions below read it, so a * call site does not have to remember to. Reading it directly is for a screen animating * something the shared helpers do not cover. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Spacing.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Spacing.kt index f333eeda..cf702bb3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Spacing.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Spacing.kt @@ -12,7 +12,7 @@ import androidx.compose.ui.unit.dp * M3's spacing scale, and the semantic names layered over it. * * `MaterialTheme` has no spacing slot, so this rides a composition local beside it, the - * same way [ExtendedColors] does. [TorchTheme] provides it. + * same way [ExtendedColors] does. [MantraTheme] provides it. * * **The scale** is M3's own, an 8dp system where `space100 = 8dp`, including the sub-8 * nested units Material defines because its components need them. Numbers are from diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt index d5c2c66f..68998d58 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt @@ -393,7 +393,7 @@ val unspecified_scheme = ColorFamily( * Brand colours that are not part of the generated scheme. * * `ColorScheme` has no slot for them -- M3 calls them extended colours and expects them to - * ride alongside -- so they travel on their own composition local, provided by [TorchTheme] + * ride alongside -- so they travel on their own composition local, provided by [MantraTheme] * from the same `darkTheme` the scheme is chosen with. Reading `isSystemInDarkTheme()` at * the call site instead would be subtly wrong: it would ignore a caller who passed * `darkTheme` explicitly, and a preview forcing dark would show light pills. @@ -408,7 +408,7 @@ data class ExtendedColors( * The extended colours for the current theme. * * Defaults to the light families rather than to `unspecified_scheme` so that anything - * composing outside [TorchTheme] renders a real colour instead of nothing. That should not + * composing outside [MantraTheme] renders a real colour instead of nothing. That should not * happen -- as of this commit nothing in the tree does -- but an invisible button is a worse * way to find out than a light-themed one. */ @@ -484,7 +484,7 @@ internal fun appColorScheme(darkTheme: Boolean, contrast: ThemeContrast): ColorS */ @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun TorchTheme( +fun MantraTheme( darkTheme: Boolean = isSystemInDarkTheme(), contrast: ThemeContrast = platformThemeContrast(), // Dynamic color is available on Android 12+ diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/ThemeGallery.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/ThemeGallery.kt index e5e393a0..7f8af17c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/ThemeGallery.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/ThemeGallery.kt @@ -44,7 +44,7 @@ import press.mantra.compose.ui.composable.widgets.Decorative @Preview(name = "Six schemes", group = "schemes", widthDp = 1200, heightDp = 900) @Composable private fun ThemeGalleryPreview() { - // One render holding all six, not six renders. The contrast step is a `TorchTheme` + // One render holding all six, not six renders. The contrast step is a `MantraTheme` // parameter and no preview annotation can set it, so the combinations have to be laid // out in the body -- and laying them out side by side is what makes them comparable, // which is the only reason to look at them at all. @@ -70,7 +70,7 @@ private fun ThemeGalleryPreview() { */ @Composable fun ThemeGallery(darkTheme: Boolean, contrast: ThemeContrast) { - TorchTheme(darkTheme = darkTheme, contrast = contrast, dynamicColor = false) { + MantraTheme(darkTheme = darkTheme, contrast = contrast, dynamicColor = false) { Surface { Column( modifier = Modifier.padding(MaterialTheme.spacing.compactPadding), diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddMemberToChatRoomConfirmationViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddMemberToChatRoomConfirmationViewModel.kt index 456e364a..914cb8ab 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddMemberToChatRoomConfirmationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddMemberToChatRoomConfirmationViewModel.kt @@ -74,7 +74,7 @@ class AddMemberToChatRoomConfirmationViewModel( val lookupTimeout = launch { delay(RELAY_LOOKUP_TIMEOUT) addMemberToChatRoomConfirmationUIState = - AddMemberToChatRoomConfirmationUIState.Error("Profile missing key package event. Try again later or tell ${profile.humanReadableNameOrPubkey()} to use Torch.") + AddMemberToChatRoomConfirmationUIState.Error("Profile missing key package event. Try again later or tell ${profile.humanReadableNameOrPubkey()} to use Mantra.") } // Observe rather than read once: the key package usually lands a moment after diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModel.kt new file mode 100644 index 00000000..10828cc2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModel.kt @@ -0,0 +1,468 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +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.core.Kind +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.exceptions.NostrPublishException +import press.mantra.compose.network.dto.RelayDTO +import press.mantra.compose.network.relays.EventPublishTransport +import press.mantra.compose.network.relays.NostrPublishResult +import press.mantra.compose.network.relays.RelayPool +import press.mantra.compose.network.sockets.NostrIncomingMessage +import press.mantra.compose.nostr.Relays +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.ui.view.state.BroadcastGroupSignedEventUIState +import kotlin.time.Duration.Companion.milliseconds + +/** + * One event the group signed, sent to the relays a member picks. + * + * Signing and sending are two acts here, on purpose. `FrostSigningManager.complete` + * files what a quorum signed and applies it locally, and nothing leaves the device; + * this is the second act, taken by a member, one event at a time, to relays they + * can see and change before pressing the button. What goes on the wire is the + * event exactly as the group signed it -- the signature is the group's, so any + * member holding the event can send it and no share of the key is needed. + * + * The send itself, [sendToRelays], is also what the entries screen's "Broadcast + * all" uses, one entry after another and each to the relays [defaultRelaysFor] + * would have seeded here; what is this screen's is the list the member edits + * and the rows that show each relay's answer. + * + * ### Why not the broadcast queue + * + * Everything the app publishes as itself goes through `BroadcastNostrEventRequest`, + * which is durable, retried and drained in the background. It is not used here + * because it hangs off a `NostrEvent` row, and a row for the group's event is + * more than a queue entry: the home feed selects by kind, so the group's post + * would appear in it as if it were anybody's; and the sync loop offers every + * local event id to the relays it reconciles with, so the event would reach + * relays this screen never named. `GroupSignedEvent` explains at length why it is + * not a `NostrEvent`; this keeps that true. The cost is that a broadcast is only + * as durable as the button press -- what each relay said is shown, and a relay + * that did not answer is sent to again by pressing again. + * + * ### Where the relays come from + * + * [defaultRelaysFor]: the app's own publish set, the same relays everything the + * app publishes as itself goes to. A group has no relay list of its own to + * prefer, so the seed is the same for every event of every kind. The member can + * add and remove before sending, and the seeded list is a suggestion rather + * than a rule. + * + * ### Nothing opens this screen yet + * + * The rows that carried a Broadcast button under each signed event -- the + * group's profile, relay lists, posts and curated lists -- have gone, and no + * screen that stayed has one. `BroadcastGroupSignedEventRoute` is still + * registered and `BroadcastButton` still built, so wiring a button in is all it + * takes to reach this again. + */ +class BroadcastGroupSignedEventViewModel( + val chatRoomId: String, + val signedEventId: HexKey, + val activeUserPublicKey: HexKey, + val relayHint: String?, + initialBroadcastGroupSignedEventUIState: BroadcastGroupSignedEventUIState, + val chatRepository: ChatRepository, + val publishTransport: EventPublishTransport, +): ViewModel() { + + var broadcastGroupSignedEventUIState: BroadcastGroupSignedEventUIState by mutableStateOf( + initialBroadcastGroupSignedEventUIState + ) + private set + + val isActionPending: MutableState = mutableStateOf(false) + + /** + * The relays the event will be sent to, in the order they are shown. + * + * Held on the view model rather than in the composition, like the relay + * editor's working copy, so a rotation does not empty the list. + */ + val relays = mutableStateListOf() + + /** + * What each relay said the last time the button was pressed. + * + * Keyed by relay rather than kept as a list, because a relay removed after it + * answered takes its answer with it, and one added afterwards has none until + * the next press. + */ + val outcomes = mutableStateMapOf() + + /** + * Why the last add did not happen, or null. Cleared by the next keystroke. + * + * The same two refusals as the relay editor's, for the same reason: an add + * button that does nothing for a URL it refuses is indistinguishable from a + * missed tap. + */ + var addFailure: AddFailure? by mutableStateOf(null) + private set + + /** + * How the last press went, or null before the first one. + * + * Shown on the screen rather than flashed in a snackbar, because it is the + * answer the member came for and it should still be there after the rows + * under it have been read. Cleared when the next press starts. + */ + var lastBroadcast: BroadcastSummary? by mutableStateOf(null) + private set + + private var isSeeded = false + + /** [accepted] of [asked] relays said OK. */ + data class BroadcastSummary(val accepted: Int, val asked: Int) + + enum class AddFailure { + /** Not a `wss://` URL, or one pointing at this machine. */ + NotARelay, + + /** Already in the list, which is a no-op rather than a mistake. */ + AlreadyListed, + } + + /** One relay's answer to one press of the button. */ + sealed interface RelayOutcome { + /** Asked, and not yet answered. */ + data object Sending : RelayOutcome + + /** The relay's OK: it holds the event now, or already did. */ + data object Accepted : RelayOutcome + + /** The relay answered and said no, in its own words. */ + data class Rejected(val reason: String) : RelayOutcome + + /** The relay could not be asked: no connection, or one that dropped. */ + data class Failed(val reason: String) : RelayOutcome + + /** Asked, and still unanswered when the whole broadcast ran out of time. */ + data object NoAnswer : RelayOutcome + } + + fun initiateBroadcastGroupSignedEvent() { + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + val signedEvent = chatRepository.groupSignedEvent(chatRoomId, signedEventId) + + broadcastGroupSignedEventUIState = when { + localChatRoom == null -> + BroadcastGroupSignedEventUIState.Error("Couldn't find the chat room") + + signedEvent == null -> + BroadcastGroupSignedEventUIState.Error( + "This device doesn't hold that signed event" + ) + + else -> BroadcastGroupSignedEventUIState.Loaded( + localChatRoom = localChatRoom, + signedEvent = signedEvent, + ) + } + } + } + + /** + * Fills the list with the defaults, once. + * + * Called from the screen rather than from the loader, because a preview and + * a layout test hand a loaded state straight in. Once and only once, so a + * member who emptied the list on purpose does not find it refilled by a + * state the view model re-emits. + */ + fun seedRelays(kind: Kind) { + if (isSeeded) return + isSeeded = true + + relays.addAll(defaultRelaysFor(kind)) + } + + fun clearAddFailure() { + addFailure = null + } + + /** + * Adds [url] to the list, or says why it did not. + * + * True when the row appeared, which is the caller's cue to clear the field. + */ + fun addRelay(url: String): Boolean { + val relayUrl = relayUrlOrNull(url) ?: run { + addFailure = AddFailure.NotARelay + return false + } + + if (relayUrl in relays) { + addFailure = AddFailure.AlreadyListed + return false + } + + relays.add(relayUrl) + addFailure = null + return true + } + + fun removeRelay(relayUrl: NormalizedRelayUrl) { + relays.remove(relayUrl) + outcomes.remove(relayUrl) + } + + /** + * Sends [signedEvent] to every relay in the list and records what each said. + * + * The send itself is [sendToRelays], which the entries screen's + * "Broadcast all" shares; what is this screen's is the state around it. + * Each answer lands in [outcomes] as it arrives, so the rows fill in one + * by one rather than all at the end, and [lastBroadcast] sums it up once + * every row has settled. + * + * [onNoRelays] instead of a send to nobody: an empty list is a question + * rather than a mistake, and the pool would otherwise fall back to the + * *member's* relays, which is not what an empty list on this screen means. + * + * Returns the send, for a caller that has to wait for it, and null when + * nothing was sent. + */ + fun broadcast( + signedEvent: GroupSignedEvent, + onNoRelays: () -> Unit, + ): Job? { + // Guard against double submits. A second press while the first is out + // would answer each relay's OK twice and count it twice. + if (isActionPending.value) return null + + val targets = relays.toList() + if (targets.isEmpty()) { + onNoRelays.invoke() + return null + } + + isActionPending.value = true + lastBroadcast = null + outcomes.clear() + targets.forEach { outcomes[it] = RelayOutcome.Sending } + + return viewModelScope.launch(Dispatchers.IO) { + val settled = sendToRelays(publishTransport, signedEvent, targets) { relay, outcome -> + outcomes[relay] = outcome + } + + lastBroadcast = BroadcastSummary( + accepted = settled.count { it.value == RelayOutcome.Accepted }, + asked = targets.size, + ) + + isActionPending.value = false + } + } + + companion object { + private const val TAG = "BroadcastGroupSignedEventViewModel" + + private val logger = Logger.withTag(TAG) + + /** + * A ceiling on the whole broadcast, past the pool's own per-relay wait, + * so a socket that opens and then says nothing cannot hold the button + * down forever. The same bound the queue drainer puts on one attempt. + */ + val BROADCAST_TIMEOUT = (RelayPool.PUBLISH_TIMEOUT * 2).milliseconds + + /** + * Sends [signedEvent] to [targets] and says what each relay answered. + * + * One call to the pool for the whole list, which opens a socket to any + * relay it does not hold and answers once per relay -- an OK, a + * rejection with the relay's reason, or the error that stopped it being + * asked. Each answer goes to [onOutcome] as it arrives, so a caller + * showing rows can fill them in one by one rather than all at the end; + * a relay that has not answered when the whole thing runs out of time + * is marked so, which is different from having been refused, and one + * the pool refused to ask at all is marked failed with the pool's reason. + * + * Returns once every relay has settled, with the answers in the order + * [targets] gave them. Shared by the one-event screen and the entries + * screen's "Broadcast all", so that what a relay's answer becomes is + * decided in one place. + */ + suspend fun sendToRelays( + publishTransport: EventPublishTransport, + signedEvent: GroupSignedEvent, + targets: List, + onOutcome: (NormalizedRelayUrl, RelayOutcome) -> Unit = { _, _ -> }, + ): Map { + val outcomes = mutableMapOf() + + fun settle(relay: NormalizedRelayUrl, outcome: RelayOutcome) { + outcomes[relay] = outcome + onOutcome(relay, outcome) + } + + try { + withTimeout(BROADCAST_TIMEOUT) { + publishTransport.publishEvent( + nostrEvent = wireEventOf(signedEvent), + relays = targets.map { RelayDTO(url = it.url, read = false, write = true) }.toSet(), + ) + // One answer per relay is all the pool ever gives, and the + // flow underneath never completes on its own. + .take(targets.size) + .collect { result -> + outcomeOf(targets, result)?.let { (relay, outcome) -> settle(relay, outcome) } + } + } + } catch (e: TimeoutCancellationException) { + // Ordinary: a relay that never answers is what the timeout is + // for. The relays still unanswered are settled below. + logger.w("Broadcast of ${signedEvent.id} ran out of time") + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + // The pool refused the whole call rather than one relay's part of + // it, so every relay that was waiting gets the same answer. + logger.e("Broadcast of ${signedEvent.id} failed before any relay answered", e) + targets.forEach { relay -> + if (relay !in outcomes) settle(relay, RelayOutcome.Failed(e.message ?: "Could not send")) + } + } + + targets.forEach { relay -> + if (relay !in outcomes) settle(relay, RelayOutcome.NoAnswer) + } + + return targets.associateWith { outcomes.getValue(it) } + } + + /** + * One relay's answer, against the relay that was asked. + * + * Matched by host rather than by string, because the pool names a relay + * by the URL it first opened a socket with, and that may carry a + * trailing slash the caller's spelling does not. Null for an answer + * from a relay nobody asked, which is logged and dropped. + */ + private fun outcomeOf( + targets: List, + result: NostrPublishResult, + ): Pair? { + val answered = NormalizedRelayUrl(result.relayUrl).displayUrl() + val relay = targets.firstOrNull { it.displayUrl() == answered } ?: run { + logger.w("Answer from ${result.relayUrl}, which was not asked") + return null + } + + val error = result.error + val ok = result.result as? NostrIncomingMessage.OkMessage + + return relay to when { + // The pool wraps a relay's ["OK", id, false, reason] in this one + // exception type and nothing else in it, so the message is the + // relay's reason in the relay's words. + error is NostrPublishException -> RelayOutcome.Rejected(error.message ?: "Rejected") + error != null -> RelayOutcome.Failed(error.message ?: "Could not send") + ok?.success == true -> RelayOutcome.Accepted + else -> RelayOutcome.Failed("No OK from the relay") + } + } + + /** + * The event as the pool wants it, and nothing more. + * + * A `NostrEvent` built to be serialised and dropped -- it is never + * written to the table, which is the whole point of this screen sending + * directly rather than through the queue. The seven fields are the ones + * the id was hashed over, so what goes out is what the group signed. + */ + fun wireEventOf(signedEvent: GroupSignedEvent): NostrEvent = NostrEvent( + id = signedEvent.id, + pubKey = signedEvent.publicKey, + kind = signedEvent.kind, + tags = signedEvent.tags, + content = signedEvent.content, + sig = signedEvent.signature, + createdAt = signedEvent.createdAt, + ) + + /** + * Where an event of [kind] should go, before the member has said otherwise. + * + * The app's own publish set, for every kind alike. A group says nothing + * about where it lives -- it signs no relay list of its own -- so there + * is no list of the group's to prefer and nothing of the group's to + * exclude, and the set the app publishes its own events to is what + * stops the button being a no-op on every group until somebody types a + * relay in. The kind is taken so a caller that has one to route by need + * not change its call when there is something to route on again. + */ + @Suppress("UNUSED_PARAMETER") + fun defaultRelaysFor(kind: Kind): List = + Relays.eventPublishRelaySet.toList() + + /** + * [url] as a relay an event could be sent to, or null. + * + * Structural only, and deliberately narrow: `wss://` because what is + * sent is read by strangers over the open network and `ws://` would ask + * them to fetch it in the clear, and no loopback because a relay only + * this device can reach is not somewhere a broadcast goes. Wisp's + * `RelayConfig.isValidUrl` refuses the same three things. + */ + fun relayUrlOrNull(url: String): NormalizedRelayUrl? { + val trimmed = url.trim() + if (!trimmed.startsWith("wss://", ignoreCase = true)) return null + + return RelayUrlNormalizer.normalizeOrNull(trimmed)?.takeUnless { it.isLocalHost() } + } + + fun factory( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + signedEventId: HexKey, + initialBroadcastGroupSignedEventUIState: BroadcastGroupSignedEventUIState = + BroadcastGroupSignedEventUIState.Loading, + chatRepository: ChatRepository, + publishTransport: EventPublishTransport, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + BroadcastGroupSignedEventViewModel( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + signedEventId = signedEventId, + initialBroadcastGroupSignedEventUIState = initialBroadcastGroupSignedEventUIState, + chatRepository = chatRepository, + publishTransport = publishTransport, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomMessagingViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomMessagingViewModel.kt index 22682987..a0d00b5e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomMessagingViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomMessagingViewModel.kt @@ -9,20 +9,14 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory -import press.mantra.compose.database.GENESIS_AT -import press.mantra.compose.database.model.NegentropySynchronizeRequest -import press.mantra.compose.database.model.types.SynchronizationFilter -import press.mantra.compose.nostr.Relays +import press.mantra.compose.nostr.DirectMessagePeerSync import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.view.state.ChatRoomMessagingUIState import co.touchlab.kermit.Logger -import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.delay @@ -73,44 +67,23 @@ class ChatRoomMessagingViewModel( // Get ChatRoomById viewModelScope.launch(Dispatchers.IO) { - // Check if we have a chat with activeUserPublicKey and chatRoomId (pubkey) as participants - val participants = chatRepository.getAllParticipantsWithPubKey(chatRoomId) - - participants.forEach { participant -> - chatRepository.getChatRoomByIdentifier(participant.chatRoomId)?.let { localChatRoom -> - // TODO: Check that chatRoom is not a group chat... - if (localChatRoom.localParticipants.size == 1) { - // This is a chat with self... - // TODO: navigateToTheChat... - viewModelScope.launch(Dispatchers.Main) { - onNavigateToChat.invoke( - ChatRoomMessagingRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = localChatRoom.chatRoom.id, - relayHint = relayHint - ) - ) - } - return@launch - - } else if (localChatRoom.localParticipants.size == 2) { - val activeUserParticipant = localChatRoom.localParticipants.find { localParticipant -> localParticipant.participant.participantPublicKey == activeUserPublicKey } - - if (activeUserParticipant != null) { - // We have found the chat we are looking for... - viewModelScope.launch(Dispatchers.Main) { - onNavigateToChat.invoke( - ChatRoomMessagingRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = localChatRoom.chatRoom.id, - relayHint = relayHint - ) - ) - } - return@launch - } - } + // A conversation with this person that already exists is the one to open. The + // rule -- the two of us and nobody else, or yourself alone -- is the + // repository's now, and the profile preview reads the same one. + chatRepository.findDirectMessageRoom( + userPublicKey = activeUserPublicKey, + peerPublicKey = chatRoomId + )?.let { localChatRoom -> + viewModelScope.launch(Dispatchers.Main) { + onNavigateToChat.invoke( + ChatRoomMessagingRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = localChatRoom.chatRoom.id, + relayHint = relayHint + ) + ) } + return@launch } // Observe BOTH halves of what we need. Watching only the profile meant a key @@ -123,7 +96,7 @@ class ChatRoomMessagingViewModel( val lookupTimeout = viewModelScope.launch { delay(RELAY_LOOKUP_TIMEOUT) chatRoomMessagingUIState = ChatRoomMessagingUIState.Error( - "Couldn't find this profile's chat details on the relays yet. They may not have finished setting up Torch." + "Couldn't find this profile's chat details on the relays yet. They may not have finished setting up Mantra." ) } @@ -133,7 +106,7 @@ class ChatRoomMessagingViewModel( ) { profile, keyPackage -> profile to keyPackage } .distinctUntilChanged() .collect { (profile, keyPackageEvent) -> - val profileIsResolved = profile != null && profile.createdAt > GENESIS_AT + val profileIsResolved = profile?.isResolved() == true if (!profileIsResolved || keyPackageEvent == null) { // Whatever is missing, ask the relays for it. This used to be queued @@ -177,35 +150,13 @@ class ChatRoomMessagingViewModel( /** * Ask the DM relays for everything needed to start a chat with [chatRoomId] (a pubkey here): - * the peer's metadata, their MLS key package and their DM relay list. + * the peer's metadata, their MLS key package and their DM relay list. The filter and + * the reasons behind it live in `DirectMessagePeerSync`, which the profile preview + * queues too. */ private suspend fun scheduleProfileAndKeyPackageSync() { - val synchronizationFilter = SynchronizationFilter( - kinds = arrayOf( - MetadataEvent.KIND, - KeyPackageEvent.KIND, - ChatMessageRelayListEvent.KIND - ), - authors = arrayOf( - chatRoomId - ) - // No `limit`: it is ignored on the negentropy path but honoured on the REQ - // fallback, where `limit = 1` returned a single newest event across all three - // kinds — almost always the kind-0 — so the key package never arrived. - ) nostrRepository.queueNegentropySynchronizeRequest( - Relays.DefaultDMRelayList.map { relay -> - NegentropySynchronizeRequest( - id = NegentropySynchronizeRequest.computeId( - relay.url, - synchronizationFilter = synchronizationFilter - ), - purpose = "initiate-chat", - synchronizationFilter = synchronizationFilter, - relayURL = relay.url, - level = 0 - ) - } + DirectMessagePeerSync.requests(chatRoomId) ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CreateProfileViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CreateProfileViewModel.kt index e1211ede..7c2ec497 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CreateProfileViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CreateProfileViewModel.kt @@ -12,20 +12,13 @@ import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.view.state.CreateProfileUIState import press.mantra.compose.ui.view.state.form.CreateProfileFormState import co.touchlab.kermit.Logger -import fr.acinq.bitcoin.MnemonicCode -import fr.acinq.bitcoin.byteVector -import fr.acinq.lightning.Lightning -import fr.acinq.lightning.crypto.LocalKeyManager -import fr.acinq.phoenix.managers.NodeParamsManager -import fr.acinq.phoenix.managers.nsecPassword -import fr.acinq.phoenix.utils.MnemonicLanguage +import fr.acinq.phoenix.data.WalletId import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO -import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import press.mantra.compose.extensions.nostrPublicKey -import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.withContext +import press.mantra.compose.identity.NewProfileWriter class CreateProfileViewModel( val initialCreateProfileUIState: CreateProfileUIState, @@ -78,19 +71,24 @@ class CreateProfileViewModel( } - private fun failProfileCreation(e: Throwable? = null) { - if (e != null) { - logger.e("error when creating new profile: ", e) - } else { - logger.e { "seed write failed, aborting profile creation" } - } + private fun failProfileCreation(e: Throwable) { + logger.e("error when creating new profile: ", e) createProfileUIState.value = CreateProfileUIState.Error isActionPending.value = false } - fun createAccount( - writeSeed: (mnemonics: List, onSeedWritten: () -> Unit, onSeedWriteError: () -> Unit) -> Unit - ) { + /** + * Makes the profile: the secret through [newProfile], then the six bootstrap events + * for the key it derives, then [onProfileCreated] with the id, on the main thread. + * + * The secret is written first, so that the events are only ever queued for a key the + * device holds and the notary can sign; and the events are in the database before the + * caller's tail activates the identity, so that the navigation machine finds the + * account the moment it looks -- an ordering the sign-in screen documents as + * load-bearing and the create flow used to get away with by luck. Which secret is made + * -- a seed, or a bare key -- is the writer's business; see [NewProfileWriter]. + */ + fun createAccount(newProfile: NewProfileWriter, onProfileCreated: (WalletId) -> Unit) { logger.d { "createAccount" } if (isActionPending.value) return isActionPending.value = true @@ -98,52 +96,21 @@ class CreateProfileViewModel( viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e -> failProfileCreation(e) }) { - logger.d("generating new wallet...") - val entropy = Lightning.randomBytes(16) - val mnemonics = MnemonicCode.toMnemonics( - entropy = entropy, - wordlist = MnemonicLanguage.English.wordlist() + val written = newProfile.write().getOrElse { e -> + failProfileCreation(e) + return@launch + } + logger.d("Pubkey: ${written.nostrPublicKey}") + + nostrRepository.createNewProfile( + written.nostrPublicKey, + name = createProfileFormState.nameField.textFieldState.text.toString(), + biography = createProfileFormState.biographyField.textFieldState.text.toString(), + onCompletion = { + logger.d("Created: ${written.nostrPublicKey}") + } ) - - val localKeyManager = LocalKeyManager( - seed = MnemonicCode.toSeed(mnemonics, "").byteVector(), - chain = NodeParamsManager.chain, - remoteSwapInExtendedPublicKey = NodeParamsManager.remoteSwapInXpub - ) - - val pubkey = localKeyManager.nostrPublicKey() - - logger.d("Pubkey: $pubkey" ) - - // Only create the profile events once the seed backing this pubkey is safely - // on disk; otherwise the notary could never sign them. - writeSeed( - mnemonics, - { - viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e -> - failProfileCreation(e) - }) { - nostrRepository.createNewProfile( - pubkey, - name = createProfileFormState.nameField.textFieldState.text.toString(), - biography = createProfileFormState.biographyField.textFieldState.text.toString(), - onCompletion = { - logger.d("Created: $pubkey" ) - } - ) - } - }, - { failProfileCreation() } - ) - } - } - - fun wipeDatabase( - onNavigateToEndThis: () -> Unit - ) { - viewModelScope.launch { - nostrRepository.wipeDatabase() - onNavigateToEndThis.invoke() + withContext(Dispatchers.Main) { onProfileCreated(written.id) } } } } \ No newline at end of file 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 791a0414..d369fd14 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 @@ -32,8 +32,7 @@ import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind -import fr.acinq.phoenix.data.ActiveWallet -import fr.acinq.phoenix.managers.nostrPrivateKey +import press.mantra.compose.identity.Identity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.Job @@ -56,7 +55,7 @@ class DkgRitualViewModel( */ val dkgSessionId: String? = null, initialDkgRitualUIState: DkgRitualUIState, - val activeWalletStateFlow: StateFlow, + val activeIdentityStateFlow: StateFlow, val chatRepository: ChatRepository, val dkgRepository: DkgRepository, ): ViewModel() { @@ -473,7 +472,7 @@ class DkgRitualViewModel( val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return - val nostrPrivateKey = activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey() + val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey if (nostrPrivateKey == null) { dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.") return @@ -513,7 +512,7 @@ class DkgRitualViewModel( val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return val session = loaded.session ?: return - val nostrPrivateKey = activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey() + val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey if (nostrPrivateKey == null) { dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.") return @@ -567,7 +566,7 @@ class DkgRitualViewModel( // guard rather than a path anybody takes. if (!loaded.isKeyStateSigned) return - val nostrPrivateKey = activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey() + val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey if (nostrPrivateKey == null) { dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.") return @@ -678,7 +677,7 @@ class DkgRitualViewModel( activeUserPublicKey: HexKey, dkgSessionId: String? = null, initialDkgRitualUIState: DkgRitualUIState = DkgRitualUIState.Loading, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, chatRepository: ChatRepository, dkgRepository: DkgRepository ): ViewModelProvider.Factory = viewModelFactory { @@ -688,7 +687,7 @@ class DkgRitualViewModel( activeUserPublicKey = activeUserPublicKey, dkgSessionId = dkgSessionId, initialDkgRitualUIState = initialDkgRitualUIState, - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, chatRepository = chatRepository, dkgRepository = dkgRepository ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt index 041f89dd..a7366cb9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt @@ -8,7 +8,7 @@ import press.mantra.compose.database.model.intermdiate.LocalAccount import press.mantra.compose.ui.composable.navigation.routes.SovereignWalletStartupRoute import press.mantra.compose.ui.view.state.NavigationUIState import co.touchlab.kermit.Logger -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -18,14 +18,12 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.launch -import press.mantra.compose.extensions.nostrPublicKey import kotlin.time.Duration.Companion.milliseconds class NavigationViewModel( - val activeWalletStateFlow: StateFlow, + val activeIdentityStateFlow: StateFlow, initialNavigationUIState: NavigationUIState, val nostrRepository: press.mantra.compose.repository.NostrRepository, val scope: CoroutineScope, @@ -38,14 +36,14 @@ class NavigationViewModel( private val PENDING_BROADCAST_STATUSES = setOf("pending", "processing") fun factory( - activeWallet: StateFlow, + activeIdentity: StateFlow, initialNavigationUIState: NavigationUIState, nostrRepository: press.mantra.compose.repository.NostrRepository, scope: CoroutineScope, ): ViewModelProvider.Factory = viewModelFactory { initializer { NavigationViewModel( - activeWallet, + activeIdentity, initialNavigationUIState = initialNavigationUIState, nostrRepository = nostrRepository, scope = scope @@ -66,7 +64,22 @@ class NavigationViewModel( } suspend fun loadNostrProfile(startupRoute: SovereignWalletStartupRoute? = null) { - val activeUserPublicKey = nostrRepository.getLocalAccounts().firstOrNull()?.profile?.publicKey + // Which account is "ours" is the active identity's to say, when there is one: with + // two identities on the device, the first kind-0 row is the wrong one half the time. + // Matched on the unsigned event's pubKey, not `profile.publicKey` -- the Profile row is + // what the notary upserts when it *signs* the kind 0, so an account planted by + // `signInToProfile` (or one created moments ago and not yet signed) has none, and + // reading through it answered `Landing` for a key that had just been imported while + // `observeProfile`, reading the same rows, answered the sync screen. Two writers to + // one state, in whichever order the coroutines ran, and Landing pops the back stack. + val accounts = nostrRepository.getLocalAccounts() + val identity = activeIdentityStateFlow.value + val account = if (identity != null) { + accounts.firstOrNull { it.unsignedNostrEvent?.pubKey == identity.nostrPublicKey } + } else { + accounts.firstOrNull() + } + val activeUserPublicKey = account?.unsignedNostrEvent?.pubKey if (activeUserPublicKey == null) { // TODO: We can just go directly to startup witout fear... @@ -82,12 +95,9 @@ class NavigationViewModel( // `observeLocalAccount`, which turned the whole call into a no-op for anyone who // already had an account: the caller had navigated to a loading screen first and then // nothing ever moved the user off it. Every path out of here must leave the navigation - // state pointing somewhere. - processLocalAccount( - nostrRepository.observeLocalAccount( - publicKey = activeUserPublicKey - ).firstOrNull() - ) + // state pointing somewhere. `getLocalAccounts` already joined the relations this + // reads, so the account goes straight in rather than being fetched a second time. + processLocalAccount(account) } else { _navigationUIState.getAndUpdate { NavigationUIState.StartupPhoenix @@ -176,34 +186,22 @@ class NavigationViewModel( scope.launch(Dispatchers.IO) { delay(2_100.milliseconds) // Looking busy... - activeWalletStateFlow.collectLatest { activeWallet -> - logger.d("Active Wallet: $activeWallet") - if (activeWallet == null) { - + activeIdentityStateFlow.collectLatest { identity -> + logger.d("Active identity: $identity") + if (identity == null) { + // Nothing has been started: go and start something. _navigationUIState.getAndUpdate { NavigationUIState.StartupPhoenix } } else { - activeWallet.business.let { business -> - if (business == null) { - _navigationUIState.getAndUpdate { - NavigationUIState.StartupPhoenix - } - } else { - val activeUserPublicKey = business.walletManager.keyManager.value?.nostrPublicKey() + // The identity carries its own key, so there is no node to wait on and no + // "couldn't get the nostrKey" dead end. The branch that used to send a + // nodeless identity back to startup went with it: an identity without a + // node is now a kind of identity, not a wallet that has not finished + // starting. + logger.i("Observing: ${identity.nostrPublicKey}") - if (activeUserPublicKey == null) { - _navigationUIState.getAndUpdate { - NavigationUIState.Loading(text = "Couldn't get the nostrKey") - } - } else { - logger.i("Observing: $activeUserPublicKey") - - loadNostrProfile(activeUserPublicKey) - } - } - logger.i("Navigation UI State is Landing") - } + loadNostrProfile(identity.nostrPublicKey) } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NewProfileWriters.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NewProfileWriters.kt new file mode 100644 index 00000000..11d06d87 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NewProfileWriters.kt @@ -0,0 +1,40 @@ +package press.mantra.compose.ui.view.model + +import fr.acinq.bitcoin.MnemonicCode +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.lightning.Lightning +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.utils.MnemonicLanguage +import kotlinx.coroutines.CompletableDeferred +import press.mantra.compose.identity.IdentityWriter +import press.mantra.compose.identity.NewProfile +import press.mantra.compose.identity.NewProfileWriter +import press.mantra.compose.identity.StoredIdentity + +/** + * A profile with a wallet attached: twelve fresh words through the seed writer, which + * since docs/multiple-profiles.md writes the derived key to the credentials file as + * well. Callback-shaped underneath, awaited here: exactly one of the two callbacks + * fires. + */ +fun SovereignWalletViewModel.seedProfileWriter(): NewProfileWriter = NewProfileWriter { + val words = MnemonicCode.toMnemonics(Lightning.randomBytes(16), MnemonicLanguage.English.wordlist()) + val outcome = CompletableDeferred>() + writeSeed( + mnemonics = words, + isRestoringWallet = false, + onSeedWritten = { id -> outcome.complete(Result.success(NewProfile(StoredIdentity.nostrPublicKeyOf(words), id))) }, + onSeedWriteError = { error -> outcome.complete(Result.failure(IllegalStateException("seed write failed: $error"))) }, + ) + outcome.await() +} + +/** A profile that is a key: thirty-two random bytes, written the way a pasted nsec is. */ +fun SovereignWalletViewModel.bareKeyProfileWriter(): NewProfileWriter = NewProfileWriter { + val key = PrivateKey(Lightning.randomBytes(32)) + when (val result = writeNostrKey(key)) { + is IdentityWriter.WriteNostrCredentialResult.Written -> Result.success(NewProfile(key.nostrPublicKeyHex(), result.id)) + is IdentityWriter.WriteNostrCredentialResult.AlreadyExists -> Result.failure(IllegalStateException("a fresh key collided with one already here")) + is IdentityWriter.WriteNostrCredentialResult.CannotLoadKeys -> Result.failure(IllegalStateException("the credentials could not be read")) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModel.kt new file mode 100644 index 00000000..f56a0962 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModel.kt @@ -0,0 +1,218 @@ +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 fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.data.DecryptNostrCredentialsResult +import fr.acinq.phoenix.managers.NostrCredentialManager +import fr.acinq.phoenix.security.NostrCredential +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import press.mantra.compose.extensions.hexToNsecHrp +import press.mantra.compose.identity.ForgetIdentity +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.IdentityKind +import press.mantra.compose.identity.IdentityWriter +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.NostrSecretUIState + +/** + * Reads back the nostr secret key an identity was signed in with. + * + * The counterpart of [RecoveryPhraseViewModel] for the identity kind that has a key and + * no phrase. There is no wallet behind such an identity, so the key is the whole of the + * backup, and everything here is about getting it in front of the user once and + * recording that they say they stored it. + * + * The key is read from the file at reveal time, not off the active identity, so the + * screen's contract -- nothing secret held longer than it is shown -- is the same as the + * phrase screen's. The backup flags are the same per-identity preferences the phrase + * screen writes; they mean "this identity's secret is not backed up" for whichever + * secret it is. + * + * It also owns the inverse of an import: [forgetKey], which removes the key from the + * device and the account with it, through [ForgetIdentity]. Only for a bare key; a + * mnemonic identity's key is in the seed, and removing a seed is a wallet question this + * does not answer. + */ +class NostrSecretViewModel( + val phoenixGlobal: PhoenixGlobal, + val activeIdentityStateFlow: StateFlow, + private val nostrRepository: NostrRepository, + private val forgetNostrCredential: suspend (Identity) -> IdentityWriter.ForgetNostrCredentialResult, + private val hideIdentityMetadata: suspend (Identity) -> Unit, +) : ViewModel() { + + private val logger = Logger.withTag(TAG) + + private val _uiState = MutableStateFlow(NostrSecretUIState.Hidden) + val uiState = _uiState.asStateFlow() + + private val _forgetting = MutableStateFlow(NostrSecretUIState.Forgetting.Idle) + val forgetting = _forgetting.asStateFlow() + + /** Whether the active identity is one whose key this screen can show and forget. */ + @OptIn(ExperimentalCoroutinesApi::class) + val isBareKeyIdentity: StateFlow = activeIdentityStateFlow + .flatMapLatest { flowOf(it?.kind == IdentityKind.NostrSecret) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + + @OptIn(ExperimentalCoroutinesApi::class) + val isBackupDone: StateFlow = activeIdentityStateFlow + .flatMapLatest { it?.internalPrefs?.isManualSeedBackupDone ?: flowOf(null) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + @OptIn(ExperimentalCoroutinesApi::class) + val isDisclaimerRead: StateFlow = activeIdentityStateFlow + .flatMapLatest { it?.internalPrefs?.isSeedLossDisclaimerRead ?: flowOf(null) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + @OptIn(ExperimentalCoroutinesApi::class) + val showBackupNotice: StateFlow = activeIdentityStateFlow + .flatMapLatest { it?.internalPrefs?.showSeedBackupNotice ?: flowOf(false) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + + fun revealNostrSecret() { + if (_uiState.value is NostrSecretUIState.Revealing) return + + val identity = activeIdentityStateFlow.value + if (identity == null) { + logger.e { "no active identity, cannot read a nostr key" } + _uiState.value = NostrSecretUIState.Error.NoActiveIdentity + return + } + + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + logger.e("failed to read the nostr keys", throwable) + _uiState.value = NostrSecretUIState.Error.KeysUnreadable + }) { + _uiState.value = NostrSecretUIState.Revealing + + // Keystore-backed, and it reads a file: it blocks, so it cannot run on the main thread. + val result = withContext(Dispatchers.IO) { + NostrCredentialManager.loadAndDecrypt(phoenixGlobal) + } + + _uiState.value = when (result) { + is DecryptNostrCredentialsResult.Success -> { + // A public entry is not a key either: this screen is not offered to a + // read-only identity, and if it were reached there is nothing to show. + val credential = result.credentials[identity.nostrPublicKey] as? NostrCredential.Secret + if (credential == null) { + logger.e { "credentials file holds no secret for identity=${identity.id}" } + NostrSecretUIState.Error.NoKeyForThisIdentity + } else { + NostrSecretUIState.Revealed(credential.privateKey.value.toHex().hexToNsecHrp()) + } + } + is DecryptNostrCredentialsResult.Failure.FileNotFound -> { + logger.e { "no credentials file, so no key for identity=${identity.id}" } + NostrSecretUIState.Error.NoKeyForThisIdentity + } + is DecryptNostrCredentialsResult.Failure -> { + logger.e { "unable to read the nostr credentials: $result" } + NostrSecretUIState.Error.KeysUnreadable + } + } + } + } + + fun hideNostrSecret() { + _uiState.value = NostrSecretUIState.Hidden + } + + fun setBackupDone(isDone: Boolean) { + val internalPrefs = activeIdentityStateFlow.value?.internalPrefs ?: return + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + logger.e("could not save the backup flag", throwable) + }) { + internalPrefs.saveManualSeedBackupDone(isDone) + } + } + + fun setDisclaimerRead(isRead: Boolean) { + val internalPrefs = activeIdentityStateFlow.value?.internalPrefs ?: return + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + logger.e("could not save the disclaimer flag", throwable) + }) { + internalPrefs.saveSeedLossDisclaimerRead(isRead) + } + } + + fun askToForgetKey() { + _forgetting.value = NostrSecretUIState.Forgetting.Confirming + } + + fun cancelForgetKey() { + _forgetting.value = NostrSecretUIState.Forgetting.Idle + } + + /** + * Key out of the file, preferences deleted, metadata hidden, account rows gone -- in + * that order, for the reason [ForgetIdentity] gives. [onForgotten] runs on the main + * thread once the device no longer holds the key; the caller clears the active + * identity and re-lists. + */ + fun forgetKey(onForgotten: () -> Unit) { + val identity = activeIdentityStateFlow.value ?: return + if (_forgetting.value is NostrSecretUIState.Forgetting.InProgress) return + _forgetting.value = NostrSecretUIState.Forgetting.InProgress + _uiState.value = NostrSecretUIState.Hidden + + viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, throwable -> + logger.e("could not forget the key", throwable) + _forgetting.value = NostrSecretUIState.Forgetting.Failed + }) { + // The sequence is ForgetIdentity's, shared with the two exits a read-only + // identity has; only the state it drives is this screen's. + when (ForgetIdentity.forget(identity, nostrRepository, forgetNostrCredential, hideIdentityMetadata)) { + is ForgetIdentity.Outcome.Forgotten -> { + _forgetting.value = NostrSecretUIState.Forgetting.Idle + withContext(Dispatchers.Main) { onForgotten() } + } + is ForgetIdentity.Outcome.WalletAttached, + is ForgetIdentity.Outcome.CannotForget -> { + _forgetting.value = NostrSecretUIState.Forgetting.Failed + } + } + } + } + + companion object { + private const val TAG = "NostrSecretViewModel" + + fun factory( + phoenixGlobal: PhoenixGlobal, + activeIdentityStateFlow: StateFlow, + nostrRepository: NostrRepository, + forgetNostrCredential: suspend (Identity) -> IdentityWriter.ForgetNostrCredentialResult, + hideIdentityMetadata: suspend (Identity) -> Unit, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + NostrSecretViewModel( + phoenixGlobal = phoenixGlobal, + activeIdentityStateFlow = activeIdentityStateFlow, + nostrRepository = nostrRepository, + forgetNostrCredential = forgetNostrCredential, + hideIdentityMetadata = hideIdentityMetadata, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NotaryViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NotaryViewModel.kt index 2f69969a..e1dade97 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NotaryViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NotaryViewModel.kt @@ -11,19 +11,15 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent -import fr.acinq.phoenix.data.ActiveWallet -import fr.acinq.phoenix.managers.nostrPrivateKey +import press.mantra.compose.identity.Identity import fr.acinq.phoenix.managers.nsecPassword import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import press.mantra.compose.database.model.NostrEvent @@ -31,7 +27,7 @@ import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant class NotaryViewModel( - val activeWalletStateFlow: StateFlow, + val activeIdentityStateFlow: StateFlow, val nostrRepository: press.mantra.compose.repository.NostrRepository, val chatRepository: press.mantra.compose.repository.ChatRepository, val marmotRepository: MarmotRepository, @@ -43,7 +39,7 @@ class NotaryViewModel( fun factory( - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, nostrRepository: press.mantra.compose.repository.NostrRepository, chatRepository: press.mantra.compose.repository.ChatRepository, marmotRepository: MarmotRepository, @@ -51,7 +47,7 @@ class NotaryViewModel( ): ViewModelProvider.Factory = viewModelFactory { initializer { NotaryViewModel( - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, nostrRepository = nostrRepository, chatRepository = chatRepository, marmotRepository = marmotRepository, @@ -63,14 +59,14 @@ class NotaryViewModel( private val logger = Logger.withTag(TAG) - @OptIn(ExperimentalCoroutinesApi::class) private fun observeActiveNostrKey() { scope.launch { - activeWalletStateFlow - .flatMapLatest { activeWallet -> - activeWallet?.business?.walletManager?.keyManager ?: flowOf(null) - } - .map { keyManager -> keyManager?.nostrPrivateKey() } + // One flow, not a `flatMapLatest` into the node's key manager: the identity + // carries its key from the moment it exists, so there is nothing to wait for. + // `distinctUntilChanged` on the key stays -- an identity re-emitted with the same + // key must not tear down and restart the observers below. + activeIdentityStateFlow + .map { identity -> identity?.nostrPrivateKey } .distinctUntilChanged() .collectLatest { nostrPrivateKey -> if (nostrPrivateKey == null) return@collectLatest diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModel.kt new file mode 100644 index 00000000..3c24ff6f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModel.kt @@ -0,0 +1,203 @@ +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.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import press.mantra.compose.database.model.MarmotKeyPackage +import press.mantra.compose.database.model.Profile +import press.mantra.compose.nostr.DirectMessagePeerSync +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.ProfilePreviewUIState +import press.mantra.compose.ui.view.state.ProfilePreviewUIState.Readiness +import kotlin.concurrent.Volatile +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * Finds the person behind a pasted npub, and says whether a chat with them can start. + * + * Cached first: the observed rows are whatever the device already holds, so a person + * already known is on screen before any relay answers, and the relays refresh them + * behind it. The relays are asked twice over -- the kind 0 of the indexers, which hold + * everyone's, and the three kinds a chat needs of the DM relays -- through + * [DirectMessagePeerSync], so that pressing the button on the screen after this one + * finds the key package already here. + * + * Both halves are observed together, as `ChatRoomMessagingViewModel` does, because a + * key package arriving after the profile touches a different table and a collect on + * the profile alone would never see it. A room that already exists is read once, up + * front: a key package is meant to be used once, and the relays need not hold a fresh + * one for a person you already have a room with. + * + * One timeout, the one both DM view models use, and it only changes what is shown: + * nothing found becomes not-found, and a person found without a key package becomes + * not-yet-on-Mantra. The collect stays live past it, so whatever arrives on the + * twenty-first second still lands. See docs/npub-profile-preview.md. + */ +class ProfilePreviewViewModel( + val activeUserPublicKey: HexKey, + val profilePublicKey: HexKey, + initialProfilePreviewUIState: ProfilePreviewUIState, + private val nostrRepository: NostrRepository, + private val chatRepository: ChatRepository, + /** How long the relays get before the screen says it came up empty. A parameter so a test need not wait it out. */ + private val relayLookupTimeout: Duration = RELAY_LOOKUP_TIMEOUT, +) : ViewModel() { + + var profilePreviewUIState: ProfilePreviewUIState by mutableStateOf(initialProfilePreviewUIState) + private set + + private val logger = Logger.withTag(TAG) + + private var started = false + private var lookupTimeout: Job? = null + + /** Set when the timeout fires; cleared by a retry. Written and read on different coroutines. */ + @Volatile + private var relaysHadTheirTime = false + + /** Once. A recomposition that calls this again must not open a second collect. */ + fun initiate() { + if (started) return + started = true + observe() + askTheRelays() + } + + /** + * Asks again. The observation is still live, so the answer arrives through the same + * collect. With a person on screen it is the key package being asked for, and the + * DM relays alone are asked; without one, everything is. + */ + fun retry() { + relaysHadTheirTime = false + when (val state = profilePreviewUIState) { + is ProfilePreviewUIState.Loaded -> { + if (state.readiness == Readiness.NotYetOnMantra) { + profilePreviewUIState = state.copy(readiness = Readiness.Checking) + } + askTheRelays(profileToo = false) + } + else -> { + profilePreviewUIState = ProfilePreviewUIState.Loading + askTheRelays(profileToo = true) + } + } + } + + private fun observe() { + viewModelScope.launch(Dispatchers.IO) { + val existingRoom = chatRepository.findDirectMessageRoom( + userPublicKey = activeUserPublicKey, + peerPublicKey = profilePublicKey, + )?.let { localChatRoom -> + Readiness.ExistingChat( + chatRoomId = localChatRoom.chatRoom.id, + relayHint = localChatRoom.localParticipants + .firstOrNull { it.participant.participantPublicKey == profilePublicKey } + ?.participant?.relayHint, + ) + } + + combine( + nostrRepository.observeProfileWithPublicKey(profilePublicKey), + chatRepository.observeMarmotKeyPackageForPublicKey(profilePublicKey), + ) { profile, keyPackage -> + // A placeholder is not a person. Mapping it to null here is what keeps + // "LOADING..." off a screen whose one job is to say who this is. + profile?.takeIf { it.isResolved() } to keyPackage + } + .distinctUntilChanged() + .collect { (profile, keyPackage) -> + if (profile == null) return@collect + show(profile, keyPackage, existingRoom) + } + } + } + + private fun show(profile: Profile, keyPackage: MarmotKeyPackage?, existingRoom: Readiness.ExistingChat?) { + val readiness = when { + existingRoom != null -> existingRoom + keyPackage != null -> Readiness.CanStartChat + relaysHadTheirTime -> Readiness.NotYetOnMantra + else -> Readiness.Checking + } + // The clock stops only when there is nothing left to wait for. A profile on its + // own cancels nothing: the key package is still owed. + if (readiness != Readiness.Checking) lookupTimeout?.cancel() + profilePreviewUIState = ProfilePreviewUIState.Loaded(profile = profile, readiness = readiness) + } + + private fun askTheRelays(profileToo: Boolean = true) { + lookupTimeout?.cancel() + lookupTimeout = viewModelScope.launch(Dispatchers.IO) { + delay(relayLookupTimeout) + relaysHadTheirTime = true + when (val state = profilePreviewUIState) { + ProfilePreviewUIState.Loading -> profilePreviewUIState = ProfilePreviewUIState.NotFound + is ProfilePreviewUIState.Loaded -> + if (state.readiness == Readiness.Checking) { + profilePreviewUIState = state.copy(readiness = Readiness.NotYetOnMantra) + } + else -> Unit + } + } + + viewModelScope.launch(Dispatchers.IO) { + try { + if (profileToo) { + nostrRepository.queueSynchronizeNostrEvent( + DirectMessagePeerSync.profileRequests(profilePublicKey) + ) + } + nostrRepository.queueNegentropySynchronizeRequest( + DirectMessagePeerSync.requests(profilePublicKey) + ) + } catch (e: Exception) { + logger.e("could not ask the relays about $profilePublicKey", e) + lookupTimeout?.cancel() + profilePreviewUIState = ProfilePreviewUIState.Error + } + } + } + + companion object { + private const val TAG = "ProfilePreviewViewModel" + + /** How long to wait on the relays before telling the user we came up empty. */ + val RELAY_LOOKUP_TIMEOUT = 20.seconds + + fun factory( + activeUserPublicKey: HexKey, + profilePublicKey: HexKey, + initialProfilePreviewUIState: ProfilePreviewUIState = ProfilePreviewUIState.Loading, + nostrRepository: NostrRepository, + chatRepository: ChatRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + ProfilePreviewViewModel( + activeUserPublicKey = activeUserPublicKey, + profilePublicKey = profilePublicKey, + initialProfilePreviewUIState = initialProfilePreviewUIState, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProfilesViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProfilesViewModel.kt new file mode 100644 index 00000000..b84e62e1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProfilesViewModel.kt @@ -0,0 +1,95 @@ +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 fr.acinq.phoenix.data.ListWalletState +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.utils.preferences.UserWalletMetadata +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.StoredIdentity +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.repository.observeProfilesOf +import press.mantra.compose.ui.view.state.ProfilesUIState + +/** + * What the switcher shows: the device's profiles, which one is open, and each one's + * nostr profile where the device has it. + * + * Everything comes from flows the app already holds -- the listing, the active + * identity, the wallet metadata -- joined to the profile rows for the listed keys. The + * three states follow `ListWalletState`: `Init` is loading, `Error` is an unreadable + * key file, and `Success` is the list. Nothing here writes; the switch itself is the + * screen's callback into `SovereignWalletViewModel.switchToIdentity`. + */ +class ProfilesViewModel( + listWalletState: StateFlow, + availableIdentities: StateFlow>, + activeIdentity: StateFlow, + walletsMetadata: Flow>, + nostrRepository: NostrRepository, +) : ViewModel() { + + @OptIn(ExperimentalCoroutinesApi::class) + val uiState: StateFlow = combine( + listWalletState, + availableIdentities, + activeIdentity, + walletsMetadata, + ) { listState, identities, active, metadata -> + Inputs(listState, identities, active?.id, metadata) + }.flatMapLatest { inputs -> + when (inputs.listState) { + is ListWalletState.Init -> flowOf(ProfilesUIState.Loading) + is ListWalletState.Error -> flowOf(ProfilesUIState.Error) + is ListWalletState.Success -> nostrRepository + .observeProfilesOf(inputs.identities.values.map { it.nostrPublicKey }) + .map { profiles -> + ProfilesUIState.Loaded( + identities = inputs.identities, + activeId = inputs.activeId, + walletsMetadata = inputs.metadata, + profiles = profiles, + ) + } + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ProfilesUIState.Loading) + + private data class Inputs( + val listState: ListWalletState, + val identities: Map, + val activeId: WalletId?, + val metadata: Map, + ) + + companion object { + fun factory( + listWalletState: StateFlow, + availableIdentities: StateFlow>, + activeIdentity: StateFlow, + walletsMetadata: Flow>, + nostrRepository: NostrRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + ProfilesViewModel( + listWalletState = listWalletState, + availableIdentities = availableIdentities, + activeIdentity = activeIdentity, + walletsMetadata = walletsMetadata, + nostrRepository = nostrRepository, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/RecoveryPhraseViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/RecoveryPhraseViewModel.kt index dad1108f..59ab0575 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/RecoveryPhraseViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/RecoveryPhraseViewModel.kt @@ -7,7 +7,7 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import co.touchlab.kermit.Logger import fr.acinq.phoenix.PhoenixGlobal -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity import fr.acinq.phoenix.data.DecryptSeedResult import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers @@ -39,7 +39,7 @@ import press.mantra.compose.ui.view.state.RecoveryPhraseUIState */ class RecoveryPhraseViewModel( val phoenixGlobal: PhoenixGlobal, - val activeWalletStateFlow: StateFlow, + val activeIdentityStateFlow: StateFlow, ): ViewModel() { private val logger = Logger.withTag(TAG) @@ -60,18 +60,18 @@ class RecoveryPhraseViewModel( * instead of drawing an unticked box over a ticked preference. */ @OptIn(ExperimentalCoroutinesApi::class) - val isBackupDone: StateFlow = activeWalletStateFlow + val isBackupDone: StateFlow = activeIdentityStateFlow .flatMapLatest { it?.internalPrefs?.isManualSeedBackupDone ?: flowOf(null) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) @OptIn(ExperimentalCoroutinesApi::class) - val isDisclaimerRead: StateFlow = activeWalletStateFlow + val isDisclaimerRead: StateFlow = activeIdentityStateFlow .flatMapLatest { it?.internalPrefs?.isSeedLossDisclaimerRead ?: flowOf(null) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) /** True until the user has both written the phrase down and read the disclaimer. */ @OptIn(ExperimentalCoroutinesApi::class) - val showBackupNotice: StateFlow = activeWalletStateFlow + val showBackupNotice: StateFlow = activeIdentityStateFlow .flatMapLatest { it?.internalPrefs?.showSeedBackupNotice ?: flowOf(false) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) @@ -86,7 +86,7 @@ class RecoveryPhraseViewModel( fun revealRecoveryPhrase() { if (_recoveryPhraseUIState.value is RecoveryPhraseUIState.Revealing) return - val walletId = activeWalletStateFlow.value?.id + val walletId = activeIdentityStateFlow.value?.id if (walletId == null) { logger.e { "no active wallet, cannot read a recovery phrase" } _recoveryPhraseUIState.value = RecoveryPhraseUIState.Error.NoActiveWallet @@ -128,7 +128,7 @@ class RecoveryPhraseViewModel( } fun setBackupDone(isDone: Boolean) { - val internalPrefs = activeWalletStateFlow.value?.internalPrefs ?: return + val internalPrefs = activeIdentityStateFlow.value?.internalPrefs ?: return viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> logger.e("could not save the backup flag", throwable) }) { @@ -137,7 +137,7 @@ class RecoveryPhraseViewModel( } fun setDisclaimerRead(isRead: Boolean) { - val internalPrefs = activeWalletStateFlow.value?.internalPrefs ?: return + val internalPrefs = activeIdentityStateFlow.value?.internalPrefs ?: return viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> logger.e("could not save the disclaimer flag", throwable) }) { @@ -150,12 +150,12 @@ class RecoveryPhraseViewModel( fun factory( phoenixGlobal: PhoenixGlobal, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, ): ViewModelProvider.Factory = viewModelFactory { initializer { RecoveryPhraseViewModel( phoenixGlobal = phoenixGlobal, - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt index 2d574794..a3c9d8ac 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt @@ -30,8 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.utils.RandomInstance -import fr.acinq.phoenix.data.ActiveWallet -import fr.acinq.phoenix.managers.nostrPrivateKey +import press.mantra.compose.identity.Identity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.async @@ -58,7 +57,7 @@ class SelectChatRoomTypeViewModel( val description: String?, val memberPublicKeys: List, initialSelectChatRoomTypeUIState: SelectChatRoomTypeUIState, - val activeWalletStateFlow: StateFlow, + val activeIdentityStateFlow: StateFlow, val nostrRepository: NostrRepository, val chatRepository: ChatRepository, val dkgRepository: DkgRepository, @@ -190,7 +189,7 @@ class SelectChatRoomTypeViewModel( return } - val nostrPrivateKey = activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey() + val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey if (nostrPrivateKey == null) { selectChatRoomTypeUIState = SelectChatRoomTypeUIState.Error( "Couldn't read your keys. Please try again." @@ -447,7 +446,7 @@ class SelectChatRoomTypeViewModel( description: String?, memberPublicKeys: List, initialSelectChatRoomTypeUIState: SelectChatRoomTypeUIState = SelectChatRoomTypeUIState.Loading, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, nostrRepository: NostrRepository, chatRepository: ChatRepository, dkgRepository: DkgRepository @@ -459,7 +458,7 @@ class SelectChatRoomTypeViewModel( description = description, memberPublicKeys = memberPublicKeys, initialSelectChatRoomTypeUIState = initialSelectChatRoomTypeUIState, - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, nostrRepository = nostrRepository, chatRepository = chatRepository, dkgRepository = dkgRepository diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt index ae4e93b2..e4ce6ff2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt @@ -12,8 +12,7 @@ import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.HexKey -import fr.acinq.phoenix.data.ActiveWallet -import fr.acinq.phoenix.managers.nostrPrivateKey +import press.mantra.compose.identity.Identity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.StateFlow @@ -51,7 +50,7 @@ class SelectSubgroupAdminsViewModel( val activeUserPublicKey: HexKey, val parentChatRoomId: String, initialUIState: SelectSubgroupAdminsUIState, - val activeWalletStateFlow: StateFlow, + val activeIdentityStateFlow: StateFlow, val chatRepository: ChatRepository, val dkgRepository: DkgRepository, ): ViewModel() { @@ -204,8 +203,7 @@ class SelectSubgroupAdminsViewModel( val loaded = uiState as? SelectSubgroupAdminsUIState.Loaded ?: return - val nostrPrivateKey = activeWalletStateFlow.value - ?.business?.walletManager?.keyManager?.value?.nostrPrivateKey() + val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey if (nostrPrivateKey == null) { uiState = SelectSubgroupAdminsUIState.Error("Couldn't read your keys. Please try again.") return @@ -293,7 +291,7 @@ class SelectSubgroupAdminsViewModel( activeUserPublicKey: HexKey, parentChatRoomId: String, initialUIState: SelectSubgroupAdminsUIState = SelectSubgroupAdminsUIState.Loading, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, chatRepository: ChatRepository, dkgRepository: DkgRepository ): ViewModelProvider.Factory = viewModelFactory { @@ -302,7 +300,7 @@ class SelectSubgroupAdminsViewModel( activeUserPublicKey = activeUserPublicKey, parentChatRoomId = parentChatRoomId, initialUIState = initialUIState, - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, chatRepository = chatRepository, dkgRepository = dkgRepository ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt index 27c90689..4155310c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt @@ -8,30 +8,71 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import co.touchlab.kermit.Logger -import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser -import com.vitorpamplona.quartz.nip19Bech32.entities.Entity -import com.vitorpamplona.quartz.nip19Bech32.entities.NPub -import com.vitorpamplona.quartz.nip19Bech32.entities.NSec +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.phoenix.data.WalletId +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import press.mantra.compose.identity.CredentialParser +import press.mantra.compose.identity.CredentialProblem +import press.mantra.compose.identity.IdentityWriter +import press.mantra.compose.identity.SignInCredential +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.SignInToProfileUIState +import press.mantra.compose.ui.view.state.form.SignInToProfileFormState +/** + * Signs in with something the user already has: a recovery phrase, an nsec, or -- to + * look and not sign -- an npub. + * + * The three writers are injected as functions rather than reached for, so the commit + * can be driven in a test without a key store, and because the recovery-phrase writer + * is `SovereignWalletViewModel.writeSeed`, which lives in another view model and drives + * a state machine of its own. + * + * What this does not do is decide where the user goes next. It writes the credential, plants + * the placeholder account, and hands the new id to [onSignedIn]; the nav host re-lists + * identities, selects that one and goes to startup, which is the same tail the create + * flow uses. The **order** inside [commit] is load-bearing: the account has to be in the + * database before the identity is activated, because `NavigationViewModel.observeProfile` + * starts reading the moment the identity flow emits, and an account that is not there + * yet reads as "go and make one". + */ class SignInToProfileViewModel( - initialWriteNewNoteUIState: press.mantra.compose.ui.view.state.SignInToProfileUIState, - val signInToProfileFormState: press.mantra.compose.ui.view.state.form.SignInToProfileFormState = press.mantra.compose.ui.view.state.form.SignInToProfileFormState(), - val nostrRepository: press.mantra.compose.repository.NostrRepository -): ViewModel() { + private val nostrRepository: NostrRepository, + private val writeNostrKey: suspend (PrivateKey) -> IdentityWriter.WriteNostrCredentialResult, + private val writeNostrPublicKey: suspend (HexKey) -> IdentityWriter.WriteNostrCredentialResult, + private val writeRecoveryPhrase: ( + words: List, + onWritten: (WalletId) -> Unit, + onError: (WritingSeedState.Error) -> Unit, + ) -> Unit, + val signInToProfileFormState: SignInToProfileFormState = SignInToProfileFormState(), +) : ViewModel() { + companion object { private const val TAG = "SignInToProfileViewModel" fun factory( - initialWriteNewNoteUIState: press.mantra.compose.ui.view.state.SignInToProfileUIState, - nostrRepository: press.mantra.compose.repository.NostrRepository + nostrRepository: NostrRepository, + writeNostrKey: suspend (PrivateKey) -> IdentityWriter.WriteNostrCredentialResult, + writeNostrPublicKey: suspend (HexKey) -> IdentityWriter.WriteNostrCredentialResult, + writeRecoveryPhrase: ( + words: List, + onWritten: (WalletId) -> Unit, + onError: (WritingSeedState.Error) -> Unit, + ) -> Unit, ): ViewModelProvider.Factory = viewModelFactory { initializer { SignInToProfileViewModel( - initialWriteNewNoteUIState, - nostrRepository = nostrRepository + nostrRepository = nostrRepository, + writeNostrKey = writeNostrKey, + writeNostrPublicKey = writeNostrPublicKey, + writeRecoveryPhrase = writeRecoveryPhrase, ) } } @@ -39,51 +80,103 @@ class SignInToProfileViewModel( private val logger = Logger.withTag(TAG) - val isActionPending: MutableState = mutableStateOf(false) + val uiState: MutableState = mutableStateOf(SignInToProfileUIState.InputPrompt) - var signInToProfileUIState: MutableState = mutableStateOf( - initialWriteNewNoteUIState - ) + /** Why the field's contents were refused, shown as its supporting text; null when they were not. */ + val inputProblem: MutableState = mutableStateOf(null) - fun validateCredential(): Entity? { - return signInToProfileFormState.textFieldForm.textFieldState.text.toString().let { credentialText -> - if (credentialText.isBlank()) { - signInToProfileFormState.textFieldForm.errorMessage.value = "Please input a credential" - return null - } else { - signInToProfileFormState.textFieldForm.errorMessage.value = null + /** Reads the field and either moves to [SignInToProfileUIState.Confirm] or marks the field. */ + fun recognise() { + when (val result = CredentialParser.parse(signInToProfileFormState.textFieldForm.textFieldState.text.toString())) { + is CredentialParser.Result.Refused -> inputProblem.value = result.problem + is CredentialParser.Result.Recognised -> { + inputProblem.value = null + uiState.value = SignInToProfileUIState.Confirm(result.credential) } - - Nip19Parser.uriToRoute(signInToProfileFormState.textFieldForm.textFieldState.text.toString())?.entity } } - fun signInToAccount( - ) { - logger.d { "signInToAccount" } - validateCredential().let { credentialEntity -> - isActionPending.value = true + /** Back to the field, keeping what was typed. */ + fun useADifferentKey() { + uiState.value = SignInToProfileUIState.InputPrompt + } - viewModelScope.launch(Dispatchers.IO) { - when (credentialEntity) { - is NPub -> { - nostrRepository.signInToProfile( - publicKey = credentialEntity.hex - ) - } - is NSec -> { - nostrRepository.signInToProfile( - publicKey = credentialEntity.toPubKeyHex() - ) - } - else -> { - // TODO: Might want to give reasons for the error. - signInToProfileUIState.value = press.mantra.compose.ui.view.state.SignInToProfileUIState.Error - } - } + /** The outcome of [commit], for the screen to act on and the tests to assert. */ + sealed interface Outcome { + data class SignedIn(val id: WalletId) : Outcome + /** @param alreadyHere for [CredentialProblem.AlreadyOnThisDevice], the id the key is listed under. */ + data class Failed(val problem: CredentialProblem, val alreadyHere: WalletId? = null) : Outcome + } + + private suspend fun write(what: String, writer: suspend () -> IdentityWriter.WriteNostrCredentialResult): Outcome = try { + when (val result = writer()) { + is IdentityWriter.WriteNostrCredentialResult.Written -> Outcome.SignedIn(result.id) + is IdentityWriter.WriteNostrCredentialResult.AlreadyExists -> Outcome.Failed(CredentialProblem.AlreadyOnThisDevice, alreadyHere = result.id) + is IdentityWriter.WriteNostrCredentialResult.CannotLoadKeys -> Outcome.Failed(CredentialProblem.CouldNotWrite) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.e("could not write the $what", e) + Outcome.Failed(CredentialProblem.CouldNotWrite) + } + + /** + * Writes the credential to its store, then plants the placeholder account that tells + * the navigation machine this pubkey has a history to fetch rather than a profile to + * create. Suspends until both are done or one has failed. The plant is idempotent by + * pubkey, which is what lets the nsec of a key held read-only sign in over it. + */ + suspend fun commit(credential: SignInCredential): Outcome { + val written: Outcome = when (credential) { + is SignInCredential.NostrSecret -> write("nostr key") { writeNostrKey(credential.privateKey) } + + is SignInCredential.NostrPublicKey -> write("nostr public key") { writeNostrPublicKey(credential.nostrPublicKey) } + + is SignInCredential.RecoveryPhrase -> { + // Callback-shaped writer, awaited: the seed writer drives WritingSeedState and + // reports through two callbacks, exactly one of which fires. + val outcome = CompletableDeferred() + writeRecoveryPhrase( + credential.words, + { id -> outcome.complete(Outcome.SignedIn(id)) }, + { error -> + outcome.complete( + when (error) { + is WritingSeedState.Error.SeedAlreadyExists -> Outcome.Failed(CredentialProblem.AlreadyOnThisDevice, alreadyHere = error.id) + is WritingSeedState.Error.CannotLoadSeedMap, + is WritingSeedState.Error.Generic -> Outcome.Failed(CredentialProblem.CouldNotWrite) + } + ) + }, + ) + outcome.await() } } + if (written is Outcome.SignedIn) { + // Before the identity is activated -- see the class comment. + nostrRepository.signInToProfile(publicKey = credential.nostrPublicKey) + } + return written } -} \ No newline at end of file + + /** [commit] from the confirm button, moving the screen through Committing to wherever it ends. */ + fun confirm(credential: SignInCredential, onSignedIn: (WalletId) -> Unit) { + if (uiState.value is SignInToProfileUIState.Committing) return + uiState.value = SignInToProfileUIState.Committing + + viewModelScope.launch(Dispatchers.IO) { + when (val outcome = commit(credential)) { + is Outcome.SignedIn -> withContext(Dispatchers.Main) { onSignedIn(outcome.id) } + is Outcome.Failed -> uiState.value = SignInToProfileUIState.Error(outcome.problem, outcome.alreadyHere) + } + } + } + + /** From the error state back to the field. */ + fun retry() { + uiState.value = SignInToProfileUIState.InputPrompt + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignOutViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignOutViewModel.kt new file mode 100644 index 00000000..f9657b88 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignOutViewModel.kt @@ -0,0 +1,106 @@ +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 kotlinx.coroutines.CoroutineExceptionHandler +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.launch +import kotlinx.coroutines.withContext +import press.mantra.compose.identity.ForgetIdentity +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.IdentityWriter +import press.mantra.compose.repository.NostrRepository + +/** + * Signing out of a read-only identity, which is forgetting it: there is nothing on the + * device to lose, so it is the first real sign out in the app. The general case -- a + * seed, with funds behind it -- stays pending, for the reason docs/nsec-sign-in.md gives. + * + * Two screens drive this with the same state: the profile tab's *sign out*, and the + * not-found screen's *use a different key*, which is the same act reached from the other + * end of the identity's life. The sequence is [ForgetIdentity]'s; this owns only the + * confirmation and the in-flight state, the way `NostrSecretViewModel` does for an nsec. + */ +class SignOutViewModel( + private val activeIdentityStateFlow: StateFlow, + private val nostrRepository: NostrRepository, + private val forgetNostrCredential: suspend (Identity) -> IdentityWriter.ForgetNostrCredentialResult, + private val hideIdentityMetadata: suspend (Identity) -> Unit, +) : ViewModel() { + + sealed interface State { + data object Idle : State + data object Confirming : State + data object InProgress : State + data object Failed : State + } + + private val logger = Logger.withTag(TAG) + + private val _state = MutableStateFlow(State.Idle) + val state = _state.asStateFlow() + + /** The npub the confirmation names, so the user sees which identity is leaving. */ + val nostrPublicKey: String? get() = activeIdentityStateFlow.value?.nostrPublicKey + + fun askToSignOut() { + if (_state.value is State.InProgress) return + _state.value = State.Confirming + } + + fun cancel() { + if (_state.value is State.InProgress) return + _state.value = State.Idle + } + + /** [onSignedOut] runs on the main thread once the device holds nothing for the identity. */ + fun signOut(onSignedOut: () -> Unit) { + val identity = activeIdentityStateFlow.value ?: return + if (_state.value is State.InProgress) return + _state.value = State.InProgress + + viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, throwable -> + logger.e("could not sign out", throwable) + _state.value = State.Failed + }) { + when (ForgetIdentity.forget(identity, nostrRepository, forgetNostrCredential, hideIdentityMetadata)) { + is ForgetIdentity.Outcome.Forgotten -> { + _state.value = State.Idle + withContext(Dispatchers.Main) { onSignedOut() } + } + is ForgetIdentity.Outcome.WalletAttached, + is ForgetIdentity.Outcome.CannotForget -> { + _state.value = State.Failed + } + } + } + } + + companion object { + private const val TAG = "SignOutViewModel" + + fun factory( + activeIdentityStateFlow: StateFlow, + nostrRepository: NostrRepository, + forgetNostrCredential: suspend (Identity) -> IdentityWriter.ForgetNostrCredentialResult, + hideIdentityMetadata: suspend (Identity) -> Unit, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + SignOutViewModel( + activeIdentityStateFlow = activeIdentityStateFlow, + nostrRepository = nostrRepository, + forgetNostrCredential = forgetNostrCredential, + hideIdentityMetadata = hideIdentityMetadata, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt index 5229bd03..1b0ea520 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt @@ -12,13 +12,19 @@ import co.touchlab.kermit.Logger import fr.acinq.lightning.logging.error import fr.acinq.phoenix.PhoenixBusiness import fr.acinq.phoenix.PhoenixGlobal -import fr.acinq.phoenix.data.ActiveWallet +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.phoenix.data.DecryptNostrCredentialsResult +import fr.acinq.phoenix.data.DecryptNostrKeysResult import fr.acinq.phoenix.data.DecryptSeedResult import fr.acinq.phoenix.data.ElectrumConfig import fr.acinq.phoenix.data.ListWalletState import fr.acinq.phoenix.data.UserWallet import fr.acinq.phoenix.data.WalletId import fr.acinq.phoenix.managers.DataStoreManager +import fr.acinq.phoenix.managers.NostrCredentialManager +import fr.acinq.phoenix.security.NostrCredential +import fr.acinq.phoenix.managers.nostrPrivateKey import fr.acinq.phoenix.utils.preferences.GlobalPrefs import fr.acinq.phoenix.utils.preferences.UserWalletMetadata import kotlinx.coroutines.CoroutineExceptionHandler @@ -33,6 +39,11 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.IdentityKind +import press.mantra.compose.identity.IdentityWriter +import press.mantra.compose.identity.SeedCredentials +import press.mantra.compose.identity.StoredIdentity sealed class WritingSeedState { data object Init : WritingSeedState() @@ -41,11 +52,19 @@ sealed class WritingSeedState { sealed class Error : WritingSeedState() { data class Generic(val cause: Throwable) : Error() data object CannotLoadSeedMap: Error() - data object SeedAlreadyExists: Error() + /** [id] is the wallet's, so that a refusal from inside the app can offer to switch to it. */ + data class SeedAlreadyExists(val id: WalletId): Error() } } expect fun updateBusinessActiveInUI(walletId: WalletId) + +/** + * Stops the node running for [walletId], if one is. The inverse of what the startup + * screen's mnemonic branch starts; an `expect` for the reason [updateBusinessActiveInUI] + * is, which is that `BusinessManager` is a per-platform object. + */ +expect fun stopPlatformBusiness(walletId: WalletId) expect fun loadAndDecryptSeed(phoenixGlobal: PhoenixGlobal): DecryptSeedResult expect fun getAvailableWalletsMeta(phoenixGlobal: PhoenixGlobal): Flow> @@ -63,28 +82,44 @@ expect suspend fun saveAvailableWalletMeta( class SovereignWalletViewModel( val phoenixGlobal: PhoenixGlobal, // We might end up only using the machankuraWalletRepository in the future where we send the walletId in each request. + /** Injected so that a switch can be pinned in a test without a node to stop. */ + private val stopBusiness: (WalletId) -> Unit = ::stopPlatformBusiness, ): ViewModel() { private val log = Logger.withTag("SovereignWalletViewModel") // A StateFlow, not a `mutableStateOf`, and it has to stay one. This view model is built by // `viewModel()` during composition, so a Compose state created in its constructor is created // inside that composition's snapshot -- and a write from another thread that lands before the - // composition is applied is discarded. `listAvailableWallets` runs from `init` and, on a device + // composition is applied is discarded. `listIdentities` runs from `init` and, on a device // with no seed file, answers in about four milliseconds, which is squarely inside that window: // the write to Success was lost and the app sat on "Decrypting..." for ever. A StateFlow has no // snapshot to belong to, so the same write from the same thread at the same moment is seen. private val _listWalletState = MutableStateFlow(ListWalletState.Init) val listWalletState = _listWalletState.asStateFlow() - private val _availableWallets = MutableStateFlow>(emptyMap()) - val availableWallets = _availableWallets.asStateFlow() + /** + * Every profile this device holds something for -- the credentials file, with each + * seed attached to the profile its key derives -- keyed by the id the preferences + * and metadata use. + */ + private val _availableIdentities = MutableStateFlow>(emptyMap()) + val availableIdentities = _availableIdentities.asStateFlow() private val _desiredWalletId = MutableStateFlow(null) val desiredWalletId = _desiredWalletId.asStateFlow() val startWalletImmediately = MutableStateFlow(true) - private val _activeWalletInUI = MutableStateFlow(null) - val activeWalletInUI = _activeWalletInUI.asStateFlow() + /** + * The identity the app is running as, or null while nothing has been started. + * + * This replaces what used to be a flow of the active *wallet*: every reader of it + * only ever wanted the nostr key, and reached it through the node -- + * `business.walletManager.keyManager.value.nostrPrivateKey()`. An [Identity] + * carries the key directly, so a reader no longer needs a node to exist, and an + * identity that never has one (a bare nsec) reads the same as one that does. + */ + private val _activeIdentity = MutableStateFlow(null) + val activeIdentity = _activeIdentity.asStateFlow() @OptIn(ExperimentalCoroutinesApi::class) val exchangeRates = phoenixGlobal.currencyManager.ratesFlow @@ -112,73 +147,206 @@ class SovereignWalletViewModel( init { - listAvailableWallets(onDone = {}) + listIdentities(onDone = {}) } - fun setActiveWallet(walletId: WalletId, business: PhoenixBusiness) { - val dataStoreManager = DataStoreManager(business) - - val userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId = walletId) - val internalPrefs = dataStoreManager.loadInternalPrefsForWallet( walletId = walletId) - _activeWalletInUI.value = ActiveWallet(id = walletId, business = business, userPrefs = userPrefs, internalPrefs = internalPrefs) - updateBusinessActiveInUI(walletId) + /** + * Makes [identity] the one the app runs as. Everything reading [activeIdentity] + * follows, and the id is saved as the default: every activation goes through here + * -- startup for all three kinds, the sign-in and create tails through startup -- so + * the default is always the profile most recently open, and a cold boot with several + * opens that one. It was read by startup and written by nothing, which made every + * cold boot with two profiles the selector. + */ + fun setActiveIdentity(identity: Identity) { + _activeIdentity.value = identity + rememberDefault { saveDefaultWallet(identity.id) } // scheduleAutoLock() } - fun listAvailableWallets(onDone: () -> Unit) { + /** + * Forgets which profile opens on launch. For the forget tails: the default is the + * one memory of an identity that would otherwise outlive it, and a null read is + * better than a miss that happens to be handled. + */ + fun forgetDefaultIdentity() { + rememberDefault { clearDefaultWallet() } + } + + /** A default that could not be written is a selector on the next boot, not a crash now. */ + private fun rememberDefault(write: suspend GlobalPrefs.() -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + runCatching { getGlobalPrefs().write() } + .onFailure { log.e("could not record which profile is open", it) } + } + } + + /** + * Activates a profile with a wallet attached, once its node has started. + * + * The key is the credential's, not the node's. The node still derives it -- and is + * asked to, as a cross-check -- but the credentials file is what the app signs with + * now, and a node that disagrees with it is the one corruption worth refusing to run + * under: the throw lands in the startup view model's handler and shows as a startup + * error. It cannot happen for a file the repair wrote, since that derived the key + * from the same seed. + */ + fun setActiveWallet(stored: StoredIdentity.Mnemonic, business: PhoenixBusiness) { + val walletId = stored.id + val keyManager = business.walletManager.keyManager.value + if (keyManager == null) { + // `startNewBusiness` loads the wallet before it reports success, so a business + // handed here without a key manager is a programming error rather than a state + // to route around. Leaving the identity unset sends the UI back to startup. + log.e { "business for wallet=$walletId started without a key manager" } + return + } + check(keyManager.nostrPrivateKey() == stored.privateKey) { + "the node for wallet=$walletId derives a different nostr key than its credential holds" + } + val dataStoreManager = DataStoreManager(business) + + setActiveIdentity( + Identity.signing( + id = walletId, + kind = IdentityKind.Mnemonic, + nostrPrivateKey = stored.privateKey, + userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId = walletId), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(walletId = walletId), + business = business, + ) + ) + updateBusinessActiveInUI(walletId) + } + + /** + * Reads both stores, repairs the credentials file so that every seed's key is in it, + * and publishes the merged list, registering metadata for any id seen for the first + * time. + * + * A failure in either file is surfaced, not skipped: a corrupt `nostr-keys.dat` would + * otherwise drop every imported identity from the list without a word, and the seed + * file has always been handled this way. A failure of the *repair* is the one thing + * logged and not surfaced -- the listing derives around it, see [SeedCredentials]. + */ + fun listIdentities(onDone: () -> Unit) { viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e -> // log.error("error when initialising startup-view: ", e) _listWalletState.value = ListWalletState.Error.Generic(e) }) { - when (val result = loadAndDecryptSeed(phoenixGlobal)) { + val wallets: Map = when (val result = loadAndDecryptSeed(phoenixGlobal)) { is DecryptSeedResult.Failure.SerializationError -> { log.error {"cannot deserialize seed file: "} _listWalletState.value = ListWalletState.Error.Serialization + return@launch } is DecryptSeedResult.Failure.DecryptionError -> { log.e("cannot decrypt seed file: ", throwable = result.cause) _listWalletState.value = ListWalletState.Error.DecryptionError.GeneralException(result.cause) + return@launch } is DecryptSeedResult.Failure.KeyStoreFailure -> { log.e("key store failure: ", throwable = result.cause) _listWalletState.value = ListWalletState.Error.DecryptionError.KeystoreFailure(result.cause) + return@launch } is DecryptSeedResult.Failure.SeedFileUnreadable -> { log.e("aborting, unreadable seed file") _listWalletState.value = ListWalletState.Error.Generic(null) + return@launch } is DecryptSeedResult.Failure.SeedInvalid -> { log.e("aborting, seed is invalid") _listWalletState.value = ListWalletState.Error.Generic(null) + return@launch } + is DecryptSeedResult.Failure.SeedFileNotFound -> emptyMap() + is DecryptSeedResult.Success -> result.userWalletsMap + } - is DecryptSeedResult.Failure.SeedFileNotFound -> { - _listWalletState.value = ListWalletState.Success - _availableWallets.value = emptyMap() - } - - is DecryptSeedResult.Success -> { - - val metadataMap = getAvailableWalletsMeta(phoenixGlobal).first() - result.userWalletsMap.forEach { (walletId, _) -> - if (metadataMap[walletId] == null) { - saveAvailableWalletMeta( - phoenixGlobal = phoenixGlobal, - walletId = walletId, - name = null, - avatar = press.mantra.compose.ui.composable.widgets.wallet.WalletAvatars.list.random(), - isHidden = false - ) - } - } - _availableWallets.value = result.userWalletsMap - _listWalletState.value = ListWalletState.Success - viewModelScope.launch(Dispatchers.Main) { - onDone() + // Before the read, and here rather than in the reader: a listing that writes + // would be a surprise, and this runs from init, before anything else touches + // either file. An old file that cannot be read is left alone and reported the + // way an unreadable credentials file is, so the user sees the error rather + // than silently losing every imported identity. + when (val migration = NostrCredentialManager.migrateFromNostrKeys(phoenixGlobal)) { + is NostrCredentialManager.MigrationResult.Migrated -> log.i { "migrated ${migration.count} nostr key(s) into the credentials file" } + is NostrCredentialManager.MigrationResult.NotNeeded -> Unit + is NostrCredentialManager.MigrationResult.Failed -> { + log.e { "nostr-keys.dat could not be migrated: ${migration.failure}" } + _listWalletState.value = when (val failure = migration.failure) { + is DecryptNostrKeysResult.Failure.SerializationError -> ListWalletState.Error.Serialization + is DecryptNostrKeysResult.Failure.DecryptionError -> ListWalletState.Error.DecryptionError.GeneralException(failure.cause) + is DecryptNostrKeysResult.Failure.KeyStoreFailure -> ListWalletState.Error.DecryptionError.KeystoreFailure(failure.cause) + is DecryptNostrKeysResult.Failure.FileUnreadable, + is DecryptNostrKeysResult.Failure.FileNotFound -> ListWalletState.Error.Generic(null) } + return@launch } } + + val readCredentials: Map = when (val result = NostrCredentialManager.loadAndDecrypt(phoenixGlobal)) { + is DecryptNostrCredentialsResult.Failure.SerializationError -> { + log.e { "cannot deserialize nostr credentials file" } + _listWalletState.value = ListWalletState.Error.Serialization + return@launch + } + is DecryptNostrCredentialsResult.Failure.DecryptionError -> { + log.e("cannot decrypt nostr credentials file: ", throwable = result.cause) + _listWalletState.value = ListWalletState.Error.DecryptionError.GeneralException(result.cause) + return@launch + } + is DecryptNostrCredentialsResult.Failure.KeyStoreFailure -> { + log.e("key store failure: ", throwable = result.cause) + _listWalletState.value = ListWalletState.Error.DecryptionError.KeystoreFailure(result.cause) + return@launch + } + is DecryptNostrCredentialsResult.Failure.FileUnreadable -> { + log.e("aborting, unreadable nostr credentials file") + _listWalletState.value = ListWalletState.Error.Generic(null) + return@launch + } + is DecryptNostrCredentialsResult.Failure.FileNotFound -> emptyMap() + is DecryptNostrCredentialsResult.Success -> result.credentials + } + + // The second write a listing makes, and for the same reason as the first: a + // seed's key belongs in the credentials file, and every seed from before that + // was so gets its entry here, once. The listing uses what the repair returns -- + // the repaired map, or the one read if the write failed -- so a seed is listed + // either way. + val credentials = when (val repair = SeedCredentials.reconcile(log, phoenixGlobal, wallets, readCredentials)) { + is SeedCredentials.Result.Written -> { + log.i { "wrote the credential of ${repair.count} seed(s) into the credentials file" } + repair.credentials + } + is SeedCredentials.Result.NotNeeded -> repair.credentials + is SeedCredentials.Result.Failed -> { + log.e("the credentials of ${wallets.size} seed(s) could not be repaired; listing by derivation", repair.cause) + repair.credentials + } + } + + val identities = StoredIdentity.merge(wallets, credentials) + + val metadataMap = getAvailableWalletsMeta(phoenixGlobal).first() + identities.keys.forEach { walletId -> + if (metadataMap[walletId] == null) { + saveAvailableWalletMeta( + phoenixGlobal = phoenixGlobal, + walletId = walletId, + name = null, + avatar = press.mantra.compose.ui.composable.widgets.wallet.WalletAvatars.list.random(), + isHidden = false + ) + } + } + _availableIdentities.value = identities + _listWalletState.value = ListWalletState.Success + viewModelScope.launch(Dispatchers.Main) { + onDone() + } } } @@ -186,7 +354,7 @@ class SovereignWalletViewModel( // // TODO: Run this through an expect... // viewModelScope.launch { // autoLockHandler.removeCallbacksAndMessages(null) -// val activeUserPrefs = activeWalletInUI.first()?.userPrefs ?: return@launch +// val activeUserPrefs = activeIdentity.first()?.userPrefs ?: return@launch // // val biometricLockEnabled = activeUserPrefs.getLockBiometricsEnabled.first() // val customPinLockEnabled = activeUserPrefs.getLockPinEnabled.first() @@ -198,21 +366,44 @@ class SovereignWalletViewModel( // } // } - /** Clears the active wallet and signals the startup screen to load the given [walletId]. */ - fun switchToWallet(walletId: WalletId) { - _desiredWalletId.value = walletId - _activeWalletInUI.value = null + /** + * Makes [id] the identity to open next and clears the active one, which is the whole + * of a switch: the navigation observer sends a null identity to startup, startup + * opens [id] through the lock gate, and the machine routes it from its account. + * Everything reading [activeIdentity] is cancelled by the null and rebuilt by the + * activation -- the notary, the pumps, the live subscriptions, the relay observer. + * See docs/multiple-profiles.md, Phase 2. + * + * [startWalletImmediately] goes back to true: a switch is the user saying which one, + * and the flag was only ever the user saying *show me the list*. Nothing set it back + * before because nothing could switch. + * + * The identity is cleared **before** the node is stopped: clearing cancels every + * collector that could reach the business, so the stop finds nothing reading it. + * The test is `business != null`, not the kind -- whether the profile being left has + * a node behind it is the fact, and the kind is how it currently comes to be true. + * `previous.id` is the wallet's id when a wallet is attached, which is why the + * manager can take it. + */ + fun switchToIdentity(id: WalletId) { + val previous = _activeIdentity.value + _desiredWalletId.value = id + startWalletImmediately.value = true + _activeIdentity.value = null + if (previous?.business != null) { + viewModelScope.launch(Dispatchers.IO) { stopBusiness(previous.id) } + } } - /** Clears the active wallet. It does not affect [desiredWalletId]. The UI may still auto-open a specific wallet, if [desiredWalletId] is not null. */ + /** Clears the active identity. It does not affect [desiredWalletId]. The UI may still auto-open a specific wallet, if [desiredWalletId] is not null. */ fun clearActiveWallet() { - _activeWalletInUI.value = null + _activeIdentity.value = null } - /** Resets the active wallet and [desiredWalletId]. The UI will redirect to the startup screen with the wallets selector prompt. */ + /** Resets the active identity and [desiredWalletId]. The UI will redirect to the startup screen with the wallets selector prompt. */ fun resetToSelector() { _desiredWalletId.value = null - _activeWalletInUI.value = null + _activeIdentity.value = null startWalletImmediately.value = false } @@ -247,10 +438,10 @@ class SovereignWalletViewModel( onSeedWriteError: (WritingSeedState.Error) -> Unit = {} ) { if (writingState is WritingSeedState.Error || writingState is WritingSeedState.WrittenToDisk) { - // A previous attempt finished; reset so platformWriteSeed does not silently skip this one. + // A previous attempt finished; reset so writeMnemonic does not silently skip this one. writingState = WritingSeedState.Init } - platformWriteSeed( + IdentityWriter.writeMnemonic( log = log, phoenixGlobal = phoenixGlobal, globalPrefs = getGlobalPrefs(), @@ -274,6 +465,55 @@ class SovereignWalletViewModel( ) } + /** + * Writes a bare nostr key as a new identity, with the same initialisation options + * [writeSeed] applies. Suspends; the caller is the sign-in view model's coroutine. + */ + suspend fun writeNostrKey(privateKey: PrivateKey): IdentityWriter.WriteNostrCredentialResult = + IdentityWriter.writeNostrKey( + log = log, + phoenixGlobal = phoenixGlobal, + globalPrefs = getGlobalPrefs(), + privateKey = privateKey, + isTorEnabled = isTorEnabled.value, + customElectrumServer = customElectrumServer.value, + ) + + /** Writes a public key as a read-only identity, with the same options. */ + suspend fun writeNostrPublicKey(nostrPublicKey: HexKey): IdentityWriter.WriteNostrCredentialResult = + IdentityWriter.writeNostrPublicKey( + log = log, + phoenixGlobal = phoenixGlobal, + globalPrefs = getGlobalPrefs(), + nostrPublicKey = nostrPublicKey, + isTorEnabled = isTorEnabled.value, + customElectrumServer = customElectrumServer.value, + ) + + /** The inverse of both writers above. Only for a credential; see [IdentityWriter.forgetNostrCredential]. */ + suspend fun forgetNostrCredential(identity: Identity): IdentityWriter.ForgetNostrCredentialResult = + IdentityWriter.forgetNostrCredential( + log = log, + phoenixGlobal = phoenixGlobal, + id = identity.id, + nostrPublicKey = identity.nostrPublicKey, + ) + + /** + * Hides a forgotten identity's entry in the wallet metadata rather than deleting it: + * the metadata store has no delete, and `isHidden` is what the selector filters on. + */ + suspend fun hideIdentityMetadata(identity: Identity) { + val existing = getAvailableWalletsMeta(phoenixGlobal).first()[identity.id] ?: return + saveAvailableWalletMeta( + phoenixGlobal = phoenixGlobal, + walletId = identity.id, + name = existing.name, + avatar = existing.avatar, + isHidden = true, + ) + } + companion object { private const val TAG = "SovereignWalletViewModel" fun factory( @@ -287,18 +527,3 @@ class SovereignWalletViewModel( } } } - -expect fun platformWriteSeed( - log: Logger, - phoenixGlobal: PhoenixGlobal, - globalPrefs: GlobalPrefs, - writingState: WritingSeedState, - viewModelScope: CoroutineScope, - mnemonics: List, - onWritingSeedError: (WritingSeedState.Error) -> Unit, - onWritingSeedStateWriting: (WritingSeedState.Writing) -> Unit, - isRestoringWallet: Boolean, - isTorEnabled: Boolean, - customElectrumServer: ElectrumConfig.Custom?, - onSeedWritten: (WalletId) -> Unit -) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt index c4fe428c..ef1e7c8b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt @@ -22,6 +22,7 @@ import press.mantra.compose.repository.RelayRepository import co.touchlab.kermit.Logger import com.vitorpamplona.negentropy.Negentropy import com.vitorpamplona.negentropy.storage.StorageVector +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd @@ -29,8 +30,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd -import fr.acinq.phoenix.data.ActiveWallet -import fr.acinq.phoenix.managers.nostrPrivateKey +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.toKeyPair import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -51,15 +52,17 @@ import kotlinx.coroutines.flow.transformWhile import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.NonCancellable +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock +import press.mantra.compose.nostr.SignInSync import kotlinx.coroutines.sync.withPermit import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds class SynchronizationViewModel( - val activeWalletStateFlow: StateFlow, + val activeIdentityStateFlow: StateFlow, val nostrRepository: NostrRepository, val chatRepository: ChatRepository, val relayRepository: RelayRepository, @@ -67,7 +70,7 @@ class SynchronizationViewModel( ): ViewModel() { val relaysSocketManager = RelaysSocketManager( - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, nostrSocketClientFactory = DefaultNostrSocketClientFactory, cachingImportRepository = CachingImportRepository.NO_OP_CACHING_IMPORT_REPOSITORY, relayRepository = relayRepository, @@ -130,7 +133,7 @@ class SynchronizationViewModel( private val mutex = Mutex() fun factory( - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, nostrRepository: NostrRepository, chatRepository: ChatRepository, relayRepository: RelayRepository, @@ -138,7 +141,7 @@ class SynchronizationViewModel( ): ViewModelProvider.Factory = viewModelFactory { initializer { SynchronizationViewModel( - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, nostrRepository = nostrRepository, chatRepository = chatRepository, relayRepository = relayRepository, @@ -188,12 +191,12 @@ class SynchronizationViewModel( init { scope.launch { - activeWalletStateFlow.collectLatest { activeWallet -> - logger.d("Synchronization ActiveWallet: $activeWallet") - activeWallet?.business?.walletManager?.keyManager?.value?.nostrPrivateKey()?.let { nostrPrivateKey -> - val keyPair = KeyPair( - privKey = nostrPrivateKey.value.toByteArray() - ) + activeIdentityStateFlow.collectLatest { identity -> + logger.d("Synchronization identity: $identity") + identity?.let { + // Through the helper, never by hand: a read-only identity has no private + // key, and quartz's KeyPair given neither key generates a fresh one. + val keyPair = identity.toKeyPair() // Anything a previous run left mid-flight is ours to retry: it was flipped to // "processing" before the publish and nothing observes that status, so @@ -214,13 +217,31 @@ class SynchronizationViewModel( // throw in one must not take the other two down with it for the rest of the // session. Each pump also guards its own per-request body (see guardPump). supervisorScope { - launch(Dispatchers.IO) { observePendingBroadcastNostrEventRequests(keyPair) } launch(Dispatchers.IO) { observePendingSyncNostrEventRequests(keyPair) } launch(Dispatchers.IO) { observePendingNegentropySynchronizeRequests(keyPair) } - // Runs until cancelled rather than draining a queue, but it belongs - // here for the same reason the pumps do: it needs the active wallet's - // key pair, and a wallet switch must tear it down. - launch(Dispatchers.IO) { liveSubscriptionManager.observe(keyPair) } + // A read-only identity runs the two pumps that read and neither of + // the two that write. The rule Phase 5 of docs/npub-sign-in.md + // leans on: such an identity never sends anything to a relay, not a + // signature and not a copy. The broadcast pump would find nothing -- + // nothing is ever signed -- and the live subscriptions are the + // gift-wrap inbox and the group-membership follow, both fetching + // things this identity cannot open. Not asking is not an + // optimisation; it is the identity not asking for what it cannot use. + if (identity.canSign) { + // The wraps that arrived while another profile was open, or + // while this one held no key: opened now, before the live inbox + // starts, so the two are not opening the same wrap at once. Both + // are idempotent by event id; the order is about not doing the + // work twice. See docs/multiple-profiles.md, Phase 6. + runCatching { nostrRepository.reopenInbox(keyPair) } + .onSuccess { if (it > 0) logger.i { "Reopened $it gift wrap(s) that arrived while this profile was not open" } } + .onFailure { logger.e("Could not reopen the inbox", it) } + launch(Dispatchers.IO) { observePendingBroadcastNostrEventRequests(keyPair) } + // Runs until cancelled rather than draining a queue, but it + // belongs here for the same reason the pumps do: it needs the + // active wallet's key pair, and a wallet switch must tear it down. + launch(Dispatchers.IO) { liveSubscriptionManager.observe(keyPair) } + } } } } @@ -233,7 +254,8 @@ class SynchronizationViewModel( ): Unit = coroutineScope { logger.i { "observePendingSyncNostrEventRequests" } - nostrRepository.observePendingSynchronizeNostrEventRequests().distinctUntilChanged().collect { synchronizeNostrEventRequestOrNull -> + // This identity's requests and the ones nobody owns; another identity's wait for it. + nostrRepository.observePendingSynchronizeNostrEventRequests(keyPair.pubKey.toHexKey()).distinctUntilChanged().collect { synchronizeNostrEventRequestOrNull -> synchronizeNostrEventRequestOrNull?.let { synchronizeNostrEventRequest -> guardPump("sync request ${synchronizeNostrEventRequest.id}") { logger.i("synchronizeNostrEventRequest: $synchronizeNostrEventRequest") @@ -279,6 +301,7 @@ class SynchronizationViewModel( activeKeyPair = keyPair // TODO: + Relays.eventPublishRelaySet.map { normalizedRelayUrl -> normalizedRelayUrl.url } ) + followSignInHop(it, synchronizeNostrEventRequest) } } } @@ -293,6 +316,7 @@ class SynchronizationViewModel( activeKeyPair = keyPair // TODO: + Relays.eventPublishRelaySet.map { normalizedRelayUrl -> normalizedRelayUrl.url } ) + followSignInHop(nostrEvent, synchronizeNostrEventRequest) } } is NostrIncomingMessage.EoseMessage -> { @@ -330,6 +354,16 @@ class SynchronizationViewModel( synchronizeNostrEventRequest.relayURL ) } + // And write down that the relay has finished, on every exit + // too. Before this a request that got EOSE with nothing stayed + // at `sent` for good, and "still searching" and "searched, + // found nothing" were the same row -- which is why the screen + // waiting on a sign-in sync could never say the second thing. + // Conditional on `sent` in the DAO: an event handler that has + // already moved the row to `processed` wins. + runCatching { + nostrRepository.synchronizeNostrEventRequestCompleted(synchronizeNostrEventRequest) + }.onFailure { logger.e("Failed to record sync completion", it) } } } } @@ -339,12 +373,50 @@ class SynchronizationViewModel( } } + /** + * Sign-in syncs this session has already followed a hop for, by account. Every + * bootstrap relay that holds the user's kind 10002 answers with it, and one hop per + * account is enough. + */ + private val signInHopsFollowed = mutableSetOf() + private val signInHopsMutex = Mutex() + + /** + * The one hop the sign-in sync follows. A level-0 request that brings back the user's + * own kind 10002 is asked again, at level 1, at the relays that list says they write + * to, less the bootstrap set already asked. That is the outbox model doing what it is + * for: the indexers know *where* the user publishes, and the user's own relays are + * where the rest of their lists are authoritative. One hop only -- a level-1 request + * never re-enters here -- because past the user's own relays lies the feed, not the + * profile. + */ + private suspend fun followSignInHop( + nostrEvent: press.mantra.compose.database.model.NostrEvent, + request: press.mantra.compose.database.model.SynchronizeNostrEventRequest, + ) { + if (request.purpose != SignInSync.PURPOSE || request.level != 0) return + if (nostrEvent.kind != AdvertisedRelayListEvent.KIND) return + val unsignedNostrEventId = request.unsignedNostrEventId ?: return + val author = request.synchronizationFilters.firstOrNull()?.authors?.firstOrNull() ?: return + if (!nostrEvent.pubKey.equals(author, ignoreCase = true)) return + + val relays = SignInSync.writeRelaysOf(nostrEvent.tags, alreadyAsked = SignInSync.bootstrapRelays) + if (relays.isEmpty()) return + val isFirst = signInHopsMutex.withLock { signInHopsFollowed.add(unsignedNostrEventId) } + if (!isFirst) return + + logger.i { "Following the sign-in relay list of $author to ${relays.size} relay(s)" } + nostrRepository.queueSynchronizeNostrEvent( + SignInSync.requests(unsignedNostrEventId, author, relays, level = 1) + ) + } + private suspend fun observePendingNegentropySynchronizeRequests( keyPair: KeyPair ): Unit = coroutineScope { logger.i { "observePendingNegentropySynchronizeRequests" } - nostrRepository.observePendingNegentropySynchronizeRequests().distinctUntilChanged().collect { negentropySynchronizeRequestOrNull -> + nostrRepository.observePendingNegentropySynchronizeRequests(keyPair.pubKey.toHexKey()).distinctUntilChanged().collect { negentropySynchronizeRequestOrNull -> negentropySynchronizeRequestOrNull?.let { negentropySynchronizeRequest -> guardPump("negentropy request ${negentropySynchronizeRequest.id}") { mutex.withLock { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UnqueuedProfileSynchronizationViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UnqueuedProfileSynchronizationViewModel.kt index 37d95631..e648c10a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UnqueuedProfileSynchronizationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UnqueuedProfileSynchronizationViewModel.kt @@ -8,18 +8,11 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import co.touchlab.kermit.Logger -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent -import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent -import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent -import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import press.mantra.compose.nostr.SignInSync class UnqueuedProfileSynchronizationViewModel( val unsignedNostrEventId: Long, @@ -48,6 +41,11 @@ class UnqueuedProfileSynchronizationViewModel( val isActionPending: MutableState = mutableStateOf(false) + /** + * Asks the bootstrap relays -- the indexers, and ours -- for everything this pubkey + * has published about itself. The relay list that comes back is followed one hop by + * the sync pump; see [SignInSync]. + */ fun queueSynchronization() { logger.d { "queueSynchronization" } viewModelScope.launch(Dispatchers.IO) { @@ -55,30 +53,12 @@ class UnqueuedProfileSynchronizationViewModel( delay(2_000) nostrRepository.queueSynchronizeNostrEvent( - press.mantra.compose.nostr.Relays.DefaultDMRelayList.map { normalizedRelayUrl -> - press.mantra.compose.database.model.SynchronizeNostrEventRequest( - purpose = "sign-in", - synchronizationFilters = arrayOf( - press.mantra.compose.database.model.types.SynchronizationFilter( - authors = arrayOf(profilePublicKey), - kinds = arrayOf( - MetadataEvent.KIND, - ContactListEvent.KIND, - AdvertisedRelayListEvent.KIND, - ChatMessageRelayListEvent.KIND, - SearchRelayListEvent.KIND, - IndexerRelayListEvent.KIND, - ChannelListEvent.KIND, - RelayFeedsListEvent.KIND - ) - ) - ), - unsignedNostrEventId = unsignedNostrEventId, - relayURL = normalizedRelayUrl.url, - level = 0 - ) - } - + SignInSync.requests( + unsignedNostrEventId = unsignedNostrEventId, + publicKey = profilePublicKey, + relays = SignInSync.bootstrapRelays, + level = 0, + ) ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UnsyncedProfileViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UnsyncedProfileViewModel.kt new file mode 100644 index 00000000..c5f041e8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UnsyncedProfileViewModel.kt @@ -0,0 +1,141 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +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.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import press.mantra.compose.database.model.intermdiate.LocalAccount +import press.mantra.compose.nostr.SignInSync +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.UnsyncedProfileUIState +import press.mantra.compose.ui.view.state.form.CreateProfileFormState + +/** + * The screen that waits for a sign-in sync, and the one decision it makes: whether the + * search is still running or has finished with nothing. + * + * `NavigationViewModel` keeps the route at `UnsyncedProfile` for as long as the account + * has sync requests and no kind 0; it cannot tell the two apart, and until Phase 5 of + * docs/nsec-sign-in.md nothing could. Now every request records when its relay has + * finished answering, and [decide] reads that: no request still in flight, and still + * no profile, is not-found. The two exits from not-found are to ask again and to set a + * profile up for the key that is already here. + */ +class UnsyncedProfileViewModel( + private val publicKey: HexKey, + private val nostrRepository: NostrRepository, + val formState: CreateProfileFormState = CreateProfileFormState(), +) : ViewModel() { + + companion object { + private const val TAG = "UnsyncedProfileViewModel" + + fun factory( + publicKey: HexKey, + nostrRepository: NostrRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + UnsyncedProfileViewModel( + publicKey = publicKey, + nostrRepository = nostrRepository, + ) + } + } + + /** + * What the account says. A kind 0 indexed or a Profile row means the navigation + * machine is about to move on, so that is still "searching" from here; a request + * still `pending` or `sent` is a relay that has not finished. Only when every + * request has finished and nothing came is the answer not-found. + */ + fun decide(account: LocalAccount?): UnsyncedProfileUIState { + val unsignedNostrEvent = account?.unsignedNostrEvent ?: return UnsyncedProfileUIState.Searching + if (account.profile != null || account.nostrEvent != null) return UnsyncedProfileUIState.Searching + val requests = account.synchronizeNostrEventRequests + if (requests.isEmpty()) return UnsyncedProfileUIState.Searching + if (requests.any { it.status in SignInSync.IN_FLIGHT_STATUSES }) return UnsyncedProfileUIState.Searching + return UnsyncedProfileUIState.NotFound(unsignedNostrEventId = unsignedNostrEvent.id) + } + } + + private val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + /** True after a submit with no name; the field shows why. */ + val nameMissing: MutableState = mutableStateOf(false) + + val uiState: StateFlow = accountFlow() + .map { decide(it) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UnsyncedProfileUIState.Searching) + + private fun accountFlow(): Flow = flow { + nostrRepository.observeLocalAccount(publicKey).collect { emit(it) } + } + + /** Asks the bootstrap relays again. The new requests are in flight, so the screen goes back to searching on its own. */ + fun tryAgain(unsignedNostrEventId: Long) { + if (isActionPending.value) return + isActionPending.value = true + viewModelScope.launch(Dispatchers.IO) { + try { + nostrRepository.queueSynchronizeNostrEvent( + SignInSync.requests( + unsignedNostrEventId = unsignedNostrEventId, + publicKey = publicKey, + relays = SignInSync.bootstrapRelays, + level = 0, + ) + ) + } catch (e: Exception) { + logger.e("could not queue the sign-in sync again", e) + } finally { + isActionPending.value = false + } + } + } + + /** + * Gives the key a profile. The rows change under `observeLocalAccount`, the + * navigation machine sees an unsigned kind 0 and routes to the create flow's own + * screens, and the notary -- which already holds this key -- signs it. + */ + fun setUpProfile(unsignedNostrEventId: Long) { + if (isActionPending.value) return + val name = formState.nameField.textFieldState.text.toString().trim() + if (name.isBlank()) { + nameMissing.value = true + return + } + nameMissing.value = false + isActionPending.value = true + + viewModelScope.launch(Dispatchers.IO) { + try { + nostrRepository.setUpProfileForExistingKey( + publicKey = publicKey, + placeholderUnsignedNostrEventId = unsignedNostrEventId, + name = name, + biography = formState.biographyField.textFieldState.text.toString().trim().ifBlank { null }, + ) + } catch (e: Exception) { + logger.e("could not set up a profile for the key", e) + isActionPending.value = false + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/BroadcastGroupSignedEventUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/BroadcastGroupSignedEventUIState.kt new file mode 100644 index 00000000..bd472a3b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/BroadcastGroupSignedEventUIState.kt @@ -0,0 +1,18 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.intermdiate.LocalChatRoom + +sealed interface BroadcastGroupSignedEventUIState { + data class Loaded( + val localChatRoom: LocalChatRoom, + /** The event to send, as the group signed it. What goes on the wire is this, byte for byte. */ + val signedEvent: GroupSignedEvent, + ): BroadcastGroupSignedEventUIState + + data class Error( + val message: String + ): BroadcastGroupSignedEventUIState + + data object Loading: BroadcastGroupSignedEventUIState +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/NostrSecretUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/NostrSecretUIState.kt new file mode 100644 index 00000000..9e0754e0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/NostrSecretUIState.kt @@ -0,0 +1,35 @@ +package press.mantra.compose.ui.view.state + +/** + * What the nostr secret key screen is showing. The key is never held here longer than + * the user is looking at it: [Revealed] is the only state that carries it, and leaving + * the screen (or hiding it) drops back to [Hidden]. The shape of [RecoveryPhraseUIState], + * for the identity kind that has a key and no phrase. + */ +sealed interface NostrSecretUIState { + data object Hidden : NostrSecretUIState + + /** Reading and decrypting the key file. */ + data object Revealing : NostrSecretUIState + + data class Revealed(val nsec: String) : NostrSecretUIState + + sealed interface Error : NostrSecretUIState { + /** No identity is active, so there is nothing to look up. */ + data object NoActiveIdentity : Error + + /** The key file was read, but holds no key for the active identity -- it is a mnemonic identity, or the key was forgotten. */ + data object NoKeyForThisIdentity : Error + + /** The key file could not be read or decrypted. */ + data object KeysUnreadable : Error + } + + /** The forget flow's own states, alongside whatever the reveal is showing. */ + sealed interface Forgetting { + data object Idle : Forgetting + data object Confirming : Forgetting + data object InProgress : Forgetting + data object Failed : Forgetting + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProfilePreviewUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProfilePreviewUIState.kt new file mode 100644 index 00000000..f61064f0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProfilePreviewUIState.kt @@ -0,0 +1,52 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.Profile + +/** + * The person behind a pasted npub, before a chat with them is started. + * + * `Loaded` needs a profile read off a kind 0 -- `Profile.isResolved()` -- and never + * the "LOADING..." placeholder a pubkey gets on first sight, because the screen's one + * job is to show who this is. See docs/npub-profile-preview.md. + */ +sealed interface ProfilePreviewUIState { + data object Loading : ProfilePreviewUIState + + data class Loaded( + val profile: Profile, + val readiness: Readiness, + ) : ProfilePreviewUIState + + /** + * Every relay asked has had its time, and no kind 0 came. Not an error: a search + * that found nothing, and one a late answer can still overturn. + */ + data object NotFound : ProfilePreviewUIState + + /** The queue itself refused: nothing was asked, and asking again is the retry. */ + data object Error : ProfilePreviewUIState + + /** + * Whether pressing the button would get anywhere, read here rather than twenty + * seconds into the next screen. + * + * A fact about a *loaded* profile: a room does not make a person known, and a + * member still "LOADING..." waits on their kind 0 here like anyone else. + */ + sealed interface Readiness { + /** A conversation with this person exists; the button opens it. */ + data class ExistingChat( + val chatRoomId: String, + val relayHint: String?, + ) : Readiness + + /** Their key package is here; `initiateNewChat` will create the room without waiting. */ + data object CanStartChat : Readiness + + /** Asked, not answered yet. */ + data object Checking : Readiness + + /** The relays have had their time and no key package came. */ + data object NotYetOnMantra : Readiness + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProfilesUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProfilesUIState.kt new file mode 100644 index 00000000..f680b2f1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProfilesUIState.kt @@ -0,0 +1,28 @@ +package press.mantra.compose.ui.view.state + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.utils.preferences.UserWalletMetadata +import press.mantra.compose.database.model.Profile +import press.mantra.compose.identity.StoredIdentity + +/** + * The switcher's states. Three, not four: an empty list cannot happen while something + * is signed in, and the screen does not pretend it can. + */ +sealed interface ProfilesUIState { + /** The list is being read -- in practice never seen, since startup read it, but the state exists and says what it waits for. */ + data object Loading : ProfilesUIState + + /** One of the two key files could not be read. A retry re-lists. */ + data object Error : ProfilesUIState + + data class Loaded( + val identities: Map, + /** The one that is open, drawn above the divider. */ + val activeId: WalletId?, + val walletsMetadata: Map, + /** The nostr profile of each listed key, where the device has one; the row falls back to the npub. */ + val profiles: Map, + ) : ProfilesUIState +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SignInToProfileUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SignInToProfileUIState.kt index 3e6bbb74..1710d0e2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SignInToProfileUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SignInToProfileUIState.kt @@ -1,20 +1,33 @@ package press.mantra.compose.ui.view.state -import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.phoenix.data.WalletId +import press.mantra.compose.identity.CredentialProblem +import press.mantra.compose.identity.SignInCredential -abstract class SignInToProfileUIState { - data object Error: SignInToProfileUIState() +/** + * The sign-in screen's four states. Problems with the *input* are not one of them: those + * stay on the field as supporting text while the prompt is still showing. [Error] is for + * what happens after the user has confirmed -- the write failed, or the key was already + * here -- where there is no field to put the message under. + */ +sealed interface SignInToProfileUIState { + /** The field, waiting for a recovery phrase or an nsec. */ + data object InputPrompt : SignInToProfileUIState - data object InputPrompt: SignInToProfileUIState() + /** + * What was recognised, and the npub it signs as, before anything is written. This is + * the step that catches a paste of the wrong key; for a recovery phrase it is where + * the user learns which profile the words open. The secret itself is never shown. + */ + data class Confirm(val credential: SignInCredential) : SignInToProfileUIState - data class ConfirmNpubSignIn( - val npub: String, - val hexKey: HexKey - ) : SignInToProfileUIState() + /** Writing the secret and planting the account. */ + data object Committing : SignInToProfileUIState - data class ConfirmNsecSignIn( - val nsec: String, - val hexKey: HexKey - ) : SignInToProfileUIState() - -} \ No newline at end of file + /** + * @param alreadyHere for [CredentialProblem.AlreadyOnThisDevice], the id the key is + * listed under: "already on this device" is a sentence with an obvious next step, + * and from inside the app the screen offers it. + */ + data class Error(val problem: CredentialProblem, val alreadyHere: WalletId? = null) : SignInToProfileUIState +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/StartupChoice.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/StartupChoice.kt new file mode 100644 index 00000000..389e61ff --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/StartupChoice.kt @@ -0,0 +1,45 @@ +package press.mantra.compose.ui.view.state + +import fr.acinq.phoenix.data.WalletId +import press.mantra.compose.identity.StoredIdentity + +/** + * Which profile the startup screen opens without asking, and when it asks instead. + * + * Lifted out of the screen's `remember` so the table can be read and tested: the + * order of these rows is the whole of the decision, and it was wrong in the one way a + * switch would hit. `!startImmediately` sat above `desired`, and nothing ever set the + * flag back to true, so after the first visit to the selector every sign-in on the + * device landed on the selector instead of the profile it had just signed in. + * See docs/multiple-profiles.md, Phase 3. + */ +object StartupChoice { + + /** + * The identity to open, or null to show the selector. + * + * @param force an id the host insisted on; nothing passes one today. + * @param desired the id a switch or a sign-in named. The most specific instruction, + * and the one a switch relies on, so it outranks the user's earlier "show me the + * list". + * @param startImmediately false when the user asked for the list -- from a forget + * tail, or the lock prompt's back button -- and true otherwise, which a switch + * sets it back to. + * @param default the last profile opened, saved at every activation, so that a cold + * boot resumes where the user was. + */ + fun resolve( + force: WalletId?, + desired: WalletId?, + startImmediately: Boolean, + identities: Map, + default: WalletId?, + ): StoredIdentity? = when { + force != null -> identities[force] + desired != null -> identities[desired] + !startImmediately -> null + identities.size == 1 -> identities.values.single() + default != null -> identities[default] + else -> null + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UnsyncedProfileUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UnsyncedProfileUIState.kt new file mode 100644 index 00000000..1c908cf1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UnsyncedProfileUIState.kt @@ -0,0 +1,10 @@ +package press.mantra.compose.ui.view.state + +/** What the screen waiting on a sign-in sync is showing. */ +sealed interface UnsyncedProfileUIState { + /** At least one relay has not finished answering, or something has arrived and the route is about to move. */ + data object Searching : UnsyncedProfileUIState + + /** Every relay asked has finished, and none had a profile for this key. */ + data class NotFound(val unsignedNostrEventId: Long) : UnsyncedProfileUIState +} diff --git a/composeApp/src/commonTest/kotlin/press/auxiliary/compose/ComposeAppCommonTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ComposeAppCommonTest.kt similarity index 82% rename from composeApp/src/commonTest/kotlin/press/auxiliary/compose/ComposeAppCommonTest.kt rename to composeApp/src/commonTest/kotlin/press/mantra/compose/ComposeAppCommonTest.kt index 3e548c4a..b143f6d9 100644 --- a/composeApp/src/commonTest/kotlin/press/auxiliary/compose/ComposeAppCommonTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ComposeAppCommonTest.kt @@ -1,4 +1,4 @@ -package press.auxiliary.compose +package press.mantra.compose import kotlin.test.Test import kotlin.test.assertEquals diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/DirectMessagePeerSyncTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/DirectMessagePeerSyncTest.kt new file mode 100644 index 00000000..1cafb423 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/DirectMessagePeerSyncTest.kt @@ -0,0 +1,90 @@ +package press.mantra.compose.nostr + +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import press.mantra.compose.database.model.NegentropySynchronizeRequest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * What a direct message with one person asks the relays for, and whom it asks. + * + * Two request lists because two sets of relays hold two different things; the test + * pins each list's shape and the one relay the two sets share, so that a change to + * either relay set is noticed here rather than as a chat that never starts. + */ +class DirectMessagePeerSyncTest { + + private val alice = "b".repeat(64) + + // --- the DM relays: everything a chat needs ---------------------------------------- + + @Test + fun `one request per DM relay, asking for the three kinds a chat needs`() { + val requests = DirectMessagePeerSync.requests(alice) + + assertEquals(Relays.DefaultDMRelayList.size, requests.size) + assertEquals(Relays.DefaultDMRelayList.map { it.url }.sorted(), requests.map { it.relayURL }.sorted()) + + requests.forEach { request -> + assertEquals(DirectMessagePeerSync.PURPOSE, request.purpose) + // Level 0: somebody is waiting on this one. + assertEquals(0, request.level) + assertEquals( + listOf(MetadataEvent.KIND, KeyPackageEvent.KIND, ChatMessageRelayListEvent.KIND), + request.synchronizationFilter.kinds?.toList(), + ) + assertEquals(listOf(alice), request.synchronizationFilter.authors?.toList()) + // `limit = 1` across three kinds returned the kind 0 alone on the REQ fallback, + // and the key package never arrived. + assertNull(request.synchronizationFilter.limit) + } + } + + /** `computeId` buckets by minute, and the id is what lets a retry re-arm the row rather than add one. */ + @Test + fun `the id is the one computeId gives the relay and the filter`() { + DirectMessagePeerSync.requests(alice).forEach { request -> + assertEquals( + NegentropySynchronizeRequest.computeId(request.relayURL, request.synchronizationFilter), + request.id, + ) + } + } + + // --- the indexers: the kind 0 alone ------------------------------------------------- + + @Test + fun `one request per bootstrap relay, asking for the kind 0 alone`() { + val requests = DirectMessagePeerSync.profileRequests(alice) + + assertEquals(SignInSync.bootstrapRelays.size, requests.size) + assertEquals(SignInSync.bootstrapRelays.map { it.url }.sorted(), requests.map { it.relayURL }.sorted()) + + requests.forEach { request -> + assertEquals(DirectMessagePeerSync.PROFILE_PURPOSE, request.purpose) + assertEquals(0, request.level) + // A peer has no unsigned event of ours to hang off, unlike a sign-in. + assertNull(request.unsignedNostrEventId) + + val filter = request.synchronizationFilters.single() + assertEquals(listOf(MetadataEvent.KIND), filter.kinds?.toList()) + assertEquals(listOf(alice), filter.authors?.toList()) + } + } + + /** + * Our own relay is the one place both lists ask, on purpose: a profile created here + * is found here, and a chat can only be started here. Anything else the two share + * is a relay set that has drifted. + */ + @Test + fun `the two relay sets share our own relay and nothing else`() { + val dmRelays = DirectMessagePeerSync.requests(alice).map { it.relayURL }.toSet() + val profileRelays = DirectMessagePeerSync.profileRequests(alice).map { it.relayURL }.toSet() + + assertEquals(setOf(Relays.ephemeral.url), dmRelays intersect profileRelays) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/SignInSyncTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/SignInSyncTest.kt new file mode 100644 index 00000000..9db84af3 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/SignInSyncTest.kt @@ -0,0 +1,76 @@ +package press.mantra.compose.nostr + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Whom the sign-in sync asks, and how it reads a relay list to ask further. + * + * The bootstrap set is the load-bearing change of Phase 5: it used to be one relay, ours, + * which is the right answer only for a profile this app made. The hop test pins the + * outbox reading -- write relays, not read relays -- because asking a user's *read* + * relays for their own events is the mistake the inbox/outbox split exists to name. + */ +class SignInSyncTest { + + private val publicKey = "a".repeat(64) + private fun relay(url: String) = RelayUrlNormalizer.normalize(url) + + @Test + fun `the bootstrap asks every indexer and our own relay, once each`() { + val bootstrap = SignInSync.bootstrapRelays + + assertTrue(bootstrap.containsAll(Relays.DefaultIndexerRelayList), "the indexers hold everyone's kinds 0, 3 and 10002") + assertTrue(Relays.ephemeral in bootstrap, "a profile created here is found here") + assertEquals(bootstrap.size, bootstrap.toSet().size, "no relay asked twice") + assertTrue(bootstrap.size > 1, "one relay was the whole problem") + } + + @Test + fun `one request per relay, all asking for the same things about the same key`() { + val relays = listOf(relay("wss://one.example"), relay("wss://two.example")) + + val requests = SignInSync.requests(unsignedNostrEventId = 7, publicKey = publicKey, relays = relays, level = 1) + + assertEquals(relays.map { it.url }, requests.map { it.relayURL }) + requests.forEach { request -> + assertEquals(SignInSync.PURPOSE, request.purpose) + assertEquals(1, request.level) + assertEquals(7L, request.unsignedNostrEventId) + assertEquals("pending", request.status) + val filter = request.synchronizationFilters.single() + assertEquals(listOf(publicKey), filter.authors?.toList()) + assertEquals(SignInSync.KINDS.toList(), filter.kinds?.toList()) + } + } + + @Test + fun `a relay list yields its write relays, less those already asked`() { + val tags = arrayOf( + arrayOf("r", "wss://write.example", "write"), + arrayOf("r", "wss://both.example"), + arrayOf("r", "wss://read.example", "read"), + arrayOf("r", "wss://asked.example"), + arrayOf("r", "wss://both.example"), // duplicate + arrayOf("r", "not a url"), // dropped by the normaliser + arrayOf("p", "wss://tagged-wrong.example"), // not an r tag + arrayOf("r"), // no url + ) + + val relays = SignInSync.writeRelaysOf(tags, alreadyAsked = listOf(relay("wss://asked.example"))) + + assertEquals( + listOf(relay("wss://write.example"), relay("wss://both.example")), + relays, + ) + } + + @Test + fun `a relay list with only read relays yields nothing to follow`() { + val tags = arrayOf(arrayOf("r", "wss://read.example", "read")) + + assertEquals(emptyList(), SignInSync.writeRelaysOf(tags, alreadyAsked = emptyList())) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt new file mode 100644 index 00000000..9ecddf7e --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt @@ -0,0 +1,39 @@ +package press.mantra.compose.text + +import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * How an event a group signs is said to the member signing it. + * + * These are read on the signing screen and in the room's list of proposals, and + * the same words in both is the point of there being one mapping. What is pinned + * here is the fallback: a kind the mapping has no words for is named by its + * number over its content, and never described wrongly. + */ +class ProposedEventTest { + + @Test + fun `a kind with no words of its own keeps its number`() { + // Refusing to describe an event is better than describing it wrongly. + val summary = ProposedEvent.summarize(event(30023, content = "An article")) + + assertEquals("Event of kind 30023", summary.label) + assertEquals("An article", summary.detail) + } + + private fun event( + kind: Int, + tags: Array> = emptyArray(), + content: String = "", + ) = Event( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1_700_000_000, + kind = kind, + tags = tags, + content = content, + sig = "c".repeat(128), + ) +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/NavigationRoutingTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/NavigationRoutingTest.kt index bde74339..a8aef044 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/NavigationRoutingTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/NavigationRoutingTest.kt @@ -1,7 +1,7 @@ package press.mantra.compose.ui.view.model import com.vitorpamplona.quartz.nip01Core.core.HexKey -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancel @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest +import press.mantra.compose.database.GENESIS_AT import press.mantra.compose.database.model.Profile import press.mantra.compose.database.model.UnsignedNostrEvent import press.mantra.compose.database.model.intermdiate.LocalAccount @@ -94,6 +95,30 @@ class NavigationRoutingTest { synchronizeNostrEventRequests = emptyList(), ) + /** + * What `signInToProfile` plants for a key that already exists elsewhere: a kind 0 + * stamped `GENESIS_AT` so the notary skips it, and nothing else -- no `Profile` + * row, which only the notary's signing writes, and no sync request yet. + */ + private fun placeholderAccount() = LocalAccount( + unsignedNostrEvent = UnsignedNostrEvent( + id = 1, + pubKey = publicKey, + kind = 0, + tags = emptyArray(), + content = "{}", + signedAt = GENESIS_AT, + createdAt = at, + updatedAt = at, + savedAt = at, + ), + nostrEvent = null, + profile = null, + broadcastNostrEventRequest = null, + broadcastNostrEventReceipt = null, + synchronizeNostrEventRequests = emptyList(), + ) + /** * The view model's `init` starts a wallet observer that would write to the * same state after its 2.1s wait. Cancelling the scope leaves that observer @@ -102,7 +127,7 @@ class NavigationRoutingTest { private fun bootedOn(vararg accounts: LocalAccount): NavigationViewModel { val scope = CoroutineScope(Job()) return NavigationViewModel( - activeWalletStateFlow = MutableStateFlow(null), + activeIdentityStateFlow = MutableStateFlow(null), initialNavigationUIState = stranded, nostrRepository = Device(accounts.toList()), scope = scope, @@ -161,4 +186,26 @@ class NavigationRoutingTest { // is up, so the answer is to go and start it. assertEquals(NavigationUIState.StartupPhoenix, viewModel.navigationUIState.value) } + + /** + * `loadNostrProfile` used to read the account through `profile.publicKey`. A + * placeholder has no Profile, so that read was null and the answer was Landing -- + * "go and make an account" -- for a key that had just been imported, while the + * identity observer, reading the same rows, answered the sync screen. Two writers + * to one state, and Landing pops the back stack. + */ + @Test + fun `a key just imported is sent to fetch its profile, not to make one`() = runTest { + val viewModel = bootedOn(placeholderAccount()) + + viewModel.loadNostrProfile(SovereignWalletStartupRoute) + + assertNotEquals(NavigationUIState.Landing, viewModel.navigationUIState.value) + assertEquals( + NavigationUIState.UnqueuedProfileSynchronization( + unsignedNostrEvent = placeholderAccount().unsignedNostrEvent!! + ), + viewModel.navigationUIState.value, + ) + } } diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/UnsyncedProfileDecisionTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/UnsyncedProfileDecisionTest.kt new file mode 100644 index 00000000..c543e8f4 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/UnsyncedProfileDecisionTest.kt @@ -0,0 +1,124 @@ +package press.mantra.compose.ui.view.model + +import press.mantra.compose.database.GENESIS_AT +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.SynchronizeNostrEventRequest +import press.mantra.compose.database.model.UnsignedNostrEvent +import press.mantra.compose.database.model.intermdiate.LocalAccount +import press.mantra.compose.database.model.types.SynchronizationFilter +import press.mantra.compose.nostr.SignInSync +import press.mantra.compose.ui.view.state.UnsyncedProfileUIState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Instant + +/** + * When "searching" becomes "not found". + * + * A request is `pending` when queued, `sent` when its REQ is out, `processed` if an + * event came back for it, and -- since Phase 5 -- `complete` if its relay finished with + * nothing. Only the first two mean a relay has not answered yet, and only when none is + * in either state, and still no kind 0 has arrived, may the screen say the search is + * over. Anything else is the wrong message at the wrong time: too early and the user + * sets up a second profile while the first is on its way; never and they sit under a + * spinner for good, which is what the screen did before. + */ +class UnsyncedProfileDecisionTest { + + private val publicKey = "a".repeat(64) + private val at = Instant.fromEpochSeconds(1_700_000_000) + + private val placeholder = UnsignedNostrEvent( + id = 1, + pubKey = publicKey, + kind = 0, + tags = emptyArray(), + content = "{}", + signedAt = GENESIS_AT, + createdAt = at, + updatedAt = at, + savedAt = at, + ) + + private fun request(status: String, relay: String) = SynchronizeNostrEventRequest( + id = "req-$relay", + purpose = SignInSync.PURPOSE, + status = status, + relayURL = "wss://$relay.example", + level = 0, + synchronizationFilters = arrayOf(SynchronizationFilter(authors = arrayOf(publicKey), kinds = SignInSync.KINDS)), + unsignedNostrEventId = 1, + createdAt = at, + updatedAt = at, + ) + + private fun account( + vararg requests: SynchronizeNostrEventRequest, + profile: Profile? = null, + nostrEvent: NostrEvent? = null, + ) = LocalAccount( + unsignedNostrEvent = placeholder, + nostrEvent = nostrEvent, + profile = profile, + broadcastNostrEventRequest = null, + broadcastNostrEventReceipt = null, + synchronizeNostrEventRequests = requests.toList(), + ) + + @Test + fun `the answer flips only when the last relay has finished`() { + val sequence = listOf( + account(request("pending", "one"), request("pending", "two")), + account(request("sent", "one"), request("pending", "two")), + account(request("sent", "one"), request("sent", "two")), + account(request("complete", "one"), request("sent", "two")), + account(request("complete", "one"), request("complete", "two")), + ) + + val decisions = sequence.map { UnsyncedProfileViewModel.decide(it) } + + assertEquals( + listOf( + UnsyncedProfileUIState.Searching, + UnsyncedProfileUIState.Searching, + UnsyncedProfileUIState.Searching, + UnsyncedProfileUIState.Searching, + UnsyncedProfileUIState.NotFound(unsignedNostrEventId = 1), + ), + decisions, + ) + } + + /** An event came back for a request -- a contact list, say -- but still no kind 0: the search is still over. */ + @Test + fun `a relay that answered with something other than a profile still counts as finished`() { + val decision = UnsyncedProfileViewModel.decide(account(request("processed", "one"), request("complete", "two"))) + + assertEquals(UnsyncedProfileUIState.NotFound(unsignedNostrEventId = 1), decision) + } + + @Test + fun `a kind 0 that has arrived, indexed or not, is never not-found`() { + val event = NostrEvent( + id = "e".repeat(64), + pubKey = publicKey, + kind = 0, + tags = emptyArray(), + content = "{}", + createdAt = at, + sig = "f".repeat(128), + unsignedNostrEventId = 1, + ) + val profile = Profile(publicKey = publicKey, nostrEventId = event.id, createdAt = at, updatedAt = at, savedAt = at) + + assertEquals(UnsyncedProfileUIState.Searching, UnsyncedProfileViewModel.decide(account(request("complete", "one"), nostrEvent = event))) + assertEquals(UnsyncedProfileUIState.Searching, UnsyncedProfileViewModel.decide(account(request("complete", "one"), profile = profile))) + } + + @Test + fun `no account, or no request yet, is still searching`() { + assertEquals(UnsyncedProfileUIState.Searching, UnsyncedProfileViewModel.decide(null)) + assertEquals(UnsyncedProfileUIState.Searching, UnsyncedProfileViewModel.decide(account())) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/StartupChoiceTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/StartupChoiceTest.kt new file mode 100644 index 00000000..1715d159 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/StartupChoiceTest.kt @@ -0,0 +1,66 @@ +package press.mantra.compose.ui.view.state + +import fr.acinq.phoenix.data.WalletId +import press.mantra.compose.identity.StoredIdentity +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * The table, one case per row, and the two orderings Phase 3 of + * docs/multiple-profiles.md exists for: what a switch or a sign-in named outranks the + * user's earlier "show me the list", and one identity with "show me the list" still + * shows it, because the user asked. + */ +class StartupChoiceTest { + + private val a = StoredIdentity.NostrPublic(WalletId("aa".repeat(20)), "a1".repeat(32)) + private val b = StoredIdentity.NostrPublic(WalletId("bb".repeat(20)), "b2".repeat(32)) + private val two = mapOf(a.id to a, b.id to b) + + private fun resolve( + force: WalletId? = null, + desired: WalletId? = null, + startImmediately: Boolean = true, + identities: Map = two, + default: WalletId? = null, + ) = StartupChoice.resolve(force, desired, startImmediately, identities, default) + + @Test + fun `a forced id wins over everything`() { + assertEquals(a, resolve(force = a.id, desired = b.id, startImmediately = false, default = b.id)) + } + + @Test + fun `what a switch or a sign-in named is opened, even after the user once asked for the list`() { + assertEquals(b, resolve(desired = b.id, startImmediately = false)) + } + + @Test + fun `the user asking for the list is honoured over a default and over a single identity`() { + assertNull(resolve(startImmediately = false, default = a.id)) + assertNull(resolve(startImmediately = false, identities = mapOf(a.id to a))) + } + + @Test + fun `one identity opens without asking`() { + assertEquals(a, resolve(identities = mapOf(a.id to a))) + } + + @Test + fun `the last profile opened is the one a cold boot opens`() { + assertEquals(b, resolve(default = b.id)) + } + + @Test + fun `a default the list no longer has, or no default, shows the list`() { + assertNull(resolve(default = WalletId("cc".repeat(20)))) + assertNull(resolve()) + } + + @Test + fun `an id that is not listed opens nothing`() { + assertNull(resolve(desired = WalletId("cc".repeat(20)))) + assertNull(resolve(force = WalletId("cc".repeat(20)))) + } +} diff --git a/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.ios.kt b/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.ios.kt index 6c04560d..e604d416 100644 --- a/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.ios.kt +++ b/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.ios.kt @@ -1,35 +1,24 @@ package press.mantra.compose.ui.view.model -import press.mantra.compose.AppVersion -import co.touchlab.kermit.Logger import fr.acinq.bitcoin.Chain -import fr.acinq.bitcoin.MnemonicCode -import fr.acinq.lightning.crypto.LocalKeyManager -import fr.acinq.lightning.utils.toByteVector import fr.acinq.phoenix.PhoenixGlobal import fr.acinq.phoenix.data.DecryptSeedResult -import fr.acinq.phoenix.data.ElectrumConfig import fr.acinq.phoenix.data.WalletId import fr.acinq.phoenix.ios.BusinessManager import fr.acinq.phoenix.managers.DataStoreManager -import fr.acinq.phoenix.managers.NodeParamsManager import fr.acinq.phoenix.managers.SeedManager -import fr.acinq.phoenix.security.EncryptedSeed -import fr.acinq.phoenix.utils.preferences.GlobalPrefs import fr.acinq.phoenix.utils.preferences.UserWalletMetadata -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.launch actual fun updateBusinessActiveInUI(walletId: WalletId) { BusinessManager.updateBusinessActiveInUI(walletId) } +actual fun stopPlatformBusiness(walletId: WalletId) { + BusinessManager.stopBusiness(walletId) +} + actual fun loadAndDecryptSeed(phoenixGlobal: PhoenixGlobal): DecryptSeedResult { return SeedManager.loadAndDecrypt( phoenixGlobal @@ -70,80 +59,3 @@ actual suspend fun saveAvailableWalletMeta( isHidden = isHidden ) } - -actual fun platformWriteSeed( - log: Logger, - phoenixGlobal: PhoenixGlobal, - globalPrefs: GlobalPrefs, - writingState: WritingSeedState, - viewModelScope: CoroutineScope, - mnemonics: List, - onWritingSeedError: (WritingSeedState.Error) -> Unit, - onWritingSeedStateWriting: (WritingSeedState.Writing) -> Unit, - isRestoringWallet: Boolean, - isTorEnabled: Boolean, - customElectrumServer: ElectrumConfig.Custom?, - onSeedWritten: (WalletId) -> Unit -) { - if (writingState !is WritingSeedState.Init) return - viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e -> - log.e("failed to write mnemonics to disk: ${e.message}") - onWritingSeedError.invoke( - WritingSeedState.Error.Generic(e) - ) - }) { - log.d("writing mnemonics to disk...") - - onWritingSeedStateWriting.invoke( - WritingSeedState.Writing(mnemonics) - ) - val existingSeeds = SeedManager.loadAndDecryptOrNull(phoenixGlobal)?.map { - it.key to it.value.words - }?.toMap() - - val seed = MnemonicCode.toSeed(mnemonics, "").toByteVector() - val keyManager = LocalKeyManager(seed, NodeParamsManager.chain, NodeParamsManager.remoteSwapInXpub) - val newWalletId = WalletId(keyManager.nodeKeys.nodeKey.publicKey) - - when { - existingSeeds == null -> { - log.e("could not load the existing seed map, aborting...") - onWritingSeedError.invoke( - WritingSeedState.Error.CannotLoadSeedMap - ) - return@launch - } - existingSeeds.containsKey(newWalletId) -> { - log.i("attempting to import a seed that already exists, aborting...") - onWritingSeedError.invoke( - WritingSeedState.Error.SeedAlreadyExists - ) - return@launch - } - else -> { - val newSeedMap = existingSeeds + (newWalletId to mnemonics) - val encrypted = EncryptedSeed.V2.encrypt(newSeedMap) - SeedManager.writeSeedToDisk(phoenixGlobal, encrypted, overwrite = true) - if (isRestoringWallet) { - log.i("successfully restored wallet=$newWalletId") - } else { - log.i("successfully created wallet=$newWalletId") - } - } - } - - globalPrefs.saveLastUsedAppCode(AppVersion.versionCode) - val dataStoreManager = DataStoreManager( - phoenixGlobal.ctx, - chain = NodeParamsManager.chain, - ) - val userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId = newWalletId) - userPrefs.saveIsTorEnabled(isTorEnabled) - userPrefs.saveElectrumServer(customElectrumServer) - - viewModelScope.launch(Dispatchers.Main) { - delay(1000) - onSeedWritten(newWalletId) - } - } -} \ No newline at end of file diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/compose/extensions/JvmGlobalPrefs.kt b/composeApp/src/jvmMain/kotlin/press/mantra/compose/extensions/JvmGlobalPrefs.kt new file mode 100644 index 00000000..74991562 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/press/mantra/compose/extensions/JvmGlobalPrefs.kt @@ -0,0 +1,26 @@ +package press.mantra.compose.extensions + +import fr.acinq.bitcoin.Chain +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.managers.DataStoreManager +import fr.acinq.phoenix.utils.preferences.GlobalPrefs + +/** + * The one way the jvm target reaches the global preferences. + * + * `DataStoreManager.loadGlobalPrefsForWallet` caches one `GlobalPrefs` per process behind + * an unsynchronised check-then-set, and DataStore refuses a second instance over the same + * file. Two first calls at once -- the listing on IO and the startup screen on Main, or a + * sign-in's write and the listing it races -- can both see the empty cache, both build + * one, and the loser throws "multiple DataStores active for the same file" on first use. + * Android is unaffected, since it reaches the prefs through the Application's single + * instance; the jvm actuals all went through the cache, and this serialises them. The + * library's own callers come later, at node start, when the cache is long populated. + */ +internal object JvmGlobalPrefs { + private val lock = Any() + + fun of(phoenixGlobal: PhoenixGlobal): GlobalPrefs = synchronized(lock) { + DataStoreManager(phoenixGlobal.ctx, chain = Chain.Mainnet).loadGlobalPrefsForWallet() + } +} diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/compose/extensions/Phoenix.jvm.kt b/composeApp/src/jvmMain/kotlin/press/mantra/compose/extensions/Phoenix.jvm.kt index d47aa7ec..31d70efd 100644 --- a/composeApp/src/jvmMain/kotlin/press/mantra/compose/extensions/Phoenix.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/press/mantra/compose/extensions/Phoenix.jvm.kt @@ -1,11 +1,9 @@ package press.mantra.compose.extensions import co.touchlab.kermit.Logger -import fr.acinq.bitcoin.Chain import fr.acinq.phoenix.PhoenixGlobal import fr.acinq.phoenix.data.StartBusinessResult import fr.acinq.phoenix.jvm.BusinessManager -import fr.acinq.phoenix.managers.DataStoreManager import fr.acinq.phoenix.utils.preferences.GlobalPrefs import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -44,9 +42,9 @@ actual fun schedulePlatformLogic(phoenixGlobal: PhoenixGlobal) { } actual fun getShowIntroFlow(phoenixGlobal: PhoenixGlobal): Flow { - return DataStoreManager(phoenixGlobal.ctx, Chain.Mainnet).loadGlobalPrefsForWallet().getShowIntro + return JvmGlobalPrefs.of(phoenixGlobal).getShowIntro } actual fun getGlobalPrefs(phoenixGlobal: PhoenixGlobal): GlobalPrefs { - return DataStoreManager(phoenixGlobal.ctx, Chain.Mainnet).loadGlobalPrefsForWallet() + return JvmGlobalPrefs.of(phoenixGlobal) } diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt b/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt index 62d6645a..1d9fa171 100644 --- a/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt @@ -1,43 +1,31 @@ -// The ios actuals with one import changed. That file uses no ios API -- SeedManager, -// DataStoreManager, EncryptedSeed and LocalKeyManager are all commonMain -- so the only -// thing that had to move was BusinessManager, which is now ported to the library's jvmMain. +// The ios actuals with one import changed: BusinessManager, ported to the library's jvmMain. +// The seed writer that used to be the bulk of this file was byte-identical on all three +// platforms and now lives once, in commonMain, as IdentityWriter.writeMnemonic. // -// Kept as a copy for the same reason the ported BusinessManager is: sharing it means an -// intermediate source set between iosMain and jvmMain, which is a change to how the module -// is wired rather than to what it does. +// What is left is kept as a copy for the same reason the ported BusinessManager is: sharing +// it means an intermediate source set between iosMain and jvmMain, which is a change to how +// the module is wired rather than to what it does. package press.mantra.compose.ui.view.model -import press.mantra.compose.AppVersion -import co.touchlab.kermit.Logger -import fr.acinq.bitcoin.Chain -import fr.acinq.bitcoin.MnemonicCode -import fr.acinq.lightning.crypto.LocalKeyManager -import fr.acinq.lightning.utils.toByteVector import fr.acinq.phoenix.PhoenixGlobal import fr.acinq.phoenix.data.DecryptSeedResult -import fr.acinq.phoenix.data.ElectrumConfig import fr.acinq.phoenix.data.WalletId import fr.acinq.phoenix.jvm.BusinessManager -import fr.acinq.phoenix.managers.DataStoreManager -import fr.acinq.phoenix.managers.NodeParamsManager import fr.acinq.phoenix.managers.SeedManager -import fr.acinq.phoenix.security.EncryptedSeed -import fr.acinq.phoenix.utils.preferences.GlobalPrefs import fr.acinq.phoenix.utils.preferences.UserWalletMetadata -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.launch +import press.mantra.compose.extensions.JvmGlobalPrefs actual fun updateBusinessActiveInUI(walletId: WalletId) { BusinessManager.updateBusinessActiveInUI(walletId) } +actual fun stopPlatformBusiness(walletId: WalletId) { + BusinessManager.stopBusiness(walletId) +} + actual fun loadAndDecryptSeed(phoenixGlobal: PhoenixGlobal): DecryptSeedResult { return SeedManager.loadAndDecrypt( phoenixGlobal @@ -45,20 +33,14 @@ actual fun loadAndDecryptSeed(phoenixGlobal: PhoenixGlobal): DecryptSeedResult { } actual fun getAvailableWalletsMeta(phoenixGlobal: PhoenixGlobal): Flow> { - return DataStoreManager( - phoenixGlobal.ctx, - chain = Chain.Mainnet - ).loadGlobalPrefsForWallet().getAvailableWalletsMeta + return JvmGlobalPrefs.of(phoenixGlobal).getAvailableWalletsMeta } actual suspend fun saveAvailableWalletMeta( phoenixGlobal: PhoenixGlobal, metadata: UserWalletMetadata ) { - DataStoreManager( - phoenixGlobal.ctx, - chain = Chain.Mainnet - ).loadGlobalPrefsForWallet().saveAvailableWalletMeta(metadata) + JvmGlobalPrefs.of(phoenixGlobal).saveAvailableWalletMeta(metadata) } actual suspend fun saveAvailableWalletMeta( @@ -68,90 +50,10 @@ actual suspend fun saveAvailableWalletMeta( avatar: String, isHidden: Boolean ) { - DataStoreManager( - phoenixGlobal.ctx, - chain = Chain.Mainnet - ).loadGlobalPrefsForWallet().saveAvailableWalletMeta( + JvmGlobalPrefs.of(phoenixGlobal).saveAvailableWalletMeta( walletId = walletId, name = name, avatar = avatar, isHidden = isHidden ) } - -actual fun platformWriteSeed( - log: Logger, - phoenixGlobal: PhoenixGlobal, - globalPrefs: GlobalPrefs, - writingState: WritingSeedState, - viewModelScope: CoroutineScope, - mnemonics: List, - onWritingSeedError: (WritingSeedState.Error) -> Unit, - onWritingSeedStateWriting: (WritingSeedState.Writing) -> Unit, - isRestoringWallet: Boolean, - isTorEnabled: Boolean, - customElectrumServer: ElectrumConfig.Custom?, - onSeedWritten: (WalletId) -> Unit -) { - if (writingState !is WritingSeedState.Init) return - viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e -> - log.e("failed to write mnemonics to disk: ${e.message}") - onWritingSeedError.invoke( - WritingSeedState.Error.Generic(e) - ) - }) { - log.d("writing mnemonics to disk...") - - onWritingSeedStateWriting.invoke( - WritingSeedState.Writing(mnemonics) - ) - val existingSeeds = SeedManager.loadAndDecryptOrNull(phoenixGlobal)?.map { - it.key to it.value.words - }?.toMap() - - val seed = MnemonicCode.toSeed(mnemonics, "").toByteVector() - val keyManager = LocalKeyManager(seed, NodeParamsManager.chain, NodeParamsManager.remoteSwapInXpub) - val newWalletId = WalletId(keyManager.nodeKeys.nodeKey.publicKey) - - when { - existingSeeds == null -> { - log.e("could not load the existing seed map, aborting...") - onWritingSeedError.invoke( - WritingSeedState.Error.CannotLoadSeedMap - ) - return@launch - } - existingSeeds.containsKey(newWalletId) -> { - log.i("attempting to import a seed that already exists, aborting...") - onWritingSeedError.invoke( - WritingSeedState.Error.SeedAlreadyExists - ) - return@launch - } - else -> { - val newSeedMap = existingSeeds + (newWalletId to mnemonics) - val encrypted = EncryptedSeed.V2.encrypt(newSeedMap) - SeedManager.writeSeedToDisk(phoenixGlobal, encrypted, overwrite = true) - if (isRestoringWallet) { - log.i("successfully restored wallet=$newWalletId") - } else { - log.i("successfully created wallet=$newWalletId") - } - } - } - - globalPrefs.saveLastUsedAppCode(AppVersion.versionCode) - val dataStoreManager = DataStoreManager( - phoenixGlobal.ctx, - chain = NodeParamsManager.chain, - ) - val userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId = newWalletId) - userPrefs.saveIsTorEnabled(isTorEnabled) - userPrefs.saveElectrumServer(customElectrumServer) - - viewModelScope.launch(Dispatchers.Main) { - delay(1000) - onSeedWritten(newWalletId) - } - } -} \ No newline at end of file diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/desktop/Main.kt b/composeApp/src/jvmMain/kotlin/press/mantra/desktop/Main.kt index 76885170..470a7ceb 100644 --- a/composeApp/src/jvmMain/kotlin/press/mantra/desktop/Main.kt +++ b/composeApp/src/jvmMain/kotlin/press/mantra/desktop/Main.kt @@ -40,7 +40,7 @@ import press.mantra.compose.MantraApp import press.mantra.compose.MantraGlobal import press.mantra.compose.PlatformContext import press.mantra.compose.defaultMantraDir -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import java.awt.Dimension import java.io.File @@ -80,16 +80,16 @@ fun main() = application { // 400x600 is the narrowest window the phase's acceptance widths name. LaunchedEffect(window) { window.minimumSize = Dimension(400, 600) } - // TorchTheme wraps both branches, not just the app. PassphraseGate used to sit + // MantraTheme wraps both branches, not just the app. PassphraseGate used to sit // beside MantraApp -- which applies the theme itself -- so it composed under the // default MaterialTheme and its colorScheme and typography were baseline M3 // rather than this app's. It is the first screen a desktop user sees. // - // MantraApp still applies TorchTheme, so the unlocked branch is wrapped twice. + // MantraApp still applies MantraTheme, so the unlocked branch is wrapped twice. // That is deliberate: android and ios enter through MantraApp and would lose the // theme entirely if it moved out, and a second application of the same values // costs one composition of a CompositionLocalProvider. - TorchTheme { + MantraTheme { if (unlocked) { val navController = rememberNavController() MantraApp( diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MarmotKeyPackageFixture.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MarmotKeyPackageFixture.kt index 08a4ef9e..ed4fc7e1 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MarmotKeyPackageFixture.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MarmotKeyPackageFixture.kt @@ -14,6 +14,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.utils.TimeUtils import press.mantra.compose.database.model.MarmotKeyPackage +import press.mantra.compose.database.model.MarmotKeyPackageBundle +import press.mantra.compose.extensions.toHex /** * A real MLS key package for [publicKey], built the same way @@ -21,7 +23,19 @@ import press.mantra.compose.database.model.MarmotKeyPackage * three key generations, a signed leaf node and a signed key package. No relay, no network, * nothing to stub. */ -internal fun marmotKeyPackageFor(publicKey: HexKey): MarmotKeyPackage { +internal fun marmotKeyPackageFor(publicKey: HexKey): MarmotKeyPackage = marmotKeyPackageBundleFor(publicKey).keyPackage + +/** + * The key package and the private keys behind it, as the invitee's own device would hold + * them: [keyPackage] is what the inviter reads off a relay, [bundle] is the row the + * invitee's `processWelcome` opens the Welcome with. [id] is the kind 443 event id the + * Welcome names in its `e` tag, and both rows carry it. + */ +internal class MarmotKeyPackagePair(val keyPackage: MarmotKeyPackage, val bundle: MarmotKeyPackageBundle) { + val id: HexKey get() = keyPackage.id +} + +internal fun marmotKeyPackageBundleFor(publicKey: HexKey, id: HexKey = publicKey): MarmotKeyPackagePair { val initKp = X25519.generateKeyPair() val encKp = X25519.generateKeyPair() val sigKp = Ed25519.generateKeyPair() @@ -65,9 +79,22 @@ internal fun marmotKeyPackageFor(publicKey: HexKey): MarmotKeyPackage { ), ) - return MarmotKeyPackage( - id = publicKey, - publicKey = publicKey, - tlsEncodedMarmotKeyPackage = keyPackage.toTlsBytes(), + val tls = keyPackage.toTlsBytes() + return MarmotKeyPackagePair( + keyPackage = MarmotKeyPackage( + id = id, + publicKey = publicKey, + tlsEncodedMarmotKeyPackage = tls, + ), + // The same three private keys the app stores raw, hex-encoded -- see + // DatabaseMarmotRepository.publishMarmotKeyPackageBundle. + bundle = MarmotKeyPackageBundle( + id = id, + publicKey = publicKey, + tlsEncodedMarmotKeyPackage = tls, + ncryptsecInitPrivateKey = initKp.privateKey.toHex(), + ncryptsecEncryptionPrivateKey = encKp.privateKey.toHex(), + ncryptsecSignaturePrivateKey = sigKp.privateKey.toHex(), + ), ) } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/OwnedRequestQueueJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/OwnedRequestQueueJvmTest.kt new file mode 100644 index 00000000..2f545adf --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/OwnedRequestQueueJvmTest.kt @@ -0,0 +1,138 @@ +package press.mantra.compose.database.dao + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.room3.Room +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.utils.preferences.InternalPrefs +import fr.acinq.phoenix.utils.preferences.UserPrefs +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import okio.Path.Companion.toPath +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.NegentropySynchronizeRequest +import press.mantra.compose.database.model.SynchronizeNostrEventRequest +import press.mantra.compose.database.model.types.SynchronizationFilter +import press.mantra.compose.database.repository.DatabaseNostrRepository +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.IdentityKind +import press.mantra.compose.identity.toWalletId +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Instant + +/** + * The two fetch queues, owned -- Phase 6 of docs/multiple-profiles.md. + * + * A request another identity queued waits for that identity, because what it brings + * back is opened with that identity's key; a request nobody owns -- one from before the + * column, or queued while nothing was open -- is anyone's. And the owner is the + * repository's to stamp, from the identity that is open when the request is queued, + * so that no call site can forget it. + */ +class OwnedRequestQueueJvmTest { + + private val db: MantraDatabase = getRoomDatabase(Room.inMemoryDatabaseBuilder()) + private val scope = CoroutineScope(Job() + Dispatchers.IO) + + private val keyA = PrivateKey(ByteVector32("0a".repeat(32))) + private val keyB = PrivateKey(ByteVector32("0b".repeat(32))) + private val a = keyA.nostrPublicKeyHex() + private val b = keyB.nostrPublicKeyHex() + private val at = Instant.fromEpochSeconds(1_700_000_000) + + @AfterTest + fun tearDown() { + scope.cancel() + db.close() + } + + private fun sync(id: String, owner: String?, createdAt: Instant = at) = SynchronizeNostrEventRequest( + id = id, + purpose = "test", + relayURL = "wss://relay.example", + level = 0, + synchronizationFilters = arrayOf(SynchronizationFilter(kinds = arrayOf(1))), + ownerPublicKey = owner, + createdAt = createdAt, + updatedAt = createdAt, + ) + + private fun negentropy(id: String, owner: String?, createdAt: Instant = at) = NegentropySynchronizeRequest( + id = id, + purpose = "test", + relayURL = "wss://relay.example", + level = 0, + synchronizationFilter = SynchronizationFilter(kinds = arrayOf(1)), + ownerPublicKey = owner, + createdAt = createdAt, + updatedAt = createdAt, + ) + + @Test + fun `a sync request is offered to its owner and to nobody else, and an unowned one to everyone`() = runBlocking { + val dao = db.synchronizeNostrEventRequestDao() + dao.insert(listOf(sync("a-1", owner = a))) + + assertEquals("a-1", dao.observeSynchronizeNostrEventRequestsByStatusFor("pending", a).first()?.id) + assertNull(dao.observeSynchronizeNostrEventRequestsByStatusFor("pending", b).first(), "A's request is not B's to answer") + + dao.insert(listOf(sync("anyone", owner = null, createdAt = at.plus(kotlin.time.Duration.parse("1s"))))) + assertEquals("a-1", dao.observeSynchronizeNostrEventRequestsByStatusFor("pending", a).first()?.id, "oldest first, still") + assertEquals("anyone", dao.observeSynchronizeNostrEventRequestsByStatusFor("pending", b).first()?.id, "nobody's is anyone's") + } + + @Test + fun `a negentropy request is offered the same way`() = runBlocking { + val dao = db.negentropySynchronizeRequestDao() + dao.insert(listOf(negentropy("a-1", owner = a), negentropy("anyone", owner = null, createdAt = at.plus(kotlin.time.Duration.parse("1s"))))) + + assertEquals("a-1", dao.observeNegentropySynchronizeRequestsByStatusFor("pending", a).first()?.id) + assertEquals("anyone", dao.observeNegentropySynchronizeRequestsByStatusFor("pending", b).first()?.id) + } + + private fun prefsFile(name: String) = + Files.createTempFile(name, ".preferences_pb").also { it.toFile().delete() }.toString().toPath() + + private fun identity(key: PrivateKey) = Identity.signing( + id = key.publicKey().xOnly().toWalletId(), + kind = IdentityKind.NostrSecret, + nostrPrivateKey = key, + userPrefs = UserPrefs(PreferenceDataStoreFactory.createWithPath { prefsFile("user") }), + internalPrefs = InternalPrefs(PreferenceDataStoreFactory.createWithPath { prefsFile("internal") }), + business = null, + ) + + @Test + fun `the repository stamps what it queues with the identity that is open, and nothing when none is`() = runBlocking { + val active = MutableStateFlow(null) + val repository = DatabaseNostrRepository(db, scope, activeIdentity = active) + + repository.queueSynchronizeNostrEvent(listOf(sync("nobody", owner = null))) + repository.queueNegentropySynchronizeRequest(listOf(negentropy("nobody-n", owner = null))) + + active.value = identity(keyA) + repository.queueSynchronizeNostrEvent(listOf(sync("as-a", owner = null))) + repository.queueNegentropySynchronizeRequest(listOf(negentropy("as-a-n", owner = null))) + // A caller that named an owner keeps it. + repository.queueSynchronizeNostrEvent(listOf(sync("named-b", owner = b))) + + val syncs = db.synchronizeNostrEventRequestDao().getAll().associate { it.id to it.ownerPublicKey } + assertEquals(mapOf("nobody" to null, "as-a" to a, "named-b" to b), syncs) + val negs = db.negentropySynchronizeRequestDao().getAll().associate { it.id to it.ownerPublicKey } + assertEquals(mapOf("nobody-n" to null, "as-a-n" to a), negs) + + // A negentropy request carries its owner into the REQ it becomes. + assertEquals(a, negentropy("x", owner = a).toSynchronizeNostrEventRequest().ownerPublicKey) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/ReadOnlyGiftWrapDaoJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/ReadOnlyGiftWrapDaoJvmTest.kt new file mode 100644 index 00000000..fe21ab2b --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/ReadOnlyGiftWrapDaoJvmTest.kt @@ -0,0 +1,167 @@ +package press.mantra.compose.database.dao + +import androidx.room3.Room +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.NostrEvent +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.time.Instant + +/** + * A gift wrap addressed to a read-only identity is kept and not opened -- and, since + * docs/multiple-profiles.md, opened later by the inbox sweep, along with any wrap that + * arrived while another profile on the device was the open one. + * + * `storeNostrEvent` is `@Transaction` and indexes inside it, and a wrap addressed to the + * active key that cannot be unsealed throws -- so before the guard in `indexNostrEvent` + * the throw took the event with it, and looked in the logs like a decryption failure. + * Worse, `decryptGiftWrapSeal` builds `KeyPair(privKey = keyPair.privKey)`, and in + * quartz a `KeyPair` given neither key generates a fresh one: the unseal would have run + * with a key nobody has. A read-only identity holds no key by construction, and the + * wrap is what the branch for someone else's mail already does with it: stored, so the + * key that opens it can be signed in later. + */ +class ReadOnlyGiftWrapDaoJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + + @AfterTest + fun closeDb() = db.close() + + private val relay = "wss://relay.example" + + /** The user, as the sync pump sees them when the device holds only their public key. */ + private val us = KeyPair() + private val readOnlyUs = KeyPair(pubKey = us.pubKey) + private val peer = KeyPair() + + private fun wrapTo(recipient: KeyPair): NostrEvent { + val signer = NostrSignerSync(peer) + val seal = signer.signNormal( + createdAt = 1_700_000_000, + kind = SealedRumorEvent.KIND, + tags = emptyArray(), + content = signer.nip44Encrypt( + plaintext = """{"kind":14,"content":"dumela"}""", + toPublicKey = recipient.pubKey.toHexKey(), + ), + ) + val giftWrap = GiftWrapEvent.create( + event = seal, + recipientPubKey = recipient.pubKey.toHexKey(), + createdAt = 1_700_000_100, + ) + return NostrEvent( + id = giftWrap.id, + pubKey = giftWrap.pubKey, + kind = giftWrap.kind, + tags = giftWrap.tags, + content = giftWrap.content, + sig = giftWrap.sig, + createdAt = Instant.fromEpochSeconds(giftWrap.createdAt), + ) + } + + @Test + fun `a wrap addressed to a read-only identity is stored, and its seal is not`() = runBlocking { + val wrap = wrapTo(us) + + db.nostrDao().storeNostrEvent( + nostrEvent = wrap, + relayURL = relay, + synchronizationRelayURLs = listOf(relay), + level = 0, + activeKeyPair = readOnlyUs, + ) + + assertNotNull(db.nostrEventDao().getNostrEventById(wrap.id), "the event itself must survive the failed unseal") + assertEquals(listOf(wrap.id), db.giftWrapMessageDao().getAllGiftWrapMessages().map { it.id }) + assertEquals(emptyList(), db.giftWrapSealDao().getAllGiftWrapSeals(), "nothing was opened") + } + + /** The same wrap, once the key is here: the seal comes out. The guard is only about the key's absence. */ + @Test + fun `the same wrap opens once the identity holds its key`() = runBlocking { + val wrap = wrapTo(us) + + db.nostrDao().storeNostrEvent( + nostrEvent = wrap, + relayURL = relay, + synchronizationRelayURLs = listOf(relay), + level = 0, + activeKeyPair = us, + ) + + assertEquals(1, db.giftWrapSealDao().getAllGiftWrapSeals().size) + } + + // --- The inbox, reopened: Phase 6 of docs/multiple-profiles.md --- + + /** Another profile on the device, open when the wrap for us arrived. */ + private val other = KeyPair() + + /** + * The case the sweep exists for. A wrap for us, fetched while another profile was + * open: stored, found not to be addressed to the active key, and left -- and + * `storeNostrEvent` never indexes an event it already holds, so the live subscription + * re-delivering it later would change nothing. The sweep runs it through the index + * again under our key. + */ + @Test + fun `a wrap fetched under another profile's key is opened by the sweep, once`() = runBlocking { + val wrap = wrapTo(us) + db.nostrDao().storeNostrEvent( + nostrEvent = wrap, + relayURL = relay, + synchronizationRelayURLs = listOf(relay), + level = 0, + activeKeyPair = other, + ) + assertEquals(listOf(wrap.id), db.giftWrapMessageDao().getAllGiftWrapMessages().map { it.id }, "stored") + assertEquals(emptyList(), db.giftWrapSealDao().getAllGiftWrapSeals(), "and not opened") + + // Re-delivery changes nothing: the id is known, and the DAO returns. + db.nostrDao().storeNostrEvent(wrap, relay, listOf(relay), 0, us) + assertEquals(emptyList(), db.giftWrapSealDao().getAllGiftWrapSeals(), "re-delivery does not reopen a known event") + + assertEquals(1, db.nostrDao().reopenInbox(us)) + assertEquals(1, db.giftWrapSealDao().getAllGiftWrapSeals().size, "the seal is out") + assertEquals(1, db.giftWrapPayloadDao().getAllGiftWrapUnsigned().size, "and the payload with it") + + assertEquals(0, db.nostrDao().reopenInbox(us), "nothing left to open") + assertEquals(1, db.giftWrapSealDao().getAllGiftWrapSeals().size) + } + + /** The other profile's sweep finds nothing of ours to open, and a read-only pair opens nothing. */ + @Test + fun `the sweep opens only what is addressed to the key it runs with`() = runBlocking { + val wrap = wrapTo(us) + db.nostrDao().storeNostrEvent(wrap, relay, listOf(relay), 0, other) + + assertEquals(0, db.nostrDao().reopenInbox(other)) + assertEquals(0, db.nostrDao().reopenInbox(readOnlyUs)) + assertEquals(emptyList(), db.giftWrapSealDao().getAllGiftWrapSeals()) + } + + /** The upgrade the npub plan left open: stored read-only, opened once the nsec is here. */ + @Test + fun `a wrap stored while the identity was read-only is opened once it holds its key`() = runBlocking { + val wrap = wrapTo(us) + db.nostrDao().storeNostrEvent(wrap, relay, listOf(relay), 0, readOnlyUs) + assertEquals(emptyList(), db.giftWrapSealDao().getAllGiftWrapSeals()) + + assertEquals(1, db.nostrDao().reopenInbox(us)) + assertEquals(1, db.giftWrapSealDao().getAllGiftWrapSeals().size) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventRequestCompletionJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventRequestCompletionJvmTest.kt new file mode 100644 index 00000000..04e60efa --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventRequestCompletionJvmTest.kt @@ -0,0 +1,74 @@ +package press.mantra.compose.database.dao + +import androidx.room3.Room +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.SynchronizeNostrEventRequest +import press.mantra.compose.database.model.UnsignedNostrEvent +import press.mantra.compose.database.model.types.SynchronizationFilter +import press.mantra.compose.nostr.SignInSync +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Instant + +/** + * `markCompleteIfSent` is conditional in SQL for a reason worth pinning: the handler + * that moves a request to `processed` when an event arrives runs in its own coroutine + * and can land after the subscription has closed. A completion that overwrote it would + * turn "found" back into "found nothing". + */ +class SynchronizeNostrEventRequestCompletionJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + + @AfterTest + fun closeDb() = db.close() + + private val at = Instant.fromEpochSeconds(1_700_000_000) + private val later = Instant.fromEpochSeconds(1_700_000_100) + + private suspend fun placeholder(): Long = db.unsignedNostrEventDao().upsert( + UnsignedNostrEvent(pubKey = "a".repeat(64), kind = 0, tags = emptyArray(), content = "{}", createdAt = at) + ) + + private fun request(id: String, status: String, unsignedNostrEventId: Long) = SynchronizeNostrEventRequest( + id = id, + purpose = SignInSync.PURPOSE, + status = status, + relayURL = "wss://$id.example", + level = 0, + synchronizationFilters = arrayOf(SynchronizationFilter(authors = arrayOf("a".repeat(64)), kinds = SignInSync.KINDS)), + unsignedNostrEventId = unsignedNostrEventId, + createdAt = at, + updatedAt = at, + ) + + private suspend fun statusOf(unsignedNostrEventId: Long, id: String): String? = + db.unsignedNostrEventDao().observeProfile("a".repeat(64)).first() + ?.synchronizeNostrEventRequests?.firstOrNull { it.id == id }?.status + + @Test + fun `a sent request becomes complete, a processed or pending one is left alone`() = runBlocking { + val accountId = placeholder() + val dao = db.synchronizeNostrEventRequestDao() + dao.insert( + listOf( + request("sent", "sent", accountId), + request("processed", "processed", accountId), + request("pending", "pending", accountId), + ) + ) + + val changed = listOf("sent", "processed", "pending").sumOf { dao.markCompleteIfSent(it, SignInSync.STATUS_COMPLETE, later) } + + assertEquals(1, changed) + assertEquals(SignInSync.STATUS_COMPLETE, statusOf(accountId, "sent")) + assertEquals("processed", statusOf(accountId, "processed")) + assertEquals("pending", statusOf(accountId, "pending")) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/UnsignedNostrEventQueueJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/UnsignedNostrEventQueueJvmTest.kt index 0764b909..544ac63e 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/UnsignedNostrEventQueueJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/UnsignedNostrEventQueueJvmTest.kt @@ -39,7 +39,7 @@ import kotlin.time.Instant * * What is asserted here is the DAO's half: that the backlog arrives whole and in a stable * order, and that publishing some rows neither disturbs nor depends on the others. The loop - * that walks it lives in NotaryViewModel, which wants an ActiveWallet flow to stand up; the + * that walks it lives in NotaryViewModel, which wants an Identity flow to stand up; the * [drain] below is shaped like it but is this test's own, so it pins the queue rather than the * collector. */ diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/WelcomeForOtherProfileJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/WelcomeForOtherProfileJvmTest.kt new file mode 100644 index 00000000..a0ed8651 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/WelcomeForOtherProfileJvmTest.kt @@ -0,0 +1,241 @@ +package press.mantra.compose.database.dao + +import androidx.room3.Room +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent +import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.ChatMessage +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 +import press.mantra.compose.extensions.toHex +import press.mantra.compose.nostr.Relays +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * Two of a device's own profiles in one group, which docs/multiple-profiles.md made + * possible and docs/curated-to-mantra-profiles.md's fourth decision refuses. + * + * `ChatRoom` is keyed by the group id alone. Before several profiles on a device the + * second local member could not exist; after it, a user can invite their own second + * profile into their own collective, and a Welcome processed for it would file the + * joiner's MLS state over the holder's. The DAO already declined to overwrite -- "Chat + * Room already exists", at warning level, and nothing else -- so the invitee never + * joined and nobody was told. This pins the refusal and the line that now reports it. + * + * The setup is real: an MLS group on the inviter's database, a key package whose + * private keys the invitee's database holds, and a Welcome sealed the way + * `DatabaseChatRepository.sealGiftWrapPayload` seals one, delivered through + * `storeNostrEvent` under the invitee's key. + */ +class WelcomeForOtherProfileJvmTest { + + /** The inviter's device. */ + private val inviterDb: MantraDatabase = getRoomDatabase(Room.inMemoryDatabaseBuilder()) + + /** The device that holds two profiles: `holder`, already in the group, and `invitee`. */ + private val deviceDb: MantraDatabase = getRoomDatabase(Room.inMemoryDatabaseBuilder()) + + @AfterTest + fun closeDbs() { + inviterDb.close() + deviceDb.close() + } + + private val relay = Relays.DefaultDMRelayList.first().url + private val inviter = KeyPair() + private val invitee = KeyPair() + private val holder = KeyPair() + private val roomId = "b".repeat(64) + private val keyPackageEventId = "d".repeat(64) + + private suspend fun MantraDatabase.seedProfile(publicKey: HexKey, name: String, nostrEventId: String) { + nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = publicKey, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = "0".repeat(128), + ) + ) + profileDao().upsert(Profile(publicKey = publicKey, userName = name, nostrEventId = nostrEventId)) + } + + /** A room with real MLS state on the inviter's device, the inviter its only member. */ + private suspend fun seedInviterRoom(): LocalChatRoom { + val inviterHex = inviter.pubKey.toHexKey() + inviterDb.seedProfile(inviterHex, "inviter", "1".repeat(64)) + inviterDb.seedProfile(invitee.pubKey.toHexKey(), "invitee", "2".repeat(64)) + val mlsGroup = MlsGroup.create( + identity = inviterHex.hexToByteArray(), + initialExtensions = listOf( + MarmotGroupData.bootstrap( + nostrGroupId = roomId, + creatorPubKey = inviterHex, + outboxRelays = listOf(relay), + ).toExtension() + ), + ) + val chatRoom = ChatRoom( + id = roomId, + userPublicKey = inviterHex, + subject = "the collective", + description = null, + mlsGroupState = mlsGroup.saveState().encodeTls().toHex(), + ) + inviterDb.chatRoomDao().upsert(chatRoom) + return LocalChatRoom(chatRoom = chatRoom) + } + + /** + * The inviter adds the invitee and the Welcome reaches the queue; this seals it for + * the invitee the way the notary would, and hands back the wrap as a relay would. + */ + private suspend fun welcomeWrapForInvitee(): NostrEvent { + // The room first: MarmotKeyPackage.publicKey is a foreign key onto Profile, and + // the room's seeding is what gives the invitee one on the inviter's device. + val localChatRoom = seedInviterRoom() + val pair = marmotKeyPackageBundleFor(invitee.pubKey.toHexKey(), id = keyPackageEventId) + inviterDb.marmotKeyPackageDao().upsert(pair.keyPackage) + deviceDb.marmotKeyPackageBundleDao().upsert(pair.bundle) + + inviterDb.marmotOutboundDao().inviteMemberToChatRoom( + localChatRoom = localChatRoom, + peerPublicKey = invitee.pubKey.toHexKey(), + peerKeyPackage = pair.keyPackage, + ) + val payload = inviterDb.giftWrapPayloadDao() + .getByChatRoomAndKinds(roomId, listOf(WelcomeEvent.KIND)) + .single() + + val signer = NostrSignerSync(inviter) + val rumor = RumorAssembler.assembleRumor( + pubKey = signer.pubKey, + ev = EventTemplate( + createdAt = payload.createdAt.epochSeconds, + kind = payload.kind, + tags = payload.tags, + content = payload.content, + ), + ) + val seal = signer.signNormal( + createdAt = 1_700_000_000, + kind = SealedRumorEvent.KIND, + tags = emptyArray(), + content = signer.nip44Encrypt(plaintext = rumor.toJson(), toPublicKey = invitee.pubKey.toHexKey()), + ) + val wrap = GiftWrapEvent.create( + event = seal, + recipientPubKey = invitee.pubKey.toHexKey(), + createdAt = 1_700_000_100, + ) + return NostrEvent( + id = wrap.id, + pubKey = wrap.pubKey, + kind = wrap.kind, + tags = wrap.tags, + content = wrap.content, + sig = wrap.sig, + createdAt = Instant.fromEpochSeconds(wrap.createdAt), + ) + } + + /** The group, already on the device under [owner], with state that is recognisably not the joiner's. */ + private suspend fun seedDeviceRoom(owner: KeyPair): ChatRoom { + val ownerHex = owner.pubKey.toHexKey() + deviceDb.seedProfile(ownerHex, "holder", "3".repeat(64)) + val chatRoom = ChatRoom( + id = roomId, + userPublicKey = ownerHex, + subject = "the collective", + description = null, + mlsGroupState = "0123456789abcdef", + ) + deviceDb.chatRoomDao().upsert(chatRoom) + return chatRoom + } + + private suspend fun deliverToInvitee(wrap: NostrEvent) = deviceDb.nostrDao().storeNostrEvent( + nostrEvent = wrap, + relayURL = relay, + synchronizationRelayURLs = listOf(relay), + level = 0, + activeKeyPair = invitee, + ) + + @Test + fun `a welcome for a group another profile on the device holds is refused and said so`() = runBlocking { + val before = seedDeviceRoom(holder) + deviceDb.seedProfile(invitee.pubKey.toHexKey(), "second self", "4".repeat(64)) + // The holder is in the group with the inviter, so this device knows them by name. + deviceDb.seedProfile(inviter.pubKey.toHexKey(), "inviter", "5".repeat(64)) + + deliverToInvitee(welcomeWrapForInvitee()) + + val room = assertNotNull(deviceDb.chatRoomDao().findChatRoomById(roomId)).chatRoom + assertEquals(before.userPublicKey, room.userPublicKey, "the room changed hands") + assertEquals(before.mlsGroupState, room.mlsGroupState, "the holder's MLS state was overwritten") + + val line = deviceDb.chatMessageDao().getChatMessagesByChatRoomId(roomId).map { it.chatMessage } + .singleOrNull { it.messageType == ChatMessage.TYPE_WELCOME_REFUSED_OTHER_PROFILE } + assertNotNull(line, "the refusal left no line in the room") + assertTrue(line.content.contains("second self"), "the line does not name the invitee: ${line.content}") + assertTrue(line.content.contains("holder"), "the line does not name who holds the group: ${line.content}") + assertTrue(line.content.contains("inviter"), "the line does not name the inviter: ${line.content}") + assertTrue(line.messageType in ChatMessage.MEMBERSHIP_TYPES, "the line would render as a bubble") + + val bundle = assertNotNull(deviceDb.marmotKeyPackageBundleDao().getMarmotKeyPackageBundleById(keyPackageEventId)) + assertFalse(bundle.consumed, "a refused welcome must not consume the key package") + } + + /** The branch the refusal was carved out of: the same profile, welcomed twice, is still a quiet no-op. */ + @Test + fun `a welcome for a group the same profile already holds writes nothing`() = runBlocking { + seedDeviceRoom(invitee) + + deliverToInvitee(welcomeWrapForInvitee()) + + assertEquals( + emptyList(), + deviceDb.chatMessageDao().getChatMessagesByChatRoomId(roomId).map { it.chatMessage.messageType }, + "a redelivered welcome for our own room is not a refusal", + ) + } + + /** And with nothing in the way, the Welcome is a join, which is what tells the fixture is real. */ + @Test + fun `the same welcome joins when no profile on the device holds the group`() = runBlocking { + deviceDb.seedProfile(invitee.pubKey.toHexKey(), "second self", "4".repeat(64)) + + deliverToInvitee(welcomeWrapForInvitee()) + + val room = assertNotNull(deviceDb.chatRoomDao().findChatRoomById(roomId), "the invitee did not join").chatRoom + assertEquals(invitee.pubKey.toHexKey(), room.userPublicKey) + assertNotNull(room.mlsGroupState, "the joiner's MLS state was not filed") + assertTrue( + assertNotNull(deviceDb.marmotKeyPackageBundleDao().getMarmotKeyPackageBundleById(keyPackageEventId)).consumed, + "a join consumes the key package", + ) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/FindDirectMessageRoomJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/FindDirectMessageRoomJvmTest.kt new file mode 100644 index 00000000..80cb7557 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/FindDirectMessageRoomJvmTest.kt @@ -0,0 +1,129 @@ +package press.mantra.compose.database.repository + +import androidx.room3.Room +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +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.NostrEvent +import press.mantra.compose.database.model.Participant +import press.mantra.compose.database.model.Profile +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * What counts as a conversation between two keys. + * + * The rule `ChatRoomMessagingViewModel.initiateNewChat` applied inline since it was + * written -- the two of us and nobody else, or yourself alone -- now has a name and a + * test, because the profile preview reads it too and the two must agree. + */ +class FindDirectMessageRoomJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + private val scope = CoroutineScope(Job()) + private val repository = DatabaseChatRepository(db, scope) + + private val me = "a".repeat(64) + private val alice = "b".repeat(64) + private val bob = "c".repeat(64) + + @AfterTest + fun closeDb() { + scope.cancel() + db.close() + } + + @Test + fun `a room with exactly the two of us is the conversation`() = runBlocking { + seedPeople(me, alice, bob) + seedRoom("group", me, alice, bob) + seedRoom("dm", me, alice) + + assertEquals("dm", repository.findDirectMessageRoom(me, alice)?.chatRoom?.id) + } + + /** A group of two is not told apart from a direct message; a group of three is not one. */ + @Test + fun `a room with a third member is not`() = runBlocking { + seedPeople(me, alice, bob) + seedRoom("group", me, alice, bob) + + assertNull(repository.findDirectMessageRoom(me, alice)) + } + + /** The peer in a room I am not in is not a conversation with me. */ + @Test + fun `a room the peer has with somebody else is not`() = runBlocking { + seedPeople(me, alice, bob) + seedRoom("theirs", alice, bob) + + assertNull(repository.findDirectMessageRoom(me, alice)) + } + + @Test + fun `a room with myself alone is the conversation with myself`() = runBlocking { + seedPeople(me, alice) + seedRoom("notes", me) + seedRoom("dm", me, alice) + + assertEquals("notes", repository.findDirectMessageRoom(me, me)?.chatRoom?.id) + assertEquals("dm", repository.findDirectMessageRoom(me, alice)?.chatRoom?.id) + } + + @Test + fun `nobody in any room is nothing`() = runBlocking { + seedPeople(me, alice) + + assertNull(repository.findDirectMessageRoom(me, alice)) + } + + // --- fixtures ----------------------------------------------------------------------- + + /** A participant row needs a profile, and a profile needs the event it was read off. */ + private suspend fun seedPeople(vararg publicKeys: HexKey) { + publicKeys.forEach { publicKey -> + val nostrEventId = publicKey.take(8).repeat(8) + db.nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = publicKey, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = "0".repeat(128), + ) + ) + db.profileDao().upsert(Profile(publicKey = publicKey, userName = publicKey.take(4), nostrEventId = nostrEventId)) + } + } + + private suspend fun seedRoom(id: String, vararg members: HexKey) { + db.chatRoomDao().upsert( + ChatRoom( + id = id, + userPublicKey = me, + subject = null, + description = null, + mlsGroupState = null, + ) + ) + db.participantDao().upsert( + members.map { publicKey -> + Participant( + participantPublicKey = publicKey, + chatRoomId = id, + relayHint = null, + ) + } + ) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/ForgetLocalAccountJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/ForgetLocalAccountJvmTest.kt new file mode 100644 index 00000000..d18e4c2c --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/ForgetLocalAccountJvmTest.kt @@ -0,0 +1,56 @@ +package press.mantra.compose.database.repository + +import androidx.room3.Room +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.nostr.SignInSync +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +/** + * Forgetting a key at the rows: the account it made goes, and so does everything that + * hung off it -- while an unrelated account is untouched. + */ +class ForgetLocalAccountJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + private val scope = CoroutineScope(Job()) + private val repository = DatabaseNostrRepository(db, scope) + + private val forgotten = "a".repeat(64) + private val kept = "b".repeat(64) + + @AfterTest + fun closeDb() { + scope.cancel() + db.close() + } + + @Test + fun `the account and its requests go, the other account stays`() = runBlocking { + repository.signInToProfile(forgotten) + repository.signInToProfile(kept) + val account = repository.getLocalAccounts().first { it.unsignedNostrEvent?.pubKey == forgotten } + val placeholderId = assertNotNull(account.unsignedNostrEvent).id + repository.queueSynchronizeNostrEvent( + SignInSync.requests(placeholderId, forgotten, listOf(RelayUrlNormalizer.normalize("wss://one.example")), level = 0) + ) + assertEquals(1, repository.observeLocalAccount(forgotten).first()?.synchronizeNostrEventRequests?.size) + + repository.forgetLocalAccount(forgotten) + + assertEquals(listOf(kept), repository.getLocalAccounts().map { it.unsignedNostrEvent?.pubKey }) + assertEquals(0, db.synchronizeNostrEventRequestDao().observeSynchronizeNostrEventRequestByPurposeAndStatusCount(SignInSync.PURPOSE, listOf("pending")).first(), + "the requests cascaded with the row they hung off") + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/SetUpProfileForExistingKeyJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/SetUpProfileForExistingKeyJvmTest.kt new file mode 100644 index 00000000..7aff683c --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/SetUpProfileForExistingKeyJvmTest.kt @@ -0,0 +1,70 @@ +package press.mantra.compose.database.repository + +import androidx.room3.Room +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.GENESIS_AT +import press.mantra.compose.database.builder.getRoomDatabase +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The exit from a sign-in that found nothing, at the rows. + * + * `signInToProfile` plants one kind-0 row per signed-in key, stamped `GENESIS_AT`, and + * `getLocalAccounts` is *every* kind-0 row. So giving the key a profile must write over + * that row, not beside it: two kind-0 rows for one pubkey would be two accounts, and + * `observeLocalAccount` would answer with whichever the query happened to return. + */ +class SetUpProfileForExistingKeyJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + private val scope = CoroutineScope(Job()) + private val repository = DatabaseNostrRepository(db, scope) + + private val publicKey = "a".repeat(64) + + @AfterTest + fun closeDb() { + scope.cancel() + db.close() + } + + @Test + fun `the placeholder is written over, once, and the rest of the bootstrap arrives beside it`() = runBlocking { + repository.signInToProfile(publicKey) + val placeholder = repository.getLocalAccounts().single().unsignedNostrEvent + assertNotNull(placeholder) + assertEquals(GENESIS_AT, placeholder.signedAt) + + repository.setUpProfileForExistingKey( + publicKey = publicKey, + placeholderUnsignedNostrEventId = placeholder.id, + name = "Alan", + biography = null, + ) + + val accounts = repository.getLocalAccounts() + assertEquals(1, accounts.size, "one kind-0 row for the key, still") + val profile = accounts.single().unsignedNostrEvent + assertNotNull(profile) + assertEquals(placeholder.id, profile.id, "the same row") + assertNull(profile.signedAt, "unsigned again, so the notary signs it") + assertTrue(profile.content.contains("Alan"), "carrying the profile the user typed") + + // The five other bootstrap events, queued for the notary alongside it. + val queued = db.unsignedNostrEventDao().observeUnsignedNostrEvents(publicKey).first() + assertEquals(6, queued.size) + assertEquals(listOf(0), queued.filter { it.kind == 0 }.map { it.kind }) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/SignInToProfileTwiceJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/SignInToProfileTwiceJvmTest.kt new file mode 100644 index 00000000..cd124312 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/SignInToProfileTwiceJvmTest.kt @@ -0,0 +1,54 @@ +package press.mantra.compose.database.repository + +import androidx.room3.Room +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.GENESIS_AT +import press.mantra.compose.database.builder.getRoomDatabase +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Signing in twice plants one account. + * + * Until the read-only kind nothing called `signInToProfile` twice for one pubkey: the + * writers refused a second sign-in before it got there. Pasting the nsec of a key held + * read-only is the first path that signs in a pubkey whose account already exists, and + * a second placeholder would be two kind-0 rows for one pubkey -- two accounts + * disagreeing about which is this one. `getLocalAccounts` is every kind-0 row, so the + * count is the assertion. + */ +class SignInToProfileTwiceJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + private val scope = CoroutineScope(Job()) + private val repository = DatabaseNostrRepository(db, scope) + + private val publicKey = "a".repeat(64) + private val someoneElse = "b".repeat(64) + + @AfterTest + fun closeDb() { + scope.cancel() + db.close() + } + + @Test + fun `a second sign-in for the same key plants nothing, and another key still gets its own account`() = runBlocking { + repository.signInToProfile(publicKey) + repository.signInToProfile(publicKey) + repository.signInToProfile(someoneElse) + + val accounts = repository.getLocalAccounts() + + assertEquals(listOf(publicKey, someoneElse).sorted(), accounts.mapNotNull { it.unsignedNostrEvent?.pubKey }.sorted()) + val placeholder = accounts.first { it.unsignedNostrEvent?.pubKey == publicKey }.unsignedNostrEvent!! + assertEquals(GENESIS_AT, placeholder.signedAt, "the placeholder is what it was: signed at genesis, so the notary skips it") + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/AddProfileFromInsideJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/AddProfileFromInsideJvmTest.kt new file mode 100644 index 00000000..604b095c --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/AddProfileFromInsideJvmTest.kt @@ -0,0 +1,186 @@ +package press.mantra.compose.identity + +import androidx.room3.Room +import co.touchlab.kermit.Logger +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.lightning.Lightning +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.jvm.BusinessManager +import fr.acinq.phoenix.managers.DataStoreManager +import fr.acinq.phoenix.managers.NodeParamsManager +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.security.JvmKeyStore +import fr.acinq.phoenix.utils.PlatformContext +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.repository.DatabaseMarmotRepository +import press.mantra.compose.database.repository.DatabaseNostrRepository +import press.mantra.compose.ui.view.model.CreateProfileViewModel +import press.mantra.compose.ui.view.model.SignInToProfileViewModel +import press.mantra.compose.ui.view.model.SovereignWalletViewModel +import press.mantra.compose.ui.view.model.bareKeyProfileWriter +import press.mantra.compose.ui.view.state.CreateProfileUIState +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A profile added from inside -- Phase 5 of docs/multiple-profiles.md -- through the + * real view models on an in-memory database. + * + * A is open. B's nsec is committed through the sign-in view model, as the switcher's + * "sign in with a key" row leads to; the tail switches; B is open; and back. Both + * accounts are intact, each with its own kind 0 and neither with the other's. Then a + * profile created through the create view model with the bare-key writer, as the + * switcher's "create a new profile" row leads to: it lists as a bare key, its account + * holds the six bootstrap rows, and no node ran -- the assertion the nsec round trip + * already makes, made of a profile the app made rather than one it was handed. + */ +class AddProfileFromInsideJvmTest { + + private lateinit var storeDir: File + private lateinit var appDir: File + private lateinit var phoenixGlobal: PhoenixGlobal + + private val db: MantraDatabase = getRoomDatabase(Room.inMemoryDatabaseBuilder()) + private val scope = CoroutineScope(Job() + Dispatchers.IO) + private val nostrRepository = DatabaseNostrRepository(db, scope) + private val marmotRepository = DatabaseMarmotRepository(db, scope) + + private val log = Logger.withTag("AddProfileFromInsideJvmTest") + private val keyA = PrivateKey(Lightning.randomBytes(32)) + private val keyB = PrivateKey(Lightning.randomBytes(32)) + + @BeforeTest + fun setUp() { + storeDir = Files.createTempDirectory("mantra-add-inside-store").toFile() + appDir = Files.createTempDirectory("mantra-add-inside-app").toFile() + JvmKeyStore.lock() + JvmKeyStore.unlock("correct horse battery staple".toCharArray(), storeDir) + // No GlobalPrefs of the test's own: DataStoreManager caches one per process, and + // when this class is the first in the JVM to populate it, a second instance over + // the same file is exactly what DataStore refuses. Everything here goes through + // the view model, and so through the cache. + phoenixGlobal = PhoenixGlobal(PlatformContext(applicationDir = appDir)) + } + + @AfterTest + fun tearDown() { + scope.cancel() + db.close() + JvmKeyStore.lock() + storeDir.deleteRecursively() + appDir.deleteRecursively() + } + + private fun sovereign() = SovereignWalletViewModel(phoenixGlobal, stopBusiness = {}) + + private fun signInViewModel(sovereign: SovereignWalletViewModel) = SignInToProfileViewModel( + nostrRepository = nostrRepository, + writeNostrKey = { key -> sovereign.writeNostrKey(key) }, + writeNostrPublicKey = { pubkey -> sovereign.writeNostrPublicKey(pubkey) }, + writeRecoveryPhrase = { _, _, onError -> onError(press.mantra.compose.ui.view.model.WritingSeedState.Error.CannotLoadSeedMap) }, + ) + + private fun activated(stored: StoredIdentity.NostrSecret): Identity { + val dataStoreManager = DataStoreManager(phoenixGlobal.ctx, chain = NodeParamsManager.chain) + return Identity.signing( + id = stored.id, + kind = IdentityKind.NostrSecret, + nostrPrivateKey = stored.privateKey, + userPrefs = dataStoreManager.loadUserPrefsForWallet(stored.id), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(stored.id), + business = null, + ) + } + + private suspend fun listed(sovereign: SovereignWalletViewModel, size: Int): Map { + val done = CompletableDeferred() + sovereign.listIdentities { done.complete(Unit) } + withTimeout(15_000) { done.await() } + return sovereign.availableIdentities.first { it.size == size } + } + + private suspend fun accountsOf(key: PrivateKey) = + nostrRepository.getLocalAccounts().filter { it.unsignedNostrEvent?.pubKey == key.nostrPublicKeyHex() } + + @Test + fun `a key signed in from inside is switched to, and the profile it was signed in from stays`() = runBlocking { + // A is open, the way the app gets there. + val sovereign = sovereign() + val signedInA = assertIs( + signInViewModel(sovereign).commit(SignInCredential.NostrSecret(keyA, keyA.nostrPublicKeyHex())) + ) + val storedA = assertIs(listed(sovereign, 1)[signedInA.id]) + sovereign.setActiveIdentity(activated(storedA)) + + // B, from the switcher's row: the sign-in screen's commit, then its tail. + val signedInB = assertIs( + signInViewModel(sovereign).commit(SignInCredential.NostrSecret(keyB, keyB.nostrPublicKeyHex())) + ) + val identities = listed(sovereign, 2) + val storedB = assertIs(identities[signedInB.id]) + assertIs(identities[signedInA.id], "the profile signed in from is still listed") + sovereign.switchToIdentity(signedInB.id) + assertNull(sovereign.activeIdentity.value) + sovereign.setActiveIdentity(activated(storedB)) + assertEquals(keyB.nostrPublicKeyHex(), sovereign.activeIdentity.value?.nostrPublicKey) + + // And back. + sovereign.switchToIdentity(signedInA.id) + sovereign.setActiveIdentity(activated(storedA)) + assertEquals(keyA.nostrPublicKeyHex(), sovereign.activeIdentity.value?.nostrPublicKey) + + // Both accounts intact, each with its own kind 0 and neither with the other's. + assertEquals(1, accountsOf(keyA).size) + assertEquals(1, accountsOf(keyB).size) + assertEquals(2, nostrRepository.getLocalAccounts().size) + + // Pasting A's key again from inside is refused with the id A is listed under. + val again = assertIs( + signInViewModel(sovereign).commit(SignInCredential.NostrSecret(keyA, keyA.nostrPublicKeyHex())) + ) + assertEquals(CredentialProblem.AlreadyOnThisDevice, again.problem) + assertEquals(signedInA.id, again.alreadyHere, "the refusal names the profile to switch to") + } + + @Test + fun `a profile created from inside is a bare key with its six bootstrap rows, and no node ran`() = runBlocking { + val sovereign = sovereign() + val created = CompletableDeferred() + val viewModel = CreateProfileViewModel( + initialCreateProfileUIState = CreateProfileUIState.ConfirmInput(name = "Bea", bio = "Second"), + nostrRepository = nostrRepository, + marmotRepository = marmotRepository, + ) + viewModel.createProfileFormState.nameField.textFieldState.edit { append("Bea") } + viewModel.createProfileFormState.biographyField.textFieldState.edit { append("Second") } + + viewModel.createAccount(sovereign.bareKeyProfileWriter()) { created.complete(it) } + val id = withTimeout(15_000) { created.await() } + + val stored = assertIs(listed(sovereign, 1)[id], "a key, not a wallet") + val account = nostrRepository.getLocalAccounts().single { it.unsignedNostrEvent?.pubKey == stored.nostrPublicKey } + assertNull(account.unsignedNostrEvent?.signedAt, "the kind 0 waits for the notary, which starts when the profile is opened") + val queued = nostrRepository.observeUnsignedNostrEvents(stored.nostrPublicKey).first() + assertEquals(6, queued.size, "the bootstrap: the kind 0, the contact list, and the relay lists") + assertEquals(0, queued.first().kind, "the kind 0 first") + assertTrue(BusinessManager.businessFlow.value.isEmpty(), "no PhoenixBusiness was started for a bare key") + assertEquals(CreateProfileUIState.ConfirmInput(name = "Bea", bio = "Second"), viewModel.createProfileUIState.value, "the screen leaves through the tail, not an error") + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/CredentialParserJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/CredentialParserJvmTest.kt new file mode 100644 index 00000000..d661009a --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/CredentialParserJvmTest.kt @@ -0,0 +1,141 @@ +package press.mantra.compose.identity + +import press.mantra.compose.extensions.hexToNpubHrp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotEquals +import kotlin.test.assertNull + +/** + * Every row of the sign-in screen's table, in and out. + * + * The accepted rows all use NIP-06's own vector, so the three ways of writing one key -- + * twelve words, an nsec, sixty-four hex characters -- are asserted to land on the same + * public key. That equality is what the duplicate check in `IdentityWriter` rests on. + */ +class CredentialParserJvmTest { + + private val words = "leader monkey parrot ring guide accident before fence cannon height naive bean" + private val nsec = "nsec10allq0gjx7fddtzef0ax00mdps9t2kmtrldkyjfs8l5xruwvh2dq0lhhkp" + private val hex = "7f7ff03d123792d6ac594bfa67bf6d0c0ab55b6b1fdb6249303fe861f1ccba9a" + private val publicKey = "17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917" + private val npub = "npub1zutzeysacnf9rru6zqwmxd54mud0k44tst6l70ja5mhv8jjumytsd2x7nu" + + private fun recognised(input: String): SignInCredential = + assertIs(CredentialParser.parse(input), "for '$input'").credential + + private fun refused(input: String): CredentialProblem = + assertIs(CredentialParser.parse(input), "for '$input'").problem + + @Test + fun `twelve words are a recovery phrase, and derive the NIP-06 key`() { + val credential = assertIs(recognised(words)) + assertEquals(words.split(" "), credential.words) + assertEquals(publicKey, credential.nostrPublicKey) + } + + @Test + fun `a phrase pasted with line breaks and extra spaces is the same phrase`() { + val credential = assertIs( + recognised(" leader monkey\nparrot ring\tguide accident before fence cannon height naive bean \n") + ) + assertEquals(publicKey, credential.nostrPublicKey) + } + + @Test + fun `an nsec, a nostr-prefixed nsec and the hex all name the same key`() { + for (input in listOf(nsec, "nostr:$nsec", hex, nsec.uppercase(), hex.uppercase())) { + val credential = assertIs(recognised(input), "for '$input'") + assertEquals(publicKey, credential.nostrPublicKey, "for '$input'") + assertEquals(hex, credential.privateKey.value.toHex(), "for '$input'") + } + } + + @Test + fun `nothing is nothing`() { + assertEquals(CredentialProblem.Empty, refused("")) + assertEquals(CredentialProblem.Empty, refused(" \n ")) + } + + @Test + fun `a single word that is not a key is not recognised`() { + assertEquals(CredentialProblem.NotRecognised, refused("hello")) + assertEquals(CredentialProblem.NotRecognised, refused("nostr:")) + assertEquals(CredentialProblem.NotRecognised, refused("7f7ff03d")) // hex, but not 64 of it + } + + @Test + fun `a wrong word, a missing word or a capitalised word is an invalid phrase`() { + assertEquals(CredentialProblem.InvalidPhrase, refused(words.replace("bean", "beans"))) + assertEquals(CredentialProblem.InvalidPhrase, refused(words.substringBeforeLast(" "))) + // The checksum would pass if this were lower-cased; the wordlist is lower-case and + // a capital is a paste artefact the user should see rather than have silently fixed. + assertEquals(CredentialProblem.InvalidPhrase, refused(words.replaceFirstChar { it.uppercase() })) + } + + @Test + fun `an npub, a nostr-prefixed npub and the upper-cased form all name the same public key`() { + for (input in listOf(npub, "nostr:$npub", npub.uppercase())) { + val credential = assertIs(recognised(input), "for '$input'") + assertEquals(publicKey, credential.nostrPublicKey) + } + } + + /** + * Sixty-four hex characters are a secret and stay one: a private key and an x-only + * public key are the same size, and the parser cannot tell them apart -- nor may it + * guess, since an x coordinate is almost always also a valid scalar and the guess + * would never fire. The public key of the vector, pasted as hex, is a *different* + * secret whose npub the confirm step shows. + */ + @Test + fun `sixty-four hex characters are still a secret, even when they are a public key`() { + val credential = assertIs(recognised(publicKey)) + assertNotEquals(publicKey, credential.nostrPublicKey) + } + + @Test + fun `an npub that does not decode, or names no point on the curve, is an invalid key`() { + assertEquals(CredentialProblem.InvalidKey, refused(npub.dropLast(1) + "q")) + // The field prime, bech32-encoded under npub: sixty-four hex characters that are + // not an x coordinate. + val notOnTheCurve = "fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f".hexToNpubHrp() + assertEquals(CredentialProblem.InvalidKey, refused(notOnTheCurve)) + } + + @Test + fun `an encrypted key is named for what it is`() { + assertEquals(CredentialProblem.EncryptedKey, refused("ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p")) + } + + @Test + fun `an nsec that does not decode, or a hex that is not a valid secret, is an invalid key`() { + assertEquals(CredentialProblem.InvalidKey, refused(nsec.dropLast(1) + "q")) + assertEquals(CredentialProblem.InvalidKey, refused("0".repeat(64))) + assertEquals(CredentialProblem.InvalidKey, refused("f".repeat(64))) + } + + // --- the address form: an npub or nothing --- + + /** The three spellings the sign-in accepts, plus the whitespace a paste brings. */ + @Test + fun `an address is an npub in any of its spellings`() { + for (input in listOf(npub, "nostr:$npub", npub.uppercase(), " $npub\n")) { + assertEquals(publicKey, CredentialParser.npubOrNull(input), "for '$input'") + } + } + + /** + * A field asking whom to write to must never take a secret for an answer, and it + * takes nothing else either: the hex the sign-in reads as a secret, an nprofile's + * TLV, and an npub with a bad checksum are all nothing here. + */ + @Test + fun `an address is not a secret, a hex, an nprofile or a broken npub`() { + val nprofile = "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p" + for (input in listOf(nsec, "nostr:$nsec", hex, publicKey, nprofile, npub.dropLast(1) + "q", "", " ")) { + assertNull(CredentialParser.npubOrNull(input), "for '$input'") + } + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/ForgetIdentityJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/ForgetIdentityJvmTest.kt new file mode 100644 index 00000000..53651918 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/ForgetIdentityJvmTest.kt @@ -0,0 +1,95 @@ +package press.mantra.compose.identity + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.utils.preferences.InternalPrefs +import fr.acinq.phoenix.utils.preferences.UserPrefs +import kotlinx.coroutines.runBlocking +import okio.Path.Companion.toPath +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import press.mantra.compose.repository.NostrRepository +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The sequence, and where it stops. + * + * Forgetting is four effects in an order that matters -- the credential out of the + * file, the metadata hidden, the account rows gone, then the caller's tail -- so that a + * failure partway leaves the credential on disk rather than an identity the selector + * lists but nothing can open. A refusal at the first step must leave the other two + * untouched: an identity that is still on the device must still have its account. + * + * The two writers are recorded rather than run; `IdentityWriterJvmTest` covers what + * they do to the disk. + */ +class ForgetIdentityJvmTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val privateKey = PrivateKey(ByteVector32("04".repeat(32))) + + private fun prefsStore(name: String) = PreferenceDataStoreFactory.createWithPath { + temporaryFolder.newFolder().resolve("$name.preferences_pb").path.toPath() + } + + private fun readOnlyIdentity() = Identity.readOnly( + id = privateKey.publicKey().xOnly().toWalletId(), + nostrPublicKey = privateKey.nostrPublicKeyHex(), + userPrefs = UserPrefs(prefsStore("user")), + internalPrefs = InternalPrefs(prefsStore("internal")), + ) + + private class Recorder : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + val effects = mutableListOf() + override suspend fun forgetLocalAccount(publicKey: HexKey) { + effects += "forgetLocalAccount:$publicKey" + } + } + + private suspend fun forget(recorder: Recorder, result: IdentityWriter.ForgetNostrCredentialResult) = + ForgetIdentity.forget( + identity = readOnlyIdentity(), + nostrRepository = recorder, + forgetNostrCredential = { recorder.effects += "forgetNostrCredential"; result }, + hideIdentityMetadata = { recorder.effects += "hideIdentityMetadata" }, + ) + + @Test + fun `the credential comes out first, then the metadata, then the account`() = runBlocking { + val recorder = Recorder() + + val outcome = forget(recorder, IdentityWriter.ForgetNostrCredentialResult.Forgotten) + + assertEquals(ForgetIdentity.Outcome.Forgotten, outcome) + assertEquals( + listOf("forgetNostrCredential", "hideIdentityMetadata", "forgetLocalAccount:${privateKey.nostrPublicKeyHex()}"), + recorder.effects, + ) + } + + @Test + fun `a profile with a wallet attached is refused before anything else is touched`() = runBlocking { + val recorder = Recorder() + + val outcome = forget(recorder, IdentityWriter.ForgetNostrCredentialResult.WalletAttached) + + assertEquals(ForgetIdentity.Outcome.WalletAttached, outcome) + assertEquals(listOf("forgetNostrCredential"), recorder.effects) + } + + @Test + fun `a store that cannot be read stops the sequence at its first step`() = runBlocking { + val recorder = Recorder() + + val outcome = forget(recorder, IdentityWriter.ForgetNostrCredentialResult.CannotLoadKeys) + + assertEquals(ForgetIdentity.Outcome.CannotForget, outcome) + assertEquals(listOf("forgetNostrCredential"), recorder.effects) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentitySwitchJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentitySwitchJvmTest.kt new file mode 100644 index 00000000..0e94f355 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentitySwitchJvmTest.kt @@ -0,0 +1,276 @@ +package press.mantra.compose.identity + +import androidx.room3.Room +import co.touchlab.kermit.Logger +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.lightning.Lightning +import fr.acinq.phoenix.PhoenixBusiness +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.jvm.BusinessManager +import fr.acinq.phoenix.managers.DataStoreManager +import fr.acinq.phoenix.managers.NodeParamsManager +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.security.JvmKeyStore +import fr.acinq.phoenix.utils.PlatformContext +import press.mantra.compose.extensions.getGlobalPrefs +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.UnsignedNostrEvent +import press.mantra.compose.database.repository.DatabaseChatRepository +import press.mantra.compose.database.repository.DatabaseMarmotRepository +import press.mantra.compose.database.repository.DatabaseNostrRepository +import press.mantra.compose.ui.view.model.NavigationViewModel +import press.mantra.compose.ui.view.model.NotaryViewModel +import press.mantra.compose.ui.view.model.SovereignWalletViewModel +import press.mantra.compose.ui.view.state.NavigationUIState +import press.mantra.compose.ui.view.state.StartupChoice +import fr.acinq.phoenix.data.EmptyWalletId +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Clock + +/** + * A switch, end to end -- Phase 2 of docs/multiple-profiles.md -- through the real + * `SovereignWalletViewModel`, with a real notary and a real navigation machine on an + * in-memory database. + * + * The assertion a switch is for: the notary signs as whoever is open and nobody else. + * Two bare keys, A and B; A active, and a kind 1 queued for A is signed. Switch to B -- + * the identity clears, the machine goes to startup, B is activated the way startup + * does -- and a kind 1 queued for A now waits. Switch back, and it goes out. + * + * And the one effect a switch has outside the identity flow: the node of the profile + * being left is stopped, once, and only when there was one. The manager is injected as + * a recorder, since a node cannot be started here; a `PhoenixBusiness` can be built + * without one, because everything in it is lazy, and that is enough to make the branch + * true. + * + * Then Phase 3's half: every activation saves which profile is open, so that a cold + * boot -- a fresh view model over the same directory -- resolves to it without asking; + * and a forget tail clears it, so the boot after shows the list. + */ +class IdentitySwitchJvmTest { + + private lateinit var storeDir: File + private lateinit var appDir: File + private lateinit var phoenixGlobal: PhoenixGlobal + + private val db: MantraDatabase = getRoomDatabase(Room.inMemoryDatabaseBuilder()) + private val scope = CoroutineScope(Job() + Dispatchers.IO) + private val nostrRepository = DatabaseNostrRepository(db, scope) + private val chatRepository = DatabaseChatRepository(db, scope) + private val marmotRepository = DatabaseMarmotRepository(db, scope) + + private val log = Logger.withTag("IdentitySwitchJvmTest") + private val keyA = PrivateKey(Lightning.randomBytes(32)) + private val keyB = PrivateKey(Lightning.randomBytes(32)) + + private val stopped = mutableListOf() + + @BeforeTest + fun setUp() { + storeDir = Files.createTempDirectory("mantra-switch-store").toFile() + appDir = Files.createTempDirectory("mantra-switch-app").toFile() + JvmKeyStore.lock() + JvmKeyStore.unlock("correct horse battery staple".toCharArray(), storeDir) + // No GlobalPrefs of the test's own -- see AddProfileFromInsideJvmTest.setUp: the + // process-wide cache and a second instance over the same file is what DataStore + // refuses, and which of the two this class gets depends on the order of the run. + phoenixGlobal = PhoenixGlobal(PlatformContext(applicationDir = appDir)) + } + + @AfterTest + fun tearDown() { + scope.cancel() + db.close() + JvmKeyStore.lock() + storeDir.deleteRecursively() + appDir.deleteRecursively() + } + + private suspend fun signIn(key: PrivateKey): WalletId { + val written = assertIs( + IdentityWriter.writeNostrKey(log, phoenixGlobal, getGlobalPrefs(phoenixGlobal), key, isTorEnabled = false, customElectrumServer = null) + ) + nostrRepository.signInToProfile(key.nostrPublicKeyHex()) + return written.id + } + + /** What the startup screen's bare-key branch builds. */ + private fun activated(stored: StoredIdentity.NostrSecret): Identity { + val dataStoreManager = DataStoreManager(phoenixGlobal.ctx, chain = NodeParamsManager.chain) + return Identity.signing( + id = stored.id, + kind = IdentityKind.NostrSecret, + nostrPrivateKey = stored.privateKey, + userPrefs = dataStoreManager.loadUserPrefsForWallet(stored.id), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(stored.id), + business = null, + ) + } + + private suspend fun queueNote(key: PrivateKey, content: String) { + val now = Clock.System.now() + db.unsignedNostrEventDao().upsert( + UnsignedNostrEvent(pubKey = key.nostrPublicKeyHex(), kind = 1, tags = emptyArray(), content = content, createdAt = now, updatedAt = now, savedAt = now) + ) + } + + private suspend fun unsignedFor(key: PrivateKey) = + nostrRepository.observeUnsignedNostrEvents(key.nostrPublicKeyHex()).first() + + @Test + fun `the notary signs as whoever is open and nobody else`() = runBlocking { + val idA = signIn(keyA) + val idB = signIn(keyB) + + val sovereign = SovereignWalletViewModel(phoenixGlobal, stopBusiness = { stopped += it }) + withTimeout(15_000) { sovereign.availableIdentities.first { it.size == 2 } } + val storedA = assertIs(sovereign.availableIdentities.value[idA]) + val storedB = assertIs(sovereign.availableIdentities.value[idB]) + + val navigation = NavigationViewModel( + activeIdentityStateFlow = sovereign.activeIdentity, + initialNavigationUIState = NavigationUIState.Loading(""), + nostrRepository = nostrRepository, + scope = scope, + ) + NotaryViewModel( + activeIdentityStateFlow = sovereign.activeIdentity, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + marmotRepository = marmotRepository, + scope = scope, + ) + + // A is open, and what A queues goes out. + sovereign.setActiveIdentity(activated(storedA)) + queueNote(keyA, "first, as A") + withTimeout(15_000) { nostrRepository.observeUnsignedNostrEvents(keyA.nostrPublicKeyHex()).first { it.isEmpty() } } + + // The switch: the identity clears, the machine goes to startup, and startup opens B. + sovereign.switchToIdentity(idB) + assertEquals(idB, sovereign.desiredWalletId.value) + assertNull(sovereign.activeIdentity.value) + assertTrue(sovereign.startWalletImmediately.value, "a switch is the user saying which one") + withTimeout(15_000) { navigation.navigationUIState.first { it is NavigationUIState.StartupPhoenix } } + sovereign.setActiveIdentity(activated(storedB)) + val routed = withTimeout(15_000) { + navigation.navigationUIState.first { it is NavigationUIState.UnqueuedProfileSynchronization } + } + assertEquals(keyB.nostrPublicKeyHex(), (routed as NavigationUIState.UnqueuedProfileSynchronization).unsignedNostrEvent.pubKey) + + // What A queues while B is open waits for A. + queueNote(keyA, "second, while B is open") + delay(3_000) + assertEquals(1, unsignedFor(keyA).size, "A's note is not B's to sign") + assertNull(unsignedFor(keyA).single().signedAt) + + // And goes out when A is back. + sovereign.switchToIdentity(idA) + sovereign.setActiveIdentity(activated(storedA)) + withTimeout(15_000) { nostrRepository.observeUnsignedNostrEvents(keyA.nostrPublicKeyHex()).first { it.isEmpty() } } + + assertEquals(emptyList(), stopped, "bare keys have no node to stop") + assertTrue(BusinessManager.businessFlow.value.isEmpty()) + } + + @Test + fun `leaving a profile with a node behind it stops that node, once`() = runBlocking { + val sovereign = SovereignWalletViewModel(phoenixGlobal, stopBusiness = { stopped += it }) + val dataStoreManager = DataStoreManager(phoenixGlobal.ctx, chain = NodeParamsManager.chain) + val walletId = WalletId("ab".repeat(20)) + val withNode = Identity.signing( + id = walletId, + kind = IdentityKind.Mnemonic, + nostrPrivateKey = keyA, + userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(walletId), + business = PhoenixBusiness(phoenixGlobal), + ) + val other = StoredIdentity.nostrSecret(keyB).id + + sovereign.setActiveIdentity(withNode) + sovereign.switchToIdentity(other) + withTimeout(5_000) { while (stopped.isEmpty()) delay(20) } + + assertEquals(listOf(walletId), stopped) + assertNull(sovereign.activeIdentity.value) + assertEquals(other, sovereign.desiredWalletId.value) + } + + @Test + fun `the last profile opened is the one a cold boot resolves, until it is forgotten`() = runBlocking { + val idA = signIn(keyA) + val idB = signIn(keyB) + val first = SovereignWalletViewModel(phoenixGlobal, stopBusiness = { stopped += it }) + withTimeout(15_000) { first.availableIdentities.first { it.size == 2 } } + val storedA = assertIs(first.availableIdentities.value[idA]) + val storedB = assertIs(first.availableIdentities.value[idB]) + + first.setActiveIdentity(activated(storedA)) + first.switchToIdentity(idB) + first.setActiveIdentity(activated(storedB)) + // Read through the view model's own GlobalPrefs, not this test's: DataStoreManager + // caches the global preferences once per process, bound to whichever directory + // was current when the first test in this JVM asked -- the same trap the nsec plan + // recorded for user preferences. The app has one directory; a test JVM has many. + val prefs = first.getGlobalPrefs() + withTimeout(5_000) { prefs.getDefaultWallet.first { it == idB } } + + // A cold boot: a fresh view model over the same directory, nothing desired, + // nothing forced, and the user has not asked for the list. + val booted = SovereignWalletViewModel(phoenixGlobal, stopBusiness = { stopped += it }) + withTimeout(15_000) { booted.availableIdentities.first { it.size == 2 } } + val resolved = StartupChoice.resolve( + force = null, + desired = booted.desiredWalletId.value, + startImmediately = booted.startWalletImmediately.value, + identities = booted.availableIdentities.value, + default = prefs.getDefaultWallet.first() as? WalletId, + ) + assertEquals(idB, resolved?.id, "the profile that was open is the one that opens") + + // The forget tail clears it; the boot after shows the list. + booted.forgetDefaultIdentity() + withTimeout(5_000) { prefs.getDefaultWallet.first { it == EmptyWalletId } } + assertNull( + StartupChoice.resolve( + force = null, + desired = null, + startImmediately = true, + identities = booted.availableIdentities.value, + default = prefs.getDefaultWallet.first() as? WalletId, + ) + ) + } + + @Test + fun `switching from nothing, or from a bare key, stops nothing`() = runBlocking { + val sovereign = SovereignWalletViewModel(phoenixGlobal, stopBusiness = { stopped += it }) + val target = StoredIdentity.nostrSecret(keyB).id + + sovereign.switchToIdentity(target) + sovereign.setActiveIdentity(activated(StoredIdentity.nostrSecret(keyA))) + sovereign.switchToIdentity(target) + delay(500) + + assertEquals(emptyList(), stopped) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentityWriterJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentityWriterJvmTest.kt new file mode 100644 index 00000000..dc61a584 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentityWriterJvmTest.kt @@ -0,0 +1,333 @@ +package press.mantra.compose.identity + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import co.touchlab.kermit.Logger +import fr.acinq.bitcoin.MnemonicCode +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.byteVector +import fr.acinq.lightning.Lightning +import fr.acinq.lightning.crypto.LocalKeyManager +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.managers.NodeParamsManager +import fr.acinq.phoenix.managers.NostrCredentialManager +import fr.acinq.phoenix.managers.SeedManager +import fr.acinq.phoenix.managers.computePreferencePath +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.security.JvmKeyStore +import fr.acinq.phoenix.security.NostrCredential +import fr.acinq.phoenix.utils.MnemonicLanguage +import fr.acinq.phoenix.utils.PlatformContext +import fr.acinq.phoenix.utils.preferences.GlobalPrefs +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import okio.FileSystem +import okio.SYSTEM +import press.mantra.compose.ui.view.model.WritingSeedState +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * The three writers, against a real key store and a real directory. + * + * What is pinned is the duplicate rule, its two exceptions, and the inverse. A second + * import of the same key is refused by public key, the same npub imported twice being + * the one thing the id check cannot see; a public key already here is *not* a duplicate + * of the secret that signs as it, and the entry becomes a secret one under the id it + * had; a bare secret already here is *not* a duplicate of the phrase that derives it, + * and the seed attaches to it; a phrase writes its key to the credentials file as well + * as its words to the seed file; and forgetting takes the entry, whichever kind, out of + * the file and the identity's preference files off the disk, leaving every other entry + * where it was -- unless a seed derives the key, in which case nothing is touched. + */ +class IdentityWriterJvmTest { + + private lateinit var storeDir: File + private lateinit var appDir: File + private lateinit var phoenixGlobal: PhoenixGlobal + private lateinit var globalPrefs: GlobalPrefs + + private val log = Logger.withTag("IdentityWriterJvmTest") + + // Fresh keys per test, not fixed ones: DataStoreManager caches each id's preferences + // in a companion object for the life of the process, so a fixed key would be served + // the UserPrefs an earlier test created in an earlier temp directory. + private val first = PrivateKey(Lightning.randomBytes(32)) + private val second = PrivateKey(Lightning.randomBytes(32)) + + private val scope = CoroutineScope(Job()) + + @BeforeTest + fun setUp() { + // The seed writer reports on Main, as the create screen's state machine needs it + // to; a real dispatcher rather than a test one, because it reports after a real + // one-second delay and a virtual clock would never reach it. + Dispatchers.setMain(Dispatchers.Default) + storeDir = Files.createTempDirectory("mantra-identity-writer-store").toFile() + appDir = Files.createTempDirectory("mantra-identity-writer-app").toFile() + JvmKeyStore.lock() + JvmKeyStore.unlock("correct horse battery staple".toCharArray(), storeDir) + phoenixGlobal = PhoenixGlobal(PlatformContext(applicationDir = appDir)) + globalPrefs = GlobalPrefs( + PreferenceDataStoreFactory.createWithPath { + computePreferencePath(phoenixGlobal.ctx, "globalprefs.preferences_pb") + } + ) + } + + @AfterTest + fun tearDown() { + scope.cancel() + Dispatchers.resetMain() + JvmKeyStore.lock() + storeDir.deleteRecursively() + appDir.deleteRecursively() + } + + /** Twelve fresh words, and what they derive. */ + private class Phrase { + val words: List = MnemonicCode.toMnemonics(Lightning.randomBytes(16), MnemonicLanguage.English.wordlist()) + private val keyManager = LocalKeyManager( + seed = MnemonicCode.toSeed(words, "").byteVector(), + chain = NodeParamsManager.chain, + remoteSwapInExtendedPublicKey = NodeParamsManager.remoteSwapInXpub, + ) + val walletId = WalletId(keyManager.nodeKeys.nodeKey.publicKey) + val nostrKey = StoredIdentity.nostrPrivateKeyOf(words) + val nostrPublicKey = nostrKey.nostrPublicKeyHex() + } + + /** The callback-shaped seed writer, awaited: exactly one of its two callbacks fires. */ + private suspend fun writePhrase(phrase: Phrase): Result { + val outcome = CompletableDeferred>() + IdentityWriter.writeMnemonic( + log = log, + phoenixGlobal = phoenixGlobal, + globalPrefs = globalPrefs, + writingState = WritingSeedState.Init, + viewModelScope = scope, + mnemonics = phrase.words, + onWritingSeedError = { outcome.complete(Result.failure(IllegalStateException(it.toString()))) }, + onWritingSeedStateWriting = {}, + isRestoringWallet = false, + isTorEnabled = false, + customElectrumServer = null, + onSeedWritten = { outcome.complete(Result.success(it)) }, + ) + return withTimeout(15_000) { outcome.await() } + } + + private fun seeds() = SeedManager.loadAndDecryptOrNull(phoenixGlobal) + + private suspend fun write(key: PrivateKey) = IdentityWriter.writeNostrKey( + log = log, + phoenixGlobal = phoenixGlobal, + globalPrefs = globalPrefs, + privateKey = key, + isTorEnabled = false, + customElectrumServer = null, + ) + + private suspend fun writePublic(key: PrivateKey) = IdentityWriter.writeNostrPublicKey( + log = log, + phoenixGlobal = phoenixGlobal, + globalPrefs = globalPrefs, + nostrPublicKey = key.nostrPublicKeyHex(), + isTorEnabled = false, + customElectrumServer = null, + ) + + private fun credentials() = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) + + private fun userPrefsFile(stored: StoredIdentity) = + computePreferencePath(phoenixGlobal.ctx, "userprefs_${stored.id.nodeIdHash}.preferences_pb") + + @Test + fun `a key is written once and refused the second time, by public key`() = runBlocking { + val stored = StoredIdentity.nostrSecret(first) + + val written = assertIs(write(first)) + assertEquals(stored.id, written.id) + assertEquals(mapOf(stored.nostrPublicKey to NostrCredential.Secret(first)), credentials()) + assertTrue(FileSystem.SYSTEM.exists(userPrefsFile(stored)), "the preferences file the recovery screens read") + + assertEquals(stored.id, assertIs(write(first)).id) + } + + @Test + fun `a public key is written once and refused the second time`() = runBlocking { + val stored = StoredIdentity.nostrPublic(first.nostrPublicKeyHex()) + + val written = assertIs(writePublic(first)) + assertEquals(stored.id, written.id) + assertEquals(mapOf(stored.nostrPublicKey to NostrCredential.Public), credentials()) + assertTrue(FileSystem.SYSTEM.exists(userPrefsFile(stored))) + + assertIs(writePublic(first)) + } + + /** + * The one exception to the duplicate rule, and the reason the two kinds share a + * file: the device does not hold this secret, so refusing it would be false. One + * write, same id, and the preferences the read-only identity had are the ones the + * signing identity keeps. + */ + @Test + fun `the nsec of a key held read-only is accepted, under the same id, and the entry becomes a secret`() = runBlocking { + val readOnly = assertIs(writePublic(first)) + writePublic(second) + + val upgraded = assertIs(write(first)) + + assertEquals(readOnly.id, upgraded.id) + assertEquals( + mapOf( + first.nostrPublicKeyHex() to NostrCredential.Secret(first), + second.nostrPublicKeyHex() to NostrCredential.Public, + ), + credentials(), + ) + assertTrue(FileSystem.SYSTEM.exists(userPrefsFile(StoredIdentity.nostrSecret(first)))) + } + + /** The other direction is a duplicate: the device already holds more than an npub for the key. */ + @Test + fun `the npub of a key held as a secret is refused`() = runBlocking { + write(first) + + assertIs(writePublic(first)) + assertEquals(mapOf(first.nostrPublicKeyHex() to NostrCredential.Secret(first)), credentials()) + } + + @Test + fun `forgetting one entry leaves the others, whichever kind, and takes the preferences with it`() = runBlocking { + write(first) + writePublic(second) + val stored = StoredIdentity.nostrSecret(first) + + val result = IdentityWriter.forgetNostrCredential(log, phoenixGlobal, stored.id, stored.nostrPublicKey) + + assertIs(result) + assertEquals(mapOf(second.nostrPublicKeyHex() to NostrCredential.Public), credentials()) + assertFalse(FileSystem.SYSTEM.exists(userPrefsFile(stored))) + assertTrue(FileSystem.SYSTEM.exists(userPrefsFile(StoredIdentity.nostrPublic(second.nostrPublicKeyHex())))) + + val readOnly = StoredIdentity.nostrPublic(second.nostrPublicKeyHex()) + assertIs( + IdentityWriter.forgetNostrCredential(log, phoenixGlobal, readOnly.id, readOnly.nostrPublicKey) + ) + assertEquals(emptyMap(), credentials()) + assertFalse(FileSystem.SYSTEM.exists(userPrefsFile(readOnly))) + } + + /** + * Not in the file at all. Since the repair that can only be a seed's key the repair + * could not write, and the answer is the same as for one it did: a wallet is attached. + */ + @Test + fun `a key that is not in the file is not forgotten`() = runBlocking { + write(first) + val other = StoredIdentity.nostrSecret(second) + + assertIs( + IdentityWriter.forgetNostrCredential(log, phoenixGlobal, other.id, other.nostrPublicKey) + ) + assertEquals(1, credentials()?.size) + } + + /** + * The credential a seed always implied. A phrase writes two files: its key as a secret + * in the credentials file, and its words in the seed file. The profile's id is the + * wallet's, and the nsec of that key is then a duplicate -- the device holds more than + * the key for it. + */ + @Test + fun `a phrase writes its key to the credentials file as well as its words to the seed file`() = runBlocking { + val phrase = Phrase() + + val id = writePhrase(phrase).getOrThrow() + + assertEquals(phrase.walletId, id) + assertEquals(mapOf(phrase.nostrPublicKey to NostrCredential.Secret(phrase.nostrKey)), credentials()) + assertEquals(setOf(phrase.walletId), seeds()?.keys) + assertTrue(FileSystem.SYSTEM.exists(computePreferencePath(phoenixGlobal.ctx, "userprefs_${id.nodeIdHash}.preferences_pb"))) + + // Refused with the wallet's id, not the bare key's: the id the profile is listed under. + assertEquals(phrase.walletId, assertIs(write(phrase.nostrKey)).id) + assertEquals(phrase.walletId, assertIs(writePublic(phrase.nostrKey)).id) + assertIs( + IdentityWriter.forgetNostrCredential(log, phoenixGlobal, id, phrase.nostrPublicKey) + ) + assertEquals(1, credentials()?.size, "the forget changed nothing") + } + + /** The same seed twice is the one thing the phrase writer still refuses. */ + @Test + fun `the same phrase twice is refused`() = runBlocking { + val phrase = Phrase() + writePhrase(phrase).getOrThrow() + + val second = writePhrase(phrase) + + assertTrue(second.isFailure) + assertTrue(second.exceptionOrNull()?.message?.contains("SeedAlreadyExists") == true, "$second") + assertEquals(1, seeds()?.size) + } + + /** + * The second exception to the duplicate rule, and the first attachment: the phrase of + * a key held as a bare secret used to be refused, and is the profile acquiring the + * wallet that derives it. The entry stays a secret for the same key; the id becomes + * the wallet's, and the bare key's preference files go with the old id. + */ + @Test + fun `the phrase of a key held as a bare secret attaches its wallet`() = runBlocking { + val phrase = Phrase() + val bare = assertIs(write(phrase.nostrKey)) + val bareUserPrefs = userPrefsFile(StoredIdentity.nostrSecret(phrase.nostrKey)) + assertTrue(FileSystem.SYSTEM.exists(bareUserPrefs)) + + val id = writePhrase(phrase).getOrThrow() + + assertEquals(phrase.walletId, id) + assertNotEquals(bare.id, id, "the profile's id is its wallet's now") + assertEquals(mapOf(phrase.nostrPublicKey to NostrCredential.Secret(phrase.nostrKey)), credentials()) + assertEquals(setOf(phrase.walletId), seeds()?.keys) + assertFalse(FileSystem.SYSTEM.exists(bareUserPrefs), "the bare key's preferences went with its id") + assertTrue(FileSystem.SYSTEM.exists(computePreferencePath(phoenixGlobal.ctx, "userprefs_${id.nodeIdHash}.preferences_pb"))) + } + + /** The read-only upgrade, landing on a wallet: the public entry becomes a secret, and the seed attaches. */ + @Test + fun `the phrase of a key held read-only attaches its wallet and the entry becomes a secret`() = runBlocking { + val phrase = Phrase() + writePublic(phrase.nostrKey) + writePublic(second) + + val id = writePhrase(phrase).getOrThrow() + + assertEquals(phrase.walletId, id) + assertEquals( + mapOf( + phrase.nostrPublicKey to NostrCredential.Secret(phrase.nostrKey), + second.nostrPublicKeyHex() to NostrCredential.Public, + ), + credentials(), + ) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/MultipleProfilesRoundTripJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/MultipleProfilesRoundTripJvmTest.kt new file mode 100644 index 00000000..1669f3ba --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/MultipleProfilesRoundTripJvmTest.kt @@ -0,0 +1,282 @@ +package press.mantra.compose.identity + +import androidx.room3.Room +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.lightning.Lightning +import fr.acinq.phoenix.PhoenixBusiness +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.data.EmptyWalletId +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.jvm.BusinessManager +import fr.acinq.phoenix.managers.DataStoreManager +import fr.acinq.phoenix.managers.NodeParamsManager +import fr.acinq.phoenix.managers.NostrCredentialManager +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.security.JvmKeyStore +import fr.acinq.phoenix.security.NostrCredential +import fr.acinq.phoenix.utils.PlatformContext +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.UnsignedNostrEvent +import press.mantra.compose.database.repository.DatabaseChatRepository +import press.mantra.compose.database.repository.DatabaseMarmotRepository +import press.mantra.compose.database.repository.DatabaseNostrRepository +import press.mantra.compose.extensions.getGlobalPrefs +import press.mantra.compose.ui.view.model.CreateProfileViewModel +import press.mantra.compose.ui.view.model.NavigationViewModel +import press.mantra.compose.ui.view.model.NotaryViewModel +import press.mantra.compose.ui.view.model.SignInToProfileViewModel +import press.mantra.compose.ui.view.model.SovereignWalletViewModel +import press.mantra.compose.ui.view.model.WritingSeedState +import press.mantra.compose.ui.view.model.bareKeyProfileWriter +import press.mantra.compose.ui.view.model.seedProfileWriter +import press.mantra.compose.ui.view.state.CreateProfileUIState +import press.mantra.compose.ui.view.state.NavigationUIState +import press.mantra.compose.ui.view.state.StartupChoice +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Clock + +/** + * Several profiles, both ways, and a boot -- Phase 8 of docs/multiple-profiles.md, the + * seams of Phases 1 through 7 in a row, through the real view models on an in-memory + * database with a real key store. + * + * A is made from a phrase, the way Landing makes the first profile: both files written, + * listed once with a wallet attached. A is opened with a node behind it -- a + * PhoenixBusiness that was never started, which is enough to make it one the switch + * has to stop. B's npub is signed in from inside; the switch stops A's node, B is open + * and read-only, and the machine routes it from its placeholder. A kind 1 queued for A + * while B is open waits, and goes out when A is back. C is created from inside as a bare + * key, with no node. B and C are forgotten; one profile remains, the default is cleared, + * and a fresh view model over the same directory -- what a cold boot is -- lists A alone, + * with its credential still the only entry in the file, and opens it without asking. + */ +class MultipleProfilesRoundTripJvmTest { + + private lateinit var storeDir: File + private lateinit var appDir: File + private lateinit var phoenixGlobal: PhoenixGlobal + + private val db: MantraDatabase = getRoomDatabase(Room.inMemoryDatabaseBuilder()) + private val scope = CoroutineScope(Job() + Dispatchers.IO) + private val nostrRepository = DatabaseNostrRepository(db, scope) + private val chatRepository = DatabaseChatRepository(db, scope) + private val marmotRepository = DatabaseMarmotRepository(db, scope) + + private val keyB = PrivateKey(Lightning.randomBytes(32)) + private val stopped = mutableListOf() + + @BeforeTest + fun setUp() { + storeDir = Files.createTempDirectory("mantra-profiles-store").toFile() + appDir = Files.createTempDirectory("mantra-profiles-app").toFile() + JvmKeyStore.lock() + JvmKeyStore.unlock("correct horse battery staple".toCharArray(), storeDir) + // No GlobalPrefs of the test's own -- see AddProfileFromInsideJvmTest.setUp. + phoenixGlobal = PhoenixGlobal(PlatformContext(applicationDir = appDir)) + } + + @AfterTest + fun tearDown() { + scope.cancel() + db.close() + JvmKeyStore.lock() + storeDir.deleteRecursively() + appDir.deleteRecursively() + } + + private val dataStoreManager by lazy { DataStoreManager(phoenixGlobal.ctx, chain = NodeParamsManager.chain) } + + private suspend fun listed(sovereign: SovereignWalletViewModel, size: Int): Map { + val done = CompletableDeferred() + sovereign.listIdentities { done.complete(Unit) } + withTimeout(15_000) { done.await() } + return sovereign.availableIdentities.first { it.size == size } + } + + /** What the create screen does with a writer, minus the screen. */ + private suspend fun create(writer: NewProfileWriter, name: String): WalletId { + val created = CompletableDeferred() + val viewModel = CreateProfileViewModel( + initialCreateProfileUIState = CreateProfileUIState.ConfirmInput(name = name, bio = "round trip"), + nostrRepository = nostrRepository, + marmotRepository = marmotRepository, + ) + viewModel.createProfileFormState.nameField.textFieldState.edit { append(name) } + viewModel.createProfileFormState.biographyField.textFieldState.edit { append("round trip") } + viewModel.createAccount(writer) { created.complete(it) } + return withTimeout(15_000) { created.await() } + } + + /** What startup does for each kind, minus the node: a wallet-attached profile gets a business it can stop. */ + private fun activated(stored: StoredIdentity): Identity = when (stored) { + is StoredIdentity.Mnemonic -> Identity.signing( + id = stored.id, kind = IdentityKind.Mnemonic, nostrPrivateKey = stored.privateKey, + userPrefs = dataStoreManager.loadUserPrefsForWallet(stored.id), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(stored.id), + business = PhoenixBusiness(phoenixGlobal), + ) + is StoredIdentity.NostrSecret -> Identity.signing( + id = stored.id, kind = IdentityKind.NostrSecret, nostrPrivateKey = stored.privateKey, + userPrefs = dataStoreManager.loadUserPrefsForWallet(stored.id), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(stored.id), + business = null, + ) + is StoredIdentity.NostrPublic -> Identity.readOnly( + id = stored.id, nostrPublicKey = stored.nostrPublicKey, + userPrefs = dataStoreManager.loadUserPrefsForWallet(stored.id), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(stored.id), + ) + } + + /** Whether a navigation state is about [publicKey]'s account, whichever step it is at. */ + private suspend fun NavigationUIState.isFor(publicKey: String): Boolean = when (this) { + is NavigationUIState.UnsignedProfile -> unsignedNostrEvent.pubKey == publicKey + is NavigationUIState.UnqueuedProfileSynchronization -> unsignedNostrEvent.pubKey == publicKey + is NavigationUIState.UnsyncedProfile -> unsignedNostrEvent.pubKey == publicKey + is NavigationUIState.UnannouncedProfile -> db.nostrEventDao().getNostrEventById(broadcastNostrEventRequest.nostrEventId)?.pubKey == publicKey + is NavigationUIState.ProfileLoaded -> this.publicKey == publicKey + else -> false + } + + private suspend fun NavigationViewModel.awaitRoutedTo(publicKey: String): NavigationUIState = withTimeout(20_000) { + navigationUIState.first { it.isFor(publicKey) } + } + + private suspend fun queueNote(publicKey: String, content: String) { + val now = Clock.System.now() + db.unsignedNostrEventDao().upsert( + UnsignedNostrEvent(pubKey = publicKey, kind = 1, tags = emptyArray(), content = content, createdAt = now, updatedAt = now, savedAt = now) + ) + } + + private suspend fun notesQueuedFor(publicKey: String) = + nostrRepository.observeUnsignedNostrEvents(publicKey).first().filter { it.kind == 1 } + + @Test + fun `several profiles, both ways, and a boot`() = runBlocking { + val sovereign = SovereignWalletViewModel(phoenixGlobal, stopBusiness = { stopped += it }) + val navigation = NavigationViewModel( + activeIdentityStateFlow = sovereign.activeIdentity, + initialNavigationUIState = NavigationUIState.Loading(""), + nostrRepository = nostrRepository, + scope = scope, + ) + NotaryViewModel( + activeIdentityStateFlow = sovereign.activeIdentity, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + marmotRepository = marmotRepository, + scope = scope, + ) + val signIn = SignInToProfileViewModel( + nostrRepository = nostrRepository, + writeNostrKey = { key -> sovereign.writeNostrKey(key) }, + writeNostrPublicKey = { pubkey -> sovereign.writeNostrPublicKey(pubkey) }, + writeRecoveryPhrase = { _, _, onError -> onError(WritingSeedState.Error.CannotLoadSeedMap) }, + ) + + // A, from a phrase: both files written, listed once with a wallet attached. + val idA = create(sovereign.seedProfileWriter(), "Ada") + val storedA = assertIs(listed(sovereign, 1)[idA], "a profile with a wallet attached, under the wallet's id") + val credentials = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) + assertEquals(mapOf(storedA.nostrPublicKey to NostrCredential.Secret(storedA.privateKey)), credentials, "the seed's key is a credential") + sovereign.setActiveIdentity(activated(storedA)) + navigation.awaitRoutedTo(storedA.nostrPublicKey) + + // B, an npub, signed in from inside: the switch stops A's node; B is open, read-only, and routed. + val signedInB = assertIs( + signIn.commit(SignInCredential.NostrPublicKey(keyB.nostrPublicKeyHex())) + ) + val storedB = assertIs(listed(sovereign, 2)[signedInB.id]) + sovereign.switchToIdentity(signedInB.id) + withTimeout(5_000) { while (stopped.isEmpty()) delay(20) } + assertEquals(listOf(idA), stopped, "leaving A stopped A's node") + sovereign.setActiveIdentity(activated(storedB)) + assertIs(navigation.awaitRoutedTo(storedB.nostrPublicKey)) + assertTrue(BusinessManager.businessFlow.value.isEmpty(), "nothing was ever started for a public key") + + // What A queues while B is open waits for A. + queueNote(storedA.nostrPublicKey, "while B is open") + delay(3_000) + assertEquals(1, notesQueuedFor(storedA.nostrPublicKey).size, "not B's to sign") + + // Back to A: routed, and the note goes out. B had no node, so nothing more was stopped. + sovereign.switchToIdentity(idA) + sovereign.setActiveIdentity(activated(storedA)) + navigation.awaitRoutedTo(storedA.nostrPublicKey) + withTimeout(15_000) { nostrRepository.observeUnsignedNostrEvents(storedA.nostrPublicKey).first { rows -> rows.none { it.kind == 1 } } } + assertEquals(listOf(idA), stopped) + + // C, created from inside: a bare key, no node, and the switch stops A's node again. + val idC = create(sovereign.bareKeyProfileWriter(), "Cy") + val storedC = assertIs(listed(sovereign, 3)[idC], "a key, not a second wallet") + sovereign.switchToIdentity(idC) + withTimeout(5_000) { while (stopped.size < 2) delay(20) } + assertEquals(listOf(idA, idA), stopped) + sovereign.setActiveIdentity(activated(storedC)) + navigation.awaitRoutedTo(storedC.nostrPublicKey) + assertTrue(BusinessManager.businessFlow.value.isEmpty(), "no node for a bare key") + + // Forget C, the one that is open, then B: the forget tail, minus the navigation. + for (leaving in listOf(activated(storedC), activated(storedB))) { + val outcome = ForgetIdentity.forget( + identity = leaving, + nostrRepository = nostrRepository, + forgetNostrCredential = { sovereign.forgetNostrCredential(it) }, + hideIdentityMetadata = { sovereign.hideIdentityMetadata(it) }, + ) + assertEquals(ForgetIdentity.Outcome.Forgotten, outcome) + } + sovereign.forgetDefaultIdentity() + sovereign.resetToSelector() + assertNull(sovereign.activeIdentity.value) + assertEquals(setOf(idA), listed(sovereign, 1).keys) + assertEquals( + mapOf(storedA.nostrPublicKey to NostrCredential.Secret(storedA.privateKey)), + NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal), + "A's credential is the only entry left", + ) + assertEquals(1, nostrRepository.getLocalAccounts().size, "B's and C's account rows went with them") + val prefs = getGlobalPrefs(phoenixGlobal) + withTimeout(5_000) { prefs.getDefaultWallet.first { it == EmptyWalletId } } + + // And A cannot be forgotten: a wallet is attached. + assertEquals( + ForgetIdentity.Outcome.WalletAttached, + ForgetIdentity.forget(activated(storedA), nostrRepository, { sovereign.forgetNostrCredential(it) }, { sovereign.hideIdentityMetadata(it) }), + ) + + // A cold boot: a fresh view model over the same directory. The repair finds nothing + // to do, A is listed alone, and startup opens it without asking. + val booted = SovereignWalletViewModel(phoenixGlobal, stopBusiness = { stopped += it }) + val identities = withTimeout(15_000) { booted.availableIdentities.first { it.isNotEmpty() } } + assertEquals(setOf(idA), identities.keys) + assertEquals(storedA.privateKey, assertIs(identities[idA]).privateKey, "listed from the credential, not derived") + val opens = StartupChoice.resolve( + force = null, + desired = null, + startImmediately = true, + identities = identities, + default = prefs.getDefaultWallet.first() as? WalletId, + ) + assertEquals(idA, opens?.id) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/NpubPreviewRoundTripJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/NpubPreviewRoundTripJvmTest.kt new file mode 100644 index 00000000..0a389ce0 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/NpubPreviewRoundTripJvmTest.kt @@ -0,0 +1,262 @@ +package press.mantra.compose.identity + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.room3.Room +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.lightning.Lightning +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.jvm.BusinessManager +import fr.acinq.phoenix.managers.DataStoreManager +import fr.acinq.phoenix.managers.NodeParamsManager +import fr.acinq.phoenix.managers.NostrCredentialManager +import fr.acinq.phoenix.managers.SeedManager +import fr.acinq.phoenix.managers.computePreferencePath +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.security.JvmKeyStore +import fr.acinq.phoenix.security.NostrCredential +import fr.acinq.phoenix.utils.PlatformContext +import fr.acinq.phoenix.utils.preferences.GlobalPrefs +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.GENESIS_AT +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.repository.DatabaseChatRepository +import press.mantra.compose.database.repository.DatabaseMarmotRepository +import press.mantra.compose.database.repository.DatabaseNostrRepository +import press.mantra.compose.ui.view.model.NavigationViewModel +import press.mantra.compose.ui.view.model.NotaryViewModel +import press.mantra.compose.ui.view.model.SignInToProfileViewModel +import press.mantra.compose.ui.view.state.NavigationUIState +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * The preview, end to end, and then the upgrade -- Phase 7 of docs/npub-sign-in.md. + * + * Unlike `NsecRestoreRoundTripJvmTest`, the repository here is real: an in-memory + * database under the same DAOs the app runs on, because the assertion this exists for + * is about what is *not* in it. Sign in with an npub the way the sign-in screen does, + * list identities the way startup does, activate the read-only one, hand + * NavigationViewModel the device: it lands on UnqueuedProfileSynchronization. Index the + * kind 0 the relays would have answered with, through the read-only key pair: it lands + * on ProfileLoaded. And with a real NotaryViewModel running against the same database + * for longer than its key package delay: **nothing was signed** -- the only unsigned row + * for the pubkey is the placeholder, still at genesis; no key package bundle; no + * broadcast request; no node. + * + * Then the nsec of the same key is written the way the sign-in screen writes it: the + * same id, one entry that is now a secret, and one account -- the second sign-in + * planted nothing. + */ +class NpubPreviewRoundTripJvmTest { + + private lateinit var storeDir: File + private lateinit var appDir: File + private lateinit var phoenixGlobal: PhoenixGlobal + private lateinit var globalPrefs: GlobalPrefs + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + private val scope = CoroutineScope(Job() + Dispatchers.IO) + private val nostrRepository = DatabaseNostrRepository(db, scope) + private val chatRepository = DatabaseChatRepository(db, scope) + private val marmotRepository = DatabaseMarmotRepository(db, scope) + + private val log = Logger.withTag("NpubPreviewRoundTripJvmTest") + private val privateKey = PrivateKey(Lightning.randomBytes(32)) + private val publicKey = privateKey.nostrPublicKeyHex() + private val at = Instant.fromEpochSeconds(1_700_000_000) + + @BeforeTest + fun setUp() { + storeDir = Files.createTempDirectory("mantra-preview-store").toFile() + appDir = Files.createTempDirectory("mantra-preview-app").toFile() + JvmKeyStore.lock() + JvmKeyStore.unlock("correct horse battery staple".toCharArray(), storeDir) + phoenixGlobal = PhoenixGlobal(PlatformContext(applicationDir = appDir)) + globalPrefs = GlobalPrefs( + PreferenceDataStoreFactory.createWithPath { + computePreferencePath(phoenixGlobal.ctx, "globalprefs.preferences_pb") + } + ) + } + + @AfterTest + fun tearDown() { + scope.cancel() + db.close() + JvmKeyStore.lock() + storeDir.deleteRecursively() + appDir.deleteRecursively() + } + + private fun signInViewModel() = SignInToProfileViewModel( + nostrRepository = nostrRepository, + writeNostrKey = { key -> IdentityWriter.writeNostrKey(log, phoenixGlobal, globalPrefs, key, isTorEnabled = false, customElectrumServer = null) }, + writeNostrPublicKey = { pubkey -> IdentityWriter.writeNostrPublicKey(log, phoenixGlobal, globalPrefs, pubkey, isTorEnabled = false, customElectrumServer = null) }, + writeRecoveryPhrase = { _, _, onError -> onError(press.mantra.compose.ui.view.model.WritingSeedState.Error.CannotLoadSeedMap) }, + ) + + private fun listed() = StoredIdentity.merge( + wallets = SeedManager.loadAndDecryptOrNull(phoenixGlobal) ?: error("seed store unreadable"), + credentials = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) ?: error("credentials unreadable"), + ) + + /** The kind 0 the relays would answer the sign-in sync with. */ + private fun kind0() = NostrEvent( + id = "e".repeat(64), + pubKey = publicKey, + kind = MetadataEvent.KIND, + tags = emptyArray(), + content = """{"name":"Reader"}""", + sig = "0".repeat(128), + createdAt = at, + ) + + @Test + fun `an npub signed in, listed, activated, routed and watched by the notary, with nothing signed`() = runBlocking { + // 1. The sign-in screen's commit, minus the screen: the credential, then the account. + val outcome = signInViewModel().commit(SignInCredential.NostrPublicKey(publicKey)) + val signedIn = assertIs(outcome) + + // 2. What startup lists. + val stored = assertIs(listed()[signedIn.id], "the public key is listed under the id the writer returned") + assertEquals(publicKey, stored.nostrPublicKey) + + // 3. What the startup screen's NostrPublic branch does: an identity with nothing behind it. + val dataStoreManager = DataStoreManager(phoenixGlobal.ctx, chain = NodeParamsManager.chain) + val identity = Identity.readOnly( + id = stored.id, + nostrPublicKey = stored.nostrPublicKey, + userPrefs = dataStoreManager.loadUserPrefsForWallet(stored.id), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(stored.id), + ) + assertFalse(identity.canSign) + assertNull(identity.nostrPrivateKey) + + val activeIdentity = MutableStateFlow(null) + val navigation = NavigationViewModel( + activeIdentityStateFlow = activeIdentity, + initialNavigationUIState = NavigationUIState.Loading("Introducing... Torch"), + nostrRepository = nostrRepository, + scope = scope, + ) + // The real notary, watching the same rows the app's would. + NotaryViewModel( + activeIdentityStateFlow = activeIdentity, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + marmotRepository = marmotRepository, + scope = scope, + ) + + activeIdentity.value = identity + + // 4. The machine takes it from the placeholder: fetch, do not create. + val first = withTimeout(15_000) { + navigation.navigationUIState.first { it !is NavigationUIState.Loading } + } + assertIs(first) + + // 5. The relays answered; the kind 0 is indexed through the read-only pair: home. + db.nostrDao().storeNostrEvent( + nostrEvent = kind0(), + relayURL = "wss://relay.example", + synchronizationRelayURLs = listOf("wss://relay.example"), + level = 0, + activeKeyPair = identity.toKeyPair(), + ) + val second = withTimeout(15_000) { + navigation.navigationUIState.first { it is NavigationUIState.ProfileLoaded } + } + assertEquals(NavigationUIState.ProfileLoaded(publicKey = publicKey), second) + + // 6. Longer than the notary's key package delay, and then: nothing was signed. + delay(4_500) + val accounts = nostrRepository.getLocalAccounts().filter { it.unsignedNostrEvent?.pubKey == publicKey } + assertEquals(1, accounts.size, "one account for the pubkey") + assertEquals(GENESIS_AT, accounts.single().unsignedNostrEvent!!.signedAt, "the placeholder is still the placeholder") + assertEquals(emptyList(), nostrRepository.observeUnsignedNostrEvents(publicKey).first(), "nothing is queued for a signature") + assertEquals(emptyList(), db.marmotKeyPackageBundleDao().getAllMarmotKeyPackageBundles(publicKey), "no key package bundle was made") + assertNull(db.broadcastNostrEventRequestDao().observeBroadcastNostrEventRequestsByStatus("pending").first(), "nothing is waiting for a relay") + assertNull(identity.business) + assertTrue(BusinessManager.businessFlow.value.isEmpty(), "no PhoenixBusiness was started for a public key") + } + + /** + * The contrast that makes the assertions above worth having: the same harness, as an + * identity that *can* sign, does sign -- the notary makes a key package bundle for it + * within its delay. Without this, "nothing was signed" could be true of a harness in + * which nothing can be signed. + */ + @Test + fun `the same harness as a signing identity does sign`() = runBlocking { + val signedIn = assertIs( + signInViewModel().commit(SignInCredential.NostrSecret(privateKey, publicKey)) + ) + val stored = assertIs(listed()[signedIn.id]) + val dataStoreManager = DataStoreManager(phoenixGlobal.ctx, chain = NodeParamsManager.chain) + val identity = Identity.signing( + id = stored.id, + kind = IdentityKind.NostrSecret, + nostrPrivateKey = stored.privateKey, + userPrefs = dataStoreManager.loadUserPrefsForWallet(stored.id), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(stored.id), + business = null, + ) + val activeIdentity = MutableStateFlow(identity) + NotaryViewModel( + activeIdentityStateFlow = activeIdentity, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + marmotRepository = marmotRepository, + scope = scope, + ) + + withTimeout(20_000) { + marmotRepository.observeActiveMarmotKeyPackageBundle(publicKey).first { it != null } + } + assertTrue(db.marmotKeyPackageBundleDao().getAllMarmotKeyPackageBundles(publicKey).isNotEmpty()) + } + + @Test + fun `the nsec of a key held read-only signs in over it, under the same id, planting nothing new`() = runBlocking { + val readOnly = assertIs( + signInViewModel().commit(SignInCredential.NostrPublicKey(publicKey)) + ) + assertIs(listed()[readOnly.id]) + + val upgraded = assertIs( + signInViewModel().commit(SignInCredential.NostrSecret(privateKey, publicKey)) + ) + + assertEquals(readOnly.id, upgraded.id, "the identity keeps its id, and with it its preferences and metadata") + val credentials = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) + assertEquals(mapOf(publicKey to NostrCredential.Secret(privateKey)), credentials, "one entry, now a secret") + val identities = listed() + assertEquals(1, identities.size) + assertIs(identities[upgraded.id]) + assertEquals(1, nostrRepository.getLocalAccounts().count { it.unsignedNostrEvent?.pubKey == publicKey }, "the second sign-in planted nothing") + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/NsecRestoreRoundTripJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/NsecRestoreRoundTripJvmTest.kt new file mode 100644 index 00000000..085dac31 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/NsecRestoreRoundTripJvmTest.kt @@ -0,0 +1,173 @@ +package press.mantra.compose.identity + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.lightning.Lightning +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.jvm.BusinessManager +import fr.acinq.phoenix.managers.DataStoreManager +import fr.acinq.phoenix.managers.NodeParamsManager +import fr.acinq.phoenix.managers.NostrCredentialManager +import fr.acinq.phoenix.managers.SeedManager +import fr.acinq.phoenix.managers.computePreferencePath +import fr.acinq.phoenix.security.JvmKeyStore +import fr.acinq.phoenix.utils.PlatformContext +import fr.acinq.phoenix.utils.preferences.GlobalPrefs +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import press.mantra.compose.database.GENESIS_AT +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.UnsignedNostrEvent +import press.mantra.compose.database.model.intermdiate.LocalAccount +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.model.NavigationViewModel +import press.mantra.compose.ui.view.state.NavigationUIState +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * The path a user takes, end to end, with the network faked at the repository. + * + * Unlock the key store into a temporary directory, write an nsec the way the sign-in + * screen does, list identities the way startup does, activate the one that came back, + * and hand NavigationViewModel a device holding the placeholder account the sign-in + * planted: it must land on UnqueuedProfileSynchronization. Then the same account with + * a kind 0 indexed: ProfileLoaded. Phases 2 through 5 each pin one seam; this is the + * seams in a row. + * + * And the assertion nothing in the UI would reveal: no Lightning node ran. The jvm + * BusinessManager's flow of running businesses is empty afterwards, and the identity + * has no business behind it. + */ +class NsecRestoreRoundTripJvmTest { + + private lateinit var storeDir: File + private lateinit var appDir: File + private lateinit var phoenixGlobal: PhoenixGlobal + private lateinit var globalPrefs: GlobalPrefs + + private val log = Logger.withTag("NsecRestoreRoundTripJvmTest") + private val privateKey = PrivateKey(Lightning.randomBytes(32)) + private val at = Instant.fromEpochSeconds(1_700_000_000) + + @BeforeTest + fun setUp() { + storeDir = Files.createTempDirectory("mantra-round-trip-store").toFile() + appDir = Files.createTempDirectory("mantra-round-trip-app").toFile() + JvmKeyStore.lock() + JvmKeyStore.unlock("correct horse battery staple".toCharArray(), storeDir) + phoenixGlobal = PhoenixGlobal(PlatformContext(applicationDir = appDir)) + globalPrefs = GlobalPrefs( + PreferenceDataStoreFactory.createWithPath { + computePreferencePath(phoenixGlobal.ctx, "globalprefs.preferences_pb") + } + ) + } + + @AfterTest + fun tearDown() { + JvmKeyStore.lock() + storeDir.deleteRecursively() + appDir.deleteRecursively() + } + + /** A device whose one account can change under the observer, as rows do. */ + private class Device(initial: LocalAccount?) : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + val account = MutableStateFlow(initial) + override suspend fun getLocalAccounts(): List = listOfNotNull(account.value) + override suspend fun observeLocalAccount(publicKey: HexKey): Flow = account + } + + private fun placeholder(publicKey: HexKey) = LocalAccount( + unsignedNostrEvent = UnsignedNostrEvent( + id = 1, pubKey = publicKey, kind = 0, tags = emptyArray(), content = "{}", + signedAt = GENESIS_AT, createdAt = at, updatedAt = at, savedAt = at, + ), + nostrEvent = null, + profile = null, + broadcastNostrEventRequest = null, + broadcastNostrEventReceipt = null, + synchronizeNostrEventRequests = emptyList(), + ) + + private fun synced(publicKey: HexKey) = placeholder(publicKey).copy( + profile = Profile(publicKey = publicKey, nostrEventId = "e".repeat(64), createdAt = at, updatedAt = at, savedAt = at), + ) + + @Test + fun `an nsec written, listed, activated and routed, with no node started`() = runBlocking { + // 1. The sign-in screen's commit, minus the screen. + val written = assertIs( + IdentityWriter.writeNostrKey(log, phoenixGlobal, globalPrefs, privateKey, isTorEnabled = false, customElectrumServer = null) + ) + + // 2. What startup lists: both stores, merged. + val identities = StoredIdentity.merge( + wallets = SeedManager.loadAndDecryptOrNull(phoenixGlobal) ?: error("seed store unreadable"), + credentials = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) ?: error("key store unreadable"), + ) + val stored = assertIs(identities[written.id], "the imported key is listed under the id the writer returned") + + // 3. What the startup screen's NostrSecret branch does: an identity, no node. + val dataStoreManager = DataStoreManager(phoenixGlobal.ctx, chain = NodeParamsManager.chain) + val identity = Identity.signing( + id = stored.id, + kind = IdentityKind.NostrSecret, + nostrPrivateKey = stored.privateKey, + userPrefs = dataStoreManager.loadUserPrefsForWallet(stored.id), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(stored.id), + business = null, + ) + val activeIdentity = MutableStateFlow(null) + val device = Device(placeholder(identity.nostrPublicKey)) + val scope = CoroutineScope(Job()) + val navigation = NavigationViewModel( + activeIdentityStateFlow = activeIdentity, + initialNavigationUIState = NavigationUIState.Loading("Introducing... Torch"), + nostrRepository = device, + scope = scope, + ) + + try { + activeIdentity.value = identity + + // 4. The machine takes it from the placeholder: fetch, do not create. + val first = withTimeout(15_000) { + navigation.navigationUIState.first { it !is NavigationUIState.Loading } + } + assertEquals( + NavigationUIState.UnqueuedProfileSynchronization(unsignedNostrEvent = placeholder(identity.nostrPublicKey).unsignedNostrEvent!!), + first, + ) + + // 5. The relays answered, the kind 0 was indexed: home. + device.account.value = synced(identity.nostrPublicKey) + val second = withTimeout(15_000) { + navigation.navigationUIState.first { it is NavigationUIState.ProfileLoaded } + } + assertEquals(NavigationUIState.ProfileLoaded(publicKey = identity.nostrPublicKey), second) + } finally { + scope.cancel() + } + + // 6. Nothing Lightning ran. + assertNull(identity.business) + assertTrue(BusinessManager.businessFlow.value.isEmpty(), "no PhoenixBusiness was started for a bare key") + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/SeedCredentialsJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/SeedCredentialsJvmTest.kt new file mode 100644 index 00000000..2cff0a73 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/SeedCredentialsJvmTest.kt @@ -0,0 +1,192 @@ +package press.mantra.compose.identity + +import co.touchlab.kermit.Logger +import fr.acinq.bitcoin.MnemonicCode +import fr.acinq.bitcoin.byteVector +import fr.acinq.lightning.Lightning +import fr.acinq.lightning.crypto.LocalKeyManager +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.managers.NodeParamsManager +import fr.acinq.phoenix.managers.NostrCredentialManager +import fr.acinq.phoenix.managers.SeedManager +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.security.EncryptedNostrCredentials +import fr.acinq.phoenix.security.EncryptedSeed +import fr.acinq.phoenix.security.JvmKeyStore +import fr.acinq.phoenix.security.NostrCredential +import fr.acinq.phoenix.utils.MnemonicLanguage +import fr.acinq.phoenix.utils.PlatformContext +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The repair that runs at listing, against a device from before the credentials file + * held a seed's key: `seed.dat` written directly, as every wallet on every device was + * until docs/multiple-profiles.md, and no credential for any of them. + * + * What is pinned is that the repair writes exactly what is missing, once; that a + * device already repaired is not written to again -- the file's bytes are compared, + * since a rewrite would carry a fresh iv and could not be told apart by its contents; + * that a public entry for a seed's key is upgraded rather than left beside a secret; + * and that a write that fails is reported with the map that was read, not thrown. + */ +class SeedCredentialsJvmTest { + + private lateinit var storeDir: File + private lateinit var appDir: File + private lateinit var phoenixGlobal: PhoenixGlobal + + private val log = Logger.withTag("SeedCredentialsJvmTest") + + private class Seed(val words: List) { + val keyManager = LocalKeyManager( + seed = MnemonicCode.toSeed(words, "").byteVector(), + chain = NodeParamsManager.chain, + remoteSwapInExtendedPublicKey = NodeParamsManager.remoteSwapInXpub, + ) + val walletId = WalletId(keyManager.nodeKeys.nodeKey.publicKey) + val nostrKey = StoredIdentity.nostrPrivateKeyOf(words) + val nostrPublicKey = nostrKey.nostrPublicKeyHex() + } + + private fun freshSeed() = Seed(MnemonicCode.toMnemonics(Lightning.randomBytes(16), MnemonicLanguage.English.wordlist())) + + private val first = freshSeed() + private val second = freshSeed() + + @BeforeTest + fun setUp() { + storeDir = Files.createTempDirectory("mantra-seed-credentials-store").toFile() + appDir = Files.createTempDirectory("mantra-seed-credentials-app").toFile() + JvmKeyStore.lock() + JvmKeyStore.unlock("correct horse battery staple".toCharArray(), storeDir) + phoenixGlobal = PhoenixGlobal(PlatformContext(applicationDir = appDir)) + } + + @AfterTest + fun tearDown() { + JvmKeyStore.lock() + storeDir.deleteRecursively() + appDir.deleteRecursively() + } + + /** A device from before: the seeds on disk, and nothing said about their keys. */ + private fun writeSeedsOnly(vararg seeds: Seed) { + SeedManager.writeSeedToDisk( + phoenixGlobal, + EncryptedSeed.V2.encrypt(seeds.associate { it.walletId to it.words }), + overwrite = true, + ) + } + + private fun wallets() = SeedManager.loadAndDecryptOrNull(phoenixGlobal) ?: error("seed store unreadable") + private fun credentials() = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) ?: error("credentials unreadable") + private fun credentialsFileBytes(): ByteArray? = + File(SeedManager.getDatadir(phoenixGlobal.ctx).toString(), "nostr-credentials.dat").takeIf { it.exists() }?.readBytes() + + @Test + fun `every seed without a credential gets one, in one write`() { + writeSeedsOnly(first, second) + assertNull(credentialsFileBytes(), "the device from before had no credentials file") + + val result = SeedCredentials.reconcile(log, phoenixGlobal, wallets(), credentials()) + + val written = assertIs(result) + assertEquals(2, written.count) + val expected = mapOf( + first.nostrPublicKey to NostrCredential.Secret(first.nostrKey), + second.nostrPublicKey to NostrCredential.Secret(second.nostrKey), + ) + assertEquals(expected, written.credentials, "the map the listing is handed") + assertEquals(expected, credentials(), "and the file") + } + + @Test + fun `a device already repaired is not written to again`() { + writeSeedsOnly(first) + SeedCredentials.reconcile(log, phoenixGlobal, wallets(), credentials()) + val bytesAfterRepair = credentialsFileBytes() + + val result = SeedCredentials.reconcile(log, phoenixGlobal, wallets(), credentials()) + + val notNeeded = assertIs(result) + assertEquals(mapOf(first.nostrPublicKey to NostrCredential.Secret(first.nostrKey)), notNeeded.credentials) + assertTrue(bytesAfterRepair.contentEquals(credentialsFileBytes()), "a rewrite would carry a fresh iv") + } + + @Test + fun `a device with no seeds needs nothing`() { + val result = SeedCredentials.reconcile(log, phoenixGlobal, emptyMap(), emptyMap()) + + assertIs(result) + assertNull(credentialsFileBytes()) + } + + /** The read-only upgrade the seed writer performs, done for a device where it was left undone. */ + @Test + fun `a public entry for a seed's key becomes a secret`() { + writeSeedsOnly(first) + NostrCredentialManager.writeToDisk( + phoenixGlobal, + EncryptedNostrCredentials.encrypt(mapOf(first.nostrPublicKey to NostrCredential.Public)), + ) + + val result = SeedCredentials.reconcile(log, phoenixGlobal, wallets(), credentials()) + + val written = assertIs(result) + assertEquals(1, written.count) + assertEquals(mapOf(first.nostrPublicKey to NostrCredential.Secret(first.nostrKey)), credentials()) + } + + /** Other people's entries are left exactly where they were. */ + @Test + fun `bare keys already in the file are untouched`() { + writeSeedsOnly(first) + val bare = StoredIdentity.nostrSecret(fr.acinq.bitcoin.PrivateKey(Lightning.randomBytes(32))) + NostrCredentialManager.writeToDisk( + phoenixGlobal, + EncryptedNostrCredentials.encrypt(mapOf(bare.nostrPublicKey to NostrCredential.Secret(bare.privateKey))), + ) + + val written = assertIs(SeedCredentials.reconcile(log, phoenixGlobal, wallets(), credentials())) + + assertEquals(1, written.count) + assertEquals( + mapOf( + bare.nostrPublicKey to NostrCredential.Secret(bare.privateKey), + first.nostrPublicKey to NostrCredential.Secret(first.nostrKey), + ), + credentials(), + ) + } + + /** + * The write fails -- here because the key store is locked, which is how it fails on + * a device whose keystore entry is gone -- and the listing must go on with what it + * read. A failed write must never hide a wallet. + */ + @Test + fun `a write that fails is reported with the map that was read`() { + writeSeedsOnly(first) + val wallets = wallets() + val read = credentials() + JvmKeyStore.lock() + + val result = SeedCredentials.reconcile(log, phoenixGlobal, wallets, read) + + val failed = assertIs(result) + assertEquals(read, failed.credentials) + assertNull(credentialsFileBytes(), "nothing reached the disk") + // And the listing built on it still holds the wallet, by derivation. + val listed = assertIs(StoredIdentity.merge(wallets, failed.credentials)[first.walletId]) + assertEquals(first.nostrKey, listed.privateKey) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/StoredIdentityJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/StoredIdentityJvmTest.kt new file mode 100644 index 00000000..971b8f26 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/StoredIdentityJvmTest.kt @@ -0,0 +1,146 @@ +package press.mantra.compose.identity + +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.phoenix.data.UserWallet +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.security.NostrCredential +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotEquals + +/** + * The merged list the startup screen reads, and the one derivation it rests on. + * + * The vector is NIP-06's own: twelve words, the key at `m/44'/1237'/0'/0/0`, and the + * x-only public key. It pins that a wallet's `nostrPublicKey` is the same key an nsec + * import of that wallet's nostr secret would produce -- which is what attaches a seed + * to its credential, and what the duplicate check in `IdentityWriter` compares. + */ +class StoredIdentityJvmTest { + + private val words = "leader monkey parrot ring guide accident before fence cannon height naive bean".split(" ") + private val nip06PrivateKey = PrivateKey(ByteVector32("7f7ff03d123792d6ac594bfa67bf6d0c0ab55b6b1fdb6249303fe861f1ccba9a")) + private val nip06PublicKey = "17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917" + + private val wallet = UserWallet( + walletId = WalletId("ab".repeat(20)), + nodeId = "02" + "cd".repeat(32), + words = words, + ) + private val bareKey = PrivateKey(ByteVector32("02".repeat(32))) + private val readOnlyKey = PrivateKey(ByteVector32("03".repeat(32))).publicKey().xOnly().value.toHex() + + @Test + fun `a wallet's nostr key is the NIP-06 derivation of its words`() { + assertEquals(nip06PublicKey, StoredIdentity.nostrPublicKeyOf(words)) + assertEquals(nip06PublicKey, StoredIdentity.nostrSecret(nip06PrivateKey).nostrPublicKey) + } + + @Test + fun `both stores land in one map, each under its own id`() { + val merged = StoredIdentity.merge( + wallets = mapOf(wallet.walletId to wallet), + credentials = mapOf( + nip06PublicKey to NostrCredential.Secret(nip06PrivateKey), + bareKey.publicKey().xOnly().value.toHex() to NostrCredential.Secret(bareKey), + readOnlyKey to NostrCredential.Public, + ), + ) + + assertEquals(3, merged.size) + val mnemonic = assertIs(merged[wallet.walletId]) + assertEquals(nip06PublicKey, mnemonic.nostrPublicKey) + assertEquals(nip06PrivateKey, mnemonic.privateKey, "the credential's key, not a derivation") + assertEquals(IdentityKind.Mnemonic, mnemonic.kind) + + val secret = assertIs(merged[bareKey.publicKey().xOnly().toWalletId()]) + assertEquals(IdentityKind.NostrSecret, secret.kind) + assertEquals(40, secret.id.nodeIdHash.length, "same shape as a wallet's id") + assertNotEquals(wallet.walletId, secret.id) + + val public = assertIs(merged[readOnlyKey.toXonlyPublicKey().toWalletId()]) + assertEquals(IdentityKind.NostrPublic, public.kind) + assertEquals(readOnlyKey, public.nostrPublicKey) + } + + /** The id a read-only identity gets is the one its secret would get, so an upgrade keeps it. */ + @Test + fun `a public key and its secret get the same id`() { + assertEquals( + StoredIdentity.nostrSecret(bareKey).id, + StoredIdentity.nostrPublic(bareKey.publicKey().xOnly().value.toHex()).id, + ) + } + + /** + * The normal state of a wallet since docs/multiple-profiles.md: its key is a secret + * in the credentials file, and the seed is attached to that entry. Listed once, under + * the wallet's id, with the credential's key -- not twice under two ids, which is what + * this map held before the credentials file was the list of profiles. + */ + @Test + fun `a secret whose key a seed derives is listed once, as the wallet, with the credential's key`() { + val merged = StoredIdentity.merge( + wallets = mapOf(wallet.walletId to wallet), + credentials = mapOf(nip06PublicKey to NostrCredential.Secret(nip06PrivateKey)), + ) + + assertEquals(1, merged.size) + val mnemonic = assertIs(merged[wallet.walletId]) + assertEquals(nip06PrivateKey, mnemonic.privateKey) + assertEquals(wallet, mnemonic.userWallet) + } + + /** + * The state a device from before the repair is in, and the one a failed repair + * leaves: the seed is listed anyway, with its key derived from the words, so that a + * missing entry never hides a wallet. + */ + @Test + fun `a seed with no credential is listed with its key derived`() { + val merged = StoredIdentity.merge( + wallets = mapOf(wallet.walletId to wallet), + credentials = emptyMap(), + ) + + assertEquals(1, merged.size) + val mnemonic = assertIs(merged[wallet.walletId]) + assertEquals(nip06PrivateKey, mnemonic.privateKey) + assertEquals(nip06PublicKey, mnemonic.nostrPublicKey) + } + + /** + * A public entry for a key a seed derives is the state the seed writer's two-file + * upgrade leaves if it dies between its writes, and the one the repair upgrades at + * the next listing. Until then the wallet is listed by derivation, and the public + * entry is not listed at all: one npub, once. + */ + @Test + fun `a public key a seed already derives is listed as the wallet only`() { + val merged = StoredIdentity.merge( + wallets = mapOf(wallet.walletId to wallet), + credentials = mapOf(nip06PublicKey to NostrCredential.Public), + ) + + assertEquals(1, merged.size) + val mnemonic = assertIs(merged[wallet.walletId]) + assertEquals(nip06PrivateKey, mnemonic.privateKey) + } + + @Test + fun `a key filed under a public key it does not derive is dropped`() { + val merged = StoredIdentity.merge( + wallets = emptyMap(), + credentials = mapOf(nip06PublicKey to NostrCredential.Secret(bareKey)), + ) + + assertEquals(emptyMap(), merged) + } + + @Test + fun `the nsec form and the hex form name the same key`() { + assertEquals(nip06PublicKey.toXonlyPublicKey().value.toHex(), nip06PublicKey) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/NostrPublicKeyJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/NostrPublicKeyJvmTest.kt new file mode 100644 index 00000000..6853fd5e --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/NostrPublicKeyJvmTest.kt @@ -0,0 +1,72 @@ +package press.mantra.compose.managers + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import fr.acinq.bitcoin.Chain +import fr.acinq.bitcoin.MnemonicCode +import fr.acinq.bitcoin.byteVector +import fr.acinq.lightning.crypto.LocalKeyManager +import fr.acinq.phoenix.managers.NodeParamsManager +import fr.acinq.phoenix.managers.nostrPrivateKey +import fr.acinq.phoenix.managers.nostrPublicKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * `LocalKeyManager.nostrPublicKey()` is what the app publishes as a profile's + * identity, and since lightning-kmp-app `84cc44c` it comes from the library rather + * than from a copy of its own. This pins the contract from the consuming side: + * x-only, sixty-four lowercase hex characters, NIP-06's published answer for + * NIP-06's own mnemonics, and the same key quartz -- the stack the app signs with + * -- derives from that secret. A library change back to the 33-byte compressed + * form, or off the NIP-06 path, fails here before it renames every profile. + */ +class NostrPublicKeyJvmTest { + + private fun keyManager( + mnemonics: String, + chain: Chain = NodeParamsManager.chain, + swapInXpub: String = NodeParamsManager.remoteSwapInXpub, + ) = LocalKeyManager(MnemonicCode.toSeed(mnemonics, "").byteVector(), chain, swapInXpub) + + // NIP-06's two test vectors: empty passphrase, path m/44'/1237'/0'/0/0 -- which is + // the mainnet path, and the app ships on mainnet. + @Test + fun `is NIP-06's answer for its twelve-word vector`() { + assertEquals(Chain.Mainnet, NodeParamsManager.chain) + assertEquals( + "17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917", + keyManager("leader monkey parrot ring guide accident before fence cannon height naive bean") + .nostrPublicKey() + ) + } + + @Test + fun `is NIP-06's answer for its twenty-four-word vector`() { + assertEquals( + "d41b22899549e1f3d335a31002cfd382174006e166d3e658e3a5eecdb6463573", + keyManager( + "what bleak badge arrange retreat wolf trade produce cricket blur garlic valid " + + "proud rude strong choose busy staff weather area salt hollow arm fade" + ).nostrPublicKey() + ) + } + + // Off mainnet the library derives from account 1', so there is no published answer; + // the shape and the agreement with quartz still have to hold there. + @Test + fun `is the x-only key quartz derives from the same secret, on every chain`() { + val mnemonics = "leader monkey parrot ring guide accident before fence cannon height naive bean" + val testnetSwapInXpub = "tpubDDt5vQap1awkyDXx1z1cP7QFKSZHDCCpbU8nSq9jy7X2grTjUVZDePexf6gc6AHtRRzkgfPW87K6EKUVV6t3Hu2hg7YkHkmMeLSfrP85x41" + for (km in listOf(keyManager(mnemonics), keyManager(mnemonics, Chain.Testnet3, testnetSwapInXpub))) { + val key = km.nostrPublicKey() + assertTrue(key.matches(Regex("[0-9a-f]{64}")), "${km.chain}: not 32 bytes of lowercase hex: $key") + assertEquals( + KeyPair(privKey = km.nostrPrivateKey().value.toByteArray()).pubKey.toHexKey(), + key, + "${km.chain}: quartz derives a different key from the same secret" + ) + } + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/network/relays/RelaysSocketManagerSwitchJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/network/relays/RelaysSocketManagerSwitchJvmTest.kt new file mode 100644 index 00000000..ad29f97c --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/network/relays/RelaysSocketManagerSwitchJvmTest.kt @@ -0,0 +1,179 @@ +package press.mantra.compose.network.relays + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.utils.preferences.InternalPrefs +import fr.acinq.phoenix.utils.preferences.UserPrefs +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonObject +import okio.Path.Companion.toPath +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import press.mantra.compose.database.model.Relay +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.IdentityKind +import press.mantra.compose.identity.toWalletId +import press.mantra.compose.network.sockets.NostrIncomingMessage +import press.mantra.compose.network.sockets.NostrSocketClient +import press.mantra.compose.network.sockets.NostrSocketClientFactory +import press.mantra.compose.network.sockets.SocketConnectionClosedCallback +import press.mantra.compose.network.sockets.SocketConnectionOpenedCallback +import press.mantra.compose.network.sockets.SocketConnectionReopenedCallback +import press.mantra.compose.repository.CachingImportRepository +import press.mantra.compose.repository.RelayRepository +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The relay observer follows the identity that is open, and stops following the one + * that was. + * + * Before Phase 2 of docs/multiple-profiles.md it kept one observer per pubkey and only + * ever cancelled the one for the pubkey being started, so after a switch the previous + * identity's observer was still feeding the pool -- and the pool followed whichever + * relay list emitted last. The regression this pins is exactly that: after switching + * from A to B, a re-emission of A's relay list changes nothing. + */ +class RelaysSocketManagerSwitchJvmTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val scope = CoroutineScope(Job() + Dispatchers.Default) + + private val keyA = PrivateKey(ByteVector32("0a".repeat(32))) + private val keyB = PrivateKey(ByteVector32("0b".repeat(32))) + + /** Each identity's relay list, as the database would emit it. */ + private val relayLists = mutableMapOf>>() + + private val relayRepository = object : RelayRepository { + override suspend fun observePublicKeyRelays(publicKey: String): Flow> = + relayLists.getOrPut(publicKey) { MutableStateFlow(emptyList()) } + } + + private fun prefsStore(name: String) = PreferenceDataStoreFactory.createWithPath { + temporaryFolder.newFolder().resolve("$name.preferences_pb").path.toPath() + } + + private fun identity(key: PrivateKey) = Identity.signing( + id = key.publicKey().xOnly().toWalletId(), + kind = IdentityKind.NostrSecret, + nostrPrivateKey = key, + userPrefs = UserPrefs(prefsStore("user")), + internalPrefs = InternalPrefs(prefsStore("internal")), + business = null, + ) + + private fun relays(key: PrivateKey, vararg urls: String) = + urls.map { Relay(publicKey = key.nostrPublicKeyHex(), type = "user", url = it, read = true, write = true) } + + private fun emit(key: PrivateKey, vararg urls: String) { + relayLists.getOrPut(key.nostrPublicKeyHex()) { MutableStateFlow(emptyList()) }.value = relays(key, *urls) + } + + @AfterTest + fun tearDown() { + scope.cancel() + } + + private suspend fun RelaysSocketManager.awaitRelays(expected: Set) = withTimeout(5_000) { + while (relayUrls != expected) delay(20) + } + + @Test + fun `after a switch the pool follows the new identity, and the old one's list no longer moves it`() = runBlocking { + val activeIdentity = MutableStateFlow(null) + emit(keyA, "wss://a-one.example", "wss://a-two.example") + emit(keyB, "wss://b.example") + val manager = RelaysSocketManager( + activeIdentityStateFlow = activeIdentity, + nostrSocketClientFactory = SwitchFakeSocketClientFactory(), + cachingImportRepository = CachingImportRepository.NO_OP_CACHING_IMPORT_REPOSITORY, + relayRepository = relayRepository, + scope = scope, + ) + + activeIdentity.value = identity(keyA) + manager.awaitRelays(setOf("wss://a-one.example", "wss://a-two.example")) + + // The switch: cleared, then the next one. + activeIdentity.value = null + activeIdentity.value = identity(keyB) + manager.awaitRelays(setOf("wss://b.example")) + + // A's list moves on -- as it would when a sync for A's rows lands -- and the pool + // must not care: A is not open. + emit(keyA, "wss://a-three.example") + delay(500) + assertEquals(setOf("wss://b.example"), manager.relayUrls) + + // B's list moves on, and the pool does follow. + emit(keyB, "wss://b.example", "wss://b-two.example") + manager.awaitRelays(setOf("wss://b.example", "wss://b-two.example")) + } + + @Test + fun `an identity whose list is still empty leaves the pool as it was`() = runBlocking { + val activeIdentity = MutableStateFlow(null) + emit(keyA, "wss://a.example") + val manager = RelaysSocketManager( + activeIdentityStateFlow = activeIdentity, + nostrSocketClientFactory = SwitchFakeSocketClientFactory(), + cachingImportRepository = CachingImportRepository.NO_OP_CACHING_IMPORT_REPOSITORY, + relayRepository = relayRepository, + scope = scope, + ) + activeIdentity.value = identity(keyA) + manager.awaitRelays(setOf("wss://a.example")) + + // B has no relay list yet: nothing is known to replace A's with, and the pool + // keeps what it had -- the behaviour `updateRelayPools` has always had for an + // empty list, unchanged by the switch. + activeIdentity.value = null + activeIdentity.value = identity(keyB) + delay(500) + assertEquals(setOf("wss://a.example"), manager.relayUrls) + + emit(keyB, "wss://b.example") + manager.awaitRelays(setOf("wss://b.example")) + } +} + +private class SwitchFakeSocketClientFactory : NostrSocketClientFactory { + override fun create( + wssUrl: String, + incomingCompressionEnabled: Boolean, + onSocketConnectionOpened: SocketConnectionOpenedCallback?, + onSocketConnectionClosed: SocketConnectionClosedCallback?, + onSocketConnectionReopened: SocketConnectionReopenedCallback?, + ): NostrSocketClient = SwitchFakeNostrSocketClient(wssUrl) +} + +private class SwitchFakeNostrSocketClient(override val socketUrl: String) : NostrSocketClient { + private val _incomingMessages = MutableSharedFlow(extraBufferCapacity = 8) + override val incomingMessages: SharedFlow = _incomingMessages.asSharedFlow() + override var autoReconnect: Boolean = false + override suspend fun close() = Unit + override suspend fun ensureSocketConnectionOrThrow() = Unit + override suspend fun sendMESSAGE(text: String, ensureSessionBeforeSend: Boolean) = Unit + override suspend fun sendAUTH(signedEvent: JsonObject) = Unit + override suspend fun sendCLOSE(subscriptionId: String) = Unit + override suspend fun sendCOUNT(data: JsonObject): String = "unused" + override suspend fun sendEVENT(signedEvent: JsonObject) = Unit + override suspend fun sendREQ(subscriptionId: String, data: JsonObject) = Unit +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreenJvmTest.kt new file mode 100644 index 00000000..710d0220 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreenJvmTest.kt @@ -0,0 +1,206 @@ +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.getBoundsInRoot +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.runDesktopComposeUiTest +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.exceptions.NostrPublishException +import press.mantra.compose.network.dto.RelayDTO +import press.mantra.compose.network.relays.EventPublishTransport +import press.mantra.compose.network.relays.NostrPublishResult +import press.mantra.compose.network.sockets.NostrIncomingMessage +import press.mantra.compose.nostr.Relays +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.state.BroadcastGroupSignedEventUIState +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * What the broadcast screen shows before, and after, the button. + * + * Before: the event named as the signing screen would name it, the app's own + * publish relays already in the list, and a way to add one. After: each relay's + * answer under its own row, in the relay's words where it gave any, and the sum + * above them -- because the sum is what the member came for and the words are + * what they can act on. + */ +@OptIn(ExperimentalTestApi::class) +class BroadcastGroupSignedEventScreenJvmTest { + + private val chatRoomId = "d4c3b2a1".repeat(8) + + /** The first two relays the screen seeds, in the order it lists them. */ + private val seeded = Relays.eventPublishRelaySet.toList() + private val one = seeded[0] + private val two = seeded[1] + + /** A pool that answers as told and never opens a socket. */ + private class FakeTransport( + private val answers: List + ) : EventPublishTransport { + override suspend fun publishEvent( + nostrEvent: NostrEvent, + relays: Set + ): Flow = flowOf(*answers.toTypedArray()) + } + + @Test + fun `the event is named, the app's relays are filled in, and the JSON is there to copy`() = + render(state()) { + // A plain note, which the signing screen has no words of its own + // for, so it is named by its number over its content. + onNodeWithText("Event of kind 1").assertIsDisplayed() + onNodeWithText("The first chapter is out.").assertIsDisplayed() + + // The app's own publish set, as hosts. + seeded.forEach { relay -> + onNodeWithText(relay.displayUrl()).assertIsDisplayed() + } + + onNodeWithText("Broadcast").assertIsDisplayed() + onNodeWithText("Copy raw JSON").assertIsDisplayed() + + // The relays come before the JSON: the list is the screen's job and + // the JSON is for whoever would rather use a different tool. + val relays = onNodeWithText(one.displayUrl()).getBoundsInRoot().top + val json = onNodeWithText("The signed event").getBoundsInRoot().top + assertTrue(relays < json, "the JSON climbed above the relay list") + } + + @Test + fun `a relay can be added, and a bad one is refused by name`() = render(state()) { + onNodeWithText("Relay url").performTextInput("wss://relay.three.example") + onNodeWithContentDescription("Add relay").performClick() + onNodeWithText("relay.three.example").assertIsDisplayed() + + onNodeWithText("Relay url").performTextInput("not a relay") + onNodeWithContentDescription("Add relay").performClick() + onNodeWithText("That isn't a relay address.", substring = true).assertIsDisplayed() + } + + @Test + fun `after the button, each relay says what it answered and the sum sits above them`() = + render( + uiState = state(), + transport = FakeTransport( + listOf( + NostrPublishResult( + result = NostrIncomingMessage.OkMessage(eventId = "b".repeat(64), success = true), + relayUrl = one.url, + ), + NostrPublishResult( + result = NostrIncomingMessage.OkMessage( + eventId = "b".repeat(64), + success = false, + message = "blocked: not on the allow list", + ), + error = NostrPublishException(message = "blocked: not on the allow list"), + relayUrl = two.url, + ), + ) + ) + ) { + onNodeWithContentDescription("Broadcast to these relays").performClick() + + // The two that answered are counted; the rest of the seeded list + // never did, and the sum says so by counting them as asked. + waitUntil(timeoutMillis = 5_000) { + onAllNodesWithText("Accepted by 1 of ${seeded.size} relays.") + .fetchSemanticsNodes().isNotEmpty() + } + + onNodeWithText("Accepted").assertIsDisplayed() + onNodeWithText("Rejected: blocked: not on the allow list").assertIsDisplayed() + + // The answer under the relay it came from, not in a list of its own. + val relayOne = onNodeWithText(one.displayUrl()).getBoundsInRoot().top + val accepted = onNodeWithText("Accepted").getBoundsInRoot().top + val relayTwo = onNodeWithText(two.displayUrl()).getBoundsInRoot().top + val rejected = onNodeWithText("Rejected: blocked: not on the allow list").getBoundsInRoot().top + assertTrue(relayOne < accepted && accepted < relayTwo, "the OK is not under its relay") + assertTrue(relayTwo < rejected, "the rejection is not under its relay") + } + + @Test + fun `an empty list asks nobody and says to add one`() = render(state()) { + // Emptied by hand, since the seed always has something in it. + repeat(seeded.size) { + onAllNodesWithContentDescription("Remove relay")[0].performClick() + } + + onNodeWithText("No relays to broadcast to. Add one above.").assertIsDisplayed() + } + + private fun signedEvent() = GroupSignedEvent( + id = "b".repeat(64), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = TextNoteEvent.KIND, + tags = emptyArray(), + content = "The first chapter is out.", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000), + ) + + private fun state() = BroadcastGroupSignedEventUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = "a".repeat(64), + subject = "Translation room", + description = "A group translating hard books.", + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = "state" + ) + ), + signedEvent = signedEvent(), + ) + + private fun render( + uiState: BroadcastGroupSignedEventUIState, + transport: EventPublishTransport = FakeTransport(emptyList()), + assertions: ComposeUiTest.() -> Unit + ) = runDesktopComposeUiTest(width = 400, height = 2400) { + setContent { + MantraTheme { + ProvideSnackbarHost { + Box(modifier = Modifier.fillMaxSize()) { + BroadcastGroupSignedEventScreen( + activeUserPublicKey = "a".repeat(64), + chatRoomId = chatRoomId, + relayHint = null, + signedEventId = "b".repeat(64), + initialBroadcastGroupSignedEventUIState = uiState, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + publishTransport = transport, + onNavigateBack = {} + ) + } + } + } + } + + assertions() + } +} 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 b4443882..ac4d48b7 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 @@ -18,7 +18,7 @@ import press.mantra.compose.repository.DkgRepository import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.state.HomeScreenUIState import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import kotlin.test.Test @@ -83,7 +83,7 @@ class ChatPaneLayoutJvmTest { @androidx.compose.runtime.Composable private fun Home() { - TorchTheme { + MantraTheme { // Required, and deliberately so: `LocalSnackbarHostState` throws rather than // defaulting, because a default would make every `notify` on a screen that // forgot the host a silent no-op. diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ChatRoomMemberRowJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ChatRoomMemberRowJvmTest.kt new file mode 100644 index 00000000..26fdfdad --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ChatRoomMemberRowJvmTest.kt @@ -0,0 +1,127 @@ +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.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runDesktopComposeUiTest +import press.mantra.compose.database.model.Participant +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.intermdiate.LocalParticipant +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.activeUserPublicKey +import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.chatRoomId +import press.mantra.compose.ui.composable.navigation.routes.ProfilePreviewRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.state.ChatRoomDetailUIState +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The member rows on the room's screen, and what one opens. + * + * A row is a promise about the screen behind it, and this one was a stub for long + * enough that the promise is worth pinning: a member opens the profile preview, + * keyed by the member's public key, with the active identity beside it so the + * preview can say whether a chat with them already exists. + * + * Two members, because the screen names them differently and the row has to open + * the same screen either way. One has a profile and is named by it. The other has + * none yet -- the "LOADING..." row `MemberProfileSync` exists for -- and is named + * by the key; that member is the reason the row opens the preview rather than the + * profile detail, which is keyed by a kind 0's event id and has nothing to open for + * a member whose kind 0 has not arrived. + */ +@OptIn(ExperimentalTestApi::class) +class ChatRoomMemberRowJvmTest { + + private val named = "b".repeat(64) + private val stillLoading = "c".repeat(64) + + @Test + fun `a member with a profile opens the preview by their key`() { + var opened: Route? = null + + render(onNavigateToRoute = { opened = it }) { + onNodeWithText("Nomvula").performClick() + } + + assertEquals(ProfilePreviewRoute(activeUserPublicKey, named), opened) + } + + @Test + fun `a member whose profile has not arrived opens the preview by their key too`() { + var opened: Route? = null + + render(onNavigateToRoute = { opened = it }) { + onNodeWithText(stillLoading).performClick() + } + + assertEquals(ProfilePreviewRoute(activeUserPublicKey, stillLoading), opened) + } + + private fun member(publicKey: String, profile: Profile?) = LocalParticipant( + participant = Participant( + participantPublicKey = publicKey, + chatRoomId = chatRoomId, + relayHint = null, + ), + profile = profile, + ) + + private fun state() = ChatRoomDetailUIState.Loaded( + localChatRoom = GroupSignedWorkFixtures.localChatRoom().copy( + localParticipants = listOf( + member( + named, + Profile( + publicKey = named, + displayName = "Nomvula", + nostrEventId = "e".repeat(64), + ), + ), + member(stillLoading, profile = null), + ), + ), + ) + + /** + * The screen at a phone's width and a window tall enough to compose the whole + * of it, as `GroupSignedWorkRowsJvmTest` renders it: the members are near the + * bottom, and a `LazyColumn` does not lay out what it would not show. + */ + private fun render( + onNavigateToRoute: (Route) -> Unit, + assertions: ComposeUiTest.() -> Unit, + ) = runDesktopComposeUiTest(width = 400, height = 4000) { + setContent { + MantraTheme { + ProvideSnackbarHost { + Box(modifier = Modifier.fillMaxSize()) { + ChatRoomDetailScreen( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = null, + initialChatRoomDetailUIState = state(), + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, + onNavigateToRoute = onNavigateToRoute, + onPopBackToRoute = {}, + onNavigateBack = {}, + ) + } + } + } + } + + assertions() + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/CreateProfileScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/CreateProfileScreenJvmTest.kt new file mode 100644 index 00000000..cac213bf --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/CreateProfileScreenJvmTest.kt @@ -0,0 +1,49 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.runDesktopComposeUiTest +import press.mantra.compose.database.model.Profile +import press.mantra.compose.identity.NewProfileWriter +import press.mantra.compose.repository.MarmotRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.state.CreateProfileUIState +import kotlin.test.Test + +/** + * What the create screen no longer offers -- Phase 5 of docs/multiple-profiles.md. + * + * "End this" wiped the database: every profile's rooms, MLS state, key packages and + * queues, from a screen whose purpose is to add a profile. With several profiles on a + * device it was a way to lose the others. The state that carried it is composed and + * looked at; the words are not there. + */ +@OptIn(ExperimentalTestApi::class) +class CreateProfileScreenJvmTest { + + @Test + fun `the screen that adds a profile cannot wipe the others`() = runDesktopComposeUiTest(400, 900) { + setContent { + MantraTheme { + ProvideSnackbarHost { + CreateProfileScreen( + initialCreateProfileUIState = CreateProfileUIState.ProfileReady( + profile = Profile(publicKey = "ab".repeat(32), nostrEventId = "e".repeat(64), displayName = "Alan"), + ), + onNavigateBack = {}, + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + marmotRepository = MarmotRepository.NO_OP_MARMOT_KEY_PACKAGE_BUNDLE, + newProfile = NewProfileWriter { Result.failure(IllegalStateException("not reached")) }, + onProfileCreated = {}, + ) + } + } + } + + onNodeWithText("Profile is ready", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("End this", useUnmergedTree = true).assertDoesNotExist() + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupKeyStateSheetLayoutJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupKeyStateSheetLayoutJvmTest.kt index 3f856919..4fc7e259 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupKeyStateSheetLayoutJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupKeyStateSheetLayoutJvmTest.kt @@ -18,7 +18,7 @@ import press.mantra.compose.database.model.GroupKeyState import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme /** * The one thing about this sheet that reading it does not settle. @@ -78,7 +78,7 @@ class GroupKeyStateSheetLayoutJvmTest { fun `the signed event wraps inside the sheet rather than running off it`() = runDesktopComposeUiTest(width = 800, height = 2400) { setContent { - TorchTheme { + MantraTheme { // The sheet reports a copy through the snackbar host, so it // needs one in scope the way every screen does. ProvideSnackbarHost { @@ -105,7 +105,7 @@ class GroupKeyStateSheetLayoutJvmTest { fun `a device holding no signed event still renders the state`() = runDesktopComposeUiTest(width = 800, height = 2400) { setContent { - TorchTheme { + MantraTheme { ProvideSnackbarHost { Box(modifier = Modifier.width(phoneWidth).testTag(SHEET)) { GroupKeyStateSheetContent( diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupSignedWorkFixtures.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupSignedWorkFixtures.kt new file mode 100644 index 00000000..c4b22221 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupSignedWorkFixtures.kt @@ -0,0 +1,31 @@ +package press.mantra.compose.ui.composable + +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.intermdiate.LocalChatRoom + +/** + * A group room to compose the group's screen against. + * + * The fork's fixture of this name also carries the signed work the group's screen + * showed there -- a profile, relay lists, posts, curated schemas and entries -- which + * Mantra took out with lines B, D and H (docs/curated-to-mantra.md). What is left is + * the room itself, under the same names, so that a test written upstream against the + * room alone compiles here unchanged; `ChatRoomMemberRowJvmTest` is the first. + */ +internal object GroupSignedWorkFixtures { + + val chatRoomId = "d4c3b2a1".repeat(8) + + val activeUserPublicKey = "a".repeat(64) + + fun localChatRoom(mlsGroupState: String? = "state") = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = activeUserPublicKey, + subject = "Translation room", + description = "A group translating hard books.", + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = mlsGroupState + ) + ) +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/NavigateBackButtonJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/NavigateBackButtonJvmTest.kt new file mode 100644 index 00000000..2ed6f184 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/NavigateBackButtonJvmTest.kt @@ -0,0 +1,111 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runDesktopComposeUiTest +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +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.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.state.ChatRoomMessagingUIState +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Where the back button is, on the two screens that have to decide. + * + * Every other pushed screen draws `NavigateBackButton` unconditionally, and there is + * nothing to assert about that beyond the compiler: the parameter is not optional. These + * two take a nullable callback because the same composable is reached two ways, and + * only the caller knows which: + * + * - the chat room is a destination on a phone and the home screen's detail pane in an + * expanded window. In the pane the room list is beside it, and a button that popped + * would pop the home screen; + * - the "coming soon" placeholder is pushed from a *learn more* and is also where the + * navigation machine lands, with the stack cleared, on an error. + * + * Read by the description a screen reader would announce, which is also the one thing + * about the button a test can check that the type system does not. + */ +@OptIn(ExperimentalTestApi::class) +class NavigateBackButtonJvmTest { + + @Test + fun `a pushed chat room has a back button, and it pops`() = runDesktopComposeUiTest(400, 900) { + var popped = false + setContent { Chat(onNavigateBack = { popped = true }) } + + onNodeWithContentDescription("Back").assertIsDisplayed().performClick() + assertTrue(popped, "the button should have called the host's pop") + } + + @Test + fun `the same chat room beside its list has none`() = runDesktopComposeUiTest(1000, 900) { + setContent { Chat(onNavigateBack = null) } + + onNodeWithContentDescription("Back").assertDoesNotExist() + } + + @Test + fun `a pushed placeholder has a back button`() = runDesktopComposeUiTest(400, 900) { + setContent { Placeholder(onNavigateBack = {}) } + + onNodeWithContentDescription("Back").assertIsDisplayed() + } + + @Test + fun `a placeholder at the root of the stack has none`() = runDesktopComposeUiTest(400, 900) { + setContent { Placeholder(onNavigateBack = null) } + + onNodeWithContentDescription("Back").assertDoesNotExist() + } + + @Composable + private fun Chat(onNavigateBack: (() -> Unit)?) { + MantraTheme { + ProvideSnackbarHost { + ChatRoomMessagingScreen( + activeUserPublicKey = "a".repeat(64), + chatRoomId = "b".repeat(64), + relayHint = null, + initialChatRoomMessagingUIState = ChatRoomMessagingUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = "b".repeat(64), + userPublicKey = "a".repeat(64), + subject = "Reading group", + description = null, + initialGiftWrapPayloadId = null, + mlsGroupState = null, + ), + ), + ), + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY, + onNavigateToRouteAndPopUpInclusive = {}, + onNavigateToRoute = {}, + onNavigateBack = onNavigateBack, + ) + } + } + } + + @Composable + private fun Placeholder(onNavigateBack: (() -> Unit)?) { + MantraTheme { + ProvideSnackbarHost { + ImplementationPendingScreen(text = "Learn more", onNavigateBack = onNavigateBack) + } + } + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreenJvmTest.kt new file mode 100644 index 00000000..0f1dc539 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreenJvmTest.kt @@ -0,0 +1,221 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.CompositionLocalProvider +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.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runDesktopComposeUiTest +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import press.mantra.compose.database.model.MarmotKeyPackage +import press.mantra.compose.database.model.NegentropySynchronizeRequest +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.SynchronizeNostrEventRequest +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.extensions.hexToNpubHrp +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.LocalCanSign +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.state.ProfilePreviewUIState +import press.mantra.compose.ui.view.state.ProfilePreviewUIState.Readiness +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The person behind a pasted npub on their own screen: what is drawn in each state, + * what the button hands over, and who is offered it. + */ +@OptIn(ExperimentalTestApi::class) +class ProfilePreviewScreenJvmTest { + + private val activeUserPublicKey = "a".repeat(64) + private val profilePublicKey = "b".repeat(64) + private val npub = profilePublicKey.hexToNpubHrp() + + private val alice = Profile( + publicKey = profilePublicKey, + displayName = "Alice", + nip05 = "alice@example.com", + about = "Translating hard books into Sesotho, one chapter at a time.", + nostrEventId = "kind0", + ) + + @Test + fun `a found profile is drawn whole, with the npub that was pasted`() = render( + ProfilePreviewUIState.Loaded(profile = alice, readiness = Readiness.CanStartChat) + ) { + onNodeWithText("Alice").assertIsDisplayed() + onNodeWithText("alice@example.com").assertIsDisplayed() + onNodeWithText("Translating hard books into Sesotho, one chapter at a time.").assertIsDisplayed() + onNodeWithText(npub).assertIsDisplayed() + } + + @Test + fun `starting a chat hands over the route the dialog used to push, and creates nothing here`() { + var opened: Route? = null + + render(ProfilePreviewUIState.Loaded(profile = alice, readiness = Readiness.CanStartChat), onStartChat = { opened = it }) { + onNodeWithText("Start new chat", useUnmergedTree = true).assertIsDisplayed().performClick() + } + + assertEquals( + ChatRoomMessagingRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = profilePublicKey, + relayHint = null, + ), + opened, + ) + } + + /** A message is a gift wrap sealed with the sender's key; a read-only identity is shown the person and nothing to do. */ + @Test + fun `a read-only identity sees the person and no button`() = render( + ProfilePreviewUIState.Loaded(profile = alice, readiness = Readiness.CanStartChat), + canSign = false, + ) { + onNodeWithText("Alice").assertIsDisplayed() + onNodeWithText("Start new chat", useUnmergedTree = true).assertDoesNotExist() + } + + /** Arriving loading is the one case that runs `initiate`, so this render is given relays that never answer. */ + // --- whether the button can be pressed --- + + @Test + fun `a room that exists is opened, not started again`() { + var opened: Route? = null + + render( + ProfilePreviewUIState.Loaded( + profile = alice, + readiness = Readiness.ExistingChat(chatRoomId = "room", relayHint = "wss://hint.example"), + ), + onStartChat = { opened = it }, + ) { + onNodeWithText("You already have a chat with Alice.").assertIsDisplayed() + onNodeWithText("Start new chat", useUnmergedTree = true).assertDoesNotExist() + onNodeWithText("Open chat", useUnmergedTree = true).assertIsDisplayed().performClick() + } + + assertEquals( + ChatRoomMessagingRoute(activeUserPublicKey = activeUserPublicKey, chatRoomId = "room", relayHint = "wss://hint.example"), + opened, + ) + } + + @Test + fun `while the key package is still owed the button waits, and says so`() = render( + ProfilePreviewUIState.Loaded(profile = alice, readiness = Readiness.Checking) + ) { + onNodeWithText("Start new chat", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("Start new chat").assertIsNotEnabled() + onNodeWithText("Checking whether Alice can receive messages here.").assertIsDisplayed() + } + + @Test + fun `a person not yet on Mantra cannot be written to, and the reason is under the button`() = render( + ProfilePreviewUIState.Loaded(profile = alice, readiness = Readiness.NotYetOnMantra) + ) { + onNodeWithText("Start new chat").assertIsNotEnabled() + onNodeWithText("Alice hasn't set up messaging on Mantra yet. Try again later, or ask them to open Mantra.").assertIsDisplayed() + onNodeWithText("Try again").assertIsDisplayed() + } + + @Test + fun `a person whose key package is here can be written to at once`() = render( + ProfilePreviewUIState.Loaded(profile = alice, readiness = Readiness.CanStartChat) + ) { + onNodeWithText("Start new chat").assertIsEnabled() + onNodeWithText("Checking whether", substring = true).assertDoesNotExist() + onNodeWithText("hasn't set up messaging", substring = true).assertDoesNotExist() + } + + // --- the other states --- + + @Test + fun `while the relays are asked the npub is already on screen`() = render( + ProfilePreviewUIState.Loading, + nostrRepository = NothingArrives, + chatRepository = NoRooms, + ) { + onNodeWithText(npub).assertIsDisplayed() + onNodeWithText("Looking for this profile on the relays.").assertIsDisplayed() + onNodeWithText("Start new chat", useUnmergedTree = true).assertDoesNotExist() + } + + @Test + fun `nothing found says so and offers to ask again`() = render( + ProfilePreviewUIState.NotFound + ) { + onNodeWithText("We could not find a profile for this key on the relays we asked.").assertIsDisplayed() + onNodeWithText("Try again").assertIsDisplayed() + onNodeWithText("Start new chat", useUnmergedTree = true).assertDoesNotExist() + } + + @Test + fun `a queue that refused is an error with a retry`() = render( + ProfilePreviewUIState.Error + ) { + onNodeWithText("Something went wrong").assertIsDisplayed() + onNodeWithText("Try again").assertIsDisplayed() + } + + /** No row on the device, and asks that go nowhere: the screen stays loading for as long as it is looked at. */ + private val NothingArrives = object : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + override suspend fun observeProfileWithPublicKey(publicKey: String): Flow = flowOf(null) + override suspend fun queueSynchronizeNostrEvent(synchronizeNostrEventRequests: List) = Unit + override suspend fun queueNegentropySynchronizeRequest(negentropySynchronizeRequests: List) = Unit + } + + /** No room with the person, and no key package for them. */ + private val NoRooms = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY { + override suspend fun observeMarmotKeyPackageForPublicKey(publicKey: String): Flow = flowOf(null) + override suspend fun findDirectMessageRoom(userPublicKey: String, peerPublicKey: String): LocalChatRoom? = null + } + + /** + * The screen at a phone's width. The state is passed in, so the NO_OP repository is + * never asked for anything: `initiate` only runs for a screen that arrives loading. + */ + private fun render( + uiState: ProfilePreviewUIState, + canSign: Boolean = true, + nostrRepository: NostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository: ChatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + onStartChat: (Route) -> Unit = {}, + assertions: ComposeUiTest.() -> Unit, + ) = runDesktopComposeUiTest(width = 400, height = 1200) { + setContent { + MantraTheme { + ProvideSnackbarHost { + CompositionLocalProvider(LocalCanSign provides canSign) { + Box(modifier = Modifier.fillMaxSize()) { + ProfilePreviewScreen( + activeUserPublicKey = activeUserPublicKey, + profilePublicKey = profilePublicKey, + initialProfilePreviewUIState = uiState, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + onStartChat = onStartChat, + onNavigateBack = {}, + ) + } + } + } + } + } + + assertions() + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilesScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilesScreenJvmTest.kt new file mode 100644 index 00000000..1d5a284b --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilesScreenJvmTest.kt @@ -0,0 +1,185 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runDesktopComposeUiTest +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.phoenix.data.ListWalletState +import fr.acinq.phoenix.data.UserWallet +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.utils.preferences.InternalPrefs +import fr.acinq.phoenix.utils.preferences.UserPrefs +import fr.acinq.phoenix.utils.preferences.UserWalletMetadata +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import okio.Path.Companion.toPath +import press.mantra.compose.database.model.Profile +import press.mantra.compose.extensions.hexToNpubHrp +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.IdentityKind +import press.mantra.compose.identity.StoredIdentity +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.model.ProfilesViewModel +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The switcher, drawn -- Phase 4 of docs/multiple-profiles.md. + * + * Three profiles: A, with a wallet attached and open; B, a bare key; C, read only. What + * is pinned is what each row says before the tap -- the nostr name where the device has + * one and the npub where it does not, *wallet* and *read only* where they apply and + * nothing for the plain case -- that the open one sits above the others, and that + * tapping another row is exactly one call with its id while tapping the open one is + * none. On the unmerged tree throughout, as `ReadOnlyEntrancesJvmTest` explains. + */ +@OptIn(ExperimentalTestApi::class) +class ProfilesScreenJvmTest { + + private val keyA = PrivateKey(ByteVector32("0a".repeat(32))) + private val keyB = PrivateKey(ByteVector32("0b".repeat(32))) + private val keyC = PrivateKey(ByteVector32("0c".repeat(32))) + + private val walletId = WalletId("aa".repeat(20)) + private val a = StoredIdentity.Mnemonic(UserWallet(walletId, nodeId = "02" + "cd".repeat(32), words = List(12) { "abandon" }), keyA) + private val b = StoredIdentity.nostrSecret(keyB) + private val c = StoredIdentity.nostrPublic(keyC.nostrPublicKeyHex()) + private val identities = linkedMapOf(a.id to a, b.id to b, c.id to c) + + private fun prefsFile(name: String) = + Files.createTempFile(name, ".preferences_pb").also { it.toFile().delete() }.toString().toPath() + + private val open = Identity.signing( + id = a.id, + kind = IdentityKind.Mnemonic, + nostrPrivateKey = keyA, + userPrefs = UserPrefs(PreferenceDataStoreFactory.createWithPath { prefsFile("user") }), + internalPrefs = InternalPrefs(PreferenceDataStoreFactory.createWithPath { prefsFile("internal") }), + business = null, + ) + + /** A has a kind 0; B and C do not. */ + private val profiles = object : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + override suspend fun observeProfileWithPublicKey(publicKey: HexKey): Flow = flowOf( + if (publicKey == a.nostrPublicKey) Profile(publicKey = publicKey, displayName = "Alice", nostrEventId = "e".repeat(64)) else null + ) + } + + private val metadata = identities.keys.associateWith { id -> + UserWalletMetadata(walletId = id, name = null, avatar = "🦊", createdAt = null, isHidden = false) + } + + private val switched = mutableListOf() + private val taps = mutableListOf() + + private fun viewModel(listState: ListWalletState = ListWalletState.Success) = ProfilesViewModel( + listWalletState = MutableStateFlow(listState), + availableIdentities = MutableStateFlow(identities), + activeIdentity = MutableStateFlow(open), + walletsMetadata = flowOf(metadata), + nostrRepository = profiles, + ) + + @Composable + private fun Screen(viewModel: ProfilesViewModel, onRetry: () -> Unit = {}) { + MantraTheme { + ProvideSnackbarHost { + ProfilesScreen( + viewModel = viewModel, + onNavigateBack = {}, + onSwitch = { switched += it }, + onRetry = onRetry, + onSignIn = { taps += "sign in" }, + onCreateProfile = { taps += "create" }, + ) + } + } + } + + private fun ComposeUiTest.awaitLoaded() = waitUntil(timeoutMillis = 5_000) { + onAllNodesWithText("Alice", useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty() + } + + @Test + fun `each row says what the device holds, and the open one sits above the others`() = runDesktopComposeUiTest(400, 900) { + setContent { Screen(viewModel()) } + awaitLoaded() + + // A by its nostr name, with its npub under it and a wallet attached. + onNodeWithText("Alice", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText(a.nostrPublicKey.hexToNpubHrp(), useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("Wallet", useUnmergedTree = true).assertIsDisplayed() + // B and C by their npub, since the device has no kind 0 for either; only C carries a label. + onNodeWithText(b.nostrPublicKey.hexToNpubHrp(), useUnmergedTree = true).assertIsDisplayed() + onNodeWithText(c.nostrPublicKey.hexToNpubHrp(), useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("Read only", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("Default name", useUnmergedTree = true).assertDoesNotExist() + + val openTop = onNodeWithText("Alice", useUnmergedTree = true).getBoundsInRoot().top + val otherTop = onNodeWithText(b.nostrPublicKey.hexToNpubHrp(), useUnmergedTree = true).getBoundsInRoot().top + assertTrue(openTop < otherTop, "the open profile is drawn above the others") + } + + @Test + fun `tapping another profile switches to it once, and tapping the open one does nothing`() = runDesktopComposeUiTest(400, 900) { + setContent { Screen(viewModel()) } + awaitLoaded() + + onNodeWithText("Alice", useUnmergedTree = true).performClick() + assertEquals(emptyList(), switched) + + onNodeWithText(b.nostrPublicKey.hexToNpubHrp(), useUnmergedTree = true).performClick() + assertEquals(listOf(b.id), switched) + } + + @Test + fun `an unreadable key file is an error with a retry`() = runDesktopComposeUiTest(400, 900) { + var retried = 0 + setContent { Screen(viewModel(ListWalletState.Error.Serialization), onRetry = { retried++ }) } + + waitUntil(timeoutMillis = 5_000) { + onAllNodesWithText("Try again", useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty() + } + onNodeWithText("Try again", useUnmergedTree = true).performClick() + assertEquals(1, retried) + } + + /** The two ways to add a profile, under the list, each pushing its own screen. */ + @Test + fun `under the list are the two ways to add a profile, and the one that is open stays`() = runDesktopComposeUiTest(400, 900) { + setContent { Screen(viewModel()) } + awaitLoaded() + + onNodeWithText("The profile you are in stays on this device.", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("Sign in with a key", useUnmergedTree = true).performClick() + onNodeWithText("Create a new profile", useUnmergedTree = true).performClick() + + assertEquals(listOf("sign in", "create"), taps) + assertEquals(emptyList(), switched, "adding is not switching") + } + + @Test + fun `the list is a pushed screen, with a way back`() = runDesktopComposeUiTest(400, 900) { + setContent { Screen(viewModel()) } + awaitLoaded() + + onNodeWithText("Profiles", useUnmergedTree = true).assertIsDisplayed() + onNodeWithContentDescription("Back", useUnmergedTree = true).assertIsDisplayed() + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt new file mode 100644 index 00000000..1cfab53d --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt @@ -0,0 +1,361 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runDesktopComposeUiTest +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.utils.preferences.InternalPrefs +import fr.acinq.phoenix.utils.preferences.UserPrefs +import kotlinx.coroutines.flow.MutableStateFlow +import okio.Path.Companion.toPath +import press.mantra.compose.database.GENESIS_AT +import press.mantra.compose.database.model.SynchronizeNostrEventRequest +import press.mantra.compose.database.model.UnsignedNostrEvent +import press.mantra.compose.database.model.intermdiate.LocalAccount +import press.mantra.compose.database.model.types.SynchronizationFilter +import press.mantra.compose.extensions.hexToNpubHrp +import press.mantra.compose.identity.Identity +import press.mantra.compose.nostr.SignInSync +import java.nio.file.Files +import kotlin.time.Instant +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +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.intermdiate.LocalNostrEvent +import press.mantra.compose.database.model.intermdiate.LocalProfileWithFollowing +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.composable.widgets.LocalCanSign +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.state.ActiveProfileUIState +import press.mantra.compose.ui.view.state.HomeScreenUIState +import press.mantra.compose.ui.view.state.ProfilePreviewUIState +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The inventory in Phase 5 of docs/npub-sign-in.md, as assertions. + * + * Every write entrance one tap from the three tabs has a row there, and a row without an + * assertion is a row that will regress: the compiler cannot say whether the inventory is + * complete, since `LocalCanSign` is read where a control is drawn and nothing forces a + * control to read it. So each screen is composed twice, once as an identity that can + * sign and once as one that cannot, and the controls are looked for by their text. + * + * The repositories are the no-op ones with the reads each screen makes delegated to a + * fixed answer, as `ChatPaneLayoutJvmTest` does. + */ +@OptIn(ExperimentalTestApi::class) +class ReadOnlyEntrancesJvmTest { + + private val publicKey = "de1a1e64d1c4e0d6bd97b0e73d4dfd0e1ec1cdd1e6d8d0e2b9a7f3c5d0e1a2b3" + private val eventId = "aa11bb22cc33dd44ee55ff6600778899aabbccddeeff00112233445566778899" + + // --- Home: the room list, or the empty state with the upgrade --- + + @Test + fun `an identity that can sign is offered a new chat, and no upgrade`() = runDesktopComposeUiTest(400, 900) { + setContent { AsIdentity(canSign = true) { Home() } } + + onNodeWithText("New chat", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("Sign in with the nsec", useUnmergedTree = true).assertDoesNotExist() + } + + @Test + fun `a read-only identity is offered the upgrade, and no new chat`() = runDesktopComposeUiTest(400, 900) { + setContent { AsIdentity(canSign = false) { Home() } } + + onNodeWithText("Sign in with the nsec", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("New chat", useUnmergedTree = true).assertDoesNotExist() + } + + /** One column at every width: there is no list to put a detail beside. */ + @Test + fun `a read-only identity in a large window still has one column`() = runDesktopComposeUiTest(1400, 900) { + setContent { AsIdentity(canSign = false) { Home() } } + + onNodeWithText("Sign in with the nsec", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("New chat", useUnmergedTree = true).assertDoesNotExist() + onNodeWithText("Pick a conversation to read it here.", useUnmergedTree = true).assertDoesNotExist() + } + + // --- Profile preview: the one thing to do about a person, or nothing --- + + @Test + fun `an identity that can sign is offered a chat with the person it looked up`() = runDesktopComposeUiTest(400, 1200) { + setContent { AsIdentity(canSign = true) { ProfilePreview() } } + + onNodeWithText("Reader", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("Start new chat", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun `a read-only identity is shown the person and offered nothing`() = runDesktopComposeUiTest(400, 1200) { + setContent { AsIdentity(canSign = false) { ProfilePreview() } } + + onNodeWithText("Reader", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("Start new chat", useUnmergedTree = true).assertDoesNotExist() + } + + // --- Profile: key packages and key recovery, or neither --- + + @Test + fun `an identity that can sign is offered key recovery and key packages`() = runDesktopComposeUiTest(400, 1200) { + setContent { AsIdentity(canSign = true) { ActiveProfile() } } + + onNodeWithText("Key recovery", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("Key package management", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("Sign out", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("Switch profile", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun `a read-only identity is offered neither, and can still sign out`() = runDesktopComposeUiTest(400, 1200) { + setContent { AsIdentity(canSign = false) { ActiveProfile() } } + + onNodeWithText("Key recovery", useUnmergedTree = true).assertDoesNotExist() + onNodeWithText("Key package management", useUnmergedTree = true).assertDoesNotExist() + onNodeWithText("Sign out", useUnmergedTree = true).assertIsDisplayed() + // A read-only identity can leave for another profile the same way a signing one can. + onNodeWithText("Switch profile", useUnmergedTree = true).assertIsDisplayed() + } + + // --- Profile: sign out is real for a read-only identity, and confirms first --- + + @Test + fun `a read-only identity's sign out asks before it acts, naming the npub`() = runDesktopComposeUiTest(400, 1200) { + setContent { AsIdentity(canSign = false) { ActiveProfile(signOut = signOutDependencies) } } + + onNodeWithText("Sign out", useUnmergedTree = true).performClick() + + onNodeWithText("Sign out of this profile?", useUnmergedTree = true).assertIsDisplayed() + // The screen shows the npub once already; the dialog names it a second time. + onAllNodesWithText(publicKey.hexToNpubHrp(), substring = true, useUnmergedTree = true).assertCountEquals(2) + onNodeWithText("nothing to lose", substring = true, useUnmergedTree = true).assertIsDisplayed() + } + + // --- Not found: set one up, or use a different key --- + + @Test + fun `an identity that can sign is offered the set-up form when its profile is not found`() = runDesktopComposeUiTest(400, 1200) { + setContent { AsIdentity(canSign = true) { NotFound() } } + + onNodeWithText("Try again", useUnmergedTree = true).assertIsDisplayed() + // Heading and button both say it; either is the form. + onAllNodesWithText("Set up a profile", useUnmergedTree = true).onFirst().assertIsDisplayed() + onNodeWithText("Use a different key", useUnmergedTree = true).assertDoesNotExist() + } + + @Test + fun `a read-only identity is offered a different key instead, and it confirms first`() = runDesktopComposeUiTest(400, 1200) { + setContent { AsIdentity(canSign = false) { NotFound() } } + + onNodeWithText("Try again", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("Set up a profile", useUnmergedTree = true).assertDoesNotExist() + onNodeWithText("Use a different key", useUnmergedTree = true).assertIsDisplayed() + + onNodeWithText("Use a different key", useUnmergedTree = true).performClick() + + onNodeWithText("Use a different key?", useUnmergedTree = true).assertIsDisplayed() + } + + // --- Not found: the third exit, when there is another profile to go to --- + + @Test + fun `with another profile on the device the not-found state offers to switch, for either kind`() = runDesktopComposeUiTest(400, 1200) { + var switched = 0 + setContent { AsIdentity(canSign = false) { NotFound(onSwitchProfile = { switched++ }) } } + + onNodeWithText("Switch profile", useUnmergedTree = true).performClick() + assertEquals(1, switched) + // The read-only exit is still there beside it; switching does not forget. + onNodeWithText("Use a different key", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun `a signing identity that was not found is offered the switch above the set-up form`() = runDesktopComposeUiTest(400, 1200) { + setContent { AsIdentity(canSign = true) { NotFound(onSwitchProfile = {}) } } + + onNodeWithText("Switch profile", useUnmergedTree = true).assertIsDisplayed() + // The heading and the button both say it; either is enough to know the form is there. + onAllNodesWithText("Set up a profile", useUnmergedTree = true).onFirst().assertIsDisplayed() + } + + @Test + fun `with one profile on the device there is nothing to switch to`() = runDesktopComposeUiTest(400, 1200) { + setContent { AsIdentity(canSign = false) { NotFound(onSwitchProfile = null) } } + + onNodeWithText("Use a different key", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("Switch profile", useUnmergedTree = true).assertDoesNotExist() + } + + // --- harness --- + + private val readOnlyIdentity = MutableStateFlow( + Identity.readOnly( + id = WalletId("ab".repeat(20)), + nostrPublicKey = publicKey, + userPrefs = UserPrefs(PreferenceDataStoreFactory.createWithPath { Files.createTempFile("user", ".preferences_pb").also { it.toFile().delete() }.toString().toPath() }), + internalPrefs = InternalPrefs(PreferenceDataStoreFactory.createWithPath { Files.createTempFile("internal", ".preferences_pb").also { it.toFile().delete() }.toString().toPath() }), + ) + ) + + /** Never run in these tests: the dialogs are looked at, not confirmed. */ + private val signOutDependencies = SignOutDependencies( + activeIdentityStateFlow = readOnlyIdentity, + forgetNostrCredential = { error("not reached") }, + hideIdentityMetadata = { error("not reached") }, + onSignedOut = { error("not reached") }, + ) + + @Composable + private fun NotFound(onSwitchProfile: (() -> Unit)? = null) { + UnsyncedProfileScreen( + unsyncedProfilePublicKey = publicKey, + nostrRepository = NotFoundAccount, + signOut = signOutDependencies, + onSwitchProfile = onSwitchProfile, + ) + } + + private val at = Instant.fromEpochSeconds(1_700_000_000) + + /** An account whose one sign-in request has finished with nothing: the not-found state. */ + private val NotFoundAccount = object : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + override suspend fun observeLocalAccount(publicKey: String): Flow = flowOf( + LocalAccount( + unsignedNostrEvent = UnsignedNostrEvent( + id = 1, pubKey = publicKey, kind = 0, tags = emptyArray(), content = "{}", + signedAt = GENESIS_AT, createdAt = at, updatedAt = at, savedAt = at, + ), + nostrEvent = null, + profile = null, + broadcastNostrEventRequest = null, + broadcastNostrEventReceipt = null, + synchronizeNostrEventRequests = listOf( + SynchronizeNostrEventRequest( + id = "req", purpose = SignInSync.PURPOSE, status = SignInSync.STATUS_COMPLETE, + relayURL = "wss://relay.example", level = 0, + synchronizationFilters = arrayOf(SynchronizationFilter(authors = arrayOf(publicKey), kinds = SignInSync.KINDS)), + unsignedNostrEventId = 1, createdAt = at, updatedAt = at, + ) + ), + ) + ) + } + + @Composable + private fun AsIdentity(canSign: Boolean, content: @Composable () -> Unit) { + MantraTheme { + ProvideSnackbarHost { + CompositionLocalProvider(LocalCanSign provides canSign) { + content() + } + } + } + } + + @Composable + private fun Home() { + HomeScreen( + activeUserPublicKey = publicKey, + initialHomeScreenUIState = HomeScreenUIState.Loaded(profileWithFollowing = profile), + onNavigateToRoute = {}, + onNavigateToDirectMessageDetail = {}, + onNavigateToChatRoomCreation = {}, + nostrRepository = FixedProfile, + chatRepository = NoRooms, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY, + ) + } + + /** The person behind a pasted npub, already found: the state is passed in, so nothing is asked. */ + @Composable + private fun ProfilePreview() { + ProfilePreviewScreen( + activeUserPublicKey = publicKey, + profilePublicKey = publicKey, + initialProfilePreviewUIState = ProfilePreviewUIState.Loaded( + profile = profile.profile, + readiness = ProfilePreviewUIState.Readiness.CanStartChat, + ), + nostrRepository = FixedProfile, + chatRepository = NoRooms, + onStartChat = {}, + onNavigateBack = {}, + ) + } + + @Composable + private fun ActiveProfile(signOut: SignOutDependencies? = null) { + ActiveProfileScreen( + initialActiveProfileUIState = ActiveProfileUIState.Loaded( + localNostrEvent = LocalNostrEvent(nostrEvent = metadataEvent, profile = profile.profile), + ), + activeUserPublicKey = publicKey, + nostrEventId = eventId, + onNavigateBack = {}, + onNavigateToRoute = {}, + nostrRepository = FixedProfile, + signOut = signOut, + ) + } + + private val metadataEvent = NostrEvent( + id = eventId, + pubKey = publicKey, + content = """{"name":"Reader"}""", + tags = emptyArray(), + sig = "", + kind = MetadataEvent.KIND, + ) + + private val profile = LocalProfileWithFollowing( + nostrEvent = NostrEvent( + id = eventId, + pubKey = publicKey, + content = "", + tags = emptyArray(), + sig = "", + kind = TextNoteEvent.KIND, + ), + profile = Profile( + publicKey = publicKey, + displayName = "Reader", + nostrEventId = eventId, + ), + following = emptyList(), + ) + + private val FixedProfile = object : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + override suspend fun observeProfileWithFollowing( + publicKey: String, + ): Flow = flowOf(profile) + + override suspend fun observeLocalNostrEventById( + nostrEventId: String, + ): Flow = flowOf(LocalNostrEvent(nostrEvent = metadataEvent, profile = profile.profile)) + } + + private val NoRooms = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY { + override suspend fun observeChatRoomListByPublicKey( + publicKey: String, + ): Flow> = flowOf(emptyList()) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/navigation/NavigationSuiteRenderJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/navigation/NavigationSuiteRenderJvmTest.kt index 4082b769..afe085b7 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/navigation/NavigationSuiteRenderJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/navigation/NavigationSuiteRenderJvmTest.kt @@ -19,7 +19,7 @@ import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRou import press.mantra.compose.ui.composable.navigation.routes.HomeRoute import press.mantra.compose.ui.composable.navigation.routes.SearchRoute import press.mantra.compose.ui.theme.Breakpoint -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import kotlin.test.Test /** @@ -131,7 +131,7 @@ class NavigationSuiteRenderJvmTest { profileNostrEventId: String?, navController: NavHostController = rememberNavController(), ) { - TorchTheme { + MantraTheme { MantraNavigationSuite( navController = navController, breakpoint = Breakpoint.Large, diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/widgets/ScreenStateTransitionJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/widgets/ScreenStateTransitionJvmTest.kt index c00c6add..6b1b0a03 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/widgets/ScreenStateTransitionJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/widgets/ScreenStateTransitionJvmTest.kt @@ -8,7 +8,7 @@ import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.runDesktopComposeUiTest -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import kotlin.test.Test /** Stand-ins for a screen's UI state: two classes, and data that changes within one. */ @@ -39,7 +39,7 @@ class ScreenStateTransitionJvmTest { runDesktopComposeUiTest(400, 600) { var state by mutableStateOf(Fixture.Loading) mainClock.autoAdvance = false - setContent { TorchTheme { ScreenStateTransition(state) { Render(it) } } } + setContent { MantraTheme { ScreenStateTransition(state) { Render(it) } } } mainClock.advanceTimeBy(16) onNodeWithText("loading").assertIsDisplayed() @@ -61,7 +61,7 @@ class ScreenStateTransitionJvmTest { fun `data changing inside one state does not animate`() = runDesktopComposeUiTest(400, 600) { var state by mutableStateOf(Fixture.Loaded("first")) mainClock.autoAdvance = false - setContent { TorchTheme { ScreenStateTransition(state) { Render(it) } } } + setContent { MantraTheme { ScreenStateTransition(state) { Render(it) } } } mainClock.advanceTimeBy(16) onNodeWithText("first").assertIsDisplayed() diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/widgets/buttons/ExtendedFabJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/widgets/buttons/ExtendedFabJvmTest.kt new file mode 100644 index 00000000..38fe09f1 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/widgets/buttons/ExtendedFabJvmTest.kt @@ -0,0 +1,111 @@ +package press.mantra.compose.ui.composable.widgets.buttons + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Icon +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.getBoundsInRoot +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runDesktopComposeUiTest +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.theme.MantraTheme +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The two things the shared FAB decides for every screen's primary action. + * + * **The label is what the button announces.** M3's `text`/`icon` overload clears the + * label from the merged semantics tree, so a screen reader hears only the icon and a + * test cannot find the button by what it says. Every screen test in the app that + * reaches a FAB by `onNodeWithText` depends on this one staying the other way, and + * a FAB with a decorative icon has no name at all without it. + * + * **Unavailable is a refusal, not only a look.** M3 gives a FAB no `enabled`, so the + * widget has to both mark the node disabled and drop the press; a button that only + * looked disabled would be announced, and pressed, as one that works. + */ +@OptIn(ExperimentalTestApi::class) +class ExtendedFabJvmTest { + + @Test + fun `the button is found by its label in the merged tree, with the icon beside it`() = + runDesktopComposeUiTest(400, 200) { + setContent { + MantraTheme { + ExtendedFab( + label = "Propose post", + icon = { Icon(Icons.Default.Add, contentDescription = "Propose post icon") }, + onClick = {}, + ) + } + } + + // No `useUnmergedTree`: this is the tree a screen reader is given. + onNodeWithText("Propose post").assertIsDisplayed().assertIsEnabled() + + // The icon first, then a gap, then the label -- not one touching the other. + val icon = onNodeWithContentDescription("Propose post icon", useUnmergedTree = true) + .getBoundsInRoot() + val label = onNodeWithText("Propose post", useUnmergedTree = true).getBoundsInRoot() + assertTrue(icon.right < label.left, "the label is not after the icon") + } + + @Test + fun `a decorative icon leaves the label as the button's only name, which is enough`() = + runDesktopComposeUiTest(400, 200) { + setContent { + MantraTheme { + ExtendedFab( + label = "Broadcast all", + icon = { Icon(Icons.Default.Add, contentDescription = Decorative) }, + onClick = {}, + ) + } + } + + onNodeWithText("Broadcast all").assertIsDisplayed() + } + + @Test + fun `disabled is announced as disabled and drops the press, and enabled takes it`() { + var presses = 0 + + runDesktopComposeUiTest(400, 200) { + setContent { + MantraTheme { + ExtendedFab( + label = "Propose post", + icon = { Icon(Icons.Default.Add, contentDescription = Decorative) }, + enabled = false, + onClick = { presses++ }, + ) + } + } + + onNodeWithText("Propose post").assertIsNotEnabled().performClick() + } + assertEquals(0, presses) + + runDesktopComposeUiTest(400, 200) { + setContent { + MantraTheme { + ExtendedFab( + label = "Propose post", + icon = { Icon(Icons.Default.Add, contentDescription = Decorative) }, + onClick = { presses++ }, + ) + } + } + + onNodeWithText("Propose post").assertIsEnabled().performClick() + } + assertEquals(1, presses) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/StartDirectMessageToNpubOrNip05DialogJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/StartDirectMessageToNpubOrNip05DialogJvmTest.kt new file mode 100644 index 00000000..d29710c6 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/StartDirectMessageToNpubOrNip05DialogJvmTest.kt @@ -0,0 +1,114 @@ +package press.mantra.compose.ui.composable.widgets.dialogs + +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.runDesktopComposeUiTest +import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute +import press.mantra.compose.ui.composable.navigation.routes.ProfilePreviewRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * What the new-chat dialog does with what is pasted into it. + * + * The button and the route read one decoded key, so the case that used to close the + * dialog and do nothing -- `nostr:npub1…`, which passed the check and failed the + * decode -- is the row worth having. The nip05 row pins where that branch still + * lands, so that it is recorded rather than assumed. + */ +@OptIn(ExperimentalTestApi::class) +class StartDirectMessageToNpubOrNip05DialogJvmTest { + + private val activeUserPublicKey = "a".repeat(64) + private val npub = "npub1zutzeysacnf9rru6zqwmxd54mud0k44tst6l70ja5mhv8jjumytsd2x7nu" + private val publicKey = "17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917" + private val nsec = "nsec10allq0gjx7fddtzef0ax00mdps9t2kmtrldkyjfs8l5xruwvh2dq0lhhkp" + + @Test + fun `a pasted npub opens the person's profile, and starts no chat`() { + var opened: Route? = null + + render(onNavigateToRoute = { opened = it }) { + onNodeWithText("Find profile").assertIsNotEnabled() + onNodeWithText("Input npub... or nip05").performTextInput(npub) + onNodeWithText("Find profile").assertIsEnabled().performClick() + } + + assertEquals(ProfilePreviewRoute(activeUserPublicKey, profilePublicKey = publicKey), opened) + } + + /** The row that failed before: accepted by the field's check, refused by the decode, and nothing happened. */ + @Test + fun `a nostr-prefixed npub is the same person`() { + var opened: Route? = null + + render(onNavigateToRoute = { opened = it }) { + onNodeWithText("Input npub... or nip05").performTextInput("nostr:$npub") + onNodeWithText("Find profile").assertIsEnabled().performClick() + } + + assertEquals(ProfilePreviewRoute(activeUserPublicKey, profilePublicKey = publicKey), opened) + } + + /** A field asking whom to write to must never take a secret for an answer. */ + @Test + fun `an nsec, or an npub with a broken checksum, enables nothing`() { + var opened: Route? = null + + render(onNavigateToRoute = { opened = it }) { + onNodeWithText("Input npub... or nip05").performTextInput(nsec) + onNodeWithText("Find profile").assertIsNotEnabled() + } + render(onNavigateToRoute = { opened = it }) { + onNodeWithText("Input npub... or nip05").performTextInput(npub.dropLast(1) + "q") + onNodeWithText("Find profile").assertIsNotEnabled() + } + + assertNull(opened) + } + + /** Recognised by shape alone, and still the placeholder: resolving one is out of scope. */ + @Test + fun `a nip05 still lands on the placeholder`() { + var opened: Route? = null + + render(onNavigateToRoute = { opened = it }) { + onNodeWithText("Input npub... or nip05").performTextInput("alice@example.com") + onNodeWithText("Find profile").assertIsEnabled().performClick() + } + + assertEquals(ImplementationPendingRoute("Direct Message to alice@example.com"), opened) + } + + private fun render( + onNavigateToRoute: (Route) -> Unit, + assertions: ComposeUiTest.() -> Unit, + ) = runDesktopComposeUiTest(width = 400, height = 800) { + setContent { + MantraTheme { + ProvideSnackbarHost { + StartDirectMessageToNpubOrNip05Dialog( + activeUserPublicKey = activeUserPublicKey, + scope = rememberCoroutineScope(), + toggleOpenDialogSetting = {}, + onNavigateToRoute = onNavigateToRoute, + ) + } + } + } + + assertions() + // The navigation runs when the close-the-dialog job completes; give it the frame. + waitForIdle() + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/theme/NavigationMotionJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/theme/NavigationMotionJvmTest.kt index 961150be..9cae71b2 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/theme/NavigationMotionJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/theme/NavigationMotionJvmTest.kt @@ -75,12 +75,12 @@ class NavigationMotionJvmTest { controller = rememberNavController() // Through the theme's parameter rather than by providing the local // around it. Providing it outside is what this test did first, and - // `TorchTheme` silently overwrote it -- the reduced case read 54dp of - // slide. That is also why `reducedMotion` is a `TorchTheme` parameter + // `MantraTheme` silently overwrote it -- the reduced case read 54dp of + // slide. That is also why `reducedMotion` is a `MantraTheme` parameter // rather than something the theme only reads from the platform: the // desktop actual is a hardcoded `false`, and a value nothing can override // is a value nothing can test either. - TorchTheme(reducedMotion = reducedMotion) { + MantraTheme(reducedMotion = reducedMotion) { NavHost( navController = controller, startDestination = First, diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/theme/ReadableContentLayoutJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/theme/ReadableContentLayoutJvmTest.kt index 647c55af..bea41cd9 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/theme/ReadableContentLayoutJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/theme/ReadableContentLayoutJvmTest.kt @@ -78,7 +78,7 @@ class ReadableContentLayoutJvmTest { var margin: Dp? = null runDesktopComposeUiTest(width = windowWidth, height = 900) { setContent { - TorchTheme { + MantraTheme { seen = MaterialTheme.breakpoint margin = MaterialTheme.spacing.screenMargin } @@ -109,7 +109,7 @@ class ReadableContentLayoutJvmTest { @Composable private fun TaggedContent() { - TorchTheme { + MantraTheme { Box(Modifier.readableContent()) { Box(Modifier.fillMaxWidth().height(8.dp).testTag(Content)) } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModelJvmTest.kt new file mode 100644 index 00000000..6c456094 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModelJvmTest.kt @@ -0,0 +1,279 @@ +package press.mantra.compose.ui.view.model + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +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.serialization.json.jsonPrimitive +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.exceptions.NostrPublishException +import press.mantra.compose.network.dto.RelayDTO +import press.mantra.compose.network.relays.EventPublishTransport +import press.mantra.compose.network.relays.NostrPublishResult +import press.mantra.compose.network.sockets.NostrIncomingMessage +import press.mantra.compose.nostr.Relays +import press.mantra.compose.nostr.frost.GroupKeyStateEvent +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.ui.view.model.BroadcastGroupSignedEventViewModel.RelayOutcome +import press.mantra.compose.ui.view.state.BroadcastGroupSignedEventUIState +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * The three decisions the broadcast screen makes that nothing else can check. + * + * **Where an event goes before anyone says otherwise.** The seeded list is what a + * member who reads nothing and presses the button sends to, so it has to be the + * app's own publish set -- a group has no relay list of its own to prefer -- and + * it has to be seeded once, so a list the member emptied stays empty. + * + * **What each relay's answer becomes.** The pool answers once per relay in three + * shapes -- an OK, a rejection wrapped in one exception type, and any other error + * -- and the rows show them as three different things because a member can act + * on "blocked: not on the allow list" and cannot act on a red icon. + * + * **What goes on the wire.** The seven fields the group hashed and nothing else, + * so a relay checking the id and the signature finds the event the group signed. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class BroadcastGroupSignedEventViewModelJvmTest { + private val chatRoomId = "d4c3b2a1".repeat(8) + + private val one = RelayUrlNormalizer.normalize("wss://relay.one.example") + private val two = RelayUrlNormalizer.normalize("wss://relay.two.example") + private val three = RelayUrlNormalizer.normalize("wss://relay.three.example") + + /** Answers each relay with whatever [answers] says, keyed by host, and remembers what it was handed. */ + private class FakeTransport( + private val answers: Map + ) : EventPublishTransport { + var sent: NostrEvent? = null + var askedRelays: Set = emptySet() + + override suspend fun publishEvent( + nostrEvent: NostrEvent, + relays: Set + ): Flow { + sent = nostrEvent + askedRelays = relays + return flowOf(*answers.values.toTypedArray()) + } + } + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + private fun viewModel(transport: EventPublishTransport = FakeTransport(emptyMap())) = + BroadcastGroupSignedEventViewModel( + chatRoomId = chatRoomId, + signedEventId = "b".repeat(64), + activeUserPublicKey = "a".repeat(64), + relayHint = null, + initialBroadcastGroupSignedEventUIState = BroadcastGroupSignedEventUIState.Loading, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + publishTransport = transport, + ) + + private fun signedEvent(kind: Int = TextNoteEvent.KIND, tags: Array> = emptyArray()) = + GroupSignedEvent( + id = "b".repeat(64), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = kind, + tags = tags, + content = "The first chapter is out.", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000), + ) + + private fun ok(relay: NormalizedRelayUrl) = NostrPublishResult( + result = NostrIncomingMessage.OkMessage(eventId = "b".repeat(64), success = true), + relayUrl = relay.url, + ) + + private fun rejected(relay: NormalizedRelayUrl, reason: String) = NostrPublishResult( + result = NostrIncomingMessage.OkMessage(eventId = "b".repeat(64), success = false, message = reason), + error = NostrPublishException(message = reason), + relayUrl = relay.url, + ) + + private fun failed(relay: NormalizedRelayUrl, reason: String) = NostrPublishResult( + error = IllegalStateException(reason), + relayUrl = relay.url, + ) + + // -- where an event goes before anyone says otherwise ------------------------ + + @Test + fun `every kind is seeded with the app's publish set`() { + // A group has no relay list of its own, so a post and a key state go to + // the same place before the member says otherwise -- the set the app + // publishes its own events to, which is what keeps the button from + // being a no-op on a group nobody has typed a relay in for. + assertEquals( + Relays.eventPublishRelaySet.toList(), + BroadcastGroupSignedEventViewModel.defaultRelaysFor(TextNoteEvent.KIND), + ) + assertEquals( + BroadcastGroupSignedEventViewModel.defaultRelaysFor(TextNoteEvent.KIND), + BroadcastGroupSignedEventViewModel.defaultRelaysFor(GroupKeyStateEvent.KIND), + ) + } + + @Test + fun `seeding happens once, so an emptied list stays empty`() { + val viewModel = viewModel() + val event = signedEvent() + + viewModel.seedRelays(event.kind) + viewModel.seedRelays(event.kind) + assertEquals(Relays.eventPublishRelaySet.toList(), viewModel.relays.toList()) + + viewModel.relays.toList().forEach { viewModel.removeRelay(it) } + viewModel.seedRelays(event.kind) + assertEquals(emptyList(), viewModel.relays.toList()) + } + + // -- what may be added ---------------------------------------------------------- + + @Test + fun `an add is refused by name, and a good one clears the refusal`() { + val viewModel = viewModel() + + assertFalse(viewModel.addRelay("https://not-a-relay.example")) + assertEquals(BroadcastGroupSignedEventViewModel.AddFailure.NotARelay, viewModel.addFailure) + + assertTrue(viewModel.addRelay(one.url)) + assertNull(viewModel.addFailure) + + assertFalse(viewModel.addRelay(one.url)) + assertEquals(BroadcastGroupSignedEventViewModel.AddFailure.AlreadyListed, viewModel.addFailure) + + assertEquals(listOf(one), viewModel.relays.toList()) + } + + // -- what each relay's answer becomes --------------------------------------------- + + @Test + fun `each relay's answer lands on its own row, and the sum counts only the OKs`() { + val transport = FakeTransport( + mapOf( + "one" to ok(one), + "two" to rejected(two, "blocked: not on the allow list"), + "three" to failed(three, "Connection refused"), + ) + ) + val viewModel = viewModel(transport) + listOf(one, two, three).forEach { assertTrue(viewModel.addRelay(it.url)) } + + var toldNoRelays = false + val job = viewModel.broadcast(signedEvent(), onNoRelays = { toldNoRelays = true }) + runBlocking { withTimeout(5_000) { job!!.join() } } + + assertFalse(toldNoRelays) + assertEquals(RelayOutcome.Accepted, viewModel.outcomes[one]) + assertEquals(RelayOutcome.Rejected("blocked: not on the allow list"), viewModel.outcomes[two]) + assertEquals(RelayOutcome.Failed("Connection refused"), viewModel.outcomes[three]) + assertEquals( + BroadcastGroupSignedEventViewModel.BroadcastSummary(accepted = 1, asked = 3), + viewModel.lastBroadcast, + ) + assertFalse(viewModel.isActionPending.value) + + // Asked as write relays: the pool would otherwise read from them too. + assertEquals( + setOf(one, two, three).map { RelayDTO(url = it.url, read = false, write = true) }.toSet(), + transport.askedRelays, + ) + } + + @Test + fun `an answer is matched by host, since the pool may spell the relay with a trailing slash`() { + val transport = FakeTransport( + mapOf("one" to ok(one).copy(relayUrl = one.url.trimEnd('/') + "/")) + ) + val viewModel = viewModel(transport) + assertTrue(viewModel.addRelay(one.url)) + + val job = viewModel.broadcast(signedEvent(), onNoRelays = {}) + runBlocking { withTimeout(5_000) { job!!.join() } } + + assertEquals(RelayOutcome.Accepted, viewModel.outcomes[one]) + } + + @Test + fun `a relay that never answers is marked so, which is not the same as refused`() { + // The pool answers for one relay and then the flow ends, which is what a + // send that ran out of time looks like from here. + val transport = FakeTransport(mapOf("one" to ok(one))) + val viewModel = viewModel(transport) + assertTrue(viewModel.addRelay(one.url)) + assertTrue(viewModel.addRelay(two.url)) + + val job = viewModel.broadcast(signedEvent(), onNoRelays = {}) + runBlocking { withTimeout(5_000) { job!!.join() } } + + assertEquals(RelayOutcome.Accepted, viewModel.outcomes[one]) + assertEquals(RelayOutcome.NoAnswer, viewModel.outcomes[two]) + assertEquals( + BroadcastGroupSignedEventViewModel.BroadcastSummary(accepted = 1, asked = 2), + viewModel.lastBroadcast, + ) + } + + @Test + fun `an empty list asks nobody and says so`() { + val transport = FakeTransport(emptyMap()) + val viewModel = viewModel(transport) + + var toldNoRelays = false + val job = viewModel.broadcast(signedEvent(), onNoRelays = { toldNoRelays = true }) + + assertNull(job) + assertTrue(toldNoRelays) + assertNull(transport.sent) + assertFalse(viewModel.isActionPending.value) + } + + // -- what goes on the wire ------------------------------------------------------------ + + @Test + fun `what goes on the wire is the event the group signed, field for field`() { + val event = signedEvent( + kind = GroupKeyStateEvent.KIND, + tags = arrayOf(arrayOf("alt", "The group's signing key")), + ) + + val wire = BroadcastGroupSignedEventViewModel.wireEventOf(event).toNostrJsonObject() + + assertEquals(event.id, wire["id"]!!.jsonPrimitive.content) + assertEquals(event.publicKey, wire["pubkey"]!!.jsonPrimitive.content) + assertEquals(event.createdAt.epochSeconds.toString(), wire["created_at"]!!.jsonPrimitive.content) + assertEquals(event.kind.toString(), wire["kind"]!!.jsonPrimitive.content) + assertEquals(event.content, wire["content"]!!.jsonPrimitive.content) + assertEquals(event.signature, wire["sig"]!!.jsonPrimitive.content) + assertEquals("""[["alt","The group's signing key"]]""", wire["tags"].toString()) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NavigationIdentityRoutingJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NavigationIdentityRoutingJvmTest.kt new file mode 100644 index 00000000..d49b2187 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NavigationIdentityRoutingJvmTest.kt @@ -0,0 +1,295 @@ +package press.mantra.compose.ui.view.model + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.utils.preferences.InternalPrefs +import fr.acinq.phoenix.utils.preferences.UserPrefs +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +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.withTimeout +import okio.Path.Companion.toPath +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.UnsignedNostrEvent +import press.mantra.compose.database.model.intermdiate.LocalAccount +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.IdentityKind +import press.mantra.compose.identity.toWalletId +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.navigation.routes.SovereignWalletStartupRoute +import press.mantra.compose.ui.view.state.NavigationUIState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * An identity with no node behind it is routed like any other. + * + * `NavigationViewModel.observeProfile` used to reach the active user's pubkey through + * the wallet -- `business.walletManager.keyManager.value.nostrPublicKey()` -- and + * answered a null `business` by navigating back to startup. For a wallet that is a + * loop the startup screen breaks by starting the node; for an identity that will + * never have a node it is a loop with no exit. The [Identity] type carries the key + * itself, so the branch is gone, and this pins that it stays gone: a `NostrSecret` + * identity with a signed, synced account on the device lands on `ProfileLoaded`. + * + * A jvm test rather than a common one because [Identity] derives its public key on + * construction, which takes the secp256k1 natives, and those are on this classpath + * (see the other tests in `jvmTest` that build a `PrivateKey`). + * + * The observer waits 2.1 seconds on `Dispatchers.IO` before its first read, so this + * runs under `runBlocking` and a real-time `withTimeout`: `runTest` would advance a + * virtual clock past the timeout while the real delay had not elapsed. + */ +class NavigationIdentityRoutingJvmTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val stranded = NavigationUIState.Loading("Introducing... Torch") + + private val privateKey = PrivateKey(ByteVector32("01".repeat(32))) + + private fun prefsStore(name: String) = PreferenceDataStoreFactory.createWithPath { + temporaryFolder.newFolder().resolve("$name.preferences_pb").path.toPath() + } + + private fun nostrSecretIdentity() = Identity.signing( + id = privateKey.publicKey().xOnly().toWalletId(), + kind = IdentityKind.NostrSecret, + nostrPrivateKey = privateKey, + userPrefs = UserPrefs(prefsStore("user")), + internalPrefs = InternalPrefs(prefsStore("internal")), + business = null, + ) + + /** The same key, held as a public key only: same id, no secret, no node. */ + private fun readOnlyIdentity() = Identity.readOnly( + id = privateKey.publicKey().xOnly().toWalletId(), + nostrPublicKey = privateKey.nostrPublicKeyHex(), + userPrefs = UserPrefs(prefsStore("user")), + internalPrefs = InternalPrefs(prefsStore("internal")), + ) + + /** Everything `observeProfile` reads, and nothing else; every other member throws. */ + private class Device( + private val accounts: List + ) : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + override suspend fun getLocalAccounts(): List = accounts + override suspend fun observeLocalAccount(publicKey: HexKey): Flow = + flowOf(accounts.firstOrNull { it.unsignedNostrEvent?.pubKey == publicKey }) + } + + private val at = Instant.fromEpochSeconds(1_700_000_000) + + private fun syncedAccount(publicKey: HexKey) = LocalAccount( + unsignedNostrEvent = UnsignedNostrEvent( + id = 1, + pubKey = publicKey, + kind = 0, + tags = emptyArray(), + content = "{}", + signedAt = at, + createdAt = at, + updatedAt = at, + savedAt = at, + ), + nostrEvent = null, + profile = Profile( + publicKey = publicKey, + nostrEventId = "e".repeat(64), + createdAt = at, + updatedAt = at, + savedAt = at, + ), + broadcastNostrEventRequest = null, + broadcastNostrEventReceipt = null, + synchronizeNostrEventRequests = emptyList(), + ) + + @Test + fun `the identity's public key is the x-only key nostr uses`() { + val identity = nostrSecretIdentity() + + // The same key quartz derives, so the notary's KeyPair and this agree on who + // the user is. Not the 33-byte compressed form the library's own helper returns. + assertEquals( + KeyPair(privKey = privateKey.value.toByteArray()).pubKey.toHexKey(), + identity.nostrPublicKey, + ) + assertEquals(64, identity.nostrPublicKey.length) + // Same shape as a wallet's id: forty hex characters of a hash160. + assertEquals(40, identity.id.nodeIdHash.length) + } + + @Test + fun `an identity with no node behind it is routed by its account, not back to startup`() { + val identity = nostrSecretIdentity() + val scope = CoroutineScope(Job()) + val viewModel = NavigationViewModel( + activeIdentityStateFlow = MutableStateFlow(identity), + initialNavigationUIState = stranded, + nostrRepository = Device(listOf(syncedAccount(identity.nostrPublicKey))), + scope = scope, + ) + + try { + val destination = runBlocking { + withTimeout(15_000) { viewModel.navigationUIState.first { it != stranded } } + } + + assertEquals( + NavigationUIState.ProfileLoaded(publicKey = identity.nostrPublicKey), + destination, + "a nodeless identity was not routed by its account", + ) + } finally { + scope.cancel() + } + } + + /** + * The startup screen's own entrance, `loadNostrProfile(startupRoute)`, used to take + * the *first* kind-0 account on the device whichever identity was active. With one + * wallet that was harmless; with two it is the wrong account half the time, and an + * imported key is how a device gets its second identity. + */ + @Test + fun `with two accounts on the device, the startup entrance reads the active identity's`() { + val identity = nostrSecretIdentity() + val someoneElse = "a".repeat(64) + val scope = CoroutineScope(Job()) + val viewModel = NavigationViewModel( + activeIdentityStateFlow = MutableStateFlow(identity), + initialNavigationUIState = stranded, + nostrRepository = Device(listOf(syncedAccount(someoneElse), syncedAccount(identity.nostrPublicKey))), + scope = scope, + ).also { scope.cancel() } + + runBlocking { viewModel.loadNostrProfile(SovereignWalletStartupRoute) } + + assertEquals( + NavigationUIState.ProfileLoaded(publicKey = identity.nostrPublicKey), + viewModel.navigationUIState.value, + ) + } + + /** + * A read-only identity is the same identity minus the secret: same public key, same + * id, and the machine routes it by its account exactly as it routes the nsec. + */ + @Test + fun `a read-only identity has the id and public key its secret would, and cannot sign`() { + val readOnly = readOnlyIdentity() + val signing = nostrSecretIdentity() + + assertEquals(signing.nostrPublicKey, readOnly.nostrPublicKey) + assertEquals(signing.id, readOnly.id) + assertNull(readOnly.nostrPrivateKey) + assertNull(readOnly.business) + assertFalse(readOnly.canSign) + assertTrue(signing.canSign) + assertEquals(IdentityKind.NostrPublic, readOnly.kind) + } + + @Test + fun `a read-only identity is routed by its account, like any other`() { + val identity = readOnlyIdentity() + val scope = CoroutineScope(Job()) + val viewModel = NavigationViewModel( + activeIdentityStateFlow = MutableStateFlow(identity), + initialNavigationUIState = stranded, + nostrRepository = Device(listOf(syncedAccount(identity.nostrPublicKey))), + scope = scope, + ) + + try { + val destination = runBlocking { + withTimeout(15_000) { viewModel.navigationUIState.first { it != stranded } } + } + + assertEquals( + NavigationUIState.ProfileLoaded(publicKey = identity.nostrPublicKey), + destination, + "a read-only identity was not routed by its account", + ) + } finally { + scope.cancel() + } + } + + /** + * The two checks that make [Identity.nostrPublicKey] safe to be a field. Without + * them a data class would accept a key filed under someone else's public key, or a + * "read-only" identity that could in fact sign. + */ + @Test + fun `an identity whose kind and key disagree is refused, and so is one whose key does not derive its public key`() { + val prefs = UserPrefs(prefsStore("user")) + val internal = InternalPrefs(prefsStore("internal")) + + // A secret filed under a public key it does not derive. + assertFailsWith { + Identity( + id = privateKey.publicKey().xOnly().toWalletId(), + kind = IdentityKind.NostrSecret, + nostrPublicKey = "a".repeat(64), + nostrPrivateKey = privateKey, + userPrefs = prefs, + internalPrefs = internal, + business = null, + ) + } + // A read-only kind that nonetheless carries a key. + assertFailsWith { + Identity( + id = privateKey.publicKey().xOnly().toWalletId(), + kind = IdentityKind.NostrPublic, + nostrPublicKey = privateKey.nostrPublicKeyHex(), + nostrPrivateKey = privateKey, + userPrefs = prefs, + internalPrefs = internal, + business = null, + ) + } + // A signing kind with no key to sign with. + assertFailsWith { + Identity( + id = privateKey.publicKey().xOnly().toWalletId(), + kind = IdentityKind.NostrSecret, + nostrPublicKey = privateKey.nostrPublicKeyHex(), + nostrPrivateKey = null, + userPrefs = prefs, + internalPrefs = internal, + business = null, + ) + } + // And the factory refuses to build a "signing" read-only identity at all. + assertFailsWith { + Identity.signing( + id = privateKey.publicKey().xOnly().toWalletId(), + kind = IdentityKind.NostrPublic, + nostrPrivateKey = privateKey, + userPrefs = prefs, + internalPrefs = internal, + business = null, + ) + } + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModelJvmTest.kt new file mode 100644 index 00000000..209d1e12 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModelJvmTest.kt @@ -0,0 +1,120 @@ +package press.mantra.compose.ui.view.model + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.utils.PlatformContext +import fr.acinq.phoenix.utils.preferences.InternalPrefs +import fr.acinq.phoenix.utils.preferences.UserPrefs +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import okio.Path.Companion.toPath +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.IdentityKind +import press.mantra.compose.identity.IdentityWriter +import press.mantra.compose.identity.toWalletId +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.NostrSecretUIState +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Forgetting a key is four effects, and their order is what this pins: the key comes + * out of the file first, and only then are the metadata hidden, the account rows + * removed and the caller told. A failure at the first step must leave everything else + * untouched -- an identity the selector lists but nothing can open is worse than one + * that is still there. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class NostrSecretViewModelJvmTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val privateKey = PrivateKey(ByteVector32("01".repeat(32))) + + private class Recorder : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + val effects = mutableListOf() + override suspend fun forgetLocalAccount(publicKey: HexKey) { + effects += "forgetLocalAccount" + } + } + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + private fun prefsStore(name: String) = PreferenceDataStoreFactory.createWithPath { + temporaryFolder.newFolder().resolve("$name.preferences_pb").path.toPath() + } + + private fun identity() = Identity.signing( + id = privateKey.publicKey().xOnly().toWalletId(), + kind = IdentityKind.NostrSecret, + nostrPrivateKey = privateKey, + userPrefs = UserPrefs(prefsStore("user")), + internalPrefs = InternalPrefs(prefsStore("internal")), + business = null, + ) + + private fun viewModel( + recorder: Recorder, + forgetOutcome: IdentityWriter.ForgetNostrCredentialResult, + ) = NostrSecretViewModel( + phoenixGlobal = PhoenixGlobal(PlatformContext(applicationDir = temporaryFolder.newFolder())), + activeIdentityStateFlow = MutableStateFlow(identity()), + nostrRepository = recorder, + forgetNostrCredential = { recorder.effects += "forgetNostrCredential"; forgetOutcome }, + hideIdentityMetadata = { recorder.effects += "hideIdentityMetadata" }, + ) + + @Test + fun `the key comes out first, then the metadata, the account, and the caller is told`() { + val recorder = Recorder() + val viewModel = viewModel(recorder, IdentityWriter.ForgetNostrCredentialResult.Forgotten) + val told = CompletableDeferred() + + viewModel.forgetKey { told.complete(Unit) } + runBlocking { withTimeout(10_000) { told.await() } } + + assertEquals( + listOf("forgetNostrCredential", "hideIdentityMetadata", "forgetLocalAccount"), + recorder.effects, + ) + assertEquals(NostrSecretUIState.Forgetting.Idle, viewModel.forgetting.value) + } + + @Test + fun `a key that could not be removed leaves everything else alone`() { + val recorder = Recorder() + val viewModel = viewModel(recorder, IdentityWriter.ForgetNostrCredentialResult.CannotLoadKeys) + + viewModel.forgetKey { error("must not be told") } + val state = runBlocking { + withTimeout(10_000) { viewModel.forgetting.first { it !is NostrSecretUIState.Forgetting.InProgress } } + } + + assertEquals(NostrSecretUIState.Forgetting.Failed, state) + assertEquals(listOf("forgetNostrCredential"), recorder.effects) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModelJvmTest.kt new file mode 100644 index 00000000..1d120e26 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModelJvmTest.kt @@ -0,0 +1,333 @@ +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.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +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.GENESIS_AT +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.MarmotKeyPackage +import press.mantra.compose.database.model.NegentropySynchronizeRequest +import press.mantra.compose.database.model.Participant +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.SynchronizeNostrEventRequest +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.intermdiate.LocalParticipant +import press.mantra.compose.nostr.DirectMessagePeerSync +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.ProfilePreviewUIState +import press.mantra.compose.ui.view.state.ProfilePreviewUIState.Readiness +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Instant + +/** + * What the preview shows while the relays are asked, when it stops waiting, and + * whether the button it ends on can be pressed. + * + * The repository fakes record every ask and hold the profile row and the key package + * in `MutableStateFlow`s, so the test can play them arriving in either order, after + * the timeout, or never. The view model launches on `Dispatchers.IO`, as the others + * do, so the timeout is real time cut to a few hundred milliseconds through its + * constructor, and the assertions poll. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ProfilePreviewViewModelJvmTest { + + private val me = "a".repeat(64) + private val alice = "b".repeat(64) + + /** Records the asks; the profile flow is the row the device holds. */ + private class Recorder( + val profile: MutableStateFlow = MutableStateFlow(null), + private val refuse: Boolean = false, + ) : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + val effects = mutableListOf() + + override suspend fun observeProfileWithPublicKey(publicKey: HexKey): Flow = profile + + override suspend fun queueSynchronizeNostrEvent(synchronizeNostrEventRequests: List) { + if (refuse) throw IllegalStateException("the queue is closed") + effects += "req:" + synchronizeNostrEventRequests.map { it.purpose }.distinct().joinToString() + } + + override suspend fun queueNegentropySynchronizeRequest(negentropySynchronizeRequests: List) { + if (refuse) throw IllegalStateException("the queue is closed") + effects += "neg:" + negentropySynchronizeRequests.map { it.purpose }.distinct().joinToString() + } + } + + /** The key package the device holds for the peer, and the room it already has with them. */ + private class Rooms( + val keyPackage: MutableStateFlow = MutableStateFlow(null), + private val existingRoom: LocalChatRoom? = null, + ) : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY { + override suspend fun observeMarmotKeyPackageForPublicKey(publicKey: HexKey): Flow = keyPackage + override suspend fun findDirectMessageRoom(userPublicKey: HexKey, peerPublicKey: HexKey): LocalChatRoom? = existingRoom + } + + private val profileAsk = "req:${DirectMessagePeerSync.PROFILE_PURPOSE}" + private val chatAsk = "neg:${DirectMessagePeerSync.PURPOSE}" + private val bothAsks = listOf(profileAsk, chatAsk) + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + private fun viewModel(recorder: Recorder, rooms: Rooms = Rooms()) = ProfilePreviewViewModel( + activeUserPublicKey = me, + profilePublicKey = alice, + initialProfilePreviewUIState = ProfilePreviewUIState.Loading, + nostrRepository = recorder, + chatRepository = rooms, + relayLookupTimeout = 300.milliseconds, + ) + + // --- finding the person --------------------------------------------------------------- + + @Test + fun `initiating asks both sets of relays once, and waits`() = runBlocking { + val recorder = Recorder() + val viewModel = viewModel(recorder) + + viewModel.initiate() + await { recorder.effects.size == 2 } + + assertEquals(bothAsks, recorder.effects) + assertEquals(ProfilePreviewUIState.Loading, viewModel.profilePreviewUIState) + + // A recomposition calling it again opens no second collect and asks nothing more. + viewModel.initiate() + delay(50) + assertEquals(bothAsks, recorder.effects) + } + + @Test + fun `a placeholder row is not a person`() = runBlocking { + val recorder = Recorder(profile = MutableStateFlow(placeholder(alice))) + val viewModel = viewModel(recorder) + + viewModel.initiate() + await { recorder.effects.size == 2 } + delay(50) + + assertEquals(ProfilePreviewUIState.Loading, viewModel.profilePreviewUIState) + } + + /** Cached first: a person already known is on screen before any relay answers. */ + @Test + fun `a row already resolved loads at once, and the relays are still asked`() = runBlocking { + val recorder = Recorder(profile = MutableStateFlow(resolved(alice))) + val viewModel = viewModel(recorder) + + viewModel.initiate() + await { viewModel.profilePreviewUIState is ProfilePreviewUIState.Loaded } + + assertEquals(loaded(Readiness.Checking), viewModel.profilePreviewUIState) + await { recorder.effects.size == 2 } + assertEquals(bothAsks, recorder.effects) + } + + @Test + fun `nothing arriving is not found, and a late kind 0 still loads`() = runBlocking { + val recorder = Recorder() + val viewModel = viewModel(recorder) + + viewModel.initiate() + await { viewModel.profilePreviewUIState == ProfilePreviewUIState.NotFound } + + recorder.profile.value = resolved(alice) + await { viewModel.profilePreviewUIState is ProfilePreviewUIState.Loaded } + } + + @Test + fun `try again from not found asks both sets of relays again`() = runBlocking { + val recorder = Recorder() + val viewModel = viewModel(recorder) + + viewModel.initiate() + await { viewModel.profilePreviewUIState == ProfilePreviewUIState.NotFound } + + viewModel.retry() + assertEquals(ProfilePreviewUIState.Loading, viewModel.profilePreviewUIState) + await { recorder.effects.size == 4 } + assertEquals(bothAsks + bothAsks, recorder.effects) + + // And the clock was restarted with it. + await { viewModel.profilePreviewUIState == ProfilePreviewUIState.NotFound } + } + + @Test + fun `a queue that refuses is an error, and try again asks again`() = runBlocking { + val recorder = Recorder(refuse = true) + val viewModel = viewModel(recorder) + + viewModel.initiate() + await { viewModel.profilePreviewUIState == ProfilePreviewUIState.Error } + + // The timeout was cancelled with it: an error does not later become "not found". + delay(400) + assertEquals(ProfilePreviewUIState.Error, viewModel.profilePreviewUIState) + + viewModel.retry() + await { viewModel.profilePreviewUIState == ProfilePreviewUIState.Error } + } + + // --- whether the button can be pressed ----------------------------------------------- + + /** + * A key package is meant to be used once, and the relays need not hold a fresh one + * for a person you already have a room with: without this row, someone you talk to + * every day would read "hasn't set up messaging". + */ + @Test + fun `a room that exists opens, key package or not`() = runBlocking { + val recorder = Recorder(profile = MutableStateFlow(resolved(alice))) + val viewModel = viewModel(recorder, Rooms(existingRoom = roomWith(alice))) + + viewModel.initiate() + await { viewModel.profilePreviewUIState is ProfilePreviewUIState.Loaded } + + assertEquals(loaded(Readiness.ExistingChat(chatRoomId = "room", relayHint = "wss://hint.example")), viewModel.profilePreviewUIState) + + // Nothing left to wait for, so the timeout has nothing to say. + delay(400) + assertIs(assertIs(viewModel.profilePreviewUIState).readiness) + } + + @Test + fun `the profile first, then the key package, and the clock stops`() = runBlocking { + val recorder = Recorder() + val rooms = Rooms() + val viewModel = viewModel(recorder, rooms) + + viewModel.initiate() + recorder.profile.value = resolved(alice) + await { viewModel.profilePreviewUIState == loaded(Readiness.Checking) } + + rooms.keyPackage.value = keyPackage(alice) + await { viewModel.profilePreviewUIState == loaded(Readiness.CanStartChat) } + + delay(400) + assertEquals(loaded(Readiness.CanStartChat), viewModel.profilePreviewUIState) + } + + @Test + fun `the key package first, then the profile, is ready at once`() = runBlocking { + val recorder = Recorder() + val rooms = Rooms(keyPackage = MutableStateFlow(keyPackage(alice))) + val viewModel = viewModel(recorder, rooms) + + viewModel.initiate() + delay(50) + assertEquals(ProfilePreviewUIState.Loading, viewModel.profilePreviewUIState) + + recorder.profile.value = resolved(alice) + await { viewModel.profilePreviewUIState == loaded(Readiness.CanStartChat) } + } + + @Test + fun `a person with no key package is not yet on Mantra after the timeout, and a late one still counts`() = runBlocking { + val recorder = Recorder(profile = MutableStateFlow(resolved(alice))) + val rooms = Rooms() + val viewModel = viewModel(recorder, rooms) + + viewModel.initiate() + await { viewModel.profilePreviewUIState == loaded(Readiness.Checking) } + await { viewModel.profilePreviewUIState == loaded(Readiness.NotYetOnMantra) } + + rooms.keyPackage.value = keyPackage(alice) + await { viewModel.profilePreviewUIState == loaded(Readiness.CanStartChat) } + } + + /** The person is found; it is the key package being asked for again, and the DM relays alone are asked. */ + @Test + fun `try again with a person on screen asks the DM relays alone and keeps the person`() = runBlocking { + val recorder = Recorder(profile = MutableStateFlow(resolved(alice))) + val viewModel = viewModel(recorder, Rooms()) + + viewModel.initiate() + await { viewModel.profilePreviewUIState == loaded(Readiness.NotYetOnMantra) } + assertEquals(bothAsks, recorder.effects) + + viewModel.retry() + assertEquals(loaded(Readiness.Checking), viewModel.profilePreviewUIState) + await { recorder.effects.size == 3 } + assertEquals(bothAsks + chatAsk, recorder.effects) + + await { viewModel.profilePreviewUIState == loaded(Readiness.NotYetOnMantra) } + } + + // --- fixtures ----------------------------------------------------------------------- + + private suspend fun await(condition: () -> Boolean) { + withTimeout(5_000) { + while (!condition()) delay(10) + } + } + + private fun loaded(readiness: Readiness) = ProfilePreviewUIState.Loaded(profile = resolved(alice), readiness = readiness) + + /** What every mint site writes: the "LOADING..." name, stamped `GENESIS_AT`. */ + private fun placeholder(publicKey: HexKey) = Profile( + publicKey = publicKey, + displayName = "LOADING...", + nostrEventId = "placeholder", + createdAt = GENESIS_AT, + ) + + private val at = Instant.fromEpochSeconds(1_700_000_000) + + private fun resolved(publicKey: HexKey) = Profile( + publicKey = publicKey, + displayName = "Alice", + nostrEventId = "kind0", + createdAt = at, + updatedAt = at, + savedAt = at, + ) + + private fun keyPackage(publicKey: HexKey) = MarmotKeyPackage( + id = "keypackage", + publicKey = publicKey, + tlsEncodedMarmotKeyPackage = ByteArray(0), + ) + + private fun roomWith(peer: HexKey) = LocalChatRoom( + chatRoom = ChatRoom( + id = "room", + userPublicKey = me, + subject = null, + description = null, + mlsGroupState = "state", + ), + localParticipants = listOf(me, peer).map { publicKey -> + LocalParticipant( + participant = Participant( + participantPublicKey = publicKey, + chatRoomId = "room", + relayHint = "wss://hint.example", + ), + profile = null, + ) + }, + ) +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModelJvmTest.kt new file mode 100644 index 00000000..953a10f7 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModelJvmTest.kt @@ -0,0 +1,145 @@ +package press.mantra.compose.ui.view.model + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.phoenix.data.WalletId +import kotlinx.coroutines.runBlocking +import press.mantra.compose.identity.CredentialProblem +import press.mantra.compose.identity.IdentityWriter +import press.mantra.compose.identity.SignInCredential +import press.mantra.compose.identity.StoredIdentity +import press.mantra.compose.repository.NostrRepository +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * What a confirmed sign-in does, and in what order. + * + * The write and the placeholder are two effects, and the second must follow the first + * and only on success: a placeholder for a key that was not saved would send the user + * to fetch a profile they can never sign for, and a placeholder planted before the + * write is a race the navigation observer can lose. The repository fake records every + * call, so both "exactly once" and "after" are assertable. + */ +class SignInToProfileViewModelJvmTest { + + private val privateKey = PrivateKey(ByteVector32("7f7ff03d123792d6ac594bfa67bf6d0c0ab55b6b1fdb6249303fe861f1ccba9a")) + private val publicKey = "17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917" + private val words = "leader monkey parrot ring guide accident before fence cannon height naive bean".split(" ") + private val id = WalletId("ab".repeat(20)) + + /** The id a key already on the device is listed under, which the refusal carries so the screen can offer to switch to it. */ + private val alreadyHere = WalletId("cd".repeat(20)) + + private val nostrSecret = SignInCredential.NostrSecret(privateKey, publicKey) + private val recoveryPhrase = SignInCredential.RecoveryPhrase(words, publicKey) + private val nostrPublicKey = SignInCredential.NostrPublicKey(publicKey) + + /** Records the order of effects; throws on anything the sign-in should not touch. */ + private class Recorder : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + val effects = mutableListOf() + override suspend fun signInToProfile(publicKey: HexKey) { + effects += "signInToProfile:$publicKey" + } + } + + private fun viewModel( + recorder: Recorder, + nostrKeyOutcome: () -> IdentityWriter.WriteNostrCredentialResult = { IdentityWriter.WriteNostrCredentialResult.Written(id) }, + publicKeyOutcome: () -> IdentityWriter.WriteNostrCredentialResult = { IdentityWriter.WriteNostrCredentialResult.Written(id) }, + seedOutcome: ((WalletId) -> Unit, (WritingSeedState.Error) -> Unit) -> Unit = { onWritten, _ -> onWritten(id) }, + ) = SignInToProfileViewModel( + nostrRepository = recorder, + writeNostrKey = { recorder.effects += "writeNostrKey"; nostrKeyOutcome() }, + writeNostrPublicKey = { recorder.effects += "writeNostrPublicKey"; publicKeyOutcome() }, + writeRecoveryPhrase = { _, onWritten, onError -> recorder.effects += "writeRecoveryPhrase"; seedOutcome(onWritten, onError) }, + ) + + @Test + fun `a public key is written, then its account is planted, then the id comes back`() = runBlocking { + val recorder = Recorder() + + val outcome = viewModel(recorder).commit(nostrPublicKey) + + assertEquals(SignInToProfileViewModel.Outcome.SignedIn(id), outcome) + assertEquals(listOf("writeNostrPublicKey", "signInToProfile:$publicKey"), recorder.effects) + } + + @Test + fun `the npub of a key already here is refused, and no account is planted`() = runBlocking { + val recorder = Recorder() + + val outcome = viewModel(recorder, publicKeyOutcome = { IdentityWriter.WriteNostrCredentialResult.AlreadyExists(alreadyHere) }).commit(nostrPublicKey) + + assertEquals(SignInToProfileViewModel.Outcome.Failed(CredentialProblem.AlreadyOnThisDevice, alreadyHere), outcome) + assertEquals(listOf("writeNostrPublicKey"), recorder.effects) + } + + @Test + fun `an nsec is written, then its account is planted, then the id comes back`() = runBlocking { + val recorder = Recorder() + + val outcome = viewModel(recorder).commit(nostrSecret) + + assertEquals(SignInToProfileViewModel.Outcome.SignedIn(id), outcome) + assertEquals(listOf("writeNostrKey", "signInToProfile:$publicKey"), recorder.effects) + } + + @Test + fun `a recovery phrase goes through the seed writer and plants the same account`() = runBlocking { + val recorder = Recorder() + + val outcome = viewModel(recorder).commit(recoveryPhrase) + + assertEquals(SignInToProfileViewModel.Outcome.SignedIn(id), outcome) + assertEquals(listOf("writeRecoveryPhrase", "signInToProfile:$publicKey"), recorder.effects) + } + + @Test + fun `a key already on the device is refused, and no account is planted`() = runBlocking { + val recorder = Recorder() + + val outcome = viewModel(recorder, nostrKeyOutcome = { IdentityWriter.WriteNostrCredentialResult.AlreadyExists(alreadyHere) }).commit(nostrSecret) + + assertEquals(SignInToProfileViewModel.Outcome.Failed(CredentialProblem.AlreadyOnThisDevice, alreadyHere), outcome) + assertEquals(listOf("writeNostrKey"), recorder.effects) + } + + @Test + fun `a seed already on the device is the same refusal`() = runBlocking { + val recorder = Recorder() + + val outcome = viewModel(recorder, seedOutcome = { _, onError -> onError(WritingSeedState.Error.SeedAlreadyExists(alreadyHere)) }) + .commit(recoveryPhrase) + + assertEquals(SignInToProfileViewModel.Outcome.Failed(CredentialProblem.AlreadyOnThisDevice, alreadyHere), outcome) + assertEquals(listOf("writeRecoveryPhrase"), recorder.effects) + } + + @Test + fun `a store that cannot be read, or a write that throws, is could-not-write`() = runBlocking { + val recorder = Recorder() + + assertEquals( + SignInToProfileViewModel.Outcome.Failed(CredentialProblem.CouldNotWrite), + viewModel(recorder, nostrKeyOutcome = { IdentityWriter.WriteNostrCredentialResult.CannotLoadKeys }).commit(nostrSecret), + ) + assertEquals( + SignInToProfileViewModel.Outcome.Failed(CredentialProblem.CouldNotWrite), + viewModel(recorder, nostrKeyOutcome = { throw IllegalStateException("disk") }).commit(nostrSecret), + ) + assertEquals( + SignInToProfileViewModel.Outcome.Failed(CredentialProblem.CouldNotWrite), + viewModel(recorder, seedOutcome = { _, onError -> onError(WritingSeedState.Error.Generic(IllegalStateException("disk"))) }) + .commit(recoveryPhrase), + ) + assertEquals(emptyList(), recorder.effects.filter { it.startsWith("signInToProfile") }) + } + + /** The credential the parser builds for the phrase and the one it builds for the key agree on the account. */ + @Test + fun `both credentials of one key plant the same account`() { + assertEquals(StoredIdentity.nostrPublicKeyOf(words), nostrSecret.nostrPublicKey) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignOutViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignOutViewModelJvmTest.kt new file mode 100644 index 00000000..2dabb167 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignOutViewModelJvmTest.kt @@ -0,0 +1,106 @@ +package press.mantra.compose.ui.view.model + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.utils.preferences.InternalPrefs +import fr.acinq.phoenix.utils.preferences.UserPrefs +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import okio.Path.Companion.toPath +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.IdentityWriter +import press.mantra.compose.identity.toWalletId +import press.mantra.compose.repository.NostrRepository +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Sign out for a read-only identity: the confirmation, the in-flight state, and the + * caller being told only once the device holds nothing for the identity. The sequence + * itself is `ForgetIdentity`'s and is pinned there; this is the state around it, the + * way `NostrSecretViewModelJvmTest` pins the nsec screen's. + */ +class SignOutViewModelJvmTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val privateKey = PrivateKey(ByteVector32("05".repeat(32))) + + private fun prefsStore(name: String) = PreferenceDataStoreFactory.createWithPath { + temporaryFolder.newFolder().resolve("$name.preferences_pb").path.toPath() + } + + private fun identity() = Identity.readOnly( + id = privateKey.publicKey().xOnly().toWalletId(), + nostrPublicKey = privateKey.nostrPublicKeyHex(), + userPrefs = UserPrefs(prefsStore("user")), + internalPrefs = InternalPrefs(prefsStore("internal")), + ) + + private class Recorder : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + val effects = mutableListOf() + override suspend fun forgetLocalAccount(publicKey: HexKey) { + effects += "forgetLocalAccount" + } + } + + private fun viewModel(recorder: Recorder, outcome: IdentityWriter.ForgetNostrCredentialResult) = SignOutViewModel( + activeIdentityStateFlow = MutableStateFlow(identity()), + nostrRepository = recorder, + forgetNostrCredential = { recorder.effects += "forgetNostrCredential"; outcome }, + hideIdentityMetadata = { recorder.effects += "hideIdentityMetadata" }, + ) + + @Test + fun `asking, then confirming, forgets and tells the caller, and the state is idle again`() = runBlocking { + val recorder = Recorder() + val viewModel = viewModel(recorder, IdentityWriter.ForgetNostrCredentialResult.Forgotten) + val told = CompletableDeferred() + + assertEquals(SignOutViewModel.State.Idle, viewModel.state.value) + viewModel.askToSignOut() + assertEquals(SignOutViewModel.State.Confirming, viewModel.state.value) + assertEquals(privateKey.nostrPublicKeyHex(), viewModel.nostrPublicKey, "the confirmation names the identity") + + viewModel.signOut { told.complete(Unit) } + withTimeout(10_000) { told.await() } + + assertEquals(listOf("forgetNostrCredential", "hideIdentityMetadata", "forgetLocalAccount"), recorder.effects) + withTimeout(10_000) { viewModel.state.first { it == SignOutViewModel.State.Idle } } + } + + @Test + fun `cancelling from the confirmation touches nothing`() { + val recorder = Recorder() + val viewModel = viewModel(recorder, IdentityWriter.ForgetNostrCredentialResult.Forgotten) + + viewModel.askToSignOut() + viewModel.cancel() + + assertEquals(SignOutViewModel.State.Idle, viewModel.state.value) + assertEquals(emptyList(), recorder.effects) + } + + @Test + fun `a failure is a state, the caller is not told, and nothing after the first step ran`() = runBlocking { + val recorder = Recorder() + val viewModel = viewModel(recorder, IdentityWriter.ForgetNostrCredentialResult.CannotLoadKeys) + var told = false + + viewModel.askToSignOut() + viewModel.signOut { told = true } + withTimeout(10_000) { viewModel.state.first { it == SignOutViewModel.State.Failed } } + + assertEquals(false, told) + assertEquals(listOf("forgetNostrCredential"), recorder.effects) + } +} diff --git a/docs/README.md b/docs/README.md index a1ab27f8..dfdf4a2c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,8 +17,15 @@ silent, or a decision that looked arbitrary and was not. | [mls-skipped-keys.md](./mls-skipped-keys.md) | why a group event that arrives a moment late is dropped for good, which flows trigger it, the quartz fix, and the partial mitigation in this app | | [long-running-sync.md](./long-running-sync.md) | the chat subscriptions that stay open instead of pulling once per screen — why the request queue could not simply hold one, and how the group filter follows the room list | | [dead-code.md](./dead-code.md) | code in the sync and relay stack that nothing calls, why each piece is still there, and which of it is a bug rather than a leftover | +| [nsec-sign-in.md](./nsec-sign-in.md) | signing in with an existing nostr key — why an nsec can never have a wallet behind it, the ten call sites that make it small, and the sign-in machine that was already built and unreachable | +| [npub-sign-in.md](./npub-sign-in.md) | signing in with only a public key — what a key that cannot sign can still see here, why that is a preview rather than a browser, and the four decisions the word settles | +| [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 | | [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 | +| [curated-to-mantra-preview.md](./curated-to-mantra-preview.md) | the third pull: the profile preview and the extended FAB, eleven commits — a brand token hidden in CamelCase, a commit half in a removed line taken as a partial pick, and the two conflicts the compiler found where git saw none | Start with the ceremony if you are new to this area; the Marmot notes all assume it. Read the skipped-keys note before debugging any "the other device never got it" @@ -40,3 +47,33 @@ subgroup does, because no ceremony is involved. The Material Design note is a phased plan that has not been built, and is the only one about what the app looks like rather than what it does; read the jvm-target note first if you want to know why its adaptive-layout phase exists. +The curated-to-mantra note is a phased plan that has been built, save for its last +phase, which is a decision: it is about the repository rather than the app, and reads +alone, except that its first decision leans on the derivation note's one rule. Two of +the lines it pulled -- the group's nostr identity and the curated lists -- were taken +out again the same day, and its record says what went and what stayed. The nsec and +npub sign-in notes arrived with it and record what they built. The profiles pull note +is the same exercise a second time, planned with its dry run already done and built +the same day; read it after the first, because it assumes the method and the three +decisions and only says what changed — chiefly that the tree is no longer a superset, +and what the check for an exact pull becomes when it is not. The preview pull note is +the third and shortest; read it for the two kinds of resolution the first two never needed. +The nsec sign-in note is a phased plan that has been built; it inherits the +key-storage decision from the jvm-target note and drives the navigation state +machine `NavigationViewModel.processLocalAccount` implements, so read it with the +code open, and read its table of where the build chose differently first. +The npub sign-in note is a phased plan that has been built, and reads as that +plan's out-of-scope note answered: it takes the `Identity` type and the sign-in +machine as given and asks what an identity with no secret is for, before it asks +how to build one; read its table of where the build chose differently first. +The multiple-profiles note is a phased plan that has been built, and reads as the +third of the sign-in notes: it asks what happens when the device holds two +identities, and its first phase changes one rule the other two share — a seed's +key becomes a credential like any other — so read it with `StoredIdentity.merge`, +`SovereignWalletViewModel.switchToIdentity` and the startup screen open, and read +its table of where the build chose differently first. +The npub profile preview note is a phased plan that has been built, and is the +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. diff --git a/docs/curated-to-mantra-preview.md b/docs/curated-to-mantra-preview.md new file mode 100644 index 00000000..0899c73d --- /dev/null +++ b/docs/curated-to-mantra-preview.md @@ -0,0 +1,111 @@ +# The third pull: the profile preview, the extended FAB, and half of a commit + +Eleven commits reached `curated/curated` on 2026-09-13, after the +[second pull](./curated-to-mantra-profiles.md) had landed — ten ordinary and one merge, +`29027f2b..272b6316` — and they sat unpushed in a local clone long enough to be asked +about. This is what they are, which of them Mantra took, and the three places this pull +differed from the two before it. It assumes the [first plan](./curated-to-mantra.md)'s +method and the second's exactness check, and says only what changed. + +**Built 2026-09-13**, the day they were pushed: eight commits landed whole, one in part, +one merge as nothing, in two phases, at `jvmTest` 871 → 914 and `testDebugUnitTest` +420 → 424 with every audit budget met. + +## The inventory + +| line | commits | what | verdict | +|---|---|---|---| +| **A profile preview before a chat** — [npub-profile-preview.md](./npub-profile-preview.md) | `db268a53` plan · `37c6f7ea` `8973c005` `923b59ec` `d6490985` · `b3bcc060` record · `80735df8` · `272b6316` | `ProfilePreviewScreen` and its view model, with `DirectMessagePeerSync` behind the button's readiness; the new-chat dialog opens the person instead of starting the chat; the button knows whether a room with them already exists; the preview's action is the app's extended FAB; a member row on the group's screen opens the preview | **pulled**, all eight | +| **One extended FAB for every primary action** | `ca6c16be` | `ExtendedFab`, and twenty screens moved onto it — thirteen of them Mantra's, including `AddArtifact`, `AddChapter`, `AddDialect`, `AddTranslationArtifactVersion`, `TranslateChunk`, `DkgRitual` | **pulled**; the seven screens Mantra does not have resolve as *still deleted* | +| **Broadcast every accepted entry** | `d2a4298d` | a *Broadcast all* on the curated-entries screen, and — to make it — `sendToRelays` lifted into `BroadcastGroupSignedEventViewModel`'s companion, a `RelayOutcomeLabel` widget, and the broadcast screen reading it | **pulled in part**: the broadcast half; the entries half is line D | +| the merge | `f4b0347e` | the preview branch joining `curated` | lands as nothing; its two join files carried only line D's side, see below | + +## Three things that differed + +### 1. A brand token the normaliser did not know + +The preview's view model names a readiness state `NotYetOnCurare`. `\bCurare\b` cannot +see a brand with no word boundary in front of it, and the first rewrite left seventeen +of them across the code, the tests and the note. The fix is a rule, not an edit — +`(?<=[a-z0-9])Curare(?=[A-Z]|\b)` → `Mantra`, for the Curare generation only, since a +CamelCase-embedded *Curated* is product vocabulary at the tip — and a second rewrite: +zero tokens left, and the earlier forty-nine rewritten commits came out hash for hash +the same, so nothing already landed was disturbed. `0728f3f9`. + +### 2. Half of a commit, taken as a partial pick + +`d2a4298d` is one commit in two lines. Its button, view model, state, tests, five strings +and nav-host wiring belong to the curated-entries screen, which Mantra took out; but the +send it lifted out of the broadcast screen into `sendToRelays`, the widget that turns a +relay's answer into words, and the screen reading it are the broadcast line Mantra kept, +and they touch only files both trees hold. Dropping the commit whole would have left four +shared files differing from the fork's and put the FAB commit, which edits the same +screen, on a base the fork's never had. The driver gained a `PARTIAL` rule: the diff +restricted to the kept paths, applied and committed under the original message and +trailer, with what was left out written into the note. `b3379f81`, and the FAB commit +then applied to the screen cleanly. + +What this does to the merge: `f4b0347e`'s two join files, `strings.xml` and the nav +host, carried the preview side's additions — which the preview commits had already +brought — plus `d2a4298d`'s entries strings and wiring. So the merge was not a source of +anything Mantra wanted, and for the first time a join's files were *not* taken from the +merge. The residual records the difference exactly: five strings and their comment, and +two nav-host hunks, one of which is a comment that says "as the entries screen is for +its Broadcast all" where Mantra's still says "the one screen", which on Mantra is true. + +### 3. Two conflicts the compiler found, not git + +Both in `272b6316`, which applied without a textual conflict. + +- Upstream's `ChatRoomDetailScreen` had no use left for `ImplementationPendingRoute` + once `808a3459` dropped the library, dialects and projects sections, so the commit + removes the import. Mantra kept the sections, and its projects section still routes + *Add new project* through that import. Kept, with the reason in the commit. +- The test it adds, `ChatRoomMemberRowJvmTest`, composes the screen against + `GroupSignedWorkFixtures`, a jvmTest fixture that went with lines B, D and H. The + test uses three of its members — the room id, the active key, `localChatRoom()` — and + none of the signed work. The fixture is recreated on Mantra reduced to the room alone, + under the same names, so the test lands byte for byte and the next upstream test + written against the room compiles here too. It is the one file this pull turned from + *upstream only* into *both have, differently*. + +The seven modify/delete conflicts at `ca6c16be` were the expected kind and resolved by +rule: `AcceptCuratedSuggestion`, `AddGroupPost`, `EditGroupCuratedSchema`, +`EditGroupNostrProfile`, `EditGroupRelays`, `GroupCuratedEntries`, +`ProposeGroupEvent` — all still deleted. + +## The record + +| | Phase A — the preview line to its record | Phase B — the broadcast half, the FAB, the two on top | +|---|---|---| +| commits | `69aa43a1` `f84ae133` `39c056a2` `5ba888d9` `2adc5036` `a8ba589e` | `b3379f81` (partial) `883bf1c8` (seven deletes) `e66c174b` `4899dcbc` (two hand fixes) | +| picks | 6 clean | 1 partial, 1 by rule, 2 clean | +| exactness | 139 residual files before and after; nothing moved | 140; moved: `strings.xml` (the five strings), `MantraNavHost.kt` (the two hunks), `ChatRoomDetailScreen.kt` (one import), `GroupSignedWorkFixtures.kt` (recreated); twelve upstream-only files changed upstream, all of them line D's or the removed screens' | +| compile | both targets clean | both targets clean | +| `jvmTest` | 871 → 909 | 909 → 914 | +| `testDebugUnitTest` | 420 → 424 (`DirectMessagePeerSyncTest`) | 424 | +| audit | all budgets met; composable literals 30 | all budgets met; 30 | +| `strings.xml` | 455 → 460 | 460 | +| schema | unchanged, v20 | unchanged, v20 | + +The rewrite: 60 commits in 128 s, twice. The library pin: `84cc44c` throughout. + +## What the user gets + +A member's row on a group's screen, and the person a new chat is started with, open a +profile preview: the cached profile at once while the relays refresh it, and one +button that says whether a chat with them exists, can be started, or cannot yet because +they have not set up messaging on Mantra. And one extended FAB, the same shape and the +same disabled state, on every screen whose primary action used to spell it its own way +— including the translation screens Curare kept and fixed. + +## What is left + +- **The other half of `d2a4298d`** is line D and stays out; the residual names its lines. +- **The reverse direction.** `ExtendedFab` and the preview are now identical on both + trees; the only Mantra-side edits in this pull are the kept import and the reduced + fixture, neither of which the fork wants. Nothing new to offer upstream from here. +- **The fork decision** stands as the first plan left it. Three pulls in one day with + the tooling in place cost minutes each of machine time; each also found one thing — + a pin, a CamelCase brand, a commit in two lines — that a rebrand-free fork would not + have had to find. diff --git a/docs/curated-to-mantra-profiles.md b/docs/curated-to-mantra-profiles.md new file mode 100644 index 00000000..02bce266 --- /dev/null +++ b/docs/curated-to-mantra-profiles.md @@ -0,0 +1,634 @@ +# Pulling Curare's several-profiles line back into Mantra + +The [first pull](./curated-to-mantra.md) landed on 2026-09-13 and the upstream moved +the same day: `curated/curated` went from `86cb876b` to `29027f2b`, ten commits that +let a device hold several profiles and move between them — and, with them, the fork's +first schema migration, version 20. That plan named them the next pull and said the +schema change earned them a decision of their own rather than a ride. This is that +decision, which of the ten Mantra wants, in what order, and how they land — with the +dry run already done, so the numbers below are measured rather than predicted. + +Read [multiple-profiles.md](./multiple-profiles.md) for the line's own reasoning once +Phase 2 brings it; nothing here repeats it. Read the first pull's +[three decisions](./curated-to-mantra.md#three-decisions) and its +[Phase 1 record](./curated-to-mantra.md#phase-1--the-dry-run) for the method and the +lessons; this document assumes both and adds what changed. The one rule everything +still rests on is the derivation note's: two strings in this tree are hash inputs and +are never renamed, and the normaliser cannot touch them by construction. + +A third pull followed the same day — eleven commits for a profile preview and an +extended FAB — and is recorded in [curated-to-mantra-preview.md](./curated-to-mantra-preview.md). + +**Built 2026-09-13**, the same day, on the branch that carries this plan: the ten +commits landed by cherry-pick in four phases, each with a `Pulled-From` trailer, at +the dry run's numbers exactly; the phases below carry a *Built* paragraph each with +what was measured at the cut. Phase 6's two native items are done — the refusal of a +Welcome for a group another profile holds (`679d5249`), and the note's four line +anchors (`2620e43d`) — and its other three are the user's: pushing the removal, the +two reverse-pulls into the fork, and the fork decision. The reverse direction was +measured rather than assumed: the refusal commit, rewritten into the fork's names by +four inverse rules, applies cleanly to `curated/curated` at `29027f2b`. + +Written against `mantra` at `776455ec` — `origin/mantra` at `0fa807a6` plus the +removal commit, which is where this plan starts and which `origin` does not yet carry +— and `curated/curated` at `29027f2b`. Dry run executed 2026-09-13. + +## The shape of the fork now + +Since the first pull, Mantra's tree has stopped being a superset of Curare's non-brand +tree. It took thirty of the fork's thirty-nine commits, then took three lines out again +— the group's nostr identity, the curated lists, and the row-per-kind group screen — by a +product decision recorded in the first plan. So the two trees now differ in 135 files: +83 that Curare has and Mantra does not (the twelve removed screens and their view +models, `nostr/curated/`, the seven group readings, and `WalletManagerExtension.kt`, +which `39fb64b6` deleted on Mantra's side), 4 that Mantra has and Curare does not (the +first plan, its two scripts, and `NostrPublicKeyJvmTest`), and 48 that both have with +different content — `strings.xml`, `MantraNavHost.kt`, `ChatRoomDetailScreen.kt` and +the rest of the removal's footprint. That residual is the seam every future pull has to +cross, and the first plan predicted that anything upstream touching the identity block +would conflict in it. + +The ten commits, all of 2026-09-13, all on one line with no merges: + +``` +* 29027f2b docs: record what the multiple-profiles plan built (record) +* 88577bb5 test(identity): several profiles, both ways, and a boot (Phase 8) +* 9bb34004 feat(ui): leaving one profile among several (Phase 7) +* 759199f2 feat(sync): a fetch queue per identity, the inbox reopened (Phase 6, schema v20) +* 2f420230 feat(identity): adding a profile from inside (Phase 5) +* 25464595 feat(ui): the switcher (Phase 4) +* 00fc9969 feat(identity): which profile opens (Phase 3) +* 9b3a1927 feat(identity): a switch that tears down what it should (Phase 2) +* 3008137e feat(identity): a seed's key is a credential (Phase 1) +* a5ff2641 docs: plan several profiles on a device (plan) +``` + +Three facts about them decide most of what follows. + +**They touch none of what Mantra removed.** The ten change 69 files. Of those, six +already differ between the trees, and in only four of the six is the difference the +removal's — `strings.xml`, `MantraNavHost.kt`, `DatabaseNostrRepository.kt`, +`NostrRepository.kt` — and in all four the removal's hunks and this line's hunks are far +apart. The seam the first plan predicted is real and this line does not cross it: nine of +the ten replay without a conflict, and the tenth conflicts on a line that is Mantra's own +([the third decision](#3-the-one-conflict-is-mantras-and-it-closes-a-loop)). + +**No library change.** The pin is `84cc44c` on both sides, unchanged by the ten; +`BusinessManager.stopBusiness`, `GlobalPrefs.saveDefaultWallet` and the credentials +file's format all existed already. This is the first pull from the fork in which the pin +does not have to follow any commit. + +**One schema migration**, `759199f2`: version 20, two nullable columns by +`AutoMigration`, the export committed beside its predecessors. Mantra has no version 20 +of its own on any branch — measured: `curated/curated` is the only ref in this +repository carrying a `20.json` — so the number is free, and after the pull Room +regenerates nothing: the built tree's `20.json` is byte-identical to the fork's. The +[second decision](#2-schema-version-20-and-who-allocates-the-number) is about the +number, not the columns. + +## The inventory + +Every commit, what it is, what it does to Mantra *as it is today*, and the verdict. +Sizes are `git show --shortstat`; `759199f2`'s is 5,698 lines of generated schema plus +395 of code. + +| commit | phase | size | what Mantra gets | verdict | +|---|---|---|---|---| +| `a5ff2641` | plan | 2 files, +1,192 | `docs/multiple-profiles.md` and its README row | **pull** | +| `3008137e` | 1 | 16 files, +649 −111 | a seed's nostr key written to `nostr-credentials.dat` as a `Secret`, repaired in for every seed already on the device; `StoredIdentity.merge` inverted so the credentials file is the list of profiles; the identity's key read from the credential rather than from the running node; `forgetNostrCredential` refusing a key a seed derives | **pull** — the on-disk change; see [Phase 2](#phase-2--the-credential-alone) | +| `9b3a1927` | 2 | 9 files, +490 −31 | `switchToWallet` becomes `switchToIdentity` and stops the node it leaves; `RelaysSocketManager.observeActiveUserId` restructured under `collectLatest` so a switch cancels the previous identity's relay observer; `stopPlatformBusiness` as three one-line actuals | **pull** — a fix Mantra has today, see below | +| `00fc9969` | 3 | 7 files, +233 −22 | `StartupChoice.resolve`, the startup precedence lifted out of the screen and reordered; `setActiveIdentity` saves the default, the forget tails clear it; the startup screen's six literals into the catalogue as *profile* words | **pull** | +| `25464595` | 4 | 13 files, +547 −24 | `ProfilesRoute`/`ProfilesScreen`, reached from the profile tab's row that today goes to `ImplementationPendingRoute`; `WalletsSelector` rows showing the nostr profile instead of "Default name" over a random emoji, and "Wallet" beside a seed | **pull** | +| `2f420230` | 5 | 24 files, +624 −201 | *sign in with a key* and *create a new profile* under both lists; a profile created from inside is a bare key through `NewProfileWriter`, a seed only from Landing; *end this* and its `wipeDatabase` gone from the create screen; a pasted key already on the device offers *switch to it*; `JvmGlobalPrefs` closing a DataStore race on desktop | **pull** — two fixes Mantra has today, see below | +| `759199f2` | 6 | 17 files, +6,093 −23 | `ownerPublicKey` on the two fetch queues, stamped by the repository, drained by the owner's pump; `NostrDao.reopenInbox` opening every wrap addressed to the active key that no seal names, once per activation; `GiftWrapSeal.giftWrapMessageId` written on the path that never wrote it; **schema v20** | **pull** — a fix Mantra has today, and the decision | +| `9bb34004` | 7 | 3 files, +61 −1 | *switch profile* on the not-found screen when another profile exists | **pull** | +| `88577bb5` | 8 | 1 file, +282 | `MultipleProfilesRoundTripJvmTest`: two profiles, both ways, and a cold boot | **pull** | +| `29027f2b` | record | 2 files, +46 −7 | the built-record table in the plan; the README's reading order | **pull** | + +Nothing is dropped. The line is one feature and seven fixes braided together — four of +them live on Mantra today — and the +[first decision](#1-take-the-whole-line-and-what-taking-less-would-cost) is why the +braid is not unpicked. + +### What is a fix for Mantra today, before any second profile exists + +Four of the ten close bugs a Mantra install has now, with one profile, and each is +verifiable against the current tree: + +- **The relay observer leaks across sign-outs** (`9b3a1927`). + [RelaysSocketManager.kt:65](../composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt) + still reads `// TODO: Cancel all pending jobs?` above a map of one relay-list job per + pubkey, of which only the job for the pubkey being *started* is ever cancelled. Sign out + of a read-only identity, sign in as another, and two observers feed `updateRelayPools` + with the one pool following whichever list emitted last. The sign-out exits that make + this reachable arrived in the first pull. +- **A read-only identity's inbox stays closed after its nsec is pasted** (`759199f2`). + [NostrDao.kt:451](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt) + says of a wrap it cannot open that "the key that opens this one may be signed in later", + and nothing runs when it is: `storeNostrEvent` never re-indexes an id it holds, so the + wraps an npub preview fetched are never opened by the upgrade the first pull built. And + `GiftWrapSeal.giftWrapMessageId` is written on the NIP-17 DAO's path and not on the one + `indexNostrEvent` takes, so nothing in the database can say which wraps were opened — + the question `reopenInbox` asks. +- **Desktop can throw "multiple DataStores active for the same file"** (`2f420230`). + `Phoenix.jvm.kt` builds a `DataStoreManager` per call, and the library caches one + `GlobalPrefs` per process behind an unsynchronised check-then-set; the listing on IO and + a sign-in's write racing at first use both build one, and the loser throws. Upstream's + tests hit it one run in three. `JvmGlobalPrefs` is the one way the jvm target reaches + the prefs, under a lock; Android goes through the `Application`'s single instance and + never raced. +- **The startup screen's words** (`00fc9969`). `SovereignWalletStartupScreen` carries six + string literals — "Initializing...", "Preparing wallet...", "Opening wallet", "Starting + wallet" — outside the catalogue and in the wallet's vocabulary; the M3 audit counts them + among the 40 literals it reports without a budget, and after the pull it reports 30. + "Starting wallet" survives, in the one branch where a wallet is what is starting. + +The other three fixes are latent until a device holds two profiles — the precedence +`when` that puts "show me the list" above "open this one", the default that is read at +every boot and saved by nothing, *end this* wiping every profile's rooms from the screen +that adds one — and the line makes two profiles possible, so they ship with it. + +### What is a feature, and whether Mantra wants it + +The profile tab has had a *Change account* row since the first pull, and it routes to +`ImplementationPendingRoute` +([ActiveProfileScreen.kt:324](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt)). +The switch behind it — `switchToWallet`, `resetToSelector`, the null identity sent to +startup with `popUpTo(0)` — has been in the tree since Phoenix and is reachable from +nowhere; Landing shows only when the device holds nothing, so a second profile cannot be +added. Mantra took the nsec and npub sign-in lines without qualification; this is the +line that answers the question both of them deferred, and it is wanted for the same +reason they were: a translator who reviews as one key and signs a chapter as another, or +who holds a collective's read-only preview beside their own profile, is the ordinary +case, and the row that promises it is already on the screen. + +The one feature choice inside the line that is a Mantra question rather than a Curare +one is that **a profile created from inside is a key, not a second wallet**. Mantra's +wallet is the Phoenix fork's; nothing in the translation product asks for a second +Lightning node, and a user who wants another profile has not asked for a second phrase +with funds behind it. The seed stays the first profile's, from Landing. That is the +right answer here too. + +## Four decisions + +### 1. Take the whole line, and what taking less would cost + +The alternative is to take the four fixes and leave the feature: `9b3a1927`'s observer, +`759199f2`'s inbox, `2f420230`'s `JvmGlobalPrefs`, `00fc9969`'s words. Measured +against the commits, that is not four cherry-picks; it is editing five commits by hand. +`9b3a1927` renames `switchToWallet` in the same hunk that stops the node; `759199f2`'s +owner column exists so that a *switch* cannot lose a message, and its `reopenInbox` +runs from the same activation collector; `2f420230` fixes the DataStore race in the +jvm actuals it rewrites for the bare-key writer; `00fc9969`'s strings are the +selector's. Every carve-out leaves a Mantra `SovereignWalletViewModel`, nav host and +startup screen that differ from upstream's in the same places, and the first plan's +principle stands: every feature left out is a permanent conflict seam, paid for at every +later pull. The removal of lines B, D and H was that principle's one exception, made on +product grounds and measured at 135 files of residual; this line touches none of it, +and adding a second exception for a feature the product wants would be paying the price +twice for nothing. + +So: all ten, in upstream's order, with the phases cut where upstream's own rollout +section says the line can ship in pieces. + +### 2. Schema version 20, and who allocates the number + +Two apps, two `applicationId`s, two databases that never meet on one device: nothing +stops Mantra from having a version 20 of its own shape and Curare another. What it would +cost is the method. The first plan's pull was mechanical because the nineteen schema +files were byte-identical on both sides; a Mantra 20 that is not Curare's 20 makes every +later migration a hand-merge of `MantraDatabase.kt`'s `autoMigrations` list and a renumbering +of every schema file after it, in both directions, forever. + +So the rule, stated once: **a schema version is allocated by whichever tree migrates +first, and the other tree pulls that migration before it adds its own.** Never two +different 20s. Today that is trivially satisfiable — Mantra has none pending; the check +that it stays satisfiable is a `git ls-tree` over every ref for the next number before +a migration is written on either side. + +Taking upstream's 20 verbatim is safe on the evidence: the columns are nullable +additions to two request-queue tables, `AutoMigration` with no data rewrite, and the +built tree regenerates nothing (`git status` clean under `composeApp/schemas/` after a +full compile; identity hash `4414c373…`). The downgrade story is the one every +migration in this database has had — an older build cannot open the file — and +`PlatformDatabaseBuilder` has the same answer it always had. The two-column change +carries no risk of its own; what it carries is the number, and the rule above is what +makes the number cheap. + +### 3. The one conflict is Mantra's, and it closes a loop + +The dry run's single conflict is `CreateProfileViewModel.kt` at `2f420230`, and it is +not the removal's seam. Mantra's `39fb64b6` moved the `nostrPublicKey()` import from the +app's `WalletManagerExtension` to the library's; upstream never took that change and +still imported the app's — one line of residual, the last of the first plan's Phase 6 +reverse-pulls that was never done. `2f420230` rewrites the import block wholesale, and +removes the seed generation the import served: the new view model asks a +`NewProfileWriter` for a key and derives nothing itself. + +Resolution: take the file as upstream leaves it. Not a union — the previous run's lesson +about joins holds for import blocks too — but *theirs*, because the base difference is +one import this commit deletes the use of. Afterwards the file is identical on both +sides, and the residual between the trees goes from 135 files to 134. The +[replay driver](./scripts/curated-replay.py) carries the rule as a `THEIRS` entry with +that reason, beside the one `42f3a697` needed for the same import in two other files. + +What is left of `39fb64b6` upstream after this: `WalletManagerExtension.kt` itself, +now with zero callers on either side. The reverse-pull the first plan owed shrinks to +deleting a dead file; see [Phase 6](#phase-6--close-the-loop). + +### 4. Two of the device's own profiles in one group + +Upstream's out-of-scope list names it and Mantra should name it louder, because groups +are Mantra's product. `ChatRoom`'s primary key is the group id and `userPublicKey` says +whose the row is; `DkgSession`, `GroupKeyState` and every FROST share hang off the room. +Today one device holds one profile and the collision cannot happen. After this line a +user can invite their second profile into their own collective, and the second member's +MLS state overwrites the first's — silently, in the shape of "the other device never got +it". The library, dialects and projects are keyed by `chatRoomId` and reached through +the room, so they scope per profile for free; the room itself does not. + +Decision: **not part of the pull, and a Mantra-native follow-up before the line ships +to users.** The cheap fix upstream names — refuse a Marmot welcome for a room the device +already holds under another profile, with a notice saying which — is one branch in the +inbound manager and a string. The real fix, keying the room by `(id, userPublicKey)`, +is a migration of every table that references a room, and it is not this plan's. Write +the cheap one natively, under Mantra's names, and offer it upstream by cherry-pick; it +is the kind of commit the fork should take. + +## How it lands + +The first plan's pipeline, unchanged in method, with three things that differ. + +**Rewrite from the fork point, not from the last pull.** The rewritten history is +rebuilt from `ba26c0b1` every time — 49 commits now, 103 seconds — because a +`filter-branch` over only `86cb876b..curated/curated` would leave the first new commit's +parent in Curare's names, and the cherry-pick of that commit would be a seven-hundred-file +rename. The normaliser has no new rules to learn: the rewritten tip holds no `curare`, +`Curare` or `com.it.curated` token outside the first plan's own prose, checked by +`git grep`. + +```bash +git fetch curated +git worktree add -b tmp/pull2 mantra # from 776455ec, the removal included +cd +git branch -f tmp/curated-src curated/curated +FILTER_BRANCH_SQUELCH_WARNING=1 git filter-branch -f -d \ + --tree-filter 'python3 /abs/path/docs/scripts/curated-unbrand.py tree .' \ + --msg-filter 'cat; echo "Pulled-From: curated/curated@$GIT_COMMIT"' \ + -- ba26c0b1..tmp/curated-src +python3 docs/scripts/curated-replay.py pick \ + a5ff2641 3008137e 9b3a1927 00fc9969 25464595 2f420230 759199f2 9bb34004 88577bb5 29027f2b +``` + +A clean worktree is required — `filter-branch` refuses unstaged changes, and a +populated `lightning-kmp-app` at a pin other than `84cc44c` counts. A fresh worktree +has no submodule and is clean; for the build afterwards, symlink a populated checkout +at `84cc44c` in (`git status` then shows ` T lightning-kmp-app`, which the ten never +touch) and pass `ANDROID_HOME` on the command line, as the first plan's appendix did. + +**The exactness check is restated**, because `git diff tmp/curated-src HEAD` is no +longer "the commits you dropped" — it is 135 files of the removal. The property that +holds instead: **the pull moves Mantra by exactly the ten commits, so the residual +between the trees is the same before and after**, file for file and hunk for hunk, +except where a resolution deliberately changed it. + +```bash +U0=; U1=; M0=776455ec; M1=HEAD +diff <(git diff --name-only $U0 $M0 | sort) <(git diff --name-only $U1 $M1 | sort) +# expected: exactly CreateProfileViewModel.kt, gone from the residual +for f in $(comm -12 <(git diff --name-only $U0 $M0 | sort) <(git diff --name-only $U1 $M1 | sort)); do + cmp -s <(git diff $U0 $M0 -- $f | grep '^[-+]' | grep -v '^[-+][-+]') \ + <(git diff $U1 $M1 -- $f | grep '^[-+]' | grep -v '^[-+][-+]') || echo "residual moved: $f" +done +# expected: nothing +``` + +Anything else in either list is a normaliser rule that is wrong, a resolution that is, +or an upstream commit that crossed the seam — and it is found here. + +**The rules the driver needs**: one. `2f420230` × `CreateProfileViewModel.kt` → theirs, +for the reason in the third decision. The `UNION` rule for `strings.xml`, +`MantraNavHost.kt` and `docs/README.md` stays in place and did not fire — git merged all +three cleanly at every one of the ten — but a future upstream commit that appends +strings where the removal deleted them will need it, and the first plan's lesson about +deletions is already encoded in it. + +Then the four checks, per phase, as before: `:composeApp:compileDebugKotlinAndroid`, +`:composeApp:compileKotlinJvm`, `:composeApp:jvmTest`, `:composeApp:testDebugUnitTest`, +`:composeApp:m3Audit`, and — new for this pull — `git status` under `composeApp/schemas/` +after the compile, which is the check that Room agrees with the committed `20.json`. + +## The phases + +Each phase ends the same way: both compilers, both test suites and the audit green on +the branch, the exactness check as above against the phase's cut, and one review of the +diff against the phase's stated contents. Upstream's rollout section says which pieces +can ship apart — Phase 1 alone, 2 with 3, 4 with 5, 6 whenever, 7 after — and the cuts +below follow it, so that each phase is also a shippable state. + +### Phase 0 — the base + +Nothing from Curare lands. Two things to settle first: + +1. **The removal is on `mantra` and not on `origin/mantra`.** `776455ec` is the local + `mantra` tip and `origin` is at `0fa807a6`. Every measurement in this document is + against `776455ec`; a pull onto `0fa807a6` would land the ten on a tree that still + holds lines B, D and H, and would then have to be removed from under them. Push the + removal, or merge it, before anything else. +2. **A worktree with the library at `84cc44c`.** The pin does not move in this line, so + there is no `PIN` entry to add; but a worktree whose submodule sits at another commit + (this session's was at `01489b8`, an older master) makes `filter-branch` refuse and + the compile fail on `NostrCredentialManager`. `git submodule update --init` fixes it + online; offline, fetch the commit from a populated sibling checkout — + `git -C lightning-kmp-app fetch /lightning-kmp-app 84cc44c && git -C lightning-kmp-app checkout --detach 84cc44c` + — since worktrees keep separate submodule git directories. + +Exit: `git status` clean in the scratch worktree; `origin/mantra` at `776455ec` or later. + +**Done 2026-09-13**, on the branch that carries this plan (`5f2414dc`, itself on +`776455ec`): the worktree's library was at `01489b8` and its nested chain already at the +pinned four, so one fetch from the main checkout's submodule and a detached checkout of +`84cc44c` made the tree clean. The rewrite was rebuilt from `ba26c0b1` in 104 s and +produced the same forty-nine rewritten commits as the dry run, hash for hash — the +normaliser and `filter-branch` are deterministic given the same input, which makes the +dry run's numbers the run's numbers. Item 1 is still the user's: `origin/mantra` was at +`0fa807a6` when this landed, and the removal goes up with the pull. + +### Phase 1 — the dry run + +**Run on 2026-09-13, against `curated/curated` at `29027f2b`, on a scratch worktree of +`776455ec`.** The record, so the next reader starts from what happened: + +| measurement | value | +|---|---| +| `filter-branch` over `ba26c0b1..curated/curated` | 49 commits, 103 s; 834 files rewritten, 828 paths moved at the tip; 0 brand tokens left in code | +| files the ten touch | 69 (+10,184 −387; without `20.json`, 68 files +4,486 −387) | +| of those, already differing between the trees | 6: `strings.xml` (−234), `MantraNavHost.kt` (−264), `DatabaseNostrRepository.kt` (−6), `NostrRepository.kt` (−14), `CreateProfileViewModel.kt` (±1), `docs/README.md` (+7) | +| replay | 10 picked: 9 clean, 1 resolved by rule, 0 stuck | +| the resolution | `2f420230` × `CreateProfileViewModel.kt`: theirs | +| exactness | residual 135 → 134 files; the one gone is `CreateProfileViewModel.kt`; no other file's residual moved | +| `compileDebugKotlinAndroid` + `compileKotlinJvm` | clean | +| `:composeApp:jvmTest` | 868 tests, 0 failures (Mantra before: 826) | +| `:composeApp:testDebugUnitTest` | 420 tests, 0 failures (before: 413; the seven are `StartupChoiceTest`) | +| `:composeApp:m3Audit` | all budgets met; 12 adaptive uses, 2 navigation components; composable literals 40 → 30 | +| Room after the compile | `git status` clean under `composeApp/schemas/`; `20.json` byte-identical to the fork's | +| `strings.xml` | 444 → 455 strings; 14 added, 3 removed (`change_account`, `end_this`, `select_a_wallet`) | +| library pin | `84cc44c` before and after; the ten do not touch the gitlink or `.gitmodules` | + +If the upstream moves before Phases 2–5 land, redo this on the day: a number that +drifts is an upstream commit that crossed the seam, and the driver stops on it rather +than guessing. + +### Phase 2 — the credential, alone + +`a5ff2641`, `3008137e`. The plan and its first phase, on their own, because this is the +one commit in the line that **changes what is on disk**: the first launch after it writes +a `Secret` credential into `nostr-credentials.dat` for every seed the device holds, and a +build from before it, reached by a downgrade after that write, lists each seed-backed +profile twice — once as a wallet with a derived key, once as a bare key under a different +id. Nothing is lost, both rows sign as the same key, and the next upgrade lists them as +one; but it is the one line of this pull worth a release note, and shipping it first +means that a rollback of any later phase rolls back to a tree that already knows the new +file. + +What to look at in review: `writeMnemonic` writes the credential *before* the seed, so a +crash between the two leaves a profile the phrase completes rather than a seed with no +listing; `SeedCredentials.reconcile` runs after both files are read and hands the listing +what it returns, so a repair that could not write still lists the seed by derivation — a +failed write must never hide a wallet; `setActiveWallet` reads the key from the +credential and `check()`s it against the node's, since a node disagreeing with the +credentials file is the one corruption worth refusing to run under; +`forgetNostrCredential` answers `WalletAttached` for a key a seed derives, because the +next repair would write it back. + +Exit: a device with a seed lists once, from the credential, with the node's key as a +cross-check; `SeedCredentialsJvmTest` and the rewritten halves of `IdentityWriterJvmTest` +and `StoredIdentityJvmTest` green; release note drafted. + +**Built 2026-09-13**, `44324c90` and `d957ee8d`, both clean picks. Exactness: the +residual is 136 files before and after (the dry run's 135 plus this document), nothing +moved. Both compilers clean; `jvmTest` 826 → 837 (the eleven are `SeedCredentialsJvmTest` +and the rewritten writer and listing tests); `testDebugUnitTest` 413; audit all budgets +met; nothing under `composeApp/schemas/` changed, as expected of a phase with no +migration. The release note this phase owes, in one line: *the first launch after this +build writes a credential for every recovery phrase on the device; going back to an +older build afterwards lists each such profile twice until the next upgrade, and loses +nothing.* + +### Phase 3 — the switch, the precedence, the switcher, the entrances + +`9b3a1927`, `00fc9969`, `25464595`, `2f420230`. Upstream ships 2 with 3 and 4 with 5; +they go together here because a switch nobody can reach and a switcher nobody can add to +are each half a feature, and the four are 53 files of which the one resolution above is +the whole of the conflict. + +What to look at: `observeActiveUserId` is a child of `collectLatest` and the per-pubkey +map is gone — the test that fails against the old observer is +`RelaysSocketManagerSwitchJvmTest`'s re-emission of A's list changing nothing; +`switchToIdentity` clears the identity *before* stopping the node, so every collector +that could reach the business is cancelled first; `StartupChoice.resolve` puts what a +switch or sign-in named above the user's earlier "show me the list"; `ProfilesScreen` +has a back button and one column at every width; `CreateProfileRoute(withWallet = true)` +only from Landing; the create view model writes the secret, then the six bootstrap +events, then calls the tail — the order the sign-in screen documents as load-bearing; +`JvmGlobalPrefs` is the only place the jvm target reaches `loadGlobalPrefsForWallet`. + +Exit: on desktop and Android, a second profile can be signed in or created from the +switcher and switched to; the previous node is stopped once; a cold boot opens the +last-used profile without asking; `IdentitySwitchJvmTest`, `ProfilesScreenJvmTest`, +`AddProfileFromInsideJvmTest`, `CreateProfileScreenJvmTest` green. + +**Built 2026-09-13**, `84ac84cc`, `73a0e5b9`, `5654e462`, `ccb9942a`: three clean picks +and the one the rule was written for — `2f420230` × `CreateProfileViewModel.kt`, taken +theirs, with the reason in its message. Exactness: 136 → 135 residual files, the one +gone being that file, as the third decision said; nothing else moved. Both compilers +clean; `jvmTest` 837 → 858 (`IdentitySwitchJvmTest`, `RelaysSocketManagerSwitchJvmTest`, +`ProfilesScreenJvmTest`, `AddProfileFromInsideJvmTest`, `CreateProfileScreenJvmTest`, and +the three added to `ReadOnlyEntrancesJvmTest`); `testDebugUnitTest` 413 → 420, the seven +being `StartupChoiceTest`; audit all budgets met, and the composable-literal count the +audit reports without a budget went 40 → 30 as the startup screen's words moved to the +catalogue; `strings.xml` 444 → 455. Read in review, as the phase asked: the observer is +one `collectLatest` child with no map; `switchToIdentity` sets the desired id, sets +`startWalletImmediately` back, clears the identity, and only then stops a node, and only +if there was one; `StartupChoice.resolve` reads *force, desired, "show me the list", +the only one, the default*; `CreateProfileRoute(withWallet = true)` appears once, under +Landing; the jvm target reaches `loadGlobalPrefsForWallet` through `JvmGlobalPrefs` +alone; `ProfilesScreen` puts `NavigateBackButton` in its bar. + +### Phase 4 — the queue owner and the inbox, with the migration + +`759199f2`, alone, because it is the schema change and upstream's rollout says it can +ship early or late independently of everything else. It fixes a case Mantra has today — +the closed inbox of an upgraded read-only identity — and the case the switch would +create: a wrap fetched under the other profile's key, stored, and never opened again. + +What to look at: the `AutoMigration(from = 19, to = 20)` comment says what the columns +mean and that the broadcast queue deliberately has none; `DatabaseNostrRepository` +stamps where the caller stamped nothing and keeps an owner a caller named; the DAO's +own requests — placeholder-profile syncs, a Marmot join's participant syncs — stay +unowned on purpose, since they fetch public kinds; `reopenInbox` runs from the sync +pumps' collector once per activation, for an identity that can sign, *before* the live +inbox and the broadcast pump are launched; one transaction per wrap, so one that +cannot be opened rolls back its own changes and the next is still tried; +`GiftWrapMessage.toGiftWrapSeal` sets `giftWrapMessageId`. And the schema check: a +full compile, then `git status` — nothing under `composeApp/schemas/`. + +Exit: `OwnedRequestQueueJvmTest` and `ReadOnlyGiftWrapDaoJvmTest` green; an npub +signed in, sent a DM by someone else, then upgraded with its nsec reads the DM on the +next activation; schema files 1–20 committed and regenerated identically. + +**Built 2026-09-13**, `72afe8aa`, a clean pick. Exactness unchanged at 135, nothing +moved. Both compilers clean; `jvmTest` 858 → 864 (`OwnedRequestQueueJvmTest` and the +sweep cases added to `ReadOnlyGiftWrapDaoJvmTest`); `testDebugUnitTest` 420; audit all +budgets met. The schema check, which is what this phase is for: after a full build of +both targets `git status` shows nothing under `composeApp/schemas/`, and the committed +`20.json` is byte-identical to the rewritten fork's, identity hash `4414c373…` — Room +computed the same twenty tables from Mantra's entities as from Curare's, which is the +second decision's rule holding on its first use. Read in review: the sweep runs inside +`identity.canSign`, before the broadcast pump and the live subscriptions are launched; +it returns 0 without a query for a key pair with no private key; each wrap is re-indexed +in its own transaction and a failure is logged and skipped rather than thrown; the DAO's +own requests carry no owner; `GiftWrapMessage.toGiftWrapSeal` sets `giftWrapMessageId`; +the migration's comment says why the broadcast queue has no owner column. + +### Phase 5 — the exits, the round trip, the record + +`9bb34004`, `88577bb5`, `29027f2b`. Small, and the last: the not-found screen's third +action when another profile exists, the eight-phase round trip through the real view +models, and the built-record table at the top of `multiple-profiles.md` with the +README's reading-order sentence. + +What to look at: `MultipleProfilesRoundTripJvmTest` constructs no `GlobalPrefs` of its +own — the library's per-process cache means a test that builds a +`SovereignWalletViewModel` must read the default through the view model's instance, the +trap `00fc9969` recorded — and Mantra's own future tests inherit that rule. + +Exit: the full suite at the dry run's numbers or above; the audit green; the README's +table has the new row between the npub note and the jvm-target note. + +**Built 2026-09-13**, `8edb52ff`, `72b9d3d2`, `ba5ef723`, three clean picks — including +the two into `docs/README.md`, where the `UNION` rule stood ready and was not needed: +git placed the fork's row between the npub and jvm-target rows and its reading-order +sentence after the npub note's, beside this document's own. Exactness at the tip: 135 +residual files, `CreateProfileViewModel.kt` the only one gone since the base, nothing +moved. Both compilers clean; `jvmTest` 864 → 868 (`MultipleProfilesRoundTripJvmTest` and +`ReadOnlyEntrancesJvmTest`'s three); `testDebugUnitTest` 420; audit all budgets met — +exactly the dry run's numbers, which is what a deterministic rewrite replayed onto an +unchanged base ought to give. The round trip reads the saved default through the app's +`getGlobalPrefs` rather than building a `GlobalPrefs` of its own, with a comment saying +why, so the trap the phase warned of is documented at the place a copy-and-paste would +start from. + +### Phase 6 — close the loop + +Five things, none of them a merge. + +1. **The follow-up in the fourth decision**, natively: refuse a welcome for a room the + device already holds under another profile. Before the line reaches users, not + after the first report. +2. **Prose that is now off by a removal.** `multiple-profiles.md` anchors three links to + line numbers — `MantraNavHost.kt:418` and `:746`, `DatabaseNostrRepository.kt:111` — + that are upstream's, in a file Mantra's removal shortened by 264 lines. The anchors + describe the tree *before* the line was built and were already approximate upstream; + either drop the numbers or re-point them, in the same commit as item 1 or alone. +3. **Reverse-pull what Mantra owes the fork**, shrunk by the third decision: + `WalletManagerExtension.kt` is dead on both sides and Curare can delete it; the Torch + retirement is unchanged from the first plan's list. Two small commits, both + hand-cherry-picks in the other repository. +4. **Offer item 1 upstream.** It is written under Mantra's names against a file both + trees hold identically, so it applies to the fork as an ordinary cherry-pick — the + direction this whole exercise exists to make cheap. +5. **The fork decision, restated with a second data point.** The first plan estimated + half a day per pull; this one cost the machine about ten minutes and the reading about an hour, + because the tooling was in place and the line crossed no seam. That is the good case. + The bad case is any upstream commit that edits `ChatRoomDetailScreen.kt` or the nav + host's identity block, and the driver will stop on it rather than resolve it. The + three options the first plan listed stand in the same order — converge the package + name, invert the relationship, or keep pulling — and nothing about this pull changes + which is best; it only shows that the third is affordable for as long as the fork's + work stays clear of what Mantra took out. + +**Built 2026-09-13**, items 1 and 2; items 3 to 5 are not this repository's to land. + +*Item 1*, `679d5249`. The DAO's welcome branch already declined to overwrite an +existing room — "Chat Room already exists", at warning level — so the joiner's MLS state +never replaced the holder's; what it could not do was tell a redelivered Welcome for our +own room from a Welcome for a room another profile holds, and it told nobody about +either. The branch now reads the room's `userPublicKey`: the same profile's redelivery +stays a quiet no-op, and another profile's Welcome is refused with a warning naming +both keys and a membership line written into the room that exists — the holder's, +since it is the only room on the device the group has and the holder is the one who +can act. `TYPE_WELCOME_REFUSED_OTHER_PROFILE`, in `MEMBERSHIP_TYPES`, error tint, +`PersonOff`; the content names inviter, invitee and holder. The key package bundle is +left unconsumed as the branch always left it. The test is two databases with a real +MLS group, a key package whose private keys the invitee's side holds — the fixture +now returns the bundle beside the public package — and a Welcome sealed as the app +seals one; three cases, refused, redelivered, joined. `jvmTest` 868 → 871. + +*Item 2*, `2620e43d`: the four anchors re-pointed at Mantra's lines — `:404`, `:741`, +`:537`, `:118` — each still landing on the thing its sentence describes. + +*Item 4, measured.* `git format-patch -1 679d5249`, four inverse rules over the +patch text (`press.mantra.` → `to.curare.`, `press/mantra/` → `to/curare/`, +`mantra.composeapp.generated.resources` → `curare.…`, `MantraDatabase` → +`CurareDatabase`), and `git apply --check` on a scratch worktree of `curated/curated` +at `29027f2b`: applies cleanly, 5 files, +360 −6. That is the whole cost of offering +a Mantra commit to the fork while both trees hold the touched files identically — +and it is also the shape a `curated-rebrand.py`, the normaliser's inverse, would +take if the fork wanted to pull regularly rather than by hand. + +*Items 3 and 5* are unchanged from the plan: two small commits in the other +repository, and a decision. + +## Risks, and the silent ones in particular + +**The write on first launch.** Phase 2's repair is the first time a pull has changed a +file in the node-data directory of an existing install. It is idempotent, one write for +however many seeds are missing, nothing at all on a device with none or one already +repaired, and a failed write is a result rather than a throw; the listing goes on with +what it read. Test it on a real install with a seed from before the first pull, not +only on the in-memory harness. + +**The downgrade double-listing** is a release note, not a bug, and Phase 2 shipping +alone is what keeps it to one line. + +**Room 20.** An older build cannot open the file; the same story as versions 2–19. +Anyone carrying a Mantra branch that adds a table must rebase onto this before numbering +it 20 — the second decision's rule, and the `ls-tree` check that enforces it. + +**iOS is unverified**, again. `stopPlatformBusiness` has an ios actual that calls +`BusinessManager.stopBusiness`, one line, compiled by nobody on linux; the ios target is +gated behind macOS. Build it on a Mac before believing it — and before it, the +first plan's `Config.xcconfig` change, which has still not been. + +**Every switch passes the social precondition gate.** Upstream names it as out of +scope: every `ProfileLoaded` routes through the onboarding gate, on every launch and so +on every switch. For Mantra, whose users will switch between a collective's preview and +their own profile more often than Curare's, this is the first thing a user will complain +about; the fix is one branch in `processLocalAccount` for an account the machine has +seen before, and it is Mantra's to write. + +**The per-process `GlobalPrefs` cache.** A test that constructs `SovereignWalletViewModel` +must not build a `GlobalPrefs` of its own, or the second instance over the same file +throws — recorded upstream, and true of every Mantra test written after this. + +**`strings.xml` loses three keys.** `change_account`, `end_this` and `select_a_wallet` +go; nothing on Mantra references them outside the files the same commits rewrite, and +the compile is the check. The `UNION` rule applies a deletion correctly since the first +plan's fix; it did not need to. + +**The upstream moves.** Between this document being written and the pull landing, +`curated/curated` may gain commits. Redo Phase 1; the driver stops on anything new. + +## Appendix — the residual, for the record + +The 135 files by which Mantra at `776455ec` differs from the rewritten `86cb876b`, and +which this pull leaves alone, so that the next reader can tell a seam from a surprise: + +| kind | count | what | +|---|---|---| +| Curare has, Mantra does not | 83 | 35 under `ui/composable`, 28 under `ui/view`, `nostr/curated/` (5), the seven `nostr/Group*` readings and `CuratedSuggestion` with their tests, `WalletManagerExtension.kt` | +| Mantra has, Curare does not | 4 | `docs/curated-to-mantra.md`, its two scripts, `NostrPublicKeyJvmTest.kt` | +| both have, differently | 48 | the logo's thirteen files and one `build.gradle.kts` comment; the seven places where Phase 0's prose about the protected names is worded for a tree that had no rebrand (`Relays.kt`, `SharedKeyDerivation.kt`, `ChillDkgRitualManager.kt`, `PlatformContext.jvm.kt`, `HomeScreen.kt`, `shared-key-derivation.md`, `material-design-conformance.md`); the removal's footprint of twenty-six — `m3-title-case.py`'s two sample titles, `strings.xml`, `MantraNavHost.kt`, `ChatRoomDetailScreen.kt` with its view model and state, `ChatMessage.kt`'s `applyInnerEvent` arms, `ProposedEvent` and its test, the four repositories, `ChatTranscript`, `ProfileAvatar`, `Member.kt`, `NostrEvent.kt`, `NostrEventDao`, `LocalChatRoom`, the broadcast screen, button, view model, state and two tests, `docs/README.md`; and, until this pull, `CreateProfileViewModel.kt` | + +After the pull: 134, the same list less one. diff --git a/docs/curated-to-mantra.md b/docs/curated-to-mantra.md new file mode 100644 index 00000000..7f168840 --- /dev/null +++ b/docs/curated-to-mantra.md @@ -0,0 +1,716 @@ +# Pulling Curated back into Mantra + +Curated forked from this repository on 2026-09-09 and has since landed thirty-nine +commits that Mantra has none of: a group's own nostr identity, curated lists over the +kinds 31888–31890, sign-in with a recovery phrase, an nsec or an npub, a back button on +every pushed screen — and two rebrands, 744 and 765 files each, underneath all of it. +This is which of that work Mantra wants, in what order, and how to land it under +Mantra's names without touching seven hundred files by hand. + +Read [nsec-sign-in.md](./nsec-sign-in.md) and [npub-sign-in.md](./npub-sign-in.md) for +the identity work's own reasoning once they arrive (Phases 3 and 5 bring them); nothing +here repeats it. [shared-key-derivation.md](./shared-key-derivation.md) states the one +rule this whole exercise is built around — that two strings in this tree are hash inputs +and must never be renamed — and the rebrands upstream were careful about it, which is the +only reason a mechanical pull is possible at all. + +**Built, Phases 0–5, on 2026-09-13**, on the branch that also carries `f4434ab1` and +`39fb64b6`: thirty commits pulled from the fork, each with a `Pulled-From` trailer, on +top of Phase 0's two native ones. Phase 6 is not done: its two reverse-pulls land in the +other repository, and its last item is a decision. The plan below is kept as written, +because the code reads better against the argument it came from than against a summary +of itself; where the execution chose differently the [Phase 1 record](#phase-1--the-dry-run) +says so, and it did so in seven places worth reading: + +| what the plan said | what it turned out to be | +|---|---| +| each phase is a `rebase --rebase-merges --onto` of a cut | a cherry-pick per commit, in topological order. A recreated merge whose other parent was replayed in an earlier phase would be pointed at the *pre*-replay commit; so the four merges land as nothing and their resolutions are folded in where the conflicts surface. The driver is [`docs/scripts/curated-replay.py`](./scripts/curated-replay.py) | +| the pin stays at `84cc44c` throughout | it follows the commit. The nsec line writes through `NostrKeyManager`, which `84cc44c` renamed away; the compiler produced eight unresolved references at the Phase 3 cut. `366b0177` moves the pin to `59c11ed` and `5efeae76` brings it to `84cc44c`, so every commit builds against the library it names. A populated submodule made git fast-forward the gitlink past the rule once, silently; the rule is now unconditional | +| take the rebrand residue *or* leave it for Phase 2 | Phase 0 took it, natively worded, so the three rewritten brand commits were dropped outright rather than squashed | +| Phase 2, 3 and 5 replay with no conflicts | Phase 3 had two, both foreseen in kind: the import block `39fb64b6` had already moved (theirs), and one word of a class comment the dropped rebrand had capitalised (theirs). Phase 5 had none | +| Phase 4's three conflicts, resolved by hand | the same three, but the join files are taken from upstream's own merges (`c8de3a1f`, `fedbe724`) rather than unioned: a union duplicates shared lines, ignores the other side's deletions, and — the one that hid longest — orders the joined blocks its own way so later commits miss their base. `fcc19f95`'s deletion of two strings survived two attempts before that was understood | +| the exactness diff is the logo plus `808a3459`'s three files | plus the fourteen files Mantra changed on its own side: Phase 0's prose at the protected names, `39fb64b6`'s four, and this document with its two scripts. Seventeen files, every one accounted for, at every cut | +| 1,036 jvm tests at the end | 1,039 — the three from `39fb64b6` — and per phase 736, 864, 905, 1,007, 1,039; `testDebugUnitTest` 403, 486, 495, 530, 530; every phase clean on both compilers with every audit budget met | + +**Lines B and D were taken out again the same day**, and H with them, by a product +decision rather than a merge problem: the group's nostr profile, its relay lists, its +posts, the curated schemas and the curated entries are not Mantra's product, and the +[third decision](#3-curated-lists-are-not-mantras-product-and-mantra-should-take-them-anyway)'s +"hide the two rows behind a constant" was not enough once the profile, relays and +posts rows went with them. What went: the five rows and the twelve screens behind +them, `nostr/curated/`, the readings (`GroupNostrProfile`, `GroupRelayList`, +`GroupPost`, `GroupCuratedSchema`, `GroupCuratedEntry`, `CuratedSuggestion`), the +`applyInnerEvent` arms for kinds 0, 1, the four relay-list kinds, 31889 and 31890, +the `ChatRepository` and `NostrRepository` reads that fed them, the pasted-event +proposal (`GroupEventProposal`, whose accepted kinds were exactly those), the +`ProposedEvent` summaries for the same kinds, 226 strings, and every test that came +with them. What stayed from the pull: F (the back button), G and I (sign-in), J (the +library chore), and E (broadcast) -- which nothing opens now, since its button sat on +the five removed screens and its relay seeding read the group's relay lists and +schemas; `defaultRelaysFor` is the app's publish set, and the route and the button +are kept against the day something wires one in. Every pre-existing file the removed +commits touched is back at its pre-pull content plus the kept lines' hunks; the +exactness check for that is `git diff ba0830a3 -- ` showing only the back +button, broadcast's `groupSignedEvent`, and the sign-in reads. Tests afterwards: 826 +jvm, 413 common, every audit budget met. The seam this leaves for the next pull is the +one the third decision predicted: any upstream commit that touches the identity block +will conflict at `ChatRoomDetailScreen`, `MantraNavHost` and `strings.xml`, and the +resolution is to drop it. + +The upstream moved before this landed: `curated/curated` went from `86cb876b` to +`29027f2b`, ten commits for several profiles on one device, one of them the fork's +first schema migration (version 20). They are the next pull, and the schema change is +why they get their own decision rather than a ride on this one — which is +[curated-to-mantra-profiles.md](./curated-to-mantra-profiles.md), planned, dry-run and +landed the same day. + +Written against `origin/mantra` at `ba26c0b1` and `curated/curated` at `86cb876b`; +executed on 2026-09-13. + +## The shape of the fork + +Two remotes: `origin` is `mantra/mantra-kmp`, `curated` is `curated/curated-kmp`, both +on code.sigidli.com. The merge-base of `origin/mantra` and `curated/curated` is +`ba26c0b1` — which *is* `origin/mantra`. Mantra has not moved since the fork, so Curated +is strictly ahead of it, by thirty-nine commits. + +That has a consequence worth stating before anything else. **`git merge curated/curated` +into `mantra` is a fast-forward.** It would not merge anything; it would make Mantra +*become* Curare — the package rename, the icon, and the commit that deletes Mantra's own +library, dialects and projects sections included. It is the one thing not to do, and it +is the thing git does by default. + +The thirty-nine are thirty-five ordinary commits and four merges, and the branch +structure matters for the phasing later: + +``` +* 86cb876b Merge claude/npub-readonly-signin into curated +|\ +| * 3116eb8a … 78807fe9 npub sign-in: 12 commits (Phase 5) +* | 55664cc7 rows on the group's screen +* | fedbe724 Merge curated into the suggestion-accept branch (carries resolutions) +|\| +| * ccbdbf7a Merge curated into the back-button branch (carries resolutions) +| |\ +| * | 0634487e back button on every pushed screen +* | | 732a4527 accepted entries on the group's screen +* | | fcc19f95 accept a suggestion into the list +| |/ +|/| +* | 6ae5a967 broadcast button under each signed event +|/ +* c8de3a1f Merge curated into the nsec branch +|\ +| * 1e52fc8f the queue of a curated list +* | 2326839e … daae63d5 nsec sign-in: 8 commits (Phase 3) +|/ +* 392b90b8 an unsigned event, pasted +* 930d37c8 the lists a group curates (kind 31889) +* 808a3459 drop the library, dialects and projects sections <- Mantra drops this +* cd3108e9 Update logo <- and this +* e6aee792 rebrand curated -> curare <- and the brand line +* 334e6dd1 what the group says out loud (kind 1 posts) +* 4c0ed0c1 the group's own nostr identity (kind 0, relay lists) +* d26cf6c7 retire the brand before mantra (Torch) +* 3bc8be53 rebrand mantra -> curated +``` + +Two brand generations sit under the features. `3bc8be53` made the app Curated — +`press.mantra.*` became `com.it.curated.*`, `MantraDatabase` became `CuratedDatabase`, +and so on — and `e6aee792` made it Curare, `to.curare.*` and `CurareDatabase`. The two +group-identity commits are written in the first vocabulary; everything from `808a3459` +on is written in the second. A pull has to read both. + +What the rebrands did **not** touch is the reason a pull is tractable. Their own messages +record it: the twelve `Mantra*` Room entities keep their names because those names are +the SQLite table names; `SharedKeyDerivation.TWEAK_TAG` and +`ChillDkgRitualManager.HOST_KEY_DERIVATION_TAG` keep their `mantra/` prefixes because +they are hash inputs; `Relays.ephemeral` keeps `wss://ephemeral.mantra.press` because it +is a running relay. So on both sides the database is at version 19 with the same nineteen +schema files, every table has the same name, every derived key is the same key, and the +app talks to the same relay. **Nothing in the thirty-nine commits adds a table or a +migration** — three DAO queries and no entity field; after a full build of the pulled tree +Room regenerates nothing and `19.json` is byte-identical to Mantra's. Every feature reads +off `GroupSignedEvent` rows that already existed, or off two new files in the node-data +directory (`nostr-keys.dat`, then `nostr-credentials.dat`). The lightning-kmp-app submodule is pinned to the same commit +on both sides, `84cc44c`, since `f4434ab1` brought Mantra's pin forward. + +## The inventory + +Every commit, grouped by the line of work it belongs to. Sizes are `git show --shortstat` +sums; the brand line's is what a hand-merge would have had to read. + +| line | commits | size | verdict | +|---|---|---|---| +| A. the brand line | `3bc8be53` `d26cf6c7` `e6aee792` `cd3108e9` | 1,593 files, ±10.5k | **not as commits.** Their residue, yes — see [the first decision](#1-the-brand-line-and-what-is-worth-keeping-from-it) | +| B. the group's nostr identity | `4c0ed0c1` `334e6dd1` | 40 files, +5.3k | **pull** | +| C. drop the library, dialects, projects | `808a3459` | 4 files, −228 | **do not pull.** It deletes Mantra's product | +| D. curated lists | `930d37c8` `392b90b8` `1e52fc8f` `fcc19f95` `732a4527` | 86 files, +12.7k | **pull** — the [third decision](#3-curated-lists-are-not-mantras-product-and-mantra-should-take-them-anyway) | +| E. broadcast | `6ae5a967` | 18 files, +2.0k | **pull** | +| F. a back button on every pushed screen | `0634487e` | 50 files, +532 −288 | **pull** — a fix | +| G. nsec sign-in | `daae63d5` `42f3a697` `366b0177` `22061601` `d61d3560` `e3cc23ae` `647ee815` `2326839e` | 84 files, +5.3k | **pull** | +| H. a row per kind of signed work | `55664cc7` | 34 files, +4.1k −1.5k | **pull**, with C dropped underneath it | +| I. npub read-only sign-in | `78807fe9` `dc8a1091` `15766596` `5efeae76` `7db863e3` `bfad1f39` `e31033e8` `a586b7c1` `315e9331` `f866b9d1` | 67 files, +3.4k | **pull** | +| J. library chores | `00c36ec5` `3116eb8a` | 3 files | **pull** `3116eb8a`; `00c36ec5`'s pin move is already Mantra's | +| K. the four merges | `c8de3a1f` `ccbdbf7a` `fedbe724` `86cb876b` | — | replayed *as merges*; two carry hand resolutions | + +### B. The group's own nostr identity + +A Marmot room's id is the pubkey it signs as, so every group has had a nostr identity +since it had a key and no way to say anything about it. `4c0ed0c1` adds the kind 0 and +the four relay lists (NIP-65, NIP-17, NIP-50, NIP-51), each proposed to the quorum like +anything else the group signs; `334e6dd1` adds kind 1 posts in the group's own name. +Both are *readings* of `GroupSignedEvent` rows gated on `verifies` — the room as author, +the id as the hash, the signature real — and both close a hole in `applyInnerEvent` +where a group-signed kind would otherwise render as raw JSON in the transcript. The +second commit's kind 1 arm is the careful one: it verifies and, on failure, falls through +to `unsupported` rather than dropping the event, because a kind 1 is content somebody +meant. + +Mantra wants this without qualification. A translation collective is exactly a group +that needs a public name, a place to be found, and a way to announce a finished chapter +under a key no single member controls. Two commits, no schema, and the tests sign their +fixtures with real FROST quorums. + +### C. Dropping the library, dialects and projects + +`808a3459` removes the three work sections from the group screen and the two repository +reads that fed them. For Curated that was dead weight; for Mantra it is the product. The +question was never whether to take it but what taking the *later* commits costs when it +is left out, because `55664cc7` restructures the same screen on top of it. That was +measured — see [the second decision](#2-mantra-keeps-its-sections-and-git-does-the-work). + +### D. Curated lists + +The curated-list NIP from the bitcoin.mov repository: a group publishes a *schema* +(kind 31889) defining a list anyone may suggest entries to; strangers publish +*suggestions* (31888) against it; the group's quorum accepts one by signing a *canonical +entry* (31890) that points back at the suggestion. `930d37c8` is the schema and its +editor, ported tag for tag with the NIP's own example as the fixture; `392b90b8` is the +untyped way in — an unsigned event pasted as JSON and filed by kind once signed, which +exists so that `npm run schema:dry` in the bitcoin.mov repo can be pasted rather than +retyped; `1e52fc8f` is the queue, every suggestion in reply to a schema, read off the +relays the schema named; `fcc19f95` accepts one into the list, seeded into a form the +schema drives; `732a4527` shows what the group has accepted. All of it lives in a new +`nostr/curated/` package plus screens, and all of it reads the same `GroupSignedEvent` +rows. + +### E. Broadcast + +Until `6ae5a967` nothing a group signed reached a relay: `FrostSigningManager.complete` +filed the rows and the only way out was the copy button on the key-state sheet. This adds +a Broadcast button under every signed event, a screen that shows and edits the relays, +sends, and reports what each answered. It deliberately bypasses the durable +`BroadcastNostrEventRequest` queue, because that queue hangs off a `NostrEvent` row and a +group's event must not become one (the home feed would show it as anybody's, and the +sync loop would offer it to relays the screen never named). Mantra's groups sign +chapters, translations and key states; none of them reach a relay today either. + +### F. A back button on every pushed screen + +Twenty-two pushed screens had no back affordance. Android's system back and iOS's edge +swipe hid it; the desktop target, which Mantra also ships, has neither, so those screens +were dead ends there. One widget, `NavigateBackButton`, replaces four spellings of the +arrow across forty-one sites; four screens with no app bar got one. Eight of the fifty files +are Mantra's translation screens — `AddArtifactScreen`, `AddChapterScreen`, +`AddDialectScreen`, `ChapterDetailScreen`, `TranslationChapterScreen` and the rest — +which Curated kept and fixed. + +### G. nsec sign-in + +Phases 1–6 of `docs/nsec-sign-in.md`, which arrives with the commits. `42f3a697` puts an +`Identity` type in front of the wallet — the nostr key, the x-only pubkey, the +per-identity preferences and an *optional* `PhoenixBusiness` — replacing the expression +`activeWallet?.business?.walletManager?.keyManager?.value?.nostrPrivateKey()` at its ten +sites and `StateFlow` at its twenty-five. `366b0177` lists and starts +identities from both stores. `22061601` turns `SignInToProfileScreen` from the sentence +"Sign in is not yet available while Mantra is in alpha testing" into a screen: one field +that recognises a recovery phrase or an nsec, confirms the npub it will sign as, and +writes the secret before anything else. `d61d3560` fixes something not nsec-specific — the +sign-in sync asked only `ephemeral.mantra.press` for the user's profile, so a key that had +lived on Damus for three years found nothing; it now asks the indexer relays and follows +the user's own kind 10002 one hop. `e3cc23ae` gives an identity with no phrase a recovery +screen for its nsec and the app's first real "forget this key". + +The library half — `NostrKeyManager` when this line was written, `NostrCredentialManager` +by the end of line I — is in lightning-kmp-app `84cc44c`, which Mantra's pin already +carries; those library commits exist to serve these two lines. + +### H. A row per kind of signed work + +`55664cc7` collapses the five identity sections (profile, relays, posts, schemas, entries) +into five rows in the shape of the signing-key row, each opening its own screen, because +five sections of cards had made the group screen as long as everything the group had +ever said. Five routes, screens, view models and states; the broadcast button becomes a +widget. It sits on top of C, and how Mantra takes it without C is the second decision. + +### I. npub read-only sign-in + +Phases 1–6 of `docs/npub-sign-in.md`. `15766596` makes the identity's private key +nullable, with `canSign` as the one place to ask. `5efeae76` replaces `nostr-keys.dat` +with `nostr-credentials.dat` — one typed entry per public key, secret or public, so a +profile signed in read-only and the same profile with its nsec pasted later are one +entry, upgraded in one write — and brings the matching library commit, which is +`84cc44c`, Mantra's current pin. `7db863e3` lists and starts a keyless identity and +builds quartz's read-only `KeyPair` in exactly one place (the trap it guards is +`KeyPair(privKey = null)`, which is quartz's "make me a new key"). `bfad1f39` recognises +an npub in the sign-in field. `e31033e8` is the product half: a `LocalCanSign` +composition local, provided once above the navigation suite, hides every action that +would need a signature — new chat, follow, send message, key packages — rather than +disabling it, and the empty state beside each says why. `a586b7c1` is sign out, for a +read-only identity only, the first real one in the app. + +### J. Library chores + +`3116eb8a` adds `branch = master` to `.gitmodules` so `git submodule update --remote` +follows the library's default branch; take it. `00c36ec5` moved Curated's pin to +`84cc44c`; Mantra is already there. + +## Three decisions + +### 1. The brand line, and what is worth keeping from it + +None of the four commits lands as itself. But run through the name-rewrite described +below, the two rebrand commits collapse from 744 and 765 files to sixteen and +twenty-one, and what is left is not brand at all — it is the *reasoning the rebrands +added about the names they refused to change*: a comment on `TWEAK_TAG`, one on +`HOST_KEY_DERIVATION_TAG`, one on `Relays.ephemeral`, a paragraph in +`docs/shared-key-derivation.md`, and the relocation of a stale `press/auxiliary` +template test into the real package. Mantra should have those comments; the next person +to touch a `mantra/` string in Mantra is as likely to "finish the rename" as anyone in +Curated was. Take them as **one squashed commit with a new message** ("docs: say why the +two hash tags and the relay host must never be renamed") and rewrite the sentences that +describe rebrands Mantra never had — they are listed under +[Risks](#risks-and-the-silent-ones-in-particular). + +`d26cf6c7`, the Torch retirement, is a different case. Mantra is still Torch in four +places: `TorchTheme` (116 references), `UserAgent.APP_NAME` and `CLIENT_NAME` (the +`client` tag on every relay list this app publishes), two user-facing error strings in +Kotlin ("tell X to use Torch", "finished setting up Torch"), and iOS, where +`Config.xcconfig` still builds `PRODUCT_NAME=Torch` under the bundle id +`ac.aux.compose.Aux` — two brands ago. That is Mantra's own debt and Phase 0 pays it +natively, as `MantraTheme` and "Mantra", so that Curated's `CurareTheme` maps onto a name +that means something rather than onto the brand before last. + +The logo commit is Curated's icon. Mantra keeps its own. + +### 2. Mantra keeps its sections, and git does the work + +Dropping `808a3459` was measured rather than reasoned about. With it removed from the +replay, `930d37c8` and `1e52fc8f` merged into `ChatRoomDetailScreen` without conflict +(the schema section is inserted where the library section is removed, different hunks), +and `55664cc7`'s rewrite of the screen merged without conflict too. The screen that +results reads, top to bottom: signing key, then the five rows, then Propose event, a +divider, **Library, Dialects, Projects**, then Subgroups — `ChatRoomDetailUIState.Loaded` +still carries `artifacts` and `dialects`, and `mantraRepository` is still passed. It +compiles for both targets and passes all 1,036 jvm tests, including +`GroupSignedWorkRowsJvmTest`, which asserts only that Subgroups sits *below* the identity +block and does not mind three sections in between. + +The one conflict it produces is a modify/delete on +`GroupNostrProfileSectionJvmTest.kt`: `808a3459` had re-anchored that test from the +"Library" heading to "Subgroups", and `55664cc7` deletes the file in favour of +`GroupSignedWorkFixtures` and six new tests. Resolution: delete, as `55664cc7` does. + +A follow-up Mantra may want, and should write natively rather than pull: the three +sections in the same row shape as the five above them, so the screen is one idiom. That +is a small change against a settled layout, not a merge problem. + +### 3. Curated lists are not Mantra's product, and Mantra should take them anyway + +This is the only real product question in the pull, so the reasoning is set out in full. + +*Against:* kinds 31888–31890 are Curated's reason to exist. A translation collective has +no obvious list to curate, and two rows — "Curated schemas", "Curated entries" — appear +on every group screen. + +*For, on cost:* excluding the five commits is not deleting five commits. `392b90b8` +(paste) routes kind 31889 alongside 0, 1 and 10002; `6ae5a967` (broadcast) seeds an +entry's relays from its schema; `55664cc7` (rows) builds five rows of which two are +these; `0634487e` (back button) touches the suggestion screens; and 165 of the 306 strings +added since the fork are theirs, interleaved with the rest. Carving them out means editing five +other commits by hand and *then* owning a group-screen layout that differs from +Curated's, so that every future pull conflicts in the same place again. Including them +costs no schema, no migration, no query on a screen that does not open them, and a +self-contained package under `nostr/curated/` whose tests are the NIP's own vectors. + +*For, on principle:* the whole approach below rests on Mantra's tree being a superset of +Curated's non-brand tree, because that is what makes the *next* pull a replay rather than +a negotiation. Every feature left out is a permanent conflict seam. + +*So:* pull them. If product decides the two rows should not show on Mantra, hide the two +`SignedWorkRow` calls behind a constant — a two-line, reversible change against a tree +that is otherwise identical to upstream — rather than surgery on five commits. + +## How it lands: rewrite the names, then let git merge + +The principle is that **a Curated commit written in Mantra's vocabulary applies to Mantra +as if it had been written there**, because Mantra has not moved since the fork. So the +work is not merging; it is translating, once, mechanically, and then replaying history +with git's three-way merge doing what it is good at. + +Three approaches were considered. Merging `curated/curated` fast-forwards into Curare, +above. Cherry-picking the original commits and fixing up names is hopeless: two 700-file +renames mean every patch's paths and every `import` line in its context are wrong, so +every file of every commit conflicts. The third — rewrite the *history* into Mantra's +names and replay it — is what was measured, and it works. + +### The normaliser + +[`docs/scripts/curated-unbrand.py`](./scripts/curated-unbrand.py) maps both brand +generations onto Mantra, in a tree (for `filter-branch`) or in a patch file. Its rules +are a table of brand tokens: `to.curare.` and `com.it.curated.` to `press.mantra.`; the +generated-resources package; the app-level identifiers (`CurareDatabase` and +`CuratedDatabase` to `MantraDatabase`, the nav host, the navigation suite, the global, +the application, the converters, the shapes, the desktop dir); the theme; the brand +string key and its value; `UserAgent`; `APP_DIR_NAME`; `rootProject.name`; the Android +`applicationId` and `namespace`; the iOS product name and bundle id; and finally the bare +words `Curare` and `curare` in prose. + +Three things it got wrong on the way to being right, each of which will bite anyone who +rewrites it: + +- **`\b` does not fire inside snake_case.** `_` is a word character, so + `\bcurare\b` misses `sign_in_is_not_yet_available_while_curare_is` and + `curare_will_delete_the_nsec_for_s_from_this`. String keys carry the brand, and need + look-around rules that treat `_` as a boundary. +- **File names carry identifiers too.** `CurareNavHost.kt` must become `MantraNavHost.kt`, + not just its contents; a path rule that only moves directories leaves + `MantraNavHost.kt` and `CurareNavHost.kt` side by side with identical content. +- **"Curated" is two words.** In generation 1 it was the brand; at the tip it is the + protocol's name — `CuratedSchemaEvent` (158 uses), `CuratedEntryEvent`, the + `nostr/curated/` package, and a `curated` string that marks a queue row the group has + taken up. The bare word is therefore *not* a rule. Only the generation-1 *identifiers* + are mapped, and that is exact because the two commits written in that generation never + use `Res.string.curated` or the bare word as a brand in code. + +What it cannot touch, by construction: every rule matches a brand token and none matches +a Mantra one, so the twelve `Mantra*` entities, the two `mantra/` prefixes and +`ephemeral.mantra.press` pass through untouched. The check is that the count of +`Mantra*` entity tokens does not go down across the rewrite — 433 in Mantra, 435 in the +dry run's tree, two new references and none lost — plus the fact that the rewritten tip +compiles, which no missed identifier would survive. + +### The pipeline + +```bash +git fetch curated +git branch tmp/curated-src curated/curated +git filter-branch -f -d /tmp/fb --tree-filter 'python3 /abs/path/to/docs/scripts/curated-unbrand.py tree .' \ + -- origin/mantra..tmp/curated-src # 84 s for 39 commits +``` + +Now `tmp/curated-src` is Curated's history in Mantra's names. Its first commit, the +rebrand, is sixteen files of comments; its features are Mantra commits that never +happened. Replay them, recreating the merges and dropping the two commits Mantra does not +want: + +```bash +git branch curated-pull tmp/curated-src # the branch that gets rewritten; tmp/curated-src stays as the reference +LOGO=$(git log --format=%h origin/mantra..tmp/curated-src --grep='Update logo' | tail -1) +SECTIONS=$(git log --format=%h origin/mantra..tmp/curated-src --grep='drop the library, dialects' | tail -1) +GIT_SEQUENCE_EDITOR="sed -i -e '/^pick ${LOGO:0:7}/d' -e '/^pick ${SECTIONS:0:7}/d'" \ + git rebase -i --rebase-merges --onto origin/mantra origin/mantra curated-pull +``` + +`--rebase-merges` matters: the two merges that carried hand resolutions are re-performed +rather than flattened, so their resolutions are re-asked at the same points instead of +silently lost. + +### What the dry run found + +| | drop the logo only | drop the logo and `808a3459` (**recommended**) | +|---|---|---| +| commits replayed | 38 | 37 | +| commits that conflicted | 2 | 3 | +| where | `strings.xml` at the recreated `c8de3a1f`; `CuratedSuggestionListScreen.kt` at the recreated `fedbe724` | the same two, plus `GroupNostrProfileSectionJvmTest.kt` at `55664cc7` (modify/delete) | +| files the original merges edited outside their conflicts | 2 | 2 | +| `git diff tmp/curated-src HEAD` afterwards | exactly the logo's 13 files | the logo, plus the three files `808a3459` touched — the screen, its view model, its UI state | +| `compileDebugKotlinAndroid`, `compileKotlinJvm` | clean | clean | +| `:composeApp:jvmTest` | 1,036 tests, 0 failures | 1,036 tests, 0 failures | +| `:composeApp:testDebugUnitTest` | 530 tests, 0 failures | 530 tests, 0 failures | +| `:composeApp:m3Audit` | all budgets met | all budgets met | + +Mantra's own suite is 733 jvm tests (736 with `39fb64b6`); the other three hundred came +with the features. + +The three conflicts and their resolutions: + +| at | file | why | resolution | +|---|---|---|---| +| `c8de3a1f'` | `strings.xml` | two branches appended strings at the end of the file | keep both blocks | +| `fedbe724'` | `CuratedSuggestionListScreen.kt` | the back-button sweep and the accept flow both edited the sheet | take the original merge's file: `git show :` | +| `55664cc7'` | `GroupNostrProfileSectionJvmTest.kt` | `808a3459` (dropped) modified it; `55664cc7` deletes it | delete | + +And the two files whose changes lived only *inside* the original merge commits — edits a +merge made beyond resolving its conflicts, which a recreated merge that does not conflict +will never reproduce: + +```bash +git checkout tmp/curated-src -- \ + composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreen.kt \ + composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt +git commit -m 'reconcile: the two files the original merges edited outside their conflicts' +``` + +Then the check that makes the whole thing honest: + +```bash +git diff --stat tmp/curated-src HEAD # must be exactly: the logo's 13 files + the three files 808a3459 touched +``` + +Anything else in that diff is a normaliser rule that is wrong or a resolution that is, +and it is found here rather than in production. + +## The phases + +Each phase ends the same way: `:composeApp:compileDebugKotlinAndroid`, +`:composeApp:jvmTest`, `:composeApp:testDebugUnitTest` and `:composeApp:m3Audit` all +green on the branch, and one review of the diff against the phase's stated contents. +The phases are cuts through the graph above — each one replays every commit reachable +from its cut that the previous phase did not — so the ranges are `--onto + ` and no commit is replayed twice. + +### Phase 0 — Mantra prepares, natively + +Nothing from Curated lands. Three things Mantra does to itself so the pull is clean: + +1. **Merge the update branch** (`f4434ab1`, `39fb64b6`): the library pin at `84cc44c` + that the identity work needs, and the removal of the app's duplicate + `nostrPublicKey()`. +2. **Retire Torch.** `TorchTheme` → `MantraTheme`; `UserAgent.APP_NAME` and + `CLIENT_NAME` → `"Mantra"`; the two error strings; `Config.xcconfig` to + `PRODUCT_NAME=Mantra` and `press.mantra.ios`, with the pbxproj's product references. + Keep the three Torch *records* the rebrands kept: + `material-design-conformance.md:278` and `:654`, and `NavigationRoutingTest`'s + fixture. Then change the normaliser's theme and `UserAgent` rules — the ones marked in + the script — so `CurareTheme`/`CuratedTheme` map to `MantraTheme` and the agent to + `"Mantra"`. +3. **Take the protective comments** — or leave this for Phase 2, where the rewritten + rebrand commits carry them. Either way the commit is squashed and re-messaged, the + sentences under [Risks](#risks-and-the-silent-ones-in-particular) are rewritten by + hand, and the rewritten `d26cf6c7` is dropped in Phase 2 because item 2 has done its + work. + +Exit: 736 jvm tests, audit green, iOS config no longer says Torch. + +### Phase 1 — The dry run + +The pipeline above, end to end, on a scratch worktree, landing nothing. Its output is the +table in the previous section, recomputed against the day's `curated/curated`. If a +number moved, find out why before Phase 2: a new conflict means a new upstream commit +touched a seam; a non-empty exactness diff means a new brand token the normaliser does +not know. Delete the worktree and the `tmp/` branches when done; the script recreates +them in under two minutes. + +**Run on 2026-09-13, after Phase 0, and it changed the plan in three places.** The +record, so the next pull starts from what happened rather than from what was planned: + +*The upstream moved first.* `curated/curated` was `86cb876b` when the plan was written +and `29027f2b` when Phase 1 ran: ten more commits, a "multiple profiles on a device" +line, one of which (`759199f2`) adds schema version 20 — the fork's first migration. +They are outside this plan's scope and are the next pull; the schema change is why +they get a decision of their own rather than a ride on this one. + +*Landing is by cherry-pick in topological order, not by `rebase --rebase-merges`.* The +phased cuts cannot be expressed as rebases: a recreated merge whose other parent was +replayed in an earlier phase would be pointed at the *pre*-replay commit, dragging the +unrebased lineage in beside the rebased one. So the ordinary commits are picked one by +one, the four merge commits land as nothing, and their content — the resolutions — is +folded in where the conflicts actually surface: at each branch join the join files +(`strings.xml`, `MantraNavHost.kt`, `CuratedSuggestionListScreen.kt`) are taken exactly +as upstream's own merge left them, and the two files the merges edited outside their +conflicts are folded into the back-button commit. Every landed commit carries a +`Pulled-From: curated/curated@` trailer, stamped by the rewrite, and a paragraph +naming any resolution made on the way in. The driver is +[`docs/scripts/curated-replay.py`](./scripts/curated-replay.py). + +*The library pin goes backwards for Phases 3 and 4, on purpose.* The nsec line was +written against lightning-kmp-app `59c11ed`, whose `NostrKeyManager` it writes through; +`84cc44c` — Mantra's pin — renamed that class to a read-only `LegacyNostrKeysFile`, and +the compiler says so at the Phase 3 cut (`Unresolved reference 'NostrKeyManager'` in +`IdentityWriter.kt`, eight errors). So `366b0177` moves the pin back to `59c11ed`, whose +nested chain is identical, and `5efeae76` brings it forward again — to `84cc44c` rather +than the `01962f3` it named upstream, which is an unfetchable branch commit whose tree +`84cc44c` reproduces exactly. Every commit on the branch compiles against the pin it +records, which is the property upstream's history had and a fixed pin would have lost. + +*Three ways a "keep both sides" resolution is wrong,* each caught by the exactness +check and each now handled: it duplicates lines both sides already hold when a hunk +widens; it never applies the other side's deletions (the copy-suggestion strings +`fcc19f95` removes survived one attempt); and, subtlest, a join resolved in a different +*order* from upstream's merge leaves later commits unable to find the block they edit, +so their deletions silently miss. Hence: conflicts are picked with `diff3` markers and +resolved as a line-set three-way merge for files whose lines are unique by +construction, and never at a join, where upstream's merge is the answer. + +The numbers, on the trial worktree: 30 commits replayed (4, 8, 6, 12), 0 stuck, 9 +carrying a resolution note; residual diff against the rewritten `86cb876b` exactly +the known set (Phase 0's prose, `39fb64b6`'s files, `808a3459`'s three, this document +and its scripts); `compileDebugKotlinAndroid` and `compileKotlinJvm` clean; +`jvmTest` 1,039 tests, `testDebugUnitTest` 530, both 0 failures; `m3Audit` all budgets +met. + +### Phase 2 — The group's identity, and the lists' read side + +Cut at `392b90b8'`: `4c0ed0c1`, `334e6dd1`, `930d37c8`, `392b90b8`, with `808a3459` +dropped. The three rewritten brand commits are ancestors of this cut too: if Phase 0 took +the protective comments and retired Torch natively, **drop all three** — their whole +content is already on Mantra and replaying them re-applies it; if Phase 0 skipped item 3, +squash `3bc8be53'` and `e6aee792'` into the one re-messaged comments commit and drop +`d26cf6c7'`. Four feature commits either way, no conflicts in the dry run. + +What to look at in review: the `applyInnerEvent` arms (the kind 1 arm must fall through +to `unsupported`, not return null); that `GROUP_IDENTITY_TYPES` renders as a +`RitualNotice` and not a bubble; that `CuratedSchemaEventTest` still parses the NIP's +example verbatim under Mantra's package. + +Exit: group screen shows profile, relays, posts and schemas as sections above Library; +tests green. + +### Phase 3 — Sign-in with a recovery phrase or an nsec + +Cut at `2326839e'`: the eight commits of line G, including the plan and its record. The +`Identity` refactor touches eighteen files' signatures; the dry run applied it without +conflict, but on top of Phase 0's `39fb64b6` expect two trivial ones — `42f3a697` removes +the `press.mantra.compose.extensions.nostrPublicKey` import from `RelaysSocketManager` +and `NavigationViewModel`, and `39fb64b6` had already replaced that line with the +library's import. Resolution: delete the line; the call it served is gone too. + +What to look at: `SignInToProfileViewModel.commit` writes the secret before it does +anything else; `SignInSync`'s bootstrap set is the indexer relays plus +`ephemeral.mantra.press`, not `ephemeral` alone; `NsecRestoreRoundTripJvmTest` asserts no +node ran. + +Exit: a pasted nsec signs in on desktop and Android; `NostrSecretScreen` reveals and +forgets it. + +### Phase 4 — The queue, broadcast, accepting, the back button, the rows + +Cut at `55664cc7'`: `1e52fc8f`, the recreated `c8de3a1f`, `6ae5a967`, `fcc19f95`, +`732a4527`, `0634487e`, the recreated `ccbdbf7a` and `fedbe724`, `55664cc7`. All three +conflicts and both reconcile files live here; the table above is the runbook. + +What to look at: the group screen's order (signing key, five rows, Propose event, +Library, Dialects, Projects, Subgroups); every screen reached by `navigate(...)` has a +back button on desktop; `BroadcastGroupSignedEventScreen` sends through +`EventPublishTransport` and not the broadcast queue; `defaultRelaysFor` an entry is its +schema's relays. + +Exit: the exactness diff is the logo plus the three files `808a3459` touched; audit +budgets met, as in the dry run. + +### Phase 5 — Read-only identities + +Cut at `86cb876b'`: the ten commits of line I, `3116eb8a`, and the final merge. No +conflicts in the dry run. `00c36ec5`'s pin change is a no-op against Mantra's pin and +can be dropped or left; dropped is cleaner. + +What to look at: `Identity.toKeyPair` is the only place a quartz `KeyPair` is built from +an identity; `ProvideSigningCapability` sits once above the navigation suite; the +migration from `nostr-keys.dat` runs before the first listing and leaves an unreadable +old file in place with an error rather than silently losing identities. + +Exit: an npub signs in read-only, every signing action is hidden with a reason beside +it, sign out removes it and re-lists. + +### Phase 6 — Close the loop + +Two things flow the other way, and one decision closes the reason this document exists. + +**Reverse-pull `39fb64b6`.** Curated still carries the app-side `WalletManagerExtension.kt` +and one caller (`CreateProfileViewModel`), while its own identity code already uses the +library's `nostrPublicKeyHex()`. The same normaliser runs backwards with its rules +inverted, or — for one small commit — it is a hand cherry-pick. + +**Reverse-pull Phase 0's Torch retirement**, so both trees agree that `CurareTheme` and +`MantraTheme` are the same thing under two names. That, too, is one commit. + +**Then decide what the fork is.** Every future Curated commit will be written in +`to.curare.*`, and every pull will need this pipeline again. With the tooling in place +the machine work is minutes and the conflicts are the three above; the cost is the dry +run, the review and the prose — call it half a day per pull, an estimate. That is +affordable once and corrosive weekly. The options, in the order they should be taken: + +1. *Converge the package name.* A Kotlin package is not a brand — the rebrands' own + messages say the app's domain is still `Mantra*` everywhere that matters — and if + Curated moved its source back to `press.mantra.*` while keeping `to.curare` as its + `applicationId`, `Curare` as its `rootProject.name` and `CurareTheme` as its theme, + the diff between the two trees would shrink to a handful of files and every pull in + either direction would be a plain cherry-pick. It costs Curated one more rename, its + fourth, and this time the last. +2. *Invert the relationship.* With the packages converged, Mantra is the natural + upstream: Curated becomes Mantra plus a short brand-and-product patch series (the + applicationId, the theme colours, the icon, the section removal, `APP_DIR_NAME`) + rebased on top of Mantra's main. Features are written once, on Mantra, and Curated + takes them by rebasing. This is what the current fork already *is* in content; it is + only the history that says otherwise. +3. *Keep pulling.* If neither is wanted, keep `curated-unbrand.py` maintained beside + the code it maps, redo Phase 1 before every pull, and treat a non-empty exactness diff + as a bug in the script. + +## Risks, and the silent ones in particular + +**The five names.** `Mantra*` entities, the two `mantra/` prefixes, the relay host, and +— on Mantra's side — nothing more. The normaliser cannot touch them; a hand edit can. The +check is `git grep -c 'mantra/' composeApp/src` and the entity token count before and +after, and `:composeApp:jvmTest`, which is what catches a renamed table. + +**Prose that lies after a rename.** A blanket rewrite turns "survived two rebrands — +Mantra to Curated, Curated to Curare" into a sentence about rebrands Mantra never had. +Eight places, ten lines, all in the rewritten brand commits or the docs, all needing a +human sentence: +`Relays.kt:9`, `SharedKeyDerivation.kt:65`, `ChillDkgRitualManager.kt:95`, +`PlatformContext.jvm.kt:48–56` (a whole block about `APP_DIR_NAME` keeping the old +brand, which is false for Mantra and should go), `HomeScreen.kt:167`, +`docs/shared-key-derivation.md:68–69`, `docs/material-design-conformance.md:656–657`, +and the `strings.xml` header comment. Rewrite them in Phase 0 or Phase 2's squash, not +later. + +**`strings.xml` grows from 377 to 670 strings** and conflicts by appending at every +branch join. Keep both blocks. Do not rename the `curated` string: it is the queue's +"Curated" mark, product vocabulary, and the normaliser leaves it alone on purpose. + +**Merges that did more than resolve.** Two files; the reconcile step; the exactness diff +catches any third. + +**Test anchoring.** `GroupSignedWorkRowsJvmTest` asserts Subgroups is below the identity +block. It is, with three sections between. `GroupNostrProfileSectionJvmTest` is deleted +upstream and stays deleted. + +**iOS is unverified on every side of this.** The rebrands say so, the dry run ran on +linux, and Phase 0's `Config.xcconfig` change is the first time this repo will have named +itself there. Build it on a Mac before believing it. + +**Data on disk moves nowhere.** Desktop `APP_DIR_NAME` stays `mantra`; the new +`nostr-credentials.dat` sits beside `seed.dat` in the node-data directory; the migration +from `nostr-keys.dat` is idempotent and refuses to delete what it could not read. An +existing Mantra install sees new files and no moved ones. + +**The upstream moves.** Between this repository's previous fetch of `curated/curated` +(`cd3108e9`, 2026-09-10) and the one this plan was written against (`86cb876b`, +2026-09-12) it gained thirty-three commits. Redo Phase 1 the day you land; the numbers +drift, the method does not. + +## Appendix: the dry run, for the record + +Run on 2026-09-13 against `curated/curated` at `86cb876b`, on a scratch worktree of +`origin/mantra` at `ba26c0b1`, with `lightning-kmp-app` at `84cc44c` symlinked in from a +populated checkout. + +| measurement | value | +|---|---| +| normaliser at the tip | 812 files rewritten, 806 paths moved | +| rewritten `3bc8be53` (rebrand 1) | 16 files, +61 −38 | +| rewritten `d26cf6c7` (Torch retirement) | 5 files, +16 −10 | +| rewritten `e6aee792` (rebrand 2) | 21 files, +85 −63 | +| rebrand 2 after normalising both sides | only sentences that describe the rebrand itself | +| `filter-branch` over 39 commits | 84 s | +| replay, logo dropped | 38 commits, 2 conflicts, 2 reconcile files, exactness = logo | +| replay, logo and `808a3459` dropped | 37 commits, 3 conflicts, 2 reconcile files, exactness = logo + the 3 files `808a3459` touched | +| `compileDebugKotlinAndroid` + `compileKotlinJvm` | clean, both variants | +| `jvmTest` | 1,036 / 0 failures, both variants (Mantra alone: 733) | +| `testDebugUnitTest` | 530 / 0 failures | +| `m3Audit` | all budgets met; 12 adaptive uses, 2 navigation components | +| Room schemas found at `press.mantra.compose.database.MantraDatabase/` | 19 | +| `Mantra*` entity tokens (`MantraArtifact|Chapter|Chunk|Dialect|Translation*|Dao|Repository`), before and after | 433 → 435; none lost | diff --git a/docs/material-design-conformance.md b/docs/material-design-conformance.md index b8709b3a..13301d2d 100644 --- a/docs/material-design-conformance.md +++ b/docs/material-design-conformance.md @@ -121,7 +121,7 @@ Three gaps: today; the trap springs the first time an expressive component does. 2. **The medium and high contrast schemes are dead code.** All four are declared - `private val`; `TorchTheme` only ever selects `darkScheme` or `lightScheme`. + `private val`; `MantraTheme` only ever selects `darkScheme` or `lightScheme`. There is no plumbing to the platform's contrast setting, so the two schemes that would honour it are unreachable. The accessibility foundation's first principle is *honour individuals* — *"supporting varying preferences and @@ -138,7 +138,7 @@ Two entry points render outside the theme: under the **default** `MaterialTheme`. Its `MaterialTheme.colorScheme.error` and `typography.headlineSmall` are baseline M3, not this app's. It is the first screen a desktop user sees. -- `Profile.kt:183` and 50 other `@Preview` bodies wrap in `TorchTheme` by hand, +- `Profile.kt:183` and 50 other `@Preview` bodies wrap in `MantraTheme` by hand, which is correct, but there is no preview that exercises dark, high-contrast or a wide window — so nothing catches the two problems below. @@ -418,7 +418,7 @@ find-and-replace. Keep the existing hex values for the roles already defined so nothing shifts visually; this phase adds, it does not restyle. -2. **Reach the contrast schemes.** *Built.* `TorchTheme` takes a `ThemeContrast` +2. **Reach the contrast schemes.** *Built.* `MantraTheme` takes a `ThemeContrast` defaulted from a new `platformThemeContrast()` expect/actual, and the six-way selection table lives in common code as `appColorScheme`. The old `themeColorScheme` expect took four arguments and did both jobs; it splits into @@ -441,7 +441,7 @@ find-and-replace. 3. **Give the theme its other three slots.** *Built.* The expressive-vs-standard question was put to the product owner on 2026-09-08 and answered - **expressive**, so `TorchTheme` calls `MaterialExpressiveTheme` with all four + **expressive**, so `MantraTheme` calls `MaterialExpressiveTheme` with all four slots passed explicitly — colour, `MotionScheme.expressive()`, `MantraShapes`, `AuxTypography`. Explicitly, because `MaterialExpressiveTheme` would otherwise default them to `expressiveLightColorScheme()` and friends, which is Material's @@ -472,10 +472,10 @@ find-and-replace. `container`/`on`/`onContainer`. `CreateProfileScreen.kt:311` and `:329` then stop pairing them with `Color.White`/`Color.DarkGray` by eye. -6. **Wrap the desktop gate.** *Built.* `Main.kt` moves `TorchTheme` outside the +6. **Wrap the desktop gate.** *Built.* `Main.kt` moves `MantraTheme` outside the `unlocked` branch, so `PassphraseGate` — the first screen a desktop user sees — stops composing under the default `MaterialTheme`. `MantraApp` keeps its own - `TorchTheme`, so the unlocked branch is wrapped twice: android and ios enter + `MantraTheme`, so the unlocked branch is wrapped twice: android and ios enter through `MantraApp` and would lose the theme entirely if it moved out, and a second application of the same values costs one `CompositionLocalProvider`. @@ -651,8 +651,12 @@ the whole sentence is, and several of them are pluralisations — `(if (n == 2) "event" else "events")` — which want a real plural resource rather than a format argument. `m3-extract-formatted.py --remaining` lists them. -`UserAgent.APP_NAME` still says "Torch". It goes on the wire to relay operators, so it is -a network identity question rather than a content one. +`UserAgent.APP_NAME` and `CLIENT_NAME` said "Torch" until the pull described in +[curated-to-mantra.md](./curated-to-mantra.md) retired the brand for good, in its Phase 0. +They were left for last on purpose: `CLIENT_NAME` goes on the wire as the `client` tag on +every relay list this app publishes, so it was a network identity question rather than a +content one. It could move because it is attribution and nothing derives from it -- unlike +the two `mantra/` hash tags, which are inputs to key derivation and must never move. --- @@ -692,7 +696,10 @@ let the component apply the 38% state layer. No `FloatingActionButton` overload material3 1.10 takes `enabled` — the spec's position is that an unavailable FAB should not appear at all — so hand-computing the colours is the only way to show one, and the existing code already pairs it with `Modifier.semantics { disabled() }` so a screen reader does not -announce a button it is happy to press. Left alone. +announce a button it is happy to press. Left alone at the time; since done once, in +`ExtendedFab`, which every screen's bottom-bar action now goes through and which also +puts M3's own 12dp between the icon and the label — the content overload the screens were +using put nothing there. **Left for a person.** Eight more screens have two or more filled buttons competing: LandingScreen's "Sign in" beside "Create profile", SocialPreconditionScreen's "Invite a @@ -729,13 +736,13 @@ panes last because both were product decisions. see below. 2. **`Breakpoint`**, the five-value enum with `ofWidth` as a pure function so the - thresholds are assertable without a Compose runtime. `TorchTheme` classifies + thresholds are assertable without a Compose runtime. `MantraTheme` classifies once and provides `LocalBreakpoint`, so no two screens can disagree about the window they are both in. It reads `currentWindowDpSize()` rather than `currentWindowAdaptiveInfo()`, because the latter also computes a `Posture` from the platform's fold state — - on android, `WindowInfoTracker` and an activity. This call sits in `TorchTheme`, + on android, `WindowInfoTracker` and an activity. This call sits in `MantraTheme`, which wraps all 51 `@Preview` bodies in the tree, and a preview context is not an activity. @@ -950,9 +957,9 @@ reduced. And a crossfade is told from a cut by the one frame in which *both* states are composed. **A test found a design flaw rather than a bug.** The reduced case read 54dp of -slide at first, because the test provided `LocalReducedMotion` around `TorchTheme` +slide at first, because the test provided `LocalReducedMotion` around `MantraTheme` and the theme overwrote it. The fix is not in the test: `reducedMotion` is now a -`TorchTheme` parameter defaulted to the platform, exactly as `contrast` is, +`MantraTheme` parameter defaulted to the platform, exactly as `contrast` is, because a value nothing can override is a value nothing can test — and because the desktop actual is a hardcoded `false` that a settings screen will need to override anyway. @@ -1017,7 +1024,7 @@ finding that out is most of what it was worth. High contrast is deliberately not in the annotation. Contrast is a property of the scheme rather than of a screen, `ColorSchemeContrastTest` measures every pair in all six, and there is no `@Preview` parameter for it — it needs - `TorchTheme(contrast = …)` in the body. `ThemeGallery` covers the six once, + `MantraTheme(contrast = …)` in the body. `ThemeGallery` covers the six once, over components rather than screens, with `dynamicColor = false` because an android 12+ preview would otherwise paint all six columns from the wallpaper. It is the only place the medium and high contrast schemes can be seen at all. @@ -1104,7 +1111,7 @@ Phases 1–5 can be worked in parallel by different people if 1 lands first; `ExperimentalMaterial3ExpressiveApi` opt-ins and the pinned alpha both point expressive, but it is a product decision with a visible outcome, and it should be made deliberately rather than inherited from an import. -- **Whether dynamic colour should keep overriding the brand.** `TorchTheme` defaults +- **Whether dynamic colour should keep overriding the brand.** `MantraTheme` defaults `dynamicColor = true`, and on Android 12+ that wins unconditionally — so on essentially every current Android device, none of the six schemes below is used and the app renders in whatever the user's wallpaper produced. Verified on an API diff --git a/docs/multiple-profiles.md b/docs/multiple-profiles.md new file mode 100644 index 00000000..27c9705d --- /dev/null +++ b/docs/multiple-profiles.md @@ -0,0 +1,1225 @@ +# More than one profile on a device + +How a user holds several profiles on one device, moves between them, and adds +another by signing in or by creating one — why a profile is a credential and a +wallet is a thing attached to one, and why the switch has to be a restart of the +signed-in app rather than a swap underneath it. + +Read this after [nsec-sign-in.md](./nsec-sign-in.md) and +[npub-sign-in.md](./npub-sign-in.md). It takes the `Identity` type, the two key +stores, the sign-in screen and the forget sequence as given, changes one rule the +two of them share, and asks the question both deferred without naming it: what +happens when the device holds two of them. + +**Built**, phases 1–8, one commit each, in the order given; Phase 9 is the rollout +and is process rather than code. The phases are kept as written because they are +the reasoning, and the code reads better against the argument it came from than +against a summary of itself. Where the implementation chose differently the table +below says so: + +| what the plan said | what it turned out to be | +|---|---| +| the repair runs "before the credentials file is read for the listing" | after both files are read, and the listing is built from what it returns — the repaired map, or the one read if the write failed — so the file is decrypted once | +| `NotACredential` becomes `WalletAttached` for a key a seed derives | and for a key not in the file at all, which since the repair can only be a seed's key the repair could not write; one outcome, two ways in | +| `stopPlatformBusiness` called from `switchToIdentity` | injected into the view model as a function, defaulting to the `expect`, so the branch can be pinned with a recorder — a node cannot be started in a test, but a `PhoenixBusiness` can be built, since everything in it is lazy | +| the relay observer restructured | as planned, plus an injectable scope and a `relayUrls` read for the test that fails against the old observer | +| `setActiveIdentity` saves the default | wrapped: a default that could not be written is a selector on the next boot, not a crash now. The forget tails call `forgetDefaultIdentity()` on the view model rather than `clearDefaultWallet()` inline | +| "Initializing…" among the four that become *opening profile* | it and "Preparing wallet…" both became *preparing profiles*; they are the same wait | +| `ProfilesViewModel` "owns the four states" | takes the four flows it joins rather than the view model that holds them, so a compose test can drive it with `MutableStateFlow`s and a fake repository; the fourth state, empty, is not modelled, as the plan said it could not happen | +| a row's fallback "the npub and the metadata's emoji" | the npub as the title over the emoji avatar, with no second line; a key with a kind 0 gets the name over the picture with the npub under it | +| "`createAccount` already takes its writer as a function" | a `NewProfileWriter` interface returning the key it derived and the id it filed it under, with `seedProfileWriter` and `bareKeyProfileWriter` on the view model and `CreateProfileRoute(withWallet)` choosing; the view model plants the six events *after* the write and calls the tail after that, so the account is in the database before the identity is activated — an ordering the create flow used to get away with by luck | +| `CredentialProblem.AlreadyOnThisDevice` "carries it" | the id rides on `Outcome.Failed` and the `Error` state instead; `CredentialProblem` stays an enum and the parser's tests stay as they were | +| the DAO's own requests "stamped from the `activeKeyPair` it is already handed" | left unowned, on purpose: the placeholder-profile syncs and a Marmot join's participant syncs fetch public kinds that need no key to open, and any profile that is open may as well fetch them. Only what goes through the repository is stamped | +| `openGiftWrap` lifted out of `indexNostrEvent` | not lifted: the branch is a screenful of room and participant handling, and running the stored wrap through `indexNostrEvent` again, in a transaction of its own per wrap, gives the same result with one code path. `reopenGiftWrap` is that | +| `ForgetIdentityJvmTest` gains "forgetting B while A remains" | the round trip covers it, forgetting B and C and listing A alone; the per-outcome tests were done in Phase 1 | +| the round trip "creates A from a phrase" | through the create view model with the seed writer, as Landing does, rather than through a pasted phrase; and A's node is a `PhoenixBusiness` that was never started, which is enough for the switch to have something to stop | + +Three things found on the way that are in no phase. `DataStoreManager` caches one +`GlobalPrefs` per process behind an unsynchronised check-then-set, and DataStore +refuses a second instance over the same file: two first calls at once — the listing +on IO and a sign-in's write — could both build one, and the loser threw at first +use. `JvmGlobalPrefs` is now the one way the jvm target reaches the prefs, under a +lock; Android goes through the `Application`'s single instance and never raced. The +same cache means a test that constructs `SovereignWalletViewModel` must not build a +`GlobalPrefs` of its own. And `GiftWrapSeal.giftWrapMessageId`, a foreign key to the +wrap a seal came out of, existed and was never written — so nothing in the database +could say which wraps had been opened, which is the question the inbox sweep asks; +`decryptGiftWrapSeal` now sets it, and a seal from before is swept once and comes +back with the link. + +## The vocabulary + +The user asks for "accounts". The code says *identity* (`Identity`, +`StoredIdentity`, `activeIdentity`), the storage layer says *wallet* +(`WalletId`, `WalletsSelector`, `select_a_wallet`, "Opening wallet"), and every +string a user reads says *profile* ("Create profile", "Sign out of this +profile?", "This profile is read only"). Three words, and they are not three +names for one thing. + +**A profile is a nostr key. A wallet is a different thing that a profile may have +attached to it.** The two live in different files for a reason both sign-in plans +spelled out: `seed.dat` holds twelve words, from which a Lightning node *and* a +nostr key are derived; `nostr-credentials.dat` holds a bare key, or a bare public +key, from which nothing else can be. The relationship runs one way — a seed +always yields a key; a key does not imply a seed — and the code has, until this +plan, drawn the line in the wrong place: a seed's key is *not written to the +credentials file*, so a seed-backed profile exists only as a derivation, and the +app has to start a Lightning node to find out who it is +([What actually blocks it](#what-actually-blocks-it)). + +This plan settles the model and the words together: + +- **The credentials file is the list of profiles.** Every profile the device can + sign as is a `Secret` entry in it, and every one it can only look at is a + `Public` entry. A seed contributes a wallet, and the wallet is *attached* to + the profile whose key it derives — matched by public key, and written as a + credential when the seed is, which is [Phase 1](#phase-1--the-credential-a-seed-always-implied). +- **The code keeps saying identity; the user reads profile; and *wallet* is used + only of a wallet.** *Account* is not used anywhere. The two Phoenix-era strings + the user can still see, `select_a_wallet` and `change_account`, become *choose + a profile* and *switch profile*; the startup screen's loading literals follow, + except the one that is genuinely a wallet starting. +- **No renaming of types.** `WalletId` keys every preference file in the library + and was left alone by both sign-in plans for that reason. A profile's id is its + wallet's when it has one — `hash160(nodeId)`, as it has always been for a + seed — and `hash160(pubkey)` when it has none, which is the rule the npub plan + set and this plan keeps, for the reason in the + [appendix](#one-id-space-for-every-profile). + +What the distinction decides, phase by phase: + +| the wallet is not the profile, so | where | +|---|---| +| a seed's key is a credential like any other, written when the seed is and repaired into the file for every seed already here; the identity's key comes from the credential, not the node | [Phase 1](#phase-1--the-credential-a-seed-always-implied) | +| a switch is between profiles; the node, where there is one, follows the profile it belongs to — stopped when that profile is left, never started for one that has none | [Phase 2](#phase-2--a-switch-that-tears-down-what-it-should) | +| the switcher says which profiles have a wallet attached, before the tap | [Phase 4](#phase-4--the-switcher) | +| a profile added from inside is a *key*, not a second seed: wanting another profile is not wanting another wallet | [Phase 5](#phase-5--adding-a-profile-from-inside) | +| a bare key can be forgotten; a key with a wallet attached has funds behind it, and its sign-out stays a wallet question | [Phase 7](#phase-7--leaving-one-profile-among-several) | +| a wallet attached to a profile that did not derive it, or one wallet every profile on the device pays from, is the model this leads to — and its own plan, which Phase 1 makes small | [Out of scope](#out-of-scope) | + +## The constraint + +One database, one process, one set of pumps, and every route on the stack +addressed by the key it was pushed for. + +Every screen from the home tab inward takes `activeUserPublicKey` as a route +argument — `HomeRoute`, `SearchRoute`, `ActiveProfileRoute`, `ChatRoomDetailRoute`, +all of them. The navigation component reads the signed-in key *off the current +route* rather than holding it +([MantraNavHost.kt:404](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt)), +precisely so that it cannot drift from the screen underneath. That is the right +design for one profile and it decides the shape of a switch: a stack of routes +built for key A cannot be handed key B. The only honest switch is to clear the +stack and rebuild it for B — which is, as it happens, exactly what the app already +does when the active identity changes. + +The second half of the constraint is the database. It is one Room file for the +device, and it is *mostly* keyed by whose it is: `ChatRoom.userPublicKey` — whose +comment reads "should help us have multiple user support" +([ChatRoom.kt:48](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatRoom.kt)) +— `UnsignedNostrEvent.pubKey`, `MarmotKeyPackageBundle.publicKey`, +`GiftWrapMessage.receiverPublicKey`, the `Connection` relation by both keys. The +profile cache is shared, and should be: a kind 0 is the same event whoever fetched +it. What is *not* keyed by whose it is are the three request queues, and +[Phase 6](#phase-6--a-queue-per-identity-and-the-inbox-that-arrived-while-another-profile-was-open) +is about the one place that matters. + +## What is already built + +Most of a switch. The Phoenix fork this app grew from is a multi-wallet +application, and its switching machinery came across intact and unused. + +| piece | where | state | +|---|---|---| +| the credentials file, its encrypted writer, and the app writing it from `writeMnemonic` — to *delete* a public entry a seed supersedes | [IdentityWriter.kt:148](../composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt) | built; writing a `Secret` from the same place is one line more | +| `migrateFromNostrKeys`: a named, idempotent write that runs from `listIdentities` before anything reads either file | [SovereignWalletViewModel.kt:183](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt) | built; the shape Phase 1's repair copies | +| `StoredIdentity.merge`, deriving every seed's nostr public key at listing to compare stores by pubkey | [StoredIdentity.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/identity/StoredIdentity.kt) | built; the derivation is what attaches a seed to its credential | +| `switchToWallet(id)`: sets `desiredWalletId`, clears the active identity | [SovereignWalletViewModel.kt:304](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt) | built; called by the sign-in and create tails and the startup selector, never from a signed-in screen | +| `resetToSelector()`: clears both and asks startup for the list | [SovereignWalletViewModel.kt:315](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt) | built; the sign-out and forget tails use it | +| `NavigationViewModel.observeProfile`: a null identity is `StartupPhoenix`, which the nav host navigates to with `popUpTo(0)`; a new identity is observed from its account | [NavigationViewModel.kt:184](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt) | built — this *is* the restart | +| the notary, the sync pumps and the live subscriptions run as children of `collectLatest` over the identity, so a change cancels them — the comments say "a wallet switch" in as many words | [NotaryViewModel.kt:62](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NotaryViewModel.kt), [SynchronizationViewModel.kt:193](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt) | built | +| the startup screen: lists, picks by `forceWalletId` / `desiredWalletId` / the default, shows `WalletsSelector` otherwise, and wraps every activation in the lock gate | [SovereignWalletStartupScreen.kt:106](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt) | built; the precedence is wrong for a switch, see below | +| `WalletsSelector`: a current section with a divider, the others below, a *read only* label, `topContent` and `bottomContent` slots | [WalletsSelector.kt:59](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt) | built; shows "Default name" and a random emoji for every row | +| `BusinessManager`: a map of running nodes by wallet id; starting one that runs returns it; `stopBusiness(walletId)` on all three platforms | [BusinessManager.kt:208](../lightning-kmp-app/library/src/androidMain/kotlin/fr/acinq/phoenix/android/BusinessManager.kt) | built; nothing in the app calls `stopBusiness` | +| `GlobalPrefs.getDefaultWallet`, `saveDefaultWallet`, `clearDefaultWallet` | [GlobalPrefs.kt:102](../lightning-kmp-app/library/src/commonMain/kotlin/fr/acinq/phoenix/utils/preferences/GlobalPrefs.kt) | read by startup; never written by anything | +| the sign-in and create tails: re-list, select, go to startup | [MantraNavHost.kt:741](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt), [:537](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt) | built; a shape that works from inside the app as well as from Landing | +| *Change account* on the profile tab | [ActiveProfileScreen.kt:309](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt) | `ImplementationPendingRoute` | +| `ForgetIdentity` and `SignOutViewModel` for a read-only identity; *forget this key* for an nsec | `identity/ForgetIdentity.kt`, `NostrSecretScreen` | built; the tail lands on the selector or Landing, which is right for several profiles too | +| `NpubPreviewRoundTripJvmTest`: a real key store, a real in-memory database, real view models | `jvmTest/.../identity/` | the harness every test below reuses | + +So the switch exists as a state transition and is reachable from nowhere, and +the list it would switch between is drawn from two files that disagree about +what a profile is. That is the whole shape of this plan: fix what a profile is, +build the two entrances, fix what the transition gets wrong, and make the one +data problem it exposes go away. + +## What actually blocks it + +Seven things, in the order a user would hit them. + +**A seed's profile is not a credential.** `writeMnemonic` writes `seed.dat` and +nothing else — and if a `Public` entry for the seed's key exists, it *deletes* +it, so the seed becomes the only holder +([IdentityWriter.kt:148](../composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt)). +The key is then derived twice on every launch: at listing, from the words, to +learn the pubkey the selector shows; and at activation, from the *running node's* +key manager ([SovereignWalletViewModel.kt:151](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt)), +which is why a seed-backed profile has to start Lightning before it can be +anyone. Both sign-in plans made this an invariant rather than an accident — one +entry per pubkey *across both files*, `writeNostrKey` refusing a key a seed +derives, `writeMnemonic` refusing a seed whose key is here as a secret — and it +is the wrong invariant: it says a wallet is a profile, and the switcher this plan +builds would inherit a list in which one kind of row is a key and another is a +seed pretending to be one. + +**There is no way in.** Landing is reached only when `availableIdentities` is +empty ([SovereignWalletStartupScreen.kt:95](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt)), +and the sign-in and create screens are reached only from Landing. A device with +one profile cannot be given a second: the flow that adds one is behind a screen +the device never shows again. + +**There is no way across.** The selector is drawn by the startup screen and +nowhere else, and nothing in the signed-in app calls `resetToSelector()` except +the sign-out tails. The row that promises it on the profile tab routes to the +pending screen. + +**The startup precedence is wrong for a switch.** The `when` that picks what to +open reads `!startWalletImmediately -> null` *before* `desiredWalletId != null` +([:122](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt)), +and `startWalletImmediately` is set to false by `resetToSelector()` and by the +lock prompt's back button and set back to true by nothing. So after the first +visit to the selector, every sign-in on that device lands on the selector instead +of the profile that was just signed in. And because `saveDefaultWallet` is never +called, `availableIdentities[defaultWallet]` is always null: a cold boot with two +profiles is the selector, every time, with no memory of which one was open. + +**Two things are not torn down.** `RelaysSocketManager.observeActiveUserId` keeps +one relay-list observer *per pubkey* and only ever cancels the one for the pubkey +being started; its own comment says `TODO: Cancel all pending jobs?` +([RelaysSocketManager.kt:65](../composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt)). +After a switch, A's observer and B's observer both feed `updateRelayPools`, and +the one pool follows whichever relay list emitted last. And the previous +profile's node, where it had one, keeps running in `BusinessManager` — Electrum, +the LSP peer, the swap-in watcher — for a profile nobody is looking at. The nsec +plan established that nothing in this app reads the node but the key; a node +running for a profile that is not active is that finding with the sign flipped. + +**The queues belong to the device, not the identity.** `SynchronizeNostrEventRequest`, +`NegentropySynchronizeRequest` and `BroadcastNostrEventRequest` have no owner +column, and the pumps drain whatever is `pending` with the *active* key pair +([DatabaseNostrRepository.kt:118](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt)). +Requests A queued a moment before the switch — its sign-in sync, its room +reconciliation — are answered while B is active, and everything that comes back +is indexed as B: a gift wrap addressed to A is stored, found not to be addressed +to the active key, and left ([NostrDao.kt:439](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt)). +Then `storeNostrEvent` never indexes an event it already holds +([NostrDao.kt:176](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt), +the `TODO: Check if event got indexed`), so when A is opened again, its live +subscription re-receives the wrap, the DAO sees a known id, and returns. **A +message that arrives while the other profile is open is lost to the one it was +for.** Silently, and it looks in the logs like nothing happened, because nothing +did. + +**Two destructive controls assume one profile.** `CreateProfileScreen`'s *end +this* calls `wipeDatabase()` ([CreateProfileScreen.kt:381](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CreateProfileScreen.kt)) +— on a device with a second profile, that profile's rooms and MLS state go with +it, from a screen the user opened to *add* one. And once a default is remembered, +forgetting the identity it names has to clear it, or the next boot opens a +profile the list no longer has. + +## The two decisions to make first + +### What a profile is + +Two models were on the table. + +**A seed is a profile by derivation.** What the code does: the credentials file +holds only bare keys, a seed's key is recomputed from the words at listing and +from the node at activation, and the writers refuse to hold the same key in both +files. One copy of every secret, nothing to migrate, and the two sign-in plans' +tests pass as written. The costs are the ones the blocker above lists: the node +has to run for the profile to know its own key, the list of profiles is a merge +of two files with opposite ideas of what a row is, and there is no place for a +wallet that belongs to a profile it did not derive. + +**A profile is a credential; a seed is a wallet attached to one.** Creating or +restoring a seed writes the seed *and* a `Secret` credential for the key it +derives; every seed already on the device gets its credential written at the +first listing; `merge` lists the credentials file and attaches each seed to the +entry it derives. The identity's key is read from the credential for every kind, +so the node stops being where a profile's key comes from and becomes what a +wallet is — which is the sentence this plan wants to be true, and the one that +makes "attach a wallet to this profile" a later plan instead of a redesign. + +**This plan takes the second.** Its costs are real and Phase 1 pays them: a +repair step at listing, the derived secret held in a second file under the same +keystore key, one of the sign-in plans' invariants inverted, and every `when` +over `IdentityKind` re-read for what the kind now means. What it does *not* do is +make the node lazy, though it makes that possible — the reason is under Phase 1, +and the decision is named under [out of scope](#out-of-scope). + +### What a switch is + +Is a switch a *restart* or a *swap*? + +**A swap** sets the new identity while the stack stands. Every route on that +stack carries the old key as an argument; every view model built from those +routes is observing the old key's rows; the notary and pumps restart under the +new key while the screens above them show the old one. Making that consistent +means every screen re-reads the identity from a flow instead of its route — the +snackbar-host problem again, in forty-three places — or a re-navigation that +amounts to a restart anyway. Rejected, and the [appendix](#a-live-swap-under-the-stack) +says so at more length. + +**A restart** is what `observeProfile` already does when the identity goes null: +`StartupPhoenix`, `popUpTo(0)`, the startup screen activates the next one, the +machine routes it from its account. Every screen is torn down; every collector is +cancelled and rebuilt; nothing built for A survives into B. It costs a visible +pass through the startup screen — "Opening profile", under a second for a bare +key, a little over one for a profile with a wallet attached — and it is honest +about what it is. + +**This plan takes the restart.** It is what the code does, it is the only design +that cannot leak one identity into another's screen, and it settles four things +that would otherwise each be an argument: + +- **The back stack is cleared by a switch**, so the control that switches is a + pushed screen reached by one deliberate tap from the profile tab — never a + global control in an app bar, a long press, or a swipe that could fire from + inside a half-written message. +- **The previous node is stopped.** It belongs to the profile being left, not + to the device; nothing reads it, the restart tears down everything else, and a + library that already handles "start one that exists" makes switching back + cheap enough. +- **The profile that opens on launch is the last one used.** A restart through + startup is also what a cold boot is, and the two should agree: `desiredWalletId` + for a switch, the saved default for a boot, the selector only when the user + asked for it. +- **A profile added from inside is switched to.** The sign-in and create tails + already select what they wrote; the one they left stays on the device. + +Everything below is downstream of those two words: credential, and restart. + +--- + +## Phase 1 — the credential a seed always implied + +**App only. No library change.** The file format, the encrypted writer and the +manager all exist; the app already writes the credentials file from the seed +writer. This phase changes what it writes there, and what the listing believes. + +### The write + +`writeMnemonic` writes two things, in this order: + +1. the credentials file, with a `Secret` for `keyManager.nostrPrivateKey()` under + its x-only public key — replacing a `Public` entry for that key if one is + there, which is the read-only upgrade the npub plan built, now landing on a + secret rather than on nothing; +2. `seed.dat`, as today. + +Credential first, for the reason the npub plan gave for the order it chose: a +crash between the two leaves a *bare-key profile* whose key the phrase derives, +which is a valid thing for the device to hold and which pasting the phrase again +completes. The reverse order would leave a seed with no credential, which the +listing below tolerates and the repair fixes — also recoverable, but the first +order says the model out loud: the profile exists, and then a wallet is attached +to it. + +`writeNostrKey` and `writeNostrPublicKey` are unchanged. The duplicate table +becomes: + +| the pubkey is already here as | pasting its nsec | pasting its npub | pasting its phrase | +|---|---|---|---| +| a secret with a wallet attached | `AlreadyExists` | `AlreadyExists` | `AlreadyExists` | +| a secret, bare | `AlreadyExists` | `AlreadyExists` | **attach**: the seed is written, the entry stays as it is, `Written` with the wallet's id | +| a public entry | upgrade to secret, one write | `AlreadyExists` | upgrade to secret and attach, two writes | +| nothing | `Written` | `Written` | `Written`, two writes | + +The one new cell is the phrase pasted over a bare key, which the nsec plan +refused as `SeedAlreadyExists`. It was a duplicate under the old model because +two files would have held one key; under this one it is a profile acquiring the +wallet that derives it, and it is the first attachment the app performs. The +profile keeps its key and every row keyed by its pubkey. Its id changes to the +wallet's, so its preferences and metadata are fresh, as a new wallet's are — the +backup flags reset, which is right: there is a new secret to back up. + +### The repair + +Every seed already on a device has no credential. `SeedCredentials.reconcile`, +beside `migrateFromNostrKeys` in `listIdentities` and shaped like it — a named, +idempotent write that runs before anything reads the merged list: + +```kotlin +object SeedCredentials { + sealed interface Result { + data class Written(val count: Int) : Result + data object NotNeeded : Result + data class Failed(val cause: Throwable) : Result + } + + /** + * Writes a Secret credential for every seed whose derived key has none, replacing a + * Public entry for that key where there is one. One write, or none. A failure is + * logged and the listing goes on: a seed that cannot be repaired into the file is + * still listed, with its key derived, by [StoredIdentity.merge]. + */ + suspend fun reconcile(phoenixGlobal, wallets: Map, credentials: Map): Result +} +``` + +It runs after both files are read and the nostr-keys migration has run, and the +listing is built from what it returns — the repaired map, or the one read if the +write failed — so the listing sees the repaired file without decrypting it twice. +On a device with no seeds it does nothing; on a device already repaired it does +nothing; on the first launch after this phase it writes once. `Failed` is logged, +not surfaced: this is a repair of something the app can still work around, and a +listing that goes red because a background write failed would be worse than the +derivation it was replacing. + +### The listing + +`merge` inverts. Today the seeds are the list and the credentials are added to +it; now the credentials are the list and the seeds are attached to it. + +```kotlin +fun merge(wallets, credentials): Map { + val walletsByPubkey = wallets.values.associateBy { nostrPublicKeyOf(it.words) } + val merged = LinkedHashMap() + credentials.forEach { (pubkey, credential) -> + when (credential) { + is Secret -> { + val wallet = walletsByPubkey[pubkey] + val stored = if (wallet != null) mnemonic(wallet, credential.privateKey) else nostrSecret(credential.privateKey) + if (stored.nostrPublicKey == pubkey) merged[stored.id] = stored + } + is Public -> if (pubkey in walletsByPubkey) log.w { "public entry for a key a seed derives; the next repair upgrades it" } + else nostrPublic(pubkey)?.let { merged[it.id] = it } + } + } + // A seed the repair could not write a credential for. Listed as it always was, + // with the key derived from the words, so that a failed write never hides a wallet. + walletsByPubkey.forEach { (pubkey, wallet) -> + if (pubkey !in credentials) { log.w { "seed with no credential; deriving" }; mnemonic(wallet).let { merged[it.id] = it } } + } + return merged +} +``` + +`StoredIdentity.Mnemonic` gains the key: `Mnemonic(userWallet, privateKey)`, with +`nostrPublicKey` derived from the key and checked at construction against what +the words derive — a mismatch is corruption, and the entry is dropped with a log +line rather than listed under a key it cannot sign as. The derivation from the +words stays, because it is what matches a seed to its credential; the nsec plan +noted `SeedManager` had already built that key manager once, so this is the same +second derivation it was. + +`IdentityKind` keeps its three values and its doc comment changes: + +```kotlin +/** A key the device also holds a seed for. The key is a credential like any other; the seed is the wallet attached to it. */ +Mnemonic, +``` + +Every `when` over the kind is re-read in this phase for what the kind now means, +and none of them change: `KeyRecoveryScreen` offers the phrase for this kind +because the phrase is the wallet's backup and derives the key; `NostrSecretScreen` +refuses to forget it because a wallet is attached; the startup branch starts the +node because a wallet is attached. The meaning shifted under them and the answers +held, which is what a kind that names the *attachment* rather than the *source* +buys. + +### The activation + +`setActiveWallet(walletId, business)` becomes `setActiveWallet(stored: StoredIdentity.Mnemonic, business)` +and builds the identity from the **credential's** key: + +```kotlin +val derived = business.walletManager.keyManager.value?.nostrPrivateKey() +check(derived == stored.privateKey) { "the node derives a different nostr key than the credential holds" } +setActiveIdentity(Identity.signing(id = stored.id, kind = IdentityKind.Mnemonic, nostrPrivateKey = stored.privateKey, …, business = business)) +``` + +The check cannot fail on a file the repair wrote — it derived the key from the +same seed — and it is kept because a credentials file is now the thing the app +signs with, and a node disagreeing with it is the one corruption worth refusing +to run under. + +**The node still starts.** Not for the key any more: for what `startNewBusiness` +does besides — it registers the wallet's metadata, applies its Electrum and Tor +preferences, records the last-used build, and on Android schedules the channel +watcher that notices a force-close while the app is shut. A phrase pasted from a +real Phoenix wallet can have channels behind it, and a build that stopped +watching them to save a second at startup would be trading a spinner for funds. +With the key in the credential, making the node lazy is one branch in +`doLoadWallet` and a decision about who watches channels while it is not running; +it is named under [out of scope](#out-of-scope) and this phase does not take it. + +### The forget + +`forgetNostrCredential` refuses two things instead of one: a key that is not in +the file, as today, and — new — a key that a seed derives. The seed would +re-derive it and the next repair would write it back, so a forget that succeeded +would undo itself at the next launch. `ForgetNostrCredentialResult.NotACredential` +becomes `WalletAttached`, because that is now the only reason a signing profile +cannot be forgotten, and `ForgetIdentity.Outcome` follows. + +### The two plans that said otherwise + +Each of the sign-in docs carries a table of where the build chose differently +from the plan. Each gains one row at the bottom, pointing here: the +one-entry-per-pubkey rule across both files, superseded — a seed's key is a +credential, and the seed is attached to it. Their tests change in the same +places: `IdentityWriterJvmTest`'s "the npub of a key a seed derives is refused" +stays; its "a seed whose key is here as a secret is refused" becomes "…is +attached to it". + +### Tests + +`SeedCredentialsJvmTest`, through the jvm key store: two seeds and an empty +credentials file — `Written(2)`, two `Secret` entries under the keys the words +derive; run again — `NotNeeded`, and the file's bytes are unchanged; a `Public` +for one seed's key — upgraded to `Secret`, `Written(1)`. + +`StoredIdentityJvmTest`: a `Secret` whose key a seed derives lists once, as +`Mnemonic`, under the wallet's id, carrying the credential's key; a seed with no +credential lists as `Mnemonic` with a derived key; a `Secret` whose key no seed +derives is `NostrSecret`; a `Public` for a seed's key is dropped. + +`IdentityWriterJvmTest`: creating from a phrase writes both files and the +credential is a `Secret` for the derived key; the phrase of a bare key attaches — +`Written` with the wallet's id, the entry unchanged; the nsec of a key with a +wallet attached is `AlreadyExists`; forgetting a key with a wallet attached is +`WalletAttached` and changes nothing. + +And a mnemonic activation in the round-trip harness, which neither sign-in plan +wrote because both were about the other kinds: the identity's key equals the +credential's and the node's. + +--- + +## Phase 2 — a switch that tears down what it should + +**App only. No UI. Needs Phase 1.** The transition, made correct, with nothing +yet calling it from a screen. Its diff is the view model, one manager, and three +one-line actuals. + +### The call + +```kotlin +// SovereignWalletViewModel +/** + * Makes [id] the identity to open next and clears the active one, which is the + * whole of a switch: the navigation observer sends a null identity to startup, + * startup opens [id] through the lock gate, and the machine routes it from its + * account. Everything reading [activeIdentity] is cancelled by the null and + * rebuilt by the activation. + */ +fun switchToIdentity(id: WalletId) { + val previous = _activeIdentity.value + _desiredWalletId.value = id + startWalletImmediately.value = true + _activeIdentity.value = null + if (previous?.business != null) { + viewModelScope.launch(Dispatchers.IO) { stopPlatformBusiness(previous.id) } + } +} +``` + +`switchToWallet` is renamed to this; its three callers — the two tails and the +startup selector's `onWalletClick` — follow. `startWalletImmediately` is set +back to true because a switch is the user saying which one, and the flag was +only ever the user saying *show me the list*. Nothing set it back before because +nothing could switch. + +The order inside is deliberate. The identity is cleared **before** the node is +stopped: clearing cancels every collector that could reach the business, so the +stop finds nothing reading it. The stop is `BusinessManager.stopBusiness` behind + +```kotlin +expect fun stopPlatformBusiness(walletId: WalletId) +``` + +with an actual per platform beside `updateBusinessActiveInUI`, which has the +same shape and the same reason to be an `expect`: the manager is a per-platform +object. + +The test is `business != null`, not `kind == Mnemonic`. The question is whether +the profile being left has a node behind it, and today those are the same fact — +but the first is the fact, and the second is how it currently comes to be true. +`previous.id` is the wallet's id when a wallet is attached, which is why +`stopBusiness` can take it, and why a profile's id is its wallet's. For a bare +key there is nothing to stop and the branch is not taken; for a read-only profile +likewise. + +**The tail keeps its own `navigate`.** The sign-in tail both switches and +navigates to startup with `popUpTo(0)`, and so does the observer on +`StartupPhoenix`; two writers to the stack, one entry popped by the other. It +looks redundant and is not: `_navigationUIState` is a `StateFlow`, and a +`getAndUpdate` to a state equal to the current one emits nothing. The explicit +navigation is what guarantees the stack moves even on the day the state does not. +Leave both, and leave a comment saying so, because the next reader will want to +remove one. + +### The relay observer + +`observeActiveUserId` becomes what the pumps already are — a child of +`collectLatest`, cancelled by the next emission: + +```kotlin +private fun observeActiveUserId() = scope.launch { + activeIdentityStateFlow.collectLatest { identity -> + val pubkey = identity?.nostrPublicKey ?: return@collectLatest + relayRepository.observePublicKeyRelays(pubkey).collect { relays -> + updateRelayPools(relays.filter { it.type == "user" }.map { it.mapToRelayDTO() }) + } + } +} +``` + +The `observeRelayJobs` map goes; it existed to replace an observer per pubkey and +could only ever leak. A null identity closes nothing — the pool is shared by the +pumps that a null identity has already cancelled, and the next identity's list +replaces it through `changeRelays` as it does today. + +### What needs nothing + +Listed so that nobody adds a guard that already exists. +`SynchronizationViewModel`'s pumps and `LiveSubscriptionManager.observe` are +children of `collectLatest`; `hasRequeuedStaleBroadcasts` is once per process +*on purpose*, and its comment already names the wallet switch as the reason. +`NotaryViewModel` keys on the private key and restarts its four observers. +`MlsGroupCache` and `MarmotInboundManager`'s pending commits are keyed by room, +and a room belongs to one identity by its `userPublicKey`. `DataStoreManager` +caches preferences per id for the life of the process, which is right for a +switch and only wrong for a test that reuses a key across directories, as the +nsec plan found. + +### Tests + +`IdentitySwitchJvmTest`, in the round-trip harness beside `NpubPreviewRoundTripJvmTest`: +write an nsec A and an nsec B through the writers, list, activate A with a real +`NotaryViewModel` and `NavigationViewModel` on the same database. Queue an +unsigned kind 1 for A: it is signed. Switch to B, activate B the way startup +does: the machine lands on B's state. Queue an unsigned kind 1 for A: it is +**not** signed while B is active, and is when A is opened again. That is the +assertion a switch is for — the notary signs as whoever is open and nobody else. + +`switchToIdentity` on its own, with `stopPlatformBusiness` injected as a recorder +the way the writers are injected into `SignInToProfileViewModel`: called once +with A's id when A had a node; not called for a bare key; not called when nothing +was active. And a `RelaysSocketManager` test over a fake relay repository with two +pubkeys' lists: after the switch the pool holds B's relays, and a re-emission of +A's list changes nothing. + +--- + +## Phase 3 — which profile opens, on launch and after a switch + +**App. Needs Phase 2.** The startup screen learns to agree with the switch. + +### The precedence + +The `when` that picks what to open, lifted out of the composable's `remember` +into a function that can be read and tested, in `ui/view/state`: + +```kotlin +object StartupChoice { + /** Which stored identity startup opens without asking, or null to show the selector. */ + fun resolve( + force: WalletId?, + desired: WalletId?, + startImmediately: Boolean, + identities: Map, + default: WalletId?, + ): StoredIdentity? = when { + force != null -> identities[force] + desired != null -> identities[desired] // a switch or a sign-in said which + !startImmediately -> null // the user asked for the list + identities.size == 1 -> identities.values.single() + default != null -> identities[default] // the last one used + else -> null + } +} +``` + +Two rows move. `desired` goes above `!startImmediately` — it is the more +specific instruction and the one a switch relies on — and the `size == 1` row +drops below it, because when both are set they name the same thing and the order +only mattered for reading. Everything else is as it was. + +### The default + +`setActiveIdentity` saves what it activates: + +```kotlin +fun setActiveIdentity(identity: Identity) { + _activeIdentity.value = identity + viewModelScope.launch(Dispatchers.IO) { getGlobalPrefs().saveDefaultWallet(identity.id) } +} +``` + +Every activation goes through here — startup for all three kinds, and the +sign-in and create tails through startup — so the default is always the profile +most recently open, and a cold boot with two profiles opens it. The selector is +one tap away in Phase 4, and the lock prompt's back-to-selector button stays for +the case it was built for. + +The inverse: the two forget tails — sign out of a read-only identity, *forget +this key* for a bare key — call `clearDefaultWallet()` before `resetToSelector()`. +`resetToSelector` already nulls `desiredWalletId`; the default is the one memory +of the identity that would otherwise outlive it, and `identities[default]` on a +forgotten id is a selector anyway, but a null read is better than a miss that +happens to be handled. + +### The words + +The five literals the startup screen shows — "Decrypting...", "Initializing...", +"Preparing wallet...", "Opening wallet", "Starting wallet" — move to the catalogue +in this phase because the phase rewrites the `when` they sit in. Four of them say +"wallet" about something that is not one and become *preparing profiles* and +*opening profile*. The fifth is right as it is: "Starting wallet" is shown from +`StartupViewState.StartingBusiness`, which only the branch with a wallet attached +reaches, and what is starting there is a Lightning node. It stays *starting +wallet*, so that a user with a bare-key profile never sees the word and a user +with a wallet sees it at the one moment it is true. The selector's title, +`select_a_wallet`, becomes `choose_a_profile`. The lock prompt's "coming soon" +literal stays; it is a stub and marked as one. + +### Tests + +`StartupChoiceTest` in `commonTest`, one case per row and the two orderings this +phase exists for: `desired` set and `startImmediately` false opens the desired +one; one identity and `startImmediately` false shows the selector, because the +user asked. `IdentitySwitchJvmTest` gains: after activating B, `getDefaultWallet` +is B's id; after forgetting B, it is empty; a fresh `SovereignWalletViewModel` +over the same store and a `StartupChoice.resolve` with its list and default +answers A. + +--- + +## Phase 4 — the switcher + +**App. Needs Phases 2 and 3.** One pushed screen, and the startup selector +brought up to match it. + +### The screen + +`ProfilesRoute`, reached from the profile tab's row — *switch profile*, where it +said *change account* — and drawn by `ProfilesScreen`: a `TopAppBar` titled +*profiles* with `NavigateBackButton` in the leading slot, and `WalletsSelector` +as its whole content, with `activeWalletId` set so the current profile sits above +the divider and the others below. + +Tapping another row is `switchToIdentity(id)` and nothing else — the observer +restarts the app as that identity, through its lock gate. Tapping the current row +does nothing; `canEdit` stays false, the edit dialog behind it is a stub. The +`bottomContent` slot holds the two rows [Phase 5](#phase-5--adding-a-profile-from-inside) +adds. + +Four states, said so. *Loading* while `listWalletState` is `Init`, which in +practice is never seen — the list was read at startup — but the state exists and +the screen says what it is waiting for. *Loaded* is the list. *Error* is +`ListWalletState.Error`, through `ErrorState` with `onRetry = listIdentities`, +because an unreadable credentials file is exactly the failure a retry can answer. +*Empty* cannot happen while something is signed in and the screen does not +pretend it can. `ScreenStateTransition` wraps the `when`; `readableContent()` on +the root; one column at every width, since a list of a handful of rows has no +detail to pair with. + +### What a row shows + +`WalletsSelector` shows `metadata.nameOrDefault()` — "Default name", for every +row, because nothing in this app writes the wallet metadata's name — over a +random emoji ([WalletsSelector.kt:162](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt)). +Two profiles side by side, both called Default name, distinguished by a bech32 +string, is not a switcher. + +The row takes the nostr profile instead: `WalletsSelector` gains a +`profiles: Map` parameter, and the row shows +`humanReadableNameOrPubkey()` over `ProfileAvatar(publicKey, profile)` — the +widgets the profile tab already draws itself with — falling back to the npub and +the metadata's emoji for a profile the device has no kind 0 for yet: one just +created, or a read-only one that was never found. `ProfilesScreen` collects the +map through `observeProfileWithPublicKey` for every listed pubkey, in a +`ProfilesViewModel` that owns the four states above. The *read only* label stays +where it is and means what it meant. + +And a second label of the same kind, *wallet*, on a row with a seed attached. +What the device holds for a profile — nothing, a key, or a key and a wallet — is +the one thing the switcher knows that the profile tab never shows. It is the +difference between a profile that can be signed out of and one whose sign-out is +a wallet question, and between two profiles with the same name, and it belongs +before the tap for the reason the read-only label does. A bare-key profile +carries no label; it is the plain case. + +The startup screen draws the same widget and gets the same map: `MantraNavHost` +already holds `databaseNostrRepository` where it composes +`SovereignWalletStartupScreen`, and passes it through. Both lists then look the +same, which is what a user who has just seen one and is now looking at the other +expects. + +### Tests + +`ProfilesScreenJvmTest`, with `runDesktopComposeUiTest` and the unmerged tree, as +`ReadOnlyEntrancesJvmTest` does: two identities, A current — A's name is above the +divider and B's below; tapping B invokes `onSwitch(B.id)` exactly once; tapping A +invokes nothing; a read-only B carries *read only*, an A with a seed attached +carries *wallet*, and a bare key carries neither; a B with no profile row shows +its npub. And `ActiveProfileScreen` under `LocalCanSign` true and false both have +*switch profile*: a read-only identity can leave for another profile the same way +a signing one can. + +--- + +## Phase 5 — adding a profile from inside + +**App. Needs Phase 4.** The two entrances, and the two things that were only +safe with one profile. + +### The rows + +Under the list, in `bottomContent`, on the switcher and on the startup selector +alike: + +| row | goes to | why not Landing | +|---|---|---| +| *sign in with a key* | `SignInRoute` | Landing has no back button and clears the stack on its way out: it is the first-run screen, and pushing it from inside would make it a pushed screen on some days and a root on others. The sign-in screen has `NavigateBackButton` already | +| *create a new profile* | `CreateProfileRoute` | same; and its tail is the one the create flow uses today | + +Landing stays exactly what it is. The caption on each row says what the tails +below make true: *the profile you are in stays on this device.* + +### The sign-in tail + +Unchanged in shape — `listIdentities { switchToIdentity(id); navigate(Startup) { popUpTo(0) } }` +— and now correct from inside: Phase 2 stops the previous node and Phase 3 makes +startup open the id it was handed. A read-only identity upgrading itself through +the Messages tab's *sign in with the nsec* is the same tail with the same id, and +keeps working; a bare key acquiring its wallet through a pasted phrase is the +same tail with the wallet's id. + +One thing changes. `IdentityWriter.WriteNostrCredentialResult.AlreadyExists` is a +`data object` and says only that the key was refused. From Landing that was the +whole message; from inside, "this key is already on this device" is a sentence +with an obvious next step, and the writers know which id they collided with — +the credentials map by pubkey, and the attached seed's wallet id where there is +one. It becomes `AlreadyExists(val id: WalletId)`, +`CredentialProblem.AlreadyOnThisDevice` carries it, and the sign-in screen's +error state gains one action, *switch to it*, which is `switchToIdentity(id)` and +the same tail. The recovery-phrase writer's `SeedAlreadyExists` maps the same way. +Two writers, one distinction, and a duplicate paste becomes the fastest switch in +the app. + +### A profile created from inside is a key + +`CreateProfileScreen` generates twelve words behind its form +([CreateProfileViewModel.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CreateProfileViewModel.kt)): +a seed, a node, a wallet, and — at the NIP-06 path — the key that becomes the +profile. That is the right thing for the first profile on a device, which is the +one the wallet will belong to, and the wrong thing for the second: a user who +wants another profile has not asked for another Lightning node, another set of +channels, or a second twelve-word phrase with funds behind it, and would not thank +the app for handing them one. + +From the switcher, *create a new profile* makes a **bare key**: thirty-two random +bytes as a `PrivateKey`, written through `writeNostrKey` — the writer the sign-in +screen uses for a pasted nsec — then `createNewProfile(pubkey, name, bio)`, the +six-event bootstrap a new key gets, then the tail. Same screen, same form: +`createAccount` already takes its writer as a function, so the nav host passes +the seed writer from Landing and the key writer from the switcher, and the +screen does not know which it was given. Under this plan's model the two writers +differ by exactly one thing — whether a wallet is attached to the profile they +make — which is what the model was for. The declaration and confirmation copy say +"profile" and "keys" and are true of both. The backup lives where it does for a +signed-in nsec: the key recovery screen already shows the secret for that kind, +and the backup flags are per id, so a profile made this way is reminded to back +up its key the way a seed's profile is reminded about its phrase. + +The previous identity's notary observes `observeUnsignedNostrEvents(publicKey = +its own)`, so it never sees the new key's events; they wait for the new +identity's notary, which starts when startup activates it — at once, since there +is no node to start. Nothing crosses. The tail gains the `popUpTo(0)` the sign-in +tail has, so that back from the new profile's startup does not return to a form +whose key is already on disk. + +A second *wallet* is not offered from inside. Restoring one is the sign-in row — +a pasted recovery phrase brings its wallet with it, and the confirm step already +says so — and creating a fresh one waits for a wallet feature to want it, which +is named under out of scope. Landing's *create profile* keeps making a seed; if +the product decides the first profile should be a bare key as well, that is the +same writer swapped in one more place. + +**And *end this* goes.** It wipes the database — every profile's rooms, MLS state, +key packages and queues — from a screen whose purpose is to add a profile, and it +was only ever a development exit. Remove the button and the string; +`wipeDatabase` stays on the repository for the tests that use it. If a +device-level reset is ever wanted, it is a settings screen with a confirmation +naming every profile it will take, and not this. + +### Tests + +`SignInToProfileViewModelJvmTest`: the nsec of a key already here fails with the +id it is already here under, for each of the three writers. +`AddProfileFromInsideJvmTest`, in the round-trip harness: A active; B's nsec +committed through the sign-in view model; list — two entries; switch; B active; +switch back — A active, and `getLocalAccounts()` holds two accounts, each with its +own kind 0 and neither with the other's. A profile created from inside through +the key writer lists as `NostrSecret`, its account holds the six bootstrap rows, +and `BusinessManager.businessFlow` is empty afterwards — no node ran, the +assertion the nsec round trip already makes. `CreateProfileScreen` composed has +no node with the text *end this*. + +--- + +## Phase 6 — a queue per identity, and the inbox that arrived while another profile was open + +**App. Needs nothing above it to compile; Phase 2 to matter.** The one data +problem, closed from both ends. + +### Whose request + +The two queues that *fetch* gain an owner, because what a fetch brings back is +opened with the fetcher's key. The one that *sends* does not, because a signed +event is anyone's to carry: the signature is the author's whoever puts it on the +wire, and holding A's outgoing message until A is opened again would be worse +than sending it as B. A read-only identity does not run the broadcast pump at all +and the npub plan's rule stands. + +```kotlin +// SynchronizeNostrEventRequest, NegentropySynchronizeRequest +/** The identity that asked. Null for rows written before there was one to record, which any identity may drain. */ +val ownerPublicKey: HexKey? = null, +``` + +Room database version 20, an `AutoMigration` with a comment in the list the way +every nullable addition before it has one: rows written before it read back null, +meaning "the device's", which is what they were. + +**The stamp is the repository's, not the call site's.** Eighteen view models and +two DAO paths queue requests, and every one of them does so *as the active +identity* — a screen cannot queue anything as anyone else. `DatabaseNostrRepository` +takes `activeIdentity: StateFlow` at construction, which the nav host +has in hand where it builds the repository, and `queueSynchronizeNostrEvent` and +`queueNegentropySynchronizeRequest` stamp `ownerPublicKey = activeIdentity.value?.nostrPublicKey` +on every row. The DAO's own requests — the placeholder-profile syncs it plants +while indexing — are stamped from the `activeKeyPair` it is already handed. No +call site changes, and no call site can forget. + +The pumps then ask for their own: + +```sql +SELECT * FROM SynchronizeNostrEventRequest +WHERE status = 'pending' AND (ownerPublicKey = :publicKey OR ownerPublicKey IS NULL) +ORDER BY createdAt ASC LIMIT 1 +``` + +`observePendingSynchronizeNostrEventRequests(publicKey)` and its negentropy twin +take the pubkey the pump already builds its key pair from. A's requests wait for +A; B never opens a wrap that was fetched for A, because B never fetches it. + +### The inbox, reopened + +The other end: wraps that *were* fetched under the wrong key — before this +migration, or by a read-only identity whose nsec was pasted later, the case the +npub plan's DAO guard left with the words "the key that opens this one may be +signed in later" and no code behind them. + +```kotlin +// NostrDao +/** + * Opens every gift wrap addressed to [activeKeyPair] that the device stored but + * could not open at the time -- because another identity fetched it, or because + * this one held no key yet. One pass: wraps do not depend on one another, so a + * wrap that cannot be opened now will not be helped by opening its neighbours. + */ +open suspend fun reopenInbox(activeKeyPair: KeyPair): Int +``` + +The query is every `GiftWrapMessage` with `receiverPublicKey = me` that no +`GiftWrapSeal` names as its `giftWrapMessageId`; the body is the unseal branch of +`indexNostrEvent` from the `isAddressedTo` check down, lifted into an +`openGiftWrap(giftWrapMessage, activeKeyPair)` that both call. A Marmot welcome +inside one of them reaches `MarmotInboundManager` the way it would have on the +day; a direct message is filed as one. + +It runs once per activation, from `SynchronizationViewModel`'s collector, inside +`if (identity.canSign)` and **before** the live subscriptions and the broadcast +pump are launched — so that the sweep and the live inbox are not opening the same +wrap at the same moment. Both are idempotent by event id, so the order is about +not doing the work twice rather than about correctness. + +### Tests + +`ReadOnlyGiftWrapDaoJvmTest` has the fixture: a wrap addressed to A, stored +through B's key pair — event and wrap rows, no seal. `reopenInbox(A)` returns one, +and afterwards the seal and payload are stored and the message is filed; +`reopenInbox(A)` again returns zero and changes nothing; `reopenInbox(B)` returns +zero. A DAO test for the filter: A's pending request is not emitted to B's pump, +a null-owner row is emitted to both, and A's is emitted to A's. And the +repository stamp: a request queued while A is active carries A's pubkey, while +nothing is active carries null. + +--- + +## Phase 7 — leaving one profile among several + +**App. Needs Phases 3 and 4.** Small, because the forget sequence was built +right. + +Sign out of a read-only identity and *forget this key* for a bare key both run +`ForgetIdentity` and then the tail `listIdentities { resetToSelector() }`, which +shows the selector with what remains or Landing if nothing does. That is the right +behaviour with several profiles as well; Phase 3 added `clearDefaultWallet()` to +it, and this phase adds nothing else to the sequence. A profile with a wallet +attached answers `WalletAttached` from Phase 1 and is not offered either exit. + +Two exits learn about the others: + +- **The not-found screen** offers *try again* and, for a read-only identity, *use + a different key*, which forgets it. With other profiles on the device a third + action makes sense and costs one row: *switch profile*, pushing `ProfilesRoute`. + The identity that was not found stays listed; forgetting it remains a separate + decision. +- **Sign out for a profile with a wallet attached** stays `ImplementationPendingRoute`, + for the reason both sign-in plans gave: removing a seed is a wallet question, + and now — since the credential would come back at the next repair — it is + also the only way that profile *can* leave. What changes is that the user is no + longer stuck behind it: *switch profile* is the row above. + +### Tests + +`ForgetIdentityJvmTest` gains: forgetting B while A remains leaves A listed and +the default cleared; forgetting a profile with a wallet attached is +`WalletAttached`. `UnsyncedProfileScreen` composed with two identities has *switch +profile*; with one, it does not. + +--- + +## Phase 8 — the tests that actually prove it + +One round trip, beside the two that exist. + +**Two profiles, both ways, and a boot.** Create A from a phrase — both files +written, A listed once with a wallet attached; write B's npub; list; activate A; +sign B in from inside through the sign-in view model; the tail switches; B is +active and read-only, A's node was stopped and B's never started; +`NavigationViewModel` lands on B's state; switch back; A's state and A's node +again. Queue a kind 1 for A while B is open — not signed; open A — signed. Create +C from inside: a `NostrSecret`, active, with no node. Forget B and C; one +identity, default cleared. Then a *fresh* `SovereignWalletViewModel` over the +same directory, which is what a cold boot is: the repair finds nothing to do, it +lists A, and `StartupChoice` resolves A without asking. + +And `./gradlew :composeApp:m3Audit`: two new screens, a new route, and eight new +strings are where a `16.dp`, a "Switch Profile" and an untriaged +`contentDescription` arrive. + +--- + +## Phase 9 — rollout + +**No library change.** `BusinessManager.stopBusiness` exists on all three +platforms, `saveDefaultWallet` has been in `GlobalPrefs` since Phoenix, and the +credentials file's format already holds what Phase 1 writes into it. This plan +owes no tag, which is the first of the three sign-in plans that can say so. + +**Phase 1 ships alone, and first.** It changes what is on disk: the first launch +after it writes a credential for every seed on the device. A build *before* it, +reached by a downgrade after that write, lists each seed-backed profile twice — +once as a wallet with a derived key, once as a bare key under a different id — +because the old `merge` kept a secret for a seed's key and trusted the writers to +have prevented it. Nothing is lost: both rows sign as the same key and share +every row keyed by it, and the next upgrade lists them as one. It is the one +line of this rollout worth a release note. + +**Phases 2 and 3 ship together.** A switch without the precedence fix lands on +the selector instead of the profile it was asked for — not broken, but it is the +thing the phase is for. Phase 3 without Phase 2 is a startup screen that +remembers a default nobody can change without it. + +**Phases 4 and 5 ship together.** A switcher with no way to add a profile is half +a feature; the add rows without the switcher leave a newly added profile reachable +only through startup, which is the state the device is in today with two. + +**Phase 6 ships whenever it is ready, and should ship early if it can.** It is a +Room migration and a DAO change, and it fixes a case that exists *today*: a device +with two identities, switched at the startup selector a moment after one of them +signed in. The migration is a nullable column, which Room generates and an older +build cannot open — the same downgrade story every migration in this database has +had, and `PlatformDatabaseBuilder` has the same answer. + +**Phase 7 can follow by a release.** Without it, the not-found screen has two +exits instead of three and a wallet-attached profile's sign-out row is the same +pending route it is now. + +**Old builds.** A build before Phase 3 ignores the saved default and shows the +selector, as it does today. A build before Phase 6 drains every queue as whoever +is open, as it does today. Apart from the double listing above, nothing written +by this plan is misread by a build that predates it; it is only not read. + +## Estimate + +| phase | work | days | blocked by | +|---|---|---|---| +| 1 | the credential written and repaired, `merge` inverted, the activation, the forget guard | 1.5–2 | — | +| 2 | the switch, the node stop, the relay observer | 1 | 1 | +| 3 | the precedence, the default, the words | 0.5–1 | 2 | +| 4 | the switcher screen, the profile on the row | 1 | 3 | +| 5 | the add rows, a key-only create, the duplicate that switches, *end this* | 1–1.5 | 4 | +| 6 | the owner column, the pump filter, the inbox reopened | 1.5 | — | +| 7 | the exits | 0.5 | 3, 4 | +| 8 | the round trip | 0.5 | all | +| 9 | rollout | — | all | + +**Roughly a week and a half**, with Phase 6 in parallel from the start if two +people are on it. Two places carry the uncertainty. Phase 1 is where the two +sign-in plans' tests are re-read against a rule they were written to enforce, and +a test that turns out to have been asserting the old model somewhere unexpected +is a test to rewrite, not to delete. Phase 6's unseal branch of `indexNostrEvent` +has never been called from anywhere but `indexNostrEvent`, and lifting it out is +where a transaction boundary will turn out to have been load-bearing. + +Phases 1–8 are now implemented, in one sitting and in order. What each turned out +to require, as against what was predicted here, is in the table at the top and in +the commit messages on this branch. The Phase 6 uncertainty resolved itself by not +lifting the branch at all; the uncertainty that did bite was in no phase — a +process-wide cache in the library, reached by two threads at once for the first +time, which the tests for Phase 5 hit one run in three. + +## Out of scope + +- **A lazy node.** Phase 1 puts the key in the credential, so a profile with a + wallet attached could be activated without starting its node and start it when + a wallet feature asks. Not done here, for the reason under Phase 1: the node is + also where the channel watcher is scheduled, and a restored Phoenix phrase can + have channels. Its own decision, one branch in `doLoadWallet`, and the nsec + plan's "start the node lazily" now has nothing in front of it. +- **A wallet attached to a profile that did not derive it**, or one wallet on the + device that every profile pays from. Phase 1 makes attachment a fact about the + listing — a seed matched to a credential by pubkey — and both sign-in plans + deferred what it would mean to attach by *choice*. `Identity.business` is the + seam, the identity would carry the wallet's id beside its own, and Phase 2's + stop reads `business` rather than the kind so that the switch does not have to + be rewritten when it lands. +- **A second fresh wallet from inside.** Reachable today only through a pasted + recovery phrase. Until a wallet feature exists there is nothing to want it + for. +- **Showing the nsec of a profile with a wallet attached.** Trivially possible + after Phase 1 — the key is in the file — and a real want, for a user taking + their profile to another client. A row on `KeyRecoveryScreen` beside the + phrase; its own small change, with the phrase's warnings rewritten for a + secret that has a wallet behind it. +- **Sign out for a profile with a wallet attached.** Unchanged from both sign-in + plans, and now the only way such a profile can leave the device. +- **Two of the device's own profiles in one Marmot group.** `ChatRoom`'s primary + key is the group id and `userPublicKey` says whose the row is; a second local + member of the same group would overwrite the first's MLS state. Today it cannot + happen — one profile per device — and after this plan it can, by inviting + yourself. Refusing the invite at the join is the cheap fix; keying the room by + `(id, userPublicKey)` is the real one and is a migration of every table that + references a room. Named here so that the first report of it is not a mystery. +- **A database per identity.** The only design that isolates the queues, the + cache and the collision above wholesale. Rejected below, but it is the shape + the collision would eventually force. +- **The social precondition gate.** Every `ProfileLoaded` routes through it, on + every launch, and so on every switch. It is an onboarding screen shown to + people who have finished onboarding; a machine that skips it for an account it + has seen before is one branch in `processLocalAccount`, and its own decision. +- **Per-profile lock, per-profile notifications.** The lock prompt is a stub and + the app has no push. Both are per-id already in the preferences and would slot + into the activation this plan routes everything through. +- **Editing a profile's local name and avatar.** The metadata dialog is a stub, + and after Phase 4 the row shows the nostr profile, which is the name. +- **Tightening secrets in memory; NIP-46; NIP-49.** Inherited, unchanged. Phase 1 + adds one copy of a secret the device already held in derivable form, under the + same keystore key, and the decrypt-at-activation both plans deferred applies + to it as to the others. + +## Appendix — what was considered and rejected + +### A seed's profile by derivation only + +The model the code had, and the one this plan's first draft built on: the +credentials file holds only bare keys, a seed is a profile because its key can +be derived, and the writers keep one key out of two files. It needs no repair +and holds every secret once. It was rejected because it puts the wallet where the +profile should be: the list of profiles is a merge of two files with opposite +ideas of what a row is, the node has to run for a profile to know its own key, +and a wallet can never belong to a profile it did not derive. The cost of +changing it — Phase 1 — is a day and a half; the cost of building a switcher on +it and changing later is that switcher, twice. + +### One id space for every profile + +Under the model this plan takes, a profile could be keyed by `hash160(pubkey)` +whether or not a wallet is attached, with the wallet's `hash160(nodeId)` carried +beside it. Cleaner on paper, and rejected: the library keys the node's +preferences and metadata by the wallet's id inside `startNewBusiness`, so a +seed-backed profile would have two preference files — one the app writes, one +the node reads — and every existing device would need its metadata and backup +flags copied from the wallet's id to the profile's. A profile's id being its +wallet's when it has one keeps every file where it is, keeps `stopBusiness` and +`updateBusinessActiveInUI` one argument, and costs only what the npub plan already +accepted for the read-only upgrade: a profile that acquires a wallet gets fresh +preferences. + +### A live swap under the stack + +Set the identity and keep the screens. Every route carries the old key, every +view model was built from a route, and the navigation component reads the key off +the current route by design. Either every screen re-reads the identity from a +flow — the parameter-in-forty-three-lists problem the snackbar host and the +signing capability were both invented to avoid — or the stack is rebuilt, which +is the restart. The restart also gets the lock gate for free: an identity is only +ever activated through `LoadWallet`. + +### A global switcher control + +The avatar in the app bar, as Google's apps do it; a long press on the profile +tab; a swipe on the navigation bar. The home app bar lost its avatar by an +information-architecture decision recorded in `TopLevelDestination`, and putting +one back for a different purpose is two meanings on one control. A long press is +undiscoverable. And every one of them fires a `popUpTo(0)` from wherever the user +is — a switch is one deliberate tap on a screen that says what it does, and the +profile tab's row is where the app has been promising it. + +### A bottom sheet instead of a screen + +The other common shape, and the app has a `ModalBottomSheet`. Rejected for +consistency rather than on merit: the startup selector is a screen, both lists +are the same widget with the same rows, and the add rows push screens anyway. A +sheet that pushes screens from under itself is a screen with a worse back story. + +### Keeping the previous node running + +The cheaper switch-back — `startNewBusiness` returns a running node in +milliseconds. But nothing reads the node except the key, and after Phase 1 not +even that; the restart tears down everything else built for that identity, and a +background node is Electrum, the LSP peer and the swap-in watcher for a profile +that is not open. If a wallet feature ships and a node has something to do while +its profile is closed, this is the decision to revisit, and it is one branch in +`switchToIdentity`. + +### Stamping the owner at every call site + +Eighteen view models each passing `activeUserPublicKey` into a queue call. +Mechanical, wide, and forgettable: the nineteenth would pass nothing. The +repository is built once, has the identity flow one parameter away, and every +request it writes is made as the active identity by construction. + +### Filtering the broadcast queue by owner + +Symmetry with the fetch queues. Rejected because a signed event needs no key to +send and holding A's message until A returns is a delivery failure the user would +never be told about. The asymmetry is the point: fetches are opened with a key, +sends are not. + +### A database per identity + +Isolates everything at once, including the group-membership collision. Costs the +shared profile cache — every kind 0 fetched once per profile — a Room instance +rebuilt on every switch, and a migration that splits the existing file by +`userPublicKey` across every table that has one and guesses for the tables that +do not. Deferred rather than rejected: it is the answer if the collision becomes +common, and nothing in this plan makes it harder to do later. diff --git a/docs/npub-profile-preview.md b/docs/npub-profile-preview.md new file mode 100644 index 00000000..77ab69b5 --- /dev/null +++ b/docs/npub-profile-preview.md @@ -0,0 +1,812 @@ +# A profile before a chat + +How the "Direct message via npub" option comes to show the person before it starts +anything, why the preview is a screen reached by public key rather than a step inside +the dialog, and what the "Start new chat" button on it is allowed to do. + +Read this with `HomeScreen.kt`, `StartDirectMessageToNpubOrNip05Dialog.kt` and +`ChatRoomMessagingViewModel.kt` open. It changes the entrance to the direct-message +flow and nothing past it: the room is still created where it is created today, by +the code that creates it today. + +**Built**, phases 1–4, one commit each, in the order given. Phases 1–3 are the +request as stated — a preview, and a button on it — and ship as a strict improvement +on today. Phase 4 is what makes the button honest, and it is last so that the first +three do not wait on it. The phases are kept as written because they are the +reasoning; where the implementation chose differently the table below says so: + +| what the plan said | what it turned out to be | +|---|---| +| a new string, `no_profile_was_found_for_this_npub` | the sign-in screen's `we_could_not_find_a_profile_for_this_key` — "We could not find a profile for this key on the relays we asked." One string, two screens, one meaning, as the npub plan found with `use_a_different_key` | +| a new string, `open_chat` | it existed: `SelectChatRoomTypeScreen`'s. The build failed on the duplicate key, which is the catalogue doing its job | +| `findDirectMessageRoom` as "the rule `initiateNewChat` applied" | the rule's *intent*: a room whose members are exactly the two keys, or the one key when they are the same. The old size-one branch would have opened any room in which the peer sat alone, whoever the peer was; the new rule needs that lone member to be you, and the repository test pins a room the peer has with somebody else as not yours | +| `retry()` from `NotYetOnMantra` | from any `Loaded`: it re-asks the DM relays alone and touches the readiness only when it was `NotYetOnMantra`, so a retry never takes an "Open chat" back to "Checking" | +| the timeout "fires with a person on screen" | a flag, `relaysHadTheirTime`, because the profile can arrive *after* the timeout and has to read not-yet-on-Mantra rather than checking; cleared by a retry, and `@Volatile` because the timeout and the collect are different coroutines | +| the view model tested on `TestScope` virtual time | real time, as the other view-model tests: `Dispatchers.setMain(UnconfinedTestDispatcher())`, a poll under `withTimeout`, and the twenty seconds cut to three hundred milliseconds through the constructor | +| `initiate()` "runs from a `LaunchedEffect`" | and is idempotent besides: a recomposition that calls it again opens no second collect, which the view-model test asserts | +| a screenshot pass under `docs/material-design-conformance.md` | five PNGs from a throwaway `jvmTest` with `captureToImage`, looked at and deleted — the found, not-found, checking, not-yet-on-Mantra and existing-chat states, at a phone's width | +| Phase 2's screen with one repository | Phase 4 adds `chatRepository` to the screen, its host entry and its tests, as the plan said it would; the Phase 2 commit is the screen without it | +| a `Button` under the block, "because a FAB has no disabled state in M3 or in the API" | **superseded on the way in**: `curated` gained `ExtendedFab` while this was built — every screen's bottom-bar action, with an `enabled` that borrows the disabled colours, marks the node disabled and drops the press — so the reason for the `Button` was gone before the branch merged. The action is that FAB in a `BottomAppBar`, the indicator in its icon slot while the key package is owed, and the reason line stays in the body above it | + +One thing found on the way that is in no phase: the dialog's `Dialog` renders inside +the desktop test root, so `onNodeWithText` finds its field and its button without a +popup matcher, and `performTextInput` drives the `inputTransformation` on every +edit — which is what made the `nostr:npub1…` row a four-line test. + +## The flow today + +1. The "New chat" button on the home screen opens the sheet + ([HomeScreen.kt:193](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt), + and again at 300 for the two-pane layout). +2. `NewChatBottomSheetDialog` — titled "Create new chat" — offers two cards. The + first, "Direct message via npub", hides the sheet and opens a dialog + ([HomeScreen.kt:353](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt)). +3. `StartDirectMessageToNpubOrNip05Dialog` is one field and a "Start chat" button. + The button is enabled when the text starts with `npub1` or `nostr:npub1`, or parses + as an email-like address + ([StartDirectMessageToNpubOrNip05Dialog.kt:125](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/StartDirectMessageToNpubOrNip05Dialog.kt)). +4. "Start chat" decodes the npub and pushes `ChatRoomMessagingRoute(chatRoomId = )` + (line 162). A nip05 goes to `ImplementationPendingRoute` (line 153). +5. `ChatRoomMessagingScreen` finds no room under that id and moves to + `InitiatingNewChat` + ([ChatRoomMessagingViewModel.kt:52](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomMessagingViewModel.kt)), + and `initiateNewChat` (line 69) looks for a room that already has the two of you + in it, otherwise waits on the peer's profile *and* key package, with a twenty-second + timeout, then calls `createMlsDirectMessage` (line 153) and replaces itself with the + room it made. + +Three things are wrong with it, in the order a user meets them. + +**Nothing is confirmed.** The pasted string goes straight to creation. What the user +sees is "Creating new chat." and then a transcript with a name in the bar — or the +twenty seconds and "Couldn't find this profile's chat details on the relays yet" — +and in neither case a way to tell *wrong npub* from *right person, not on Mantra yet*. +The one place in the app that shows a person and offers "Send message", +`MetadataEventDetail` +([MetadataEventDetail.kt:296](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt)), +is reached by the *event id* of a kind 0 the device already holds, and a pasted npub +has neither. + +**`nostr:npub1…` is accepted and then dropped.** The field's check allows the prefix; +`bech32ToHexOrNull` on line 158 is handed the text with the prefix still on it, +fails, and the null goes unhandled. The dialog has already closed. An npub +with a bad checksum takes the same silent exit. + +**The creation is committed before anything is confirmed.** When a key package is +there — a real person on Mantra, only not the one meant — `createMlsDirectMessage` +writes a room, an MLS group and a Welcome on the strength of a pasted string, and +the wrong person is invited to a conversation before the right one has been looked +at. + +## What is already built + +Almost all of it. The preview is a new screen over pieces that exist. + +| piece | where | state | +|---|---|---| +| a person plus one action, reached by pubkey: `AddMemberToChatRoomConfirmationScreen` waits on a key package with a timeout and shows the profile's name over a bottom-bar button | [AddMemberToChatRoomConfirmationViewModel.kt:74](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddMemberToChatRoomConfirmationViewModel.kt) | the shape, whole | +| the existing-room check, the profile-and-key-package wait, the timeout, the creation, the self-replacement | [ChatRoomMessagingViewModel.kt:69](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomMessagingViewModel.kt) | stays exactly where it is; the preview hands over to it | +| the three-kind filter a chat needs — kind 0, key package, DM relay list — on the DM relays | `ChatRoomMessagingViewModel.scheduleProfileAndKeyPackageSync`, line 182 | private; lifted out in Phase 1 | +| a sync object with tests, and the rule that a placeholder is not a profile: `createdAt > GENESIS_AT` | [MemberProfileSync.kt:52](../composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MemberProfileSync.kt) | the precedent for both | +| where a kind 0 is found for a key that has lived elsewhere: the indexer relays plus our own | [SignInSync.kt:53](../composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/SignInSync.kt), `bootstrapRelays` | reused as is | +| the full npub validation — `nostr:` stripped, case folded, hrp checked, thirty-two bytes, a point on the curve | [CredentialParser.kt:139](../composeApp/src/commonMain/kotlin/press/mantra/compose/identity/CredentialParser.kt) | private; exposed in Phase 3 | +| `observeProfileWithPublicKey`, `observeMarmotKeyPackageForPublicKey`, `getAllParticipantsWithPubKey`, `getChatRoomByIdentifier` | `NostrRepository`, `ChatRepository` | built | +| the queue stamps the asking identity on every request | [DatabaseNostrRepository.kt:686](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt) | nothing for a view model to do | +| a re-queued negentropy request inside the same minute re-arms the row rather than being ignored | [NegentropySynchronizeRequestDao.kt:32](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NegentropySynchronizeRequestDao.kt) | "try again" works | +| a kind 0 arriving overwrites the placeholder row | [NostrDao.kt:424](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt) | the observed flow emits it | +| the stack shape for "a profile becomes a chat": push the room, pop the profile | [MantraNavHost.kt:1706](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt), `NostrEventDetailRoute`'s DM exit | copied | +| `ProfileAvatar`, `humanReadableNameOrPubkey()`, `staticIdentifier()`, `hexToNpubHrp()` | `Profile.kt`, `Credentials.kt` | built | +| a constant app bar with the state transition inside it | [GroupNostrProfileScreen.kt:106](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileScreen.kt) | the layout to copy | +| `LoadingDataIndicator(text = …)`, `EmptyState(message, icon, action)`, `ErrorState(onRetry)`, `NavigateBackButton`, `LocalCanSign` | `widgets/` | built | +| strings: `profile`, `try_again`, `start_chat`, `send_message` | `strings.xml` | present; the new ones are listed per phase | + +## The four decisions + +### Where the preview lives: a pushed screen, by public key + +Three places were possible. Inside the dialog, under the field, as a card that +appears once the npub parses. On a screen that replaces the dialog, with the field at +its top. Or on a screen of its own, reached from the dialog by the pubkey it parsed. + +The third. The preview has real states — asked, found, not found after the relays +have had their time, and failed — and the conventions in `CLAUDE.md` put those on a +screen: `ScreenStateTransition` over a `when`, `EmptyState` with an action, +`ErrorState` with a retry, and a `NavigateBackButton` so the desktop has a way off +it. A `Dialog` has none of that, and one that spends twenty seconds searching relays +is a dialog doing a screen's job. Replacing the dialog with a screen is the better +product and is the one refactor this plan leaves for later, on purpose: it changes +the entrance, where the request was about what comes after it, and with the preview +keyed by pubkey the field can move onto the same screen without the screen changing +(see the [appendix](#the-field-on-the-preview-screen)). + +Keyed by *pubkey*, not by the kind 0's event id, because that is all the entrance +has. It is also why the same route will serve a scanned QR code, a `nostr:npub…` +link and, once one exists, a resolved nip05: every one of them arrives as a key. + +### What "found" means: a resolved kind 0, from the relays that hold everyone's + +A pubkey gets a `Profile` row the moment the device first sees it, and that row is +the "LOADING..." placeholder stamped `GENESIS_AT` +([NostrDao.kt:273](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt)). +The preview shows a profile only when a kind 0 has been read for it — the rule +`ChatRoomMessagingViewModel` already applies at line 136 and `MemberProfileSync` at +line 58, given a name in Phase 1 so that it is applied once. + +Whom to ask is the sign-in plan's answer, for the sign-in plan's reason: an npub +pasted from elsewhere has lived elsewhere, and the indexer relays exist to hold +everyone's kind 0. So the kind 0 is asked of `SignInSync.bootstrapRelays` — the +indexers plus our own relay — and the key package and DM relay list, which only our +relay holds, are asked of the DM relays with the filter the chat screen already +uses. Two requests, not one, because a three-kind filter sent to an indexer returns +the kind 0 and nothing else, and a kind-0-only filter sent to our relay finds a +person who is not on Mantra nowhere. + +Cached first. The observed flow emits the row the device already holds before any +relay answers, so a person already known — a room member, a follow, a search result +— is on screen at once, and the relays refresh them behind it. + +### What the button does: hands over; it does not create + +"Start new chat" pushes `ChatRoomMessagingRoute(chatRoomId = , relayHint = null)` +and pops the preview — exactly the route the dialog pushes today, and exactly the +exit `MetadataEventDetail`'s "Send message" takes. `initiateNewChat` then does what +it has always done: finds the existing room or creates one, and replaces itself with +it. Back from the room is the home screen, as it is from a chat opened off a profile. + +Not creating on the preview keeps one creation path with one set of failure modes, +and keeps the preview a *reader*: nothing on it writes until the button is pressed, +and the button writes by leaving. It also means a read-only identity can be shown +the screen with the button hidden behind `LocalCanSign`, as `MetadataEventDetail` +hides its own, and the inventory in `ReadOnlyEntrancesJvmTest` gains a row rather +than an exception. + +### Whether the button waits: yes, in its own phase + +With Phases 1–3 alone the preview moves the twenty-second dead end one screen later: +the person is found on an indexer, the button is pressed, and `InitiatingNewChat` +waits for a key package that a person who has never opened Mantra will never have +published. Phase 4 answers that on the preview, where the answer can be read before +the button is pressed — a room that already exists opens; a key package that is +here enables the button; one that is not, after the relays have had their time, +disables it and says why. The two DM view models already wait on the key package +with the same timeout; Phase 4 waits on it one screen earlier, and the screen after +it never waits at all. + +Its own phase because it changes what the button is allowed to do, and because it +adds a repository method whose rule — what counts as a direct message between two +keys — is today a loop with a `TODO` in it. That rule deserves its own commit and its +own test. + +--- + +## Phase 1 — the lookup, and the sync it shares + +**No screen.** A sync object, one rule named, and a view model — everything the +screen will read, testable without a composition. + +### `DirectMessagePeerSync` + +`ChatRoomMessagingViewModel.scheduleProfileAndKeyPackageSync` lifted into +`press.mantra.compose.nostr`, beside `MemberProfileSync` and shaped like it, with the +kind 0 request the preview adds: + +```kotlin +/** + * Asking the relays for what a direct message with one person needs. + * + * Two asks, because two sets of relays hold two different things. The DM relays + * hold the key package and the DM relay list, and the kind 0 of anyone who has + * published one here; the indexer relays hold everyone's kind 0 and nothing else + * a chat needs. A three-kind filter to the indexers comes back with the kind 0 + * alone; a kind-0-only filter to our relay finds a person who has never opened + * Mantra nowhere. See docs/npub-profile-preview.md. + */ +object DirectMessagePeerSync { + /** The purpose the chat screen has queued under since it was written; kept so nothing reading the queue by purpose changes. */ + const val PURPOSE = "initiate-chat" + const val PROFILE_PURPOSE = "profile-preview" + + /** What a chat needs. No `limit`: ignored on the negentropy path and, on the REQ fallback, `limit = 1` across three kinds returned the kind 0 alone. */ + val KINDS = arrayOf(MetadataEvent.KIND, KeyPackageEvent.KIND, ChatMessageRelayListEvent.KIND) + + /** One negentropy request per DM relay, level 0, the id bucketed by minute so a retry re-arms the row. */ + fun requests(peerPublicKey: HexKey): List + + /** The kind 0 alone, as a REQ to `SignInSync.bootstrapRelays`, level 0. */ + fun profileRequests(peerPublicKey: HexKey): List +} +``` + +`ChatRoomMessagingViewModel.scheduleProfileAndKeyPackageSync` becomes one line that +queues `DirectMessagePeerSync.requests(chatRoomId)`. Its comment about `limit` moves +onto `KINDS`, where the next reader will look for it. `AddMemberToChatRoomConfirmationViewModel.scheduleKeyPackageSync` +asks for the key package alone under a different purpose and is left as it is. + +Level 0 on both, for the reason `MemberProfileSync` gives: it marks a request as one +somebody is waiting on, and a kind 0 arriving at level 0 over a placeholder queues the +rest of that person's profile kinds off the back of it +([NostrDao.kt:1243](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt)). +From an indexer that follow-up asks the indexer for key packages it does not hold, +which is harmless and is why the DM-relay request is queued explicitly rather than +relied on. + +### `Profile.isResolved()` + +```kotlin +/** Read off a kind 0, as against the "LOADING..." placeholder minted when a pubkey is first seen. */ +fun isResolved(): Boolean = createdAt > GENESIS_AT +``` + +A member function on `Profile`, beside `humanReadableNameOrPubkey()`. The two +call sites that spell the rule out — `ChatRoomMessagingViewModel` line 136 and +`MemberProfileSync` line 58 — switch to it in this commit, so that there is one +definition of resolved. The DAO's own `== GENESIS_AT` comparisons stay: they are +on the write side, deciding whether a row *is* the placeholder, and read better as +they are. + +### `ProfilePreviewViewModel` + +```kotlin +sealed interface ProfilePreviewUIState { + data object Loading : ProfilePreviewUIState + data class Loaded(val profile: Profile) : ProfilePreviewUIState + /** Every relay asked has had its time, and no kind 0 came. Not an error: a search that found nothing. */ + data object NotFound : ProfilePreviewUIState + /** The queue itself refused: nothing was asked. */ + data object Error : ProfilePreviewUIState +} + +class ProfilePreviewViewModel( + val activeUserPublicKey: HexKey, + val profilePublicKey: HexKey, + initialProfilePreviewUIState: ProfilePreviewUIState, + val nostrRepository: NostrRepository, +) : ViewModel() { + var profilePreviewUIState by mutableStateOf(initialProfilePreviewUIState); private set + + fun initiate() { observeProfile(); askTheRelays() } + + /** Asks again. The observation is still live, so the answer arrives through the same collect. */ + fun retry() { profilePreviewUIState = ProfilePreviewUIState.Loading; askTheRelays() } +} +``` + +`observeProfile` collects `observeProfileWithPublicKey(profilePublicKey)`, maps a +row that is not resolved to null, `distinctUntilChanged`, and on a non-null profile +cancels the timeout and sets `Loaded`. A null keeps waiting; the timeout is what +says when to stop. + +`askTheRelays` cancels any running timeout, starts one — `RELAY_LOOKUP_TIMEOUT`, +twenty seconds, the value both DM view models use — that moves `Loading` to +`NotFound` when it fires, and queues `profileRequests` and `requests`. A throw from +either queue call sets `Error`. The collect is never cancelled by the timeout: a +kind 0 that arrives on the twenty-first second still loads, exactly as +`ChatRoomMessagingViewModel` keeps its collect live past its own. + +The timer rather than the queue's own "every relay has answered" signal, which the +sign-in screen reads through `LocalAccount`: that signal is joined to the account's +unsigned event, and a peer has none. The two DM screens use the timer; this is a +third, and the signal is a follow-up for all three (see out of scope). + +### Tests + +`DirectMessagePeerSyncTest` in `commonTest`, beside `MemberProfileSyncTest`: + +- `requests` yields one row per DM relay, purpose `initiate-chat`, level 0, the three + kinds and the one author, no limit, and an id equal to `computeId` of its relay and + filter; +- `profileRequests` yields one row per bootstrap relay, purpose `profile-preview`, + kind 0 alone, the one author, level 0, no `unsignedNostrEventId`; +- the two relay sets overlap on `ephemeral` and nowhere else — pinned so that a + change to either set is noticed here. + +`ProfilePreviewViewModelJvmTest`, with the recording fake `SignInToProfileViewModelJvmTest` +uses — `NostrRepository by NO_OP_NOSTR_REPOSITORY` with the profile flow a +`MutableStateFlow` and both queue calls appended to an `effects` list: + +- `initiate` queues both request lists, once each, and the state is `Loading`; +- a placeholder row (`createdAt = GENESIS_AT`) does not load; +- a resolved row loads, and loads *before* the queue is asked if it was already + there — cached first; +- with nothing arriving the state is `NotFound` after the timeout, and a resolved + row arriving after that still loads; +- `retry` from `NotFound` returns to `Loading` and queues both lists again; +- a queue call that throws sets `Error`, and `retry` from `Error` asks again. + +The view model launches on `Dispatchers.IO`, as the others do, so virtual time is +no help: the test does what `AcceptCuratedSuggestionViewModelJvmTest` does — +`Dispatchers.setMain(UnconfinedTestDispatcher())`, and `withTimeout(5_000)` around a +poll of the state — and the view model takes `RELAY_LOOKUP_TIMEOUT` as a +constructor parameter with the twenty-second default, so the timeout cases pass a +few hundred milliseconds instead. + +`MemberProfileSyncTest` and whatever covers `ChatRoomMessagingViewModel` stay green +through the `isResolved()` switch, which is the point of switching them in the same +commit. + +--- + +## Phase 2 — the screen and its route + +**Reachable by nothing.** The route is registered and the screen is complete, and +no button in the app navigates to it until Phase 3. That is deliberate: the screen +is reviewed as a screen, against its previews and its tests, before the flow that +depends on it changes. + +### The route + +```kotlin +@Serializable +data class ProfilePreviewRoute( + val activeUserPublicKey: String, + val profilePublicKey: HexKey, +): Route() +``` + +No `relayHint`. The dialog has none to give, and `ChatRoomMessagingRoute` takes +null for it today from the same dialog. + +### The screen + +`ProfilePreviewScreen`, in `ui/composable/`, laid out as `GroupNostrProfileScreen` +is: a constant `Scaffold` with a `TopAppBar` — title `profile`, `NavigateBackButton` +in the leading slot — and `ScreenStateTransition` over the view model's state +inside the content lambda. The `when` is not the composable's whole body, but none +of its branches needs `ColumnScope`, which is the one thing that layout costs. + +```kotlin +@Composable +fun ProfilePreviewScreen( + activeUserPublicKey: HexKey, + profilePublicKey: HexKey, + initialProfilePreviewUIState: ProfilePreviewUIState = ProfilePreviewUIState.Loading, + nostrRepository: NostrRepository, + onStartChat: (Route) -> Unit, + onNavigateBack: () -> Unit, +) +``` + +The four states: + +| state | what is drawn | +|---|---| +| `Loading` | the npub, in `bodySmall` and `onSurfaceVariant`, so the user can already check it is the one they pasted; under it `LoadingDataIndicator(fillScreen = false, text = looking_for_this_profile_on_the_relays)` | +| `Loaded` | the profile block and the button, below | +| `NotFound` | `EmptyState(message = no_profile_was_found_for_this_npub, icon = Icons.Default.PersonSearch, action = TextButton(try_again) → viewModel.retry())`. `EmptyState`, not `ErrorState`: nothing went wrong, the search came back empty, and that is the one absence this screen has to name | +| `Error` | `ErrorState(onRetry = viewModel::retry)` — the queue refused, and asking again is the right retry | + +The profile block, in a `Column` under `readableContent()`, leading-aligned — rows +with an avatar align to a leading edge, and centring is for a block that is the only +thing on the screen, which this is not once the button is under it: + +- `ProfileAvatar(size = 75.dp, profile, publicKey)` — 75dp is a dimension, not a + spacing, and is what `MetadataEventDetail` uses; +- `humanReadableNameOrPubkey()` in `titleLarge`; +- `staticIdentifier()` in `labelMedium`, when there is one — the nip05 or the + lightning address, which is the thing a user compares against what they were + told; +- `about` in `bodyMedium`, the whole of it: this is the one screen whose job is to + let the user read it; +- the npub, `hexToNpubHrp()` remembered once, in `bodySmall` and + `onSurfaceVariant`, wrapped — sixty-three characters of bech32 are what the user + pasted, and the block is not a confirmation without them. + +The button, under the block, full width, only when `LocalCanSign.current`: + +```kotlin +Button(onClick = { + onStartChat(ChatRoomMessagingRoute(activeUserPublicKey, chatRoomId = profilePublicKey, relayHint = null)) +}) { + Icon(Icons.Default.Mail, contentDescription = Decorative) // the label is beside it + Spacer(Modifier.width(MaterialTheme.spacing.relatedGap)) + Text(stringResource(Res.string.start_new_chat)) +} +``` + +A `Button`, not the bottom-bar `ExtendedFloatingActionButton` the invite +confirmation uses, because Phase 4 will disable it with a reason beside it, and a +FAB has no disabled state in M3 or in the API. (Superseded before the merge — see the +table at the top.) + +A read-only identity sees the block and no button. The screen is not reachable by +one today — the sheet is behind `canSign` — but the route will be reachable from a +link one day, and `MetadataEventDetail` already makes the same choice for the same +control. + +`initiate()` runs from a `LaunchedEffect` only when the screen arrives `Loading`, as +every screen that takes an initial state does; a test that passes `Loaded` never +touches the repository. + +### The words + +| name | text | +|---|---| +| `start_new_chat` | Start new chat | +| `looking_for_this_profile_on_the_relays` | Looking for this profile on the relays. | +| `no_profile_was_found_for_this_npub` | No profile was found for this npub on the relays it was asked of. Check the npub, or try again later. | + +Sentence case throughout. Apostrophes are written plainly — `StringCatalogueJvmTest` +is the record that `Don't` survives the round trip and `Don\'t` does not. + +### The host + +In `MantraNavHost`, after `NostrEventDetailRoute`: + +```kotlin +composable { backStackEntry -> + val route = backStackEntry.toRoute() + ProfilePreviewScreen( + activeUserPublicKey = route.activeUserPublicKey, + profilePublicKey = route.profilePublicKey, + nostrRepository = databaseNostrRepository, + // The room replaces the preview, as it replaces the profile detail: back + // from a chat is the list, not the person it was started from. + onStartChat = { chatRoom -> + navController.navigate(route = chatRoom) { popUpTo(route) { inclusive = true } } + }, + onNavigateBack = { navController.popBackStack() }, + ) +} +``` + +The stack after the button: `Home → ChatRoomMessaging(pubkey)`, and once +`initiateNewChat` has made the room, `Home → ChatRoomMessaging(roomId)` — the +same two steps the dialog produces today, with the preview gone from between them. + +### Tests + +`ProfilePreviewScreenJvmTest`, on the pattern of `GroupNostrProfileScreenJvmTest` +— `runDesktopComposeUiTest` at a phone's width, `MantraTheme`, `ProvideSnackbarHost`, +the state passed in, the NO_OP repository never asked: + +- `Loaded` draws the name, the nip05, the whole of the about, and the npub; +- "Start new chat" is there, and pressing it hands over exactly + `ChatRoomMessagingRoute(activeUserPublicKey, chatRoomId = pubkey, relayHint = null)`; +- with `LocalCanSign` false the block is drawn and the button is not — searched on + the unmerged tree, as `ReadOnlyEntrancesJvmTest` explains; +- `NotFound` shows its message and a "Try again"; +- `Loading` shows the npub above the indicator. + +`ReadOnlyEntrancesJvmTest` gains the row: the preview is a write entrance, and the +inventory is the list of those. + +A `@ConformancePreviews` preview of the `Loaded` state, as every screen has, so the +screenshot pass in `docs/material-design-conformance.md` covers it. + +--- + +## Phase 3 — the dialog hands over + +**The user-visible change.** After this commit "Direct message via npub" shows the +person before anything is started. + +### The parse + +`CredentialParser.npub` is exactly the validation the dialog needs and is private +to a parser for sign-in credentials, whose `parse` would also recognise a pasted +nsec — which a message-address field must never treat as an address. So the branch +is exposed on its own: + +```kotlin +/** + * The x-only key under an `npub1…`, with or without a `nostr:` prefix, or null. + * The one shape a message address can arrive in: hex stays a secret here for the + * reason [parse] gives, and an nsec is refused because it is not an address. + */ +fun npubOrNull(raw: String): HexKey? +``` + +`parse` calls it from its `npub1` branch, so the two cannot disagree. A pasted +`nprofile1…` returns null: the TLV form is a different shape, and reading its +pubkey and relay hints out is its own small change (out of scope, and the route +already has room for the hint). + +### The dialog + +`StartDirectMessageToNpubOrNip05Dialog` keeps its title, its field, its focus rule +and its nip05 branch. What changes: + +- the `inputTransformation` keeps what it parsed rather than whether it parsed: + `parsedPublicKey`, a `MutableState` set from `npubOrNull(text)`, replaces + the boolean `startChatButtonEnabled`, and the button is enabled when it or the + email-like parse is non-null. The `nostr:` bug and the bad-checksum bug were one + bug — the check and the decode were two different functions — and holding the + decoded key is the fix, because there is no second decode to disagree; +- the button reads `find_profile` with `Icons.Default.PersonSearch`, because it no + longer starts a chat and a label that says it does is the thing M3's "tell users + what will happen" is about; +- on confirm, the npub branch pushes + `ProfilePreviewRoute(activeUserPublicKey, profilePublicKey = parsedPublicKey)` + through the same `onNavigateToRoute` — `HomeScreen` passes a push and needs no + change; the nip05 branch is untouched. + +The imports of `bech32ToHexOrNull` and `ChatRoomMessagingRoute` go with the old +branch. `NewChatBottomSheetDialog` is not touched. + +### The words + +| name | text | +|---|---| +| `find_profile` | Find profile | + +`start_chat` goes with its only reader, the button it labelled; `start_chat_via_npub_or_nip05`, the dialog's title, stays. + +### Tests + +`CredentialParserJvmTest` gains rows for `npubOrNull`: a bare npub, `nostr:npub1…`, +an upper-cased npub, a bad checksum, an `nsec1…`, an `nprofile1…`, sixty-four hex +characters — the last four all null. The npub the sign-in tests already use is the +fixture. + +A `StartDirectMessageToNpubOrNip05DialogJvmTest`: + +- typing a valid npub enables "Find profile", and confirming hands over + `ProfilePreviewRoute` with the decoded key; +- `nostr:npub1…` does the same — the test that would have failed on the old code; +- an nsec leaves the button disabled; +- an email-like address enables it and still lands on `ImplementationPendingRoute`, + pinned so that the nip05 branch's behaviour is recorded rather than assumed. + +The compose test drives the `TextField` through `performTextInput`; the +`inputTransformation` runs on every edit, so no other trigger is needed. + +--- + +## Phase 4 — ready before the button + +**The button becomes honest.** It opens a chat that exists, starts one that can be +started, and otherwise says why not, before it is pressed rather than twenty seconds +after. + +### The existing room + +`initiateNewChat`'s loop — every `Participant` row for the peer, its room, the +size-one-or-two rule, the `TODO` about groups of two — becomes a repository method +with the rule as its contract: + +```kotlin +/** + * The room that is a conversation between [userPublicKey] and [peerPublicKey] + * alone, if there is one: two participants and both of them, or one when the two + * keys are the same. A group of two is not told apart from a direct message here — + * the schema does not record which a room is — so this is the rule `initiateNewChat` + * has applied since it was written, in one place with its name on it. + */ +suspend fun findDirectMessageRoom(userPublicKey: HexKey, peerPublicKey: HexKey): LocalChatRoom? +``` + +On `ChatRepository`, implemented in `DatabaseChatRepository` from +`getAllParticipantsWithPubKey` and `getChatRoomByIdentifier`, as the loop is today. +`initiateNewChat` calls it and navigates to what it returns; its own loop goes. +Whether a room the user has *left* (`leftGroupAt`) should count is a question the +loop never asked, and this phase does not answer it: same rule, one place. + +### Readiness + +`Loaded` grows a second field: + +```kotlin +data class Loaded(val profile: Profile, val readiness: Readiness) + +/** Whether pressing the button would get anywhere, read here rather than twenty seconds into the next screen. */ +sealed interface Readiness { + /** A conversation with this person exists; the button opens it. */ + data class ExistingChat(val chatRoomId: String, val relayHint: String?) : Readiness + /** Their key package is here; `initiateNewChat` will create the room without waiting. */ + data object CanStartChat : Readiness + /** Asked, not answered yet. */ + data object Checking : Readiness + /** The relays have had their time and no key package came. */ + data object NotYetOnMantra : Readiness +} +``` + +The view model takes `chatRepository` and, in `initiate`, reads +`findDirectMessageRoom` once before observing. Then it collects, as +`ChatRoomMessagingViewModel` does at line 130, the profile flow *combined with* +`observeMarmotKeyPackageForPublicKey`, and on each emission with a resolved profile +sets `Loaded(profile, readiness)` where readiness is `ExistingChat` if the room was +found, else `CanStartChat` if the key package is non-null, else `NotYetOnMantra` if +the timeout has fired, else `Checking`. The one timeout now does two things when it +fires: `Loading` becomes `NotFound`, and `Loaded(_, Checking)` becomes +`Loaded(_, NotYetOnMantra)`. A key package cancels it only if the profile has also +arrived; a profile cancels nothing on its own, since the key package is still owed. + +`retry()` from `NotYetOnMantra` keeps the profile on screen — it is found; it is the +key package being asked for again — sets readiness back to `Checking`, restarts the +timeout and queues `DirectMessagePeerSync.requests` alone. + +`ExistingChat` carries the room's `relayHint` from its `Participant` row for the +peer, which is what `ChatRoomMessagingRoute` wants and what the dialog never had. + +Readiness is a fact about a *loaded* profile, and a room does not make a person +known: a member still "LOADING..." — the case `MemberProfileSync` exists for — +waits on their kind 0 here like anyone else, and the open button appears with the +name. The alternative, a `Loaded` over a placeholder, would put "LOADING..." in +`titleLarge` on a screen whose one job is to show who this is. + +### The screen + +`ProfilePreviewScreen` and its host entry gain `chatRepository: ChatRepository`, +passed as `databaseChatRepository` like every screen that reads a room; the +factory hands it to the view model. The button reads its state: + +| readiness | button | under it | +|---|---|---| +| `ExistingChat` | `open_chat`, enabled → `ChatRoomMessagingRoute(chatRoomId = room id, relayHint)` | `you_already_have_a_chat_with_s` | +| `CanStartChat` | `start_new_chat`, enabled → as Phase 2 | nothing; the button is the answer | +| `Checking` | `start_new_chat`, disabled | a `LoadingIndicator` and `checking_whether_s_can_receive_messages_here` | +| `NotYetOnMantra` | `start_new_chat`, disabled | `s_hasn_t_set_up_messaging_on_mantra_yet`, and a `TextButton(try_again)` → `retry()` | + +`%1$s` is `humanReadableNameOrPubkey()` in every one. The line under the button is +`bodySmall` in `onSurfaceVariant` — a supporting line, not a second message. A +disabled button with the reason beside it, rather than a hidden one: the action +exists and is unavailable, which is what M3's disabled state is for, and a screen +that lost its only button would read as broken. + +Pressing "Open chat" pops the preview exactly as "Start new chat" does; the +`onStartChat` callback and its host wiring are unchanged. + +### The words + +| name | text | +|---|---| +| `open_chat` | Open chat | +| `you_already_have_a_chat_with_s` | You already have a chat with %1$s. | +| `checking_whether_s_can_receive_messages_here` | Checking whether %1$s can receive messages here. | +| `s_hasn_t_set_up_messaging_on_mantra_yet` | %1$s hasn't set up messaging on Mantra yet. Try again later, or ask them to open Mantra. | + +### Tests + +`DatabaseChatRepositoryJvmTest` — or a new `FindDirectMessageRoomJvmTest` beside +`SignInToProfileTwiceJvmTest`, on an in-memory Room under the app's own DAOs: + +- a room with exactly the two participants is found; +- a room with the peer and a third member is not; +- a room with the user alone is found when the peer is the user; +- no rows, null. + +`ProfilePreviewViewModelJvmTest` grows a `ChatRepository` fake beside the +`NostrRepository` one — `findDirectMessageRoom` answering a fixed room or null, the +key package flow a `MutableStateFlow`: + +- an existing room is `ExistingChat` the moment the profile loads, key package or + not — the case that would otherwise read "hasn't set up messaging" for someone + you talk to every day, since a key package is meant to be used once and the + relays need not hold a fresh one for a person you already have a room with; +- profile then key package: `Checking` then `CanStartChat`, and the timeout is + cancelled; +- key package then profile: `Loading` until the profile, then `CanStartChat` at once; +- profile and no key package: `Checking` until the timeout, then `NotYetOnMantra`, + and a late key package still flips it to `CanStartChat`; +- `retry` from `NotYetOnMantra` queues `requests` alone, not `profileRequests`, and + the profile stays on screen. + +`ProfilePreviewScreenJvmTest` grows the four rows of the table above: the label, +whether the button is enabled, the line under it, and for `ExistingChat` the route +handed over. + +`initiateNewChat` is covered wherever `ChatRoomMessagingViewModel` is; the +existing-room cases move to the repository test and stay green through the switch. + +--- + +## Checking the work + +Each phase, before its commit: + +```bash +./gradlew :composeApp:m3Audit +``` + +```bash +./gradlew :composeApp:compileDebugKotlinAndroid :composeApp:jvmTest +``` + +The audit's budgets this plan can touch: spacing literals (none — the avatar's +`75.dp` is a dimension), hardcoded colours (none), title case (every new string is +sentence case; `docs/scripts/m3-title-case.py --list` shows any that is not), +untriaged `contentDescription = null` (the button's icon is `Decorative`, the +avatar carries its own), and the bare-`clickable` budget, which nothing here uses. +The adaptive floor is untouched: the preview reads no breakpoint and needs none. + +Phase 3 is the one to run on a device or the desktop target as well as in tests, +because it is the one that changes what a tap does: the sheet, the dialog, "Find +profile", the preview, "Start new chat", the room, and back to the list. + +## Estimate + +| phase | work | days | blocked by | +|---|---|---|---| +| 1 | `DirectMessagePeerSync`, `isResolved()`, the view model, both tests | 0.5–1 | — | +| 2 | the route, the screen, the host, the strings, the screen test, the `@ConformancePreviews` entry | 1 | 1 | +| 3 | `npubOrNull`, the dialog, the two tests, a run on a device | 0.5 | 2 | +| 4 | `findDirectMessageRoom`, readiness, the four words, three tests | 1 | 3 | + +**About three days.** The uncertainty is in Phase 4, in one place: the combined +collect and the one timer that means two things. `ChatRoomMessagingViewModel` has +the same shape and got it wrong twice before it was right — the comments at lines +116 and 138 are the record — so the view model test's ordering cases (profile first, key +package first, neither, late) are the phase, and the screen is what is left. + +Phases 1–4 are now implemented, in one sitting and in order. The Phase 4 +uncertainty resolved as predicted: the ordering cases are where the flag in the +table above came from, and the screen was what was left. + +## Out of scope + +- **nip05.** The dialog's email-like branch still lands on `ImplementationPendingRoute`. + Resolving one is an HTTPS fetch of `/.well-known/nostr.json` and a lookup, and + the result is a pubkey — which is the preview's route. When it is built it ends + here: the dialog's nip05 branch resolves, then pushes the same route the npub + branch does. +- **The field on the preview screen.** See the appendix. One refactor, after Phase 3, + that removes the dialog rather than changing it. +- **`nprofile1…`**, whose TLV carries relay hints the route could use. A second + branch in `npubOrNull`'s caller, and the route's `relayHint` when it grows one. +- **The two-pane home screen.** From the preview, "Start new chat" pushes the room + full-screen on a wide window, as "Send message" on a profile does today; selecting + it in the detail pane instead means the home screen owning the preview, which is + a question for its list-detail layout (the adaptive phase of + [material-design-conformance.md](./material-design-conformance.md)) and not this + plan's. +- **Rooms the user has left.** `findDirectMessageRoom` applies the rule + `initiateNewChat` applied; whether a left room should open, or a fresh one be + made beside it, is a rule that needs deciding on its own. +- **A "chat with yourself" preview.** Pasting your own npub shows your own profile + and, through `findDirectMessageRoom`, opens the one-participant room if you have + one — exactly what `initiateNewChat` does today. Whether the button should say + something else for yourself is a product question this plan does not raise. +- **The queue's "every relay has answered" signal for a peer.** Three screens now + use a twenty-second timer where the sign-in screen reads a status. Giving the + queue a per-request handle that any screen can wait on is a change to the queue, + and would retire all three timers at once. +- **`ChatRoomMessagingScreen`'s `Error` state**, which after Phase 4 is reached + only by a key package that vanished between the preview and the press. It still + has no retry, as it has none today. + +## Appendix — what was considered and rejected + +### The preview inside the dialog + +A card under the field once the npub parses, with the button on the card. Fewest +taps. Rejected because the preview has states a dialog cannot carry well — twenty +seconds of searching, a not-found with a retry, a queue failure — and because a +`Dialog` on the desktop target is the surface this app has the least of and the +least reason to grow. The screen is where those states have a home, and the dialog +stays exactly as small as it is. + +### The field on the preview screen + +Replace the dialog with a screen: the field at the top, the preview filling in +under it as the npub parses, the button under that. The better product — the sheet's +other card already pushes a screen, and this would make the two cards symmetrical — +and the one this plan does not build, because it changes the entrance where the +request was about what follows it. With the preview keyed by pubkey it is a +follow-up that moves the field and deletes the dialog, and touches the screen only +to give it a state before `Loading`. + +### Reaching `MetadataEventDetail` instead + +Wait for the kind 0, then push `NostrEventDetailRoute` with its event id. It is the +app's profile screen and it has "Send message". Rejected because it needs the event +id, which the entrance does not have and would have to wait for on a screen of its +own — at which point that screen *is* the preview — and because it offers follow, +unfollow, add to group and block on the way to a chat, which is a profile, not a +confirmation. + +### Creating the chat from the preview + +Call `createMlsDirectMessage` on the button and push the room's id. It would spare +one `InitiatingNewChat` frame. Rejected because it would be a second creation path +with the same key-package wait, the same timeout and the same error, one screen +apart from the first; and because the preview is worth keeping a reader. The frame +it would spare is, after Phase 4, the one frame `InitiatingNewChat` shows when the +key package is already here. + +### A bottom sheet for the preview + +The sheet is already open when the flow starts. Rejected for the reason the +multiple-profiles plan gave for its own switcher: a sheet that pushes a screen from +under itself is a screen with a worse back story, and this one's whole purpose is +to push a screen. diff --git a/docs/npub-sign-in.md b/docs/npub-sign-in.md new file mode 100644 index 00000000..68193b62 --- /dev/null +++ b/docs/npub-sign-in.md @@ -0,0 +1,980 @@ +# Signing in with an npub + +How a user looks at this app as a profile they hold no secret for, what such an +identity can and cannot do here, and why the answer to "what is it for" decides +every screen it touches. + +Read this after [nsec-sign-in.md](./nsec-sign-in.md). It inherits the `Identity` +type, the two key stores, the sign-in screen and the not-found exit from there, and +most of what follows is that plan's out-of-scope note taken at its word — "a +read-only mode is a product, not a branch" — and asked what the product would be. + +**Built**, phases 1–7, one commit each, in the order given; Phase 8 is the rollout +and is process rather than code. The phases are kept as written because they are +the reasoning, and the code reads better against the argument it came from than +against a summary of itself. Where the implementation chose differently the table +below says so: + +| what the plan said | what it turned out to be | +|---|---| +| the startup screen's third branch in Phase 3 | in Phase 2: `StoredIdentity` is sealed, and the compiler asked for the branch the moment `NostrPublic` existed. The right code either way, one phase early | +| `KeyRecoveryScreen`'s `NostrPublic` arm "unreachable, and says so" | for the option, `Unit` with the comment; for the sentence above it, grouped with `NostrSecret` — so that if the screen is ever reached it does not promise coins | +| "the old reader kept as `LegacyNostrKeysFile` for exactly one caller" | `LegacyNostrKeysFile` is the read half of what was `NostrKeyManager`, and `EncryptedNostrKeys` stays beside it, because the migration's test has to *produce* a v1 file and nothing else can | +| the migration, unspecified in its outcomes | `MigrationResult` — `Migrated(count)`, `NotNeeded`, `Failed(failure)` — and `listIdentities` maps a failure onto the same `ListWalletState.Error` an unreadable credentials file gets, per kind | +| the wire shape as "a `@Serializable sealed class`" | two types: `NostrCredential`, whose `Secret` carries a `PrivateKey` and cannot be built with a key that is not one, and a private serializable mirror inside the encrypted file whose `Secret` carries hex | +| the DAO guard as "belt and braces" | load-bearing between Phase 3 and Phase 5, when the room list's inbox sync still ran for a read-only identity; and verified both ways — the test fails with the guard removed | +| `ProvideSigningCapability` from the active identity | a null identity — startup, landing, sign-in — answers *true*: it is nobody, not read-only, and those screens have nothing to hide | +| `ForgetIdentity` "dispatches the first step on its kind" | no dispatch: since the credentials file the first step is one call for both credential kinds, and `forgetNostrCredential` itself answers `NotACredential` for a mnemonic | +| sign out and the not-found exit, shape unspecified | one `SignOutViewModel` owning the confirmation and the in-flight state, one `SignOutOfReadOnlyDialog` differing only in its title, and a `SignOutDependencies` bundle so each screen takes one nullable parameter rather than four lambdas that throw | +| `signInToProfile` "made idempotent by pubkey" | done, and tested by signing in twice and counting one; the sign-in view model's three writers now go through one shared write-and-map | +| the round trip "with the network faked at the repository" | with the *database* real: an in-memory Room under the app's own DAOs and a real `NotaryViewModel`, because "nothing was signed" is about what is not in the tables — plus a contrast case the plan did not ask for, the same harness as a signing identity producing a key package, so the assertions are known to bite | +| `use_a_different_key` as a new string | it existed: the sign-in screen's own "use a different key" button. One string, two screens, one meaning | +| one entry per pubkey *across both files*, with `merge` listing the seeds and adding the credentials | **superseded** by [multiple-profiles.md](./multiple-profiles.md), Phase 1: the credentials file is the list of profiles and a seed is attached to the entry its key derives, so a seed's key *is* in the file; `merge` lists the credentials and attaches the seeds. The upgrade table gains a row — a phrase over a bare secret attaches — and `NotACredential` becomes `WalletAttached` | + +One thing found on the way that is in no phase: a compose test that looks for a +floating action button's label by text has to search the unmerged tree, because +`ExtendedFloatingActionButton` merges its label into the button's semantics — and +on the merged tree an `assertDoesNotExist` for a hidden control is vacuously true. +`ReadOnlyEntrancesJvmTest` uses the unmerged tree for every lookup for that reason. +(More precisely, the `text`/`icon` overload *clears* the label's semantics, so the +merged tree never had it; the shared `ExtendedFab` that replaced it keeps the label +in the merged tree, and the test's habit is now only a habit.) + +The library commit is on `claude/nostr-credentials` in the submodule, at `84cc44c`, +one commit ahead of the library's `master` and bumped into the app by the Phase 2 +commit; it has to be pushed with this branch and tagged for JitPack consumers +before any of this leaves the machine. The nsec plan's `59c11ed` reached `master` +through the library's PR #1 but was never tagged; Phase 8 below says why the two go +out on one tag. + +## The constraint + +A nostr public key can be addressed and can verify. It cannot do anything else, +and in this app "anything else" is most of the app: + +| what needs the secret | where | +|---|---| +| signing every queued event — the kind 0, the follow list, key packages, group posts, proposals | `NotaryViewModel.observeUnsignedNostrEvents` | +| opening a NIP-17 gift wrap addressed to the user | [GiftWrapMessage.kt:126](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt) — NIP-44 with the recipient's key | +| joining a Marmot group — a key package has to be signed and published before any welcome can be addressed to it, and the welcome itself arrives as a gift wrap | `MarmotRepository.publishMarmotKeyPackageBundle`, `MarmotInboundManager` | +| the ChillDKG host key, `sha256(tag ‖ nostrPrivateKey)` | [ChillDkgRitualManager.kt:117](../composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt) | + +What a public key *gets* is the events it has published — the kind 0, the kind 3, +the relay lists — and the profiles of the people it follows, because indexing a +kind 3 plants a placeholder for each and queues its sync +([NostrDao.kt:1128](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt)). +In a client with a public feed, that is the client: read-only in Damus or Amethyst +means "browse as this person". + +**This app has no public feed.** The home tab is a list of chat rooms, and every +room is MLS or a gift wrap, both encrypted to the key the user did not paste. The +curated lists, proposals, ceremonies and translations are reached only through a +room (`ChatRoomDetailScreen`). Search is over profiles, and trending notes is a +string that says "coming soon". So a read-only identity here sees its own profile +card, the people it follows — as search results, by name, once the sync has +fetched them; nothing renders the list itself — any profile's detail, and share +profile. That is the honest inventory, and it is thin — thin +enough that the first thing to decide is not how to build it but what it is +[for](#the-one-decision-to-make-first). + +## What is already built + +More than the nsec plan's out-of-scope note suggests, because that plan built most +of it on the way. + +| piece | where | state | +|---|---|---| +| the parser recognises `npub1…` and `nostr:npub1…` — and refuses it, as `PublicKeyOnly` | [CredentialParser.kt:94](../composeApp/src/commonMain/kotlin/press/mantra/compose/identity/CredentialParser.kt) | one branch from accepting it | +| `signInToProfile(pubkey)` plants the `GENESIS_AT` placeholder from a pubkey alone | [DatabaseNostrRepository.kt:258](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt) | built; needs one property, see [Phase 4](#a-sign-in-that-can-be-repeated) | +| the state machine from there — `UnqueuedProfileSynchronization → UnsyncedProfile → UnindexedProfile → ProfileLoaded` — reads `identity.nostrPublicKey` and never a key | [NavigationViewModel.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt) | built | +| the sign-in sync, the indexer relays, the one hop, and the not-found exit | `SignInSync`, `UnsyncedProfileViewModel` | built; the exit needs a third action, see [Phase 6](#phase-6--leaving) | +| `RelaysSocketManager` follows the identity's relay list by pubkey | [RelaysSocketManager.kt:70](../composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt) | works unchanged | +| the notary returns before doing anything when there is no key | [NotaryViewModel.kt:72](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NotaryViewModel.kt) | works unchanged — and so the key package bundle it would publish is never attempted | +| quartz's `KeyPair(pubKey = …)` with no private key, commented in its own source as "this is a read-only account" | `com.vitorpamplona.quartz.nip01Core.crypto.KeyPair` | usable; it is why the DAO carries `privKey!!` at all | +| `Identity.business` is nullable; a read-only identity is `NostrSecret` minus the secret | [Identity.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt) | one field from it | +| the forget flow — key out, prefs deleted, metadata hidden, account rows gone, re-list, selector | `NostrSecretViewModel.forgetKey`, `IdentityWriter.forgetNostrKey` | built for an nsec; the sequence transfers whole | +| `WalletsSelector` shows the npub for every kind | [WalletsSelector.kt:161](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt) | needs a "read only" label | +| `nostr-keys.dat`, with a version byte and a doc that says "a new shape would be a new version" | `EncryptedNostrKeys`, `NostrKeyManager`, `AtomicFileWrite` | the envelope, the atomic write and the manager shape all carry over; only the entries change | +| a string, `this_will_give_you_read_only_access_to_the` | `strings.xml` | present, unused, and says too little | + +## What actually blocks it + +Five things, in dependency order. + +**The identity's key is not optional.** `Identity.nostrPrivateKey` is a +`PrivateKey` and `nostrPublicKey` is computed from it +([Identity.kt:50](../composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt), +62). Every reader of the identity compiles against that. + +**There is nowhere to keep a public key.** Both stores hold secrets. `nostr-keys.dat` +is a map from public key to private key, and `EncryptedNostrKeys` refuses a file +whose entry does not derive the key it is filed under — the right rule for that +shape, which is why the answer is a new shape rather than a value smuggled into +this one ([Phase 2](#phase-2--one-credentials-file)). + +**The pumps are gated on the private key.** `SynchronizationViewModel` runs its +four collectors inside `identity?.nostrPrivateKey?.let { … }` +([SynchronizationViewModel.kt:194](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt)). +A read-only identity's sign-in sync would be queued and never dispatched, and the +machine would park on `UnsyncedProfile` for good — the nsec plan's Phase 5 failure +again, from a different cause. + +**The DAO opens what is addressed to it, or rolls back.** `storeNostrEvent` is +`@Transaction` and indexes inside it; a gift wrap addressed to the active key that +cannot be unsealed throws +([NostrDao.kt:448–459](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt)), +and the throw takes the event with it. Worse, and not obvious: `decryptGiftWrapSeal` +builds `KeyPair(privKey = keyPair.privKey)` +([GiftWrapMessage.kt:126](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt)), +and in quartz a `KeyPair` given neither key *generates a fresh random one*. A +read-only pair forwarded there would try to open the wrap with a key nobody has, +fail, and roll back — silently correct-looking in the logs. + +**Every write entrance is unconditional.** Some twenty-five view models write +something the notary has to sign or a key has to seal. Most are behind a chat +room and unreachable without one; but the "New chat" button, +follow, send message, key package management, key recovery and the not-found +screen's "set one up" are each one tap from the three tabs. A pasted npub that +lands on a screen offering "New chat" is not a preview, it is a broken app. + +## The one decision to make first + +What is a read-only identity *for?* Three answers were considered. + +**Browsing.** Needs a public feed. This app does not have one, and a mode whose +whole content is "coming soon" should not ship ahead of it. + +**Auditing a group from outside.** The curated lists a group publishes are public +events (kinds 31889, 31888, 31890), and a member's device is not the only place they +could be read. But nothing today reaches them except through a room, and building +a public list browser is a feature with its own screens — the feed problem in a +smaller shape. + +**A preview.** Look at the app as your own profile — the name it has for you, the +people it knows you follow, what it found on the relays — before you paste a secret +into it. And, having looked, paste it. + +**This plan takes the third.** It is what the thin inventory is actually good for, +and it settles four questions that would otherwise each be an argument of their own: + +- The Messages tab shows an empty state that says why it is empty and offers the + one thing that fills it: signing in with the nsec. +- Pasting the nsec of a key already here read-only is an **upgrade in place**, not + `AlreadyOnThisDevice`. The device does not have that key; refusing it would be + false. +- Sign out is real for this kind, and only this kind: there is nothing on the + device to lose. It is the first sign out in the app, and the general case stays + pending for the reason the nsec plan gave. +- Not-found offers *try again* or *a different key*, never *set one up* — a profile + cannot be set up for a key that cannot sign its kind 0. + +Everything below is downstream of that one word, preview, and if it is ever +revisited — a public feed lands, say — the places to change are the four above. + +--- + +## Phase 1 — a key the identity may not have + +**App only. No behaviour change.** A type change that ships alone, so its diff is +boring. + +### The type + +```kotlin +enum class IdentityKind { + Mnemonic, + NostrSecret, + /** A bare nostr public key. Nothing can be signed, opened or derived from it. */ + NostrPublic, +} + +data class Identity( + val id: WalletId, + val kind: IdentityKind, + val nostrPublicKey: HexKey, + /** Null only for [IdentityKind.NostrPublic]. */ + val nostrPrivateKey: PrivateKey?, + val userPrefs: UserPrefs, + val internalPrefs: InternalPrefs, + val business: PhoenixBusiness?, +) { + /** What the notary, the pumps and every write entrance ask. */ + val canSign: Boolean get() = nostrPrivateKey != null + + init { + require((nostrPrivateKey == null) == (kind == IdentityKind.NostrPublic)) { "kind and key disagree" } + require(nostrPrivateKey == null || nostrPrivateKey.nostrPublicKeyHex() == nostrPublicKey) { "key does not derive its public key" } + } + + override fun toString(): String = "Identity(id=$id, kind=$kind, key=)" + + companion object { + fun signing(id: WalletId, kind: IdentityKind, privateKey: PrivateKey, userPrefs: UserPrefs, internalPrefs: InternalPrefs, business: PhoenixBusiness?): Identity + fun readOnly(id: WalletId, nostrPublicKey: HexKey, userPrefs: UserPrefs, internalPrefs: InternalPrefs): Identity + } +} +``` + +`nostrPublicKey` moves from a derived property to a constructor field, because for +one kind there is nothing to derive it from; the two `require`s in `init` keep the +field and the key from disagreeing, which a data class would otherwise happily +allow. The factories exist so that the two places that build a signing identity +today — `SovereignWalletViewModel.setActiveWallet` and the nsec branch of +`SovereignWalletStartupScreen` — keep deriving the pubkey in one place, and so that +nobody constructs a read-only identity with a key by accident. + +`id` for a read-only identity is `toWalletId()` of the x-only key — the **same id** +its nsec would have. That is deliberate and it is what makes the upgrade in +[Phase 2](#the-upgrade-rule) free: preferences and metadata are keyed by id, and a +read-only identity that becomes a signing one keeps both. + +### The readers + +Every reader of `nostrPrivateKey` already reaches it through `?.` on a nullable +identity — `activeIdentityStateFlow.value?.nostrPrivateKey` in the DKG, chat-room +and subgroup view models, `.map { it?.nostrPrivateKey }` in the notary, +`identity?.nostrPrivateKey?.let` in the pumps. Making the field nullable changes +the type of none of those expressions, and **the compiler will be silent**. That +is worth saying plainly, because it is the opposite of Phase 1 of the nsec plan, +where twenty-five parameter types forced every site to be looked at: here nothing +forces anything, and the sites that need a decision are found by reading, in +[Phase 5](#phase-5--what-a-read-only-screen-shows), not by compiling. + +One site does change. `KeyRecoveryScreen` branches on `activeIdentity?.kind` with +`NostrSecret` in one arm and `else` in the other +([KeyRecoveryScreen.kt:177](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt)), +and the `else` meant *mnemonic*. A read-only identity would fall into it and be +offered a recovery phrase it does not have. Make both `when`s in that file +exhaustive; the `NostrPublic` arm is unreachable after Phase 5 hides the row, and +an unreachable arm that says so is better than an `else` that will mean something +different next time a kind is added. + +### Tests + +`NavigationIdentityRoutingJvmTest` has a `nostrSecretIdentity()` fixture and the +case that a nodeless identity is routed by its account rather than to startup. It +gains a `readOnlyIdentity()` fixture and the same case: with the placeholder +account, `UnqueuedProfileSynchronization`; with a kind 0 indexed, `ProfileLoaded`. +And the two `require`s each get a one-line negative test, because they are the +whole reason the field is a constructor parameter. + +--- + +## Phase 2 — one credentials file + +**Library, then app. The library half needs nothing above it; the app half needs +Phase 1.** The only phase that touches the library, and the one place this plan +changes one of the nsec plan's stores rather than adding to them. + +### The file that was written expecting this + +`EncryptedNostrKeys`'s own doc says it: "this file has one shape, and a new shape +would be a new version." A read-only credential is the new shape. Rather than a +second file beside `nostr-keys.dat` holding public keys in the clear — the design +this plan first had, and rejected for the reason in the +[appendix](#a-plaintext-sibling-file-for-public-keys) — the file becomes what its +name should have been: **`nostr-credentials.dat`**, one entry per nostr public key, +each entry saying what the device holds for it. + +```json +{ + "": { "type": "secret", "privateKey": "" }, + "": { "type": "public" } +} +``` + +Same envelope — version byte, sixteen-byte iv, ciphertext of UTF-8 JSON under +`KeyStoreNames.KEY_NO_AUTH` — same atomic write through `AtomicFileWrite`, same +manager shape. The JSON is a `Map` with `NostrCredential` +a `@Serializable sealed class` and `type` its class discriminator — kotlinx's +standard sealed polymorphism, `Json { classDiscriminator = "type" }`, which the +library does not use elsewhere (its cloud payloads pick a variant by a version +field) and which is chosen here because the two variants have different fields and +the map then decodes in one call with no hand-written dispatch. + +```kotlin +package fr.acinq.phoenix.security + +@Serializable +sealed class NostrCredential { + @Serializable @SerialName("secret") data class Secret(val privateKey: PrivateKey) : NostrCredential() + @Serializable @SerialName("public") data object Public : NostrCredential() +} + +class EncryptedNostrCredentials(val iv: ByteArray, val ciphertext: ByteArray) { + fun decryptAndGetCredentials(): Map + fun serialize(): ByteArray + companion object { + const val VERSION: Byte = 1 + fun deserialize(bytes: ByteArray): EncryptedNostrCredentials + fun encrypt(credentials: Map): EncryptedNostrCredentials + } +} +``` + +The read keeps the check that makes the file trustworthy and adds its counterpart: +a `secret` entry must derive the public key it is filed under, as today, and a +`public` entry's map key must be sixty-four hex characters naming a point on the +curve. Either failure is `SerializationError`, because either can only be +corruption. + +Encrypting a public key protects nothing and costs nothing. What it buys is one +read path with one failure classification, and — a small thing, but real — the +list of profiles a user has looked at is a browsing record, and it is at rest +under the same key as everything else about them. + +### Why the file is renamed rather than versioned in place + +A version byte of 2 in `nostr-keys.dat` would be the natural move and it is the +wrong one. An older build's reader throws on an unknown version, `NostrKeyManager` +classifies that as `SerializationError`, and `listIdentities` **returns** on that +result before it publishes anything +([SovereignWalletViewModel.kt:218](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt)): +the old build would list no identities at all, seed wallets included. That is the +`seed.dat` failure the nsec plan's appendix rejected a version-4 payload for. A +file the old build does not look for cannot do that to it. + +So: `nostr-credentials.dat`, version 1 of a new file, and the old reader kept as +`LegacyNostrKeysFile` for exactly one caller. + +### Migration + +`NostrCredentialManager.migrateFromNostrKeys(phoenixGlobal)`: if +`nostr-credentials.dat` is absent and `nostr-keys.dat` exists, read the old file, +convert every entry to `Secret`, write the new file through the verified atomic +write, and **delete the old one**. Called once from `listIdentities`, which runs +from `SovereignWalletViewModel.init` before anything else touches either file, so +a read stays a read and the migration is a named step with a test of its own. + +Delete, rather than leave a frozen copy for an older build to find. A copy goes +stale in the one direction that matters: a key the user asks the new build to +*forget* would survive in a file only the old build reads, and come back on a +downgrade. Losing sight of every nsec identity on a downgrade until the next +upgrade is the lesser failure, and [Phase 8](#phase-8--rollout) says so out loud. + +As far as the repository can show, the migration will run for nobody: the library +commit that introduced `nostr-keys.dat` carries no tag and the app has none. It +exists because the repository cannot prove a negative, and because it is thirty +lines. + +### Listing + +```kotlin +sealed interface StoredIdentity { + … + data class NostrPublic( + override val id: WalletId, + override val nostrPublicKey: HexKey, + ) : StoredIdentity { + override val kind: IdentityKind get() = IdentityKind.NostrPublic + } +} +``` + +`StoredIdentity.merge(wallets, credentials)` keeps its two inputs; a `Secret` is a +`NostrSecret` and a `Public` a `NostrPublic`. One entry per pubkey is the file's +own invariant, so there is no precedence between credentials to decide. There is +one between a seed and a credential, and it exists for the one upgrade that has to +span two files (below): a `Public` whose pubkey a seed derives is dropped, with a +log line, and the next write repairs the file. + +### The writers + +```kotlin +object IdentityWriter { + … + suspend fun writeNostrPublicKey(log, phoenixGlobal, globalPrefs, publicKey: HexKey, isTorEnabled, customElectrumServer): WriteNostrCredentialResult + suspend fun forgetNostrCredential(log, phoenixGlobal, id: WalletId, publicKey: HexKey): ForgetNostrCredentialResult +} +``` + +`writeNostrKey` stays and writes a `Secret`; `writeNostrPublicKey` writes a +`Public`; both refuse a duplicate by public key against the seeds *and* the +credentials, and both `prepareIdentity` — the Tor and Electrum preferences mean +even less to a read-only identity than to an nsec one, and are saved for the +reason the nsec plan gave. `forgetNostrKey` becomes `forgetNostrCredential` and +removes an entry of either kind; its `NotABareKey` becomes `NotACredential`, which +still means a mnemonic. + +### The upgrade rule + +`writeNostrKey`'s duplicate check learns one distinction: + +| the pubkey is already here as | pasting its nsec | pasting its npub | +|---|---|---| +| a wallet (seed) | `AlreadyExists` | `AlreadyExists` | +| a `secret` credential | `AlreadyExists` | `AlreadyExists` | +| a `public` credential | **upgrade**: the entry becomes `secret`, in one write; `Written(id)` with the id unchanged | `AlreadyExists` | + +One write, because it is one file. There is no window in which the device holds +both or neither, and nothing to reconcile afterwards — which is the whole reason +this phase is a library change and not a second file. + +The one upgrade that still spans two files is a *recovery phrase* pasted over a +public credential: the seed goes to `seed.dat`, and that file cannot hold anything +else. `writeMnemonic` removes the `public` entry **first**, then writes the seed. +A crash between the two loses the read-only identity — recoverable by pasting the +npub again — rather than leaving one pubkey listed twice under two ids, which is +what the reverse order would do and what the merge rule above is there to catch +if it somehow happens anyway. The wallet's id is `hash160(nodeId)` and its +preferences are fresh, as a new wallet's are today. + +### Tests + +In the library, after the two that exist: `EncryptedNostrCredentialsTest` in +`commonTest`, the layout as literal bytes with one entry of each kind, because the +format is a compatibility contract from the moment it exists; and, in `jvmTest`, a +round trip through the key store, plus the migration — a v1 `nostr-keys.dat` +written by `LegacyNostrKeysFile`'s test fixture, read back as `Secret` entries in +`nostr-credentials.dat`, the old file gone. + +In the app: `StoredIdentityJvmTest` gains a `Public` credential listed as +`NostrPublic`, and a `Public` whose pubkey a seed derives dropped. +`IdentityWriterJvmTest` gains: a public key is written once and refused the second +time; its nsec is then accepted, under the same id, and the entry is now `secret`; +the npub of an nsec already here is refused; forgetting a credential of either kind +leaves the others and takes its preferences. + +--- + +## Phase 3 — listing, starting, and reading without a key + +**App. Needs Phase 2.** + +### Listing and starting + +`SovereignWalletViewModel.listIdentities` runs the migration, then reads +`nostr-credentials.dat` where it read `nostr-keys.dat`, with the same five results +handled the same way; the change is the type of the map it hands to `merge` +([SovereignWalletViewModel.kt:242](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt)). + +`SovereignWalletStartupScreen` gains its third branch, beside the nsec one +([SovereignWalletStartupScreen.kt:185](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt)): + +```kotlin +is StoredIdentity.NostrPublic -> sovereignWalletViewModel.setActiveIdentity( + Identity.readOnly( + id = identity.id, + nostrPublicKey = identity.nostrPublicKey, + userPrefs = dataStoreManager.loadUserPrefsForWallet(identity.id), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(identity.id), + ) +) +``` + +No node, no `platformStartupLogic`, active the moment it is read — exactly the nsec +branch. The screen-lock gate wraps all three, as it was built to. + +`WalletsSelector` shows the npub for every kind already; a read-only row adds the +words *read only* under it, in `labelSmall`, so the user can tell which of two +identities with the same avatar will let them send a message before they tap. + +### The pumps + +`SynchronizationViewModel`'s collector becomes + +```kotlin +activeIdentityStateFlow.collectLatest { identity -> + identity?.let { + val keyPair = identity.toKeyPair() + … + supervisorScope { + launch(Dispatchers.IO) { observePendingSyncNostrEventRequests(keyPair) } + launch(Dispatchers.IO) { observePendingNegentropySynchronizeRequests(keyPair) } + if (identity.canSign) { + launch(Dispatchers.IO) { observePendingBroadcastNostrEventRequests(keyPair) } + launch(Dispatchers.IO) { liveSubscriptionManager.observe(keyPair) } + } + } + } +} +``` + +**A read-only identity runs the two pumps that read and neither of the two that +write.** Stated as a rule, because Phase 5 leans on it: *a read-only identity never +sends anything to a relay*, not a signature and not a copy. The broadcast pump +would find nothing — nothing is ever signed, and the one control that queues an +already-signed event is hidden — and the +live subscriptions are the gift-wrap inbox and the group-membership follow, both of +which fetch things this identity cannot open, and the catch-up negentropy for the +same. Not running them is not an optimisation; it is the identity not asking for +what it cannot use. The sync pump and the negentropy pump are what the sign-in +sync, the one hop and *try again* are queued on, and they are all a preview needs. + +`Identity.toKeyPair()` is the one place a quartz `KeyPair` is built from an +identity: + +```kotlin +fun Identity.toKeyPair(): KeyPair = KeyPair( + privKey = nostrPrivateKey?.value?.toByteArray(), + pubKey = nostrPublicKey.hexToByteArray(), +) +``` + +With a private key, quartz recomputes the public key from it and the second +argument is a cross-check. Without one, it is the read-only constructor. What it +must never be is `KeyPair(privKey = null)` with nothing else — that is quartz's +"make me a new key", and it is what `decryptGiftWrapSeal` does today +([GiftWrapMessage.kt:126](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt)). +The helper exists so that the trap has one place to be avoided; leave a comment on +it that says what the trap is. + +### The one DAO guard + +After the `isAddressedTo` check in `indexNostrEvent` +([NostrDao.kt:439](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt)): + +```kotlin +if (activeKeyPair.privKey == null) { + // Addressed to us, and we cannot open it. Keep the event and the wrap we just + // stored, as the branch above does for wraps addressed to someone else; the + // key that opens this one may be signed in later. + logger.d("GiftWrap ${nostrEvent.id} is addressed to us, but this identity holds no key") + return@let +} +``` + +The two `privKey!!` sites further down (lines 898 and 1376) are downstream of a +successful unseal and a Marmot room this device is in; a read-only identity reaches +neither, and they stay as they are. With the live subscriptions off, and once +Phase 5 leaves the room list uncomposed, a gift wrap addressed to a read-only +identity arrives only if something else asks for kind 1059 — nothing else does — +so this guard is belt and braces. It stays because the alternative, when something +does ask, is a rolled-back transaction that logs as a decryption failure and is +not one; and between this phase and Phase 5 it is not belt and braces at all, it +is what keeps the room list's inbox sync from rolling back every wrap it fetches. + +### Tests + +`NostrDaoJvmTest` gains one case: a gift wrap addressed to a read-only `KeyPair` +is stored, its `GiftWrapMessage` row is stored, no seal is stored, and nothing +throws — `NostrNip17DaoJvmTest` has the fixtures for building the wrap. The +listing itself is covered where the nsec plan ended up covering it: `merge`, in +`StoredIdentityJvmTest` (Phase 2), and the round trip in Phase 7, which lists +through the view model against a real directory and a real key store. The nsec plan asked for a +`SovereignWalletViewModel` listing test of its own and the build did not write +one; this plan does not pretend to. + +--- + +## Phase 4 — the sign-in screen + +**App. Needs Phase 3.** The field accepts a third thing. + +### Shape + +| pasted | recognised as | validation | +|---|---|---| +| words | recovery phrase | unchanged | +| `nsec1…`, `nostr:nsec1…` | nostr secret | unchanged | +| 64 hex characters | **nostr secret, still** | see below | +| `npub1…`, `nostr:npub1…` | nostr public key, read only | bech32 decodes with hrp `npub` to 32 bytes that are an x coordinate on the curve — `XonlyPublicKey(bytes).publicKey.isValid()`, the counterpart of the `isValid()` a secret is checked with | +| `ncryptsec1…` | rejected, with the reason | unchanged | + +**Hex stays a secret.** A private key and an x-only public key are both thirty-two +bytes, and sixty-four hex characters cannot say which it is. The parser has always +read hex as a secret and shown the derived npub on the confirm step so a wrong +paste is visible; a user who pastes a public key as hex will see an npub they do +not recognise and go back. Guessing — "it is not a valid secret, so try it as a +public key" — would never fire when it mattered: a public key's x coordinate is, +with overwhelming probability, also a valid scalar, so the parser would accept it +as a secret, derive an unrelated npub, and never reach the guess. An npub has to +arrive as an npub. + +`CredentialProblem.PublicKeyOnly` goes, and with it the string +`an_npub_is_a_public_key_mantra_needs_the`. An npub that does not decode, or +decodes to a point that is not on the curve, is `InvalidKey`, which already says +"that nostr key is not valid". + +### The confirm step + +`SignInCredential` gains + +```kotlin +data class NostrPublicKey(override val nostrPublicKey: HexKey) : SignInCredential +``` + +and `Confirm` a third arm, `Icons.Default.Visibility` beside a sentence that says +what the user is about to get and not get. `this_will_give_you_read_only_access_to_the` +says "This will give you read only access to the profile", which is true and not +enough: it does not say that messages stay closed, and it does not say what to do +about it. Replace it: + +> Recognised as a public key. You will see this profile and the people it +> follows; messages stay closed and nothing can be sent. Paste the nsec later to +> open it. + +Sentence case, one string, read on the confirm step and nowhere else. The button +still says *Sign in*. + +### Committing + +```kotlin +is SignInCredential.NostrPublicKey -> when (val result = writeNostrPublicKey(credential.nostrPublicKey)) { + is Written -> Outcome.SignedIn(result.id) + is AlreadyExists -> Outcome.Failed(CredentialProblem.AlreadyOnThisDevice) + is CannotLoadKeys -> Outcome.Failed(CredentialProblem.CouldNotWrite) +} +``` + +then `signInToProfile`, then the same tail as the other two: re-list, select, go to +startup. Startup finds a `StoredIdentity.NostrPublic`, activates it, and the machine +runs from the placeholder exactly as it does for an nsec — the sync pump is running +because Phase 3 made it run. + +### A sign-in that can be repeated + +`signInToProfile` inserts a new kind-0 `UnsignedNostrEvent` every time it is called +([DatabaseNostrRepository.kt:258](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt)). +Until now nothing called it twice for one pubkey: the writers refused the second +sign-in before it got there. The upgrade is the first path that signs in a pubkey +whose account already exists, and a second placeholder would be "two kind-0 rows +for one pubkey, two accounts disagreeing about which is this one" — the failure +`setUpProfileForExistingKey`'s own comment describes. + +Make it idempotent by pubkey: if a kind-0 `UnsignedNostrEvent` exists for the key, +do nothing. That is the right property regardless of the upgrade, and the existing +test `both credentials of one key plant the same account` becomes `…plant one +account, however many times they sign in`. + +### Landing + +The caption under *Sign in* names what the screen accepts, and the label and +placeholder on the field do too. Three strings change to say "a recovery phrase, +an nsec, or an npub to look around" — the last clause because a user who does not +know the word *read-only* should still be told the difference before they paste. + +### Tests + +`CredentialParserJvmTest`: an npub, a `nostr:`-prefixed npub and the upper-cased +form all name the same public key; an npub that does not decode is an invalid key; +sixty-four hex characters are still a secret. `SignInToProfileViewModelJvmTest`: a +public key is written, then its account is planted, then the id comes back; the +npub of a key already here is refused and plants nothing; the nsec of a key here +read-only is written under the same id and plants nothing new. + +--- + +## Phase 5 — what a read-only screen shows + +**App. Compiles on Phase 1; visible after Phase 3.** The half the nsec plan called +a product. + +### One question, asked in one way + +Screens do not receive the identity. `HomeScreen`, `ActiveProfileScreen` and the +detail widgets take an `activeUserPublicKey`, and `MetadataEventDetail` — where +*follow* and *send message* live — is several composables below anything that +could be handed more. Threading `canSign` down would be the snackbar host's +problem again: the same parameter in twenty-six lists and forgotten in the +twenty-seventh. + +```kotlin +// ui/composable/widgets/SigningCapability.kt +val LocalCanSign: ProvidableCompositionLocal = staticCompositionLocalOf { true } + +@Composable +fun ProvideSigningCapability(activeIdentity: StateFlow, content: @Composable () -> Unit) +``` + +Provided once, in `MantraNavHost` around the `NavHost`, from +`sovereignWalletViewModel.activeIdentity`. The default is `true` rather than an +error, and that is the one way this differs from `LocalSnackbarHostState`: the +provider sits above every screen and cannot be forgotten per screen, so the only +things composed outside it are previews and the existing compose tests, and those +should render as they always have. + +It is a capability, not a kind. A screen asks "can this identity sign?", not "is +this an npub?", because the answer is what it needs and because a remote signer — +which can sign and holds no local key — would otherwise be a fourth value in every +`when` (see [Out of scope](#out-of-scope)). + +### The inventory + +Every write entrance reachable from the three tabs without a chat room, and what +a read-only identity gets instead. A row that says *hidden* is hidden, not +disabled: a disabled "New chat" invites the question "why", and the answer is on +the empty state beside it. + +| screen | control | it writes | read only | +|---|---|---|---| +| `HomeScreen` | *New chat* (both layouts), the sheet, the npub dialog | key packages, welcomes, gift wraps | hidden; the tab shows the [empty state below](#the-messages-tab) | +| `HomeScreen` | `ChatRoomListViewModel.initiate` | queues the inbox sync and the MLS negentropy | not composed, so not queued | +| `MetadataEventDetail` | *follow*, *unfollow*, *follow back* ([:215](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt)) | a kind 3 | hidden; the "follows you" state still shows | +| `MetadataEventDetail` | *send message* ([:303](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt)) | a gift wrap | hidden | +| `MetadataEventDetail` | *edit profile* | a kind 0 | hidden (it is `ImplementationPendingRoute` today, and stays so for the other kinds) | +| `ActiveProfileScreen` | *key package management* ([:233](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt)) | key packages | hidden | +| `ActiveProfileScreen` | *key recovery* ([:286](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt)) | nothing, but there is nothing to recover | hidden | +| `ActiveProfileScreen` | *sign out* ([:341](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt)) | — | **real**, see [Phase 6](#phase-6--leaving) | +| `ShareProfileScreen` | *re-broadcast* ([:191](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ShareProfileScreen.kt)) | nothing is signed, but a copy of the kind 0 is queued for the finder relays | hidden; a preview puts nothing on a relay, not even a copy, and the broadcast pump is not running to carry it | +| `UnsyncedProfileScreen` | *set one up* ([:217](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt)) | the six-event bootstrap | hidden; replaced, see Phase 6 | +| `SocialPreconditionScreen` | *invite a friend*, *view invites* | pending, and writes when they exist | hidden; only *skip for now* | +| `WriteNewNoteScreen` and the rest | — | — | unreachable: every route to them starts in a room or behind a control above | + +Two things are *not* on the list because they are already handled. The notary's +key package bundle is behind its key check, so a read-only identity never publishes +one. And the DKG, chat-room and subgroup view models each answer a missing key with +an error state — they are unreachable, and if they were reached they would say so +rather than crash. + +### The Messages tab + +Where the room list would be, for a read-only identity: + +```kotlin +EmptyState( + message = stringResource(Res.string.messages_need_the_secret_key_this_profile), + icon = Icons.Default.Lock, + action = { + TextButton(onClick = { onNavigateToRoute(SignInRoute) }) { + Text(stringResource(Res.string.sign_in_with_the_nsec)) + } + }, +) +``` + +> Messages need the secret key. This profile is read only: you can see it and the +> people it follows, but nothing here can be opened or sent. + +`EmptyState`'s message is required for exactly this reason — an absence has to say +which absence it is — and its `action` slot is where the upgrade lives. The +`SignInRoute` it opens is the same screen as landing's; the nsec pasted there hits +the upgrade rule, and the tail takes the user through startup to a Messages tab +with rooms in it. + +One column at every width. The two-pane layout is a list beside a detail, and +there is no list; an empty state that is the only thing on the screen is the one +case `readableContent()`'s centring is for. + +The tab stays. Hiding it would leave a navigation bar with two items — the +conformance work promoted search and profile to peers precisely so the bar would +not be "strictly worse than the app bar it replaced", and two items is halfway +back to that — and the bar is where the upgrade is found. + +### Tests + +`ReadOnlyEntrancesJvmTest`, with `runDesktopComposeUiTest` as `ChatPaneLayoutJvmTest` +does: `HomeScreen` in its loaded state under `LocalCanSign provides false` has no +node with the text *New chat* and one with *Sign in with the nsec*; under `true`, +the reverse. `ActiveProfileScreen` under `false` has no *Key recovery* and has +*Sign out*. The inventory above is the list of assertions; a row without one is a +row that will regress. + +And `./gradlew :composeApp:m3Audit` — an empty state with an action and a new +`when` arm is where a `16.dp` and a "Read Only" arrive. + +--- + +## Phase 6 — leaving + +**App. Needs Phases 2 and 5.** Two exits, one sequence. + +### Sign out, for the kind that can + +`NostrSecretViewModel.forgetKey` does five things in an order that matters — key +out of the file, preferences deleted, metadata hidden, account rows gone, then the +caller re-lists and clears the active identity — and the order is documented on the +function: a failure partway leaves the key on disk rather than an identity the +selector lists but nothing can open. The sequence is the same for a read-only +identity, and since Phase 2 the first step is the same call — `forgetNostrCredential` +removes an entry of either kind. Lift the sequence into a `ForgetIdentity` helper +that takes the identity, returns `NotACredential` for a mnemonic one, and is +called by `NostrSecretViewModel` and the two exits below alike. + +`ActiveProfileScreen`'s *sign out* then does, for a read-only identity only, what +its colour has been promising: a confirmation dialog naming the npub — + +> Sign out of this profile? This device holds no key for it, so there is nothing +> to lose. The profile stays on the relays, and you can sign in again any time. + +— and `ForgetIdentity`, then the nav host's existing tail +(`listIdentities { resetToSelector() }`, which shows the selector or, if this was +the last identity, Landing). For the other two kinds the button keeps routing to +`ImplementationPendingRoute("Sign out")`, and the nsec plan's reason stands: +removing a seed is a wallet question. + +### The not-found exit + +`UnsyncedProfileScreen`'s not-found state offers *try again* and *set one up*. For +a read-only identity the second is hidden by Phase 5 — and then the state has +**no exit**: the profile tab is not reachable before `ProfileLoaded`, so a user +whose npub was not found on any relay could try again for ever. That is the nsec +plan's "parks on a spinner" with the spinner replaced by a button. + +A third action, shown only where the second is not: *use a different key*, which +is `ForgetIdentity` behind the same confirmation and the same tail, landing on the +selector or Landing. The screen learns the two callbacks the way `NostrSecretScreen` +did — passed from the nav host — and reads `LocalCanSign` to decide which of the +two it shows. + +### Tests + +`ForgetIdentity` gets the `IdentityWriterJvmTest` treatment: a read-only identity +is forgotten and its list entry, preferences and account rows are gone; the other +entries remain. `UnsyncedProfileViewModel`'s existing decision test is unchanged — +not-found is still not-found — and the screen test asserts which action each kind +gets. + +--- + +## Phase 7 — the tests that actually prove it + +Two, beside `NsecRestoreRoundTripJvmTest`, whose fixture they reuse. + +**The preview, end to end.** Write an npub through Phase 2's writer, list +identities, activate the `NostrPublic` one, hand `NavigationViewModel` a repository +holding the placeholder: `UnqueuedProfileSynchronization`. The same account with a +kind 0 indexed: `ProfileLoaded`. Then the assertion the nsec round trip makes about +the node, made about the key: **nothing was signed**. The only `UnsignedNostrEvent` +for the pubkey is the placeholder, still at `GENESIS_AT`; no key package bundle +row; no broadcast request; `identity.business == null`; `identity.canSign` false. + +**The upgrade.** From the state above, write the nsec of the same pubkey through +`writeNostrKey`: `Written` with the same id; the credentials file holds one entry +for the pubkey and it is `secret`; `signInToProfile` planted nothing new, and the +account routes to `ProfileLoaded` as before. Re-list: one identity, `NostrSecret`, +same id, same metadata. + +--- + +## Phase 8 — rollout + +**Library first, and on the tag the nsec work still owes.** Phase 2 is a commit +on the submodule, reaching the app as a pointer bump in the Phase 2 commit. The +library commit that introduced `nostr-keys.dat` is on the library's `master` — it +arrived through PR #1 — but is untagged, and the library has no tags at all; the +nsec plan says it has to be tagged before any of that work leaves the machine, and +Phase 2 goes out on the same tag, so that no JitPack consumer ever sees the v1 file +without the reader that migrates it. + +**Phase 1 ships alone.** A type change with two `require`s and one exhaustive +`when`; if the release after it behaves differently, the cause is in one diff. + +**Phases 3 through 5 ship together.** A build with the file but not the screen is +harmless; a build with the screen but not the file strands the user at +"Initializing…"; and a build with both but not Phase 5 is the thing this plan is +for — an identity that looks signed in and offers "New chat". Phase 6 can follow +by a release: without it a read-only identity has no sign out and no not-found +exit, which is a dead end but not a lie. + +**Old builds and the new file.** A build before Phase 3 does not read +`nostr-credentials.dat` and does not know it exists. If it also predates the nsec +work it is unaffected in every way. If it is a build *with* `nostr-keys.dat` and +the migration has run on this device, that file is gone and the build lists no +nsec identities until the next upgrade — the failure Phase 2 chose over a +forgotten key coming back, and the one line of this rollout worth a release note. +`seed.dat` is untouched throughout. + +## Estimate + +| phase | work | days | blocked by | +|---|---|---|---| +| 1 | a key the identity may not have | 0.5–1 | — | +| 2 | the credentials file, migration, the writers, the upgrade rule (library + app) | 1.5 | 1 | +| 3 | listing, starting, the pumps, the DAO guard | 1 | 2 | +| 4 | the sign-in screen; an idempotent sign-in | 0.5–1 | 3 | +| 5 | the capability, the inventory, the Messages tab | 1 | 1 to compile, 3 to see | +| 6 | sign out, and the not-found exit | 0.5 | 2, 5 | +| 7 | the two round trips | 0.5 | 4, 5, 6 | +| 8 | rollout | — | all | + +**Roughly one focused week.** Phase 5 is where the estimate is least reliable: +the inventory was made by reading, and the compiler will not say whether it is +complete. The compose tests are how it is checked, and a row found late is a row +added to both. + +## Out of scope + +- **A public feed.** The thing that would make read-only *browsing* rather than + *previewing*. Its own plan, and when it lands the four decisions under + [the one decision](#the-one-decision-to-make-first) are the places to revisit. +- **NIP-46 remote signer, NIP-55 Android signer.** This plan builds half of what + they need — an identity whose secret is not on `Identity` — and none of the + other half, a signing interface the notary calls instead of a `NostrSignerSync`. + `LocalCanSign` is a Boolean on purpose: a remote signer answers it *true* — + which is why `canSign` is a property of the identity and not `nostrPrivateKey != null` + repeated at each site — and the question it adds is "can it derive?": the + ChillDKG host key needs the raw bytes, and no signer protocol gives them. A + second capability, not a third value of this one. +- **NIP-05 sign-in.** `alice@example.com` resolves to a pubkey with one HTTPS + request, and the app already accepts a NIP-05 for starting a message. It is a + natural fourth row in Phase 4's table, and it is a network round trip on a form + field, which is why `EditGroupCuratedSchemaViewModel.pubkeyOrNull` declined it. + A cheap follow-on once the npub path is proven. +- **NIP-49 `ncryptsec`.** Unchanged from the nsec plan. +- **Setting up a profile for a public key.** Impossible: the kind 0 has to be + signed. Said here so that nobody looks for the branch Phase 5 hid. +- **Reading a group's curated lists from outside it.** Public events, but every + path to them goes through a room today. The feed problem in a smaller shape. +- **Tightening secrets in memory.** Unchanged, and this plan adds no secret to + tighten. + +## Appendix — what was considered and rejected + +### A plaintext sibling file for public keys + +This plan's first design: `nostr-public-keys.json` beside the two `.dat` files, +unencrypted because a public key is public, written through the same atomic +helper, and app-side — no library change, no tag. It was rejected in review, and +rightly. The upgrade became two writes across two files with a crash window +between them and a "secret wins" rule in `merge` to repair the window; forget +became two paths; and the advantage that paid for all of that was worth less than +it looked, because the library commit that introduced `nostr-keys.dat` is itself +untagged, so a credentials format rides the tag that work already owes. One file, +one entry per pubkey, one write. + +### A sentinel entry in the version-1 format + +An empty-string value, or a zero key, under the pubkey, in `nostr-keys.dat` as it +is. Rejected because the file's contract is the reason it is trustworthy: +`EncryptedNostrKeys` refuses an entry whose key does not derive its pubkey, and a +sentinel is exactly such an entry. A typed credential is what a sentinel is trying +to be, and the file's doc had already reserved a version for it. + +### Bumping the version in place + +Version byte 2 in `nostr-keys.dat`, with the typed entries. Rejected in +[Phase 2](#why-the-file-is-renamed-rather-than-versioned-in-place): an older +build's `listIdentities` returns on `SerializationError` before it publishes +anything, so the old build would list no identities at all, seed wallets included. +A new file name is invisible to a build that does not know it. + +### A fake private key + +Give a read-only identity a random `PrivateKey` so that nothing has to become +nullable. The notary would sign as a key nobody follows, the DAO would try to +unseal wraps with it, the key package bundle would be published under it, and +every one of those would look, in the logs, like success. The nsec plan rejected +the fake `LocalKeyManager` for the same reason: a real object in a state its +authors never intended. Nullable is honest. + +### Gating on the kind at each site + +`if (identity.kind == IdentityKind.NostrPublic) hide()`. Works today, and is the +wrong question: the site wants to know whether it can sign. When a remote signer +arrives — the nsec plan already names it — every such `if` is a bug, and the +compiler will point at none of them. + +### Hiding the Messages tab + +A read-only identity lands on its profile and the bar has two items. Rejected: the +bar is the navigation, M3 gives a navigation bar three to five destinations and the +conformance work made an IA decision to reach three, and the empty state is where +the upgrade is offered. An identity that cannot find the way to become a signing +one is a preview of nothing. + +### Running all four pumps regardless + +Let the broadcast pump idle and the live subscriptions fetch what they fetch; the +DAO guard keeps the wraps. Rejected for what it costs the user for nothing: +`GiftWrapMessage` rows that will never be opened, placeholder profiles for their +senders, profile syncs for each, and a socket held open for an inbox that cannot +be read. The pumps that run are the ones with something to do. + +### A Room table for the list + +`ReadOnlyIdentity(publicKey)`, listed by the repository. Rejected because the +startup listing is built from the key stores by a view model that has no +repository, and because the nsec plan's listing test exists to catch two stores +disagreeing about what an id is; a store in a different layer, read at a different +time, is a second opinion where the credentials file is meant to be the only one. diff --git a/docs/nsec-sign-in.md b/docs/nsec-sign-in.md new file mode 100644 index 00000000..f91e0eb4 --- /dev/null +++ b/docs/nsec-sign-in.md @@ -0,0 +1,1026 @@ +# Signing in with an nsec + +How a user who already has a nostr key gets it onto this device, why that key can +never have a wallet behind it, and the ten call sites that make the whole thing +smaller than it looks. + +Read this against [jvm-target.md](./jvm-target.md) for the key-storage decision it +inherits, and with `NavigationViewModel.processLocalAccount` open for the state +machine it drives — the machine is already built, and half of this plan is about +finding its unreachable entrance. + +**Built**, phases 1–7, one commit each, in the order given; Phase 8 is the rollout +and is process rather than code. The phases are kept as written because they are +the reasoning, and the code reads better against the argument it came from than +against a summary of itself. Where the implementation chose differently the +table below says so, and it did so in ways worth reading before touching any of +it: + +| what the plan said | what it turned out to be | +|---|---| +| `WalletManagerExtension.nostrPublicKey()` "can go with" the ten sites | it stays: `CreateProfileViewModel` derives a fresh key's pubkey with it. Wrong by one caller | +| pull the failure classification into `TechnicalExtensions.kt` | its own file, `DecryptionFailure.kt`. `androidMain` has a `TechnicalExtensions.kt` in the same package, and a common file of that name may hold only `expect`s — anything with a body is a second `TechnicalExtensionsKt` facade the android target refuses. The `graceful*Seed*` actuals were left as they were | +| "lift the write into a helper both managers call" | `AtomicFileWrite.writeVerified`, and `SeedManager.writeSeedToDir` now goes through it with its original check and exception type | +| the nsec branch of startup "reports the startup" itself | it only sets the identity; the existing `activeIdentity != null` branch reports it. The mnemonic path reports twice (once from `onStartupSuccess`, once from that branch); the nsec path does not repeat the mistake | +| read `loadNostrProfile(startupRoute)` by the active identity | done, with a fallback to the first account when there is no identity — which is how `NavigationRoutingTest` constructs it, and never how production reaches it | +| input problems "through `ErrorState`" | on the field, as supporting text, while the prompt is still showing. `ErrorState` is for what happens *after* confirming — the key was already here, the write failed — where there is no field to put the message under | +| `SignInToProfileUIState.Confirm(kind, npub, hexKey)` | `Confirm(credential)`, with the `SignInCredential` carrying the pubkey; the two writers are injected as functions so `commit` is a suspend function with a result, testable without a key store | +| the not-found exit as a route | a state of the *screen*: `UnsyncedProfileViewModel.decide` over the account, with `NavigationUIState` unchanged. A `processed` request — an event came back, just not a kind 0 — counts as finished; the plan's test sketch said the opposite and was wrong | +| "set one up" opens `CreateProfileScreen` | an inline form on the not-found state. `CreateProfileScreen` is built around generating a seed and says so in its copy; and the kind 0 has to be written *over* the placeholder row, not beside it — see `setUpProfileForExistingKey` | +| the one hop, unspecified where | in the sync pump, after `saveNostrEvent`, with a per-account set so five indexers answering with the same kind 10002 make one hop | +| forget: key, prefs, metadata, active identity | plus the account's unsigned rows (`forgetLocalAccount`), which the plan did not list: the kind 0 that made it a local account, and anything queued that can now never be signed. Published events and the profile cache stay | +| assert `platformStartupLogic` was not called | `identity.business == null` and the jvm `BusinessManager.businessFlow` empty afterwards — the observable fact rather than the call | +| the dedupe rule: a seed's key is kept out of the key file, and a seed whose key is here as a bare secret is `SeedAlreadyExists` | **superseded** by [multiple-profiles.md](./multiple-profiles.md), Phase 1: a seed's key is a `Secret` credential like any other, written when the seed is and repaired in for every seed already here; a bare secret's phrase *attaches* the wallet rather than being refused; the identity's key is read from the credential, with the node's as a cross-check | + +Two things found on the way that are not in any phase. `DataStoreManager` caches +each id's preferences in a companion object for the life of the process, so a test +that reuses a key across temporary directories is served the wrong file +(`IdentityWriterJvmTest` uses fresh keys for that reason). And the library commit +is on `claude/nostr-key-store` in the submodule, at `59c11ed`, bumped into the app +by the Phase 3 commit; it has to be pushed with this branch, and tagged for JitPack +consumers, before any of this leaves the machine. + +## The constraint + +The profile's nostr key is not stored anywhere. It is derived, every time the +wallet starts, at the NIP-06 path: + +```kotlin +// lightning-kmp-app/library/.../managers/WalletManager.kt:105 +fun LocalKeyManager.nostrPrivateKey(): PrivateKey { + val path = KeyPath(if (isMainnet()) "m/44'/1237'/0'/0/0" else "m/44'/1237'/1'/0/0") + return derivePrivateKey(path).privateKey +} +``` + +That is BIP32 child derivation — `HMAC-SHA512(chainCode, parentKey ‖ index)` at +every step — and it runs one way. Holding the leaf tells you nothing about the +seed above it, and no seed can be chosen to land on a given leaf. So the object the +whole app hangs off, `LocalKeyManager`, is out of reach for an nsec by +construction: + +```kotlin +// lightning-kmp/modules/core/.../crypto/LocalKeyManager.kt:34 +data class LocalKeyManager(val seed: ByteVector, val chain: Chain, val remoteSwapInExtendedPublicKey: String) +``` + +No `LocalKeyManager` means no node key, no `PhoenixBusiness`, no channels, no +on-chain wallet. **"Restore only the nostr key" is not a product decision** — it +is the only thing an nsec can be restored *as*. Every design below starts from +that, and the ones in the [appendix](#appendix--what-was-considered-and-rejected) +that tried to get around it are there because they cannot. + +One consequence worth stating early: the twelve words and the nsec are not two +encodings of one thing, so a user who signs in with an nsec and later wants a +wallet is creating a *second* secret with a *second* recovery story. That is out +of scope here and is said so at the end. + +## What the app actually needs from the wallet + +Nothing but the key. That is the finding that makes this tractable. + +Every consumer of the wallet in `composeApp` is the same expression: + +```kotlin +activeWallet?.business?.walletManager?.keyManager?.value?.nostrPrivateKey() +``` + +ten times, in eight files: + +| site | what it does with the key | +|---|---| +| [NotaryViewModel.kt:71](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NotaryViewModel.kt) | signs the queues, seals Marmot bundles with `nsecPassword` | +| [SynchronizationViewModel.kt:193](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt) | the three pumps' key pair | +| [RelaysSocketManager.kt:68](../composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt) | the pubkey whose relay list to follow | +| [NavigationViewModel.kt:193](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt) | the pubkey whose account to observe | +| [DkgRitualViewModel.kt:476](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt), 516, 570 | the ChillDKG host key, derived from the nostr key | +| [SelectChatRoomTypeViewModel.kt:193](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt) | same | +| [SelectSubgroupAdminsViewModel.kt:208](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt) | same | +| [NostrNotaryRepository.kt:70](../composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrNotaryRepository.kt) | signs — but nothing constructs this class; it only has to compile | + +And that is the whole list. There is no call into `peerManager`, `paymentsManager`, +`balanceManager`, `sendManager` or `lnurlManager` anywhere in the app. The +Lightning node — Electrum connection, peer connection to the LSP, the WorkManager +watchers `schedulePlatformLogic` sets up — is started on every cold boot as a +**side effect of reaching a 32-byte key**. + +Everything downstream of the key is already independent of the seed. +`ChillDkgRitualManager.deriveHostSecretKey` hashes the nostr key +([ChillDkgRitualManager.kt:117](../composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt)); +`nsecPassword` is `hash160` of it; the notary and pumps build a quartz `KeyPair` +from its bytes. None of them would notice where the key came from. + +So the leverage point is one type: put a *signing identity* between the app and +the wallet, and an nsec becomes "an identity with no wallet behind it". The ten +sites collapse to one flow, and two of them get simpler — the `flatMapLatest` over +the key manager's `StateFlow` in the notary and the relay manager exists only +because the key arrives late, after the node; an identity is whole from the moment +it exists. + +## What is already built + +More than you would expect. The nostr half of "restore" was designed for the +app's earlier life as Torch and left in place, unreachable, when the seed became +the only way in. + +| piece | where | state | +|---|---|---| +| `signInToProfile(pubkey)` — plants a kind‑0 `UnsignedNostrEvent` stamped `signedAt = GENESIS_AT`, so the notary skips it and the navigation machine treats the account as "signed, needs syncing" | [DatabaseNostrRepository.kt:215](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt) | built, no caller | +| the state machine that takes it from there: `UnqueuedProfileSynchronization → UnsyncedProfile → UnindexedProfile → ProfileLoaded` | [NavigationViewModel.kt:96](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt) | built | +| the `purpose = "sign-in"` relay sync for kinds 0, 3, 10002, 10005, 10007, 10012, 10050 and 10086 — profile, contacts, and every relay list the app reads | [UnqueuedProfileSynchronizationViewModel.kt:57](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UnqueuedProfileSynchronizationViewModel.kt) | built, asks one relay — see [Phase 5](#phase-5--finding-the-profile-and-not-finding-it) | +| a `SignInToProfileViewModel` that parses `nsec1…`/`npub1…`/`nostr:` with quartz's `Nip19Parser` and calls `signInToProfile`; a `SignInToProfileUIState` with `InputPrompt`, `ConfirmNsecSignIn(nsec, hexKey)`, `ConfirmNpubSignIn`, `Error`; a `SignInToProfileFormState` | [SignInToProfileViewModel.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt) | built, unreferenced — and it **drops the nsec on the floor**; nothing ever stored it | +| `String.extractKeyPairFromPrivateKeyOrThrow()` — hex or bech32 in, `(nsec, npub)` out, `InvalidNostrPrivateKeyException` otherwise | [Credentials.kt:60](../composeApp/src/commonMain/kotlin/press/mantra/compose/extensions/Credentials.kt) | built | +| strings: `enter_the_nsec_or_npub_read_only_that_you`, `sign_in_to_nsec`, `sign_in_with_an_npub`, `be_sure_to_keep_this_nsec_safe`, `sign_in_to_torch_via_nsec_or_remote_signer` | `strings.xml` | present, unused | +| `ActiveWallet.business` is already `PhoenixBusiness?` | [Wallet.kt:50](../lightning-kmp-app/library/src/commonMain/kotlin/fr/acinq/phoenix/data/Wallet.kt) | nullable today | +| `keyStoreEncryption` / `keyStoreDecryption` — public `expect` functions with android, jvm and ios actuals, parameterised by key alias | [KeyStoreFunctions.kt](../lightning-kmp-app/library/src/commonMain/kotlin/fr/acinq/phoenix/security/KeyStoreFunctions.kt) | usable as-is | +| `platformWriteSeed(…, isRestoringWallet = …)` — the seed writer already has a restore flag | [NavigationViewModel.android.kt:64](../composeApp/src/androidMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.android.kt) | built; recovery-phrase sign-in is nearly free once a screen exists | +| `SignInToProfileScreen` | [SignInScreen.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt) | a sentence saying sign in is not available | + +The gap, then, is exactly two things: **somewhere for the nsec to live**, and +**something for the app to read the key from that is not the node**. Everything +else is wiring. + +## What actually blocks it + +Four things, in dependency order. + +**The at-rest store is shaped for words.** `seed.dat` is +`EncryptedSeed.V2.MultipleSeed`: the platform keystore's AES over a JSON +`Map>` +([EncryptedSeed.kt:60](../lightning-kmp-app/library/src/commonMain/kotlin/fr/acinq/phoenix/security/EncryptedSeed.kt)), +and `SeedManager.loadAndDecrypt` runs `MnemonicCode.toSeed` on every entry and +builds a `LocalKeyManager` from it to learn the wallet id +([SeedManager.kt:48](../lightning-kmp-app/library/src/commonMain/kotlin/fr/acinq/phoenix/managers/SeedManager.kt)). +A 32-byte key has no place in that shape, and the [appendix](#extending-encryptedseed-with-a-version-4-payload) +says why it should not be given one. + +**The identity is carried by the node.** `activeWalletInUI: +StateFlow` is set only from `StartBusinessResult.Success` +([SovereignWalletViewModel.kt:118](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt)), +and twenty-five parameters across eighteen files are typed on it. + +**Startup only knows about wallets.** `SovereignWalletStartupScreen` sends an +empty `availableWallets` to the landing page +([SovereignWalletStartupScreen.kt:91](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt)) +and starts whatever is selected with `startupNode(words)` (line 171). An nsec +stored anywhere else is invisible to it, and `NavigationViewModel.observeProfile` +answers a null `business` by navigating back to startup +([NavigationViewModel.kt:187](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt)) +— a loop, for an identity that will never have one. + +**The bootstrap asks the wrong relay.** The sign-in sync queues its `REQ` at +`Relays.DefaultDMRelayList`, which is `listOf(ephemeral)` — +`wss://ephemeral.mantra.press` +([Relays.kt:61](../composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/Relays.kt)). +That is the right relay for a profile *this app* created. An identity that has +lived on Damus for three years has never heard of it, and the machine has no exit +for "nothing came back". + +## The one decision to make first + +Should a mnemonic identity still start the Lightning node to reach its key? + +`CreateProfileViewModel` already derives the pubkey without one — it constructs +`LocalKeyManager(seed)` locally and reads `nostrPublicKey()` off it +([CreateProfileViewModel.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CreateProfileViewModel.kt)). +Doing the same at startup would make both kinds of identity symmetric — secret → +key in memory, node optional — and would take the Electrum and LSP connections off +every cold boot of what is, today, a chat application. + +**This plan keeps the node start for mnemonic identities.** Three reasons. +`BusinessManager.startNewBusiness` does more than load a key — it registers wallet +metadata, applies Electrum and Tor preferences, records the last-used app code, +and the comments in `SovereignWalletStartupScreen` and `SovereignWalletViewModel` +are the record of a flow that has already bitten twice; moving it is its own piece +of work with its own review. The lazy-start path will have to exist anyway the +day a wallet feature ships. And Phase 1 makes the later change small: once +everything reads an `Identity`, whether the node is behind it is one branch in one +place. + +So: the nsec branch has no node, the mnemonic branch keeps the one it has, and +"start the node lazily" is a follow-on named under [Out of scope](#out-of-scope). + +--- + +## Phase 1 — an identity in front of the wallet + +**App only. No behaviour change.** This is the refactor everything else stands on, +and it should land alone so that its diff is boring. + +### The type + +```kotlin +package press.mantra.compose.identity + +enum class IdentityKind { + /** Twelve words. The nostr key is derived from them, and so is a wallet. */ + Mnemonic, + /** A bare nostr secret. Nothing else can be derived from it. */ + NostrSecret, +} + +data class Identity( + val id: WalletId, + val kind: IdentityKind, + val nostrPrivateKey: PrivateKey, + val userPrefs: UserPrefs, + val internalPrefs: InternalPrefs, + /** The running node. Non-null only for [IdentityKind.Mnemonic]. */ + val business: PhoenixBusiness?, +) { + /** X-only, hex — the form every nostr call site wants. Computed once. */ + val nostrPublicKey: HexKey = nostrPrivateKey.publicKey().xOnly().value.toHex() + + override fun toString() = "Identity(id=$id, kind=$kind, key=)" +} +``` + +It replaces `ActiveWallet` in the app rather than wrapping it. `ActiveWallet` is +declared in the library but nothing in the library uses it — it is an app type +that happens to live in the wrong module — and flattening its three fields into +`Identity` means `KeyRecoveryScreen` and `RecoveryPhraseViewModel`, which read +`internalPrefs` off the active wallet today, keep working for an identity that +has no wallet. + +**`WalletId` stays as the id.** The field is called `nodeIdHash` and for an nsec +identity it will not be one, but every preference in the library keys on the type +— `loadUserPrefsForWallet`, `loadInternalPrefsForWallet`, `UserWalletMetadata`, +`getDefaultWallet` — and a second id type would mean a second copy of each. For +an nsec identity: + +```kotlin +// app side; WalletId has no companion to hang this off +fun XonlyPublicKey.toWalletId(): WalletId = + WalletId(Crypto.hash160(value).byteVector().toHex()) +``` + +Same shape as `WalletId(nodeId: PublicKey)` — forty hex characters of a +`hash160` — so nothing downstream can tell the kinds apart by the id, which is +what you want from an id. The kind is on the `Identity`, where it can be asked. +The two hashes are over different keys (the node key at `m/50'/0'`, the nostr +key at `m/44'/1237'/…`), so a mnemonic wallet and its own nostr identity would +never share an id even if both were registered — which is exactly the case +Phase 4's dedupe has to catch by pubkey instead. + +### The flow + +`SovereignWalletViewModel` gains + +```kotlin +private val _activeIdentity = MutableStateFlow(null) +val activeIdentity = _activeIdentity.asStateFlow() +``` + +and `setActiveWallet(walletId, business)` becomes `setActiveIdentity(identity)`, +with the mnemonic caller building the identity from the node it just started: + +```kotlin +val keyManager = business.walletManager.keyManager.value + ?: error("business started without a key manager") +Identity( + id = walletId, + kind = IdentityKind.Mnemonic, + nostrPrivateKey = keyManager.nostrPrivateKey(), + userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(walletId), + business = business, +) +``` + +`activeWalletInUI` goes. The twenty-five `StateFlow` parameters +become `StateFlow` — a rename in most of them; the eighteen files are +listed by `grep -rl 'StateFlow' composeApp/src/commonMain`. + +### The ten sites + +Each becomes a read of `activeIdentity.value?.nostrPrivateKey` (or +`?.nostrPublicKey`). Two deserve a word: + +- **NotaryViewModel** and **RelaysSocketManager** currently `flatMapLatest` from + the wallet flow into the key manager flow, because the key arrives after the + node. With an identity there is one flow and one `collectLatest`, and the + children it launches are cancelled on identity change exactly as they are now. + Keep the `distinctUntilChanged` on the key: a `data class` `Identity` compares + by value and the prefs objects inside it are stable per id, so it already + behaves, but the comment on `observeUnsignedNostrEvents` is about a + `distinctUntilChanged` that bit once; do not remove one without reading it. +- **NavigationViewModel.observeProfile** loses its `business == null → + StartupPhoenix` branch. That branch was the only thing that made a nodeless + identity loop; a null *identity* still means "go start something", which is + right. + +`WalletManagerExtension.kt` — the app's own `LocalKeyManager.nostrPublicKey()` — +is used by exactly these sites and can go with them. + +### Tests + +`NavigationRoutingTest` constructs a `NavigationViewModel` around a +`MutableStateFlow`; it becomes `MutableStateFlow` and +gains one case: an identity with `business = null` and a local account routes to +`ProfileLoaded`, not `StartupPhoenix`. That is the regression this phase is for, +and it is the assertion Phase 3 will lean on. + +--- + +## Phase 2 — the nostr key store + +**Library. Independent of Phase 1.** + +A sibling of the seed file, not a change to it: `node-data/nostr-keys.dat`, +encrypted under the same keystore key, with the same write discipline, read by a +manager shaped like `SeedManager`. + +### Why the same alias + +`KeyStoreNames.KEY_NO_AUTH`. Not for convenience — because Android gives no +choice: + +```kotlin +// KeystoreHelper.kt:74 +private fun getKeyForName(keyName: String): SecretKey = when (keyName) { + KeyStoreNames.KEY_NO_AUTH -> getOrCreateKeyNoAuthRequired() + KeyStoreNames.KEY_FOR_PINCODE_V1 -> getOrCreateKeyNoAuthRequired() + else -> throw IllegalArgumentException("unhandled key=$keyName") +} +``` + +A new alias is a library change on that platform regardless, and both existing +aliases already resolve to the one key. On the jvm, `JvmKeyStore.unlock` runs in +`Main.kt` before anything reads +([Main.kt:146](../composeApp/src/jvmMain/kotlin/press/mantra/desktop/Main.kt)), so a +second file under the same alias costs nothing there either; the ios keychain +helper is likewise keyed by alias. + +### The format + +``` +byte 0 file version = 1 +bytes 1..16 iv +bytes 17.. ciphertext of UTF-8 JSON: { "": "", … } +``` + +One version byte, not two. `EncryptedSeed.V2` carries a second because it has to +distinguish `SingleSeed` from `MultipleSeed`; this file has one shape and a new +shape would be a new version. Keyed by the **public** key so that a lookup and a +duplicate check are the same map operation, and so that the store can list what +it holds without decrypting anything more than it already has. + +```kotlin +package fr.acinq.phoenix.security + +class EncryptedNostrKeys(val iv: ByteArray, val ciphertext: ByteArray) { + fun decryptAndGetKeyMap(): Map + fun serialize(): ByteArray + companion object { + const val VERSION: Byte = 1 + fun deserialize(bytes: ByteArray): EncryptedNostrKeys + fun encrypt(keys: Map): EncryptedNostrKeys // KEY_NO_AUTH + } +} +``` + +```kotlin +package fr.acinq.phoenix.managers + +object NostrKeyManager { + fun loadAndDecrypt(phoenixGlobal: PhoenixGlobal): DecryptNostrKeysResult + fun loadAndDecryptOrNull(phoenixGlobal: PhoenixGlobal): Map? // empty map when no file + fun writeToDisk(phoenixGlobal: PhoenixGlobal, keys: EncryptedNostrKeys) +} +``` + +`writeToDisk` is `SeedManager.writeSeedToDir` with the type changed: encrypt to +`temporary_nostr_keys.dat`, read it back, compare `iv` and `ciphertext` +byte-for-byte, `atomicMove` over `nostr-keys.dat`, delete the temp file on any +failure. That discipline exists because a truncated seed file is an unrecoverable +wallet; a truncated key file is an unrecoverable identity, and the reasoning +transfers whole. Lift the body into a private helper both managers call rather +than copying it — the diff will show whether that is one function or two. + +`DecryptNostrKeysResult` mirrors `DecryptSeedResult` — `Success(map)`, +`Failure.FileNotFound`, `SerializationError`, `KeyStoreFailure(cause)`, +`DecryptionError(cause)`, `FileUnreadable` — and the exception mapping is the +one `gracefulMultiSeedDecryption` does per platform +([TechnicalExtensions.android.kt:17](../lightning-kmp-app/library/src/androidMain/kotlin/fr/acinq/phoenix/utils/extensions/TechnicalExtensions.android.kt)): +`SerializationException`/`IllegalArgumentException` → serialization, +`KeyStoreException` → keystore, else → decryption. Those three `inline` actuals +are typed on `DecryptSeedResult`; rather than three more, pull the classification +into a common `expect fun classifyDecryptionFailure(e: Exception): DecryptionFailureKind` +and have both result types map from it. + +### Two small things while in this file + +**`LocalKeyManager.nostrPublicKey()` in the library is not a nostr public key.** +It returns `nostrPrivateKey().publicKey().toHex()` — the 33-byte compressed +encoding, sixty-six hex characters. Nostr keys are x-only, sixty-four. Nothing in +the app calls it (the app has its own, correct, `press.mantra.compose.extensions.nostrPublicKey`), +which is the only reason it has not mattered. Fix it or delete it; a helper this +plan does want is + +```kotlin +fun PrivateKey.nostrPublicKeyHex(): String = publicKey().xOnly().value.toHex() +``` + +so that the app's `Identity.nostrPublicKey` and the store's map key are computed +the same way in one place. + +**Testnet derives at account `1'`.** `m/44'/1237'/1'/0/0` is legal — NIP-06 leaves +the account index to the application — but an imported nsec bypasses the chain +entirely, so a testnet build signs with whatever key was pasted. That is correct; +it is only worth a comment next to the path so nobody "fixes" it. + +### Tests + +Two, in the library, each following a test that already exists. + +`EncryptedNostrKeysTest` in `commonTest`, after `EncryptedSeedTest`: the +serialized layout spelled out as literal bytes — version byte, sixteen iv bytes, +the rest — because, as that test's header says, the format is a compatibility +contract and the expected bytes must not be derived from the constants under test. + +A round trip in `jvmTest`, after `JvmKeyStoreTest`: `JvmKeyStore.unlock` against +a temp directory, `NostrKeyManager.writeToDisk`, `loadAndDecrypt`, and the same +map back. This is the one platform where encrypt-and-decrypt can be exercised on +the host, and it covers the atomic write path, which the layout test cannot. + +--- + +## Phase 3 — listing and starting identities + +**App. Needs Phases 1 and 2.** + +### Listing + +`SovereignWalletViewModel.listAvailableWallets` reads one store and exposes +`availableWallets: Map`. It becomes `listIdentities`, +reads both, and exposes + +```kotlin +sealed interface StoredIdentity { + val id: WalletId + val nostrPublicKey: HexKey + data class Mnemonic(val userWallet: UserWallet, override val nostrPublicKey: HexKey) : StoredIdentity { + override val id get() = userWallet.walletId + } + data class NostrSecret(override val id: WalletId, override val nostrPublicKey: HexKey, val privateKey: PrivateKey) : StoredIdentity +} +val availableIdentities: StateFlow> +``` + +For a mnemonic entry the nostr pubkey is one `LocalKeyManager(MnemonicCode.toSeed(words))` +away; `SeedManager.loadAndDecrypt` has already built exactly that key manager to +learn the node id, so this is the second derivation, not a new cost. The pubkey +is needed at listing time for one reason — see [dedupe](#dedupe-on-the-nostr-key-not-the-id) +in Phase 4 — and it is what the selector should show for an nsec identity, where +there is no node id to show. + +On secrets in memory: `availableWallets` holds decrypted words today for the +lifetime of the view model, because `startupNode(words)` needs them. +`StoredIdentity.NostrSecret` holds the private key for the same reason and is no +worse. Tightening that — decrypt at activation, hold only ids and pubkeys in the +list — is a real improvement and applies to both kinds equally, which is why it +is not done here. + +Metadata registration is unchanged: the loop that saves a `UserWalletMetadata` +with a random avatar for every id it has not seen +([SovereignWalletViewModel.kt:128](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt)) +works on `WalletId` and does not care what is behind it. + +### Starting + +`SovereignWalletStartupScreen` picks a `StoredIdentity` where it now picks a +`UserWallet`, and `LoadWallet` — the screen-lock gate — takes the identity rather +than the wallet. It only ever read `walletId` off the wallet, to look up the lock +preferences, so the change is the parameter type; and it matters that the gate +sits *outside* the branch, so a future lock is not something to remember to add +twice: + +```kotlin +LoadWallet(stored, metadata, userPrefs, promptScreenLockImmediately) { stored -> + when (stored) { + is StoredIdentity.Mnemonic -> + sovereignWalletStartupViewModel.startupNode(stored.id, stored.userWallet.words) { business -> + sovereignWalletViewModel.setActiveIdentity(identityFor(stored.id, business)) + onSuccessfulStartup() + } + is StoredIdentity.NostrSecret -> { + sovereignWalletViewModel.setActiveIdentity( + Identity( + id = stored.id, + kind = IdentityKind.NostrSecret, + nostrPrivateKey = stored.privateKey, + userPrefs = dataStoreManager.loadUserPrefsForWallet(stored.id), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(stored.id), + business = null, + ) + ) + onSuccessfulStartup() + } + } + loadingIdentity = null +} +``` + +No `platformStartupLogic`, no `schedulePlatformLogic`, no `StartupViewState` +transitions for the nsec branch — an nsec identity is active the moment it is +read. The `StartingBusiness`/`BusinessActive` spinners that follow the `when` in +the screen today are reached only from the mnemonic branch, which is the one that +still has something to wait for. + +`availableWallets.isEmpty()` (line 91) becomes `availableIdentities.isEmpty()`. +The default-wallet and desired-wallet logic keys on `WalletId` and needs nothing. + +### The other entrance, and the race it hides + +`NavigationViewModel` decides where a boot goes from two places, and only one of +them is the flow the ten sites feed. `onSuccessfulStartup` calls +`loadNostrProfile(startupRoute)`, which reads + +```kotlin +val activeUserPublicKey = nostrRepository.getLocalAccounts().firstOrNull()?.profile?.publicKey +if (activeUserPublicKey == null) { … Landing … } +``` + +Two things are wrong with that line for this plan. It takes the *first* kind-0 +account on the device, whichever identity is active — harmless with one wallet, +wrong the moment there are two. And it keys off `profile.publicKey`, which is the +`Profile` row the notary upserts when it *signs* the kind 0. A placeholder +account planted by `signInToProfile` has an `UnsignedNostrEvent` and no +`Profile`, so this reads null and answers **`Landing`** — for a key that has just +been imported. Meanwhile `observeProfile`, collecting the same account through +the identity flow, answers `UnqueuedProfileSynchronization`. Both write +`_navigationUIState`; the order is whichever coroutine runs last, and `Landing` +navigates with `popUpTo(0)`. + +The create flow has the same window today — between `createNewProfile` and the +notary signing the kind 0, there is no `Profile` either — and gets away with it +because the flow collector keeps emitting as the rows change. An imported +identity's rows change less often. So, in this phase: + +```kotlin +val identity = activeIdentity.value +val account = nostrRepository.getLocalAccounts() + .firstOrNull { it.unsignedNostrEvent?.pubKey == identity?.nostrPublicKey } +val activeUserPublicKey = account?.unsignedNostrEvent?.pubKey +``` + +Read by the active identity, keyed on the unsigned event's `pubKey` — the row +`signInToProfile` writes and `getLocalAccounts()` selects on — and pass the +account straight to `processLocalAccount`, which already knows what a placeholder +means. `NavigationRoutingTest` gains the fixture it is missing: an account whose +`profile` is null and whose `signedAt` is `GENESIS_AT`, asserted to land on +`UnqueuedProfileSynchronization` and never on `Landing`. + +### The selector + +The app's `WalletsSelector` takes `Map` and shows +`userWallet.nodeId` in monospace +([WalletsSelector.kt:55](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt)). +It takes `Map` and shows the npub for an nsec identity +and the node id for a mnemonic one — or the npub for both, which is the identifier +the rest of the app uses and the one a user might actually recognise. The +`onWalletClick` callback carries the `StoredIdentity`. + +### Hoist the seed writer while you are here + +The three `platformWriteSeed` actuals — android, jvm, ios — are byte-identical +(`diff` of the three bodies is empty). Every symbol they use is `commonMain`: +`SeedManager`, `EncryptedSeed`, `LocalKeyManager`, `DataStoreManager`, +`AppVersion`. They are an `expect` because something once needed to differ and +nothing does now. Before adding a second writer beside them, make them one +common function; then add + +```kotlin +suspend fun writeNostrKey(phoenixGlobal, privateKey: PrivateKey, isTorEnabled, customElectrumServer): WalletId +``` + +next to it, with the same shape: load the existing map, refuse a duplicate, add, +encrypt, write, save the per-id prefs, return the id. The Tor and Electrum prefs +are meaningless for an nsec identity; save them anyway, because +`loadUserPrefsForWallet` is what creates the prefs file and the recovery screens +read it. + +### Tests + +`NavigationRoutingTest` already covers the state machine from a local account +inward. Add, in `jvmTest`, a `SovereignWalletViewModel` listing test that seeds +both stores through the jvm keystore (Phase 2's fixture) and asserts the merged +map: one entry per secret, ids of the right shape, pubkeys that match what +`Identity` computes. This is the test that would have caught the two stores +disagreeing about what an id is. + +--- + +## Phase 4 — the sign-in screen + +**App. Needs Phase 3.** `SignInToProfileScreen` becomes a screen. + +### Shape + +One screen, one text field, two things it accepts — because a user does not +choose an input type, they paste what they have, and the two are unambiguous: + +| pasted | recognised as | validation | +|---|---|---| +| twelve (or twenty-four) space-separated words | recovery phrase | `MnemonicCode.validate(words, English.wordlist())` — the checksum catches a wrong word | +| `nsec1…`, or `nostr:nsec1…` | nostr secret | quartz `Nip19Parser.uriToRoute(...)?.entity is NSec`, as the skeleton already does | +| 64 hex characters | nostr secret | `extractKeyPairFromPrivateKeyOrThrow()` accepts it; the confirm step shows the npub so a wrong paste is visible | +| `npub1…` | rejected, with the reason | see [Out of scope](#out-of-scope) — the app cannot do anything with a key it cannot sign with | +| `ncryptsec1…` | rejected, with the reason | NIP-49; a cheap follow-on, named in Out of scope | + +The field is a `TextFieldForm` with `SignInToProfileFormState`, which exists. +Trim, collapse whitespace, lower-case a hex string; do not lower-case words +(the wordlist is lower-case already and a capitalised word is a paste artefact +worth showing rather than silently fixing). + +### Flow + +`SignInToProfileUIState` already has the states; use them, with `ConfirmNsecSignIn` +generalised to a `Confirm(kind, npub, hexKey)`: + +1. **InputPrompt.** The field, a paste button, and the sentence from + `enter_the_nsec_or_npub_read_only_that_you` rewritten for what is actually + accepted — a recovery phrase or an nsec. Sentence case. New string. +2. **Confirm.** The derived npub, in full, in monospace, with which kind was + recognised, and one button. This is the step that catches the paste of the + wrong nsec, and for a recovery phrase it is where the user learns which + profile the words open. The nsec itself is never echoed back. +3. **Committing.** `LoadingDataIndicator`. What happens is in the next section. +4. **Error**, through `ErrorState` with an `onRetry` that returns to the prompt; + `null` is not the right answer here. Failures are named: invalid checksum, + not a key, already on this device, could not write. + +Four states, said so, wrapped in `ScreenStateTransition` since the `when` is the +body. Snackbar for the one success message, read above the handler through +`rememberNotifier`. Content root has `readableContent()`; the field spans it. + +### Committing an nsec + +```kotlin +val privateKey = PrivateKey.fromHex(entity.hex) // NSec.hex, 32 bytes, from the parser +val pubkey = privateKey.nostrPublicKeyHex() +rejectIfKnown(pubkey) // below +val id = writeNostrKey(phoenixGlobal, privateKey, …) // Phase 3's writer +nostrRepository.signInToProfile(publicKey = pubkey) // the kind-0 placeholder, GENESIS_AT +sovereignWalletViewModel.listIdentities { + sovereignWalletViewModel.switchToWallet(id) + navController.navigate(SovereignWalletStartupRoute) +} +``` + +The tail is the one `CreateProfileRoute` already uses after `writeSeed` +([MantraNavHost.kt:505](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt)): +re-list, select, go to startup. Startup finds a `StoredIdentity.NostrSecret`, +activates it, `NavigationViewModel` observes its pubkey, finds the placeholder +account with `signedAt != null` and no sync requests, and lands on +`UnqueuedProfileSynchronization`. From there the existing machine runs. + +The order of the two middle lines is not a style choice. The placeholder has to +be in the database **before** the identity is activated: `observeProfile` starts +collecting the account the moment the identity flow emits, and an account that +is not there yet reads as `null`, which `processLocalAccount` answers with +`Landing`. It would correct itself when the row landed, through a `popUpTo(0)` +the user can see. Write the key, plant the placeholder, then re-list. + +### Committing a recovery phrase + +```kotlin +MnemonicCode.validate(words, wordlist) +val pubkey = LocalKeyManager(MnemonicCode.toSeed(words, "").byteVector(), chain, xpub).nostrPrivateKey().nostrPublicKeyHex() +rejectIfKnown(pubkey) +sovereignWalletViewModel.writeSeed(words, isRestoringWallet = true, onSeedWritten = { id -> + nostrRepository.signInToProfile(pubkey) + listIdentities { switchToWallet(id); navigate(SovereignWalletStartupRoute) } +}) +``` + +Same tail. `writeSeed` already carries `isRestoringWallet`; the only new line is +the `signInToProfile` that tells the machine this pubkey has a history to fetch +rather than a profile to create. Without it a restored wallet with no local rows +goes to `Landing` and is offered "create profile" for a key that already has one +— that is the state of restore today, and it is why the two inputs belong on one +screen. + +### Dedupe on the nostr key, not the id + +`platformWriteSeed` refuses a seed whose `WalletId` is already in the map. That +is the wrong key for this check. A mnemonic wallet and an imported nsec can be +the **same npub** with **different ids** — one is `hash160(nodeId)`, the other +`hash160(nostrPubkey)` — and the database is keyed by pubkey: `getLocalAccounts()` +is `SELECT * FROM UnsignedNostrEvent WHERE kind = 0`, joined to `Profile` on +`pubKey`. Two identities for one pubkey would share every row and disagree about +which is active. + +So `rejectIfKnown(pubkey)` checks the merged `availableIdentities` by +`nostrPublicKey`, for both inputs. The existing `WalletId` check stays; it is a +cheaper first pass for the seed case, not a replacement. + +### Landing + +`LandingScreen`'s "Sign in" already navigates to `SignInRoute`. The caption +beneath it, `sign_in_to_torch_via_nsec_or_remote_signer`, promises a remote +signer this plan does not deliver; change it to name what the screen accepts. + +### Tests + +`SignInToProfileViewModel` gets a `jvmTest` beside +`EditGroupCuratedSchemaViewModelJvmTest`: each row of the table above, in and +out — the npub for a known nsec test vector (NIP-06's own vectors give a +mnemonic → nsec → npub triple, which covers both inputs against the same +expected key); a wrong word; an `npub`; an `ncryptsec`; a duplicate. The +repository is `NostrRepository.NO_OP_NOSTR_REPOSITORY` with `signInToProfile` +overridden to record, so the test also proves the placeholder is planted exactly +once and only after the write succeeds. + +--- + +## Phase 5 — finding the profile, and not finding it + +**App. Needs nothing above it to compile, but is pointless before Phase 4 gives +it a caller.** + +### Ask the relays that would know + +`UnqueuedProfileSynchronizationViewModel.queueSynchronization` fans the sign-in +`REQ` out over `Relays.DefaultDMRelayList` — one relay, ours. Change it to +`Relays.DefaultIndexerRelayList` +([Relays.kt:65](../composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/Relays.kt): +purplepag.es, indexer.coracle.social, user.kindpag.es, directory.yabu.me, +nostr1) **plus** `ephemeral`. Indexer relays exist to hold everyone's kinds 0, 3 +and 10002; that is the whole of their purpose, and it is exactly the list the +sync asks for. Our own relay stays in the set so a profile created here is found +here. + +Then follow the answer. When the sync indexes a kind 10002 for this pubkey, queue +a second, `level = 1`, `purpose = "sign-in"` request at the relays it names, for +the same kinds. That is the outbox model doing what it is for, and the +`SynchronizeNostrEventRequest.level` field is already there to mark the hop. +Bound it at one hop; the user's own relays are where their relay list is +authoritative, and anything past that is the feed, not the profile. + +### Give the machine an exit + +Today `processLocalAccount` reads + +```kotlin +unsignedNostrEvent.signedAt != null && synchronizeNostrEventRequests.isNotEmpty() -> UnsyncedProfile +``` + +and `UnsyncedProfile` shows "we are searching the internet to find your profile" +with a spinner, forever. Nothing records that a search *finished empty*. A +request goes `pending → sent` when its `REQ` is dispatched +([DatabaseNostrRepository.kt:489](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt)) +and `sent → processed` only when an event arrives for it (line 532, which also +stamps the `nostrEventId`). A relay that answers with EOSE and nothing else +leaves its request at `sent` for good; the EOSE branch in +`SynchronizationViewModel` closes the subscription and writes nothing down. So +"still searching" and "searched, found nothing" are the same row. + +Two changes: + +1. **Record completion.** On EOSE, CLOSED, or the bounded timeout the + subscription already enforces, upsert any request still at `sent` with + `status = "complete"`. A request already at `processed` stays there — an + event arrived, which is the better answer. This is the one place the app + learns a relay has said everything it has, and it is currently thrown away. +2. **Observe it.** `UnsyncedProfileViewModel` watches the account's sign-in + requests; when none is still `pending` or `sent` and there is still no kind 0 + indexed for the pubkey, it moves the screen to a **not-found** state: "We could not + find a profile for this key on the relays we asked" with two actions — + *try again* (re-queue at level 0) and *set one up*. The second opens the + existing `CreateProfileScreen` form (name, bio) and calls + `nostrRepository.createNewProfile(pubkey, name, bio)` — the six-event + bootstrap a new key gets — **without** the seed generation that + `CreateProfileViewModel.createAccount` does first. The key already exists; only + the events do not. + +The relay list that "set one up" writes is the default one, which is the right +answer for a key that has never published anywhere. + +This exit is not nsec-specific. A restored recovery phrase whose profile was +never published, or a device offline at sign-in, sits on the same spinner today. +It is listed here because an imported nsec is the first path that will hit it +routinely: many nostr keys are made in a client that never wrote a kind 0. + +### Tests + +The navigation state stays `UnsyncedProfile` throughout — not-found is a state +of the *screen*, decided by its view model, so `NavigationRoutingTest` needs +nothing here. `UnsyncedProfileViewModel` gets a `jvmTest` over a fake repository +that emits the account's sign-in requests in sequence — all `pending`, then +`sent`, then `complete` — with no kind 0 indexed, and asserts the screen state +flips to not-found on the last emission and not before; and a second run where +one request goes `processed` instead, asserting it never flips at all. + +--- + +## Phase 6 — recovery, for an identity with no phrase + +**App. Needs Phase 3.** + +`KeyRecoveryScreen` offers one backup: *recovery phrase*, routing to +`RecoveryPhraseRoute`, whose view model reads `userWallet.words` out of the +decrypted seed map +([RecoveryPhraseViewModel.kt:114](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/RecoveryPhraseViewModel.kt)). +For an nsec identity there are no words, and `NoPhraseForThisWallet` is the +honest error it would show. Instead: + +- **`KeyRecoveryScreen` branches on `identity.kind`.** A mnemonic identity keeps + the phrase option. An nsec identity gets a *nostr secret key* option in its + place — `Icons.Default.Key`, `be_sure_to_keep_this_nsec_safe` as the + description, which exists — routing to a `NostrSecretRoute`. +- **`NostrSecretScreen`** is `RecoveryPhraseScreen` with the word grid replaced by + the nsec: hidden until revealed, revealed in monospace with a copy action, the + same "I have saved it" and "I understand" checkboxes, hidden again on leaving. + It reads the key from `NostrKeyManager.loadAndDecrypt` at reveal time, as the + phrase screen reads the seed file — not from the active identity — so the + screen's contract ("nothing secret held longer than it is shown") is the same + for both. The nsec is `privateKey.value.toHex().hexToNsecHrp()` — + `Credentials.kt` already has the encoder. +- **The backup flags reuse.** `isManualSeedBackupDone` and + `isSeedLossDisclaimerRead` live in the per-id `InternalPrefs` + ([InternalPrefs.kt:66](../lightning-kmp-app/library/src/commonMain/kotlin/fr/acinq/phoenix/utils/preferences/InternalPrefs.kt)), + so `showSeedBackupNotice` already means "this identity's secret is not backed + up" for whichever secret it is. The screens say "phrase" in three strings; + those become kind-aware or neutral ("recovery information", which the cloud + backup row already uses). +- **The disclaimer changes meaning.** `i_understand_that_if_i_lose_this_phone_and` + says "…I lose this profile and the funds in its wallet". An nsec identity has + no wallet; a new string for that kind, so the app is not warning about funds + it cannot hold. +- **Forget this key.** An import needs an inverse. Under the key options, for an + nsec identity only: remove the key from `nostr-keys.dat`, delete its prefs + (`DataStoreManager.deleteNodeUserPrefs` exists), hide its metadata, clear the + active identity, and go to startup. Behind a confirmation that names the npub + and says the profile stays on the relays. This is the first real "sign out" + in the app — `ActiveProfileScreen`'s button routes to + `ImplementationPendingRoute("Sign out")` — and the general case stays pending; + removing a *seed* is a wallet question and is not this plan's to answer. + +The two wallet-shaped rows on `KeyRecoveryScreen` — cloud backup, emergency kit +— are `ImplementationPendingRoute` today and stay for both kinds. + +--- + +## Phase 7 — the tests that actually prove it + +The per-phase tests above are unit tests of one seam each. Two more say the whole +thing works. + +**A restore round trip, on the jvm.** Unlock the keystore into a temp dir, write +an nsec through Phase 3's writer, list identities, activate the `NostrSecret`, +hand the `NavigationViewModel` a repository holding the `GENESIS_AT` placeholder, +and assert it lands on `UnqueuedProfileSynchronization`; then hand it the same +account with a kind 0 indexed and assert `ProfileLoaded`. This is the path a user +takes, end to end, with the network faked at the repository. + +**Nothing Lightning ran.** For the nsec activation above, assert +`platformStartupLogic` was not called. Wrap the `expect` in a counting fake for +the test, or — cheaper — assert `Identity.business == null` and that +`SovereignWalletStartupViewModel.state` stayed `Init`. An nsec identity that +quietly started a node would be a bug that nothing in the UI would reveal. + +And the one that already exists: `./gradlew :composeApp:m3Audit` over the two +new screens. The string budget is zero title-case literals and the spacing budget +is zero `.dp` in spacing positions; a sign-in form is exactly where a stray `16.dp` +and a "Sign In" arrive. + +--- + +## Phase 8 — rollout + +**Library first.** Phase 2 is a commit on `kngako/lightning-kmp-app`; the app +builds against the submodule checkout through the composite build, so it reaches +the app as a submodule pointer bump in the same commit as Phase 3. Tag it, since +JitPack consumers resolve by tag, and the README there says so. + +**Phase 1 ships alone,** before any of the rest is visible. It touches about +twenty files and changes nothing a user can see, and that is the point: if the release +after it behaves differently, the cause is in one diff. + +**Phases 3 and 4 ship together.** A build that can list an nsec identity but not +create one is fine; a build that can create one and not list it strands the user +at "Initializing…", which is where the comment at the top of +`SovereignWalletStartupScreen` says this app has stranded people before. + +**Phase 5 can ship before 4** — it fixes restore for recovery phrases too — but +should not ship *after* 4 by more than a release, for the reason given there. + +**Old builds and the new file.** A build without Phase 3 does not read +`nostr-keys.dat` and does not know the file exists. A user who imports an nsec +and then downgrades sees the identity vanish from the selector and nothing else +breaks; the file is still there for the next upgrade. `seed.dat` is untouched +throughout, so no build old or new misreads a wallet. + +## Estimate + +| phase | work | days | blocked by | +|---|---|---|---| +| 1 | identity in front of the wallet (app) | 1–2 | — | +| 2 | nostr key store (library) | 1 | — | +| 3 | listing and starting identities; the other entrance; hoist the seed writer | 2 | 1, 2 | +| 4 | the sign-in screen, both inputs, dedupe | 1–2 | 3 | +| 5 | indexer relays, one hop, the not-found exit | 1 | — | +| 6 | recovery for an nsec identity; forget this key | 1 | 3 | +| 7 | round-trip tests | 0.5 | 4, 5 | +| 8 | rollout | — | all | + +**Roughly one and a half to two focused weeks**, with Phases 1 and 2 in parallel +if two people are on it. Phase 3 is where the estimate is least reliable: it is +the first time both halves meet, and the startup screen has a history. + +Phases 1–7 are now implemented, in one sitting and in order. What each turned out +to require, as against what was predicted here, is in the table at the top and in +the commit messages on this branch. The two surprises were not in any phase's +description: the android source set's file-name collision in Phase 2, and the +process-global preferences cache that only a test with a fixed key could have +found. + +## Out of scope + +- **A wallet for an nsec identity.** Not possible from the nsec, for the reason + in [The constraint](#the-constraint). Possible as a *second* secret — generate + a seed, attach it to the identity — but then one profile has two things to back + up and every recovery screen in this plan has to say which. Its own plan. +- **Starting the node lazily for mnemonic identities.** Recommended, separable, + and made small by Phase 1. See [The one decision](#the-one-decision-to-make-first). +- **`npub` sign-in (read only).** The skeleton parses it, and `signInToProfile` + would accept it. But every write path in the app assumes a key: the notary + would have nothing to sign with and the state machine would park on + `UnsignedProfile` the first time anything was queued. A read-only mode is a + product, not a branch — and [npub-sign-in.md](./npub-sign-in.md) is the plan + for that product, starting from what it is for. +- **NIP-49 `ncryptsec`.** A passphrase-encrypted nsec. quartz 1.14.0 ships + `nip49PrivKeyEnc.Nip49.decrypt`, so it is one call and a passphrase field; it + belongs in Phase 4's table once the plain nsec path is proven, and is a cheap + follow-on. +- **NIP-46 remote signer, NIP-55 Android signer.** The landing caption promises + one. Both need a signing *interface* — sign, NIP-44 encrypt, decrypt — in place + of a raw key, and `deriveHostSecretKey` (ChillDKG) needs the raw key bytes and + cannot be done through a remote signer at all. That is a different design with + a real constraint in it, and `NostrNotaryRepository`'s `isExternalSignerLogin` + stub is where it would start. +- **Tightening secrets in memory.** Both `availableWallets` today and + `StoredIdentity` here hold decrypted secrets for the view model's life. + Decrypt-at-activation applies to both kinds and should be done once, for both. +- **General sign out.** Phase 6 removes an *nsec*. Removing a seed is a wallet + operation with funds behind it, and `ActiveProfileScreen`'s button stays + pending. + +## Appendix — what was considered and rejected + +### Extending `EncryptedSeed` with a version-4 payload + +Put both kinds in `seed.dat`: a new `V2` variant (version byte 4 — 1 is single, +2 is retired, 3 is multiple) whose JSON is +`{ "": { "type": "mnemonic", "words": [...] } | { "type": "nsec", "key": "…" } }`, +and make `UserWallet` a sealed type. + +One file, one migration, one keystore alias — and rejected. The library is a +Phoenix fork whose history is visibly a stream of upstream ports; `seed.dat` is +Phoenix's format, and a fork of it is a merge conflict on every port from now on. +An older build reading the new file throws `unhandled V2 seed version=4` and +reports the *wallet* unreadable — a user who imported an nsec and downgraded +would lose access to their funds' seed until they upgraded, which is a worse +failure than the sibling file's "the nsec is not listed". And `BusinessManager.startNewBusiness(words)` +on three platforms, `RecoveryPhraseViewModel`, `WalletsSelector` and every +`UserWallet.words` site would have to learn a variant that has no words. A class +named `EncryptedSeed` should hold seeds. + +### Encoding the nsec as a mnemonic + +Thirty-two bytes is two hundred and fifty-six bits of entropy, which BIP39 +encodes as twenty-four words. So an nsec *can* be written as a valid mnemonic. +It is not the same thing as a seed whose NIP-06 leaf is that nsec — the words +would be run through `MnemonicCode.toSeed` and `LocalKeyManager` and derive a +completely different nostr key — and a store that cannot tell "words that are a +seed" from "words that are a key in disguise" is a store with a silent +misinterpretation in it. Rejected without a second look. + +### Deriving a seed from the nsec + +Not possible. Stated in [The constraint](#the-constraint); listed here so nobody +spends an afternoon confirming it. + +### The node as the key's carrier + +Leave `activeWalletInUI` as it is and build a fake `PhoenixBusiness`, or a fake +`LocalKeyManager`, for an nsec identity so the ten sites need not change. +`LocalKeyManager` is a `data class` with a seed; there is no interface to fake, +and a `PhoenixBusiness` lazily constructs eleven managers that expect a node. +Every "fake" would be a real object in a state its authors never intended, and +the ten sites are ten lines. + +### A new keystore alias for the key file + +Cleaner in principle — a compromise of one key would not be a compromise of +both. But `KeystoreHelper.getKeyForName` maps both existing aliases to **the same +key**, so the separation is already notional on Android, and a third alias means +touching that switch. Reuse, and note that separating the seed's and the key's +keystore entries is a hardening item for the wallet as much as for this. + +### Plaintext in DataStore + +`UserPrefs` is a DataStore file per wallet id and it would be one line to put the +nsec in it. It is unencrypted on disk. The seed has never been stored that way +here, and the key that signs as the user should not be the first. diff --git a/docs/scripts/curated-replay.py b/docs/scripts/curated-replay.py new file mode 100755 index 00000000..bf6b209d --- /dev/null +++ b/docs/scripts/curated-replay.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Land rewritten Curated commits on a Mantra branch, one cherry-pick at a time. + +The companion of curated-unbrand.py, and the second half of docs/curated-to-mantra.md's +pipeline as it was actually run; docs/curated-to-mantra-profiles.md is the second pull it +served, and added the one rule that pull needed. The rewrite (filter-branch with curated-unbrand.py as the +tree filter and a `Pulled-From: curated/curated@` trailer stamped by its message +filter) leaves a branch `tmp/curated-src` in Mantra's names; this replays commits off it +by their *original* sha, in the order you give, onto the current branch of . + + curated-replay.py pick ... cherry-pick each, resolving by the rules below + curated-replay.py check files differing from the rewritten cut + +Resolutions are rules, and anything the rules do not cover stops the run with the commit +aborted, so a new conflict is a decision rather than a guess. What the rules encode: + + * a branch join -- the first commit off one side of an upstream merge landing on top of + the other side -- is resolved by taking the join files exactly as upstream's own merge + left them (TAKE_FROM). Not a union: a union orders the two appended blocks its own way, + and every later commit that edits the block then misses its base and its deletions + silently fail. + * files whose lines are unique by construction (string keys, imports, route + registrations, table rows) get a line-set three-way merge over diff3 hunks (UNION): + ours, minus what theirs deleted from base, plus what theirs added that the file does + not already hold anywhere. + * the library pin follows the commit being replayed (PIN), because each upstream commit + compiled against the pin it recorded; `5efeae76` named a branch commit that no longer + exists and is mapped to the master commit with the identical tree. + * a handful of (commit, file) pairs where one side simply wins (THEIRS, DELETE), each + with the reason written into the commit. DELETE is also how a sweep over screens + lands on a tree that no longer has some of them: the modify/delete conflict on each + removed screen resolves as "still deleted". + * a commit of which Mantra wants only some files (PARTIAL): the diff restricted to those + paths is applied and committed under the original message, and the note names what + was left out. For a commit half in a removed line and half in a kept one. + +Every resolution is appended to the landed commit's message as a paragraph, ahead of the +trailers, so the branch explains itself. +""" +import os, re, subprocess, sys + +STRINGS = 'composeApp/src/commonMain/composeResources/values/strings.xml' +NAVHOST = 'composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt' +UI = 'composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/' +GITLINK = 'lightning-kmp-app' + +UNION = {STRINGS, NAVHOST, 'docs/README.md'} +PIN = { + '366b0177': '59c11ed926ac05c820b98afb4cc961a5a3506310', # the nsec line was written against 59c11ed's NostrKeyManager + '5efeae76': '84cc44c855e3d173e26016b807c449153a3af597', # upstream: 01962f3, a rebased-away branch commit with this tree + '00c36ec5': '84cc44c855e3d173e26016b807c449153a3af597', +} +TAKE_FROM = { # (orig, path) -> the original merge commit whose rewritten tree supplies the file + ('1e52fc8f', STRINGS): 'c8de3a1f', ('1e52fc8f', NAVHOST): 'c8de3a1f', + ('0634487e', UI + 'CuratedSuggestionListScreen.kt'): 'fedbe724', + ('0634487e', STRINGS): 'fedbe724', ('0634487e', NAVHOST): 'fedbe724', +} +AFTER = { # orig -> files folded in after the pick, from the merge that edited them outside its conflicts + '0634487e': [(UI + 'AcceptCuratedSuggestionScreen.kt', 'fedbe724'), (UI + 'BroadcastGroupSignedEventScreen.kt', 'fedbe724')], +} +THEIRS = { + ('42f3a697', 'composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt'), + ('42f3a697', 'composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt'), + ('e3cc23ae', UI + 'KeyRecoveryScreen.kt'), + ('2f420230', 'composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CreateProfileViewModel.kt'), +} +THEIRS_WHY = { + '42f3a697': "this commit's import block taken whole -- Mantra had already moved the nostrPublicKey() import to the library's (39fb64b6), and this commit removes the call it served", + 'e3cc23ae': 'the class comment this commit rewrites is taken whole; the only base difference was the dropped rebrand capitalising the brand in one word of it', + '2f420230': "this commit's import block taken whole -- Mantra had already moved the nostrPublicKey() import to the library's (39fb64b6), and this commit removes the seed derivation that import served; the file is identical on both sides afterwards (docs/curated-to-mantra-profiles.md, the third decision)", +} +DELETE = {('55664cc7', 'composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt')} | { + # the extended-FAB sweep touches twenty screens; these seven were taken out with lines B, D and H + ('ca6c16be', UI + f) for f in ( + 'AcceptCuratedSuggestionScreen.kt', 'AddGroupPostScreen.kt', 'EditGroupCuratedSchemaScreen.kt', + 'EditGroupNostrProfileScreen.kt', 'EditGroupRelaysScreen.kt', 'GroupCuratedEntriesScreen.kt', + 'ProposeGroupEventScreen.kt', + ) +} +VM = 'composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/' +PARTIAL = { # orig -> (paths to take, why the rest stays out) + 'd2a4298d': ([ + 'composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt', + UI + 'BroadcastGroupSignedEventScreen.kt', + UI + 'widgets/RelayOutcomeLabel.kt', + VM + 'BroadcastGroupSignedEventViewModel.kt', + ], "only its broadcast half: sendToRelays lifted into the view model's companion, the RelayOutcomeLabel widget and the screen reading it, and the GroupSignedEvent comment. " + "The entries screen's Broadcast-all button, its view model and state, its tests, its five strings and its nav-host wiring are line D, which Mantra took out (docs/curated-to-mantra.md)"), +} + + +def main(): + mode, wt, args = sys.argv[1], sys.argv[2], sys.argv[3:] + def git(*a, check=True, **kw): + return subprocess.run(['git', *a], cwd=wt, capture_output=True, text=True, check=check, **kw) + rewritten = {} + for line in git('log', '--format=%H %(trailers:key=Pulled-From,valueonly)', 'origin/mantra..tmp/curated-src').stdout.splitlines(): + parts = line.split() + if len(parts) == 2: rewritten[parts[1].split('@')[-1][:8]] = parts[0] + rw = lambda orig: rewritten[orig[:8]] + + if mode == 'check': + print(git('diff', '--name-only', rw(args[0]), 'HEAD').stdout, end=''); return + + def union_resolve(path): + p = os.path.join(wt, path); lines = open(p, encoding='utf-8').read().split('\n') + state = None; outside = set() + for l in lines: + if l.startswith('<<<<<<< '): state = 'in'; continue + if l.startswith('>>>>>>> '): state = None; continue + if state is None: outside.add(l) + out = []; state = None; ours = base = theirs = None + for l in lines: + if l.startswith('<<<<<<< '): state = 'ours'; ours, base, theirs = [], [], []; continue + if l.startswith('||||||| ') and state == 'ours': state = 'base'; continue + if l == '=======' and state in ('ours', 'base'): state = 'theirs'; continue + if l.startswith('>>>>>>> ') and state == 'theirs': + removed = {b for b in base if b not in theirs and b.strip()} + added = [t for t in theirs if t not in base] + kept = [o for o in ours if o not in removed] + have = outside | set(kept) + out.extend(kept); out.extend(a for a in added if a not in have or a.strip() == ''); state = None; continue + if state == 'ours': ours.append(l) + elif state == 'base': base.append(l) + elif state == 'theirs': theirs.append(l) + else: out.append(l) + open(p, 'w', encoding='utf-8').write('\n'.join(out)) + + def amend_note(note): + msg = git('log', '-1', '--format=%B').stdout.rstrip('\n').split('\n'); i = len(msg) + while i > 0 and re.match(r'^[A-Za-z-]+: ', msg[i - 1]): i -= 1 + body, trailers = msg[:i], msg[i:] + while body and body[-1] == '': body.pop() + subprocess.run(['git', 'commit', '-q', '--amend', '-F', '-'], cwd=wt, input='\n'.join(body + ['', note, ''] + trailers) + '\n', text=True, check=True) + + for orig in args: + o8 = orig[:8]; sha = rw(o8); subject = git('log', '-1', '--format=%s', sha).stdout.strip(); notes = [] + if o8 in PARTIAL: + paths, why = PARTIAL[o8] + patch = git('diff', '--binary', f'{sha}^', sha, '--', *paths).stdout + r = subprocess.run(['git', 'apply', '--index', '--3way'], cwd=wt, input=patch, capture_output=True, text=True) + if r.returncode != 0: sys.exit(f'STUCK at {o8} {subject}: the partial pick did not apply: {r.stderr.strip()[:300]}') + git('commit', '-q', '-C', sha) + amend_note(f'Replayed onto Mantra by docs/curated-to-mantra.md, in part: {why}.') + print(f"{o8} partial {subject[:80]}"); continue + r = git('-c', 'merge.conflictStyle=diff3', 'cherry-pick', '--allow-empty', sha, check=False) + if r.returncode != 0: + stuck = [] + for f in git('diff', '--name-only', '--diff-filter=U').stdout.split(): + if f == GITLINK and o8 in PIN: + git('update-index', '--cacheinfo', f'160000,{PIN[o8]},{GITLINK}'); notes.append(f'gitlink -> {PIN[o8][:8]}, the pin this commit compiles against') + elif (o8, f) in TAKE_FROM: + src = TAKE_FROM[(o8, f)]; open(os.path.join(wt, f), 'w').write(git('show', f'{rw(src)}:{f}').stdout); git('add', f) + notes.append(f'{os.path.basename(f)}: taken as the original merge {src} left it, this being the branch join') + elif (o8, f) in THEIRS: + git('checkout', '--theirs', '--', f); git('add', f); notes.append(f'{os.path.basename(f)}: {THEIRS_WHY[o8]}') + elif (o8, f) in DELETE: + git('rm', '-q', '-f', f); notes.append(f'{os.path.basename(f)}: deleted, as this commit deletes it upstream') + elif f in UNION: + union_resolve(f); git('add', f); notes.append(f'{os.path.basename(f)}: line-set three-way merge, both sides\' additions kept and this commit\'s deletions applied') + else: + stuck.append(f) + if stuck: + git('cherry-pick', '--abort', check=False); sys.exit(f'STUCK at {o8} {subject}: no rule for {stuck}') + r2 = subprocess.run(['git', '-c', 'core.editor=true', 'cherry-pick', '--continue'], cwd=wt, capture_output=True, text=True) + if r2.returncode != 0: sys.exit(f'STUCK continuing {o8}: {r2.stderr.strip()[:300]}') + for path, src in AFTER.get(o8, []): + open(os.path.join(wt, path), 'w').write(git('show', f'{rw(src)}:{path}').stdout); git('add', path) + notes.append(f'{os.path.basename(path)}: brought to the state the original merge {src} left it in, an edit that merge made outside its conflicts') + if o8 in PIN and git('ls-tree', 'HEAD', GITLINK).stdout.split()[2] != PIN[o8]: + # a populated submodule lets git resolve the gitlink by fast-forward and keep the newer pin; + # the pin has to follow the commit, since each compiles only against the library it named + git('update-index', '--cacheinfo', f"160000,{PIN[o8]},{GITLINK}") + notes.append('gitlink -> 84cc44c: upstream pinned 01962f3, a branch commit since rebased onto the library\'s master as 84cc44c with an identical tree' if o8 == '5efeae76' + else f'gitlink -> {PIN[o8][:8]}, the pin this commit compiles against (git had fast-forwarded it to the newer one)') + if notes: + git('commit', '-q', '--amend', '--no-edit', check=False) + amend_note('Replayed onto Mantra by docs/curated-to-mantra.md: ' + '; '.join(notes) + '.') + print(f"{o8} {'resolved' if notes else 'clean':8s} {subject[:80]}") + + +if __name__ == '__main__': + if len(sys.argv) < 4 or sys.argv[1] not in ('pick', 'check'): sys.exit(__doc__) + main() diff --git a/docs/scripts/curated-unbrand.py b/docs/scripts/curated-unbrand.py new file mode 100755 index 00000000..01853f66 --- /dev/null +++ b/docs/scripts/curated-unbrand.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Rewrite a tree, or a patch, from Curated's names into Mantra's. + +Curated (curated/curated-kmp, brand "Curare" since e6aee792, "Curated" before it) forked from +Mantra at ba26c0b1 and renamed the app twice: `press.mantra.*` -> `com.it.curated.*` -> +`to.curare.*`, with the app-level identifiers (`MantraDatabase`, `MantraNavHost`, ...) and the +generated-resources package following. Every feature commit on the fork is therefore written in +one of those two vocabularies. This script maps both back to Mantra's, so a Curated commit can be +replayed onto Mantra as an ordinary cherry-pick. docs/curated-to-mantra.md is the plan it serves. + +What it must never touch, and by construction cannot: every rule below matches a *brand* token +(`Curare`, `curare`, `com.it.curated`, a `Curated*` app identifier) and none matches a Mantra one. +The twelve `Mantra*` Room entities, the two `mantra/` hash-tag prefixes, `wss://ephemeral.mantra.press` +and the `curated` product vocabulary (`CuratedSchemaEvent`, `nostr/curated/`, the "Curated" queue +mark) pass through unchanged. The bare word "Curated" is deliberately NOT a rule: in generation 1 +it was the brand, at the tip it is the protocol's name, and the two generation-1 feature commits +never use it as a brand in code -- so mapping only the gen-1 *identifiers* is exact. + +Modes + tree rewrite a checked-out tree in place: contents, then paths (for filter-branch) + patch ... rewrite git format-patch files in place: headers, paths and body alike + +Check your work the way the plan does: the rewritten tip must compile and pass jvmTest, and the +diff between a replay and the rewritten tip must be exactly the commits you chose to drop. +""" +import os, re, shutil, sys + +# ---- content rules, applied in order. \b keeps `CurareApp` off `CurareApplication`. ---- +_RULES = [ + # generation 2: to.curare / Curare* + (r'to\.curare\.', 'press.mantra.'), + (r'to/curare/', 'press/mantra/'), + (r'curare\.composeapp\.generated\.resources', 'mantra.composeapp.generated.resources'), + (r'\bCurareDatabaseConstructor\b', 'MantraDatabaseConstructor'), + (r'\bCurareDatabaseJvmTest\b', 'MantraDatabaseJvmTest'), + (r'\bCurareDatabase\b', 'MantraDatabase'), + (r'\bCurareApplication\b', 'MantraApplication'), + (r'\bCurareApp\b', 'MantraApp'), + (r'\bCurareGlobal\b', 'MantraGlobal'), + (r'\bcurareGlobal\b', 'mantraGlobal'), + (r'\bCurareNavHost\b', 'MantraNavHost'), + (r'\bCurareNavigationSuite\b', 'MantraNavigationSuite'), + (r'\bCurareShapes\b', 'MantraShapes'), + (r'\bCurareConverters\b', 'MantraConverters'), + (r'\bdefaultCurareDir\b', 'defaultMantraDir'), + (r'\bCurareTheme\b', 'MantraTheme'), + (r'Res\.string\.curare\b', 'Res.string.mantra'), + (r'Curare', 'Mantra'), + (r'const val APP_NAME = "Curare"', 'const val APP_NAME = "Mantra"'), + (r'const val CLIENT_NAME = "Curare"', 'const val CLIENT_NAME = "Mantra"'), + (r'APP_DIR_NAME = "curated"', 'APP_DIR_NAME = "mantra"'), + (r'rootProject\.name = "Curare"', 'rootProject.name = "Mantra"'), + (r'namespace = "to\.curare\.android"', 'namespace = "press.mantra.android"'), + (r'applicationId = "to\.curare\.android"', 'applicationId = "press.mantra.android"'), + # generation 1: com.it.curated / Curated* app identifiers (the two group-identity commits) + (r'com\.it\.curated\.', 'press.mantra.'), + (r'com/it/curated/', 'press/mantra/'), + (r'curated\.composeapp\.generated\.resources', 'mantra.composeapp.generated.resources'), + (r'\bCuratedDatabaseConstructor\b', 'MantraDatabaseConstructor'), + (r'\bCuratedDatabaseJvmTest\b', 'MantraDatabaseJvmTest'), + (r'\bCuratedDatabase\b', 'MantraDatabase'), + (r'\bCuratedApplication\b', 'MantraApplication'), + (r'\bCuratedApp\b', 'MantraApp'), + (r'\bCuratedGlobal\b', 'MantraGlobal'), + (r'\bcuratedGlobal\b', 'mantraGlobal'), + (r'\bCuratedNavHost\b', 'MantraNavHost'), + (r'\bCuratedNavigationSuite\b', 'MantraNavigationSuite'), + (r'\bCuratedShapes\b', 'MantraShapes'), + (r'\bCuratedConverters\b', 'MantraConverters'), + (r'\bdefaultCuratedDir\b', 'defaultMantraDir'), + (r'\bCuratedTheme\b', 'MantraTheme'), + (r'const val APP_NAME = "Curated"', 'const val APP_NAME = "Mantra"'), + (r'const val CLIENT_NAME = "Curated"', 'const val CLIENT_NAME = "Mantra"'), + (r'rootProject\.name = "Curated"', 'rootProject.name = "Mantra"'), + (r'namespace = "com\.it\.curated\.android"', 'namespace = "press.mantra.android"'), + (r'applicationId = "com\.it\.curated"', 'applicationId = "press.mantra.android"'), + # ios: Mantra is `Mantra` / press.mantra.ios since the plan's Phase 0 + (r'PRODUCT_NAME=Curare\b', 'PRODUCT_NAME=Mantra'), (r'PRODUCT_NAME=Curated\b', 'PRODUCT_NAME=Mantra'), + (r'PRODUCT_BUNDLE_IDENTIFIER=to\.curare\.ios', 'PRODUCT_BUNDLE_IDENTIFIER=press.mantra.ios'), + (r'PRODUCT_BUNDLE_IDENTIFIER=com\.it\.curated\b', 'PRODUCT_BUNDLE_IDENTIFIER=press.mantra.ios'), + (r'\bCurare\.app\b', 'Mantra.app'), (r'\bCurated\.app\b', 'Mantra.app'), + # string keys carrying the brand: `_` is a word character, so \b never fires inside them + (r'sign_in_is_not_yet_available_while_curare_is', 'sign_in_is_not_yet_available_while_mantra_is'), + (r'sign_in_is_not_yet_available_while_curated_is', 'sign_in_is_not_yet_available_while_mantra_is'), + (r'(? `NotYetOnMantra` + (r'(?<=[a-z0-9])Curare(?=[A-Z]|\b)', 'Mantra'), + # the brand word itself, in comments, string values and docs + (r'\bCurare\b', 'Mantra'), + (r'\bcurare\b', 'mantra'), +] +RULES = [(re.compile(p), r) for p, r in _RULES] + +TEXT_EXT = {'.kt', '.kts', '.xml', '.md', '.py', '.sh', '.toml', '.properties', '.json', '.yml', '.yaml', + '.txt', '.xcconfig', '.pbxproj', '.plist', '.swift', '.gitignore', '.gitmodules'} + + +def normalise_text(s: str) -> str: + for rx, rep in RULES: + s = rx.sub(rep, s) + return s + + +def normalise_path(rel: str) -> str: + """Directories first, then the identifier rules over the whole path (file names carry them too).""" + rel = (rel.replace('/to/curare/', '/press/mantra/').replace('/com/it/curated/', '/press/mantra/') + .replace('to.curare.compose.database.CurareDatabase', 'press.mantra.compose.database.MantraDatabase') + .replace('com.it.curated.compose.database.CuratedDatabase', 'press.mantra.compose.database.MantraDatabase')) + return normalise_text(rel) + + +def _is_text(path: str) -> bool: + return os.path.splitext(path)[1] in TEXT_EXT or os.path.basename(path) in ('.gitmodules', 'gradlew') + + +def rewrite_tree(root: str) -> None: + rewritten = 0 + for dirpath, _, filenames in os.walk(root): + if '/.git' in dirpath or dirpath.endswith('/.git'): + continue + for fn in filenames: + p = os.path.join(dirpath, fn) + if not _is_text(p) or os.path.islink(p): + continue + try: + s = open(p, encoding='utf-8').read() + except UnicodeDecodeError: + continue + t = normalise_text(s) + if t != s: + open(p, 'w', encoding='utf-8').write(t) + rewritten += 1 + moves = [] + for dirpath, _, filenames in os.walk(root): + if '/.git' in dirpath or dirpath.endswith('/.git'): + continue + for fn in filenames: + rel = os.path.relpath(os.path.join(dirpath, fn), root) + new = normalise_path(rel) + if new != rel: + moves.append((rel, new)) + for rel, new in moves: + dst = os.path.join(root, new) + os.makedirs(os.path.dirname(dst), exist_ok=True) + shutil.move(os.path.join(root, rel), dst) + for dirpath, _, _ in os.walk(root, topdown=False): + if dirpath != root and '/.git' not in dirpath and not os.listdir(dirpath): + os.rmdir(dirpath) + print(f'rewritten {rewritten} files, moved {len(moves)} paths', file=sys.stderr) + + +def rewrite_patches(paths) -> None: + for p in paths: + s = open(p, encoding='utf-8').read() + open(p, 'w', encoding='utf-8').write(normalise_text(s)) + + +if __name__ == '__main__': + if len(sys.argv) >= 3 and sys.argv[1] == 'tree': + rewrite_tree(os.path.abspath(sys.argv[2])) + elif len(sys.argv) >= 3 and sys.argv[1] == 'patch': + rewrite_patches(sys.argv[2:]) + else: + sys.exit(__doc__) diff --git a/docs/shared-key-derivation.md b/docs/shared-key-derivation.md index 2332f2f0..69a758e0 100644 --- a/docs/shared-key-derivation.md +++ b/docs/shared-key-derivation.md @@ -65,6 +65,16 @@ Each scalar commits to the key being tweaked as well as the index, so steps cann be reordered or replayed at a different depth to reach the same key. `listOf(0L)` and `listOf(0L, 0L, 0L)` do not collide — there is a test for it. +The `mantra/` prefix is deliberate and is not a brand string: the string is an input to +the hash, so renaming it derives different keys from the same threshold key — orphaning +every room already created, and splitting devices on the new string off from devices on +the old one, since their partial signatures would no longer aggregate to one that +verifies. The Curated fork of this app keeps it byte for byte for that reason, and so +must this one. A rename is a protocol fork and would need a `v2` tag, not an edit to +this one. The ChillDKG host-key tag in `ChillDkgRitualManager` is protected by the same +argument, and `Relays.ephemeral` stays on its `mantra.press` host for the duller reason +that it is a relay that is running. + ### What avoiding BIP32 also avoids With x-only keys there is no single obvious `serP(K_par)`: BIP32 serialises diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig index 3faaa0ad..951f1f68 100644 --- a/iosApp/Configuration/Config.xcconfig +++ b/iosApp/Configuration/Config.xcconfig @@ -1,7 +1,7 @@ TEAM_ID= -PRODUCT_NAME=Torch -PRODUCT_BUNDLE_IDENTIFIER=ac.aux.compose.Aux$(TEAM_ID) +PRODUCT_NAME=Mantra +PRODUCT_BUNDLE_IDENTIFIER=press.mantra.ios$(TEAM_ID) CURRENT_PROJECT_VERSION=1 MARKETING_VERSION=1.0 \ No newline at end of file diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 683c17c7..3a0c2f5e 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -7,7 +7,7 @@ objects = { /* Begin PBXFileReference section */ - 41CB266BF907B985085BE6EC /* Aux.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Aux.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 41CB266BF907B985085BE6EC /* Mantra.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mantra.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -59,7 +59,7 @@ ECDAFDDED408A506DEFE0AF1 /* Products */ = { isa = PBXGroup; children = ( - 41CB266BF907B985085BE6EC /* Aux.app */, + 41CB266BF907B985085BE6EC /* Mantra.app */, ); name = Products; sourceTree = ""; @@ -87,7 +87,7 @@ packageProductDependencies = ( ); productName = iosApp; - productReference = 41CB266BF907B985085BE6EC /* Aux.app */; + productReference = 41CB266BF907B985085BE6EC /* Mantra.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ diff --git a/lightning-kmp-app b/lightning-kmp-app index 01489b81..84cc44c8 160000 --- a/lightning-kmp-app +++ b/lightning-kmp-app @@ -1 +1 @@ -Subproject commit 01489b81fe8fa2e47ca813fe231b9e5cd3b34ee2 +Subproject commit 84cc44c855e3d173e26016b807c449153a3af597