diff --git a/README.md b/README.md index 1e169bf3..c0d8bf82 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,7 @@ When rotating the server updater key, update [Server/updater/server_update_publi - [docs/protocol.md](docs/protocol.md) - [docs/schema.md](docs/schema.md) - [docs/architecture/client.md](docs/architecture/client.md) — client architecture (replaces client-architecture.md) +- [docs/architecture/ux/](docs/architecture/ux/README.md) — client UX specification (target-state flows, per-view states, event→reaction maps) - [docs/contributing.md](docs/contributing.md) - [docs/security.md](docs/security.md) diff --git a/docs/architecture/README.md b/docs/architecture/README.md index ecd6a51d..22ea30ab 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -16,7 +16,16 @@ natively) followed by a prose explanation and a **Source of truth** file list. | [websocket.md](websocket.md) | D4 WS connect / replay / dispatch | Real-time engine: auth handshake, 3-tier reconnect replay, backpressure, V1/V2 dispatch | | [data-model.md](data-model.md) | D5 Entity-relationship overview | All 23 tables from migrations 001–015, grouped by domain | | [voice-e2ee.md](voice-e2ee.md) | D6 Voice + E2EE flow | LiveKit token flow, loopback TLS tunnel, ECDH key-holder relay | -| [client.md](client.md) | D7 Client module map | Tauri client: bootstrap, dispatcher, stores, Rust sidecars | +| [client.md](client.md) | D7 Client module map | Tauri client: bootstrap, dispatcher, stores, Rust sidecars (structure, as-built) | +| [ux/](ux/README.md) | UX flow + state diagrams | Client **behavior** spec (target state): what every view does and how it reacts to events, permissions, and failure | + +### Structure vs. behavior + +[client.md](client.md) maps the client *as-built* (modules, stores, wiring). The +[ux/](ux/README.md) set is the complementary *behavior* spec — prescriptive +(to-be) flows for every view, with per-view state matrices and event→reaction +maps. Where today's code diverges from the target, the UX docs carry dated +**⚠ Current gap** callouts, so the set doubles as a UX improvement backlog. ## Maintenance rule diff --git a/docs/architecture/ux/README.md b/docs/architecture/ux/README.md new file mode 100644 index 00000000..b8b08ca7 --- /dev/null +++ b/docs/architecture/ux/README.md @@ -0,0 +1,175 @@ +# OwnCord Client UX Specification (target state) + +**Verified against:** commit `da4acc5`, 2026-07-19 +**Companion:** [../client.md](../client.md) (structural module map) · [../../audit-2026-07-19.md](../../audit-2026-07-19.md) + +This directory specifies **how the Tauri client should behave** — what every UI +step does, and how each view reacts to server events, permission state, and +failure. Unlike [client.md](../client.md), which maps the code as-built, these +documents are **prescriptive (to-be)**: they describe the intended target UX. +Where today's code diverges, each flow carries a **⚠ Current gap** callout — so +this set doubles as a UX improvement backlog. Gaps are grounded in real +`file:line` references from the client. + +> **Scope.** This is a behavior spec, not a visual design spec. It defines +> states, transitions, events, and reactions — not pixel layout, spacing, or +> color. Those live in `src/styles/tokens.css` and the component CSS. + +## Documents + +| Doc | Covers | +|-----|--------| +| [connection-and-auth.md](connection-and-auth.md) | App boot, server profiles, connect/health, login, TOTP, register-by-invite, the connected handshake, reconnect, and cert-TOFU trust prompts | +| [messaging.md](messaging.md) | Composer + send (optimistic), edit/delete, reactions, attachments, replies, pins, search, read/unread, slow-mode, announcement read-only gating | +| [channels-members-dms.md](channels-members-dms.md) | Channel list/switch/categories, member list + presence + typing, roles, DM open/close, blocking | +| [voice-and-e2ee.md](voice-and-e2ee.md) | Voice join/leave, mute/deafen/camera/screenshare, push-to-talk, active-speaker, and the E2EE securing/key-ready indicators | +| [settings-and-admin.md](settings-and-admin.md) | Settings tabs, profile/password/2FA/delete-account, appearance/theming, the inline admin surface (ban/kick/roles, channel CRUD, invites), and the updater | + +The cross-cutting vocabulary and global reaction matrices below apply to **every** +document; the per-flow docs reference them rather than repeating them. + +--- + +## 1. View-state vocabulary + +Every data-bearing view must be able to represent each of these states and must +choose a defined presentation for each (a view may legitimately collapse some — +e.g. a view that can never be empty — but that must be a decision, not an +omission): + +| State | Meaning | Default presentation | +|-------|---------|----------------------| +| `loading` | A fetch/subscription is in flight and no cached data is shown yet | Skeleton or inline spinner in the view's own region — **never** a full-screen blocker except the initial connected handshake | +| `ready` | Data present and current | The normal view | +| `empty` | Fetch succeeded, zero items | A labelled empty state with a one-line "what goes here / what to do next" hint | +| `error` | Fetch/action failed | Inline error with a **Retry** affordance for recoverable errors; a toast only for fire-and-forget actions | +| `stale` | Data shown but known out of date (e.g. during reconnect) | The normal view plus a non-blocking status hint (connection banner); interactions that require a live socket are disabled with a reason | +| `permission-denied` | The user may see the view but not act | The view renders read-only; the disallowed control is **disabled with a visible reason**, never hidden silently and never enabled-then-rejected | +| `offline` | No live socket | Live-only controls disabled with the connection status surfaced | + +**Principle — no silent states.** Every terminal outcome (success, empty, +failure, denial) produces *some* observable feedback. A control that will be +rejected by the server must be pre-disabled with a reason; an action that +succeeds without a visible result must emit a confirmation. + +--- + +## 2. Feedback primitives + +The client has a fixed set of feedback surfaces. Each has one job; pick by the +decision table, don't improvise. + +| Primitive | Source | Use for | Do **not** use for | +|-----------|--------|---------|--------------------| +| **Toast** (`info`/`success`/`error`, 5 s auto-dismiss, max 5) | `lib/toast.ts` → `components/Toast.ts` | Transient results of an explicit user action (sent, copied, saved, "couldn't reach server") | Anything the user must act on; anything that must survive navigation | +| **Inline field error** | per-form | Validation and per-field server rejections (bad password, weak input) | Global/connection state | +| **Inline section error + Retry** | per-view | A failed load of a view's own data (messages, invites, pins) | One-shot actions (use a toast) | +| **Persistent banner** | `components/ServerBanner.ts` (reconnect/restart), ad-hoc cert banner | Connection status: reconnecting, server-restart countdown, first-trust cert notice | Per-action results | +| **Blocking modal** | `lib/modalFactory.ts` (+ `CertMismatchModal`) | Decisions that must be made before proceeding: cert mismatch, destructive confirm | Routine feedback; anything dismissable-by-ignoring | +| **Two-click / inline confirm** | `AdminActions.ts` `withConfirmation`, `PendingDeleteManager` | Reversible-ish destructive actions in dense menus (kick, ban, delete channel, delete message) | Irreversible account-level actions (use a modal with typed confirm) | +| **Disabled control + reason** | per-control | Actions not currently permitted (offline, no permission, slow-mode cooldown, upload in flight) | Errors that already happened | +| **Transient-error store** (`ui.store.setTransientError`) | survives navigation | A message that must appear on the *connect* page after a forced disconnect (banned, kicked, restart) | In-session messaging (use a toast) | + +--- + +## 3. Connection status is a first-class, observable state + +Every live-only interaction keys off one connection status. Target: a single +source of truth in `ui.store.connectionStatus` +(`connected | reconnecting | disconnected`), written from the WS client's +`onStateChange`, and read by any control that needs a live socket. + +> **⚠ Current gap.** The authoritative connection state lives in a closure inside +> `src/lib/ws.ts` (`state`, `ws.ts:33-38`) and is surfaced only through +> `onStateChange` callbacks wired ad hoc in `MainPage.ts:199-211`; +> `ui.store.connectionStatus` exists (`ui.store.ts:14`) but is not the single +> writer/reader. Consolidating onto the store lets every control reactively +> disable itself when the socket drops, instead of each call site guarding +> `ws.getState() !== "connected"` and reporting failure *after* the click +> (as the composer does today, `ChannelController.ts:200-204`). + +| Status | Composer / send | Voice controls | Presence picker | Reconnect banner | +|--------|-----------------|----------------|-----------------|------------------| +| `connected` | enabled | enabled | enabled | hidden | +| `reconnecting` | disabled, "Reconnecting…" | frozen, retrying underneath | disabled | visible, spinner | +| `disconnected` | disabled | torn down | disabled | visible or → connect page on fatal | + +--- + +## 4. Global event → reaction map + +The dispatcher (`src/lib/dispatcher.ts`) is the single fan-in from the socket to +the stores. Target: **every** inbound message type produces a defined store +mutation *and*, where user-visible, a defined UI reaction. The per-flow docs +detail each; this is the index. + +| Inbound event | Store effect | Target UI reaction | +|---------------|--------------|--------------------| +| `auth_ok` | `auth.setAuth` | Advance handshake → ready overlay | +| `auth_error` | `ui.setTransientError` + `auth.clearAuth` | Return to connect page with the reason shown | +| `ready` | bulk-load channels/roles/members/voice/dm | Render main view; resolve the connected overlay | +| `chat_message` | `messages.addMessage` (+ unread/DM/notify) | Append; reconcile a pending optimistic row if it's our echo | +| `chat_send_ok` | `messages.confirmSend` | Mark the optimistic row **sent** (see gap in [messaging.md](messaging.md)) | +| `chat_edited` / `chat_deleted` | `messages.editMessage` / `deleteMessage` | In-place edit / tombstone | +| `reaction_update` | `messages.updateReaction` | Toggle the pill + count, reflect `me` | +| `typing` | `members.setTyping` (5 s auto-clear) | Typing indicator | +| `presence` / `member_update` / `user_update` | `members.*` | Live member-list update | +| `member_join` / `member_leave` / `member_ban` | `members.add/remove` | Member-list add/remove | +| `channel_create` / `channel_update` / `channel_delete` | `channels.*` | Sidebar update; redirect if the active channel was deleted | +| `voice_state` / `voice_leave` / `voice_config` / `voice_speakers` | `voice.*` | Voice roster + speaking rings | +| `voice_token` / `voice_e2ee_*` | `livekitSession.*` | Drive the voice-join + securing indicators | +| `dm_channel_open` / `dm_channel_close` | `dm.*` | DM list add/remove | +| `server_restart` | `ui.setTransientError` | Restart banner with countdown | +| `error` | `ui.setTransientError` (+ `clearAuth` on `BANNED`) | Map the code → the reaction in §5 | + +> **⚠ Current gap.** Several codes are received and dropped. `error` handles only +> `BANNED`/`RATE_LIMITED`/`FORBIDDEN`; `SLOW_MODE`, `INVALID_INPUT`, conflict, +> etc. are silently ignored (`dispatcher.ts:421-436`). WS `chat_send_ok` carries +> the real `message_id`/`timestamp` but they are discarded +> (`messages.store.ts:252-258`). Both are addressed in [messaging.md](messaging.md). + +--- + +## 5. Error & permission reaction matrix + +One canonical reaction per failure class, applied everywhere. Today error +handling is per-call-site with no shared mapper (`api.ts:81-140` centralizes only +401); this matrix is the target contract. + +| Class | Source | Target reaction | +|-------|--------|-----------------| +| **401 Unauthorized** | any REST call | Global: `clearAuth()` → disconnect → connect page, with "Your session expired — sign in again." (already centralized in `api.ts:116-120` + `main.ts:92-95`; extend to `uploadFile`, which skips it today, `api.ts:380-383`) | +| **403 Forbidden** (action) | REST/WS | Toast "You don't have permission to do that." **and** pre-disable the control so it can't be attempted again in that context | +| **403 Suspended/Banned** | login REST / WS `BANNED` | Transient-error store → connect page: "Your account has been suspended." Force logout, no reconnect | +| **429 Rate-limited** | REST/WS `RATE_LIMITED` | Non-destructive toast "You're doing that too fast — try again in a moment." Keep the user's input; re-enable the control after a short cooldown | +| **Slow-mode** | WS `SLOW_MODE` | Disable send with a live countdown in the composer; do not drop the drafted message | +| **Validation (400)** | REST | Inline field error with the server message (capped to a safe length — the login form caps at 200 chars, `LoginForm.ts:598`; apply everywhere) | +| **Conflict/Not-found (404/409)** | REST/WS | Contextual inline message + refresh the affected view (the target moved/vanished) | +| **5xx / network** | REST | Inline section error + **Retry**; for one-shot actions, a toast "Couldn't reach the server." Never a silent drop | +| **Transport backpressure** | WS `ws_send` "channel full" | Surface it: mark the optimistic row failed with Retry. Today it's dropped silently (`ws.ts:432-437`) | +| **Cert first-use** | Rust `cert-tofu: trusted_first_use` | 8 s informational banner (already: `main.ts:105-129`) | +| **Cert mismatch** | Rust `cert-tofu: mismatch` | Blocking `CertMismatchModal`; Accept re-pins + reconnects, Reject disconnects + returns to connect (already: `main.ts:133-164`) | + +--- + +## 6. Cross-cutting principles + +1. **Optimistic where the user acts, authoritative where the server decides.** + Local actions (send, react, mute) reflect immediately with a *pending* marker, + then reconcile against the server echo; on failure they roll back visibly with + a retry — never silently. +2. **Permission is expressed as affordance, not as rejection.** If the server + will refuse, the client disables the control with a reason first. The + announcement-channel composer is the canonical example (see + [messaging.md](messaging.md)). +3. **Connection state gates live controls reactively** (§3), not per-click. +4. **One reaction per failure class** (§5), applied uniformly. +5. **No silent success and no silent failure** (§1). + +## Maintenance rule + +Same as the blueprint set: if a PR changes a client flow, event handler, or the +set of states a view must represent, it updates the corresponding UX doc in the +same change. These specs reference stable identifiers (event-type strings, store +action names, component names) over line numbers; the `file:line` anchors in the +gap callouts are point-in-time and dated by the header. diff --git a/docs/architecture/ux/channels-members-dms.md b/docs/architecture/ux/channels-members-dms.md new file mode 100644 index 00000000..11063a19 --- /dev/null +++ b/docs/architecture/ux/channels-members-dms.md @@ -0,0 +1,164 @@ +# Channels, Members & Direct Messages — target UX + +**Verified against:** commit `da4acc5`, 2026-07-19 +Part of the [Client UX Specification](README.md). + +Covers the sidebar surfaces: the channel list (switch, categories, reorder, +announcement affordance), the member list (presence, typing, roles), and DMs +(open/close, blocking). + +--- + +## 1. Channel sidebar + +Renders from `channels.store` (`channels` map, `activeChannelId`), grouped by +category, sorted by position. The sidebar has two modes (`ui.store.sidebarMode`): +`channels` and `dms`. + +| State | Trigger | Target reaction | +|-------|---------|-----------------| +| `ready` | Channels loaded from `ready` | Grouped, collapsible category list | +| `empty` | Zero channels | "No channels yet" + hint (already `ChannelSidebar.ts:422-430`) | +| category collapsed | User toggles | Persisted per-server in localStorage (`ui.toggleCategory`); chevron reflects state | +| active channel | `setActiveChannel` | Highlighted; unread cleared | +| unread | `chat_message` in a non-active channel | Unread pill; badge on the channel | + +### 1.1 Channel type affordances + +Each channel type gets a distinct icon and interaction: + +| Type | Icon | Click behavior | +|------|------|----------------| +| `text` | hash | Focus → load messages | +| `announcement` | megaphone (D1) | Focus → load messages; **composer read-only unless MANAGE_MESSAGES** (see [messaging.md §2](messaging.md)) | +| `voice` | speaker | Join voice (see [voice-and-e2ee.md](voice-and-e2ee.md)); shows the participant roster inline | +| `dm` | — | Not in the channel list; lives in DM mode | + +### 1.2 Channel switching + +```mermaid +sequenceDiagram + autonumber + participant U as User + participant CS as ChannelSidebar + participant CH as channels.store + participant CC as ChannelController + U->>CS: click channel + CS->>CH: setActiveChannel(id) %% clears that channel's unread + CH-->>CC: activeChannelId change + CC->>CC: mountChannel(id, type) — MessageList + Typing + Composer + CC->>SRV: channel_focus{channel_id} %% server read-state +``` + +**Target rules:** +- Switching is instantaneous from cache; the message area shows its own loading + state for uncached history ([messaging.md §1](messaging.md)), never a global block. +- If the active channel is **deleted** server-side (`channel_delete`), redirect to + the first text channel by position and toast "This channel was deleted." + (redirect already exists, `dispatcher.ts:286-292`; add the toast). + +### 1.3 Reorder & CRUD (admin) + +Drag-reorder and create/edit/delete are admin affordances — see +[settings-and-admin.md §3](settings-and-admin.md). **Target:** reorder should be +optimistic (position updates locally, then `PATCH` per moved channel) and roll +back on failure. + +--- + +## 2. Member list + +Renders from `members.store` (`members` map + `typingUsers`). Shows presence and +role grouping. + +| State | Trigger | Target reaction | +|-------|---------|-----------------| +| `ready` | `ready.members` | Grouped by role, sorted; presence dot per member | +| `empty` | No online members | "No members online" (already `MemberList.ts:167-170`) | +| presence change | `presence` event | Live dot update; offline members styled distinctly | +| role change | `member_update` | Re-group live | +| profile change | `user_update` | Name/avatar update; if it's us, also patch `auth.store` (already `dispatcher.ts:334-341`) | +| join/leave/ban | `member_join`/`member_leave`/`member_ban` | Add/remove with no reflow flash | + +### 2.1 Typing indicator + +`typing` events populate `members.typingUsers` with a 5 s auto-clear timer. +**Target:** show "X is typing…" / "X and Y are typing…" / "Several people are +typing…" below the message list, excluding the current user (already +`TypingIndicator.ts:35`). The client emits `typing_start` while composing +(debounced), never per-keystroke. + +### 2.2 Member actions (context menu) + +Right-click / long-press a member → context menu (roles, kick, ban) — moderation +affordances covered in [settings-and-admin.md §3](settings-and-admin.md). Actions +the user lacks permission for are **not shown** (menu items gated by the actor's +role), consistent with the affordance principle. + +> **⚠ Current gap — role source split.** Role lookups read from two stores that +> aren't kept in sync: `channels.store` carries `roles`/`getRoleIdByName` (wired +> in the dispatcher) while a parallel `roles.store` exposes the same API, consumed +> by `SidebarMemberSection.ts:11` — only `channels.store.setRoles` is updated by +> `ready` (`dispatcher.ts:10`). Target: one role store, one writer. A stale +> `roles.store` can mis-map a role name→id in the member context menu. + +--- + +## 3. Direct messages + +DM mode (`sidebarMode: "dms"`) renders from `dm.store` (`channels` list, each with +recipient, last-message preview, unread). + +| State | Trigger | Target reaction | +|-------|---------|-----------------| +| `ready` | `ready.dm_channels` | DM list sorted by recency | +| `empty` | No DMs | "No direct messages yet" + "Start one from a member's profile" | +| open DM | `dm_channel_open` | Prepend/move-to-top, dedup (already `dm.store.ts:38`) | +| close DM | `dm_channel_close` | Remove from list | +| new DM message | `chat_message` in a DM | `updateDmLastMessage` (unread bump + reorder) if not focused; `updateDmLastMessagePreview` (no bump) if own/active | +| last-message empty | Never messaged | "No messages yet" fallback (already `SidebarDmHelpers.ts:127`) | + +### 3.1 Opening a DM + +```mermaid +sequenceDiagram + autonumber + participant U as User + participant P as Member profile popup + participant API as api.ts + participant DM as dm.store + U->>P: "Message" on a member + P->>API: POST /dms {recipient_id} + API-->>P: DM channel + P->>DM: open DM mode + focus channel + Note over U,DM: server also broadcasts dm_channel_open to both parties +``` + +### 3.2 Blocking + +Blocking gates DM delivery server-side (a blocked user can't post into the DM, +and `IsEitherBlocked` is bidirectional). **Target UX:** + +| Action | Reaction | +|--------|----------| +| Block user | Confirm → block; DM composer becomes read-only with "You've blocked this user. Unblock to send messages." | +| Being blocked | Composer read-only with a neutral "You can't message this user right now." (do not reveal the block state explicitly — the server returns a generic refusal) | +| Unblock | Composer re-enables | + +> **⚠ Current gap.** There is no client-side block-state composer gating (the +> composer has no read-only mode at all — see [messaging.md §2](messaging.md)). +> The block/unblock REST surface exists server-side; the client would refuse a +> DM send only via the generic WS `error`/`FORBIDDEN` path today. Target ties DM +> block state into the same composer-state machine. + +--- + +## Source of truth + +`src/components/ChannelSidebar.ts` (+ `channel-sidebar/`), +`src/components/MemberList.ts`, `src/components/TypingIndicator.ts`, +`src/components/DmSidebar.ts`, `src/components/DmProfileSidebar.ts`, +`src/pages/main-page/SidebarArea.ts`, `SidebarMemberSection.ts`, +`SidebarDmSection.ts`, `SidebarDmHelpers.ts`, `src/stores/channels.store.ts`, +`members.store.ts`, `dm.store.ts`, `roles.store.ts`, `src/lib/dispatcher.ts`; +server `Server/service/channel.go`, `dm.go`, `block.go`. diff --git a/docs/architecture/ux/connection-and-auth.md b/docs/architecture/ux/connection-and-auth.md new file mode 100644 index 00000000..5476bf54 --- /dev/null +++ b/docs/architecture/ux/connection-and-auth.md @@ -0,0 +1,249 @@ +# Connection & Authentication — target UX + +**Verified against:** commit `da4acc5`, 2026-07-19 +Part of the [Client UX Specification](README.md). Shared vocabulary, feedback +primitives, and the error matrix live in the [README](README.md) and are not +repeated here. + +Covers: app boot → server-profile selection → health → login / TOTP / +register-by-invite → the connected handshake → reconnect → cert-TOFU trust. + +--- + +## 1. Boot & page model + +The app is a two-page state machine (`lib/router.ts`: `connect | main`). The +router only tracks the page; `main.ts:renderPage` mounts/destroys the page DOM. + +```mermaid +stateDiagram-v2 + [*] --> Connect: launch + Connect --> Authenticating: submit login (valid) + Authenticating --> TotpChallenge: requires_2fa + TotpChallenge --> Authenticating: code accepted + Authenticating --> Connecting: token obtained → WS connect + Connecting --> ConnectedOverlay: ws "connected" + ConnectedOverlay --> Main: "ready" received + Main --> Connect: logout / 401 / banned / cert-reject + Connecting --> Connect: auth_error / connect fail + Authenticating --> Connect: login error (stay on form) +``` + +**Target rule:** the transition `Connect → Main` is gated by the **connected +overlay**, which resolves only on the `ready` event — never navigate to Main on a +bare socket-open. (Already the case: `main.ts:270-286`.) This guarantees Main +never renders against empty stores. + +--- + +## 2. Connect page + +Three regions: **server panel** (profiles + health), **login form**, and a +status area. Settings are reachable unauthenticated (for appearance/advanced). + +### 2.1 Server profiles & health + +| State | Trigger | Target reaction | +|-------|---------|-----------------| +| `loading` | Profile list resolving from the Rust store (`owncord:profiles`) | Skeleton rows; no flash of "no servers" | +| `ready` | Profiles loaded | List with per-profile health dot | +| `empty` | No saved profiles | "Add a server to get started" with an inline add affordance | +| health: reachable | `GET /api/v1/health` ok within 3 s | Green dot + server name/MOTD preview | +| health: unreachable | timeout/opaque error | Amber "unreachable" dot; **do not** block selecting it (user may still try) | + +Health polls every 15 s (`profiles.ts`); auto-connect, if enabled for the active +profile, drives the login form's `auto-connecting` state. + +### 2.2 Login form — state machine + +The form is an explicit FSM: `idle | loading | totp | connecting | error | +auto-connecting` (`LoginForm.ts:12`). This is the model other views should +follow. + +| State | Presentation | Exit | +|-------|--------------|------| +| `idle` | Enabled fields; Login/Register toggle | submit → validate | +| `loading` | Submit shows spinner, fields disabled (`LoginForm.ts:232-235,443-446`) | `auth.login` resolves | +| `totp` | 6-digit overlay, Verify/Cancel | code → `verifyTotp` | +| `connecting` | "Connecting…" while WS handshakes | ws `connected` | +| `auto-connecting` | Dedicated spinner card for saved-profile auto-login | any key/click cancels to `idle` | +| `error` | Shake-animated banner, server message capped 200 chars (`LoginForm.ts:590-606`) | user edits → `idle` | + +**Client-side validation before any request** (`LoginForm.ts:536-560`): host, +username, password required; password ≥ 8; register mode also requires the invite +code. Validation failures never hit the network. + +### 2.3 Login sequence + +```mermaid +sequenceDiagram + autonumber + participant U as User + participant F as LoginForm + participant API as api.ts (via HTTP TOFU proxy) + participant WS as ws.ts + U->>F: enter host + credentials, submit + F->>F: validate (host/user/pass≥8) + F->>API: POST /auth/login + alt requires_2fa + API-->>F: 200 {partial_token, requires_2fa} + F->>U: show TOTP overlay + U->>F: 6-digit code + F->>API: POST /auth/verify-totp (Bearer partial_token) + API-->>F: 200 {token, user} + else banned + API-->>F: 403 "account suspended" + F->>U: error banner (stay on form) + else success + API-->>F: 200 {token, user} + end + F->>WS: connect(wss://host/api/v1/ws) with token + WS->>WS: auth handshake → auth_ok → ready + WS-->>U: connected overlay → Main +``` + +**Auth branches → reaction** (server `auth_handler.go`): + +| Server result | Target reaction | +|---------------|-----------------| +| `200 {token, user}` | Proceed to WS connect | +| `200 {partial_token, requires_2fa}` | TOTP overlay; on cancel, clear the partial token (already cleared in `finally`, `main.ts:377-380`) | +| `403` banned/suspended | Error banner with the server message; remain on the form | +| `403` require-2FA-but-none-set | Error banner directing the user to set up 2FA on the web panel | +| `400` invalid input | Inline field error | +| `429` rate-limited | "Too many attempts — wait a moment." Keep entered username; re-enable after cooldown | + +### 2.4 Register-by-invite + +Same form, register mode reveals the invite field. `POST /auth/register` returns +a token directly → straight to WS connect (no separate login round-trip). Closed +registration / require-2FA policy → `403` shown as an error banner. + +> **Note — first-run owner setup is not in this client.** `POST /admin/api/setup` +> is server-web-panel only; the Tauri client has no owner-setup UI +> (`admin/setup_handler.go`). If the target is to support standing up a server +> from the desktop app, that is a **new flow** (detect `GET /admin/api/setup/status` +> = no users → offer an owner-creation form) — currently out of scope, flagged +> here so the omission is a decision. + +--- + +## 3. The connected handshake + +```mermaid +sequenceDiagram + autonumber + participant WS as ws.ts + participant OVL as ConnectedOverlay + participant ST as stores + WS->>WS: ws-state "open" → send auth{token,last_seq} + WS->>WS: auth_ok → state=connected, start heartbeat(30s) + WS-->>OVL: onStateChange("connected") → show overlay + WS->>ST: ready → setChannels/roles/members/voice/dm + ST-->>OVL: ready handled → markReady() + OVL->>OVL: onReady → router.navigate("main") +``` + +**Target rule:** the ready overlay is the *only* full-screen blocker in the app. +It exists specifically so Main never renders mid-populate. Everything else +(message load, member load) uses in-region loading, not a global block. + +--- + +## 4. Reconnect UX + +The WS client auto-reconnects with exponential backoff (base 1 s, cap 30 s, no +jitter/cap; `ws.ts:123-126`), preserving `last_seq` for replay. The user-facing +contract: + +```mermaid +stateDiagram-v2 + Connected --> Reconnecting: socket closed (unintentional) + Reconnecting --> Reconnecting: backoff retry (1,2,4,…,30s) + Reconnecting --> Resyncing: socket open → auth{last_seq} + Resyncing --> Connected: replay (dedup) or full ready + Reconnecting --> Connect: auth_error (fatal) → transient-error + Connected --> Restarting: server_restart{delay} + Restarting --> Reconnecting: server drops us +``` + +| Phase | Target reaction | +|-------|-----------------| +| `reconnecting` | `ServerBanner.showReconnecting()` (already `MainPage.ts:199-211`); **live-only controls disable** via connection status (§3 of README); drafted input preserved | +| replay resync | Silent when the ring buffer covers `last_seq`; deduped so no double-render (`ws.ts:212-231`); unread suppressed during replay (`dispatcher.ts:195`) | +| full resync | If `last_seq` predates buffer coverage, server replays from the events table or forces a full `ready`; the UI simply re-populates — no user action | +| `server_restart` | `ServerBanner.showRestart(delay_seconds)` with a live countdown (`ServerBanner.ts:28-43`) | +| fatal (`auth_error`) | `intentionalClose`, transient-error store → connect page | + +**Target rule:** reconnection is invisible on the happy path and honest on the +sad path. The user should never wonder whether the app is live — the banner and +the disabled live-controls answer it. This is where consolidating connection +status onto `ui.store` (README §3) pays off: the composer, voice controls, and +presence picker all disable *reactively* while reconnecting, instead of accepting +a click and failing. + +--- + +## 5. Cert trust (TOFU) prompts + +The Rust proxies pin the server cert on first use and emit `cert-tofu` events. +The HTTP proxy usually establishes the pin first (login precedes WS). + +| Event | Target reaction | Current | +|-------|-----------------|---------| +| `trusted_first_use` | 8 s informational banner "Trusting this server's certificate" | Implemented ad hoc in `main.ts:105-129` | +| `trusted` | No UI (silent, expected) | — | +| `mismatch` | **Blocking** `CertMismatchModal`: explain the fingerprint changed; **Accept** re-pins (`accept_cert_fingerprint`) + reconnects; **Reject** disconnects, `clearAuth()`, → connect page | Implemented `main.ts:133-164`; reconnect blocked until resolved (`certMismatchBlock`) | + +```mermaid +sequenceDiagram + autonumber + participant P as Rust proxy + participant M as main.ts + participant U as User + P-->>M: cert-tofu {status: mismatch, fingerprint} + M->>M: certMismatchBlock = true (reconnect halted) + M->>U: CertMismatchModal (blocking) + alt Accept + U->>M: Accept + M->>P: accept_cert_fingerprint(host, fp) + M->>M: clear block → reconnect + else Reject + U->>M: Reject + M->>M: disconnect + clearAuth → connect page + end +``` + +**Target rule:** a cert mismatch is the one moment the client must *stop and ask* +— never auto-accept, never silently reconnect. This is correct today; the spec +locks it. + +--- + +## 6. Logout & session lifecycle + +| Trigger | Target behavior | +|---------|-----------------| +| User logout | `clearAuth()` → leave voice, disconnect WS, delete stored credential for the host, → connect page | +| 401 anywhere | Same as logout, with "Your session expired — sign in again." | +| WS `BANNED` | Transient-error → connect page, no reconnect | +| Cert reject | Disconnect → connect page | + +> **⚠ Current gap — server session not revoked on logout.** `api.logout()` +> (`POST /auth/logout`, `api.ts:211`) is defined but never called; logout is +> client-local only (`MainPage.ts:298` → `clearAuth()`), so the bearer token +> stays valid server-side until it expires. Target: user-initiated logout should +> `POST /auth/logout` (best-effort, before tearing down) so the session is +> actually revoked. The credential *is* deleted locally (`main.ts:491-515`), but +> the server token is not. + +--- + +## Source of truth + +`src/lib/router.ts`, `src/main.ts`, `src/pages/ConnectPage.ts`, +`src/pages/connect-page/LoginForm.ts`, `src/lib/ws.ts`, `src/lib/api.ts`, +`src/lib/httpProxy.ts`, `src/components/ConnectedOverlay.ts`, +`src/components/ServerBanner.ts`, `src/components/CertMismatchModal.ts`, +`src-tauri/src/ws_proxy.rs`, `src-tauri/src/http_proxy.rs`; +server `Server/api/auth_handler.go`, `Server/api/totp_handler.go`. diff --git a/docs/architecture/ux/messaging.md b/docs/architecture/ux/messaging.md new file mode 100644 index 00000000..46ac4c6f --- /dev/null +++ b/docs/architecture/ux/messaging.md @@ -0,0 +1,227 @@ +# Messaging — target UX + +**Verified against:** commit `da4acc5`, 2026-07-19 +Part of the [Client UX Specification](README.md). Shared vocabulary and the error +matrix live in the [README](README.md). + +Covers the chat surface: loading history, the composer, sending (optimistic), +edit/delete, reactions, attachments, replies, pins, search, read/unread, +slow-mode, and announcement read-only gating. + +--- + +## 1. Message list — states + +The list renders from `messages.store` (`messagesByChannel`, capped 500/channel). + +| State | Trigger | Target reaction | +|-------|---------|-----------------| +| `loading` | Channel opened, history fetch in flight, nothing cached | **In-region loading placeholder** in the message area | +| `ready` | Messages present | Virtualized list | +| `empty` | Loaded, zero messages | "This is the beginning of #channel." welcome state (already `MessageList.ts:109-125`) | +| `loading older` | Scroll-to-top with `hasMore` | Top spinner while `prependMessages` resolves (already `MessageList.ts:459-468`) | +| `error` | History fetch failed | **Inline section error + Retry** in the message area | + +> **⚠ Current gap — no loading state on history fetch.** `MessageController.loadMessages` +> fetches silently; there is no placeholder in the message slot, only the +> post-render empty state or a toast on failure (`MessageController.ts:73-97`). +> Target: show an in-region loading placeholder while the first page loads, and +> an inline **Retry** on failure instead of a transient toast. + +--- + +## 2. Composer — permission & connection gating + +This is the spec's canonical example of **permission-as-affordance**. The +composer must reflect, *before the user types or sends*, whether posting is +possible. + +```mermaid +stateDiagram-v2 + [*] --> Evaluate: channel mounted + Evaluate --> Enabled: text/DM channel + SEND perm + connected + Evaluate --> ReadOnly: announcement channel without MANAGE_MESSAGES + Evaluate --> NoPerm: no SEND_MESSAGES on this channel + Evaluate --> Offline: socket not connected + Evaluate --> SlowMode: slow-mode cooldown active + Enabled --> Sending: submit + Sending --> Enabled: ack / next message + ReadOnly --> [*] + NoPerm --> [*] + Offline --> Enabled: reconnected + SlowMode --> Enabled: cooldown elapsed +``` + +| Composer state | Presentation | Reason shown | +|----------------|--------------|--------------| +| `enabled` | Editable textarea, attach + pickers active | — | +| `read-only` (announcement, no MANAGE_MESSAGES) | Textarea replaced by a disabled bar | "Only moderators can post in announcement channels." | +| `no-permission` | Disabled bar | "You don't have permission to send messages here." | +| `offline` | Disabled, "Reconnecting…" | connection status (README §3) | +| `slow-mode` | Disabled with a live countdown | "Slow mode: wait Ns." | +| `uploading` | Send disabled until uploads settle (already `MessageInput.ts:138-141`) | per-attachment spinner | + +> **⚠ Current gap — the composer has no permission/read-only mode.** +> `MessageInput` always renders an enabled textarea (`MessageInput.ts:379-384`); +> the only disabled control is the attach button when uploads aren't wired. There +> is **no** client gating for announcement channels, missing `SEND_MESSAGES`, or +> slow-mode — even though the server enforces all three (announcement requires +> MANAGE_MESSAGES since D1; `ChannelType` `"announcement"` is already threaded to +> `mountChannel`, `ChannelController.ts:114`, but unused). Today the only +> send-time block is "not connected", surfaced as a toast *after* the click +> (`ChannelController.ts:200-204`). Target: derive composer state from +> `permissions` + channel type + connection status and disable with a reason, +> so a forbidden send is never attempted. This needs the client to know the +> user's effective per-channel permission — see the note at the end. + +--- + +## 3. Sending — optimistic lifecycle + +**Target: send is optimistic.** On submit, the message renders immediately in a +`pending` state, then reconciles against the server. + +```mermaid +sequenceDiagram + autonumber + participant U as User + participant C as Composer + participant S as messages.store + participant WS as ws.ts + participant SRV as Server + U->>C: type + Enter + C->>S: addPendingSend(correlationId, optimistic row) %% renders "sending…" + C->>WS: chat_send{correlationId, channel, content, reply_to, attachments} + alt server accepts + SRV-->>WS: chat_send_ok{id=correlationId, message_id, timestamp} + WS->>S: confirmSend(correlationId, message_id, timestamp) %% row → "sent", real id + SRV-->>WS: chat_message (broadcast) + WS->>S: addMessage — reconcile: replace pending row, do not duplicate + else server rejects + SRV-->>WS: error{code} %% SLOW_MODE / RATE_LIMITED / FORBIDDEN / INVALID_INPUT + WS->>S: markSendFailed(correlationId, code) %% row → "failed", Retry + else transport drop + WS-->>S: markSendFailed(correlationId, "network") %% ws_send channel-full/closed + end +``` + +| Optimistic state | Presentation | Transition | +|------------------|--------------|------------| +| `pending` | Row shown dimmed with a subtle "sending" affordance | `chat_send_ok` → `sent`; error → `failed` | +| `sent` | Normal row; the subsequent `chat_message` broadcast reconciles (same `id`), never duplicates | — | +| `failed` | Row marked failed with **Retry** and **Delete draft**; content preserved | Retry re-sends with a new correlation id | + +**Reconciliation contract:** the correlation id (`ws.ts` per-send UUID, echoed as +`chat_send_ok.id`) is the join key. `addMessage` from the broadcast must detect an +existing pending/sent row for that id and replace-in-place rather than append. + +> **⚠ Current gap — sending is not optimistic and acks are dropped.** The send +> path fires `chat_send` and does nothing locally; the message appears only when +> the server's `chat_message` broadcast arrives (`ChannelController.ts:199-214`, +> `dispatcher.ts:174-218`). The `pendingSends`/`addPendingSend`/`confirmSend` +> machinery already exists in `messages.store.ts` (`:243-258`) but `addPendingSend` +> has **zero callers**, and `confirmSend` discards the real `message_id`/`timestamp` +> (`messages.store.ts:252`). Transport backpressure ("channel full") is dropped +> silently (`ws.ts:432-437`), and rejection codes other than +> RATE_LIMITED/FORBIDDEN/BANNED are ignored (`dispatcher.ts:433-436`). Target: +> wire the existing pending-send machinery into an optimistic row with +> pending/sent/failed states and a Retry — the store scaffolding is already there. + +--- + +## 4. Edit / delete + +| Action | Target UX | +|--------|-----------| +| Edit (own message) | Inline edit in the composer (`startEdit`, `MessageInput.ts`); optimistic content swap; `chat_edited` reconciles + stamps "edited"; failure rolls back with a toast | +| Delete (own / moderator) | **Two-click confirm** on the row (`PendingDeleteManager`, `MessageController.ts:32-54`); optimistic tombstone; `chat_deleted` confirms; failure restores the row + toast | +| Delete (no permission) | The delete affordance is not offered on others' messages unless the user has MANAGE_MESSAGES | + +Deleted messages are soft-deleted (kept as a tombstone in the array, `deleted:true`) +so surrounding context and reply references stay intact. + +--- + +## 5. Reactions + +| Action | Target UX | +|--------|-----------| +| Add/remove reaction | Optimistic pill toggle + count adjustment, reflecting `me`; `reaction_update` echo reconciles; failure rolls the pill back | +| Emoji picker | `EmojiPicker` with recent-emoji memory (`owncord:recent-emoji`) | + +> Current: reactions render only from the server `reaction_update` echo +> (`messages.store.ts:282`); there is no local optimistic toggle. Target adds the +> optimistic toggle for immediacy, consistent with §3. + +--- + +## 6. Attachments + +The composer supports file attach with client-side validation and per-item +upload state (already thorough — `MessageInput.ts`). + +| State | Presentation | +|-------|--------------| +| selected | Thumbnail/chip per file | +| validating | Reject oversize/disallowed type inline via `showUploadError` (`MessageInput.ts:114-129`) | +| uploading | Per-item spinner; **send disabled** until all settle (`MessageInput.ts:243-247`) | +| uploaded | Chip ready; ids attached to the `chat_send` payload | +| failed | Inline error on the chip with remove/retry | + +Upload goes through `POST /uploads` (multipart). **Target:** `uploadFile` should +honor the global 401 handler like other calls (today it does not — `api.ts:380-383`). + +--- + +## 7. Replies, pins, search, read/unread + +| Feature | Target UX | +|---------|-----------| +| Reply | Reply target chip above the composer (`setReplyTo`/`clearReply`); `reply_to` sent; rendered as a quoted preview | +| Pin/unpin | Optimistic (`setMessagePinned`, already optimistic `messages.store.ts:226-240`); pinned panel lists them, empty state "No pinned messages" (already `PinnedMessages.ts:81-89`) | +| Search | Overlay with a status line cycling *type-N-chars → searching → results → no results → failed* (already thorough `SearchOverlay.ts:123-145`); abort in-flight on new query | +| Read/unread | Unread badge per channel; cleared on focus (`setActiveChannel`); incremented only for non-active, non-own, non-replay messages (`dispatcher.ts:195`); focus emits `channel_focus` for server read-state | + +**Read-state target rule:** unread counts must be suppressed during reconnect +replay (already handled via `isReplaying()`), so catching up 500 buffered +messages doesn't light every channel red. + +--- + +## 8. Slow-mode + +Server enforces per-channel slow-mode. **Target:** after a successful send in a +slow-mode channel, disable the composer with a live countdown (derived from the +channel's `slow_mode` seconds) and re-enable at zero; on a WS `SLOW_MODE` +rejection, snap the composer to the countdown state without dropping the drafted +text. + +> **⚠ Current gap.** `SLOW_MODE` errors are received but ignored +> (`dispatcher.ts:433-436`); there is no countdown UI. Part of the composer-state +> work in §2. + +--- + +## Note — the client needs effective per-channel permissions + +Several targets here (§2 composer gating, §4 delete affordance) require the client +to know the user's **effective permission on the active channel** (base role bits +± channel overrides, with the announcement-channel MANAGE_MESSAGES rule). The +client currently receives roles (`ready.roles`) and member roles but does **not** +compute effective per-channel permissions the way the server does +(`Server/permissions`). Delivering the gated composer cleanly likely means either +(a) the server sending a per-channel `can_send`/`permissions` hint (e.g. on +`ready`/`channel_focus`), or (b) porting the permission-bit evaluation to the +client. This is a prerequisite decision for §2 and is flagged as such rather than +hand-waved. + +--- + +## Source of truth + +`src/components/MessageList.ts` (+ `message-list/`), `src/components/MessageInput.ts` +(+ `message-input/`), `src/pages/main-page/ChannelController.ts`, +`src/pages/main-page/MessageController.ts`, `src/pages/main-page/ReactionController.ts`, +`src/stores/messages.store.ts`, `src/lib/dispatcher.ts`, `src/lib/ws.ts`, +`src/components/SearchOverlay.ts`, `src/components/PinnedMessages.ts`; +server `Server/service/message.go`, `Server/ws/handlers_chat.go`. diff --git a/docs/architecture/ux/settings-and-admin.md b/docs/architecture/ux/settings-and-admin.md new file mode 100644 index 00000000..27d413ae --- /dev/null +++ b/docs/architecture/ux/settings-and-admin.md @@ -0,0 +1,193 @@ +# Settings & Admin — target UX + +**Verified against:** commit `da4acc5`, 2026-07-19 +Part of the [Client UX Specification](README.md). + +Covers: the settings overlay and its tabs, account operations (profile, password, +2FA, delete), appearance/theming, the client's **inline** admin surface (ban/kick/ +roles, channel CRUD, invites), and the updater. It also marks the boundary +between what the desktop client does and what lives only on the server web panel. + +--- + +## 1. Settings overlay + +A tabbed overlay (`SettingsOverlay`) available both authenticated (in Main) and +unauthenticated (on Connect, for appearance/advanced). Tabs: Account, +Appearance, Notifications, Text & Images, Accessibility, Voice & Audio, Keybinds, +Advanced, Logs. + +**Target rules:** +- Every save is confirmed: a toast on success, an inline error on failure. No + silent saves. +- Preference writes are immediate and local (localStorage `owncord:settings:*`), + broadcast via the `owncord:pref-change` event so open views re-read live (e.g. + theme, message density) without a restart. +- Structural/durable data (server profiles, window geometry, per-user volumes) + persists through the Rust key-allowlisted store (`settings.json`); lightweight + UI prefs through localStorage. This split is intentional; the spec preserves it. + +--- + +## 2. Account operations + +The Account tab holds the identity-sensitive flows. All require the current +password for sensitive changes and are rate-limited server-side. + +### 2.1 Profile edit + +| Step | Reaction | +|------|----------| +| Edit username/avatar | `PATCH /users/me`; optimistic `auth.updateUser`; server broadcasts `user_update` so the member list + own bar update live | +| Failure | Inline field error + rollback | + +### 2.2 Change password (with session revocation) + +```mermaid +sequenceDiagram + autonumber + participant U as User + participant A as AccountTab + participant API as api.ts + U->>A: current + new + confirm (new min 8, new equals confirm) + A->>API: PUT /users/me/password + alt success + API-->>A: 204 — all other sessions revoked, this one kept + A->>U: "Password changed" toast, fields cleared + else revoke step failed + API-->>A: 200 warning, sessions_revoked + A->>U: success plus note — some sessions may still be active + else wrong current password + API-->>A: 403 — lockout counter server-side + A->>U: inline "Incorrect password" + else weak or same-as-old + API-->>A: 400 + A->>U: inline validation error + end +``` + +**Target rule (the W2-2 contract, surfaced to the user):** once the password is +committed, the operation is a **success** even if the session-revocation step +fails — the UI must never present a committed change as an error (that would walk +the user into the confirm-lockout). The partial-success `200 {warning}` maps to a +success message with a soft note, never a red error. (Server contract: +`profile_handler.go:237-248`; client already toasts success, `MainPage.ts:280-283`.) + +### 2.3 Two-factor (TOTP) + +| Flow | Steps | +|------|-------| +| Enable | Password prompt → `POST /totp/enable` → render QR URI + backup codes → 6-digit confirm → `POST /totp/confirm` → "Enabled" badge, `auth` user `totp_enabled:true` | +| Disable | Password confirm → `DELETE /totp`; a `403`/"required" is rewritten to "2FA is required by this server and cannot be disabled" (already `AccountTab.ts:442-451`) | + +**Target rule:** backup codes are shown exactly once, with an explicit "Save these +now — you won't see them again" and a copy affordance. + +### 2.4 Sessions & delete account + +| Action | Reaction | +|--------|----------| +| List sessions | `GET /users/me/sessions`; show device/IP/last-used; current session marked | +| Revoke a session | `DELETE /users/me/sessions/{id}`; optimistic removal + toast | +| Delete account | **Modal with password confirm** (irreversible — stronger than a two-click); `DELETE /auth/account` → `clearAuth()` → connect page | + +--- + +## 3. Inline admin surface (client) + +The desktop client exposes a **subset** of admin operations inline, gated by the +actor's role. Everything here must (a) only appear for users who can perform it, +and (b) confirm destructive actions. + +| Operation | Affordance | REST | Reaction | +|-----------|-----------|------|----------| +| Change role | Member context menu → submenu | `PATCH /admin/api/users/{id}` `{role_id}` | Toast; `member_update` reflects live | +| Kick | Member menu, two-click confirm | `DELETE /admin/api/users/{id}/sessions` | Toast "Kicked {user}"; `member_leave` | +| Ban | Member menu, two-click confirm | `PATCH /admin/api/users/{id}` `{banned, ban_reason}` | Toast; `member_ban` removes them | +| Create channel | Sidebar → modal | `POST /admin/api/channels` | Modal closes on success; `channel_create` | +| Edit channel | Channel menu → modal | `PATCH /admin/api/channels/{id}` | `channel_update` | +| Delete channel | Channel menu, two-click confirm | `DELETE /admin/api/channels/{id}` | `channel_delete`; redirect if active | +| Reorder channels | Drag | `PATCH …/{id}` `{position}` per moved | Optimistic; roll back on failure | +| Invites | Invite manager modal | `GET/POST/DELETE /invites` | List with masked codes, copy, revoke; empty state "No active invites" | + +**Target rules:** +- Destructive admin actions should show an **in-flight** state (today the + two-click label reverts immediately and only a toast reports the result — + `AdminActions.ts:54-78`; add a pending state so a slow ban doesn't look ignored). +- **Ban should collect a reason.** `adminBanMember` accepts a `reason` but the + menu passes none (`SidebarMemberSection.ts:159-166`). Target: a small reason + prompt on ban, since the server stores and displays it. + +### 3.1 What is *not* in the client (by design) + +The full admin panel — user list, audit log, server settings, channel +permissions, plugin management, backups, updates, first-run setup — is the +**server-rendered web panel** under `/admin`, gated by IP restriction + admin +auth. The Tauri client has **no** REST methods for these (confirmed: no plugin/ +audit/settings/permissions/setup calls in `api.ts`). + +> **Decision point.** If the target is for admins to manage the server from the +> desktop app (audit log, settings, plugins) rather than the web panel, that is a +> **new surface** to build, not a gap in an existing flow. Flagged here so the +> boundary is explicit; the current split (inline moderation in the client, full +> administration on the web) may well be the intended design. + +--- + +## 4. Appearance & theming + +Themes are CSS custom properties (`styles/tokens.css`), 4 built-ins +(`dark | neon-glow | midnight | light`) plus custom overrides. **Target:** theme +changes apply **live** — `ui.setTheme` + the `owncord:pref-change` event re-skin +open views without reload. Appearance is editable pre-login (on the connect page) +so the app respects the user's theme before they authenticate. + +--- + +## 5. Updater + +Self-hosted: the update endpoint derives from the connected server URL, over +TOFU-pinned (or system) TLS, minisign-verified inside the Tauri updater plugin. + +```mermaid +sequenceDiagram + autonumber + participant N as UpdateNotifier + participant R as Rust updater + N->>N: 3s after mount → check_client_update(server_url) + alt update available + R-->>N: {available, version, body} + N->>U: banner "Update vX available" [Update Now] [Later] + U->>N: Update Now + N->>N: banner → "Downloading update…" + N->>R: download_and_install_update (minisign verify + apply) + R-->>N: (success → relaunch()) + else up to date / check failed + N->>N: no banner (or "Update failed. Try again." + Dismiss) + end +``` + +| State | Presentation | +|-------|--------------| +| checking | Silent (no UI until a result) | +| available | Non-modal banner with version + Update Now / Later (already `UpdateNotifier.ts:30-62`) | +| downloading | Banner "Downloading update…" | +| applied | App relaunches automatically | +| failed | "Update failed. Please try again later." + Dismiss | + +> **⚠ Current gap — no download progress.** The download callback is a no-op +> (`update_commands.rs:177`), so "Downloading update…" has no percentage. For a +> large binary this looks hung. Target: surface a progress indicator (percentage +> or indeterminate-with-bytes) by wiring the plugin's progress callback. + +--- + +## Source of truth + +`src/components/SettingsOverlay.ts` (+ `settings/*`), `src/components/AdminActions.ts`, +`src/components/InviteManager.ts`, `CreateChannelModal.ts`, `EditChannelModal.ts`, +`DeleteChannelModal.ts`, `src/components/UpdateNotifier.ts`, `src/lib/updater.ts`, +`src/lib/api.ts`, `src/lib/themes.ts`, `src/lib/preferences.ts`, +`src/pages/main-page/SidebarArea.ts`, `SidebarMemberSection.ts`, +`OverlayManagers.ts`, `src-tauri/src/commands.rs`, `src-tauri/src/update_commands.rs`; +server `Server/admin/api.go`, `Server/api/profile_handler.go`, `totp_handler.go`. diff --git a/docs/architecture/ux/voice-and-e2ee.md b/docs/architecture/ux/voice-and-e2ee.md new file mode 100644 index 00000000..88445f9a --- /dev/null +++ b/docs/architecture/ux/voice-and-e2ee.md @@ -0,0 +1,152 @@ +# Voice, Video & E2EE — target UX + +**Verified against:** commit `da4acc5`, 2026-07-19 +Part of the [Client UX Specification](README.md). The signaling/crypto mechanics +are mapped structurally in [../voice-e2ee.md](../voice-e2ee.md); this document +specifies the **user-facing** states and reactions. + +Covers: joining/leaving voice, mute/deafen/camera/screenshare, push-to-talk, the +active-speaker display, and — the main gap — the E2EE "securing / secured" +indicators. + +--- + +## 1. Two state machines, one status + +Internally there are **two** FSMs: + +- The **WS connection** FSM (`ws.ts`: `disconnected…connected`) — the socket. +- The **voice session** FSM (`livekitSession.ts`: `idle | connecting | + connected | reconnecting`) — the LiveKit room. + +Plus the user-facing booleans in `voice.store` (`localMuted`, `localDeafened`, +`localCamera`, `localScreenshare`, `listenOnly`, `joinedAt`) and the per-user +roster (`voiceUsers` with per-user `speaking/muted/deafened/camera/screenshare`). + +**Target:** expose the voice session as one observable `voiceStatus` the widgets +read — `idle | joining | securing | connected | reconnecting | failed` — rather +than inferring it from `isVoiceConnected()` alone. + +> **⚠ Current gap.** The voice session FSM is internal; the only UI-observable +> connection signal is `isVoiceConnected()` (`livekitSession.ts:1713`, true only +> in `connected`). There is **no** store-backed `joining`/`securing`/`reconnecting` +> indicator, so the UI can't distinguish "connecting to the room" from "securing +> the encryption" from "reconnecting". Target adds an explicit status field. + +--- + +## 2. Join / leave + +```mermaid +stateDiagram-v2 + idle --> joining: click voice channel → voice_join → voice_token + joining --> securing: room.connect ok, E2EE key exchange begins + securing --> connected: room key ready (holder generates / member receives) + securing --> failed: e2ee_timeout (no key within ~15s) + joining --> reconnecting: transient connect failure (retry ≤3) + connected --> reconnecting: socket/room drop + reconnecting --> connected: re-announce key + rejoin (≤2 attempts) + reconnecting --> failed: attempts exhausted + connected --> idle: leave + failed --> idle: auto-leave + error +``` + +| Status | Presentation | Notes | +|--------|--------------|-------| +| `joining` | Voice widget shows "Connecting…"; channel roster shows self pending | `handleVoiceToken` → `connectAndSetup` | +| `securing` | "Securing connection…" indicator (lock, in-progress) | Non-key-holders block here until a room key arrives (10 s + 5 s retry, `livekitSession.ts:860-902`) | +| `connected` | "Voice connected · secured 🔒" + elapsed timer (from `joinedAt`) | E2EE active; per-user tiles live | +| `reconnecting` | "Reconnecting voice…"; controls frozen, not torn down | Keypair regenerated for forward secrecy (`livekitSession.ts:451-468`) | +| `failed` | Toast "Voice connection lost" / "Couldn't secure the call"; auto-leave | `onErrorCallback` fires | + +**Target rules:** +- The "connecting" vs "securing" distinction is user-visible: while a non-key-holder + waits for the room key, show **securing**, not a generic spinner — an E2EE call + that's still exchanging keys is not yet private. +- Leaving is immediate and local (`leaveVoice`): tear down tracks, clear E2EE + state, reset camera/screenshare, `idle`. + +> **⚠ Current gap — E2EE has no visible indicator.** Key exchange produces only +> log lines; the sole user-facing effects are (a) the join *blocking* while the +> key is fetched and (b) an `"e2ee_timeout"` error string on failure +> (`livekitSession.ts:893`). There is no "securing" state and no persistent +> "secured 🔒" affirmation once connected. Target: a `voiceStatus: "securing"` +> phase + a secured indicator on the connected widget, so users can *see* the +> call is end-to-end encrypted (and see when it isn't yet). + +--- + +## 3. Local controls + +All four are optimistic with rollback; each also emits a WS control message. + +| Control | Local state | WS message | Rollback | +|---------|-------------|-----------|----------| +| **Mute** | `localMuted` (`setLocalMuted`) — fully unpublishes the mic track | `voice_mute{muted}` | n/a (local-authoritative) | +| **Deafen** | `localDeafened` + forces mute | `voice_deafen` + `voice_mute` | implies mute | +| **Camera** | `localCamera` set optimistically, rolled back on device failure (`screenShare.ts:177,204`) | `voice_camera{enabled}` | revert on failure + toast | +| **Screenshare** | `localScreenshare` optimistic, rollback on failure (`screenShare.ts:265,311`); rate-limited | `voice_screenshare{enabled}` | revert + toast | + +| Control state | Presentation | +|---------------|--------------| +| mic muted | Mic-slash icon on self tile + control bar | +| deafened | Headphone-slash; implies muted styling | +| listen-only | Badge "Listen only — no microphone" with a **Retry mic** affordance (`retryMicPermission`) | +| camera on | Self video tile in the grid | +| screenshare on | Screen tile; a stop-share affordance always visible | +| speaking | Green ring on the speaking user's tile/avatar (from `voice_speakers` / ActiveSpeakers) | + +**Mic-permission failure** (`restoreLocalVoiceState`): on denied/absent mic, set +`listenOnly` and surface the specific reason ("Microphone permission denied" / +"No microphone found") as a toast with a retry — already wired to +`onErrorCallback` (`livekitSession.ts:734-743`); the spec makes the **Retry mic** +control a permanent part of the listen-only badge. + +--- + +## 4. Push-to-talk + +PTT is a Rust key-poller (`ptt.rs`, 20 ms) emitting `ptt-state{pressed}` → +`setMuted(!pressed)` only while in a channel (`ptt.ts:98-105`). **Target UX:** + +| State | Presentation | +|-------|--------------| +| PTT bound, released | Muted; hint "Hold {key} to talk" | +| PTT pressed | Unmuted + speaking ring | +| binding a key | Keybinds tab: "Press a key…" (10 s capture window, `ptt_listen_for_key`); reject text keys with "Pick a non-text key" | +| PTT thread error | Toast "Push-to-talk stopped unexpectedly" on `ptt-error`, offer re-enable | + +--- + +## 5. Voice roster (per channel) + +The channel's voice roster renders from `voiceUsers`. Each participant tile +reflects their `speaking/muted/deafened/camera/screenshare`. **Target:** + +| Signal | Tile reaction | +|--------|---------------| +| `voice_state` | Add/update the participant with their flags | +| `voice_leave` | Remove the tile; if it's us (kick/disconnect), clear local voice state (already `dispatcher.ts:364-367`) | +| `voice_speakers` | Speaking ring on the listed users | +| key-holder change | Invisible to users (re-election is automatic on leave); no UI churn | + +Per-user volume is adjustable and persisted (`userVolume_{id}` in the Rust store). + +--- + +## 6. Token refresh & reconnect (invisible) + +Token refresh (23 h timer) and voice reconnect (≤2 attempts, 3 s apart) should be +**invisible on success**. Only exhaustion surfaces: "Voice connection lost — +failed to reconnect" + auto-leave. The 60 s token-refresh response guard and the +forward-secrecy keypair rotation on reconnect are mechanics the user never sees. + +--- + +## Source of truth + +`src/lib/livekitSession.ts`, `src/stores/voice.store.ts`, `src/lib/screenShare.ts`, +`src/lib/ptt.ts`, `src/lib/roomEventHandlers.ts`, `src/components/VoiceWidget.ts`, +`VoiceChannel.ts`, `VideoGrid.ts`, `src-tauri/src/livekit_proxy.rs`, +`src-tauri/src/ptt.rs`, `src/lib/e2eeCrypto.ts`; and the structural map in +[../voice-e2ee.md](../voice-e2ee.md).