Files
ThreadNet-Web/docs/room-list.md
T

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

215 lines
12 KiB
Markdown
Raw Normal View History

2026-08-10 16:19:19 +02:00
# Room list
The room list is the sorted, filtered list of rooms in the left-hand navigation panel, always
scoped to the active space. It is a full [MVVM](MVVM.md) feature split across three layers:
- **Model** — `RoomListStoreV3`, the store that keeps every room sorted and answers queries for
the rooms in the active space. It lives in
[`apps/web/src/stores/room-list-v3/`](https://github.com/element-hq/element-web/tree/develop/apps/web/src/stores/room-list-v3/).
- **View models** — adapt the store (and other stores) into snapshots for the UI. They live in
[`apps/web/src/viewmodels/room-list/`](https://github.com/element-hq/element-web/tree/develop/apps/web/src/viewmodels/room-list/).
- **Views** — the presentational React components, owned by `@element-hq/web-shared-components`.
## Architecture
```mermaid
flowchart TD
events["Matrix events"] --> store
otherStores["Notification / preview / call stores, room events"] --> rlvm
otherStores --> itemVM
otherStores --> sectionHeaderVM
subgraph model["Model"]
store["RoomListStoreV3(owns the skip list)"]
end
subgraph vms["View models"]
rlvm["RoomListViewModel"]
itemVM["RoomListItemViewModel"]
sectionHeaderVM["RoomListSectionHeaderViewModel"]
headerVM["RoomListHeaderViewModel"]
searchVM["RoomListSearchViewModel"]
end
subgraph views["Views (Shared components)"]
rlview["RoomListView"]
virtualizedView["VirtualizedRoomListView"]
itemView["RoomListItemView"]
sectionHeaderView["RoomListSectionHeaderView"]
headerView["RoomListHeaderView"]
searchView["RoomListSearchView"]
end
rlvm -->|"consume events"| store
rlvm -->|"creates"| itemVM
rlvm -->|"creates"| sectionHeaderVM
rlview -->|"snapshot"| rlvm
rlview -->|"renders"| virtualizedView
virtualizedView -->|"renders"| itemView
virtualizedView -->|"renders"| sectionHeaderView
virtualizedView -->|"snapshot"| rlvm
itemView -->|"snapshot"| itemVM
sectionHeaderView -->|"snapshot"| sectionHeaderVM
headerView -->|"snapshot"| headerVM
searchView -->|"snapshot"| searchVM
```
## Model — the store
`RoomListStoreV3` (the "V3" is the third implementation) is a singleton exposed as
`RoomListStoreV3.instance`. Unlike the previous implementations, which re-computed lists on
demand, V3 keeps every room permanently sorted so retrieval is cheap.
**The skip list.** The core data structure is a
[skip list](https://en.wikipedia.org/wiki/Skip_list): stacked linked lists that keep every room
permanently sorted, so a room that changes is simply re-inserted into place and the rest of the
list is untouched. Rooms are wrapped in `RoomNode`s that cache whether the room is in the active
space and which filters it matches, so reading the list is just an ordered walk that drops the
nodes outside the space or not matching the requested filters.
**Sorters.** A sorter defines the order of the list. There are three: **Alphabetic** (by room
name), **Recency** (most recent meaningful activity, with low-priority and muted rooms forced to
the bottom), and **Unread** (by unread importance). The user's choice is persisted and can be
changed at runtime, which rebuilds the list.
**Filters.** A filter answers a single yes/no question about a room, and each room's matching
filters are precomputed on its node. Filters serve two roles: **capability filters** back the
primary filter chips in the UI (Unread, People, Rooms, Favourites, Mentions, Invites, Low
Priority), and **section-tag filters** decide which section a room belongs to. Not all of the
capability filters are offered as chips at all times: while sectioning is enabled, Favourites and
Low Priority are hidden, since those rooms already have their own sections.
**Sections.** The store groups rooms into named sections (Favourites, Low Priority, Chats, and
user-created custom sections) using the section-tag filters described above. Sections are a topic
of their own — see [Sections](#sections) below.
**Spaces and updates.** The list is always scoped to the active space; rather than re-filter on
every read, each node caches its active-space membership and the store recomputes that flag when
the space changes. The store listens for the Matrix events that affect ordering or membership
(receipts, tag changes, account data, push rules, decryption, timeline events, membership) and
responds by re-inserting or removing the single affected room. Emissions to consumers are
coalesced with `requestAnimationFrame`, so a burst of updates within a frame collapses into one
notification.
**Public surface.** This is the seam the view models bind to:
- Events: `ListsUpdate` (lists changed), `ListsLoaded` (initial load done), `SectionCreated`,
and `RoomTagged` (a locally-initiated tag change, which drives the "chat moved" toast).
- `getSortedRoomsInActiveSpace(filterKeys?)` returns the sorted, filtered rooms grouped into
sections. Narrower helpers exist too (DM rooms, server-notice rooms, the full sorted list).
- Section mutators (`createSection` / `editSection` / `removeSection` / `reorderSection`) and
`resort`.
Note that **sticky-room behaviour lives in the view model, not the store** — the store itself is
unaware of a selected room.
## Sections
Sections are the named groups the room list displays. Membership is driven entirely by the Matrix
`m.tag` account data on each room, so moving a room between sections is just a matter of changing
its tags:
- **Favourites** and **Low Priority** use the default `m.favourite` and `m.lowpriority` tags and
are pinned to the top and bottom of the list respectively.
- **Chats** is a synthetic catch-all section (tag `chats`) for every room that isn't in any other
explicit section.
- **Custom sections** are user-created. Each is identified by a generated tag of the form
`element.io.section.<uuid>`, and a room is placed in it by tagging the room with that tag.
The store keeps an ordered list of section tags — Favourites first, Low Priority last, and the
custom sections plus Chats reorderable in between (new custom sections are inserted just above
Chats by default). Custom sections can be created, renamed, removed, and reordered through
dialogs; that logic lives in [`section.ts`](https://github.com/element-hq/element-web/blob/develop/apps/web/src/stores/room-list-v3/section.ts).
Each custom section also records the space it was created in, which controls the visibility of
empty sections: an empty custom section is only shown in the space it belongs to, so sections
created in one space don't clutter unrelated spaces. Legacy sections without a stored space (or
whose space no longer exists) fall back to the Home meta-space. Note that this visibility rule is
applied by `RoomListViewModel` when it builds its snapshot, not by the store.
### Flat list mode
When sectioning is turned off, the room list becomes a single flat list instead of a grouped one.
This is triggered in two places:
- If the `RoomList.showSections` setting is disabled, the store skips sectioning altogether and
`getSortedRoomsInActiveSpace()` returns a single `chats` section containing every room in the
active space.
- Even with sectioning enabled, `RoomListViewModel` discards the sections it shouldn't render (the
empty ones, and custom sections belonging to another space) and treats what remains as flat
(`isFlatList`) if it is nothing at all, or the Chats catch-all on its own — for example when the
user has no favourite, low-priority, or custom-tagged rooms.
In flat mode the view renders a `FlatVirtualizedList` (no section headers); otherwise it renders a
`GroupedVirtualizedList` with headers and section drag-and-drop.
### Settings
Sections are configured entirely through settings:
- **`RoomList.showSections`** — whether sectioning is enabled at all. When off, the list is flat
(see above).
- **`RoomList.CustomSectionData`** (account level) — the custom-section definitions, keyed by tag.
Each entry stores the tag, the user-chosen name, and the space the section was created in.
Malformed entries are dropped when the data is read.
- **`RoomList.OrderedCustomSections`** (account level) — the display order of the reorderable
sections (the custom sections and Chats). Favourites and Low Priority are not stored here since
they are always pinned to the top and bottom.
- **`RoomList.SectionExpansionState`** (device level) — the expanded/collapsed state of each
section, stored per space and then per section tag. Sections default to expanded when no state
has been persisted.
The account-level settings sync across a user's devices, while the expansion state is
device-local so that collapsing a section on one device doesn't affect the others.
## View models
The view models in [`apps/web/src/viewmodels/room-list/`](https://github.com/element-hq/element-web/tree/develop/apps/web/src/viewmodels/room-list/)
extend `BaseViewModel` and produce immutable snapshots consumed by the views (see [MVVM](MVVM.md)
for the base pattern).
**`RoomListViewModel`** is the root orchestrator and the **only** view model that subscribes to
the store's list events and calls `getSortedRoomsInActiveSpace()`. It builds the top-level
snapshot — whose sections carry only **room IDs**, not room objects — and owns the sticky-room
logic, the active filter, the toasts, section drag-and-drop, and keyboard navigation. It also
lazily creates and owns the child view models.
**`RoomListItemViewModel`** (one per room) and **`RoomListSectionHeaderViewModel`** (one per
section) are created on demand by the root view model. Crucially, they do **not** listen to the
store's list events; instead they subscribe to fine-grained domain stores directly —
notification state, message previews, calls, room events and settings — so a single row can
update independently of the rest of the list. The section header view model draws on a narrower
set of those, and is _fed_ its set of rooms imperatively by the parent so it can aggregate their
notification state.
**`RoomListHeaderViewModel`** (the header bar: space title, sort menu, create actions) and
**`RoomListSearchViewModel`** (the search/dial/explore row) are decoupled siblings of the root
view model. They do not share state directly; the header view model and the root view model talk
through the global dispatcher, in both directions — collapse-all-sections, for instance, is
requested by the header and the resulting state dispatched back to it.
Permission and create-room helpers used by these view models live in
[`utils.ts`](https://github.com/element-hq/element-web/blob/develop/apps/web/src/viewmodels/room-list/utils.ts).
## Views
The presentational components are owned by `@element-hq/web-shared-components` (developed in
Storybook); `apps/web` supplies the view models, a `renderAvatar` callback and a key handler for
landmark navigation, and the shared package owns the rendering. The app-side entry point is
[`RoomListPanel`](https://github.com/element-hq/element-web/blob/develop/apps/web/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx), which
composes the search row, the header view, and the room list itself.
The room list proper is `RoomListView`, which renders the filter chips and any toast above a body
that is a loading skeleton, an empty state, or — in the usual case — `VirtualizedRoomListView`
(built on [`react-virtuoso`](https://virtuoso.dev/)). Virtualization drives the lazy lifecycle of
the child view models: as rows scroll into view the list calls back through
`getRoomItemViewModel(id)` and `getSectionHeaderViewModel(tag)` to obtain the view model for each
rendered room or header, and reports its visible range so off-screen item view models can be
disposed. Because each row binds to its own view model, it re-renders on its own data without
re-rendering the whole list.
The list renders either as a flat list or a grouped list with section headers, depending on the
`isFlatList` flag in the snapshot (see [Flat list mode](#flat-list-mode)); in the grouped case,
drag-and-drop is wired back to the root view model, which reorders sections, moves rooms between
them, and collapses the sections for the duration of a section drag.