12 KiB
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 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 inapps/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/. - Views — the presentational React components, owned by
@element-hq/web-shared-components.
Architecture
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: 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 RoomNodes 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 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, andRoomTagged(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) andresort.
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.favouriteandm.lowprioritytags 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.
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.showSectionssetting is disabled, the store skips sectioning altogether andgetSortedRoomsInActiveSpace()returns a singlechatssection containing every room in the active space. - Even with sectioning enabled,
RoomListViewModeldiscards 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/
extend BaseViewModel and produce immutable snapshots consumed by the views (see MVVM
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.
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, 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). 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); 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.