mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -213,6 +213,7 @@ When rotating the server updater key, update [Server/updater/server_update_publi
|
|||||||
- [docs/protocol.md](docs/protocol.md)
|
- [docs/protocol.md](docs/protocol.md)
|
||||||
- [docs/schema.md](docs/schema.md)
|
- [docs/schema.md](docs/schema.md)
|
||||||
- [docs/architecture/client.md](docs/architecture/client.md) — client architecture (replaces client-architecture.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/contributing.md](docs/contributing.md)
|
||||||
- [docs/security.md](docs/security.md)
|
- [docs/security.md](docs/security.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 |
|
| [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 |
|
| [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 |
|
| [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
|
## Maintenance rule
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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`.
|
||||||
@@ -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`.
|
||||||
@@ -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`.
|
||||||
@@ -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).
|
||||||
Reference in New Issue
Block a user