docs: add client UX specification (target-state flows + per-view states)

Adds docs/architecture/ux/ — a prescriptive (to-be) behavior spec for the
Tauri client, complementing the as-built module map in
docs/architecture/client.md. Covers every view and how it should react to
server events, permission state, and failure:

  - README.md            — view-state vocabulary, feedback primitives,
                           connection-status contract, the global
                           event->reaction map, and the error/permission
                           reaction matrix
  - connection-and-auth  — boot, profiles/health, login, TOTP,
                           register-by-invite, connected handshake,
                           reconnect, cert-TOFU trust
  - messaging            — composer permission/connection gating, optimistic
                           send lifecycle, edit/delete, reactions,
                           attachments, pins, search, read/unread, slow-mode
  - channels-members-dms — channel list/switch/categories, member list +
                           presence + typing, DM open/close, blocking
  - voice-and-e2ee       — join/leave, mute/deafen/camera/screenshare, PTT,
                           active-speaker, and the E2EE securing/secured
                           indicators
  - settings-and-admin   — settings tabs, profile/password/2FA/delete,
                           theming, inline admin (ban/kick/roles, channel
                           CRUD, invites), updater

Each flow carries dated "Current gap" callouts where today's code diverges
from the target (grounded in file:line references), so the set doubles as a
UX improvement backlog. Notable gaps captured: non-optimistic send with a
dead pending-send path, no composer read-only/permission gating (incl.
announcement channels), silently-dropped WS error codes, no E2EE "securing"
indicator, no updater download progress, client-local logout that never
revokes the server session, and a duplicated role store.

All 12 Mermaid diagrams validated; intra-repo links checked. Indexed from
docs/architecture/README.md and the top-level Docs Index.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
This commit is contained in:
Claude
2026-07-19 16:58:43 +00:00
parent 719c910330
commit 0c86264b8c
8 changed files with 1171 additions and 1 deletions
+1
View File
@@ -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)
+10 -1
View File
@@ -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 001015, 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
+175
View File
@@ -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.
@@ -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`.
+249
View File
@@ -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`.
+227
View File
@@ -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`.
+193
View File
@@ -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`.
+152
View File
@@ -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).