From 4476c55963a485a3a282a593d687ce669ce301e5 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:11:56 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20invite=20friends=20overhaul=20=E2=80=94?= =?UTF-8?q?=20space=5Finvite=20system=20message,=20relay=20type=20field,?= =?UTF-8?q?=20in-instance=20/join=20interception?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/systems/desktop.md | 17 +++++++++++++++++ docs/systems/dm-system.md | 26 ++++++++++++++++++++++++++ docs/systems/federation.md | 8 ++++++++ docs/systems/spaces.md | 8 ++++++++ 4 files changed, 59 insertions(+) diff --git a/docs/systems/desktop.md b/docs/systems/desktop.md index 6297d3c6..1e1c95b2 100644 --- a/docs/systems/desktop.md +++ b/docs/systems/desktop.md @@ -84,6 +84,19 @@ Window `focus`/`blur` events send `window-focus-changed` (boolean) to the render `setWindowOpenHandler` intercepts all `window.open()` calls. HTTP/HTTPS URLs are opened in the default browser via `shell.openExternal()`. All popup windows are denied (`action: 'deny'`). +### In-Instance `/join/` Interception + +Click-handlers on `https://...` URLs use `setWindowOpenHandler` (`packages/desktop/src/main.ts:391`). The handler intercepts URLs whose origin matches a connected instance and whose pathname starts with `/join/`, routing them in-app via the `open-internal-route` IPC channel instead of `shell.openExternal`. + +State plumbing: +- Main process maintains `knownInstanceOrigins: Set` populated via `set-connected-origins` IPC pushes from the renderer. Synchronously readable from inside `setWindowOpenHandler` (which must return its result synchronously). +- Renderer's `instanceStore` subscribes its own `instances` selector and forwards the connected-origin list (home + connected remotes) to main on every change, including initial mount. +- `packages/web/src/platform/deepLink.ts` (`useDeepLinkHandler`) subscribes to `onOpenInternalRoute` and calls `navigate(path)`. + +Predicate scope: only intercept URLs whose host matches a **currently connected** instance. Invites for unknown instances or disconnected federated peers still open externally — that's the entry point for `JoinPage`'s federation-redirect flow and must not be hijacked. + +Dev-mode caveat: if a developer runs Electron pointed at the Vite dev server (`http://localhost:5173`) while the API runs on `http://localhost:3005`, `window.location.origin` won't match the API origin and interception will not trigger. Production and standard-Electron-pointed-at-server dev are unaffected. + --- ## Instance Picker @@ -330,6 +343,7 @@ All handlers registered in `main.ts:registerIpcHandlers()`. | `check-for-updates` | R->M | — | `autoUpdater.checkForUpdates()` | | `screen-share-selected` | R->M | `sourceId, shareAudio?` | Safety net (actual handler is `ipcMain.once` in display media flow) | | `keybinds-sync` | R->M | `KeybindConfig[]` | `keybindManager.updateKeybinds()` | +| `set-connected-origins` | R->M | `string[]` | Update `knownInstanceOrigins` set (used by in-instance `/join/` interception) | ### Request/Response (`ipcMain.handle`) @@ -358,6 +372,7 @@ All handlers registered in `main.ts:registerIpcHandlers()`. | `keybind-action` | `{ actionId, pressed }` | KeybindManager match | | `accessibility-status` | `{ trusted }` | macOS accessibility check result | | `keybind-hook-error` | `{ message }` | uIOhook start failure | +| `open-internal-route` | `string` (path) | In-instance `/join/` interception: renderer navigates to `path` instead of opening externally | --- @@ -399,6 +414,8 @@ Detection: `typeof window.backspace !== 'undefined'` (see `platform.ts:isElectro | `onAccessibilityStatus(cb)` | listen | M->R | Returns cleanup function | | `onKeybindHookError(cb)` | listen | M->R | Returns cleanup function | | `checkAccessibility()` | invoke | R->M | Returns `Promise` | +| `setConnectedOrigins(origins)` | fire | R->M | Push connected-instance origin list to main (in-instance `/join/` interception) | +| `onOpenInternalRoute(cb)` | listen | M->R | Returns cleanup function; `cb` receives a path string to navigate in-app | Direction legend: **fire** = `ipcRenderer.send` (no response), **invoke** = `ipcRenderer.invoke` (returns Promise), **listen** = `ipcRenderer.on` (event subscription). diff --git a/docs/systems/dm-system.md b/docs/systems/dm-system.md index ac9fda2c..48b04fd0 100644 --- a/docs/systems/dm-system.md +++ b/docs/systems/dm-system.md @@ -505,6 +505,31 @@ System messages (`type = 'system'` in `dm_messages`) record group lifecycle even | `member_removed` | `{ event, targetUserId, targetDisplayName, reason }` | User who left/was removed | | `owner_changed` | `{ event, newOwnerId, newOwnerDisplayName }` | Previous owner | +### `space_invite` (user-initiated, federated via processCreateEvent) + +Sent by `POST /api/dm/space-invite` (see `docs/systems/spaces.md`). Unlike membership-event system messages, this one is user-initiated content — the inviter authored it deliberately. It travels through the standard DM message create relay (`processCreateEvent`), not a dedicated event kind. + +JSON content shape: + +```json +{ + "event": "space_invite", + "spaceId": "", + "spaceInstanceOrigin": "https://z.example", + "inviteCode": "<8-hex>", + "snapshot": { + "spaceName": "...", + "icon": null, + "avatarColor": null, + "memberCount": 12, + "description": "...", + "instanceName": "..." + } +} +``` + +The `spaceInstanceOrigin` is the space's home instance, **not** the sender's. The recipient's client uses it to fetch the live preview (`getApiForOrigin(spaceInstanceOrigin).spaces.invitePreview`) and to call `joinByCode(code, spaceInstanceOrigin)` on click. + ### Instance-Local Creation System messages are NOT relayed via federation. Each instance creates its own independently: @@ -643,6 +668,7 @@ const normalized = homeInstance.startsWith('http') | `GET` | `/api/dm` | JWT | List caller's DM channels (excludes `closed=1` and `deleted_at` IS NOT NULL) | | `POST` | `/api/dm` | JWT | Create or get existing 1-on-1 DM. Accepts `{ userId }` (local) or `{ homeUserId, homeInstance }` (federated) | | `POST` | `/api/dm/group` | JWT | Create group DM with multiple members | +| `POST` | `/api/dm/space-invite` | JWT | Send a space invite card to a friend via DM (see `docs/systems/spaces.md`) | | `DELETE` | `/api/dm/:id` | JWT | Soft-close DM for caller | | `POST` | `/api/dm/:id/members` | JWT | Add member to group DM (any member). Accepts `{ userId }` or `{ homeUserId, homeInstance }` | | `DELETE` | `/api/dm/:id/members` | JWT | Leave group DM | diff --git a/docs/systems/federation.md b/docs/systems/federation.md index a416f637..c6577cb8 100644 --- a/docs/systems/federation.md +++ b/docs/systems/federation.md @@ -530,6 +530,14 @@ Every relayed message is stored with: The `(source_instance, source_message_id)` pair is checked before insertion. Duplicates are rejected with reason `'duplicate'`. A unique partial index enforces this at the DB level: `idx_dm_messages_source_unique ON dm_messages(source_instance, source_message_id) WHERE source_instance IS NOT NULL`. +### Optional `message.type` field (added 2026-04-29) + +`FederationRelayEvent.message.type?: 'user' | 'system'` is optional. When present and set to `'system'`, `processCreateEvent` writes the inserted `dm_messages.type` column accordingly; when absent, the receiving instance defaults to `'user'`. + +This is a **forward- and backward-compatible** addition because the inbound relay endpoint (`/api/federation/relay`) validates only structural fields (`version`, `events` array shape, `sourceInstance`); unknown fields are passed through. Old peers that don't emit `type` produce relay events that get inserted as user messages on receiving peers (the existing default), and old peers receiving relay events from new peers ignore the field entirely. No protocol-version bump is required. + +This permissiveness is **intentional** — the relay envelope is designed for additive evolution. Future optional fields should follow this same pattern (no schema bump, document the field here, defaults preserve old-peer behavior). + ### Typing Indicator Relay **Event types:** `dm_typing_start`, `dm_typing_stop` diff --git a/docs/systems/spaces.md b/docs/systems/spaces.md index 1de6cd4e..cc869801 100644 --- a/docs/systems/spaces.md +++ b/docs/systems/spaces.md @@ -131,6 +131,14 @@ Parses three input formats into `{ code: string; origin?: string }`: If the parsed origin matches `window.location.origin`, it is treated as a bare code (origin stripped). +### Direct Friend Invitation (in-app) + +`POST /api/dm/space-invite` (see [dm-system.md](dm-system.md)) sends a structured invite card to a friend via DM. The card carries a snapshot of the space (name, icon, member count, description) plus the canonical identifiers (`spaceId`, `spaceInstanceOrigin`, `inviteCode`). + +The endpoint lives on the **caller's home instance**, not the space's home. The caller's instance fetches the snapshot server-to-server from the space's `GET /api/spaces/invite/:code/preview` endpoint, then inserts a `type='system'` DM message with `event: 'space_invite'` content. Three-way federation (sender on X, recipient on Y, space on Z) is supported without new federation event kinds. + +The friend-picker surface in `InviteModal` uses the same per-space invite code as the link-share footer — there is exactly one invite code per space at any time, and revocation (when implemented) invalidates all outstanding cards atomically. + ### Invite Preview **Endpoint:** `GET /api/spaces/invite/:code/preview` (no auth required)