mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
Merge pull request #1189 from J3vb/claude/blueprints-architectural-audit-k927qb
docs: client UX specification (target-state flows + per-view state maps)
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/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)
|
||||
|
||||
|
||||
@@ -16,7 +16,16 @@ natively) followed by a prose explanation and a **Source of truth** file list.
|
||||
| [websocket.md](websocket.md) | D4 WS connect / replay / dispatch | Real-time engine: auth handshake, 3-tier reconnect replay, backpressure, V1/V2 dispatch |
|
||||
| [data-model.md](data-model.md) | D5 Entity-relationship overview | All 23 tables from migrations 001–015, grouped by domain |
|
||||
| [voice-e2ee.md](voice-e2ee.md) | D6 Voice + E2EE flow | LiveKit token flow, loopback TLS tunnel, ECDH key-holder relay |
|
||||
| [client.md](client.md) | D7 Client module map | Tauri client: bootstrap, dispatcher, stores, Rust sidecars |
|
||||
| [client.md](client.md) | D7 Client module map | Tauri client: bootstrap, dispatcher, stores, Rust sidecars (structure, as-built) |
|
||||
| [ux/](ux/README.md) | UX flow + state diagrams | Client **behavior** spec (target state): what every view does and how it reacts to events, permissions, and failure |
|
||||
|
||||
### Structure vs. behavior
|
||||
|
||||
[client.md](client.md) maps the client *as-built* (modules, stores, wiring). The
|
||||
[ux/](ux/README.md) set is the complementary *behavior* spec — prescriptive
|
||||
(to-be) flows for every view, with per-view state matrices and event→reaction
|
||||
maps. Where today's code diverges from the target, the UX docs carry dated
|
||||
**⚠ Current gap** callouts, so the set doubles as a UX improvement backlog.
|
||||
|
||||
## Maintenance rule
|
||||
|
||||
|
||||
@@ -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