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 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-05 00:59:07 +02:00
parent 3f4f05162d
commit 74c352ab35

View File

@@ -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.