Update room list documentation (#34359)

This commit is contained in:
Florian Duros
2026-08-10 14:19:19 +00:00
committed by GitHub
parent fef1f0e0dd
commit 3a7be0d3a9
4 changed files with 215 additions and 166 deletions
+1 -1
View File
@@ -158,7 +158,7 @@ export default withMermaid({
{ text: "Iconography", link: "/icons" },
{ text: "Local echo", link: "/local-echo-dev" },
{ text: "Media", link: "/media-handling" },
{ text: "Room List Store", link: "/room-list-store" },
{ text: "Room List", link: "/room-list" },
{ text: "Scrolling", link: "/scrolling" },
{ text: "Usercontent", link: "/usercontent" },
{ text: "Widget layouts", link: "/widget-layouts" },
Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

-165
View File
@@ -1,165 +0,0 @@
# Room list sorting
It's so complicated it needs its own README.
![](img/RoomListStore2.png)
Legend:
- Orange = External event.
- Purple = Deterministic flow.
- Green = Algorithm definition.
- Red = Exit condition/point.
- Blue = Process definition.
## Algorithms involved
There's two main kinds of algorithms involved in the room list store: list ordering and tag sorting.
Throughout the code an intentional decision has been made to call them the List Algorithm and Sorting
Algorithm respectively. The list algorithm determines the primary ordering of a given tag whereas the
tag sorting defines how rooms within that tag get sorted, at the discretion of the list ordering.
Behaviour of the overall room list (sticky rooms, etc) are determined by the generically-named Algorithm
class. Here is where much of the coordination from the room list store is done to figure out which list
algorithm to call, instead of having all the logic in the room list store itself.
Tag sorting is effectively the comparator supplied to the list algorithm. This gives the list algorithm
the power to decide when and how to apply the tag sorting, if at all. For example, the importance algorithm,
later described in this document, heavily uses the list ordering behaviour to break the tag into categories.
Each category then gets sorted by the appropriate tag sorting algorithm.
### Tag sorting algorithm: Alphabetical
When used, rooms in a given tag will be sorted alphabetically, where the alphabet's order is a problem
for the browser. All we do is a simple string comparison and expect the browser to return something
useful.
### Tag sorting algorithm: Manual
Manual sorting makes use of the `order` property present on all tags for a room, per the
[Matrix specification](https://matrix.org/docs/spec/client_server/r0.6.0#room-tagging). Smaller values
of `order` cause rooms to appear closer to the top of the list.
### Tag sorting algorithm: Recent
Rooms get ordered by the timestamp of the most recent useful message. Usefulness is yet another algorithm
in the room list system which determines whether an event type is capable of bubbling up in the room list.
Normally events like room messages, stickers, and room security changes will be considered useful enough
to cause a shift in time.
Note that this is reliant on the event timestamps of the most recent message. Because Matrix is eventually
consistent this means that from time to time a room might plummet or skyrocket across the tag due to the
timestamp contained within the event (generated server-side by the sender's server).
### List ordering algorithm: Natural
This is the easiest of the algorithms to understand because it does essentially nothing. It imposes no
behavioural changes over the tag sorting algorithm and is by far the simplest way to order a room list.
Historically, it's been the only option in Element and extremely common in most chat applications due to
its relative deterministic behaviour.
### List ordering algorithm: Importance
On the other end of the spectrum, this is the most complicated algorithm which exists. There's major
behavioural changes, and the tag sorting algorithm gets selectively applied depending on circumstances.
Each tag which is not manually ordered gets split into 4 sections or "categories". Manually ordered tags
simply get the manual sorting algorithm applied to them with no further involvement from the importance
algorithm. There are 4 categories: Red, Grey, Bold, and Idle. Each has their own definition based off
relative (perceived) importance to the user:
- **Red**: The room has unread mentions waiting for the user.
- **Grey**: The room has unread notifications waiting for the user. Notifications are simply unread
messages which cause a push notification or badge count. Typically, this is the default as rooms get
set to 'All Messages'.
- **Bold**: The room has unread messages waiting for the user. Essentially this is a grey room without
a badge/notification count (or 'Mentions Only'/'Muted').
- **Idle**: No useful (see definition of useful above) activity has occurred in the room since the user
last read it.
Conveniently, each tag gets ordered by those categories as presented: red rooms appear above grey, grey
above bold, etc.
Once the algorithm has determined which rooms belong in which categories, the tag sorting algorithm
gets applied to each category in a sub-list fashion. This should result in the red rooms (for example)
being sorted alphabetically amongst each other as well as the grey rooms sorted amongst each other, but
collectively the tag will be sorted into categories with red being at the top.
## Sticky rooms
When the user visits a room, that room becomes 'sticky' in the list, regardless of ordering algorithm.
From a code perspective, the underlying algorithm is not aware of a sticky room and instead the base class
manages which room is sticky. This is to ensure that all algorithms handle it the same.
The sticky flag is simply to say it will not move higher or lower down the list while it is active. For
example, if using the importance algorithm, the room would naturally become idle once viewed and thus
would normally fly down the list out of sight. The sticky room concept instead holds it in place, never
letting it fly down until the user moves to another room.
Only one room can be sticky at a time. Room updates around the sticky room will still hold the sticky
room in place. The best example of this is the importance algorithm: if the user has 3 red rooms and
selects the middle room, they will see exactly one room above their selection at all times. If they
receive another notification which causes the room to move into the topmost position, the room that was
above the sticky room will move underneath to allow for the new room to take the top slot, maintaining
the sticky room's position.
Though only applicable to the importance algorithm, the sticky room is not aware of category boundaries
and thus the user can see a shift in what kinds of rooms move around their selection. An example would
be the user having 4 red rooms, the user selecting the third room (leaving 2 above it), and then having
the rooms above it read on another device. This would result in 1 red room and 1 other kind of room
above the sticky room as it will try to maintain 2 rooms above the sticky room.
An exception for the sticky room placement is when there's suddenly not enough rooms to maintain the placement
exactly. This typically happens if the user selects a room and leaves enough rooms where it cannot maintain
the N required rooms above the sticky room. In this case, the sticky room will simply decrease N as needed.
The N value will never increase while selection remains unchanged: adding a bunch of rooms after having
put the sticky room in a position where it's had to decrease N will not increase N.
## Responsibilities of the store
The store is responsible for the ordering, upkeep, and tracking of all rooms. The room list component simply gets
an object containing the tags it needs to worry about and the rooms within. The room list component will
decide which tags need rendering (as it commonly filters out empty tags in most cases), and will deal with
all kinds of filtering.
## Filtering
Filters are provided to the store as condition classes and have two major kinds: Prefilters and Runtime.
Prefilters flush out rooms which shouldn't appear to the algorithm implementations. Typically this is
due to some higher order room list filtering (such as spaces or tags) deliberately exposing a subset of
rooms to the user. The algorithm implementations will not see a room being prefiltered out.
Runtime filters are used for more dynamic filtering, such as the user filtering by room name. These
filters are passed along to the algorithm implementations where those implementations decide how and
when to apply the filter. In practice, the base `Algorithm` class ends up doing the heavy lifting for
optimization reasons.
The results of runtime filters get cached to avoid needlessly iterating over potentially thousands of
rooms, as the old room list store does. When a filter condition changes, it emits an update which (in this
case) the `Algorithm` class will pick up and act accordingly. Typically, this also means filtering a
minor subset where possible to avoid over-iterating rooms.
All filter conditions are considered "stable" by the consumers, meaning that the consumer does not
expect a change in the condition unless the condition says it has changed. This is intentional to
maintain the caching behaviour described above.
One might ask why we don't just use prefilter conditions for everything, and the answer is one of slight
subtlety: in the cases of prefilters we are knowingly exposing the user to a workspace-style UX where
room notifications are self-contained within that workspace. Runtime filters tend to not want to affect
visible notification counts (as it doesn't want the room header to suddenly be confusing to the user as
they type), and occasionally UX like "found 2/12 rooms" is desirable. If prefiltering were used instead,
the notification counts would vary while the user was typing and "found 2/12" UX would not be possible.
## Class breakdowns
The `RoomListStore` is the major coordinator of various algorithm implementations, which take care
of the various `ListAlgorithm` and `SortingAlgorithm` options. The `Algorithm` class is responsible
for figuring out which tags get which rooms, as Matrix specifies them as a reverse map: tags get
defined on rooms and are not defined as a collection of rooms (unlike how they are presented to the
user). Various list-specific utilities are also included, though they are expected to move somewhere
more general when needed. For example, the `membership` utilities could easily be moved elsewhere
as needed.
The various bits throughout the room list store should also have jsdoc of some kind to help describe
what they do and how they work.
+214
View File
@@ -0,0 +1,214 @@
# 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.