mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
feat(client): optimistic message send + composer permission gating
Implements the two highest-impact gaps from the client UX spec. Optimistic send: - messages.store gains addOptimisticMessage / markSendFailed / removeOptimistic, and confirmSend now stamps the real id + "sent" on the ack. addMessage reconciles the broadcast by real id (idempotent, replay-safe) with a defensive author match, so an echo never duplicates. Message gains status/correlationId/errorCode. - ChannelController.performSend renders a pending row immediately and supports retry / delete-draft (retry preserves attachments). - MessageList renders pending (dimmed) and failed (reason + Retry / Delete) rows; the hover action bar is limited to confirmed rows. - Failures are precise: the server echoes the request id on error replies (buildErrorMsgWithID), so the dispatcher maps SLOW_MODE / FORBIDDEN / RATE_LIMITED / BAD_REQUEST to the exact row instead of dropping the code. An offline send is shown failed, not silently lost. Composer permission + connection gating: - The server computes an authoritative per-channel can_send in the ready payload (channelCanSend mirrors MessageService.checkSendPermission: READ|SEND, MANAGE_MESSAGES for announcement, admin bypass, channel overrides). channels.store carries it as Channel.canSend. - MessageInput gains a disabled-with-reason mode; ChannelController derives the reason from can_send + channel type + connection status and disables the composer reactively (announcement read-only, no-permission, reconnecting) rather than accepting a click and failing. Older servers that omit can_send default permissive. Docs: the corresponding "Current gap" callouts in docs/architecture/ux are updated to reflect the implementation. Verified: full server suite + client tsc + 3204 unit tests + lint + gofmt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
This commit is contained in:
@@ -122,11 +122,12 @@ detail each; this is the index.
|
||||
| `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).
|
||||
> **✓ Implemented (2026-07).** Error codes are no longer silently dropped for
|
||||
> sends: the server echoes the request id on error replies, so `SLOW_MODE`,
|
||||
> `FORBIDDEN`, `RATE_LIMITED`, `BAD_REQUEST`, etc. are mapped to the exact
|
||||
> optimistic row that failed (retry offered), and `chat_send_ok`'s
|
||||
> `message_id`/`timestamp` now reconcile the pending row. See the optimistic
|
||||
> lifecycle in [messaging.md](messaging.md).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -61,18 +61,15 @@ stateDiagram-v2
|
||||
| `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.
|
||||
> **✓ Implemented (2026-07).** The server sends an authoritative per-channel
|
||||
> `can_send` in the ready payload (`ws/serve.go` `channelCanSend`, mirroring
|
||||
> `MessageService.checkSendPermission`: READ|SEND, plus MANAGE_MESSAGES for
|
||||
> announcement, admin bypass, channel overrides). `channels.store` carries it as
|
||||
> `Channel.canSend`; `MessageInput.setDisabled(reason)` disables the composer
|
||||
> with a visible reason, and `ChannelController` derives that reason from
|
||||
> `can_send` + channel type + connection status. Older servers that omit
|
||||
> `can_send` default permissive. Remaining: slow-mode countdown (see §8) and DM
|
||||
> block-state gating (handled today via the failed-row path in §3).
|
||||
|
||||
---
|
||||
|
||||
@@ -115,17 +112,16 @@ sequenceDiagram
|
||||
`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.
|
||||
> **✓ Implemented (2026-07).** `messages.store` now has `addOptimisticMessage`
|
||||
> (pending row), `confirmSend` (stamps the real id + "sent" on the `chat_send_ok`
|
||||
> ack), `markSendFailed`, and `removeOptimistic`; `addMessage` reconciles the
|
||||
> broadcast by real id (idempotent, replay-safe) with a defensive author match.
|
||||
> `ChannelController.performSend` renders the pending row and `MessageList`
|
||||
> shows pending (dimmed) and failed (reason + **Retry** / **Delete**) states.
|
||||
> Failures are precise: the server now echoes the request id on error replies
|
||||
> (`ws/handlers.go` → `buildErrorMsgWithID`), so the dispatcher's `error` handler
|
||||
> maps `SLOW_MODE` / `FORBIDDEN` / `RATE_LIMITED` / `BAD_REQUEST` to the exact
|
||||
> row (`dispatcher.ts`), and an offline send is shown failed rather than dropped.
|
||||
|
||||
---
|
||||
|
||||
@@ -196,24 +192,25 @@ 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.
|
||||
> **Partially implemented (2026-07).** `SLOW_MODE` errors are now surfaced: they
|
||||
> mark the optimistic row failed with a "Slow mode — wait before sending again"
|
||||
> reason and a **Retry** (via the request-id error correlation in §3). The live
|
||||
> **countdown** in the composer is still outstanding — it needs the channel's
|
||||
> `slow_mode` seconds, which the ready payload does not yet carry.
|
||||
|
||||
---
|
||||
|
||||
## Note — the client needs effective per-channel permissions
|
||||
## Note — effective per-channel permissions (resolved)
|
||||
|
||||
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.
|
||||
§2's composer gating needs the user's **effective permission on the active
|
||||
channel** (base role ± channel overrides, with the announcement MANAGE_MESSAGES
|
||||
rule). This was resolved by **option (a)**: the server sends an authoritative
|
||||
per-channel `can_send` in the ready payload, computed by `channelCanSend`
|
||||
(`ws/serve.go`) as a mirror of `MessageService.checkSendPermission`. The client
|
||||
consumes it directly rather than re-deriving permission math, so overrides and
|
||||
the announcement rule are always correct. The delete affordance (§4) remains
|
||||
role-name based; tightening it to effective per-channel permission could reuse
|
||||
the same signal in future.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user