diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 79be407b944..7af3aa151dd 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -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" }, diff --git a/docs/img/RoomListStore2.png b/docs/img/RoomListStore2.png deleted file mode 100644 index 9952d1c910f..00000000000 Binary files a/docs/img/RoomListStore2.png and /dev/null differ diff --git a/docs/room-list-store.md b/docs/room-list-store.md deleted file mode 100644 index 4e131ee309d..00000000000 --- a/docs/room-list-store.md +++ /dev/null @@ -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. diff --git a/docs/room-list.md b/docs/room-list.md new file mode 100644 index 00000000000..57e47755548 --- /dev/null +++ b/docs/room-list.md @@ -0,0 +1,198 @@ +# 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"] --> children + + subgraph model["Model"] + store["RoomListStoreV3
(owns the skip list)"] + end + + subgraph vms["View models"] + rlvm["RoomListViewModel"] + children["RoomListItemViewModel
RoomListSectionHeaderViewModel"] + siblings["RoomListHeaderViewModel
RoomListSearchViewModel"] + end + + subgraph views["Views"] + rlview["RoomListView
VirtualizedRoomListView"] + rowviews["RoomListItemView
RoomListSectionHeaderView"] + sibviews["RoomListHeaderView
RoomListSearchView"] + end + + store -->|"events"| rlvm + rlvm -->|"creates & owns"| children + rlvm -->|"snapshot"| rlview + rlview -->|"renders"| rowviews + children -->|"snapshot"| rowviews + siblings -->|"snapshot"| sibviews +``` + +The key thing the diagram captures: view models subscribe to the **store**, never to the skip +list — the skip list is an internal detail of `RoomListStoreV3`. And the per-room and +per-section view models get their data from fine-grained domain stores directly, not from the +room-list store's update events. + +## 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 make insertion +`O(log n)` on average. 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 +skips nodes outside the space or not matching the requested filters. When a room changes it is +re-inserted (re-sorted into place) and the rest of the list is untouched. + +**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. + +**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, 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.`, 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. + +### 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` treats the result as flat whenever the only + section left is the Chats catch-all (`isFlatList`) — 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 "unread activity below the fold" toast, and section drag-and-drop. +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 — +`RoomNotificationStateStore`, `MessagePreviewStore`, `CallStore`, room events, and settings — so +a single row can update independently of the rest of the list. The section header view model is +_fed_ its set of rooms imperatively by the parent and aggregates 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 receives things like the +collapse-all-sections state from the root view model through the global dispatcher. + +Shared permission and create-room helpers used across these 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 and a `renderAvatar` callback, and the shared +package owns all 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 delegates to `VirtualizedRoomListView` (built on +[`react-virtuoso`](https://virtuoso.dev/)). Virtualization is what 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's `changeSectionOrder` and `changeRoomSection` +actions.