The second live subscription: kind 445 h-tagged with every Marmot group we
belong to, chunked 100 group ids to a filter, open on every DM relay. Group
messages now arrive whether or not that chat is on screen — or any chat.
**Derived from the room list, not from the join sites.** The filter comes from
chatRepository.observeChatRoomListByPublicKey, the same Flow the chat list
screen renders, collected at wallet scope so it keeps running with no chat
screen open. That is the whole reason this stays correct: a group id can appear
four ways, and only one of them is somewhere anyone would think to call a
subscribe function.
- we create a group (MarmotOutboundDao),
- we are added to one — the Welcome arrives inside a gift wrap and is
processed deep inside NostrDao.storeNostrEvent, on the inbound path,
- membership shifts under us via a commit
(MarmotInboundManager.processGroupMembershipChanges),
- we leave, or the room is deleted.
Observing the table catches all four, and it closes the loop with the previous
commit: a Welcome lands on the gift wrap subscription, a ChatRoom row is
written, this flow re-emits, the group filter widens — and the first message in
a group we were just added to arrives without anyone opening a chat.
Rooms are filtered to those with mlsGroupState (a NIP-17 room has none and is
served by the gift wrap subscription instead) and without leftGroupAt or
deletedAt — a room we have left keeps its history locally but must stop pulling
new messages. Ids are sorted before distinctUntilChanged so the same membership
in a different row order is the same value, then debounced 500ms: joining a
group writes the room, its participants and placeholder profiles in quick
succession, each of which re-emits the list.
**Reconciling, not rebuilding.** A chunk that already has a subscription is
updated in place — the relay replaces that subscription's filter on a repeated
REQ with the same id — so adding a group does not interrupt delivery on the
groups already being watched. Only chunks that no longer exist are torn down,
and they are cancelAndJoin'd rather than cancelled: the CLOSE is sent from the
coroutine's finally, and returning before it lands would let a later reconcile
open a subscription on that id which the old one then closes out from under.
The filter is read from a supplier at every open rather than captured at launch,
so a subscription re-opened after a CLOSED comes back with the current
membership rather than the membership it was created with.
**No `since` here either, but for a different reason.** Group events do carry
honest timestamps — MarmotOutboundDao stamps them with TimeUtils.now(), unlike
gift wraps — so a watermark would be safe. It is still not used: limit already
bounds the initial burst, and a watermark would have to be recomputed every time
a chunk's membership changed. The group limit is 500 rather than the gift wrap
subscription's 100, because one chunk covers up to 100 conversations.
Also in here: RelayPool.updateLiveSubscription now retains the new REQ before
attempting to send it, and treats a missing or failing socket as "send it on
reconnect" rather than throwing. Throwing would have left the OLD filter
retained, so a reconnect would restore a subscription the caller had already
moved on from — the one outcome worse than not sending.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>