215 lines
12 KiB
Markdown
215 lines
12 KiB
Markdown
# 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.
|