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 938caff7..304a048c 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 @@ -708,6 +708,15 @@ abstract class NostrDao( } } + // The room exists either way by here, so file the + // message itself. Without this the whole branch only + // ever built rooms: the payload was decrypted, stored + // and dropped, and the feed reads ChatMessage. + persistInboundChatMessage( + decryptedGiftWrapPayload = decryptedGiftWrapPayload, + activeKeyPair = activeKeyPair + ) + } else if (decryptedGiftWrapPayload.kind == WelcomeEvent.KIND) { val welcomeEvent = WelcomeEvent( @@ -1238,6 +1247,63 @@ abstract class NostrDao( } + /** + * Files an arriving NIP-17 chat message so it shows up in the room's feed. + * + * The feed reads ChatMessage, and until now nothing wrote one for an inbound + * gift wrap: the payload was decrypted, stored, used to build the room, and then + * dropped. Members could send to each other and see their own words -- written + * locally by `sendChatMessage` -- while everything they were sent stayed + * invisible. MLS rooms were never affected, since that path builds its rows + * through `ChatMessage.fromGroupEventResult`. + * + * Two arrivals are deliberately not filed: + * + * A message already filed. Relays redeliver and negentropy re-syncs, and the + * same gift wrap yields the same payload id each time, so the payload id is what + * makes a second delivery a no-op rather than a second line in the chat -- + * ChatMessage.id is autogenerated and would happily take a duplicate. + * + * Our own words coming back. `sealGiftWrapPayload` wraps a copy to every + * participant including the sender, so a message returns to the device that sent + * it, and `sendChatMessage` already wrote that row on the way out. The two copies + * cannot be matched on the payload id -- the outbound row is keyed on the rumor's + * hash and the inbound one on the seal's -- so the sender is matched instead. + * The cost is that a second install of the same identity will not pick up + * messages sent from the first; that needs the inbound payload keyed on the rumor + * it came from, which is a change to identity for every kind, not just this one. + */ + private suspend fun persistInboundChatMessage( + decryptedGiftWrapPayload: GiftWrapPayload, + activeKeyPair: KeyPair + ) { + if (database.chatMessageDao().getChatMessagesByGiftWrapPayloadId(decryptedGiftWrapPayload.id) != null) { + logger.d("Chat message for payload ${decryptedGiftWrapPayload.id} is already filed") + return + } + + if (decryptedGiftWrapPayload.publicKey == activeKeyPair.pubKey.toHex()) { + logger.d("Payload ${decryptedGiftWrapPayload.id} is our own message coming back; already stored on send") + return + } + + database.chatMessageDao().upsert( + ChatMessage( + senderPublicKey = decryptedGiftWrapPayload.publicKey, + isUserMessage = false, + giftWrapPayloadId = decryptedGiftWrapPayload.id, + marmotGroupEventId = null, + marmotInnerEventId = null, + chatRoomId = decryptedGiftWrapPayload.chatRoomId, + content = decryptedGiftWrapPayload.content, + // The rumor's own timestamp, not the wrap's -- NIP-17 fuzzes the + // wrap and the seal by up to two days to frustrate correlation, and + // ordering the feed by that would shuffle the conversation. + createdAt = decryptedGiftWrapPayload.createdAt + ) + ) + } + /** * The NIP-17 room a decrypted payload belongs to, standing it up if this is the * first the device has heard of the group.