Fixes from an adversarial review of the previous commit:
- MainPage banner: sync the banner with the current store status at mount.
The selector subscription baselines on the current value and only fires
on change, so a MainPage mounted mid-outage (status already
"reconnecting") would never show the banner — the whole retry cycle maps
to the same 3-state value. The status→banner dispatch is extracted to
ServerBanner.applyConnectionStatus and unit-tested.
- History-fetch failure is no longer silent when the channel already has
rows (live broadcasts / optimistic sends): the inline error region only
renders in an empty channel, so loadMessages now also raises a toast in
that case.
- Composer disable reason distinguishes "Reconnecting…" from
"Not connected" per the spec §3 table (it previously showed
"Reconnecting…" while disconnected, contradicting the banner).
- The single-writer wiring is extracted to
dispatcher.wireConnectionStatus(ws) and pinned by a test (it was
previously an untestable main.ts module-scope line — deleting it would
have failed zero tests).
- Docs honesty: messaging.md's transport-drop diagram arm now shows both
codes (channel full → NETWORK, closed/not-open → OFFLINE) instead of
claiming NETWORK for both; README §3's callout now explicitly lists the
voice column ("frozen" during reconnect) as a remaining gap instead of
implying the section is fully closed; the composer table documents both
offline reasons.
- New pinning tests: SidebarArea passes ws to UserBar (the production-bug
fix was previously unasserted), ServerBanner.showDisconnected,
applyConnectionStatus mapping, ChannelController onRetryLoad /
onRetry-resend / onDeleteDraft, composer reason per status, and the
history-failure toast fallback.
Verified: tsc + full client unit suite (3234 tests) + oxlint/eslint +
prettier all green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
12 KiB
Messaging — target UX
Verified against: commit da4acc5, 2026-07-19
Part of the Client UX Specification. Shared vocabulary and the error
matrix live in the README.
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 |
✓ Implemented (2026-07).
messages.storetracks a per-channelhistoryLoadState(loading/error, absent = idle);loadMessagessets it synchronously before the fetch andsetMessagesclears it. With no rows,MessageListrenders the matching region state: a.messages-loadingspinner placeholder, or.messages-load-errorwith an inline Retry button (onRetryLoadre-invokesloadMessages) — no toast. The welcome/empty state renders only once the channel is actually loaded and empty.
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.
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…" while retrying, "Not connected" when disconnected | 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 |
✓ Implemented (2026-07). The server sends an authoritative per-channel
can_sendin the ready payload (ws/serve.gochannelCanSend, mirroringMessageService.checkSendPermission: READ|SEND, plus MANAGE_MESSAGES for announcement, admin bypass, channel overrides).channels.storecarries it asChannel.canSend;MessageInput.setDisabled(reason)disables the composer with a visible reason, andChannelControllerderives that reason fromcan_send+ channel type + connection status. Older servers that omitcan_senddefault permissive. Remaining: slow-mode countdown (see §8) and DM block-state gating (handled today via the failed-row path in §3).
3. Sending — optimistic lifecycle
Target: send is optimistic. On submit, the message renders immediately in a
pending state, then reconciles against the server.
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, code) %% channel full → "NETWORK"; closed/not-open → "OFFLINE"
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.
✓ Implemented (2026-07).
messages.storenow hasaddOptimisticMessage(pending row),confirmSend(stamps the real id + "sent" on thechat_send_okack),markSendFailed, andremoveOptimistic;addMessagereconciles the broadcast by real id (idempotent, replay-safe) with a defensive author match.ChannelController.performSendrenders the pending row andMessageListshows 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'serrorhandler mapsSLOW_MODE/FORBIDDEN/RATE_LIMITED/BAD_REQUESTto the exact row (dispatcher.ts), and an offline send is shown failed rather than dropped. The transport-drop arm is wired too:ws.tsnotifiesonSendFailure(id, code)whenws_sendfails locally (channel full →NETWORK, closed/not-open →OFFLINE), and the dispatcher fails the matching pending row — fire-and-forget sends (typing, presence) have no pending entry and stay silent by design.
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_updateecho (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). ✓ Implemented (2026-07):
uploadFile now honors the global 401 handler like every other call — a 401
calls onUnauthorized (clearAuth → connect page with "Your session expired —
sign in again.") and throws ApiClientError(401).
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.
Partially implemented (2026-07).
SLOW_MODEerrors 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'sslow_modeseconds, which the ready payload does not yet carry.
Note — effective per-channel permissions (resolved)
§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.
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.