From 74c352ab3548d6846c264c7ff10341594634e8e7 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 00:59:07 +0200 Subject: [PATCH] fix: show NIP-17 messages that arrive, not just the ones you send A NIP-17 room only ever displayed your own words. Sending worked end to end -- sendChatMessage queues the gift wrap and writes a local ChatMessage so you see what you typed -- but nothing on the inbound side ever wrote a row for a message that arrived. The kind-14 branch decrypted the payload, stored it, built the chat room from its p-tags, updated the subject, queued profile and relay-list syncs, and stopped. The feed is `SELECT * FROM ChatMessage WHERE chatRoomId = ?`, so with no row written there was nothing to show. Every write of a ChatMessage in the tree confirms it: the sender's own copy in DatabaseChatRepository, three MLS outbound sites, ChatMessage.fromGroupEventResult for inbound MLS group events, one commented out in the welcome branch, and the ritual notices. Nothing for an inbound gift wrap. MLS rooms were never affected, which is why this survived -- CONVENIENT groups render both directions. persistInboundChatMessage files the message once the room is known to exist. ## Two arrivals are deliberately not filed A message already filed. Relays redeliver and negentropy re-syncs the same gift wraps, and the same wrap yields the same payload id every time, so a lookup on giftWrapPayloadId makes a redelivery a no-op. It has to be checked rather than relied on: ChatMessage.id is autogenerated, so a second insert is simply a second line in the conversation. Our own words coming back. sealGiftWrapPayload wraps a copy to every participant of the room including the sender, so a message returns to the device that sent it -- and that device already wrote the row on the way out. Left alone, every message you sent would appear twice. The two copies of your own message cannot be matched on the payload id, which is the interesting part: the outbound row is keyed on EventHasher.hashId over the rumor, while GiftWrapSeal.decryptGiftWrapPayload keys the inbound one on the seal's id. Same message, two ids -- and since every recipient gets their own seal, the same message has a different id on every device that receives it. So the sender is matched instead, which costs multi-device: a second install of the same identity will not pick up messages sent from the first. Keying the inbound payload on the rumor it came from would fix both, and would make payload ids agree across devices, but it changes identity for every gift-wrapped kind rather than just this one and belongs in its own change. ## Timestamps come from the rumor NIP-17 fuzzes the seal and the wrap by up to two days to frustrate correlation, so ordering the feed by either would shuffle the conversation into nonsense. The rumor keeps the real time and that is what the row records. ## Scope Kind 14 only, which is the kind this app sends. A kind 15 file message from another client still falls through to the "Unsupported event" log, as before. Not covered by tests: this is Room writes on the inbound path, which does not run under :composeApp:testDebugUnitTest. Verified by compilation and by tracing the branch. Co-Authored-By: Claude Opus 5 --- .../mantra/compose/database/dao/NostrDao.kt | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) 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.