diff --git a/docs/systems/dm-system.md b/docs/systems/dm-system.md index 538c7684..543bac3a 100644 --- a/docs/systems/dm-system.md +++ b/docs/systems/dm-system.md @@ -159,6 +159,15 @@ When a new message arrives in a DM channel, for each member with `closed = 1`: This ensures closed DMs resurface automatically when new activity occurs. +### Federation + +Close and reopen are relayed to all peer instances that hold a copy of the DM: + +- **Close relay:** After setting `closed = 1` locally, `queueDmCloseRelay(channelId, userId, 'dm_close')` queues a `dm_close` outbox event. The receiving instance finds the channel by `federatedId`, resolves the acting user via `resolveLocalUser`, sets `closed = 1` on the local `dm_members` row, and broadcasts `dm_channel_closed`. +- **Reopen relay:** Explicit reopens (`POST /api/dm/:id/reopen`) queue a `dm_reopen` event. The receiving instance sets `closed = 0` and broadcasts `dm_channel_created` with a full channel payload. +- **Relayed-message reopen:** `processCreateEvent` (inbound message relay) also checks each recipient's `closed` flag and performs the same resurface sequence (`dm_channel_created` → `dm_message_created`) — mirroring `broadcastDmMessage`. This ensures messages relayed from a remote instance properly reopen closed DMs on the receiving instance. +- Only fires for DMs with a `federatedId`. Legacy local-only DMs (no `federatedId`) are unaffected. + ### Frontend - `spaceStore.closeDm(id)` calls `api.dm.close(id)` then removes the channel from `dmChannels` state diff --git a/docs/systems/federation.md b/docs/systems/federation.md index 7769ad07..e5b46885 100644 --- a/docs/systems/federation.md +++ b/docs/systems/federation.md @@ -469,6 +469,8 @@ Body limit: 10 MB. Max 50 events per batch. Rate-limited to 90 requests/min per | `file_rejected` | `processFileRejectedEvent` | dm | | `dm_typing_start` | `processDmTypingStartEvent` | dm (fire-and-forget, no outbox) | | `dm_typing_stop` | `processDmTypingStopEvent` | dm (fire-and-forget, no outbox) | +| `dm_close` | `processDmCloseEvent` | dm | +| `dm_reopen` | `processDmReopenEvent` | dm | After processing all events, the relay endpoint updates the peer's `lastSeenAt` and resets `consecutiveFailures`, then returns accepted/rejected arrays plus `maxUploadSize`. @@ -673,6 +675,78 @@ When a user marks a DM channel as read (`channel_ack`) or marks it unread (`mark --- +## 8b. DM Close/Reopen Relay + +### Overview + +When a user closes or reopens a DM on their home instance, the action is relayed to all peer instances that hold a copy of the channel. This keeps the visibility state of a DM consistent across all instances that participate in it. + +Only DMs with a `federatedId` are eligible. Legacy local-only DMs (created before federation was added, with no `federatedId`) are silently skipped. + +### Event Types + +| Event | Trigger | +|-------|---------| +| `dm_close` | User calls `DELETE /api/dm/:id` (soft-close) | +| `dm_reopen` | User calls `POST /api/dm/:id/reopen` (explicit reopen) | + +### Payload + +```typescript +{ + eventType: 'dm_close' | 'dm_reopen', + dmChannelId: string, // local channel ID (context only) + federatedId: string, // cross-instance channel lookup key + messageId: string, // unique event ID: 'dm_close:{userId}:{ts}' or 'dm_reopen:{userId}:{ts}' + encryptionVersion: 0, + timestamp: number, + dmCloseReopen: { + homeUserId: string, // acting user's home user ID + homeInstance: string, // acting user's home instance (full URL) + } +} +``` + +### Outbound (`federationOutbox.ts:queueDmCloseRelay`) + +Called from `dm.ts` after the local close or reopen is committed. + +1. Fetch the channel's `federatedId` — if null (local-only DM), return silently +2. Fetch the acting user's `(homeUserId, homeInstance)` federation identity +3. Build `FederationRelayEvent` with `eventType` and `dmCloseReopen` payload +4. `getGroupDmTargetOrigins(dmChannelId)` resolves the delivery targets: + - 1-on-1 DMs (`ownerId = NULL`): returns `undefined` → broadcast to ALL active peers + - Group DMs: returns the set of peer origins that have at least one participant +5. Enqueue via `appendMutationLog` + `queueOutboxEvent` + +### Inbound + +**`processDmCloseEvent` (`federation.ts`):** +1. Look up channel by `federatedId` — if not found, accept silently (idempotent) +2. Resolve acting user via `resolveLocalUser` (lookup-only; no stub creation for close/reopen) — if not found, accept silently +3. If the user has no `dm_members` row in this channel, accept silently +4. Set `dm_members.closed = 1` for the resolved local user +5. Broadcast `dm_channel_closed` to the user's local WebSocket connections + +**`processDmReopenEvent` (`federation.ts`):** +1. Look up channel by `federatedId` — if not found, accept silently +2. Resolve acting user via `resolveLocalUser` — if not found, accept silently +3. If the user has no `dm_members` row, accept silently +4. Set `dm_members.closed = 0` +5. Build full `DmChannel` payload and broadcast `dm_channel_created` to the user's local WebSocket connections (mirrors the automatic reopen path in `broadcastDmMessage`) + +### Closed-State Reopen on Message Relay (Bug Fix) + +`processCreateEvent` (inbound message relay) now mirrors the local `broadcastDmMessage` logic: before broadcasting `dm_message_created`, it checks each recipient's `dm_members.closed` flag. For any recipient with `closed = 1`: + +1. Set `closed = 0` +2. Broadcast `dm_channel_created` (with the new message as `lastMessage`) to resurface the DM in the recipient's sidebar +3. Then broadcast `dm_message_created` + +This fixes a gap where relayed messages bypassed the closed-state reopen logic, leaving the DM hidden for recipients whose `closed` flag was set on the receiving instance. + +--- + ## 9. Friend Relay ### Event Flow (social.ts)