Skip to content

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:

  • ModelRoomListStoreV3, 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/.
  • 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

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, 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.

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/ 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.