docs: document S2S DM close/reopen relay events
Add federation.md section 8b covering dm_close/dm_reopen relay events: payload, outbound queueing via queueDmCloseRelay, inbound processDmCloseEvent/ processDmReopenEvent handlers (lookup-only identity resolution, silent no-ops on missing channel/user/membership), and the processCreateEvent closed-state reopen bug fix. Update dm-system.md soft-close section with federation behaviour: relay to peers for both close and reopen, relayed-message reopen in processCreateEvent, and the federatedId-only guard for legacy DMs.
This commit is contained in:
@@ -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.
|
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
|
### Frontend
|
||||||
|
|
||||||
- `spaceStore.closeDm(id)` calls `api.dm.close(id)` then removes the channel from `dmChannels` state
|
- `spaceStore.closeDm(id)` calls `api.dm.close(id)` then removes the channel from `dmChannels` state
|
||||||
|
|||||||
@@ -469,6 +469,8 @@ Body limit: 10 MB. Max 50 events per batch. Rate-limited to 90 requests/min per
|
|||||||
| `file_rejected` | `processFileRejectedEvent` | dm |
|
| `file_rejected` | `processFileRejectedEvent` | dm |
|
||||||
| `dm_typing_start` | `processDmTypingStartEvent` | dm (fire-and-forget, no outbox) |
|
| `dm_typing_start` | `processDmTypingStartEvent` | dm (fire-and-forget, no outbox) |
|
||||||
| `dm_typing_stop` | `processDmTypingStopEvent` | 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`.
|
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
|
## 9. Friend Relay
|
||||||
|
|
||||||
### Event Flow (social.ts)
|
### Event Flow (social.ts)
|
||||||
|
|||||||
Reference in New Issue
Block a user