7.8 KiB
Channels, Members & Direct Messages — target UX
Verified against: commit da4acc5, 2026-07-19
Part of the Client UX Specification.
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) |
voice |
speaker | Join voice (see voice-and-e2ee.md); shows the participant roster inline |
dm |
— | Not in the channel list; lives in DM mode |
1.2 Channel switching
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), 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. 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. Actions the user lacks permission for are not shown (menu items gated by the actor's role), consistent with the affordance principle.
✓ Resolved 2026-07-20 — single role store. Roles live only in
channels.store(roles/getRoleIdByName), the store the dispatcher writes onready(dispatcher.ts).SidebarMemberSection.tsnow reads from it, and the parallel, never-updatedroles.storehas been deleted — so the member context menu can no longer mis-map a role name→id from stale data.
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
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 |
✅ Wired (composer gating). DM block state now drives the same disabled-with-reason composer mode (see messaging.md §2) via
blocks.store.blockedByMeis loaded authoritatively fromGET /blockson everyready(dispatcher.ts) → the explicit "You've blocked this user…" reason.blockedByThemis inferred from a refused DM send (ErrBlocked→FORBIDDEN, bidirectional) → the neutral "You can't message this user right now." reason, and is cleared on the nextreadyso a reconnect re-evaluates.ChannelControllerreadsdmComposerBlockReason(recipientId)and subscribes toblocks.store, so an unblock (shrunkenGET /blocks) re-enables the composer live.blockedByMetakes precedence when both directions apply.Remaining gap. There is no in-client block button yet (the block/unblock REST surface exists server-side; blocks made from the web panel or a prior session are honoured via
GET /blocks). Adding the block affordance to the DM profile sidebar would callPUT/DELETE /blocks/{userId}and updateblocks.storedirectly for an instant local un-gate.
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, src/lib/dispatcher.ts;
server Server/service/channel.go, dm.go, block.go.