Remove legacy room list (#34040)

* refactor(room-list): migrate SpaceStore off the legacy room list store

SpaceStore.setActiveRoomInSpace iterated the legacy RoomListStore's
`orderedLists` in `TAG_ORDER`; switch it to the space-aware
RoomListStoreV3.getSortedRoomsInActiveSpace() accessor. This drops the
last non-UI dependency on the legacy store and on `TAG_ORDER` (exported
from LegacyRoomList, deleted next).

* feat(room-list)!: remove the legacy room list UI

Delete the old sublist-based room list and its components now that the
new RoomListPanel is the default. Removed: LegacyRoomList,
LegacyRoomListHeader, RoomSublist, ExtraTile, RoomTile (+ Subtitle/
CallSummary), RoomBreadcrumbs and RoomSearch, plus their styles and
tests.

LeftPanel collapses to the RoomListPanel-only path. The shared
`contextMenuBelow` helper is relocated into RoomResultContextMenus (its
only remaining consumer).

* feat(room-list)!: remove the legacy RoomListStore

The legacy sublist-based room list UI is gone, so the old
`stores/room-list` store (Algorithm, sorters, filters, layout store,
space
watcher) has no remaining consumers. Delete the directory and its tests.

MatrixChat.forgetRoom no longer calls the legacy `manualRoomUpdate`; the
new room list store removes the room on the `AfterForgetRoom` dispatch
that still fires. Drop the `mxRoomListStore`/`mxRoomListLayoutStore`
globals and the now-dead test imports.

* feat(room-list)!: remove the feature_new_room_list labs flag

The new room list is now the only room list, so remove the
feature_new_room_list labs flag and make its enabled behaviour
unconditional everywhere it was gated:

- LoggedInView: always use the resizable layout and
NEW_ROOM_LIST_MIN_WIDTH;
  drop the collapsible/minimized legacy path.
- SpaceStore: People and Favourites are dropped from metaSpaceOrder (per
the
  long-standing TODO on the removed accessor).
- MessagePreviewStore: stop appending thread replies to previews.
- Settings, SidebarUserSettingsTab, PreferencesUserSettingsTab,
  QuickSettingsButton, SpacePanel, LandmarkNavigation: drop the flag
reads and
  legacy branches.

Update the tests that toggled the flag; the People/Favourites meta space
tests covered behaviour that the flag (default on) already disabled.

* feat(room-list)!: remove the dead legacy left-panel resizer

LoggedInView still built the old `Resizer`/`CollapseDistributor` over an
`lp-resizer` ResizeHandle and persisted `mx_lhs_size`. That handle is no
longer rendered (the resizable layout is now driven by
LeftResizablePanelView + ResizerViewModel, which persists its own state
via RoomList.panelSize/RoomList.isPanelCollapsed), so the old resizer
was
inert dead code left over from the legacy room list.

Remove createResizer/loadResizer/loadResizerPreferences, the
_resizeContainer/resizeHandler refs, the ResizeHandle render, the
mx_lhs_size handling and NEW_ROOM_LIST_MIN_WIDTH, plus the unit tests
that
exercised the mocked resizer.

* feat(room-list)!: update i18n files

* refactor(room-list): remove the now-unused collapseLhs state

`collapseLhs` is write-only since the left panel no longer collapses: it
was last read by LoggedInView's `shouldUseMinimizedUI`, removed with the
feature_new_room_list flag. Drop it from MatrixChat's IState (and its
assignments), collapsing the hide/show_left_panel handlers to just the
`notifyLeftHandleResized()` call they still need, and from
LoggedInView's
IProps and the test props.

* fix(room-list): instantiate message previewers lazily

Removing the unused SettingsStore import from MessagePreviewStore (when
the
feature_new_room_list flag was dropped) changed module load order and
exposed a latent circular dependency: ReactionEventPreview imports
MessagePreviewStore, which eagerly did `new ReactionEventPreview()` at
module-eval — so importing ReactionEventPreview first (as its unit test
does) hit "ReactionEventPreview is not a constructor".

Construct the previewers lazily on first use (cached) instead of at
module
load, so nothing dereferences a mid-evaluation module. Fixes
ReactionEventPreview-test.

* test(room-list): remove `feature_new_room_list` labs flag in e2e tests

* chore: remove remaining `newRoomList` flag

* chore: cleanup theme files

* fix: restore the re-resizable TouchEvent polyfill

* chore: remove usage of breadcrumbs settings in BreadcrumbStore

* Revert "fix(room-list): instantiate message previewers lazily"

This reverts commit 4e6eedfff0449c68a96c0470a4eb425b5aec5512.

* chore: remove unused function in BreadCrumbStore

* test: remove unused fuction of BreadcrumStore in tests

* test: add tests for RoomResultContextMenu
This commit is contained in:
Florian Duros
2026-07-02 09:07:37 +00:00
committed by GitHub
parent 522ec3aa57
commit b68ecdb860
103 changed files with 91 additions and 11086 deletions
+1 -6
View File
@@ -115,14 +115,9 @@ export function setupMacosTitleBar(window: BrowserWindow): void {
.mx_LeftPanel::before {
content: "";
height: 20px;
-webkit-app-region: drag;
}
.mx_LeftPanel_newRoomList::before {
/* Aligned with the room header */
height: 13px;
border-right: 1px solid var(--cpd-color-bg-subtle-primary);
-webkit-app-region: drag;
}
.mx_RoomView::before,
@@ -22,7 +22,6 @@ import {
test.describe("Room list custom sections", () => {
test.use({
displayName: "Alice",
labsFlags: ["feature_new_room_list"],
botCreateOpts: {
displayName: "BotBob",
autoAcceptInvites: true,
@@ -20,7 +20,6 @@ test.describe("Room list filters and sort", () => {
displayName: "BotBob",
autoAcceptInvites: true,
},
labsFlags: ["feature_new_room_list"],
});
/**
@@ -11,10 +11,6 @@ import { test, expect } from "../../../element-web-test";
import { getHeaderSection } from "./utils";
test.describe("Header section of the room list", () => {
test.use({
labsFlags: ["feature_new_room_list"],
});
test.beforeEach(async ({ page, app, user }) => {
// The toasts are displayed above the search section
await rejectToast(page, "Verify this device");
@@ -11,10 +11,6 @@ import { test, expect } from "../../../element-web-test";
import { getRoomListView } from "./utils";
test.describe("Room list panel", () => {
test.use({
labsFlags: ["feature_new_room_list"],
});
test.beforeEach(async ({ page, app, user }) => {
// The toasts are displayed above the search section
await rejectToast(page, "Verify this device");
@@ -11,10 +11,6 @@ import { test, expect } from "../../../element-web-test";
import { getSearchSection } from "./utils";
test.describe("Search section of the room list", () => {
test.use({
labsFlags: ["feature_new_room_list"],
});
test.beforeEach(async ({ page, app, user }) => {
// The toasts are displayed above the search section
await rejectToast(page, "Verify this device");
@@ -13,7 +13,6 @@ import { assertRoomInSection, dragRoomToSection, getPrimaryFilters, getRoomList,
test.describe("Room list sections", () => {
test.use({
displayName: "Alice",
labsFlags: ["feature_new_room_list"],
botCreateOpts: {
displayName: "BotBob",
autoAcceptInvites: true,
@@ -45,8 +45,6 @@ test.describe("Room list unread activity toast", () => {
}
test.describe("flat list", () => {
test.use({ labsFlags: ["feature_new_room_list"] });
test.beforeEach(async ({ page, app, user }) => {
// Toasts are displayed above the room list; dismiss the unrelated ones.
await rejectToast(page, "Verify this device");
@@ -139,8 +137,6 @@ test.describe("Room list unread activity toast", () => {
});
test.describe("sections", () => {
test.use({ labsFlags: ["feature_new_room_list", "feature_room_list_sections"] });
test.beforeEach(async ({ page, app, user }) => {
await rejectToast(page, "Verify this device");
await rejectToast(page, "Notifications");
@@ -16,7 +16,6 @@ import { getRoomList } from "./utils";
test.describe("Room list", () => {
test.use({
displayName: "Alice",
labsFlags: ["feature_new_room_list"],
botCreateOpts: {
displayName: "BotBob",
},
@@ -315,7 +314,7 @@ test.describe("Room list", () => {
});
test.describe("Avatar decoration", () => {
test.use({ labsFlags: ["feature_video_rooms", "feature_new_room_list"] });
test.use({ labsFlags: ["feature_video_rooms"] });
test("should be a public room", { tag: "@screenshot" }, async ({ page, app, user }) => {
// @ts-ignore Visibility enum is not accessible
@@ -21,8 +21,6 @@ test.describe("Preferences user settings tab", () => {
const locator = await app.settings.openUserSettings("Preferences");
await use(locator);
},
// display message preview settings
labsFlags: ["feature_new_room_list"],
});
test("should be rendered properly", { tag: "@screenshot" }, async ({ app, page, user, axe }) => {
-7
View File
@@ -56,7 +56,6 @@
@import "./compound/_SuccessDialog.pcss";
@import "./structures/_AutoHideScrollbar.pcss";
@import "./structures/_AutocompleteInput.pcss";
@import "./structures/_BackdropPanel.pcss";
@import "./structures/_CompatibilityPage.pcss";
@import "./structures/_ContextualMenu.pcss";
@import "./structures/_ErrorMessage.pcss";
@@ -72,7 +71,6 @@
@import "./structures/_PictureInPictureDragger.pcss";
@import "./structures/_QuickSettingsButton.pcss";
@import "./structures/_RightPanel.pcss";
@import "./structures/_RoomSearch.pcss";
@import "./structures/_RoomView.pcss";
@import "./structures/_SearchBox.pcss";
@import "./structures/_SpaceHierarchy.pcss";
@@ -259,8 +257,6 @@
@import "./views/rooms/_IRCLayout.pcss";
@import "./views/rooms/_InvitedIconView.pcss";
@import "./views/rooms/_JumpToBottomButton.pcss";
@import "./views/rooms/_LegacyRoomList.pcss";
@import "./views/rooms/_LegacyRoomListHeader.pcss";
@import "./views/rooms/_LiveContentSummary.pcss";
@import "./views/rooms/_MemberListHeaderView.pcss";
@import "./views/rooms/_MemberListView.pcss";
@@ -277,15 +273,12 @@
@import "./views/rooms/_ReadReceiptGroup.pcss";
@import "./views/rooms/_ReplyPreview.pcss";
@import "./views/rooms/_ReplyTile.pcss";
@import "./views/rooms/_RoomBreadcrumbs.pcss";
@import "./views/rooms/_RoomHeader.pcss";
@import "./views/rooms/_RoomInfoLine.pcss";
@import "./views/rooms/_RoomKnocksBar.pcss";
@import "./views/rooms/_RoomPreviewBar.pcss";
@import "./views/rooms/_RoomPreviewCard.pcss";
@import "./views/rooms/_RoomSearchAuxPanel.pcss";
@import "./views/rooms/_RoomSublist.pcss";
@import "./views/rooms/_RoomTile.pcss";
@import "./views/rooms/_RoomUpgradeWarningBar.pcss";
@import "./views/rooms/_SendMessageComposer.pcss";
@import "./views/rooms/_Stickers.pcss";
@@ -1,29 +0,0 @@
/*
Copyright 2021-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.mx_BackdropPanel {
position: absolute;
left: 0;
top: 0;
height: 100vh;
width: 100%;
overflow: hidden;
filter: blur(var(--lp-background-blur));
/* Force a new layer for the backdropPanel so it's better hardware supported */
transform: translateZ(0);
}
.mx_BackdropPanel--image {
position: absolute;
top: 0;
left: 0;
min-height: 100%;
z-index: 0;
pointer-events: none;
overflow: hidden;
user-select: none;
}
+4 -139
View File
@@ -43,7 +43,6 @@ Please see LICENSE files in the repository root for full details.
height: 100%; /* ensure space panel is still scrollable with an outer wrapper */
.mx_LeftPanel_wrapper--user {
background-color: $roomlist-bg-color;
display: flex;
overflow: hidden;
position: relative;
@@ -57,7 +56,10 @@ Please see LICENSE files in the repository root for full details.
}
.mx_LeftPanel {
background-color: $roomlist-bg-color;
background-color: var(--cpd-color-bg-canvas-default);
/* The room list is not designed to be collapsed to just icons. */
/* 224 + 68(spaces bar) was deemed by design to be a good minimum for the left panel. */
--collapsedWidth: 224px;
/* Create a row-based flexbox for the space panel and the room list */
display: flex;
@@ -66,119 +68,12 @@ Please see LICENSE files in the repository root for full details.
flex-grow: 1;
overflow: hidden;
/* Note: The 'room list' in this context is actually everything that isn't the tag */
/* panel, such as the menu options, breadcrumbs, filtering, etc */
.mx_LeftPanel_roomListContainer {
background-color: $roomlist-bg-color;
flex: 1 0 0;
min-width: 0;
/* Create another flexbox (this time a column) for the room list components */
display: flex;
flex-direction: column;
.mx_LeftPanel_userHeader {
/* 12px top, 12px sides, 20px bottom (using 13px bottom to account
* for internal whitespace in the breadcrumbs)
*/
padding: 12px;
flex-shrink: 0; /* to convince safari's layout engine the flexbox is fine */
/* Create another flexbox column for the rows to stack within */
display: flex;
flex-direction: column;
}
.mx_LeftPanel_breadcrumbsContainer {
overflow-y: hidden;
overflow-x: scroll;
margin: 12px 12px 0 12px;
flex: 0 0 auto;
/* Create yet another flexbox, this time within the row, to ensure items stay */
/* aligned correctly. This is also a row-based flexbox. */
display: flex;
align-items: center;
contain: content;
&.mx_IndicatorScrollbar_leftOverflow {
mask-image: linear-gradient(90deg, transparent, black 5%);
}
&.mx_IndicatorScrollbar_rightOverflow {
mask-image: linear-gradient(90deg, black, black 95%, transparent);
}
&.mx_IndicatorScrollbar_rightOverflow.mx_IndicatorScrollbar_leftOverflow {
mask-image: linear-gradient(90deg, transparent, black 5%, black 95%, transparent);
}
}
.mx_LeftPanel_filterContainer {
margin: 0 12px;
padding: 12px 0 8px;
border-bottom: 1px solid $quinary-content;
flex-shrink: 0; /* to convince safari's layout engine the flexbox is fine */
/* Create a flexbox to organize the inputs */
display: flex;
align-items: center;
& + .mx_LegacyRoomListHeader {
margin-top: 12px;
}
.mx_LeftPanel_dialPadButton,
.mx_LeftPanel_exploreButton {
width: 20px;
height: 20px;
padding: var(--cpd-space-1-5x);
border-radius: 8px;
background-color: $panel-actions;
margin-left: 8px;
/* For enhanced visibility under contrast control */
outline: 1px solid transparent;
svg {
width: inherit;
height: inherit;
display: block;
color: $secondary-content;
}
&:hover {
background-color: $tertiary-content;
svg {
color: $background;
}
}
}
}
.mx_LegacyRoomListHeader:first-child {
margin-top: 12px;
}
.mx_LeftPanel_roomListWrapper {
/* Make the y-scrollbar more responsive */
padding-right: 2px;
overflow: hidden;
margin-top: 10px; /* so we're not up against the search/filter */
flex: 1 0 0; /* needed in Safari to properly set flex-basis */
&.mx_LeftPanel_roomListWrapper_stickyBottom {
padding-bottom: 32px;
}
&.mx_LeftPanel_roomListWrapper_stickyTop {
padding-top: 32px;
}
}
.mx_LeftPanel_actualRoomListContainer {
position: relative; /* for sticky headers */
height: 100%; /* ensure scrolling still works */
}
}
/* These styles override the defaults for the minimized (66px) layout */
@@ -189,40 +84,10 @@ Please see LICENSE files in the repository root for full details.
.mx_LeftPanel_roomListContainer {
width: var(--collapsedWidth);
.mx_LeftPanel_userHeader {
flex-direction: row;
justify-content: center;
}
.mx_LeftPanel_filterContainer {
/* Organize the flexbox into a centered column layout */
flex-direction: column;
justify-content: center;
.mx_LeftPanel_dialPadButton {
margin-left: 0;
margin-top: 8px;
background-color: transparent;
}
.mx_LeftPanel_exploreButton {
margin-left: 0;
margin-top: 8px;
}
}
}
}
}
.mx_LeftPanel_newRoomList {
/* Thew new rooms list is not designed to be collapsed to just icons. */
/* 224 + 68(spaces bar) was deemed by design to be a good minimum for the left panel. */
--collapsedWidth: 224px;
/* Important to force the color on ED titlebar until we remove the old room list */
background-color: var(--cpd-color-bg-canvas-default) !important;
}
#left-panel .mx_LeftPanel_wrapper--user {
/*
* Disable background when using the new room list.
@@ -110,9 +110,7 @@ Please see LICENSE files in the repository root for full details.
display: flex;
}
}
}
.mx_QuickSettingsButton_ContextMenuWrapper_new_room_list {
.mx_QuickThemeSwitcher {
margin-top: var(--cpd-space-2x);
}
@@ -1,93 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
/* Note: this component expects to be contained within a flexbox */
.mx_RoomSearch {
flex: 1;
min-width: 0;
border-radius: 8px;
background-color: $panel-actions;
/* keep border thickness consistent to prevent movement */
border: 1px solid transparent;
height: 28px;
padding: 1px;
/* Create a flexbox for the icons (easier to manage) */
display: flex;
align-items: center;
cursor: pointer;
.mx_RoomSearch_icon {
width: 20px;
height: 20px;
color: $secondary-content;
margin-left: var(--cpd-space-2x);
flex-shrink: 0;
}
.mx_RoomSearch_spotlightTriggerText {
color: var(--cpd-color-text-secondary);
flex: 1;
min-width: 0;
/* the following rules are to match that of a real input field */
overflow: hidden;
margin: 9px;
font: var(--cpd-font-body-sm-semibold);
}
.mx_RoomSearch_shortcutPrompt {
border-radius: 6px;
background-color: $panel-actions;
padding: 2px 4px;
user-select: none;
font-size: $font-12px;
line-height: $font-15px;
font-family: inherit;
font-weight: var(--cpd-font-weight-semibold);
color: $light-fg-color;
margin-right: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&.mx_RoomSearch_minimized {
height: 32px;
min-height: 32px;
width: 32px;
box-sizing: border-box;
.mx_RoomSearch_icon {
margin: 0 auto;
padding: 1px;
align-self: center;
}
.mx_RoomSearch_shortcutPrompt {
display: none;
}
}
&:hover {
background-color: $tertiary-content;
.mx_RoomSearch_spotlightTriggerText {
color: $background;
}
.mx_RoomSearch_shortcutPrompt {
background-color: $background;
color: $secondary-content;
}
.mx_RoomSearch_icon {
color: $background;
}
}
}
+2 -6
View File
@@ -14,7 +14,8 @@ Please see LICENSE files in the repository root for full details.
--height-nested: 24px;
--height-topLevel: 32px;
background-color: $spacePanel-bg-color;
background-color: var(--cpd-color-bg-canvas-default);
border-right: 1px solid var(--cpd-color-bg-subtle-primary);
flex: 0 0 auto;
padding: 0;
margin: 0;
@@ -33,11 +34,6 @@ Please see LICENSE files in the repository root for full details.
width: 68px;
}
&.newUi {
background-color: var(--cpd-color-bg-canvas-default);
border-right: 1px solid var(--cpd-color-bg-subtle-primary);
}
.mx_SpacePanel_toggleCollapse {
position: absolute;
width: 18px;
@@ -1,11 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.mx_LegacyRoomList {
padding-right: 7px; /* width of the scrollbar, to line things up */
}
@@ -1,75 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.mx_LegacyRoomListHeader {
display: flex;
align-items: center;
.mx_LegacyRoomListHeader_contextLessTitle,
.mx_LegacyRoomListHeader_contextMenuButton {
font: var(--cpd-font-heading-sm-semibold);
font-weight: var(--cpd-font-weight-semibold);
padding: 1px 24px 1px 4px;
position: relative;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-left: 8px;
margin-right: auto;
user-select: none;
}
.mx_LegacyRoomListHeader_contextMenuButton {
border-radius: 6px;
&:hover {
background-color: $quinary-content;
}
svg {
width: 20px;
height: 20px;
top: 3px;
right: 0;
position: absolute;
color: $tertiary-content;
}
&[aria-expanded="true"] {
background-color: $quinary-content;
}
}
.mx_LegacyRoomListHeader_plusButton {
width: 32px;
height: 32px;
border-radius: 8px;
position: relative;
padding: 8px;
margin-left: 8px;
margin-right: 12px;
background-color: $panel-actions;
box-sizing: border-box;
flex-shrink: 0;
svg {
width: 16px;
height: 16px;
position: absolute;
color: $secondary-content;
}
&:hover {
background-color: $tertiary-content;
svg {
color: $background;
}
}
}
}
@@ -18,7 +18,7 @@ Please see LICENSE files in the repository root for full details.
/* ^- The count is an element floating within that. */
&.mx_NotificationBadge_visible {
background-color: $roomtile-default-badge-bg-color;
background-color: var(--cpd-color-icon-secondary);
/* For enhanced visibility under contrast control */
outline: 1px solid transparent;
@@ -1,43 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.mx_RoomBreadcrumbs {
width: 100%;
/* Create a flexbox for the crumbs */
display: flex;
flex-direction: row;
align-items: flex-start;
margin-bottom: 12px;
.mx_RoomBreadcrumbs_crumb {
margin-right: 8px;
width: 32px;
}
/* These classes come from the CSSTransition component. There's many more classes we */
/* could care about, but this is all we worried about for now. The animation works by */
/* first triggering the enter state with the newest breadcrumb off screen (-40px) then */
/* sliding it into view. */
&.mx_RoomBreadcrumbs-enter {
transform: translateX(-40px); /* 32px for the avatar, 8px for the margin */
}
&.mx_RoomBreadcrumbs-enter-active {
transform: translateX(0);
/* Timing function is as-requested by design. */
/* NOTE: The transition time MUST match the value passed to CSSTransition! */
transition: transform 640ms cubic-bezier(0.66, 0.02, 0.36, 1);
}
.mx_RoomBreadcrumbs_placeholder {
font: var(--cpd-font-body-md-semibold);
line-height: 32px; /* specifically to match the height this is not scaled */
height: 32px;
}
}
@@ -1,389 +0,0 @@
/*
Copyright 2024,2025 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.mx_RoomSublist {
margin-left: 8px;
margin-bottom: 4px;
&.mx_RoomSublist_hidden {
display: none;
}
&:not(.mx_RoomSublist_minimized) {
.mx_RoomSublist_headerContainer {
height: auto;
}
}
.mx_RoomSublist_headerContainer {
/* Create a flexbox to make alignment easy */
display: flex;
align-items: center;
/* *************************** */
/* Sticky Headers Start */
/* Ideally we'd be able to use `position: sticky; top: 0; bottom: 0;` on the */
/* headerContainer, however due to our layout concerns we actually have to */
/* calculate it manually so we can sticky things in the right places. We also */
/* target the headerText instead of the container to reduce jumps when scrolling, */
/* and to help hide the badges/other buttons that could appear on hover. This */
/* all works by ensuring the header text has a fixed height when sticky so the */
/* fixed height of the container can maintain the scroll position. */
/* The combined height must be set in the LeftPanel component for sticky headers */
/* to work correctly. */
padding-bottom: 8px;
height: 24px;
color: $secondary-content;
.mx_RoomSublist_stickableContainer {
width: 100%;
}
.mx_RoomSublist_stickable {
flex: 1;
max-width: 100%;
/* Create a flexbox to make ordering easy */
display: flex;
align-items: center;
/* We use a generic sticky class for 2 reasons: to reduce style duplication and */
/* to identify when a header is sticky. If we didn't have a consistent sticky class, */
/* we'd have to do the "is sticky" checks again on click, as clicking the header */
/* when sticky scrolls instead of collapses the list. */
&.mx_RoomSublist_headerContainer_sticky {
position: fixed;
height: 32px; /* to match the header container */
/* width set by JS because of a compat issue between Firefox and Chrome */
width: calc(100% - 15px);
}
/* We don't have a top style because the top is dependent on the room list header's */
/* height, and is therefore calculated in JS. */
/* The class, mx_RoomSublist_headerContainer_stickyTop, is applied though. */
}
/* Sticky Headers End */
/* *************************** */
.mx_RoomSublist_badgeContainer {
/* Create another flexbox row because it's super easy to position the badge this way. */
display: flex;
align-items: center;
justify-content: center;
/* Apply the width and margin to the badge so the container doesn't occupy dead space */
.mx_NotificationBadge {
/* Do not set a width so the badges get properly sized */
margin-left: 8px; /* same as menu+aux buttons */
}
}
&:not(.mx_RoomSublist_headerContainer_withAux) {
.mx_NotificationBadge {
margin-right: 4px; /* just to push it over a bit, aligning it with the other elements */
}
}
.mx_RoomSublist_auxButton,
.mx_RoomSublist_menuButton {
margin-left: 8px; /* should be the same as the notification badge */
position: relative;
width: 24px;
height: 24px;
border-radius: 8px;
svg {
width: 16px;
height: 16px;
position: absolute;
top: 4px;
left: 4px;
color: var(--cpd-color-icon-secondary);
}
}
.mx_RoomSublist_auxButton:hover svg,
.mx_RoomSublist_menuButton:hover svg {
color: $panel-actions;
}
/* Hide the menu button by default */
.mx_RoomSublist_menuButton {
visibility: hidden;
width: 0;
margin: 0;
}
.mx_RoomSublist_headerText {
flex: 1;
max-width: calc(100% - 16px); /* 16px is the badge width */
font: var(--cpd-font-body-sm-semibold);
/* Ellipsize any text overflow */
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
.mx_RoomSublist_collapseBtn {
display: inline-block;
position: relative;
width: 14px;
height: 14px;
margin-right: 6px;
svg {
width: 18px;
height: 18px;
position: absolute;
color: var(--cpd-color-icon-secondary);
}
}
}
}
/* In the general case, we reserve space for each sublist header to prevent */
/* scroll jumps when they become sticky. However, that leaves a gap when */
/* scrolled to the top above the first sublist (whose header can only ever */
/* stick to top), so we make sure to exclude the first visible sublist. */
&:not(.mx_RoomSublist_hidden) ~ .mx_RoomSublist .mx_RoomSublist_stickableContainer {
height: 24px;
}
.mx_RoomSublist_resizeBox {
position: relative;
/* Create another flexbox column for the tiles */
display: flex;
flex-direction: column;
overflow: hidden;
.mx_RoomSublist_tiles {
flex: 1 0 0;
overflow: hidden;
overflow: clip;
/* need this to be flex otherwise the overflow hidden from above */
/* sometimes vertically centers the clipped list ... no idea why it would do this */
/* as the box model should be top aligned. Happens in both FF and Chromium */
display: flex;
flex-direction: column;
align-self: stretch;
/* without this Firefox will prefer pushing the resizer & show more/less button into the overflow */
min-height: 0;
mask-image: linear-gradient(0deg, transparent, black 4px);
}
&.mx_RoomSublist_resizeBox_forceExpanded .mx_RoomSublist_tiles {
/* in this state the div can collapse its height entirely in Chromium, */
/* so prevent that by allowing overflow */
overflow: visible;
/* clear the min-height to make it not collapse entirely in a state with no active resizer */
min-height: unset;
}
.mx_RoomSublist_resizerHandles_showNButton {
flex: 0 0 32px;
}
.mx_RoomSublist_resizerHandles {
flex: 0 0 4px;
display: flex;
justify-content: center;
width: 100%;
}
/* Class name comes from the ResizableBox component */
/* The hover state needs to use the whole sublist, not just the resizable box, */
/* so that selector is below and one level higher. */
.mx_RoomSublist_resizerHandle {
cursor: ns-resize;
border-radius: 3px;
/* Override styles from library */
max-width: 64px;
height: 4px !important; /* Update RESIZE_HANDLE_HEIGHT if this changes */
/* This is positioned directly below the 'show more' button. */
position: relative !important;
bottom: 0 !important; /* override from library */
}
&:hover,
&.mx_RoomSublist_hasMenuOpen {
.mx_RoomSublist_resizerHandle {
opacity: 0.8;
background-color: $primary-content;
}
}
}
.mx_RoomSublist_showNButton {
cursor: pointer;
font-size: $font-13px;
line-height: $font-18px;
color: $secondary-content;
/* Update the render() function for RoomSublist if these change */
/* Update the ListLayout class for minVisibleTiles if these change. */
height: 24px;
padding-bottom: 4px;
/* We create a flexbox to cheat at alignment */
display: flex;
align-items: center;
.mx_RoomSublist_showNButtonChevron {
position: relative;
width: 18px;
height: 18px;
margin-left: 12px;
margin-right: 16px;
color: $tertiary-content;
left: -1px; /* adjust for image position */
}
}
&.mx_RoomSublist_hasMenuOpen,
&:not(.mx_RoomSublist_minimized) > .mx_RoomSublist_headerContainer:focus-within,
&:not(.mx_RoomSublist_minimized) > .mx_RoomSublist_headerContainer:hover {
.mx_RoomSublist_menuButton {
visibility: visible;
width: 24px;
margin-left: 8px;
}
}
&.mx_RoomSublist_minimized {
.mx_RoomSublist_headerContainer {
height: auto;
flex-direction: column;
position: relative;
.mx_RoomSublist_badgeContainer {
order: 0;
align-self: flex-end;
margin-right: 0;
}
.mx_RoomSublist_stickable {
order: 1;
max-width: 100%;
}
.mx_RoomSublist_auxButton {
order: 2;
visibility: visible;
width: 32px !important; /* !important to override hover styles */
height: 32px !important; /* !important to override hover styles */
margin-left: 0 !important; /* !important to override hover styles */
background-color: $panel-actions;
margin-top: 8px;
svg {
top: 8px;
left: 8px;
}
}
}
.mx_RoomSublist_resizeBox {
align-items: center;
}
.mx_RoomSublist_showNButton {
flex-direction: column;
.mx_RoomSublist_showNButtonChevron {
margin-right: 12px; /* to center */
}
}
.mx_RoomSublist_menuButton {
height: 16px;
}
&.mx_RoomSublist_hasMenuOpen,
& > .mx_RoomSublist_headerContainer:hover {
.mx_RoomSublist_menuButton {
visibility: visible;
position: absolute;
bottom: 48px; /* align to middle of name, 40px for aux button (with padding) and 8px for alignment */
right: 0;
width: 16px;
height: 16px;
border-radius: 0;
z-index: 1; /* occlude the list name */
/* This is the same color as the left panel background because it needs */
/* to occlude the sublist title */
background-color: $roomlist-bg-color;
svg {
top: 0;
left: 0;
}
}
&.mx_RoomSublist_headerContainer:not(.mx_RoomSublist_headerContainer_withAux) {
.mx_RoomSublist_menuButton {
bottom: 8px; /* align to the middle of name, 40px less than the `bottom` above. */
}
}
}
}
}
.mx_RoomSublist_contextMenu {
padding: 20px 16px;
width: 250px;
hr {
margin-top: 16px;
margin-bottom: 16px;
margin-right: 16px; /* additional 16px */
border: 1px solid $primary-content;
opacity: 0.1;
}
.mx_RoomSublist_contextMenu_title {
font-size: $font-15px;
line-height: $font-20px;
font-weight: var(--cpd-font-weight-semibold);
margin-bottom: 4px;
}
.mx_StyledRadioButton {
margin-top: 8px;
}
}
.mx_RoomSublist_skeletonUI {
position: relative;
margin-left: 4px;
height: 240px;
&::before {
background-color: var(--cpd-color-bg-subtle-secondary);
width: 100%;
height: 100%;
content: "";
position: absolute;
mask-repeat: repeat-y;
mask-size: auto 48px;
mask-image: url("/res/img/element-icons/roomlist/skeleton-ui.svg");
}
}
.mx_RoomSublist_minimized .mx_RoomSublist_skeletonUI {
width: 32px; /* cut off the horizontal lines in the svg */
margin-left: 10px; /* align with sublist + buttons */
}
-158
View File
@@ -1,158 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020-2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
/* Note: the room tile expects to be in a flexbox column container */
.mx_RoomTile {
margin-bottom: 4px;
padding: 4px;
/* The tile is also a flexbox row itself */
display: flex;
contain: content; /* Not strict as it will break when resizing a sublist vertically */
box-sizing: border-box;
font-size: var(--cpd-font-size-body-sm);
&.mx_RoomTile_selected,
&:hover,
&:focus-within,
&.mx_RoomTile_hasMenuOpen {
background-color: $panel-actions;
border-radius: 8px;
}
.mx_DecoratedRoomAvatar,
.mx_RoomTile_avatarContainer {
margin-right: 10px;
}
.mx_RoomTile_details {
min-width: 0;
}
.mx_RoomTile_titleContainer {
height: 32px;
min-width: 0;
flex-basis: 0;
flex-grow: 1;
margin-right: 8px; /* spacing to buttons/badges */
/* Create a new column layout flexbox for the title parts */
display: flex;
flex-direction: column;
justify-content: center;
.mx_RoomTile_subtitle {
align-items: center;
color: $secondary-content;
display: flex;
gap: $spacing-4;
line-height: 1.25;
position: relative;
top: -1px;
}
.mx_RoomTile_title,
.mx_RoomTile_subtitle_text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mx_RoomTile_title {
font: var(--cpd-font-body-md-regular);
line-height: 1.25;
&.mx_RoomTile_titleHasUnreadEvents {
font-weight: var(--cpd-font-weight-semibold);
}
}
.mx_RoomTile_titleWithSubtitle {
margin-top: -2px; /* shift the title up a bit more */
}
}
.mx_RoomTile_notificationsButton {
margin-left: 4px; /* spacing between buttons */
}
.mx_RoomTile_badgeContainer {
height: 16px;
/* don't set width so that it takes no space when there is no badge to show */
margin: auto 0; /* vertically align */
/* Create a flexbox to make aligning dot badges easier */
display: flex;
align-items: center;
.mx_NotificationBadge {
margin-right: 2px; /* centering */
}
.mx_NotificationBadge_dot {
/* make the smaller dot occupy the same width for centering */
margin-left: 5px;
margin-right: 7px;
}
}
/* The context menu buttons are hidden by default */
.mx_RoomTile_menuButton,
.mx_RoomTile_notificationsButton {
width: 16px;
height: 16px;
padding: var(--cpd-space-0-5x);
flex-shrink: 0;
margin-top: auto;
margin-bottom: auto;
position: relative;
display: none;
svg {
width: inherit;
height: inherit;
display: block;
color: var(--cpd-color-icon-primary);
}
}
/* If the room has an overriden notification setting then we always show the notifications menu button */
.mx_RoomTile_notificationsButton.mx_RoomTile_notificationsButton_show {
display: block;
}
&:not(.mx_RoomTile_minimized, .mx_RoomTile_sticky) {
&:hover,
&:focus-within,
&.mx_RoomTile_hasMenuOpen {
/* Hide the badge container on hover because it'll be a menu button */
.mx_RoomTile_badgeContainer {
width: 0;
height: 0;
display: none;
}
.mx_RoomTile_notificationsButton,
.mx_RoomTile_menuButton {
display: block;
}
}
}
&.mx_RoomTile_minimized {
flex-direction: column;
align-items: center;
position: relative;
.mx_DecoratedRoomAvatar,
.mx_RoomTile_avatarContainer {
margin-right: 0;
}
}
}
+1 -14
View File
@@ -15,16 +15,10 @@ $panels: var(--cpd-color-bg-subtle-secondary);
$panel-actions: var(--cpd-color-alpha-gray-300);
$separator: var(--cpd-color-gray-400);
$system: var(--cpd-color-bg-subtle-secondary);
/* ******************** */
/* RoomList */
/* ******************** */
$roomlist-bg-color: rgba(38, 40, 45, 0.9);
$roomsublist-skeleton-ui-bg: linear-gradient(180deg, $background 0%, #ffffff00 100%);
$roomtile-default-badge-bg-color: var(--cpd-color-icon-secondary);
/* ******************** */
/**
* Creating a `semantic` color scale. This will not be needed with the new
* visual language, but necessary during the transition period
@@ -147,13 +141,6 @@ $dialog-close-fg-color: $icon-button-color;
$dialog-close-external-color: $primary-content;
/* ******************** */
/* RoomList */
/* ******************** */
$system: var(--cpd-color-bg-subtle-secondary);
$roomsublist-skeleton-ui-bg: linear-gradient(180deg, #3e444c 0%, #3e444c00 100%);
$roomtile-default-badge-bg-color: var(--cpd-color-icon-secondary);
/* ******************** */
/* Tabbed views */
/* ******************** */
$tab-label-fg-color: $secondary-content;
@@ -103,7 +103,7 @@ $background: $primary-bg-color;
$overlay-background: rgba($background, 0.85);
$panels: rgba($system, 0.9);
$panel-actions: $roomtile-selected-bg-color;
$panel-actions: #1a1d23;
$separator: var(--cpd-color-gray-400);
@@ -148,16 +148,10 @@ $call-background: $background;
$call-primary-content: $primary-content;
$call-light-quaternary-content: #c1c6cd;
$roomlist-filter-active-bg-color: $panel-actions;
$roomlist-bg-color: rgba(38, 40, 45, 0.9);
$roomsublist-skeleton-ui-bg: linear-gradient(180deg, #3e444c 0%, #3e444c00 100%);
$spacePanel-divider-color: $tertiary-content;
$roomtile-default-badge-bg-color: #61708b;
$roomtile-selected-bg-color: #1a1d23;
/* ******************** */
$widget-menu-bar-bg-color: $header-panel-bg-color;
@@ -138,9 +138,6 @@ $room-icon-unread-color: var(--cpd-color-icon-tertiary);
/* ******************** */
$roomtile-default-badge-bg-color: #61708b;
$roomtile-selected-bg-color: #fff;
$presence-away: #d9b072;
$presence-offline: #e3e8f0;
$presence-busy: #ff5b55;
@@ -161,19 +158,13 @@ $background: $primary-bg-color;
$overlay-background: rgba($background, 0.85);
$panels: rgba($system, 0.9);
$panel-actions: $roomtile-selected-bg-color;
$panel-actions: #fff;
$separator: var(--cpd-color-gray-400);
/* Legacy theme backports */
/* ******************** */
$roomlist-filter-active-bg-color: $panel-actions;
$roomlist-bg-color: rgba(245, 245, 245, 0.9);
$roomlist-header-color: $primary-fg-color;
$roomsublist-skeleton-ui-bg: linear-gradient(180deg, #ffffff 0%, #ffffff00 100%);
$voipcall-plinth-color: $system;
$call-view-button-on-foreground: $secondary-content;
@@ -69,8 +69,6 @@ $dialog-title-fg-color: var(--timeline-text-color);
$authpage-lang-color: var(--timeline-text-color);
/* was #232f32 */
$authpage-primary-color: var(--timeline-text-color);
/* --roomlist-text-secondary-color */
$roomtile-default-badge-bg-color: var(--roomlist-text-secondary-color);
/* --roomlist-separator-color */
$input-darker-bg-color: var(--roomlist-separator-color);
+1 -8
View File
@@ -204,13 +204,6 @@ $imagebody-giflabel-border: rgba(0, 0, 0, 0.2);
$imagebody-giflabel-color: $accent-fg-color;
/* ******************** */
/* RoomList */
/* ******************** */
$roomlist-bg-color: rgba(245, 245, 245, 0.9);
$roomsublist-skeleton-ui-bg: linear-gradient(180deg, $background 0%, #ffffff00 100%);
$roomtile-default-badge-bg-color: var(--cpd-color-icon-secondary);
/* ******************** */
/* e2e */
/* ******************** */
$e2e-verified-color: var(--cpd-color-icon-success-primary);
@@ -244,7 +237,7 @@ $togglesw-ball-color: var(--cpd-color-bg-action-primary-rest);
/* ******************** */
$authpage-primary-color: #232f32;
$authpage-bg-color: #2e3649;
$authpage-modal-bg-color: $roomlist-bg-color;
$authpage-modal-bg-color: rgb(245, 245, 245, 0.9);
/* background colour of the modal, when the background is blurred */
$authpage-modal-content-with-blur-bg-color: rgb(255, 255, 255, 0.59);
$authpage-focus-bg-color: $focus-bg-color;
-4
View File
@@ -15,9 +15,7 @@ import type ContentMessages from "../ContentMessages";
import { type IMatrixClientPeg } from "../MatrixClientPeg";
import type ToastStore from "../stores/ToastStore";
import { type DeviceListener } from "../device-listener";
import { type RoomListStore } from "../stores/room-list/Interface";
import { type PlatformPeg } from "../PlatformPeg";
import type RoomListLayoutStore from "../stores/room-list/RoomListLayoutStore";
import { type IntegrationManagers } from "../integrations/IntegrationManagers";
import { type ModalManager } from "../Modal";
import type SettingsStore from "../settings/SettingsStore";
@@ -94,9 +92,7 @@ declare global {
mxContentMessages: ContentMessages;
mxToastStore: ToastStore;
mxDeviceListener: DeviceListener;
mxRoomListStore: RoomListStore;
getRoomListStoreV3: () => RoomListStoreV3Class;
mxRoomListLayoutStore: RoomListLayoutStore;
mxPlatformPeg: PlatformPeg;
mxIntegrationManagers: typeof IntegrationManagers;
singletonModalManager: ModalManager;
@@ -9,7 +9,6 @@
import { TimelineRenderingType } from "../contexts/RoomContext";
import { Action } from "../dispatcher/actions";
import defaultDispatcher from "../dispatcher/dispatcher";
import SettingsStore from "../settings/SettingsStore";
export const enum Landmark {
// This is the space/home button in the left panel.
@@ -73,16 +72,10 @@ export class LandmarkNavigation {
const landmarkToDomElementMap: Record<Landmark, () => HTMLElement | null | undefined> = {
[Landmark.ACTIVE_SPACE_BUTTON]: () => document.querySelector<HTMLElement>(".mx_SpaceButton_active"),
[Landmark.ROOM_SEARCH]: () =>
SettingsStore.getValue("feature_new_room_list")
? document.querySelector<HTMLElement>("#room-list-search-button")
: document.querySelector<HTMLElement>(".mx_RoomSearch"),
[Landmark.ROOM_SEARCH]: () => document.querySelector<HTMLElement>("#room-list-search-button"),
[Landmark.ROOM_LIST]: () =>
SettingsStore.getValue("feature_new_room_list")
? document.querySelector<HTMLElement>(".mx_RoomListItemView_selected") ||
document.querySelector<HTMLElement>(".mx_RoomListItemView")
: document.querySelector<HTMLElement>(".mx_RoomTile_selected") ||
document.querySelector<HTMLElement>(".mx_RoomTile"),
document.querySelector<HTMLElement>(".mx_RoomListItemView_selected") ||
document.querySelector<HTMLElement>(".mx_RoomListItemView"),
[Landmark.MESSAGE_COMPOSER_OR_HOME]: () => {
const isComposerOpen = !!document.querySelector(".mx_MessageComposer");
@@ -1,75 +0,0 @@
/*
Copyright 2024,2025 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2018 New Vector Ltd
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { useRovingTabIndex } from "../RovingTabIndex";
import StyledCheckbox from "../../components/views/elements/StyledCheckbox";
import { KeyBindingAction } from "../KeyboardShortcuts";
import { getKeyBindingsManager } from "../../KeyBindingsManager";
interface IProps extends React.ComponentProps<typeof StyledCheckbox> {
onChange(this: void): void; // we handle keyup/down ourselves so lose the ChangeEvent
onClose(this: void): void; // gets called after onChange on KeyBindingAction.ActivateSelectedButton
}
// Semantic component for representing a styled role=menuitemcheckbox
export const StyledMenuItemCheckbox: React.FC<IProps> = ({ children, onChange, onClose, ...props }) => {
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLInputElement>();
const onKeyDown = (e: React.KeyboardEvent): void => {
let handled = true;
const action = getKeyBindingsManager().getAccessibilityAction(e);
switch (action) {
case KeyBindingAction.Space:
onChange();
break;
case KeyBindingAction.Enter:
onChange();
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
onClose();
break;
default:
handled = false;
}
if (handled) {
e.stopPropagation();
e.preventDefault();
}
};
const onKeyUp = (e: React.KeyboardEvent): void => {
const action = getKeyBindingsManager().getAccessibilityAction(e);
switch (action) {
case KeyBindingAction.Space:
case KeyBindingAction.Enter:
// prevent the input default handler as we handle it on keydown to match
// https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html
e.stopPropagation();
e.preventDefault();
break;
}
};
return (
<StyledCheckbox
{...props}
role="menuitemcheckbox"
onChange={onChange}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
onFocus={onFocus}
inputRef={ref}
tabIndex={isActive ? 0 : -1}
>
{children}
</StyledCheckbox>
);
};
@@ -1,77 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2018 New Vector Ltd
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { useRovingTabIndex } from "../RovingTabIndex";
import StyledRadioButton from "../../components/views/elements/StyledRadioButton";
import { KeyBindingAction } from "../KeyboardShortcuts";
import { getKeyBindingsManager } from "../../KeyBindingsManager";
interface IProps extends React.ComponentProps<typeof StyledRadioButton> {
label?: string;
onChange(this: void): void; // we handle keyup/down ourselves so lose the ChangeEvent
onClose(this: void): void; // gets called after onChange on KeyBindingAction.Enter
}
// Semantic component for representing a styled role=menuitemradio
export const StyledMenuItemRadio: React.FC<IProps> = ({ children, label, onChange, onClose, ...props }) => {
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLInputElement>();
const onKeyDown = (e: React.KeyboardEvent): void => {
let handled = true;
const action = getKeyBindingsManager().getAccessibilityAction(e);
switch (action) {
case KeyBindingAction.Space:
onChange();
break;
case KeyBindingAction.Enter:
onChange();
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
onClose();
break;
default:
handled = false;
}
if (handled) {
e.stopPropagation();
e.preventDefault();
}
};
const onKeyUp = (e: React.KeyboardEvent): void => {
const action = getKeyBindingsManager().getAccessibilityAction(e);
switch (action) {
case KeyBindingAction.Enter:
case KeyBindingAction.Space:
// prevent the input default handler as we handle it on keydown to match
// https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html
e.stopPropagation();
e.preventDefault();
break;
}
};
return (
<StyledRadioButton
{...props}
role="menuitemradio"
aria-label={label}
onChange={onChange}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
onFocus={onFocus}
inputRef={ref}
tabIndex={isActive ? 0 : -1}
>
{children}
</StyledRadioButton>
);
};
@@ -1,33 +0,0 @@
/*
Copyright 2021-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type CSSProperties } from "react";
interface IProps {
backgroundImage?: string;
blurMultiplier?: number;
}
export const BackdropPanel: React.FC<IProps> = ({ backgroundImage, blurMultiplier }) => {
if (!backgroundImage) return null;
const styles: CSSProperties = {};
if (blurMultiplier) {
const rootStyle = getComputedStyle(document.documentElement);
const blurValue = rootStyle.getPropertyValue("--lp-background-blur");
const pixelsValue = blurValue.replace("px", "");
const parsed = parseInt(pixelsValue, 10);
if (!isNaN(parsed)) {
styles.filter = `blur(${parsed * blurMultiplier}px)`;
}
}
return (
<div className="mx_BackdropPanel">
<img role="presentation" alt="" style={styles} className="mx_BackdropPanel--image" src={backgroundImage} />
</div>
);
};
@@ -609,5 +609,3 @@ export { ContextMenuTooltipButton } from "../../accessibility/context_menu/Conte
export { MenuItem } from "../../accessibility/context_menu/MenuItem";
export { MenuItemCheckbox } from "../../accessibility/context_menu/MenuItemCheckbox";
export { MenuItemRadio } from "../../accessibility/context_menu/MenuItemRadio";
export { StyledMenuItemCheckbox } from "../../accessibility/context_menu/StyledMenuItemCheckbox";
export { StyledMenuItemRadio } from "../../accessibility/context_menu/StyledMenuItemRadio";
@@ -6,37 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { createRef } from "react";
import React from "react";
import classNames from "classnames";
import { ExploreIcon, DialPadIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import dis from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
import LegacyRoomList from "../views/rooms/LegacyRoomList";
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../LegacyCallHandler";
import { HEADER_HEIGHT } from "../views/rooms/RoomSublist";
import { Action } from "../../dispatcher/actions";
import RoomSearch from "./RoomSearch";
import type ResizeNotifier from "../../utils/ResizeNotifier";
import SpaceStore from "../../stores/spaces/SpaceStore";
import { MetaSpace, type SpaceKey, UPDATE_SELECTED_SPACE } from "../../stores/spaces";
import { getKeyBindingsManager } from "../../KeyBindingsManager";
import UIStore from "../../stores/UIStore";
import { type IState as IRovingTabIndexState } from "../../accessibility/RovingTabIndex";
import LegacyRoomListHeader from "../views/rooms/LegacyRoomListHeader";
import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore";
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import IndicatorScrollbar from "./IndicatorScrollbar";
import RoomBreadcrumbs from "../views/rooms/RoomBreadcrumbs";
import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
import { shouldShowComponent } from "../../customisations/helpers/UIComponents";
import { UIComponent } from "../../settings/UIFeature";
import AccessibleButton, { type ButtonEvent } from "../views/elements/AccessibleButton";
import PosthogTrackers from "../../PosthogTrackers";
import { Landmark, LandmarkNavigation } from "../../accessibility/LandmarkNavigation";
import SettingsStore from "../../settings/SettingsStore";
import { type SpaceKey, UPDATE_SELECTED_SPACE } from "../../stores/spaces";
import { RoomListPanel } from "../views/rooms/RoomListPanel";
interface IProps {
@@ -44,393 +19,41 @@ interface IProps {
resizeNotifier: ResizeNotifier;
}
enum BreadcrumbsMode {
Disabled,
Legacy,
}
interface IState {
showBreadcrumbs: BreadcrumbsMode;
activeSpace: SpaceKey;
supportsPstnProtocol: boolean;
}
export default class LeftPanel extends React.Component<IProps, IState> {
private listContainerRef = createRef<HTMLDivElement>();
private roomListRef = createRef<LegacyRoomList>();
private focusedElement: Element | null = null;
private isDoingStickyHeaders = false;
public constructor(props: IProps) {
super(props);
this.state = {
activeSpace: SpaceStore.instance.activeSpace,
showBreadcrumbs: LeftPanel.breadcrumbsMode,
supportsPstnProtocol: LegacyCallHandler.instance.getSupportsPstnProtocol(),
};
}
private static get breadcrumbsMode(): BreadcrumbsMode {
return !BreadcrumbsStore.instance.visible ? BreadcrumbsMode.Disabled : BreadcrumbsMode.Legacy;
}
public componentDidMount(): void {
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.updateActiveSpace);
LegacyCallHandler.instance.on(LegacyCallHandlerEvent.ProtocolSupport, this.updateProtocolSupport);
if (this.listContainerRef.current) {
UIStore.instance.trackElementDimensions("ListContainer", this.listContainerRef.current);
// Using the passive option to not block the main thread
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
this.listContainerRef.current.addEventListener("scroll", this.onScroll, { passive: true });
}
UIStore.instance.on("ListContainer", this.refreshStickyHeaders);
}
public componentWillUnmount(): void {
BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.updateActiveSpace);
LegacyCallHandler.instance.off(LegacyCallHandlerEvent.ProtocolSupport, this.updateProtocolSupport);
UIStore.instance.stopTrackingElementDimensions("ListContainer");
UIStore.instance.removeListener("ListContainer", this.refreshStickyHeaders);
this.listContainerRef.current?.removeEventListener("scroll", this.onScroll);
}
public componentDidUpdate(prevProps: IProps, prevState: IState): void {
if (prevState.activeSpace !== this.state.activeSpace) {
this.refreshStickyHeaders();
}
}
private updateProtocolSupport = (): void => {
this.setState({ supportsPstnProtocol: LegacyCallHandler.instance.getSupportsPstnProtocol() });
};
private updateActiveSpace = (activeSpace: SpaceKey): void => {
this.setState({ activeSpace });
};
private onDialPad = (): void => {
dis.fire(Action.OpenDialPad);
};
private onExplore = (ev: ButtonEvent): void => {
dis.fire(Action.ViewRoomDirectory);
PosthogTrackers.trackInteraction("WebLeftPanelExploreRoomsButton", ev);
};
private refreshStickyHeaders = (): void => {
if (!this.listContainerRef.current) return; // ignore: no headers to sticky
this.handleStickyHeaders(this.listContainerRef.current);
};
private onBreadcrumbsUpdate = (): void => {
const newVal = LeftPanel.breadcrumbsMode;
if (newVal !== this.state.showBreadcrumbs) {
this.setState({ showBreadcrumbs: newVal });
// Update the sticky headers too as the breadcrumbs will be popping in or out.
if (!this.listContainerRef.current) return; // ignore: no headers to sticky
this.handleStickyHeaders(this.listContainerRef.current);
}
};
private handleStickyHeaders(list: HTMLDivElement): void {
if (this.isDoingStickyHeaders) return;
this.isDoingStickyHeaders = true;
window.requestAnimationFrame(() => {
this.doStickyHeaders(list);
this.isDoingStickyHeaders = false;
});
}
private doStickyHeaders(list: HTMLDivElement): void {
if (!list.parentElement) return;
const topEdge = list.scrollTop;
const bottomEdge = list.offsetHeight + list.scrollTop;
const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist:not(.mx_RoomSublist_hidden)");
// We track which styles we want on a target before making the changes to avoid
// excessive layout updates.
const targetStyles = new Map<
HTMLDivElement,
{
stickyTop?: boolean;
stickyBottom?: boolean;
makeInvisible?: boolean;
}
>();
let lastTopHeader: HTMLDivElement | undefined;
let firstBottomHeader: HTMLDivElement | undefined;
for (const sublist of sublists) {
const header = sublist.querySelector<HTMLDivElement>(".mx_RoomSublist_stickable");
if (!header) continue; // this should never occur
header.style.removeProperty("display"); // always clear display:none first
// When an element is <=40% off screen, make it take over
const offScreenFactor = 0.4;
const isOffTop = sublist.offsetTop + offScreenFactor * HEADER_HEIGHT <= topEdge;
const isOffBottom = sublist.offsetTop + offScreenFactor * HEADER_HEIGHT >= bottomEdge;
if (isOffTop || sublist === sublists[0]) {
targetStyles.set(header, { stickyTop: true });
if (lastTopHeader) {
lastTopHeader.style.display = "none";
targetStyles.set(lastTopHeader, { makeInvisible: true });
}
lastTopHeader = header;
} else if (isOffBottom && !firstBottomHeader) {
targetStyles.set(header, { stickyBottom: true });
firstBottomHeader = header;
} else {
targetStyles.set(header, {}); // nothing == clear
}
}
// Run over the style changes and make them reality. We check to see if we're about to
// cause a no-op update, as adding/removing properties that are/aren't there cause
// layout updates.
for (const header of targetStyles.keys()) {
const style = targetStyles.get(header)!;
if (style.makeInvisible) {
// we will have already removed the 'display: none', so add it back.
header.style.display = "none";
continue; // nothing else to do, even if sticky somehow
}
if (style.stickyTop) {
if (!header.classList.contains("mx_RoomSublist_headerContainer_stickyTop")) {
header.classList.add("mx_RoomSublist_headerContainer_stickyTop");
}
const newTop = `${list.parentElement.offsetTop}px`;
if (header.style.top !== newTop) {
header.style.top = newTop;
}
} else {
if (header.classList.contains("mx_RoomSublist_headerContainer_stickyTop")) {
header.classList.remove("mx_RoomSublist_headerContainer_stickyTop");
}
if (header.style.top) {
header.style.removeProperty("top");
}
}
if (style.stickyBottom) {
if (!header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) {
header.classList.add("mx_RoomSublist_headerContainer_stickyBottom");
}
const offset =
UIStore.instance.windowHeight - (list.parentElement.offsetTop + list.parentElement.offsetHeight);
const newBottom = `${offset}px`;
if (header.style.bottom !== newBottom) {
header.style.bottom = newBottom;
}
} else {
if (header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) {
header.classList.remove("mx_RoomSublist_headerContainer_stickyBottom");
}
if (header.style.bottom) {
header.style.removeProperty("bottom");
}
}
if (style.stickyTop || style.stickyBottom) {
if (!header.classList.contains("mx_RoomSublist_headerContainer_sticky")) {
header.classList.add("mx_RoomSublist_headerContainer_sticky");
}
const listDimensions = UIStore.instance.getElementDimensions("ListContainer");
if (listDimensions) {
const headerRightMargin = 15; // calculated from margins and widths to align with non-sticky tiles
const headerStickyWidth = listDimensions.width - headerRightMargin;
const newWidth = `${headerStickyWidth}px`;
if (header.style.width !== newWidth) {
header.style.width = newWidth;
}
}
} else if (!style.stickyTop && !style.stickyBottom) {
if (header.classList.contains("mx_RoomSublist_headerContainer_sticky")) {
header.classList.remove("mx_RoomSublist_headerContainer_sticky");
}
if (header.style.width) {
header.style.removeProperty("width");
}
}
}
// add appropriate sticky classes to wrapper so it has
// the necessary top/bottom padding to put the sticky header in
const listWrapper = list.parentElement; // .mx_LeftPanel_roomListWrapper
if (!listWrapper) return;
if (lastTopHeader) {
listWrapper.classList.add("mx_LeftPanel_roomListWrapper_stickyTop");
} else {
listWrapper.classList.remove("mx_LeftPanel_roomListWrapper_stickyTop");
}
if (firstBottomHeader) {
listWrapper.classList.add("mx_LeftPanel_roomListWrapper_stickyBottom");
} else {
listWrapper.classList.remove("mx_LeftPanel_roomListWrapper_stickyBottom");
}
}
private onScroll = (ev: Event): void => {
const list = ev.target as HTMLDivElement;
this.handleStickyHeaders(list);
};
private onFocus = (ev: React.FocusEvent): void => {
this.focusedElement = ev.target;
};
private onBlur = (): void => {
this.focusedElement = null;
};
private onKeyDown = (ev: React.KeyboardEvent, state?: IRovingTabIndexState): void => {
if (!this.focusedElement) return;
const action = getKeyBindingsManager().getRoomListAction(ev);
switch (action) {
case KeyBindingAction.NextRoom:
if (!state) {
ev.stopPropagation();
ev.preventDefault();
this.roomListRef.current?.focus();
}
break;
}
const navAction = getKeyBindingsManager().getNavigationAction(ev);
if (navAction === KeyBindingAction.PreviousLandmark || navAction === KeyBindingAction.NextLandmark) {
ev.stopPropagation();
ev.preventDefault();
LandmarkNavigation.findAndFocusNextLandmark(
Landmark.ROOM_SEARCH,
navAction === KeyBindingAction.PreviousLandmark,
);
}
};
private renderBreadcrumbs(): React.ReactNode {
if (this.state.showBreadcrumbs === BreadcrumbsMode.Legacy && !this.props.isMinimized) {
return (
<IndicatorScrollbar
role="navigation"
aria-label={_t("a11y|recent_rooms")}
className="mx_LeftPanel_breadcrumbsContainer"
verticalScrollsHorizontally={true}
>
<RoomBreadcrumbs />
</IndicatorScrollbar>
);
}
}
private renderSearchDialExplore(): React.ReactNode {
let dialPadButton: JSX.Element | undefined;
// If we have dialer support, show a button to bring up the dial pad to start a new call
if (this.state.supportsPstnProtocol) {
dialPadButton = (
<AccessibleButton
className="mx_LeftPanel_dialPadButton"
onClick={this.onDialPad}
title={_t("left_panel|open_dial_pad")}
>
<DialPadIcon />
</AccessibleButton>
);
}
let rightButton: JSX.Element | undefined;
if (this.state.activeSpace === MetaSpace.Home && shouldShowComponent(UIComponent.ExploreRooms)) {
rightButton = (
<AccessibleButton
className="mx_LeftPanel_exploreButton"
onClick={this.onExplore}
title={_t("action|explore_rooms")}
>
<ExploreIcon />
</AccessibleButton>
);
}
return (
<div
className="mx_LeftPanel_filterContainer"
onFocus={this.onFocus}
onBlur={this.onBlur}
onKeyDown={this.onKeyDown}
role="search"
>
<RoomSearch isMinimized={this.props.isMinimized} />
{dialPadButton}
{rightButton}
</div>
);
}
public render(): React.ReactNode {
const useNewRoomList = SettingsStore.getValue("feature_new_room_list");
const containerClasses = classNames({
mx_LeftPanel: true,
mx_LeftPanel_newRoomList: useNewRoomList,
mx_LeftPanel_minimized: this.props.isMinimized,
});
const roomListClasses = classNames("mx_LeftPanel_actualRoomListContainer", "mx_AutoHideScrollbar");
if (useNewRoomList) {
return (
<div className={containerClasses}>
<div className="mx_LeftPanel_roomListContainer">
<RoomListPanel activeSpace={this.state.activeSpace} />
</div>
</div>
);
}
const roomList = (
<LegacyRoomList
onKeyDown={this.onKeyDown}
resizeNotifier={this.props.resizeNotifier}
onFocus={this.onFocus}
onBlur={this.onBlur}
isMinimized={this.props.isMinimized}
activeSpace={this.state.activeSpace}
onResize={this.refreshStickyHeaders}
onListCollapse={this.refreshStickyHeaders}
ref={this.roomListRef}
/>
);
return (
<div className={containerClasses}>
<div className="mx_LeftPanel_roomListContainer">
{shouldShowComponent(UIComponent.FilterContainer) && this.renderSearchDialExplore()}
{this.renderBreadcrumbs()}
{!this.props.isMinimized && <LegacyRoomListHeader onVisibilityChange={this.refreshStickyHeaders} />}
<nav className="mx_LeftPanel_roomListWrapper" aria-label={_t("common|rooms")}>
<div
className={roomListClasses}
ref={this.listContainerRef}
// Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order.
tabIndex={-1}
>
{roomList}
</div>
</nav>
<RoomListPanel activeSpace={this.state.activeSpace} />
</div>
</div>
);
@@ -31,8 +31,6 @@ import dis from "../../dispatcher/dispatcher";
import { type IMatrixClientCreds } from "../../MatrixClientPeg";
import SettingsStore from "../../settings/SettingsStore";
import { SettingLevel } from "../../settings/SettingLevel";
import ResizeHandle from "../views/elements/ResizeHandle";
import { CollapseDistributor, Resizer } from "../../resizer";
import PlatformPeg from "../../PlatformPeg";
import { hideToast as hideServerLimitToast, showToast as showServerLimitToast } from "../../toasts/ServerLimitToast";
import { Action } from "../../dispatcher/actions";
@@ -42,7 +40,6 @@ import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
import NonUrgentToastContainer from "./NonUrgentToastContainer";
import { type IOOBData, type IThreepidInvite } from "../../stores/ThreepidInviteStore";
import Modal from "../../Modal";
import { type CollapseItem, type ICollapseConfig } from "../../resizer/distributors/collapse";
import { getKeyBindingsManager } from "../../KeyBindingsManager";
import { type IOpts } from "../../createRoom";
import SpacePanel from "../views/spaces/SpacePanel";
@@ -53,7 +50,6 @@ import { UPDATE_EVENT } from "../../stores/AsyncStore";
import { RoomView } from "./RoomView";
import ToastContainer from "./ToastContainer";
import UserView from "./UserView";
import { BackdropPanel } from "./BackdropPanel";
import { mediaFromMxc } from "../../customisations/Media";
import { UserTab } from "../views/dialogs/UserTab";
import { type OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
@@ -94,7 +90,6 @@ interface IProps {
threepidInvite?: IThreepidInvite;
roomOobData?: IOOBData;
currentRoomId: string | null;
collapseLhs: boolean;
currentUserId: string | null;
justRegistered?: boolean;
roomJustCreatedOpts?: IOpts;
@@ -111,7 +106,6 @@ interface IState {
backgroundImage?: string;
}
const NEW_ROOM_LIST_MIN_WIDTH = 224;
/**
* This is what our MatrixChat shows when we are logged in. The precise view is
* determined by the page_type property.
@@ -126,13 +120,10 @@ class LoggedInView extends React.Component<IProps, IState> {
protected readonly _matrixClient: MatrixClient;
protected readonly _roomView: React.RefObject<RoomView | null>;
protected readonly _resizeContainer: React.RefObject<HTMLDivElement | null>;
protected readonly resizeHandler: React.RefObject<HTMLDivElement | null>;
protected layoutWatcherRef?: string;
protected compactLayoutWatcherRef?: string;
protected backgroundImageWatcherRef?: string;
protected timezoneProfileUpdateRef?: string[];
protected resizer?: Resizer<ICollapseConfig, CollapseItem>;
private resizerViewModel?: ResizerViewModel;
@@ -156,8 +147,6 @@ class LoggedInView extends React.Component<IProps, IState> {
MediaDeviceHandler.loadDevices();
this._roomView = React.createRef();
this._resizeContainer = React.createRef();
this.resizeHandler = React.createRef();
}
public componentDidMount(): void {
@@ -194,8 +183,6 @@ class LoggedInView extends React.Component<IProps, IState> {
// system time has changed between sessions.
void this.onTimezoneUpdate();
this.loadResizer();
OwnProfileStore.instance.on(UPDATE_EVENT, this.refreshBackgroundImage);
this.refreshBackgroundImage();
}
@@ -212,24 +199,6 @@ class LoggedInView extends React.Component<IProps, IState> {
this.resizerViewModel = undefined;
}
/**
* Load or reload the resizer for the left panel
*/
private loadResizer(): void {
// If the resizer already exists, detach it first
this.resizer?.detach();
this.resizer = this.createResizer();
this.resizer.attach();
this.loadResizerPreferences();
}
public componentDidUpdate(nextProps: Readonly<IProps>, nextState: Readonly<IState>, nextContext: any): void {
if (nextProps.page_type !== this.props.page_type) {
this.loadResizer();
}
}
private onTimezoneUpdate = async (): Promise<void> => {
// TODO: In a future app release, remove support for legacy key.
if (!SettingsStore.getValue("userTimezonePublish")) {
@@ -269,7 +238,6 @@ class LoggedInView extends React.Component<IProps, IState> {
SettingsStore.unwatchSetting(this.compactLayoutWatcherRef);
SettingsStore.unwatchSetting(this.backgroundImageWatcherRef);
this.timezoneProfileUpdateRef?.forEach((s) => SettingsStore.unwatchSetting(s));
this.resizer?.detach();
this.resizerViewModel?.dispose();
}
@@ -297,66 +265,6 @@ class LoggedInView extends React.Component<IProps, IState> {
return this._roomView.current.canResetTimeline();
};
private createResizer(): Resizer<ICollapseConfig, CollapseItem> {
let panelSize: number | null;
let panelCollapsed: boolean;
const useNewRoomList = SettingsStore.getValue("feature_new_room_list");
// TODO decrease this once Spaces launches as it'll no longer need to include the 56px Community Panel
const toggleSize = useNewRoomList ? NEW_ROOM_LIST_MIN_WIDTH : 206 - 50;
const collapseConfig: ICollapseConfig = {
toggleSize,
onCollapsed: (collapsed) => {
if (useNewRoomList) {
// The new room list does not support collapsing.
return;
}
panelCollapsed = collapsed;
if (collapsed) {
dis.dispatch({ action: "hide_left_panel" });
window.localStorage.setItem("mx_lhs_size", "0");
} else {
dis.dispatch({ action: "show_left_panel" });
}
},
onResized: (size) => {
panelSize = size;
this.context.resizeNotifier.notifyLeftHandleResized();
},
onResizeStart: () => {
this.context.resizeNotifier.startResizing();
},
onResizeStop: () => {
// Always save the lhs size for the new room list.
if (useNewRoomList || !panelCollapsed) window.localStorage.setItem("mx_lhs_size", "" + panelSize);
this.context.resizeNotifier.stopResizing();
},
isItemCollapsed: (domNode) => {
// New rooms list does not support collapsing.
return !useNewRoomList && domNode.classList.contains("mx_LeftPanel_minimized");
},
handler: this.resizeHandler.current ?? undefined,
};
const resizer = new Resizer(this._resizeContainer.current, CollapseDistributor, collapseConfig);
resizer.setClassNames({
handle: "mx_ResizeHandle",
vertical: "mx_ResizeHandle--vertical",
reverse: "mx_ResizeHandle_reverse",
});
return resizer;
}
private loadResizerPreferences(): void {
const useNewRoomList = SettingsStore.getValue("feature_new_room_list");
let lhsSize = parseInt(window.localStorage.getItem("mx_lhs_size")!, 10);
// If the user has not set a size, or for the new room list if the size is less than the minimum width,
// set a default size.
if (isNaN(lhsSize) || (useNewRoomList && lhsSize < NEW_ROOM_LIST_MIN_WIDTH)) {
lhsSize = 350;
}
this.resizer?.forHandleWithId("lp-resizer")?.resize(lhsSize);
}
private onAccountData = (event: MatrixEvent): void => {
if (event.getType() === "m.ignored_user_list") {
dis.dispatch({ action: "ignore_state_changed" });
@@ -763,38 +671,19 @@ class LoggedInView extends React.Component<IProps, IState> {
"mx_MatrixChat--with-avatar": this.state.backgroundImage,
});
const useNewRoomList = SettingsStore.getValue("feature_new_room_list");
const leftPanelWrapperClasses = classNames({
mx_LeftPanel_wrapper: true,
mx_LeftPanel_newRoomList: useNewRoomList,
});
const leftPanelWrapperClasses = classNames("mx_LeftPanel_wrapper");
const audioFeedArraysForCalls = this.state.activeCalls.map((call) => {
return <AudioFeedArrayForLegacyCall call={call} key={call.callId} />;
});
const shouldUseMinimizedUI = !useNewRoomList && this.props.collapseLhs;
const leftPanel = (
<div className="mx_LeftPanel_outerWrapper">
<LeftPanelLiveShareWarning isMinimized={shouldUseMinimizedUI || false} />
<LeftPanelLiveShareWarning isMinimized={false} />
<div className={leftPanelWrapperClasses}>
{!useNewRoomList && (
<BackdropPanel blurMultiplier={0.5} backgroundImage={this.state.backgroundImage} />
)}
{!useNewRoomList && <SpacePanel />}
{!useNewRoomList && <BackdropPanel backgroundImage={this.state.backgroundImage} />}
{!moduleRenderer && (
<div
className="mx_LeftPanel_wrapper--user"
ref={this._resizeContainer}
data-collapsed={shouldUseMinimizedUI ? true : undefined}
>
<LeftPanel
isMinimized={shouldUseMinimizedUI || false}
resizeNotifier={this.context.resizeNotifier}
/>
<div className="mx_LeftPanel_wrapper--user">
<LeftPanel isMinimized={false} resizeNotifier={this.context.resizeNotifier} />
</div>
)}
</div>
@@ -805,9 +694,9 @@ class LoggedInView extends React.Component<IProps, IState> {
let content: React.ReactNode;
const resizerViewModel = !moduleRenderer ? this.getResizerViewModel() : undefined;
if (useNewRoomList && resizerViewModel && !moduleRenderer) {
// New room list owned by element-web: resizable layout with a draggable separator.
// The SpacePanel lives inside GroupView (leftPanel omits it when the new room list is enabled).
if (resizerViewModel && !moduleRenderer) {
// Resizable layout with a draggable separator. The SpacePanel lives inside GroupView
// (leftPanel omits it).
content = (
<GroupView vm={resizerViewModel}>
<SpacePanel />
@@ -825,15 +714,13 @@ class LoggedInView extends React.Component<IProps, IState> {
</GroupView>
);
} else {
// Fallback layout: the old room list, or a module's full-screen view (e.g. multiroom) which
// must not use the resizable layout above. SpacePanel is guarded on useNewRoomList to avoid a
// duplicate (the old room list already renders one inside leftPanel); the legacy ResizeHandle is
// dropped for module views, which manage their own layout.
// Fallback layout for a module's full-screen view (e.g. multiroom) which must not use the
// resizable layout above. The ResizeHandle is dropped for module views, which manage their
// own layout.
content = (
<>
{useNewRoomList && <SpacePanel />}
<SpacePanel />
{leftPanel}
{!moduleRenderer && <ResizeHandle passRef={this.resizeHandler} id="lp-resizer" />}
{roomView}
</>
);
@@ -75,8 +75,6 @@ import { UIFeature } from "../../settings/UIFeature";
import DialPadModal from "../views/voip/DialPadModal";
import { showToast as showMobileGuideToast } from "../../toasts/MobileGuideToast";
import { shouldUseLoginForWelcome } from "../../utils/pages";
import RoomListStore from "../../stores/room-list/RoomListStore";
import { RoomUpdateCause } from "../../stores/room-list/models";
import { ModuleRunner } from "../../modules/ModuleRunner";
import Spinner from "../views/elements/Spinner";
import QuestionDialog from "../views/dialogs/QuestionDialog";
@@ -191,8 +189,6 @@ interface IState {
currentRoomId: string | null;
// If we're trying to just view a user ID (i.e. /user URL), this is it
currentUserId: string | null;
// this is persisted as mx_lhs_size, loaded in LoggedInView
collapseLhs: boolean;
// Parameters used in the registration dance with the IS
// eslint-disable-next-line camelcase
register_client_secret?: string;
@@ -253,7 +249,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.state = {
view: Views.LOADING,
collapseLhs: false,
currentRoomId: null,
currentUserId: null,
@@ -873,24 +868,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
break;
}
case "hide_left_panel":
this.setState(
{
collapseLhs: true,
},
() => {
this.stores.resizeNotifier.notifyLeftHandleResized();
},
);
break;
case "show_left_panel":
this.setState(
{
collapseLhs: false,
},
() => {
this.stores.resizeNotifier.notifyLeftHandleResized();
},
);
this.stores.resizeNotifier.notifyLeftHandleResized();
break;
case Action.OpenDialPad:
Modal.createDialog(DialPadModal, {}, "mx_Dialog_dialPadWrapper");
@@ -1364,9 +1343,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
if (room) {
// Legacy room list store needs to be told to manually remove this room
RoomListStore.instance.manualRoomUpdate(room, RoomUpdateCause.RoomRemoved);
// New room list store will remove the room on the following dispatch
// The room list store will remove the room on the following dispatch
dis.dispatch<AfterForgetRoomPayload>({ action: Action.AfterForgetRoom, room });
}
})
@@ -1576,7 +1553,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
private onLoggedOut(): void {
this.viewWelcome({
ready: false,
collapseLhs: false,
currentRoomId: null,
});
this.subTitleStatus = "";
@@ -1592,7 +1568,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.setStateForNewView({
view: Views.SOFT_LOGOUT,
ready: false,
collapseLhs: false,
currentRoomId: null,
});
this.subTitleStatus = "";
@@ -1,54 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import classNames from "classnames";
import React from "react";
import { SearchIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { ALTERNATE_KEY_NAME } from "../../accessibility/KeyboardShortcuts";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { IS_MAC, Key } from "../../Keyboard";
import { _t } from "../../languageHandler";
import AccessibleButton from "../views/elements/AccessibleButton";
import { Action } from "../../dispatcher/actions";
interface IProps {
isMinimized: boolean;
}
export default class RoomSearch extends React.PureComponent<IProps> {
private openSpotlight(this: void): void {
defaultDispatcher.fire(Action.OpenSpotlight);
}
public render(): React.ReactNode {
const classes = classNames(
{
mx_RoomSearch: true,
mx_RoomSearch_minimized: this.props.isMinimized,
},
"mx_RoomSearch_spotlightTrigger",
);
const shortcutPrompt = (
<kbd className="mx_RoomSearch_shortcutPrompt">
{IS_MAC ? "⌘ K" : _t(ALTERNATE_KEY_NAME[Key.CONTROL]) + " K"}
</kbd>
);
return (
<AccessibleButton onClick={this.openSpotlight} className={classes} aria-label={_t("action|search")}>
<SearchIcon className="mx_RoomSearch_icon" />
{!this.props.isMinimized && (
<div className="mx_RoomSearch_spotlightTriggerText">{_t("action|search")}</div>
)}
{shortcutPrompt}
</AccessibleButton>
);
}
}
@@ -22,7 +22,7 @@ import { RoomGeneralContextMenu } from "../../context_menus/RoomGeneralContextMe
import { RoomNotificationContextMenu } from "../../context_menus/RoomNotificationContextMenu";
import SpaceContextMenu from "../../context_menus/SpaceContextMenu";
import { type ButtonEvent } from "../../elements/AccessibleButton";
import { contextMenuBelow } from "../../rooms/RoomTile";
import { ChevronFace, type MenuProps } from "../../../structures/ContextMenu";
import { shouldShowComponent } from "../../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../../settings/UIFeature";
@@ -30,6 +30,14 @@ interface Props {
room: Room;
}
const contextMenuBelow = (elementRect: DOMRect): MenuProps => {
// align the context menu's icons with the icon which opened the context menu
const left = elementRect.left + window.scrollX - 9;
const top = elementRect.bottom + window.scrollY + 17;
const chevronFace = ChevronFace.None;
return { left, top, chevronFace };
};
export function getNotificationIcon(state: RoomNotifState): ReactNode {
switch (state) {
case RoomNotifState.Mute:
@@ -1,87 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020-2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import classNames from "classnames";
import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex";
import NotificationBadge from "./NotificationBadge";
import { type NotificationState } from "../../../stores/notifications/NotificationState";
import { type ButtonEvent } from "../elements/AccessibleButton";
import useHover from "../../../hooks/useHover";
interface ExtraTileProps {
isMinimized: boolean;
isSelected: boolean;
displayName: string;
avatar: React.ReactElement;
notificationState?: NotificationState;
onClick: (ev: ButtonEvent) => void;
}
export default function ExtraTile({
isSelected,
isMinimized,
notificationState,
displayName,
onClick,
avatar,
}: ExtraTileProps): JSX.Element {
const [, { onMouseOver, onMouseLeave }] = useHover(() => false);
// XXX: We copy classes because it's easier
const classes = classNames({
mx_ExtraTile: true,
mx_RoomTile: true,
mx_RoomTile_selected: isSelected,
mx_RoomTile_minimized: isMinimized,
});
let badge: JSX.Element | null = null;
if (notificationState) {
badge = <NotificationBadge notification={notificationState} />;
}
let name = displayName;
if (typeof name !== "string") name = "";
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
const nameClasses = classNames({
mx_RoomTile_title: true,
mx_RoomTile_titleHasUnreadEvents: notificationState?.isUnread,
});
let nameContainer: JSX.Element | null = (
<div className="mx_RoomTile_titleContainer">
<div title={name} className={nameClasses} tabIndex={-1} dir="auto">
{name}
</div>
</div>
);
if (isMinimized) nameContainer = null;
return (
<RovingAccessibleButton
className={classes}
onMouseEnter={onMouseOver}
onMouseLeave={onMouseLeave}
onClick={onClick}
role="treeitem"
title={name}
disableTooltip={!isMinimized}
>
<div className="mx_RoomTile_avatarContainer">{avatar}</div>
<div className="mx_RoomTile_details">
<div className="mx_RoomTile_primaryDetails">
{nameContainer}
<div className="mx_RoomTile_badgeContainer">{badge}</div>
</div>
</div>
</RovingAccessibleButton>
);
}
@@ -1,706 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2015-2018 , 2020, 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { EventType, type Room, RoomType } from "matrix-js-sdk/src/matrix";
import React, { type JSX, type ComponentType, createRef, type ReactComponentElement, type SyntheticEvent } from "react";
import {
PlusIcon,
UserAddSolidIcon,
RoomIcon,
SearchIcon,
ShareIcon,
VideoCallSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { type IState as IRovingTabIndexState, RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex.tsx";
import MatrixClientContext from "../../../contexts/MatrixClientContext.tsx";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents.ts";
import { Action } from "../../../dispatcher/actions.ts";
import defaultDispatcher from "../../../dispatcher/dispatcher.ts";
import { type ActionPayload } from "../../../dispatcher/payloads.ts";
import { type ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload.ts";
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload.ts";
import { useEventEmitterState } from "../../../hooks/useEventEmitter.ts";
import { _t, _td } from "../../../languageHandler.tsx";
import { MatrixClientPeg } from "../../../MatrixClientPeg.ts";
import PosthogTrackers from "../../../PosthogTrackers.ts";
import SettingsStore from "../../../settings/SettingsStore.ts";
import { useFeatureEnabled } from "../../../hooks/useSettings.ts";
import { UIComponent } from "../../../settings/UIFeature.ts";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore.ts";
import { type ITagMap } from "../../../stores/room-list/algorithms/models.ts";
import { DefaultTagID, type TagID } from "../../../stores/room-list-v3/skip-list/tag";
import { UPDATE_EVENT } from "../../../stores/AsyncStore.ts";
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore.ts";
import {
isMetaSpace,
type ISuggestedRoom,
MetaSpace,
type SpaceKey,
UPDATE_SELECTED_SPACE,
UPDATE_SUGGESTED_ROOMS,
} from "../../../stores/spaces/index.ts";
import SpaceStore from "../../../stores/spaces/SpaceStore.ts";
import { arrayFastClone, arrayHasDiff } from "../../../utils/arrays.ts";
import { objectShallowClone, objectWithOnly } from "../../../utils/objects.ts";
import type ResizeNotifier from "../../../utils/ResizeNotifier.ts";
import {
shouldShowSpaceInvite,
showAddExistingRooms,
showCreateNewRoom,
showSpaceInvite,
} from "../../../utils/space.tsx";
import {
ChevronFace,
ContextMenuTooltipButton,
type MenuProps,
useContextMenu,
} from "../../structures/ContextMenu.tsx";
import RoomAvatar from "../avatars/RoomAvatar.tsx";
import { BetaPill } from "../beta/BetaCard.tsx";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "../context_menus/IconizedContextMenu.tsx";
import ExtraTile from "./ExtraTile.tsx";
import RoomSublist, { type IAuxButtonProps } from "./RoomSublist.tsx";
import { SDKContextClass } from "../../../contexts/SDKContextClass";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts.ts";
import { getKeyBindingsManager } from "../../../KeyBindingsManager.ts";
import AccessibleButton from "../elements/AccessibleButton.tsx";
import { Landmark, LandmarkNavigation } from "../../../accessibility/LandmarkNavigation.ts";
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../../LegacyCallHandler.tsx";
interface IProps {
onKeyDown: (ev: React.KeyboardEvent, state: IRovingTabIndexState) => void;
onFocus: (ev: React.FocusEvent) => void;
onBlur: (ev: React.FocusEvent) => void;
onResize: () => void;
onListCollapse?: (isExpanded: boolean) => void;
resizeNotifier: ResizeNotifier;
isMinimized: boolean;
activeSpace: SpaceKey;
}
interface IState {
sublists: ITagMap;
currentRoomId?: string;
suggestedRooms: ISuggestedRoom[];
}
export const TAG_ORDER: TagID[] = [
DefaultTagID.Invite,
DefaultTagID.Favourite,
DefaultTagID.DM,
DefaultTagID.Untagged,
DefaultTagID.Conference,
DefaultTagID.LowPriority,
DefaultTagID.ServerNotice,
DefaultTagID.Suggested,
// DefaultTagID.Archived isn't here any more: we don't show it at all.
// The section still exists in the code as a place for rooms that we know
// about but aren't joined. At some point it could be removed entirely
// but we'd have to make sure that rooms you weren't in were hidden.
];
const ALWAYS_VISIBLE_TAGS: TagID[] = [DefaultTagID.DM, DefaultTagID.Untagged];
interface ITagAesthetics {
sectionLabel: TranslationKey;
sectionLabelRaw?: string;
AuxButtonComponent?: ComponentType<IAuxButtonProps>;
isInvite: boolean;
defaultHidden: boolean;
}
type TagAestheticsMap = Partial<{
[tagId in TagID]: ITagAesthetics;
}>;
const auxButtonContextMenuPosition = (handle: HTMLDivElement): MenuProps => {
const rect = handle.getBoundingClientRect();
return {
chevronFace: ChevronFace.None,
left: rect.left - 7,
top: rect.top + rect.height,
};
};
const DmAuxButton: React.FC<IAuxButtonProps> = ({ tabIndex, dispatcher = defaultDispatcher }) => {
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLDivElement>();
const activeSpace = useEventEmitterState(SpaceStore.instance, UPDATE_SELECTED_SPACE, () => {
return SpaceStore.instance.activeSpaceRoom;
});
const showCreateRooms = shouldShowComponent(UIComponent.CreateRooms);
const showInviteUsers = shouldShowComponent(UIComponent.InviteUsers);
if (activeSpace && (showCreateRooms || showInviteUsers)) {
let contextMenu: JSX.Element | undefined;
if (menuDisplayed && handle.current) {
const canInvite = shouldShowSpaceInvite(activeSpace);
contextMenu = (
<IconizedContextMenu {...auxButtonContextMenuPosition(handle.current)} onFinished={closeMenu} compact>
<IconizedContextMenuOptionList first>
{showCreateRooms && (
<IconizedContextMenuOption
label={_t("action|start_new_chat")}
icon={<UserAddSolidIcon />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
defaultDispatcher.dispatch({ action: Action.CreateChat });
PosthogTrackers.trackInteraction(
"WebRoomListRoomsSublistPlusMenuCreateChatItem",
e,
);
}}
/>
)}
{showInviteUsers && (
<IconizedContextMenuOption
label={_t("action|invite_to_space")}
icon={<ShareIcon />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
showSpaceInvite(activeSpace);
}}
disabled={!canInvite}
title={canInvite ? undefined : _t("spaces|error_no_permission_invite")}
/>
)}
</IconizedContextMenuOptionList>
</IconizedContextMenu>
);
}
return (
<>
<ContextMenuTooltipButton
tabIndex={tabIndex}
onClick={openMenu}
className="mx_RoomSublist_auxButton"
aria-label={_t("action|add_people")}
title={_t("action|add_people")}
isExpanded={menuDisplayed}
ref={handle}
>
<PlusIcon />
</ContextMenuTooltipButton>
{contextMenu}
</>
);
} else if (!activeSpace && showCreateRooms) {
return (
<AccessibleButton
tabIndex={tabIndex}
onClick={(e) => {
dispatcher.dispatch({ action: Action.CreateChat });
PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuCreateChatItem", e);
}}
className="mx_RoomSublist_auxButton"
aria-label={_t("action|start_chat")}
title={_t("action|start_chat")}
>
<PlusIcon />
</AccessibleButton>
);
}
return null;
};
const UntaggedAuxButton: React.FC<IAuxButtonProps> = ({ tabIndex }) => {
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLDivElement>();
const activeSpace = useEventEmitterState<Room | null>(SpaceStore.instance, UPDATE_SELECTED_SPACE, () => {
return SpaceStore.instance.activeSpaceRoom;
});
const showCreateRoom = shouldShowComponent(UIComponent.CreateRooms);
const showExploreRooms = shouldShowComponent(UIComponent.ExploreRooms);
const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
let contextMenuContent: JSX.Element | undefined;
if (menuDisplayed && activeSpace) {
const canAddRooms = activeSpace.currentState.maySendStateEvent(
EventType.SpaceChild,
MatrixClientPeg.safeGet().getSafeUserId(),
);
contextMenuContent = (
<IconizedContextMenuOptionList first>
<IconizedContextMenuOption
label={_t("action|explore_rooms")}
icon={<SearchIcon />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: activeSpace.roomId,
metricsTrigger: undefined, // other
});
PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuExploreRoomsItem", e);
}}
/>
{showCreateRoom ? (
<>
<IconizedContextMenuOption
label={_t("action|new_room")}
icon={<PlusIcon />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
showCreateNewRoom(activeSpace);
PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuCreateRoomItem", e);
}}
disabled={!canAddRooms}
title={canAddRooms ? undefined : _t("spaces|error_no_permission_create_room")}
/>
{videoRoomsEnabled && (
<IconizedContextMenuOption
label={_t("action|new_video_room")}
icon={<VideoCallSolidIcon />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
showCreateNewRoom(
activeSpace,
elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo,
);
}}
disabled={!canAddRooms}
title={canAddRooms ? undefined : _t("spaces|error_no_permission_create_room")}
>
<BetaPill />
</IconizedContextMenuOption>
)}
<IconizedContextMenuOption
label={_t("action|add_existing_room")}
icon={<RoomIcon />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
showAddExistingRooms(activeSpace);
}}
disabled={!canAddRooms}
title={canAddRooms ? undefined : _t("spaces|error_no_permission_add_room")}
/>
</>
) : null}
</IconizedContextMenuOptionList>
);
} else if (menuDisplayed) {
contextMenuContent = (
<IconizedContextMenuOptionList first>
{showCreateRoom && (
<>
<IconizedContextMenuOption
label={_t("action|new_room")}
icon={<PlusIcon />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
defaultDispatcher.dispatch({ action: Action.CreateRoom });
PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuCreateRoomItem", e);
}}
/>
{videoRoomsEnabled && (
<IconizedContextMenuOption
label={_t("action|new_video_room")}
icon={<VideoCallSolidIcon />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
defaultDispatcher.dispatch({
action: Action.CreateRoom,
type: elementCallVideoRoomsEnabled
? RoomType.UnstableCall
: RoomType.ElementVideo,
});
}}
>
<BetaPill />
</IconizedContextMenuOption>
)}
</>
)}
{showExploreRooms ? (
<IconizedContextMenuOption
label={_t("action|explore_public_rooms")}
icon={<SearchIcon />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuExploreRoomsItem", e);
defaultDispatcher.fire(Action.ViewRoomDirectory);
}}
/>
) : null}
</IconizedContextMenuOptionList>
);
}
let contextMenu: JSX.Element | null = null;
if (menuDisplayed && handle.current) {
contextMenu = (
<IconizedContextMenu {...auxButtonContextMenuPosition(handle.current)} onFinished={closeMenu} compact>
{contextMenuContent}
</IconizedContextMenu>
);
}
if (showCreateRoom || showExploreRooms) {
return (
<>
<ContextMenuTooltipButton
tabIndex={tabIndex}
onClick={openMenu}
className="mx_RoomSublist_auxButton"
aria-label={_t("room_list|add_room_label")}
title={_t("room_list|add_room_label")}
isExpanded={menuDisplayed}
ref={handle}
>
<PlusIcon />
</ContextMenuTooltipButton>
{contextMenu}
</>
);
}
return null;
};
const TAG_AESTHETICS: TagAestheticsMap = {
[DefaultTagID.Invite]: {
sectionLabel: _td("action|invites_list"),
isInvite: true,
defaultHidden: false,
},
[DefaultTagID.Favourite]: {
sectionLabel: _td("common|favourites"),
isInvite: false,
defaultHidden: false,
},
[DefaultTagID.DM]: {
sectionLabel: _td("common|people"),
isInvite: false,
defaultHidden: false,
AuxButtonComponent: DmAuxButton,
},
[DefaultTagID.Conference]: {
sectionLabel: _td("voip|metaspace_video_rooms|conference_room_section"),
isInvite: false,
defaultHidden: false,
},
[DefaultTagID.Untagged]: {
sectionLabel: _td("common|rooms"),
isInvite: false,
defaultHidden: false,
AuxButtonComponent: UntaggedAuxButton,
},
[DefaultTagID.LowPriority]: {
sectionLabel: _td("common|low_priority"),
isInvite: false,
defaultHidden: false,
},
[DefaultTagID.ServerNotice]: {
sectionLabel: _td("common|system_alerts"),
isInvite: false,
defaultHidden: false,
},
// TODO: Replace with archived view: https://github.com/vector-im/element-web/issues/14038
[DefaultTagID.Archived]: {
sectionLabel: _td("common|historical"),
isInvite: false,
defaultHidden: true,
},
[DefaultTagID.Suggested]: {
sectionLabel: _td("room_list|suggested_rooms_heading"),
isInvite: false,
defaultHidden: false,
},
};
export default class LegacyRoomList extends React.PureComponent<IProps, IState> {
private dispatcherRef?: string;
private treeRef = createRef<HTMLDivElement>();
public static contextType = MatrixClientContext;
declare public context: React.ContextType<typeof MatrixClientContext>;
public constructor(props: IProps) {
super(props);
this.state = {
sublists: {},
suggestedRooms: SpaceStore.instance.suggestedRooms,
};
}
public componentDidMount(): void {
this.dispatcherRef = defaultDispatcher.register(this.onAction);
SDKContextClass.instance.roomViewStore.on(UPDATE_EVENT, this.onRoomViewStoreUpdate);
SpaceStore.instance.on(UPDATE_SUGGESTED_ROOMS, this.updateSuggestedRooms);
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.updateLists);
LegacyCallHandler.instance.on(LegacyCallHandlerEvent.ProtocolSupport, this.updateProtocolSupport);
this.updateLists(); // trigger the first update
}
public componentWillUnmount(): void {
SpaceStore.instance.off(UPDATE_SUGGESTED_ROOMS, this.updateSuggestedRooms);
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.updateLists);
defaultDispatcher.unregister(this.dispatcherRef);
SDKContextClass.instance.roomViewStore.off(UPDATE_EVENT, this.onRoomViewStoreUpdate);
LegacyCallHandler.instance.off(LegacyCallHandlerEvent.ProtocolSupport, this.updateProtocolSupport);
}
private updateProtocolSupport = (): void => {
this.updateLists();
};
private onRoomViewStoreUpdate = (): void => {
this.setState({
currentRoomId: SDKContextClass.instance.roomViewStore.getRoomId() ?? undefined,
});
};
private onAction = (payload: ActionPayload): void => {
if (payload.action === Action.ViewRoomDelta) {
const viewRoomDeltaPayload = payload as ViewRoomDeltaPayload;
const currentRoomId = SDKContextClass.instance.roomViewStore.getRoomId();
if (!currentRoomId) return;
const room = this.getRoomDelta(currentRoomId, viewRoomDeltaPayload.delta, viewRoomDeltaPayload.unread);
if (room) {
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: room.roomId,
show_room_tile: true, // to make sure the room gets scrolled into view
metricsTrigger: "WebKeyboardShortcut",
metricsViaKeyboard: true,
});
}
}
};
private getRoomDelta = (roomId: string, delta: number, unread = false): Room => {
const lists = RoomListStore.instance.orderedLists;
const rooms: Room[] = [];
TAG_ORDER.forEach((t) => {
let listRooms = lists[t];
if (unread) {
// filter to only notification rooms (and our current active room so we can index properly)
listRooms = listRooms.filter((r) => {
const state = RoomNotificationStateStore.instance.getRoomState(r);
return state.room.roomId === roomId || state.isUnread;
});
}
rooms.push(...listRooms);
});
const currentIndex = rooms.findIndex((r) => r.roomId === roomId);
// use slice to account for looping around the start
const [room] = rooms.slice((currentIndex + delta) % rooms.length);
return room;
};
private updateSuggestedRooms = (suggestedRooms: ISuggestedRoom[]): void => {
this.setState({ suggestedRooms });
};
private updateLists = (): void => {
const newLists = RoomListStore.instance.orderedLists;
const previousListIds = Object.keys(this.state.sublists);
const newListIds = Object.keys(newLists);
let doUpdate = arrayHasDiff(previousListIds, newListIds);
if (!doUpdate) {
// so we didn't have the visible sublists change, but did the contents of those
// sublists change significantly enough to break the sticky headers? Probably, so
// let's check the length of each.
for (const tagId of newListIds) {
const oldRooms = this.state.sublists[tagId];
const newRooms = newLists[tagId];
if (oldRooms.length !== newRooms.length) {
doUpdate = true;
break;
}
}
}
if (doUpdate) {
// We have to break our reference to the room list store if we want to be able to
// diff the object for changes, so do that.
// @ts-ignore - ITagMap is ts-ignored so this will have to be too
const newSublists = objectWithOnly(newLists, newListIds);
const sublists = objectShallowClone(newSublists, (k, v) => arrayFastClone(v));
this.setState({ sublists }, () => {
this.props.onResize();
});
}
};
private renderSuggestedRooms(): ReactComponentElement<typeof ExtraTile>[] {
return this.state.suggestedRooms.map((room) => {
const name = room.name || room.canonical_alias || room.aliases?.[0] || _t("empty_room");
const avatar = (
<RoomAvatar
oobData={{
name,
avatarUrl: room.avatar_url,
}}
size="32px"
/>
);
const viewRoom = (ev: SyntheticEvent): void => {
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_alias: room.canonical_alias || room.aliases?.[0],
room_id: room.room_id,
via_servers: room.viaServers,
oob_data: {
avatarUrl: room.avatar_url,
name,
},
metricsTrigger: "RoomList",
metricsViaKeyboard: ev.type !== "click",
});
};
return (
<ExtraTile
isMinimized={this.props.isMinimized}
isSelected={this.state.currentRoomId === room.room_id}
displayName={name}
avatar={avatar}
onClick={viewRoom}
key={`suggestedRoomTile_${room.room_id}`}
/>
);
});
}
private renderSublists(): React.ReactElement[] {
// show a skeleton UI if the user is in no rooms and they are not filtering and have no suggested rooms
const showSkeleton =
!this.state.suggestedRooms?.length &&
Object.values(RoomListStore.instance.orderedLists).every((list) => !list?.length);
return TAG_ORDER.map((orderedTagId) => {
let extraTiles: ReactComponentElement<typeof ExtraTile>[] | undefined;
if (orderedTagId === DefaultTagID.Suggested) {
extraTiles = this.renderSuggestedRooms();
}
const aesthetics = TAG_AESTHETICS[orderedTagId];
if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`);
let alwaysVisible = ALWAYS_VISIBLE_TAGS.includes(orderedTagId);
if (
(this.props.activeSpace === MetaSpace.Favourites && orderedTagId !== DefaultTagID.Favourite) ||
(this.props.activeSpace === MetaSpace.People && orderedTagId !== DefaultTagID.DM) ||
(this.props.activeSpace === MetaSpace.Orphans && orderedTagId === DefaultTagID.DM) ||
(this.props.activeSpace === MetaSpace.VideoRooms && orderedTagId === DefaultTagID.DM) ||
(!isMetaSpace(this.props.activeSpace) &&
orderedTagId === DefaultTagID.DM &&
!SettingsStore.getValue("Spaces.showPeopleInSpace", this.props.activeSpace))
) {
alwaysVisible = false;
}
let forceExpanded = false;
if (
(this.props.activeSpace === MetaSpace.Favourites && orderedTagId === DefaultTagID.Favourite) ||
(this.props.activeSpace === MetaSpace.People && orderedTagId === DefaultTagID.DM)
) {
forceExpanded = true;
}
// The cost of mounting/unmounting this component offsets the cost
// of keeping it in the DOM and hiding it when it is not required
return (
<RoomSublist
key={`sublist-${orderedTagId}`}
tagId={orderedTagId}
forRooms={true}
startAsHidden={aesthetics.defaultHidden}
label={aesthetics.sectionLabelRaw ? aesthetics.sectionLabelRaw : _t(aesthetics.sectionLabel)}
AuxButtonComponent={aesthetics.AuxButtonComponent}
isMinimized={this.props.isMinimized}
showSkeleton={showSkeleton}
extraTiles={extraTiles}
resizeNotifier={this.props.resizeNotifier}
alwaysVisible={alwaysVisible}
onListCollapse={this.props.onListCollapse}
forceExpanded={forceExpanded}
/>
);
});
}
public focus(): void {
// focus the first focusable element in this aria treeview widget
const treeItems = this.treeRef.current?.querySelectorAll<HTMLElement>('[role="treeitem"]');
if (!treeItems) return;
[...treeItems].find((e) => e.offsetParent !== null)?.focus();
}
public render(): React.ReactNode {
const sublists = this.renderSublists();
return (
<RovingTabIndexProvider handleHomeEnd handleUpDown onKeyDown={this.props.onKeyDown}>
{({ onKeyDownHandler }) => (
<div
onFocus={this.props.onFocus}
onBlur={this.props.onBlur}
onKeyDown={(ev) => {
const navAction = getKeyBindingsManager().getNavigationAction(ev);
if (
navAction === KeyBindingAction.NextLandmark ||
navAction === KeyBindingAction.PreviousLandmark
) {
LandmarkNavigation.findAndFocusNextLandmark(
Landmark.ROOM_LIST,
navAction === KeyBindingAction.PreviousLandmark,
);
ev.stopPropagation();
ev.preventDefault();
return;
}
onKeyDownHandler(ev);
}}
className="mx_LegacyRoomList"
role="tree"
aria-label={_t("common|rooms")}
ref={this.treeRef}
>
{sublists}
</div>
)}
</RovingTabIndexProvider>
);
}
}
@@ -1,441 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021, 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { ClientEvent, EventType, type Room, RoomEvent, RoomType } from "matrix-js-sdk/src/matrix";
import React, { type JSX, useContext, useEffect, useState } from "react";
import { Tooltip } from "@vector-im/compound-web";
import {
PlusIcon,
UserAddSolidIcon,
SearchIcon,
UserAddIcon,
VideoCallSolidIcon,
ChevronDownIcon,
ChevronUpIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { Action } from "../../../dispatcher/actions";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { useDispatcher } from "../../../hooks/useDispatcher";
import { useEventEmitterState, useTypedEventEmitter, useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
import { useFeatureEnabled } from "../../../hooks/useSettings";
import { _t } from "../../../languageHandler";
import PosthogTrackers from "../../../PosthogTrackers";
import { UIComponent } from "../../../settings/UIFeature";
import {
getMetaSpaceName,
MetaSpace,
type SpaceKey,
UPDATE_HOME_BEHAVIOUR,
UPDATE_SELECTED_SPACE,
} from "../../../stores/spaces";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import {
shouldShowSpaceInvite,
showAddExistingRooms,
showCreateNewRoom,
showCreateNewSubspace,
showSpaceInvite,
} from "../../../utils/space";
import {
ChevronFace,
ContextMenuButton,
ContextMenuTooltipButton,
type MenuProps,
useContextMenu,
} from "../../structures/ContextMenu";
import { BetaPill } from "../beta/BetaCard";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "../context_menus/IconizedContextMenu";
import SpaceContextMenu from "../context_menus/SpaceContextMenu";
import InlineSpinner from "../elements/InlineSpinner";
import { HomeButtonContextMenu } from "../spaces/SpacePanel";
const contextMenuBelow = (elementRect: DOMRect): MenuProps => {
// align the context menu's icons with the icon which opened the context menu
const left = elementRect.left + window.scrollX;
const top = elementRect.bottom + window.scrollY + 12;
const chevronFace = ChevronFace.None;
return { left, top, chevronFace };
};
// Long-running actions that should trigger a spinner
enum PendingActionType {
JoinRoom,
BulkRedact,
}
const usePendingActions = (): Map<PendingActionType, Set<string>> => {
const cli = useContext(MatrixClientContext);
const [actions, setActions] = useState(new Map<PendingActionType, Set<string>>());
const addAction = (type: PendingActionType, key: string): void => {
const keys = new Set(actions.get(type));
keys.add(key);
setActions(new Map(actions).set(type, keys));
};
const removeAction = (type: PendingActionType, key: string): void => {
const keys = new Set(actions.get(type));
if (keys.delete(key)) {
setActions(new Map(actions).set(type, keys));
}
};
useDispatcher(defaultDispatcher, (payload) => {
switch (payload.action) {
case Action.JoinRoom:
addAction(PendingActionType.JoinRoom, payload.roomId);
break;
case Action.JoinRoomReady:
case Action.JoinRoomError:
removeAction(PendingActionType.JoinRoom, payload.roomId);
break;
case Action.BulkRedactStart:
addAction(PendingActionType.BulkRedact, payload.roomId);
break;
case Action.BulkRedactEnd:
removeAction(PendingActionType.BulkRedact, payload.roomId);
break;
}
});
useTypedEventEmitter(cli, ClientEvent.Room, (room: Room) => removeAction(PendingActionType.JoinRoom, room.roomId));
return actions;
};
interface IProps {
onVisibilityChange?(this: void): void;
}
const LegacyRoomListHeader: React.FC<IProps> = ({ onVisibilityChange }) => {
const cli = useContext(MatrixClientContext);
const [mainMenuDisplayed, mainMenuHandle, openMainMenu, closeMainMenu] = useContextMenu<HTMLDivElement>();
const [plusMenuDisplayed, plusMenuHandle, openPlusMenu, closePlusMenu] = useContextMenu<HTMLDivElement>();
const [spaceKey, activeSpace] = useEventEmitterState<[SpaceKey, Room | null]>(
SpaceStore.instance,
UPDATE_SELECTED_SPACE,
() => [SpaceStore.instance.activeSpace, SpaceStore.instance.activeSpaceRoom],
);
const allRoomsInHome = useEventEmitterState(SpaceStore.instance, UPDATE_HOME_BEHAVIOUR, () => {
return SpaceStore.instance.allRoomsInHome;
});
const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
const pendingActions = usePendingActions();
const canShowMainMenu = !!activeSpace || spaceKey === MetaSpace.Home;
useEffect(() => {
if (mainMenuDisplayed && !canShowMainMenu) {
// Space changed under us and we no longer has a main menu to draw
closeMainMenu();
}
}, [closeMainMenu, canShowMainMenu, mainMenuDisplayed]);
const spaceName = useTypedEventEmitterState(activeSpace ?? undefined, RoomEvent.Name, () => activeSpace?.name);
useEffect(() => {
onVisibilityChange?.();
}, [onVisibilityChange]);
const canExploreRooms = shouldShowComponent(UIComponent.ExploreRooms);
const canCreateRooms = shouldShowComponent(UIComponent.CreateRooms);
const canCreateSpaces = shouldShowComponent(UIComponent.CreateSpaces);
const hasPermissionToAddSpaceChild = activeSpace?.currentState?.maySendStateEvent(
EventType.SpaceChild,
cli.getUserId()!,
);
const canAddSubRooms = hasPermissionToAddSpaceChild && canCreateRooms;
const canAddSubSpaces = hasPermissionToAddSpaceChild && canCreateSpaces;
// If the user can't do anything on the plus menu, don't show it. This aims to target the
// plus menu shown on the Home tab primarily: the user has options to use the menu for
// communities and spaces, but is at risk of no options on the Home tab.
const canShowPlusMenu = canCreateRooms || canExploreRooms || canCreateSpaces || activeSpace;
let contextMenu: JSX.Element | undefined;
if (mainMenuDisplayed && mainMenuHandle.current) {
let ContextMenuComponent;
if (activeSpace) {
ContextMenuComponent = SpaceContextMenu;
} else {
ContextMenuComponent = HomeButtonContextMenu;
}
contextMenu = (
<ContextMenuComponent
{...contextMenuBelow(mainMenuHandle.current.getBoundingClientRect())}
space={activeSpace!}
onFinished={closeMainMenu}
hideHeader={true}
/>
);
} else if (plusMenuDisplayed && activeSpace) {
let inviteOption: JSX.Element | undefined;
if (shouldShowSpaceInvite(activeSpace)) {
inviteOption = (
<IconizedContextMenuOption
label={_t("action|invite")}
icon={<UserAddIcon />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
showSpaceInvite(activeSpace);
closePlusMenu();
}}
/>
);
}
let newRoomOptions: JSX.Element | undefined;
if (activeSpace?.currentState.maySendStateEvent(EventType.RoomAvatar, cli.getUserId()!)) {
newRoomOptions = (
<>
<IconizedContextMenuOption
icon={<PlusIcon />}
label={_t("action|new_room")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
showCreateNewRoom(activeSpace);
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateRoomItem", e);
closePlusMenu();
}}
/>
{videoRoomsEnabled && (
<IconizedContextMenuOption
icon={<VideoCallSolidIcon />}
label={_t("action|new_video_room")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
showCreateNewRoom(
activeSpace,
elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo,
);
closePlusMenu();
}}
>
<BetaPill />
</IconizedContextMenuOption>
)}
</>
);
}
contextMenu = (
<IconizedContextMenu
{...contextMenuBelow(plusMenuHandle.current!.getBoundingClientRect())}
onFinished={closePlusMenu}
compact
>
<IconizedContextMenuOptionList first>
{inviteOption}
{newRoomOptions}
<IconizedContextMenuOption
label={_t("action|explore_rooms")}
icon={<SearchIcon />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: activeSpace.roomId,
metricsTrigger: undefined, // other
});
closePlusMenu();
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuExploreRoomsItem", e);
}}
/>
<IconizedContextMenuOption
label={_t("action|add_existing_room")}
icon={<PlusIcon />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
showAddExistingRooms(activeSpace);
closePlusMenu();
}}
disabled={!canAddSubRooms}
title={!canAddSubRooms ? _t("spaces|error_no_permission_add_room") : undefined}
/>
{canCreateSpaces && (
<IconizedContextMenuOption
label={_t("room_list|add_space_label")}
icon={<PlusIcon />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
showCreateNewSubspace(activeSpace);
closePlusMenu();
}}
disabled={!canAddSubSpaces}
title={!canAddSubSpaces ? _t("spaces|error_no_permission_add_space") : undefined}
>
<BetaPill />
</IconizedContextMenuOption>
)}
</IconizedContextMenuOptionList>
</IconizedContextMenu>
);
} else if (plusMenuDisplayed) {
let newRoomOpts: JSX.Element | undefined;
let joinRoomOpt: JSX.Element | undefined;
if (canCreateRooms) {
newRoomOpts = (
<>
<IconizedContextMenuOption
label={_t("action|start_new_chat")}
icon={<UserAddSolidIcon />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
defaultDispatcher.dispatch({ action: Action.CreateChat });
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateChatItem", e);
closePlusMenu();
}}
/>
<IconizedContextMenuOption
label={_t("action|new_room")}
icon={<PlusIcon />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
defaultDispatcher.dispatch({ action: Action.CreateRoom });
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateRoomItem", e);
closePlusMenu();
}}
/>
{videoRoomsEnabled && (
<IconizedContextMenuOption
label={_t("action|new_video_room")}
icon={<VideoCallSolidIcon />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
defaultDispatcher.dispatch({
action: Action.CreateRoom,
type: elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo,
});
closePlusMenu();
}}
>
<BetaPill />
</IconizedContextMenuOption>
)}
</>
);
}
if (canExploreRooms) {
joinRoomOpt = (
<IconizedContextMenuOption
label={_t("room_list|join_public_room_label")}
icon={<SearchIcon />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
defaultDispatcher.dispatch({ action: Action.ViewRoomDirectory });
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuExploreRoomsItem", e);
closePlusMenu();
}}
/>
);
}
contextMenu = (
<IconizedContextMenu
{...contextMenuBelow(plusMenuHandle.current!.getBoundingClientRect())}
onFinished={closePlusMenu}
compact
>
<IconizedContextMenuOptionList first>
{newRoomOpts}
{joinRoomOpt}
</IconizedContextMenuOptionList>
</IconizedContextMenu>
);
}
let title: string;
if (activeSpace && spaceName) {
title = spaceName;
} else {
title = getMetaSpaceName(spaceKey as MetaSpace, allRoomsInHome);
}
const pendingActionSummary = [...pendingActions.entries()]
.filter(([type, keys]) => keys.size > 0)
.map(([type, keys]) => {
switch (type) {
case PendingActionType.JoinRoom:
return _t("room_list|joining_rooms_status", { count: keys.size });
case PendingActionType.BulkRedact:
return _t("room_list|redacting_messages_status", { count: keys.size });
}
})
.join("\n");
let contextMenuButton: JSX.Element = <div className="mx_LegacyRoomListHeader_contextLessTitle">{title}</div>;
if (canShowMainMenu) {
const commonProps = {
ref: mainMenuHandle,
onClick: openMainMenu,
isExpanded: mainMenuDisplayed,
className: "mx_LegacyRoomListHeader_contextMenuButton",
children: (
<>
{title} {mainMenuDisplayed ? <ChevronUpIcon /> : <ChevronDownIcon />}
</>
),
};
if (!!activeSpace) {
contextMenuButton = (
<ContextMenuButton
{...commonProps}
label={_t("room_list|space_menu_label", { spaceName: spaceName ?? activeSpace.name })}
/>
);
} else {
contextMenuButton = <ContextMenuTooltipButton {...commonProps} title={_t("room_list|home_menu_label")} />;
}
}
return (
<aside className="mx_LegacyRoomListHeader" aria-label={_t("room|context_menu|title")}>
{contextMenuButton}
{pendingActionSummary ? (
<Tooltip label={pendingActionSummary} isTriggerInteractive={false}>
<InlineSpinner />
</Tooltip>
) : null}
{canShowPlusMenu && (
<ContextMenuTooltipButton
ref={plusMenuHandle}
onClick={openPlusMenu}
isExpanded={plusMenuDisplayed}
className="mx_LegacyRoomListHeader_plusButton"
title={_t("action|add")}
>
<PlusIcon />
</ContextMenuTooltipButton>
)}
{contextMenu}
</aside>
);
};
export default LegacyRoomListHeader;
@@ -1,142 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { createRef } from "react";
import { type EmptyObject, type Room } from "matrix-js-sdk/src/matrix";
import { CSSTransition } from "react-transition-group";
import { BreadcrumbsStore } from "../../../stores/BreadcrumbsStore";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import { _t } from "../../../languageHandler";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { useRovingTabIndex } from "../../../accessibility/RovingTabIndex";
import Toolbar from "../../../accessibility/Toolbar";
import { Action } from "../../../dispatcher/actions";
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton";
interface IState {
// Both of these control the animation for the breadcrumbs. For details on the
// actual animation, see the CSS.
//
// doAnimation is to lie to the CSSTransition component (see onBreadcrumbsUpdate
// for info). skipFirst is used to try and reduce jerky animation - also see the
// breadcrumb update function for info on that.
doAnimation: boolean;
skipFirst: boolean;
}
const RoomBreadcrumbTile: React.FC<{ room: Room; onClick: (ev: ButtonEvent) => void }> = ({ room, onClick }) => {
const [onFocus, isActive, ref] = useRovingTabIndex();
return (
<AccessibleButton
className="mx_RoomBreadcrumbs_crumb"
onClick={onClick}
aria-label={_t("a11y|room_name", { name: room.name })}
title={room.name}
onFocus={onFocus}
ref={ref}
tabIndex={isActive ? 0 : -1}
placement="right"
>
<DecoratedRoomAvatar
room={room}
size="32px"
displayBadge={true}
hideIfDot={true}
tooltipProps={{ tabIndex: isActive ? 0 : -1 }}
/>
</AccessibleButton>
);
};
export default class RoomBreadcrumbs extends React.PureComponent<EmptyObject, IState> {
private unmounted = false;
private toolbar = createRef<HTMLDivElement>();
public constructor(props: EmptyObject) {
super(props);
this.state = {
doAnimation: true, // technically we want animation on mount, but it won't be perfect
skipFirst: false, // render the thing, as boring as it is
};
}
public componentDidMount(): void {
this.unmounted = false;
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
}
public componentWillUnmount(): void {
this.unmounted = true;
BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
}
private onBreadcrumbsUpdate = (): void => {
if (this.unmounted) return;
// We need to trick the CSSTransition component into updating, which means we need to
// tell it to not animate, then to animate a moment later. This causes two updates
// which means two renders. The skipFirst change is so that our don't-animate state
// doesn't show the breadcrumb we're about to reveal as it causes a visual jump/jerk.
// The second update, on the next available tick, causes the "enter" animation to start
// again and this time we want to show the newest breadcrumb because it'll be hidden
// off screen for the animation.
this.setState({ doAnimation: false, skipFirst: true });
window.setTimeout(() => this.setState({ doAnimation: true, skipFirst: false }), 0);
};
private viewRoom = (room: Room, index: number, viaKeyboard = false): void => {
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: room.roomId,
metricsTrigger: "WebHorizontalBreadcrumbs",
metricsViaKeyboard: viaKeyboard,
});
};
public render(): React.ReactElement {
const tiles = BreadcrumbsStore.instance.rooms.map((r, i) => (
<RoomBreadcrumbTile
key={r.roomId}
room={r}
onClick={(ev: ButtonEvent) => this.viewRoom(r, i, ev.type !== "click")}
/>
));
if (tiles.length > 0) {
// NOTE: The CSSTransition timeout MUST match the timeout in our CSS!
return (
<CSSTransition
appear={true}
in={this.state.doAnimation}
timeout={640}
classNames="mx_RoomBreadcrumbs"
nodeRef={this.toolbar}
>
<Toolbar
className="mx_RoomBreadcrumbs"
aria-label={_t("room_list|breadcrumbs_label")}
ref={this.toolbar}
>
{tiles.slice(this.state.skipFirst ? 1 : 0)}
</Toolbar>
</CSSTransition>
);
} else {
return (
<div className="mx_RoomBreadcrumbs">
<div className="mx_RoomBreadcrumbs_placeholder">{_t("room_list|breadcrumbs_empty")}</div>
</div>
);
}
}
}
@@ -1,857 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2017, 2018 Vector Creations Ltd
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type Room } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { type Enable, Resizable } from "re-resizable";
import { type Direction } from "re-resizable/lib/resizer";
import React, { type JSX, type ComponentType, createRef, type ReactComponentElement, type ReactNode } from "react";
import {
ChevronDownIcon,
ChevronRightIcon,
ChevronUpIcon,
OverflowHorizontalIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { polyfillTouchEvent } from "../../../@types/polyfill";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { RovingAccessibleButton, RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
import { Action } from "../../../dispatcher/actions";
import defaultDispatcher, { type MatrixDispatcher } from "../../../dispatcher/dispatcher";
import { type ActionPayload } from "../../../dispatcher/payloads";
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { _t } from "../../../languageHandler";
import { type ListNotificationState } from "../../../stores/notifications/ListNotificationState";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import { ListAlgorithm, SortAlgorithm } from "../../../stores/room-list/algorithms/models";
import { type ListLayout } from "../../../stores/room-list/ListLayout";
import { DefaultTagID, type TagID } from "../../../stores/room-list-v3/skip-list/tag";
import RoomListLayoutStore from "../../../stores/room-list/RoomListLayoutStore";
import RoomListStore, { LISTS_UPDATE_EVENT, LISTS_LOADING_EVENT } from "../../../stores/room-list/RoomListStore";
import { arrayFastClone, arrayHasOrderChange } from "../../../utils/arrays";
import { objectExcluding, objectHasDiff } from "../../../utils/objects";
import type ResizeNotifier from "../../../utils/ResizeNotifier";
import ContextMenu, {
ChevronFace,
ContextMenuTooltipButton,
StyledMenuItemCheckbox,
StyledMenuItemRadio,
} from "../../structures/ContextMenu";
import AccessibleButton, { type ButtonEvent } from "../../views/elements/AccessibleButton";
import type ExtraTile from "./ExtraTile";
import NotificationBadge from "./NotificationBadge";
import RoomTile from "./RoomTile";
const SHOW_N_BUTTON_HEIGHT = 28; // As defined by CSS
const RESIZE_HANDLE_HEIGHT = 4; // As defined by CSS
export const HEADER_HEIGHT = 32; // As defined by CSS
const MAX_PADDING_HEIGHT = SHOW_N_BUTTON_HEIGHT + RESIZE_HANDLE_HEIGHT;
// HACK: We really shouldn't have to do this.
polyfillTouchEvent();
export interface IAuxButtonProps {
tabIndex: number;
dispatcher?: MatrixDispatcher;
}
interface IProps {
forRooms: boolean;
startAsHidden: boolean;
label: string;
AuxButtonComponent?: ComponentType<IAuxButtonProps>;
isMinimized: boolean;
tagId: TagID;
showSkeleton?: boolean;
alwaysVisible?: boolean;
forceExpanded?: boolean;
resizeNotifier: ResizeNotifier;
extraTiles?: ReactComponentElement<typeof ExtraTile>[] | null;
onListCollapse?: (isExpanded: boolean) => void;
}
function getLabelId(tagId: TagID): string {
return `mx_RoomSublist_label_${tagId}`;
}
// TODO: Use re-resizer's NumberSize when it is exposed as the type
interface ResizeDelta {
width: number;
height: number;
}
type PartialDOMRect = Pick<DOMRect, "left" | "top" | "height">;
interface IState {
contextMenuPosition?: PartialDOMRect;
isResizing: boolean;
isExpanded: boolean; // used for the for expand of the sublist when the room list is being filtered
height: number;
rooms: Room[];
roomsLoading: boolean;
}
export default class RoomSublist extends React.Component<IProps, IState> {
private headerButton = createRef<HTMLDivElement>();
private sublistRef = createRef<HTMLDivElement>();
private tilesRef = createRef<HTMLDivElement>();
private dispatcherRef?: string;
private layout: ListLayout;
private heightAtStart: number;
private notificationState: ListNotificationState;
public constructor(props: IProps) {
super(props);
this.layout = RoomListLayoutStore.instance.getLayoutFor(this.props.tagId);
this.heightAtStart = 0;
this.notificationState = RoomNotificationStateStore.instance.getListState(this.props.tagId);
this.state = {
isResizing: false,
isExpanded: !this.layout.isCollapsed,
height: 0, // to be fixed in a moment, we need `rooms` to calculate this.
rooms: arrayFastClone(RoomListStore.instance.orderedLists[this.props.tagId] || []),
roomsLoading: false,
};
// Why Object.assign() and not this.state.height? Because TypeScript says no.
this.state = Object.assign(this.state, { height: this.calculateInitialHeight() });
}
private calculateInitialHeight(): number {
const requestedVisibleTiles = Math.max(Math.floor(this.layout.visibleTiles), this.layout.minVisibleTiles);
const tileCount = Math.min(this.numTiles, requestedVisibleTiles);
return this.layout.tilesToPixelsWithPadding(tileCount, this.padding);
}
private get padding(): number {
let padding = RESIZE_HANDLE_HEIGHT;
// this is used for calculating the max height of the whole container,
// and takes into account whether there should be room reserved for the show more/less button
// when fully expanded. We can't rely purely on the layout's defaultVisible tile count
// because there are conditions in which we need to know that the 'show more' button
// is present while well under the default tile limit.
const needsShowMore = this.numTiles > this.numVisibleTiles;
// ...but also check this or we'll miss if the section is expanded and we need a
// 'show less'
const needsShowLess = this.numTiles > this.layout.defaultVisibleTiles;
if (needsShowMore || needsShowLess) {
padding += SHOW_N_BUTTON_HEIGHT;
}
return padding;
}
private get extraTiles(): ReactComponentElement<typeof ExtraTile>[] | null {
return this.props.extraTiles ?? null;
}
private get numTiles(): number {
return RoomSublist.calcNumTiles(this.state.rooms, this.extraTiles);
}
private static calcNumTiles(rooms: Room[], extraTiles?: any[] | null): number {
return (rooms || []).length + (extraTiles || []).length;
}
private get numVisibleTiles(): number {
const nVisible = Math.ceil(this.layout.visibleTiles);
return Math.min(nVisible, this.numTiles);
}
public componentDidUpdate(prevProps: Readonly<IProps>, prevState: Readonly<IState>): void {
const prevExtraTiles = prevProps.extraTiles;
// as the rooms can come in one by one we need to reevaluate
// the amount of available rooms to cap the amount of requested visible rooms by the layout
if (RoomSublist.calcNumTiles(prevState.rooms, prevExtraTiles) !== this.numTiles) {
this.setState({ height: this.calculateInitialHeight() });
}
}
public shouldComponentUpdate(nextProps: Readonly<IProps>, nextState: Readonly<IState>): boolean {
if (objectHasDiff(this.props, nextProps)) {
// Something we don't care to optimize has updated, so update.
return true;
}
// Do the same check used on props for state, without the rooms we're going to no-op
const prevStateNoRooms = objectExcluding(this.state, ["rooms"]);
const nextStateNoRooms = objectExcluding(nextState, ["rooms"]);
if (objectHasDiff(prevStateNoRooms, nextStateNoRooms)) {
return true;
}
// If we're supposed to handle extra tiles, take the performance hit and re-render all the
// time so we don't have to consider them as part of the visible room optimization.
const prevExtraTiles = this.props.extraTiles || [];
const nextExtraTiles = nextProps.extraTiles || [];
if (prevExtraTiles.length > 0 || nextExtraTiles.length > 0) {
return true;
}
// If we're about to update the height of the list, we don't really care about which rooms
// are visible or not for no-op purposes, so ensure that the height calculation runs through.
if (RoomSublist.calcNumTiles(nextState.rooms, nextExtraTiles) !== this.numTiles) {
return true;
}
// Before we go analyzing the rooms, we can see if we're collapsed. If we're collapsed, we don't need
// to render anything. We do this after the height check though to ensure that the height gets appropriately
// calculated for when/if we become uncollapsed.
if (!nextState.isExpanded) {
return false;
}
// Quickly double check we're not about to break something due to the number of rooms changing.
if (this.state.rooms.length !== nextState.rooms.length) {
return true;
}
// Finally, determine if the room update (as presumably that's all that's left) is within
// our visible range. If it is, then do a render. If the update is outside our visible range
// then we can skip the update.
//
// We also optimize for order changing here: if the update did happen in our visible range
// but doesn't result in the list re-sorting itself then there's no reason for us to update
// on our own.
const prevSlicedRooms = this.state.rooms.slice(0, this.numVisibleTiles);
const nextSlicedRooms = nextState.rooms.slice(0, this.numVisibleTiles);
if (arrayHasOrderChange(prevSlicedRooms, nextSlicedRooms)) {
return true;
}
// Finally, nothing happened so no-op the update
return false;
}
public componentDidMount(): void {
this.dispatcherRef = defaultDispatcher.register(this.onAction);
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onListsUpdated);
RoomListStore.instance.on(LISTS_LOADING_EVENT, this.onListsLoading);
// Using the passive option to not block the main thread
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
this.tilesRef.current?.addEventListener("scroll", this.onScrollPrevent, { passive: true });
}
public componentWillUnmount(): void {
defaultDispatcher.unregister(this.dispatcherRef);
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onListsUpdated);
RoomListStore.instance.off(LISTS_LOADING_EVENT, this.onListsLoading);
this.tilesRef.current?.removeEventListener("scroll", this.onScrollPrevent);
}
private onListsLoading = (tagId: TagID, isLoading: boolean): void => {
if (this.props.tagId !== tagId) {
return;
}
this.setState({
roomsLoading: isLoading,
});
};
private onListsUpdated = (): void => {
const stateUpdates = {} as IState;
const currentRooms = this.state.rooms;
const newRooms = arrayFastClone(RoomListStore.instance.orderedLists[this.props.tagId] || []);
if (arrayHasOrderChange(currentRooms, newRooms)) {
stateUpdates.rooms = newRooms;
}
if (Object.keys(stateUpdates).length > 0) {
this.setState(stateUpdates);
}
};
private onAction = (payload: ActionPayload): void => {
if (payload.action === Action.ViewRoom && payload.show_room_tile && this.state.rooms) {
// XXX: we have to do this a tick later because we have incorrect intermediate props during a room change
// where we lose the room we are changing from temporarily and then it comes back in an update right after.
setTimeout(() => {
const roomIndex = this.state.rooms.findIndex((r) => r.roomId === payload.room_id);
if (!this.state.isExpanded && roomIndex > -1) {
this.toggleCollapsed();
}
// extend the visible section to include the room if it is entirely invisible
if (roomIndex >= this.numVisibleTiles) {
this.layout.visibleTiles = this.layout.tilesWithPadding(roomIndex + 1, MAX_PADDING_HEIGHT);
this.forceUpdate(); // because the layout doesn't trigger a re-render
}
}, 0);
}
};
private applyHeightChange(newHeight: number): void {
const heightInTiles = Math.ceil(this.layout.pixelsToTiles(newHeight - this.padding));
this.layout.visibleTiles = Math.min(this.numTiles, heightInTiles);
}
private onResize = (
e: MouseEvent | TouchEvent,
travelDirection: Direction,
refToElement: HTMLElement,
delta: ResizeDelta,
): void => {
const newHeight = this.heightAtStart + delta.height;
this.applyHeightChange(newHeight);
this.setState({ height: newHeight });
};
private onResizeStart = (): void => {
this.heightAtStart = this.state.height;
this.setState({ isResizing: true });
};
private onResizeStop = (
e: MouseEvent | TouchEvent,
travelDirection: Direction,
refToElement: HTMLElement,
delta: ResizeDelta,
): void => {
const newHeight = this.heightAtStart + delta.height;
this.applyHeightChange(newHeight);
this.setState({ isResizing: false, height: newHeight });
};
private onShowAllClick = async (): Promise<void> => {
// read number of visible tiles before we mutate it
const numVisibleTiles = this.numVisibleTiles;
const newHeight = this.layout.tilesToPixelsWithPadding(this.numTiles, this.padding);
this.applyHeightChange(newHeight);
this.setState({ height: newHeight }, () => {
// focus the top-most new room
this.focusRoomTile(numVisibleTiles);
});
};
private onShowLessClick = (): void => {
const newHeight = this.layout.tilesToPixelsWithPadding(this.layout.defaultVisibleTiles, this.padding);
this.applyHeightChange(newHeight);
this.setState({ height: newHeight });
};
private focusRoomTile = (index: number): void => {
if (!this.sublistRef.current) return;
const elements = this.sublistRef.current.querySelectorAll<HTMLDivElement>(".mx_RoomTile");
const element = elements && elements[index];
if (element) {
element.focus();
}
};
private onOpenMenuClick = (ev: ButtonEvent): void => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLButtonElement;
this.setState({ contextMenuPosition: target.getBoundingClientRect() });
};
private onContextMenu = (ev: React.MouseEvent): void => {
ev.preventDefault();
ev.stopPropagation();
this.setState({
contextMenuPosition: {
left: ev.clientX,
top: ev.clientY,
height: 0,
},
});
};
private onCloseMenu = (): void => {
this.setState({ contextMenuPosition: undefined });
};
private onUnreadFirstChanged = (): void => {
const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance;
const newAlgorithm = isUnreadFirst ? ListAlgorithm.Natural : ListAlgorithm.Importance;
RoomListStore.instance.setListOrder(this.props.tagId, newAlgorithm);
this.forceUpdate(); // because if the sublist doesn't have any changes then we will miss the list order change
};
private onTagSortChanged = async (sort: SortAlgorithm): Promise<void> => {
RoomListStore.instance.setTagSorting(this.props.tagId, sort);
this.forceUpdate();
};
private onMessagePreviewChanged = (): void => {
this.layout.showPreviews = !this.layout.showPreviews;
this.forceUpdate(); // because the layout doesn't trigger a re-render
};
private onBadgeClick = (ev: React.MouseEvent): void => {
ev.preventDefault();
ev.stopPropagation();
let room;
if (this.props.tagId === DefaultTagID.Invite) {
// switch to first room as that'll be the top of the list for the user
room = this.state.rooms && this.state.rooms[0];
} else {
// find the first room with a count of the same colour as the badge count
room = RoomListStore.instance.orderedLists[this.props.tagId].find((r: Room) => {
const notifState = this.notificationState.getForRoom(r);
return notifState.count > 0 && notifState.level === this.notificationState.level;
});
}
if (room) {
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: room.roomId,
show_room_tile: true, // to make sure the room gets scrolled into view
metricsTrigger: "WebRoomListNotificationBadge",
metricsViaKeyboard: ev.type !== "click",
});
}
};
private onHeaderClick = (): void => {
const possibleSticky = this.headerButton.current?.parentElement;
const sublist = possibleSticky?.parentElement?.parentElement;
const list = sublist?.parentElement?.parentElement;
if (!possibleSticky || !list) return;
// the scrollTop is capped at the height of the header in LeftPanel, the top header is always sticky
const listScrollTop = Math.round(list.scrollTop);
const isAtTop = listScrollTop <= Math.round(HEADER_HEIGHT);
const isAtBottom = listScrollTop >= Math.round(list.scrollHeight - list.offsetHeight);
const isStickyTop = possibleSticky.classList.contains("mx_RoomSublist_headerContainer_stickyTop");
const isStickyBottom = possibleSticky.classList.contains("mx_RoomSublist_headerContainer_stickyBottom");
if ((isStickyBottom && !isAtBottom) || (isStickyTop && !isAtTop)) {
// is sticky - jump to list
sublist.scrollIntoView({ behavior: "smooth" });
} else {
// on screen - toggle collapse
const isExpanded = this.state.isExpanded;
this.toggleCollapsed();
// if the bottom list is collapsed then scroll it in so it doesn't expand off screen
if (!isExpanded && isStickyBottom) {
setTimeout(() => {
sublist.scrollIntoView({ behavior: "smooth" });
}, 0);
}
}
};
private toggleCollapsed = (): void => {
if (this.props.forceExpanded) return;
this.layout.isCollapsed = this.state.isExpanded;
this.setState({ isExpanded: !this.layout.isCollapsed });
if (this.props.onListCollapse) {
this.props.onListCollapse(!this.layout.isCollapsed);
}
};
private onHeaderKeyDown = (ev: React.KeyboardEvent): void => {
const action = getKeyBindingsManager().getRoomListAction(ev);
switch (action) {
case KeyBindingAction.CollapseRoomListSection:
ev.stopPropagation();
if (this.state.isExpanded) {
// Collapse the room sublist if it isn't already
this.toggleCollapsed();
}
break;
case KeyBindingAction.ExpandRoomListSection: {
ev.stopPropagation();
if (!this.state.isExpanded) {
// Expand the room sublist if it isn't already
this.toggleCollapsed();
} else if (this.sublistRef.current) {
// otherwise focus the first room
const element = this.sublistRef.current.querySelector(".mx_RoomTile") as HTMLDivElement;
if (element) {
element.focus();
}
}
break;
}
}
};
private onKeyDown = (ev: React.KeyboardEvent): void => {
const action = getKeyBindingsManager().getAccessibilityAction(ev);
switch (action) {
// On ArrowLeft go to the sublist header
case KeyBindingAction.ArrowLeft:
ev.stopPropagation();
this.headerButton.current?.focus();
break;
// Consume ArrowRight so it doesn't cause focus to get sent to composer
case KeyBindingAction.ArrowRight:
ev.stopPropagation();
}
};
private renderVisibleTiles(): React.ReactElement[] {
if (!this.state.isExpanded && !this.props.forceExpanded) {
// don't waste time on rendering
return [];
}
const tiles: React.ReactElement[] = [];
if (this.state.rooms) {
let visibleRooms = this.state.rooms;
if (!this.props.forceExpanded) {
visibleRooms = visibleRooms.slice(0, this.numVisibleTiles);
}
for (const room of visibleRooms) {
tiles.push(
<RoomTile
room={room}
key={`room-${room.roomId}`}
showMessagePreview={this.layout.showPreviews}
isMinimized={this.props.isMinimized}
tag={this.props.tagId}
/>,
);
}
}
if (this.extraTiles) {
// HACK: We break typing here, but this 'extra tiles' property shouldn't exist.
(tiles as any[]).push(...this.extraTiles);
}
// We only have to do this because of the extra tiles. We do it conditionally
// to avoid spending cycles on slicing. It's generally fine to do this though
// as users are unlikely to have more than a handful of tiles when the extra
// tiles are used.
if (tiles.length > this.numVisibleTiles && !this.props.forceExpanded) {
return tiles.slice(0, this.numVisibleTiles);
}
return tiles;
}
private renderMenu(): ReactNode {
if (this.props.tagId === DefaultTagID.Suggested) return null; // not sortable
let contextMenu: JSX.Element | undefined;
if (this.state.contextMenuPosition) {
const isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic;
const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance;
// Invites don't get some nonsense options, so only add them if we have to.
let otherSections: JSX.Element | undefined;
if (this.props.tagId !== DefaultTagID.Invite) {
otherSections = (
<React.Fragment>
<hr />
<fieldset>
<legend className="mx_RoomSublist_contextMenu_title">{_t("common|appearance")}</legend>
<StyledMenuItemCheckbox
onClose={this.onCloseMenu}
onChange={this.onUnreadFirstChanged}
checked={isUnreadFirst}
>
{_t("room_list|sort_unread_first")}
</StyledMenuItemCheckbox>
<StyledMenuItemCheckbox
onClose={this.onCloseMenu}
onChange={this.onMessagePreviewChanged}
checked={this.layout.showPreviews}
>
{_t("room_list|show_previews")}
</StyledMenuItemCheckbox>
</fieldset>
</React.Fragment>
);
}
contextMenu = (
<ContextMenu
chevronFace={ChevronFace.None}
left={this.state.contextMenuPosition.left}
top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height}
onFinished={this.onCloseMenu}
>
<div className="mx_RoomSublist_contextMenu">
<fieldset>
<legend className="mx_RoomSublist_contextMenu_title">{_t("room_list|sort_by")}</legend>
<StyledMenuItemRadio
onClose={this.onCloseMenu}
onChange={() => this.onTagSortChanged(SortAlgorithm.Recent)}
checked={!isAlphabetical}
name={`mx_${this.props.tagId}_sortBy`}
>
{_t("room_list|sort_by_activity")}
</StyledMenuItemRadio>
<StyledMenuItemRadio
onClose={this.onCloseMenu}
onChange={() => this.onTagSortChanged(SortAlgorithm.Alphabetic)}
checked={isAlphabetical}
name={`mx_${this.props.tagId}_sortBy`}
>
{_t("room_list|sort_by_alphabet")}
</StyledMenuItemRadio>
</fieldset>
{otherSections}
</div>
</ContextMenu>
);
}
return (
<React.Fragment>
<ContextMenuTooltipButton
className="mx_RoomSublist_menuButton"
onClick={this.onOpenMenuClick}
title={_t("room_list|sublist_options")}
isExpanded={!!this.state.contextMenuPosition}
>
<OverflowHorizontalIcon />
</ContextMenuTooltipButton>
{contextMenu}
</React.Fragment>
);
}
private renderHeader(): React.ReactElement {
return (
<RovingTabIndexWrapper inputRef={this.headerButton}>
{({ onFocus, isActive, ref }) => {
const tabIndex = isActive ? 0 : -1;
let ariaLabel = _t("a11y_jump_first_unread_room");
if (this.props.tagId === DefaultTagID.Invite) {
ariaLabel = _t("a11y|jump_first_invite");
}
const badge = (
<NotificationBadge
hideIfDot={true}
notification={this.notificationState}
onClick={this.onBadgeClick}
tabIndex={tabIndex}
aria-label={ariaLabel}
showUnsentTooltip={true}
/>
);
let addRoomButton: JSX.Element | undefined;
if (this.props.AuxButtonComponent) {
const AuxButtonComponent = this.props.AuxButtonComponent;
addRoomButton = <AuxButtonComponent tabIndex={tabIndex} />;
}
const collapsed = !this.state.isExpanded && !this.props.forceExpanded;
const classes = classNames({
mx_RoomSublist_headerContainer: true,
mx_RoomSublist_headerContainer_withAux: !!addRoomButton,
});
const badgeContainer = <div className="mx_RoomSublist_badgeContainer">{badge}</div>;
// Note: the addRoomButton conditionally gets moved around
// the DOM depending on whether or not the list is minimized.
// If we're minimized, we want it below the header so it
// doesn't become sticky.
// The same applies to the notification badge.
return (
<div
className={classes}
onKeyDown={this.onHeaderKeyDown}
onFocus={onFocus}
aria-label={this.props.label}
role="treeitem"
aria-expanded={this.state.isExpanded}
aria-level={1}
aria-selected="false"
>
<div className="mx_RoomSublist_stickableContainer">
<div className="mx_RoomSublist_stickable">
<AccessibleButton
onFocus={onFocus}
ref={ref}
tabIndex={tabIndex}
className="mx_RoomSublist_headerText"
aria-expanded={this.state.isExpanded}
onClick={this.onHeaderClick}
onContextMenu={this.onContextMenu}
title={this.props.isMinimized ? this.props.label : undefined}
>
<span className="mx_RoomSublist_collapseBtn">
{collapsed ? <ChevronRightIcon /> : <ChevronDownIcon />}
</span>
<span id={getLabelId(this.props.tagId)}>{this.props.label}</span>
</AccessibleButton>
{this.renderMenu()}
{this.props.isMinimized ? null : badgeContainer}
{this.props.isMinimized ? null : addRoomButton}
</div>
</div>
{this.props.isMinimized ? badgeContainer : null}
{this.props.isMinimized ? addRoomButton : null}
</div>
);
}}
</RovingTabIndexWrapper>
);
}
private onScrollPrevent(this: void, e: Event): void {
// the RoomTile calls scrollIntoView and the browser may scroll a div we do not wish to be scrollable
// this fixes https://github.com/vector-im/element-web/issues/14413
(e.target as HTMLDivElement).scrollTop = 0;
}
public render(): React.ReactElement {
const visibleTiles = this.renderVisibleTiles();
const hidden = !this.state.rooms.length && !this.props.extraTiles?.length && this.props.alwaysVisible !== true;
const classes = classNames({
mx_RoomSublist: true,
mx_RoomSublist_hasMenuOpen: !!this.state.contextMenuPosition,
mx_RoomSublist_minimized: this.props.isMinimized,
mx_RoomSublist_hidden: hidden,
});
let content: JSX.Element | undefined;
if (this.state.roomsLoading) {
content = <div className="mx_RoomSublist_skeletonUI" />;
} else if (visibleTiles.length > 0 && this.props.forceExpanded) {
content = (
<div className="mx_RoomSublist_resizeBox mx_RoomSublist_resizeBox_forceExpanded">
<div className="mx_RoomSublist_tiles" ref={this.tilesRef}>
{visibleTiles}
</div>
</div>
);
} else if (visibleTiles.length > 0) {
const layout = this.layout; // to shorten calls
const minTiles = Math.min(layout.minVisibleTiles, this.numTiles);
const showMoreAtMinHeight = minTiles < this.numTiles;
const minHeightPadding = RESIZE_HANDLE_HEIGHT + (showMoreAtMinHeight ? SHOW_N_BUTTON_HEIGHT : 0);
const minTilesPx = layout.tilesToPixelsWithPadding(minTiles, minHeightPadding);
const maxTilesPx = layout.tilesToPixelsWithPadding(this.numTiles, this.padding);
const showMoreBtnClasses = classNames({
mx_RoomSublist_showNButton: true,
});
// If we're hiding rooms, show a 'show more' button to the user. This button
// floats above the resize handle, if we have one present. If the user has all
// tiles visible, it becomes 'show less'.
let showNButton: JSX.Element | undefined;
if (maxTilesPx > this.state.height) {
// the height of all the tiles is greater than the section height: we need a 'show more' button
const nonPaddedHeight = this.state.height - RESIZE_HANDLE_HEIGHT - SHOW_N_BUTTON_HEIGHT;
const amountFullyShown = Math.floor(nonPaddedHeight / this.layout.tileHeight);
const numMissing = this.numTiles - amountFullyShown;
const label = _t("room_list|show_n_more", { count: numMissing });
let showMoreText: ReactNode = <span className="mx_RoomSublist_showNButtonText">{label}</span>;
if (this.props.isMinimized) showMoreText = null;
showNButton = (
<RovingAccessibleButton
role="treeitem"
onClick={this.onShowAllClick}
className={showMoreBtnClasses}
aria-label={label}
>
<ChevronDownIcon className="mx_RoomSublist_showNButtonChevron" />
{showMoreText}
</RovingAccessibleButton>
);
} else if (this.numTiles > this.layout.defaultVisibleTiles) {
// we have all tiles visible - add a button to show less
const label = _t("room_list|show_less");
let showLessText: ReactNode = <span className="mx_RoomSublist_showNButtonText">{label}</span>;
if (this.props.isMinimized) showLessText = null;
showNButton = (
<RovingAccessibleButton
role="treeitem"
onClick={this.onShowLessClick}
className={showMoreBtnClasses}
aria-label={label}
>
<ChevronUpIcon className="mx_RoomSublist_showNButtonChevron" />
{showLessText}
</RovingAccessibleButton>
);
}
// Figure out if we need a handle
const handles: Enable = {
bottom: true, // the only one we need, but the others must be explicitly false
bottomLeft: false,
bottomRight: false,
left: false,
right: false,
top: false,
topLeft: false,
topRight: false,
};
if (layout.visibleTiles >= this.numTiles && this.numTiles <= layout.minVisibleTiles) {
// we're at a minimum, don't have a bottom handle
handles.bottom = false;
}
// We have to account for padding so we can accommodate a 'show more' button and
// the resize handle, which are pinned to the bottom of the container. This is the
// easiest way to have a resize handle below the button as otherwise we're writing
// our own resize handling and that doesn't sound fun.
//
// The layout class has some helpers for dealing with padding, as we don't want to
// apply it in all cases. If we apply it in all cases, the resizing feels like it
// goes backwards and can become wildly incorrect (visibleTiles says 18 when there's
// only mathematically 7 possible).
const handleWrapperClasses = classNames({
mx_RoomSublist_resizerHandles: true,
mx_RoomSublist_resizerHandles_showNButton: !!showNButton,
});
content = (
<Resizable
size={{ height: this.state.height } as any}
minHeight={minTilesPx}
maxHeight={maxTilesPx}
onResizeStart={this.onResizeStart}
onResizeStop={this.onResizeStop}
onResize={this.onResize}
handleWrapperClass={handleWrapperClasses}
handleClasses={{ bottom: "mx_RoomSublist_resizerHandle" }}
className="mx_RoomSublist_resizeBox"
enable={handles}
>
<div className="mx_RoomSublist_tiles" ref={this.tilesRef}>
{visibleTiles}
</div>
{showNButton}
</Resizable>
);
} else if (this.props.showSkeleton && this.state.isExpanded) {
content = <div className="mx_RoomSublist_skeletonUI" />;
}
return (
<div
ref={this.sublistRef}
className={classes}
role="group"
aria-hidden={hidden}
aria-labelledby={getLabelId(this.props.tagId)}
onKeyDown={this.onKeyDown}
>
{this.renderHeader()}
{content}
</div>
);
}
}
@@ -1,481 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2015-2017 , 2019-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { createRef } from "react";
import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import classNames from "classnames";
import { OverflowHorizontalIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import type { Call } from "../../../models/Call";
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
import AccessibleButton, { type ButtonEvent } from "../../views/elements/AccessibleButton";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { _t } from "../../../languageHandler";
import { ChevronFace, ContextMenuTooltipButton, type MenuProps } from "../../structures/ContextMenu";
import { DefaultTagID, type TagID } from "../../../stores/room-list-v3/skip-list/tag";
import { type MessagePreview, MessagePreviewStore } from "../../../stores/message-preview";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import { RoomNotifState } from "../../../RoomNotifs";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { RoomNotificationContextMenu } from "../context_menus/RoomNotificationContextMenu";
import NotificationBadge from "./NotificationBadge";
import { type ActionPayload } from "../../../dispatcher/payloads";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import { type NotificationState, NotificationStateEvents } from "../../../stores/notifications/NotificationState";
import { EchoChamber } from "../../../stores/local-echo/EchoChamber";
import { CachedRoomKey, type RoomEchoChamber } from "../../../stores/local-echo/RoomEchoChamber";
import { PROPERTY_UPDATED } from "../../../stores/local-echo/GenericEchoChamber";
import PosthogTrackers from "../../../PosthogTrackers";
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { RoomGeneralContextMenu } from "../context_menus/RoomGeneralContextMenu";
import { CallStore, CallStoreEvent } from "../../../stores/CallStore";
import { SDKContextClass } from "../../../contexts/SDKContextClass";
import { RoomTileSubtitle } from "./RoomTileSubtitle";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import { isKnockDenied } from "../../../utils/membership";
import SettingsStore from "../../../settings/SettingsStore";
import { getNotificationIcon } from "../dialogs/spotlight/RoomResultContextMenus.tsx";
interface Props {
room: Room;
showMessagePreview: boolean;
isMinimized: boolean;
tag: TagID;
}
type PartialDOMRect = Pick<DOMRect, "left" | "bottom">;
interface State {
selected: boolean;
notificationsMenuPosition: PartialDOMRect | null;
generalMenuPosition: PartialDOMRect | null;
call: Call | null;
messagePreview: MessagePreview | null;
}
const messagePreviewId = (roomId: string): string => `mx_RoomTile_messagePreview_${roomId}`;
export const contextMenuBelow = (elementRect: PartialDOMRect): MenuProps => {
// align the context menu's icons with the icon which opened the context menu
const left = elementRect.left + window.scrollX - 9;
const top = elementRect.bottom + window.scrollY + 17;
const chevronFace = ChevronFace.None;
return { left, top, chevronFace };
};
class RoomTile extends React.PureComponent<Props, State> {
private dispatcherRef?: string;
private roomTileRef = createRef<HTMLDivElement>();
private notificationState: NotificationState;
private roomProps: RoomEchoChamber;
public constructor(props: Props) {
super(props);
this.state = {
selected: SDKContextClass.instance.roomViewStore.getRoomId() === this.props.room.roomId,
notificationsMenuPosition: null,
generalMenuPosition: null,
call: CallStore.instance.getCall(this.props.room.roomId),
// generatePreview() will return nothing if the user has previews disabled
messagePreview: null,
};
this.notificationState = RoomNotificationStateStore.instance.getRoomState(this.props.room);
this.roomProps = EchoChamber.forRoom(this.props.room);
}
private onRoomNameUpdate = (room: Room): void => {
this.forceUpdate();
};
private onNotificationUpdate = (): void => {
this.forceUpdate(); // notification state changed - update
};
private onRoomPropertyUpdate = (property: CachedRoomKey): void => {
if (property === CachedRoomKey.NotificationVolume) this.onNotificationUpdate();
// else ignore - not important for this tile
};
private get showContextMenu(): boolean {
return (
this.props.tag !== DefaultTagID.Invite &&
this.props.room.getMyMembership() !== KnownMembership.Knock &&
!isKnockDenied(this.props.room) &&
shouldShowComponent(UIComponent.RoomOptionsMenu)
);
}
private get showMessagePreview(): boolean {
return !this.props.isMinimized && this.props.showMessagePreview;
}
public componentDidUpdate(prevProps: Readonly<Props>, prevState: Readonly<State>): void {
const showMessageChanged = prevProps.showMessagePreview !== this.props.showMessagePreview;
const minimizedChanged = prevProps.isMinimized !== this.props.isMinimized;
if (showMessageChanged || minimizedChanged) {
this.generatePreview();
}
if (prevProps.room?.roomId !== this.props.room?.roomId) {
MessagePreviewStore.instance.off(
MessagePreviewStore.getPreviewChangedEventName(prevProps.room),
this.onRoomPreviewChanged,
);
MessagePreviewStore.instance.on(
MessagePreviewStore.getPreviewChangedEventName(this.props.room),
this.onRoomPreviewChanged,
);
prevProps.room?.off(RoomEvent.Name, this.onRoomNameUpdate);
this.props.room?.on(RoomEvent.Name, this.onRoomNameUpdate);
}
}
public componentDidMount(): void {
this.generatePreview();
// when we're first rendered (or our sublist is expanded) make sure we are visible if we're active
if (this.state.selected) {
this.scrollIntoView();
}
SDKContextClass.instance.roomViewStore.addRoomListener(this.props.room.roomId, this.onActiveRoomUpdate);
this.dispatcherRef = defaultDispatcher.register(this.onAction);
MessagePreviewStore.instance.on(
MessagePreviewStore.getPreviewChangedEventName(this.props.room),
this.onRoomPreviewChanged,
);
this.notificationState.on(NotificationStateEvents.Update, this.onNotificationUpdate);
this.roomProps.on(PROPERTY_UPDATED, this.onRoomPropertyUpdate);
this.props.room.on(RoomEvent.Name, this.onRoomNameUpdate);
CallStore.instance.on(CallStoreEvent.Call, this.onCallChanged);
// Recalculate the call for this room, since it could've changed between
// construction and mounting
this.setState({ call: CallStore.instance.getCall(this.props.room.roomId) });
}
public componentWillUnmount(): void {
SDKContextClass.instance.roomViewStore.removeRoomListener(this.props.room.roomId, this.onActiveRoomUpdate);
MessagePreviewStore.instance.off(
MessagePreviewStore.getPreviewChangedEventName(this.props.room),
this.onRoomPreviewChanged,
);
this.props.room.off(RoomEvent.Name, this.onRoomNameUpdate);
defaultDispatcher.unregister(this.dispatcherRef);
this.notificationState.off(NotificationStateEvents.Update, this.onNotificationUpdate);
this.roomProps.off(PROPERTY_UPDATED, this.onRoomPropertyUpdate);
CallStore.instance.off(CallStoreEvent.Call, this.onCallChanged);
}
private onAction = (payload: ActionPayload): void => {
if (
payload.action === Action.ViewRoom &&
payload.room_id === this.props.room.roomId &&
payload.show_room_tile
) {
setTimeout(() => {
this.scrollIntoView();
});
}
};
private onRoomPreviewChanged = (room: Room): void => {
if (this.props.room && room.roomId === this.props.room.roomId) {
this.generatePreview();
}
};
private onCallChanged = (call: Call, roomId: string): void => {
if (roomId === this.props.room?.roomId) this.setState({ call });
};
private async generatePreview(): Promise<void> {
if (!this.showMessagePreview) {
return;
}
const messagePreview =
(await MessagePreviewStore.instance.getPreviewForRoom(this.props.room, this.props.tag)) ?? null;
this.setState({ messagePreview });
}
private scrollIntoView = (): void => {
if (!this.roomTileRef.current) return;
this.roomTileRef.current.scrollIntoView({
block: "nearest",
behavior: "auto",
});
};
private onTileClick = async (ev: ButtonEvent): Promise<void> => {
ev.preventDefault();
ev.stopPropagation();
const action = getKeyBindingsManager().getAccessibilityAction(ev as React.KeyboardEvent);
const clearSearch = ([KeyBindingAction.Enter, KeyBindingAction.Space] as Array<string | undefined>).includes(
action,
);
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
show_room_tile: true, // make sure the room is visible in the list
room_id: this.props.room.roomId,
clear_search: clearSearch,
metricsTrigger: "RoomList",
metricsViaKeyboard: ev.type !== "click",
});
};
private onActiveRoomUpdate = (isActive: boolean): void => {
this.setState({ selected: isActive });
};
private onNotificationsMenuOpenClick = (ev: ButtonEvent): void => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLButtonElement;
this.setState({ notificationsMenuPosition: target.getBoundingClientRect() });
PosthogTrackers.trackInteraction("WebRoomListRoomTileNotificationsMenu", ev);
};
private onCloseNotificationsMenu = (): void => {
this.setState({ notificationsMenuPosition: null });
};
private onGeneralMenuOpenClick = (ev: ButtonEvent): void => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLButtonElement;
this.setState({ generalMenuPosition: target.getBoundingClientRect() });
};
private onContextMenu = (ev: React.MouseEvent): void => {
// If we don't have a context menu to show, ignore the action.
if (!this.showContextMenu) return;
ev.preventDefault();
ev.stopPropagation();
this.setState({
generalMenuPosition: {
left: ev.clientX,
bottom: ev.clientY,
},
});
};
private onCloseGeneralMenu = (): void => {
this.setState({ generalMenuPosition: null });
};
private renderNotificationsMenu(isActive: boolean): React.ReactElement | null {
if (
MatrixClientPeg.safeGet().isGuest() ||
this.props.tag === DefaultTagID.Archived ||
!this.showContextMenu ||
this.props.isMinimized
) {
// the menu makes no sense in these cases so do not show one
return null;
}
const state = this.roomProps.notificationVolume;
const classes = classNames("mx_RoomTile_notificationsButton", {
// Only show the icon by default if the room is overridden to muted.
// TODO: [FTUE Notifications] Probably need to detect global mute state
mx_RoomTile_notificationsButton_show: state === RoomNotifState.Mute,
});
return (
<React.Fragment>
<ContextMenuTooltipButton
className={classes}
onClick={this.onNotificationsMenuOpenClick}
title={_t("room_list|notification_options")}
isExpanded={!!this.state.notificationsMenuPosition}
tabIndex={isActive ? 0 : -1}
>
{getNotificationIcon(state!)}
</ContextMenuTooltipButton>
{this.state.notificationsMenuPosition && (
<RoomNotificationContextMenu
{...contextMenuBelow(this.state.notificationsMenuPosition)}
onFinished={this.onCloseNotificationsMenu}
room={this.props.room}
/>
)}
</React.Fragment>
);
}
private renderGeneralMenu(): React.ReactElement | null {
if (!this.showContextMenu) return null; // no menu to show
return (
<React.Fragment>
<ContextMenuTooltipButton
className="mx_RoomTile_menuButton"
onClick={this.onGeneralMenuOpenClick}
title={_t("room|context_menu|title")}
isExpanded={!!this.state.generalMenuPosition}
>
<OverflowHorizontalIcon />
</ContextMenuTooltipButton>
{this.state.generalMenuPosition && (
<RoomGeneralContextMenu
{...contextMenuBelow(this.state.generalMenuPosition)}
onFinished={this.onCloseGeneralMenu}
room={this.props.room}
onPostFavoriteClick={(ev: ButtonEvent) =>
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuFavouriteToggle", ev)
}
onPostInviteClick={(ev: ButtonEvent) =>
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuInviteItem", ev)
}
onPostSettingsClick={(ev: ButtonEvent) =>
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuSettingsItem", ev)
}
onPostLeaveClick={(ev: ButtonEvent) =>
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuLeaveItem", ev)
}
onPostMarkAsReadClick={(ev: ButtonEvent) =>
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkRead", ev)
}
onPostMarkAsUnreadClick={(ev: ButtonEvent) =>
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkUnread", ev)
}
/>
)}
</React.Fragment>
);
}
/**
* RoomTile has a subtile if one of the following applies:
* - there is a call
* - message previews are enabled and there is a previewable message
*/
private get shouldRenderSubtitle(): boolean {
return !!this.state.call || (this.props.showMessagePreview && !!this.state.messagePreview);
}
public render(): React.ReactElement {
const classes = classNames({
mx_RoomTile: true,
mx_RoomTile_sticky:
SettingsStore.getValue("feature_ask_to_join") &&
(this.props.room.getMyMembership() === KnownMembership.Knock || isKnockDenied(this.props.room)),
mx_RoomTile_selected: this.state.selected,
mx_RoomTile_hasMenuOpen: !!(this.state.generalMenuPosition || this.state.notificationsMenuPosition),
mx_RoomTile_minimized: this.props.isMinimized,
});
let name = this.props.room.name;
if (typeof name !== "string") name = "";
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
let badge: React.ReactNode;
if (!this.props.isMinimized && this.notificationState) {
// aria-hidden because we summarise the unread count/highlight status in a manual aria-label below
badge = (
<div className="mx_RoomTile_badgeContainer" aria-hidden="true">
<NotificationBadge notification={this.notificationState} roomId={this.props.room.roomId} />
</div>
);
}
const subtitle = this.shouldRenderSubtitle ? (
<RoomTileSubtitle
call={this.state.call}
messagePreview={this.state.messagePreview}
roomId={this.props.room.roomId}
showMessagePreview={this.props.showMessagePreview}
/>
) : null;
const titleClasses = classNames({
mx_RoomTile_title: true,
mx_RoomTile_titleWithSubtitle: !!subtitle,
mx_RoomTile_titleHasUnreadEvents: this.notificationState.isUnread,
});
const titleContainer = this.props.isMinimized ? null : (
<div className="mx_RoomTile_titleContainer">
<div title={name} className={titleClasses} tabIndex={-1}>
<span dir="auto">{name}</span>
</div>
{subtitle}
</div>
);
let ariaLabel = name;
// The following labels are written in such a fashion to increase screen reader efficiency (speed).
if (this.props.tag === DefaultTagID.Invite) {
// append nothing
} else if (this.notificationState.hasMentions) {
ariaLabel +=
" " +
_t("a11y|n_unread_messages_mentions", {
count: this.notificationState.count,
});
} else if (this.notificationState.hasUnreadCount) {
ariaLabel +=
" " +
_t("a11y|n_unread_messages", {
count: this.notificationState.count,
});
} else if (this.notificationState.isUnread) {
ariaLabel += " " + _t("a11y|unread_messages");
}
let ariaDescribedBy: string;
if (this.showMessagePreview) {
ariaDescribedBy = messagePreviewId(this.props.room.roomId);
}
return (
<RovingTabIndexWrapper inputRef={this.roomTileRef}>
{({ onFocus, isActive, ref }) => (
<AccessibleButton
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
ref={ref}
className={classes}
onClick={this.onTileClick}
onContextMenu={this.onContextMenu}
role="treeitem"
aria-label={ariaLabel}
aria-selected={this.state.selected}
aria-describedby={ariaDescribedBy}
title={this.props.isMinimized && !this.state.generalMenuPosition ? name : undefined}
>
<DecoratedRoomAvatar
room={this.props.room}
size="32px"
displayBadge={this.props.isMinimized}
tooltipProps={{ tabIndex: isActive ? 0 : -1 }}
/>
{titleContainer}
{badge}
{this.renderGeneralMenu()}
{this.renderNotificationsMenu(isActive)}
</AccessibleButton>
)}
</RovingTabIndexWrapper>
);
}
}
export default RoomTile;
@@ -1,38 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type FC } from "react";
import type { Call } from "../../../models/Call";
import { _t } from "../../../languageHandler";
import { useConnectionState, useParticipantCount } from "../../../hooks/useCall";
import { ConnectionState } from "../../../models/Call";
import { LiveContentSummary } from "./LiveContentSummary";
interface Props {
call: Call;
}
export const RoomTileCallSummary: FC<Props> = ({ call }) => {
let text: string;
let active: boolean;
switch (useConnectionState(call)) {
case ConnectionState.Disconnected:
text = _t("common|video");
active = false;
break;
case ConnectionState.Connected:
case ConnectionState.Disconnecting:
text = _t("common|joined");
active = true;
break;
}
return <LiveContentSummary text={text} active={active} participantCount={useParticipantCount(call)} />;
};
@@ -1,51 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import classNames from "classnames";
import { ThreadsIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { type MessagePreview } from "../../../stores/message-preview";
import { type Call } from "../../../models/Call";
import { RoomTileCallSummary } from "./RoomTileCallSummary";
interface Props {
call: Call | null;
messagePreview: MessagePreview | null;
roomId: string;
showMessagePreview: boolean;
}
const messagePreviewId = (roomId: string): string => `mx_RoomTile_messagePreview_${roomId}`;
export const RoomTileSubtitle: React.FC<Props> = ({ call, messagePreview, roomId, showMessagePreview }) => {
if (call) {
return (
<div className="mx_RoomTile_subtitle">
<RoomTileCallSummary call={call} />
</div>
);
}
if (showMessagePreview && messagePreview) {
const className = classNames("mx_RoomTile_subtitle", {
"mx_RoomTile_subtitle--thread-reply": messagePreview.isThreadReply,
});
const icon = messagePreview.isThreadReply ? <ThreadsIcon className="mx_Icon mx_Icon_12" /> : null;
return (
<div className={className} id={messagePreviewId(roomId)} title={messagePreview.text}>
{icon}
<span className="mx_RoomTile_subtitle_text">{messagePreview.text}</span>
</div>
);
}
return null;
};
@@ -117,8 +117,6 @@ const SpellCheckSection: React.FC = () => {
};
export default class PreferencesUserSettingsTab extends React.Component<EmptyObject, IState> {
private static ROOM_LIST_SETTINGS: BooleanSettingKey[] = ["breadcrumbs"];
private static SPACES_SETTINGS: BooleanSettingKey[] = ["Spaces.allRoomsInHome"];
private static KEYBINDINGS_SETTINGS: BooleanSettingKey[] = ["ctrlFForSearch"];
@@ -241,7 +239,6 @@ export default class PreferencesUserSettingsTab extends React.Component<EmptyObj
timezone: TimezoneHandler.shortBrowserTimezone(),
});
const newRoomListEnabled = SettingsStore.getValue("feature_new_room_list");
const brand = SdkConfig.get().brand;
const timezones = this.state.timezones.map((tz) => {
@@ -274,11 +271,7 @@ export default class PreferencesUserSettingsTab extends React.Component<EmptyObj
)}
<SettingsSubsection heading={_t("settings|preferences|room_list_heading")} formWrap>
{!newRoomListEnabled && this.renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS)}
{/* The settings is on device level where the other room list settings are on account level */}
{newRoomListEnabled && (
<SettingsFlag name="RoomList.showMessagePreview" level={SettingLevel.DEVICE} />
)}
<SettingsFlag name="RoomList.showMessagePreview" level={SettingLevel.DEVICE} />
</SettingsSubsection>
<SettingsSubsection heading={_t("common|spaces")} formWrap>
@@ -7,12 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/
import React, { type ChangeEvent, useMemo } from "react";
import {
VideoCallSolidIcon,
HomeSolidIcon,
UserProfileSolidIcon,
FavouriteSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { VideoCallSolidIcon, HomeSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { _t } from "../../../../../languageHandler";
import SettingsStore from "../../../../../settings/SettingsStore";
@@ -53,8 +48,6 @@ export const onMetaSpaceChangeFactory =
const SidebarUserSettingsTab: React.FC = () => {
const {
[MetaSpace.Home]: homeEnabled,
[MetaSpace.Favourites]: favouritesEnabled,
[MetaSpace.People]: peopleEnabled,
[MetaSpace.Orphans]: orphansEnabled,
[MetaSpace.VideoRooms]: videoRoomsEnabled,
} = useSettingValue("Spaces.enabledMetaSpaces");
@@ -71,9 +64,6 @@ const SidebarUserSettingsTab: React.FC = () => {
PosthogTrackers.trackInteraction("WebSettingsSidebarTabSpacesCheckbox", event, 1);
};
// "Favourites" and "People" meta spaces are not available in the new room list
const newRoomListEnabled = useSettingValue("feature_new_room_list");
return (
<SettingsTab>
<SettingsSection>
@@ -103,36 +93,6 @@ const SidebarUserSettingsTab: React.FC = () => {
{_t("settings|sidebar|metaspaces_home_all_rooms")}
</StyledCheckbox>
{!newRoomListEnabled && (
<>
<StyledCheckbox
checked={!!favouritesEnabled}
onChange={onMetaSpaceChangeFactory(
MetaSpace.Favourites,
"WebSettingsSidebarTabSpacesCheckbox",
)}
className="mx_SidebarUserSettingsTab_checkbox"
description={_t("settings|sidebar|metaspaces_favourites_description")}
>
<FavouriteSolidIcon className="mx_SidebarUserSettingsTab_icon" />
{_t("common|favourites")}
</StyledCheckbox>
<StyledCheckbox
checked={!!peopleEnabled}
onChange={onMetaSpaceChangeFactory(
MetaSpace.People,
"WebSettingsSidebarTabSpacesCheckbox",
)}
className="mx_SidebarUserSettingsTab_checkbox"
description={_t("settings|sidebar|metaspaces_people_description")}
>
<UserProfileSolidIcon className="mx_SidebarUserSettingsTab_icon" />
{_t("common|people")}
</StyledCheckbox>
</>
)}
<StyledCheckbox
checked={!!orphansEnabled}
onChange={onMetaSpaceChangeFactory(MetaSpace.Orphans, "WebSettingsSidebarTabSpacesCheckbox")}
@@ -8,25 +8,15 @@ Please see LICENSE files in the repository root for full details.
import React, { type JSX } from "react";
import classNames from "classnames";
import {
OverflowHorizontalIcon,
UserProfileSolidIcon,
FavouriteSolidIcon,
PinSolidIcon,
SettingsSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { SettingsSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { IconButton, Text, Tooltip } from "@vector-im/compound-web";
import { _t } from "../../../languageHandler";
import ContextMenu, { alwaysAboveRightOf, ChevronFace, useContextMenu } from "../../structures/ContextMenu";
import AccessibleButton from "../elements/AccessibleButton";
import StyledCheckbox from "../elements/StyledCheckbox";
import { MetaSpace } from "../../../stores/spaces";
import { useSettingValue } from "../../../hooks/useSettings";
import { onMetaSpaceChangeFactory } from "../settings/tabs/user/SidebarUserSettingsTab";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { UserTab } from "../dialogs/UserTab";
import QuickThemeSwitcher from "./QuickThemeSwitcher";
import Modal from "../../../Modal";
import DevtoolsDialog from "../dialogs/DevtoolsDialog";
@@ -37,22 +27,15 @@ const QuickSettingsButton: React.FC<{
}> = ({ isPanelCollapsed = false }) => {
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLButtonElement>();
const { [MetaSpace.Favourites]: favouritesEnabled, [MetaSpace.People]: peopleEnabled } =
useSettingValue("Spaces.enabledMetaSpaces");
const currentRoomId = SDKContextClass.instance.roomViewStore.getRoomId();
const developerModeEnabled = useSettingValue("developerMode");
// "Favourites" and "People" meta spaces are not available in the new room list
const newRoomListEnabled = useSettingValue("feature_new_room_list");
let contextMenu: JSX.Element | undefined;
if (menuDisplayed && handle.current) {
contextMenu = (
<ContextMenu
{...alwaysAboveRightOf(handle.current.getBoundingClientRect(), ChevronFace.None, 16)}
wrapperClassName={classNames("mx_QuickSettingsButton_ContextMenuWrapper", {
mx_QuickSettingsButton_ContextMenuWrapper_new_room_list: newRoomListEnabled,
})}
wrapperClassName="mx_QuickSettingsButton_ContextMenuWrapper"
// Eventually replace with a properly aria-labelled menu
data-testid="quick-settings-menu"
onFinished={closeMenu}
@@ -91,49 +74,6 @@ const QuickSettingsButton: React.FC<{
</AccessibleButton>
)}
{!newRoomListEnabled && (
<>
<h4>
<PinSolidIcon className="mx_QuickSettingsButton_icon" />
{_t("quick_settings|metaspace_section")}
</h4>
<StyledCheckbox
className="mx_QuickSettingsButton_option"
checked={!!favouritesEnabled}
onChange={onMetaSpaceChangeFactory(
MetaSpace.Favourites,
"WebQuickSettingsPinToSidebarCheckbox",
)}
>
<FavouriteSolidIcon className="mx_QuickSettingsButton_icon" />
{_t("common|favourites")}
</StyledCheckbox>
<StyledCheckbox
className="mx_QuickSettingsButton_option"
checked={!!peopleEnabled}
onChange={onMetaSpaceChangeFactory(
MetaSpace.People,
"WebQuickSettingsPinToSidebarCheckbox",
)}
>
<UserProfileSolidIcon className="mx_QuickSettingsButton_icon" />
{_t("common|people")}
</StyledCheckbox>
<AccessibleButton
className="mx_QuickSettingsButton_moreOptionsButton mx_QuickSettingsButton_option"
onClick={() => {
closeMenu();
defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Sidebar,
});
}}
>
<OverflowHorizontalIcon className="mx_QuickSettingsButton_icon" />
{_t("quick_settings|sidebar_settings")}
</AccessibleButton>
</>
)}
<QuickThemeSwitcher requestClose={closeMenu} />
</ContextMenu>
);
@@ -405,8 +405,6 @@ const SpacePanel: React.FC = () => {
}
});
const newRoomListEnabled = useSettingValue("feature_new_room_list");
const userMenuVm = useCreateAutoDisposedViewModel(
() =>
new UserMenuViewModel(
@@ -444,7 +442,6 @@ const SpacePanel: React.FC = () => {
<nav
className={classNames("mx_SpacePanel", {
collapsed: isPanelCollapsed,
newUi: newRoomListEnabled,
})}
onKeyDown={(ev) => {
const navAction = getKeyBindingsManager().getNavigationAction(ev);
-25
View File
@@ -1,25 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { useState } from "react";
export default function useHover(
ignoreHover: (ev: React.MouseEvent) => boolean,
): [boolean, { onMouseOver: () => void; onMouseLeave: () => void; onMouseMove: (ev: React.MouseEvent) => void }] {
const [hovered, setHoverState] = useState(false);
const props = {
onMouseOver: () => setHoverState(true),
onMouseLeave: () => setHoverState(false),
onMouseMove: (ev: React.MouseEvent): void => {
setHoverState(!ignoreHover(ev));
},
};
return [hovered, props];
}
+1 -52
View File
@@ -11,8 +11,6 @@
"one": "1 unread mention.",
"other": "%(count)s unread messages including mentions."
},
"recent_rooms": "Recent rooms",
"room_name": "Room %(name)s",
"room_status_bar": "Room status bar",
"seek_bar_label": "Audio seek bar",
"unread_messages": "Unread messages."
@@ -22,7 +20,6 @@
"accept": "Accept",
"add": "Add",
"add_existing_room": "Add existing room",
"add_people": "Add people",
"apply": "Apply",
"approve": "Approve",
"ask_to_join": "Ask to join",
@@ -57,7 +54,6 @@
"enter_fullscreen": "Enter fullscreen",
"exit_fullscreeen": "Exit fullscreen",
"expand": "Expand",
"explore_public_rooms": "Explore public rooms",
"explore_rooms": "Explore rooms",
"export": "Export",
"forward": "Forward",
@@ -69,8 +65,6 @@
"ignore": "Ignore",
"import": "Import",
"invite": "Invite",
"invite_to_space": "Invite to space",
"invites_list": "Invites",
"join": "Join",
"learn_more": "Learn more",
"leave": "Leave",
@@ -116,8 +110,6 @@
"sign_out": "Remove this device",
"skip": "Skip",
"start": "Start",
"start_chat": "Start chat",
"start_new_chat": "Start new chat",
"stop": "Stop",
"submit": "Submit",
"subscribe": "Subscribe",
@@ -496,7 +488,6 @@
"go_to_settings": "Go to Settings",
"guest": "Guest",
"help": "Help",
"historical": "Historical",
"home": "Home",
"homeserver": "Homeserver",
"identity_server": "Identity server",
@@ -508,7 +499,6 @@
"light": "Light",
"loading": "Loading…",
"location": "Location",
"low_priority": "Low priority",
"matrix": "Matrix",
"message": "Message",
"message_layout": "Message layout",
@@ -574,7 +564,6 @@
"success": "Success",
"suggestions": "Suggestions",
"support": "Support",
"system_alerts": "System Alerts",
"theme": "Theme",
"thread": "Thread",
"threads": "Threads",
@@ -591,7 +580,6 @@
"username": "Username",
"verified": "Verified",
"version": "Version",
"video": "Video",
"video_room": "Video room",
"view_message": "View message",
"warning": "Warning"
@@ -1575,7 +1563,6 @@
"login_with_qr": "Log in with QR code",
"mjolnir": "New ways to ignore people",
"msc3531_hide_messages_pending_moderation": "Let moderators hide messages pending moderation.",
"new_room_list": "Enable new room list",
"notification_settings": "New Notification Settings",
"notification_settings_beta_caption": "Introducing a simpler way to change your notification settings. Customize your %(brand)s, just the way you like.",
"notification_settings_beta_title": "Notification Settings",
@@ -1586,7 +1573,6 @@
"sliding_sync_description": "Under active development, cannot be disabled. Currently, not compatible with Element Call.",
"sliding_sync_disabled_notice": "Sign in again to disable",
"sliding_sync_server_no_support": "Your server lacks support",
"under_active_development": "Under active development.",
"unrealiable_e2e": "Unreliable in encrypted rooms",
"video_rooms": "Video rooms",
"video_rooms_a_new_way_to_chat": "A new way to chat over voice and video in %(brand)s.",
@@ -1642,9 +1628,6 @@
"room_rejoin_warning": "This room is not public. You will not be able to rejoin without an invite.",
"space_rejoin_warning": "This space is not public. You will not be able to rejoin without an invite."
},
"left_panel": {
"open_dial_pad": "Open dial pad"
},
"lightbox": {
"rotate_left": "Rotate Left",
"rotate_right": "Rotate Right",
@@ -1841,7 +1824,6 @@
},
"quick_settings": {
"all_settings": "All settings",
"metaspace_section": "Pin to sidebar",
"sidebar_settings": "More options",
"title": "Quick settings"
},
@@ -2144,46 +2126,21 @@
"waiting_for_join_title": "Waiting for users to join %(brand)s"
},
"room_list": {
"add_room_label": "Add room",
"add_space_label": "Add space",
"breadcrumbs_empty": "No recently visited rooms",
"breadcrumbs_label": "Recently visited rooms",
"failed_add_tag": "Failed to add tag %(tagName)s to room",
"failed_remove_tag": "Failed to remove tag %(tagName)s from room",
"failed_set_dm_tag": "Failed to set direct message tag",
"home_menu_label": "Home options",
"join_public_room_label": "Join public room",
"joining_rooms_status": {
"one": "Currently joining %(count)s room",
"other": "Currently joining %(count)s rooms"
},
"list_title": "Room list",
"more_options": {
"leave_room": "Leave room"
},
"notification_options": "Notification options",
"redacting_messages_status": {
"one": "Currently removing messages in %(count)s room",
"other": "Currently removing messages in %(count)s rooms"
},
"section": {
"chats": "Chats",
"favourites": "Favourites",
"low_priority": "Low Priority"
},
"show_less": "Show less",
"show_n_more": {
"one": "Show %(count)s more",
"other": "Show %(count)s more"
},
"show_previews": "Show previews of messages",
"sort_by": "Sort by",
"sort_by_activity": "Activity",
"sort_by_alphabet": "A-Z",
"sort_unread_first": "Show rooms with unread messages first",
"space_menu_label": "%(spaceName)s menu",
"sublist_options": "List options",
"suggested_rooms_heading": "Suggested Rooms"
"show_less": "Show less"
},
"room_settings": {
"access": {
@@ -3007,13 +2964,11 @@
"showbold": "Show all activity in the room list (dots or number of unread messages)",
"sidebar": {
"dialog_title": "<strong>Settings:</strong> Sidebar",
"metaspaces_favourites_description": "Group all your favourite rooms and people in one place.",
"metaspaces_home_all_rooms": "Show all rooms",
"metaspaces_home_all_rooms_description": "Show all your rooms in Home, even if they're in a space.",
"metaspaces_home_description": "Home is useful for getting an overview of everything.",
"metaspaces_orphans": "Rooms outside of a space",
"metaspaces_orphans_description": "Group all your rooms that aren't part of a space in one place.",
"metaspaces_people_description": "Group all your people in one place.",
"metaspaces_subsection": "Spaces to show",
"metaspaces_video_rooms": "Video rooms and conferences",
"metaspaces_video_rooms_description": "Group all private video rooms and conferences.",
@@ -3233,12 +3188,6 @@
"space_settings": {
"title": "Settings - %(spaceName)s"
},
"spaces": {
"error_no_permission_add_room": "You do not have permissions to add rooms to this space",
"error_no_permission_add_space": "You do not have permissions to add spaces to this space",
"error_no_permission_create_room": "You do not have permissions to create new rooms in this space",
"error_no_permission_invite": "You do not have permissions to invite people to this space"
},
"spotlight": {
"public_rooms": {
"network_dropdown_add_dialog_description": "Enter the name of a new server you want to explore.",
@@ -1,60 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { BaseDistributor } from "./fixed";
import ResizeItem from "../item";
import { type IConfig } from "../resizer";
import type Resizer from "../resizer";
import type Sizer from "../sizer";
export interface ICollapseConfig extends IConfig {
toggleSize: number;
onCollapsed?(collapsed: boolean, id: string | null, element: HTMLElement): void;
isItemCollapsed(element: HTMLElement): boolean;
}
export class CollapseItem extends ResizeItem<ICollapseConfig> {
public notifyCollapsed(collapsed: boolean): void {
this.resizer.config?.onCollapsed?.(collapsed, this.id, this.domNode);
}
public get isCollapsed(): boolean {
return this.resizer.config?.isItemCollapsed?.(this.domNode) ?? false;
}
}
export default class CollapseDistributor extends BaseDistributor<ICollapseConfig, CollapseItem> {
public static createItem(
resizeHandle: HTMLDivElement,
resizer: Resizer<ICollapseConfig, CollapseItem>,
sizer: Sizer,
container?: HTMLElement,
): CollapseItem {
return new CollapseItem(resizeHandle, resizer, sizer, container);
}
private readonly toggleSize: number | undefined;
private isCollapsed: boolean;
public constructor(item: CollapseItem) {
super(item);
this.toggleSize = item.resizer?.config?.toggleSize;
this.isCollapsed = item.isCollapsed;
}
public resize(newSize: number): void {
const isCollapsedSize = !!this.toggleSize && newSize < this.toggleSize;
if (isCollapsedSize !== this.isCollapsed) {
this.isCollapsed = isCollapsedSize;
this.item.notifyCollapsed(isCollapsedSize);
}
if (!isCollapsedSize) {
super.resize(newSize);
}
}
}
-12
View File
@@ -1,12 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
export { default as FixedDistributor } from "./distributors/fixed";
export { default as PercentageDistributor } from "./distributors/percentage";
export { default as CollapseDistributor } from "./distributors/collapse";
export { default as Resizer } from "./resizer";
-10
View File
@@ -225,7 +225,6 @@ export interface Settings {
"feature_location_share_live": IFeature;
"feature_dynamic_room_predecessors": IFeature;
"feature_render_reaction_images": IFeature;
"feature_new_room_list": IFeature;
"feature_retention": IFeature;
"feature_ask_to_join": IFeature;
"feature_notifications": IFeature;
@@ -655,15 +654,6 @@ export const SETTINGS: Settings = {
supportedLevelsAreOrdered: true,
default: false,
},
"feature_new_room_list": {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG_PRIORITISED,
labsGroup: LabGroup.Ui,
displayName: _td("labs|new_room_list"),
description: _td("labs|under_active_development"),
isFeature: true,
default: true,
controller: new ReloadOnChangeController(),
},
"feature_login_with_qr": {
supportedLevels: [SettingLevel.CONFIG],
labsGroup: LabGroup.Ui,
+3 -36
View File
@@ -6,9 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { type Room, RoomEvent, ClientEvent } from "matrix-js-sdk/src/matrix";
import { type Room, ClientEvent } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
import SettingsStore from "../settings/SettingsStore";
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
@@ -24,7 +23,6 @@ const MAX_ROOMS = 20; // arbitrary
const AUTOJOIN_WAIT_THRESHOLD_MS = 90000; // 90s, the time we wait for an autojoined room to show up
interface IState {
enabled?: boolean;
rooms?: Room[];
}
@@ -41,7 +39,6 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
super(defaultDispatcher);
SettingsStore.monitorSetting("breadcrumb_rooms", null);
SettingsStore.monitorSetting("breadcrumbs", null);
}
public static get instance(): BreadcrumbsStore {
@@ -52,29 +49,10 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
return this.state.rooms || [];
}
public get visible(): boolean {
return !!this.state.enabled && this.meetsRoomRequirement;
}
/**
* Do we have enough rooms to justify showing the breadcrumbs?
* (Or is the labs feature enabled?)
*
* @returns true if there are at least 20 visible rooms.
*/
public get meetsRoomRequirement(): boolean {
const msc3946ProcessDynamicPredecessor = SettingsStore.getValue("feature_dynamic_room_predecessors");
return !!this.matrixClient && this.matrixClient.getVisibleRooms(msc3946ProcessDynamicPredecessor).length >= 20;
}
protected async onAction(payload: SettingUpdatedPayload | ViewRoomPayload | JoinRoomPayload): Promise<void> {
if (!this.matrixClient) return;
if (payload.action === Action.SettingUpdated) {
if (payload.settingName === "breadcrumb_rooms") {
await this.updateRooms();
} else if (payload.settingName === "breadcrumbs") {
await this.updateState({ enabled: SettingsStore.getValue("breadcrumbs", null) });
}
if (payload.action === Action.SettingUpdated && payload.settingName === "breadcrumb_rooms") {
await this.updateRooms();
} else if (payload.action === Action.ViewRoom) {
if (payload.auto_join && payload.room_id && !this.matrixClient.getRoom(payload.room_id)) {
// Queue the room instead of pushing it immediately. We're probably just
@@ -94,29 +72,18 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
protected async onReady(): Promise<void> {
await this.updateRooms();
await this.updateState({ enabled: SettingsStore.getValue("breadcrumbs", null) });
if (this.matrixClient) {
this.matrixClient.on(RoomEvent.MyMembership, this.onMyMembership);
this.matrixClient.on(ClientEvent.Room, this.onRoom);
}
}
protected async onNotReady(): Promise<void> {
if (this.matrixClient) {
this.matrixClient.removeListener(RoomEvent.MyMembership, this.onMyMembership);
this.matrixClient.removeListener(ClientEvent.Room, this.onRoom);
}
}
private onMyMembership = async (room: Room): Promise<void> => {
// Only turn on breadcrumbs is the user hasn't explicitly turned it off again.
const settingValueRaw = SettingsStore.getValue("breadcrumbs", null, /*excludeDefault=*/ true);
if (this.meetsRoomRequirement && isNullOrUndefined(settingValueRaw)) {
await SettingsStore.setValue("breadcrumbs", null, SettingLevel.ACCOUNT, true);
}
};
private onRoom = async (room: Room): Promise<void> => {
const waitingRoom = this.waitingRooms.find((r) => r.roomId === room.roomId);
if (!waitingRoom) return;
@@ -10,7 +10,6 @@ import {
type Room,
RelationType,
type MatrixEvent,
type Thread,
M_POLL_START,
RoomEvent,
type EmptyObject,
@@ -31,7 +30,6 @@ import { ReactionEventPreview } from "./previews/ReactionEventPreview";
import { UPDATE_EVENT } from "../AsyncStore";
import { type Preview } from "./previews";
import shouldHideEvent from "../../shouldHideEvent";
import SettingsStore from "../../settings/SettingsStore";
// Emitted event for when a room's preview has changed. First argument will the room for which
// the change happened.
@@ -153,15 +151,6 @@ export class MessagePreviewStore extends AsyncStoreWithClient<EmptyObject> {
private async generatePreview(room: Room, tagId?: TagID): Promise<void> {
const events = [...room.getLiveTimeline().getEvents(), ...room.getPendingEvents()];
const isNewRoomListEnabled = SettingsStore.getValue("feature_new_room_list");
if (!isNewRoomListEnabled) {
// add last reply from each thread
room.getThreads().forEach((thread: Thread): void => {
const lastReply = thread.lastReply();
if (lastReply) events.push(lastReply);
});
}
// sort events from oldest to newest
events.sort((a: MatrixEvent, b: MatrixEvent) => {
return a.getTs() - b.getTs();
@@ -17,7 +17,6 @@ export enum DefaultTagID {
DM = "im.vector.fake.direct",
Conference = "im.vector.fake.conferences",
ServerNotice = "m.server_notice",
Suggested = "im.vector.fake.suggested",
}
/**
-103
View File
@@ -1,103 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import type { Room } from "matrix-js-sdk/src/matrix";
import type { EventEmitter } from "events";
import { type ITagMap, type ListAlgorithm, type SortAlgorithm } from "./algorithms/models";
import { type RoomUpdateCause } from "./models";
import { type TagID } from "../room-list-v3/skip-list/tag";
import { type IFilterCondition } from "./filters/IFilterCondition";
export enum RoomListStoreEvent {
// The event/channel which is called when the room lists have been changed.
ListsUpdate = "lists_update",
// The event which is called when the room list is loading.
// Called with the (tagId, bool) which is true when the list is loading, else false.
ListsLoading = "lists_loading",
}
export interface RoomListStore extends EventEmitter {
/**
* Gets an ordered set of rooms for the all known tags.
* @returns {ITagMap} The cached list of rooms, ordered,
* for each tag. May be empty, but never null/undefined.
*/
get orderedLists(): ITagMap;
/**
* Return the total number of rooms in this list. Prefer this method to
* RoomListStore.orderedLists[tagId].length because the client may not
* be aware of all the rooms in this list (e.g in Sliding Sync).
* @param tagId the tag to get the room count for.
* @returns the number of rooms in this list, or 0 if the list is unknown.
*/
getCount(tagId: TagID): number;
/**
* Set the sort algorithm for the specified tag.
* @param tagId the tag to set the algorithm for
* @param sort the sort algorithm to set to
*/
setTagSorting(tagId: TagID, sort: SortAlgorithm): void;
/**
* Get the sort algorithm for the specified tag.
* @param tagId tag to get the sort algorithm for
* @returns the sort algorithm
*/
getTagSorting(tagId: TagID): SortAlgorithm | null;
/**
* Set the list algorithm for the specified tag.
* @param tagId the tag to set the algorithm for
* @param order the list algorithm to set to
*/
setListOrder(tagId: TagID, order: ListAlgorithm): void;
/**
* Get the list algorithm for the specified tag.
* @param tagId tag to get the list algorithm for
* @returns the list algorithm
*/
getListOrder(tagId: TagID): ListAlgorithm | null;
/**
* Regenerates the room whole room list, discarding any previous results.
*
* Note: This is only exposed externally for the tests. Do not call this from within
* the app.
* @param params.trigger Set to false to prevent a list update from being sent. Should only
* be used if the calling code will manually trigger the update.
*/
regenerateAllLists(params: { trigger: boolean }): void;
/**
* Adds a filter condition to the room list store. Filters may be applied async,
* and thus might not cause an update to the store immediately.
* @param {IFilterCondition} filter The filter condition to add.
*/
addFilter(filter: IFilterCondition): Promise<void>;
/**
* Removes a filter condition from the room list store. If the filter was
* not previously added to the room list store, this will no-op. The effects
* of removing a filter may be applied async and therefore might not cause
* an update right away.
* @param {IFilterCondition} filter The filter condition to remove.
*/
removeFilter(filter: IFilterCondition): void;
/**
* Manually update a room with a given cause. This should only be used if the
* room list store would otherwise be incapable of doing the update itself. Note
* that this may race with the room list's regular operation.
* @param {Room} room The room to update.
* @param {RoomUpdateCause} cause The cause to update for.
*/
manualRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<void>;
}
-111
View File
@@ -1,111 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type TagID } from "../room-list-v3/skip-list/tag";
const TILE_HEIGHT_PX = 44;
interface ISerializedListLayout {
numTiles: number;
showPreviews: boolean;
collapsed: boolean;
}
export class ListLayout {
private _n = 0;
private _previews = false;
private _collapsed = false;
public constructor(public readonly tagId: TagID) {
const serialized = localStorage.getItem(this.key);
if (serialized) {
// We don't use the setters as they cause writes.
const parsed = <ISerializedListLayout>JSON.parse(serialized);
this._n = parsed.numTiles;
this._previews = parsed.showPreviews;
this._collapsed = parsed.collapsed;
}
}
public get isCollapsed(): boolean {
return this._collapsed;
}
public set isCollapsed(v: boolean) {
this._collapsed = v;
this.save();
}
public get showPreviews(): boolean {
return this._previews;
}
public set showPreviews(v: boolean) {
this._previews = v;
this.save();
}
public get tileHeight(): number {
return TILE_HEIGHT_PX;
}
private get key(): string {
return `mx_sublist_layout_${this.tagId}_boxed`;
}
public get visibleTiles(): number {
if (this._n === 0) return this.defaultVisibleTiles;
return Math.max(this._n, this.minVisibleTiles);
}
public set visibleTiles(v: number) {
this._n = v;
this.save();
}
public get minVisibleTiles(): number {
return 1;
}
public get defaultVisibleTiles(): number {
// This number is what "feels right", and mostly subject to design's opinion.
return 8;
}
public tilesWithPadding(n: number, paddingPx: number): number {
return this.pixelsToTiles(this.tilesToPixelsWithPadding(n, paddingPx));
}
public tilesToPixelsWithPadding(n: number, paddingPx: number): number {
return this.tilesToPixels(n) + paddingPx;
}
public tilesToPixels(n: number): number {
return n * this.tileHeight;
}
public pixelsToTiles(px: number): number {
return px / this.tileHeight;
}
public reset(): void {
localStorage.removeItem(this.key);
}
private save(): void {
localStorage.setItem(this.key, JSON.stringify(this.serialize()));
}
private serialize(): ISerializedListLayout {
return {
numTiles: this.visibleTiles,
showPreviews: this.showPreviews,
collapsed: this.isCollapsed,
};
}
}
@@ -1,65 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { logger } from "matrix-js-sdk/src/logger";
import { type EmptyObject } from "matrix-js-sdk/src/matrix";
import { type TagID } from "../room-list-v3/skip-list/tag";
import { ListLayout } from "./ListLayout";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { type ActionPayload } from "../../dispatcher/payloads";
export default class RoomListLayoutStore extends AsyncStoreWithClient<EmptyObject> {
private static internalInstance: RoomListLayoutStore;
private readonly layoutMap = new Map<TagID, ListLayout>();
public constructor() {
super(defaultDispatcher);
}
public static get instance(): RoomListLayoutStore {
if (!this.internalInstance) {
this.internalInstance = new RoomListLayoutStore();
this.internalInstance.start();
}
return RoomListLayoutStore.internalInstance;
}
public ensureLayoutExists(tagId: TagID): void {
if (!this.layoutMap.has(tagId)) {
this.layoutMap.set(tagId, new ListLayout(tagId));
}
}
public getLayoutFor(tagId: TagID): ListLayout {
if (!this.layoutMap.has(tagId)) {
this.layoutMap.set(tagId, new ListLayout(tagId));
}
return this.layoutMap.get(tagId)!;
}
// Note: this primarily exists for debugging, and isn't really intended to be used by anything.
public async resetLayouts(): Promise<void> {
logger.warn("Resetting layouts for room list");
for (const layout of this.layoutMap.values()) {
layout.reset();
}
}
protected async onNotReady(): Promise<any> {
// On logout, clear the map.
this.layoutMap.clear();
}
// We don't need this function, but our contract says we do
protected async onAction(payload: ActionPayload): Promise<void> {}
}
window.mxRoomListLayoutStore = RoomListLayoutStore.instance;
@@ -1,632 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2018-2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type MatrixClient, type Room, EventType, type EmptyObject } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import SettingsStore from "../../settings/SettingsStore";
import { OrderedDefaultTagIDs, RoomUpdateCause } from "./models";
import {
type IListOrderingMap,
type ITagMap,
type ITagSortingMap,
ListAlgorithm,
SortAlgorithm,
} from "./algorithms/models";
import { type ActionPayload } from "../../dispatcher/payloads";
import defaultDispatcher, { type MatrixDispatcher } from "../../dispatcher/dispatcher";
import { readReceiptChangeIsFor } from "../../utils/read-receipts";
import { FILTER_CHANGED, type IFilterCondition } from "./filters/IFilterCondition";
import { Algorithm, LIST_UPDATED_EVENT } from "./algorithms/Algorithm";
import { EffectiveMembership, getEffectiveMembership, getEffectiveMembershipTag } from "../../utils/membership";
import RoomListLayoutStore from "./RoomListLayoutStore";
import { MarkedExecution } from "../../utils/MarkedExecution";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import { RoomNotificationStateStore } from "../notifications/RoomNotificationStateStore";
import { isRoomVisible } from "../room-list-v3/isRoomVisible";
import { SpaceWatcher } from "./SpaceWatcher";
import { type IRoomTimelineActionPayload } from "../../actions/MatrixActionCreators";
import { type RoomListStore as Interface, RoomListStoreEvent } from "./Interface";
import { UPDATE_EVENT } from "../AsyncStore";
import { SDKContextClass } from "../../contexts/SDKContextClass";
import { getChangedOverrideRoomMutePushRules } from "../room-list-v3/utils";
import { type TagID } from "../room-list-v3/skip-list/tag";
export const LISTS_UPDATE_EVENT = RoomListStoreEvent.ListsUpdate;
export const LISTS_LOADING_EVENT = RoomListStoreEvent.ListsLoading; // unused; used by SlidingRoomListStore
export class RoomListStoreClass extends AsyncStoreWithClient<EmptyObject> implements Interface {
/**
* Set to true if you're running tests on the store. Should not be touched in
* any other environment.
*/
public static TEST_MODE = false;
private initialListsGenerated = false;
private msc3946ProcessDynamicPredecessor: boolean;
private msc3946SettingWatcherRef: string;
private algorithm = new Algorithm();
private prefilterConditions: IFilterCondition[] = [];
private updateFn = new MarkedExecution(() => {
for (const tagId of Object.keys(this.orderedLists)) {
RoomNotificationStateStore.instance.getListState(tagId).setRooms(this.orderedLists[tagId]);
}
this.emit(LISTS_UPDATE_EVENT);
});
public constructor(dis: MatrixDispatcher) {
super(dis);
this.setMaxListeners(20); // RoomList + LeftPanel + 8xRoomSubList + spares
this.algorithm.start();
this.msc3946ProcessDynamicPredecessor = SettingsStore.getValue("feature_dynamic_room_predecessors");
this.msc3946SettingWatcherRef = SettingsStore.watchSetting(
"feature_dynamic_room_predecessors",
null,
(_settingName, _roomId, _level, _newValAtLevel, newVal) => {
this.msc3946ProcessDynamicPredecessor = !!newVal;
this.regenerateAllLists({ trigger: true });
},
);
}
public componentWillUnmount(): void {
SettingsStore.unwatchSetting(this.msc3946SettingWatcherRef);
}
private setupWatchers(): void {
// TODO: Maybe destroy this if this class supports destruction
new SpaceWatcher(this);
}
public get orderedLists(): ITagMap {
if (!this.algorithm) return {}; // No tags yet.
return this.algorithm.getOrderedRooms();
}
// Intended for test usage
public async resetStore(): Promise<void> {
await this.reset();
this.prefilterConditions = [];
this.initialListsGenerated = false;
this.algorithm.off(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated);
this.algorithm.off(FILTER_CHANGED, this.onAlgorithmListUpdated);
this.algorithm.stop();
this.algorithm = new Algorithm();
this.algorithm.on(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated);
this.algorithm.on(FILTER_CHANGED, this.onAlgorithmListUpdated);
// Reset state without causing updates as the client will have been destroyed
// and downstream code will throw NPE errors.
await this.reset(null, true);
}
// Public for test usage. Do not call this.
public async makeReady(forcedClient?: MatrixClient): Promise<void> {
if (forcedClient) {
this.readyStore.useUnitTestClient(forcedClient);
}
SDKContextClass.instance.roomViewStore.addListener(UPDATE_EVENT, () => this.handleRVSUpdate({}));
this.algorithm.on(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated);
this.algorithm.on(FILTER_CHANGED, this.onAlgorithmFilterUpdated);
this.setupWatchers();
// Update any settings here, as some may have happened before we were logically ready.
logger.log("Regenerating room lists: Startup");
this.updateAlgorithmInstances();
this.regenerateAllLists({ trigger: false });
this.handleRVSUpdate({ trigger: false }); // fake an RVS update to adjust sticky room, if needed
this.updateFn.mark(); // we almost certainly want to trigger an update.
this.updateFn.trigger();
}
/**
* Handles suspected RoomViewStore changes.
* @param trigger Set to false to prevent a list update from being sent. Should only
* be used if the calling code will manually trigger the update.
*/
private handleRVSUpdate({ trigger = true }): void {
if (!this.matrixClient) return; // We assume there won't be RVS updates without a client
const activeRoomId = SDKContextClass.instance.roomViewStore.getRoomId();
if (!activeRoomId && this.algorithm.stickyRoom) {
this.algorithm.setStickyRoom(null);
} else if (activeRoomId) {
const activeRoom = this.matrixClient.getRoom(activeRoomId);
if (!activeRoom) {
logger.warn(`${activeRoomId} is current in RVS but missing from client - clearing sticky room`);
this.algorithm.setStickyRoom(null);
} else if (activeRoom !== this.algorithm.stickyRoom) {
this.algorithm.setStickyRoom(activeRoom);
}
}
if (trigger) this.updateFn.trigger();
}
protected async onReady(): Promise<any> {
await this.makeReady();
}
protected async onNotReady(): Promise<any> {
await this.resetStore();
}
protected async onAction(payload: ActionPayload): Promise<void> {
// If we're not remotely ready, don't even bother scheduling the dispatch handling.
// This is repeated in the handler just in case things change between a decision here and
// when the timer fires.
const logicallyReady = this.matrixClient && this.initialListsGenerated;
if (!logicallyReady) return;
// When we're running tests we can't reliably use setImmediate out of timing concerns.
// As such, we use a more synchronous model.
if (RoomListStoreClass.TEST_MODE) {
await this.onDispatchAsync(payload);
return;
}
// We do this to intentionally break out of the current event loop task, allowing
// us to instead wait for a more convenient time to run our updates.
setTimeout(() => this.onDispatchAsync(payload));
}
protected async onDispatchAsync(payload: ActionPayload): Promise<void> {
// Everything here requires a MatrixClient or some sort of logical readiness.
if (!this.matrixClient || !this.initialListsGenerated) return;
if (!this.algorithm) {
// This shouldn't happen because `initialListsGenerated` implies we have an algorithm.
throw new Error("Room list store has no algorithm to process dispatcher update with");
}
if (payload.action === "MatrixActions.Room.receipt") {
// First see if the receipt event is for our own user. If it was, trigger
// a room update (we probably read the room on a different device).
if (readReceiptChangeIsFor(payload.event, this.matrixClient)) {
const room = payload.room;
if (!room) {
logger.warn(`Own read receipt was in unknown room ${room.roomId}`);
return;
}
await this.handleRoomUpdate(room, RoomUpdateCause.ReadReceipt);
this.updateFn.trigger();
return;
}
} else if (payload.action === "MatrixActions.Room.tags") {
const roomPayload = <any>payload; // TODO: Type out the dispatcher types
await this.handleRoomUpdate(roomPayload.room, RoomUpdateCause.PossibleTagChange);
this.updateFn.trigger();
} else if (payload.action === "MatrixActions.Room.timeline") {
const eventPayload = <IRoomTimelineActionPayload>payload;
// Ignore non-live events (backfill) and notification timeline set events (without a room)
if (!eventPayload.isLiveEvent || !eventPayload.isLiveUnfilteredRoomTimelineEvent || !eventPayload.room) {
return;
}
const roomId = eventPayload.event.getRoomId();
const room = this.matrixClient.getRoom(roomId);
const tryUpdate = async (updatedRoom: Room): Promise<void> => {
if (
eventPayload.event.getType() === EventType.RoomTombstone &&
eventPayload.event.getStateKey() === ""
) {
const newRoom = this.matrixClient?.getRoom(eventPayload.event.getContent()["replacement_room"]);
if (newRoom) {
// If we have the new room, then the new room check will have seen the predecessor
// and did the required updates, so do nothing here.
return;
}
}
// If the join rule changes we need to update the tags for the room.
// A conference tag is determined by the room public join rule.
if (eventPayload.event.getType() === EventType.RoomJoinRules)
await this.handleRoomUpdate(updatedRoom, RoomUpdateCause.PossibleTagChange);
else await this.handleRoomUpdate(updatedRoom, RoomUpdateCause.Timeline);
this.updateFn.trigger();
};
if (!room) {
logger.warn(`Live timeline event ${eventPayload.event.getId()} received without associated room`);
logger.warn(`Queuing failed room update for retry as a result.`);
window.setTimeout(async (): Promise<void> => {
const updatedRoom = this.matrixClient?.getRoom(roomId);
if (updatedRoom) {
await tryUpdate(updatedRoom);
}
}, 100); // 100ms should be enough for the room to show up
return;
} else {
await tryUpdate(room);
}
} else if (payload.action === "MatrixActions.Event.decrypted") {
const eventPayload = <any>payload; // TODO: Type out the dispatcher types
const roomId = eventPayload.event.getRoomId();
if (!roomId) {
return;
}
const room = this.matrixClient.getRoom(roomId);
if (!room) {
logger.warn(`Event ${eventPayload.event.getId()} was decrypted in an unknown room ${roomId}`);
return;
}
await this.handleRoomUpdate(room, RoomUpdateCause.Timeline);
this.updateFn.trigger();
} else if (payload.action === "MatrixActions.accountData" && payload.event_type === EventType.Direct) {
const eventPayload = <any>payload; // TODO: Type out the dispatcher types
const dmMap = eventPayload.event.getContent();
for (const userId of Object.keys(dmMap)) {
const roomIds = dmMap[userId];
for (const roomId of roomIds) {
const room = this.matrixClient.getRoom(roomId);
if (!room) {
logger.warn(`${roomId} was found in DMs but the room is not in the store`);
continue;
}
// We expect this RoomUpdateCause to no-op if there's no change, and we don't expect
// the user to have hundreds of rooms to update in one event. As such, we just hammer
// away at updates until the problem is solved. If we were expecting more than a couple
// of rooms to be updated at once, we would consider batching the rooms up.
await this.handleRoomUpdate(room, RoomUpdateCause.PossibleTagChange);
}
}
this.updateFn.trigger();
} else if (payload.action === "MatrixActions.Room.myMembership") {
this.onDispatchMyMembership(<any>payload);
return;
}
const possibleMuteChangeRoomIds = getChangedOverrideRoomMutePushRules(payload);
if (possibleMuteChangeRoomIds) {
for (const roomId of possibleMuteChangeRoomIds) {
const room = roomId && this.matrixClient.getRoom(roomId);
if (room) {
await this.handleRoomUpdate(room, RoomUpdateCause.PossibleMuteChange);
}
}
this.updateFn.trigger();
}
}
/**
* Handle a MatrixActions.Room.myMembership event from the dispatcher.
*
* Public for test.
*/
public async onDispatchMyMembership(membershipPayload: any): Promise<void> {
// TODO: Type out the dispatcher types so membershipPayload is not any
const oldMembership = getEffectiveMembership(membershipPayload.oldMembership);
const newMembership = getEffectiveMembershipTag(membershipPayload.room, membershipPayload.membership);
if (oldMembership !== EffectiveMembership.Join && newMembership === EffectiveMembership.Join) {
// If we're joining an upgraded room, we'll want to make sure we don't proliferate the dead room in the list.
const room: Room = membershipPayload.room;
const roomUpgradeHistory = room.client.getRoomUpgradeHistory(
room.roomId,
true,
this.msc3946ProcessDynamicPredecessor,
);
const predecessors = roomUpgradeHistory.slice(0, roomUpgradeHistory.indexOf(room));
for (const predecessor of predecessors) {
const isSticky = this.algorithm.stickyRoom === predecessor;
if (isSticky) {
this.algorithm.setStickyRoom(null);
}
// Note: we hit the algorithm instead of our handleRoomUpdate() function to
// avoid redundant updates.
this.algorithm.handleRoomUpdate(predecessor, RoomUpdateCause.RoomRemoved);
}
await this.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.NewRoom);
this.updateFn.trigger();
return;
}
if (oldMembership !== EffectiveMembership.Invite && newMembership === EffectiveMembership.Invite) {
await this.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.NewRoom);
this.updateFn.trigger();
return;
}
// If it's not a join, it's transitioning into a different list (possibly historical)
if (oldMembership !== newMembership) {
await this.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.PossibleTagChange);
this.updateFn.trigger();
return;
}
}
private async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<any> {
if (!isRoomVisible(room)) {
return; // don't do anything on rooms that aren't visible
}
if (
(cause === RoomUpdateCause.NewRoom || cause === RoomUpdateCause.PossibleTagChange) &&
!this.prefilterConditions.every((c) => c.isVisible(room))
) {
return; // don't do anything on new/moved rooms which ought not to be shown
}
const shouldUpdate = this.algorithm.handleRoomUpdate(room, cause);
if (shouldUpdate) {
this.updateFn.mark();
}
}
private async recalculatePrefiltering(): Promise<void> {
if (!this.algorithm) return;
if (!this.algorithm.hasTagSortingMap) return; // we're still loading
// Inhibit updates because we're about to lie heavily to the algorithm
this.algorithm.updatesInhibited = true;
// Figure out which rooms are about to be valid, and the state of affairs
const rooms = this.getPlausibleRooms();
const currentSticky = this.algorithm.stickyRoom;
const stickyIsStillPresent = currentSticky && rooms.includes(currentSticky);
// Reset the sticky room before resetting the known rooms so the algorithm
// doesn't freak out.
this.algorithm.setStickyRoom(null);
this.algorithm.setKnownRooms(rooms);
// Set the sticky room back, if needed, now that we have updated the store.
// This will use relative stickyness to the new room set.
if (stickyIsStillPresent) {
this.algorithm.setStickyRoom(currentSticky);
}
// Finally, mark an update and resume updates from the algorithm
this.updateFn.mark();
this.algorithm.updatesInhibited = false;
}
public setTagSorting(tagId: TagID, sort: SortAlgorithm): void {
this.setAndPersistTagSorting(tagId, sort);
// We'll always need an update after changing the sort order, so mark for update and trigger
// immediately.
this.updateFn.mark();
this.updateFn.trigger();
}
private setAndPersistTagSorting(tagId: TagID, sort: SortAlgorithm): void {
this.algorithm.setTagSorting(tagId, sort);
// TODO: Per-account? https://github.com/vector-im/element-web/issues/14114
localStorage.setItem(`mx_tagSort_${tagId}`, sort);
}
public getTagSorting(tagId: TagID): SortAlgorithm | null {
return this.algorithm.getTagSorting(tagId);
}
// noinspection JSMethodCanBeStatic
private getStoredTagSorting(tagId: TagID): SortAlgorithm {
// TODO: Per-account? https://github.com/vector-im/element-web/issues/14114
return <SortAlgorithm>localStorage.getItem(`mx_tagSort_${tagId}`);
}
// logic must match calculateListOrder
private calculateTagSorting(tagId: TagID): SortAlgorithm {
const definedSort = this.getTagSorting(tagId);
const storedSort = this.getStoredTagSorting(tagId);
// We use the following order to determine which of the 4 flags to use:
// Stored > Settings > Defined > Default
let tagSort = SortAlgorithm.Recent;
if (storedSort) {
tagSort = storedSort;
} else if (definedSort) {
tagSort = definedSort;
} // else default (already set)
return tagSort;
}
public setListOrder(tagId: TagID, order: ListAlgorithm): void {
this.setAndPersistListOrder(tagId, order);
this.updateFn.trigger();
}
private setAndPersistListOrder(tagId: TagID, order: ListAlgorithm): void {
this.algorithm.setListOrdering(tagId, order);
// TODO: Per-account? https://github.com/vector-im/element-web/issues/14114
localStorage.setItem(`mx_listOrder_${tagId}`, order);
}
public getListOrder(tagId: TagID): ListAlgorithm | null {
return this.algorithm.getListOrdering(tagId);
}
// noinspection JSMethodCanBeStatic
private getStoredListOrder(tagId: TagID): ListAlgorithm {
// TODO: Per-account? https://github.com/vector-im/element-web/issues/14114
return <ListAlgorithm>localStorage.getItem(`mx_listOrder_${tagId}`);
}
// logic must match calculateTagSorting
private calculateListOrder(tagId: TagID): ListAlgorithm {
const defaultOrder = ListAlgorithm.Natural;
const definedOrder = this.getListOrder(tagId);
const storedOrder = this.getStoredListOrder(tagId);
// We use the following order to determine which of the 4 flags to use:
// Stored > Settings > Defined > Default
let listOrder = defaultOrder;
if (storedOrder) {
listOrder = storedOrder;
} else if (definedOrder) {
listOrder = definedOrder;
} // else default (already set)
return listOrder;
}
private updateAlgorithmInstances(): void {
// We'll require an update, so mark for one. Marking now also prevents the calls
// to setTagSorting and setListOrder from causing triggers.
this.updateFn.mark();
for (const tag of Object.keys(this.orderedLists)) {
const definedSort = this.getTagSorting(tag);
const definedOrder = this.getListOrder(tag);
const tagSort = this.calculateTagSorting(tag);
const listOrder = this.calculateListOrder(tag);
if (tagSort !== definedSort) {
this.setAndPersistTagSorting(tag, tagSort);
}
if (listOrder !== definedOrder) {
this.setAndPersistListOrder(tag, listOrder);
}
}
}
private onAlgorithmListUpdated = (forceUpdate: boolean): void => {
this.updateFn.mark();
if (forceUpdate) this.updateFn.trigger();
};
private onAlgorithmFilterUpdated = (): void => {
// The filter can happen off-cycle, so trigger an update. The filter will have
// already caused a mark.
this.updateFn.trigger();
};
private onPrefilterUpdated = async (): Promise<void> => {
await this.recalculatePrefiltering();
this.updateFn.trigger();
};
private getPlausibleRooms(): Room[] {
if (!this.matrixClient) return [];
let rooms = this.matrixClient.getVisibleRooms(this.msc3946ProcessDynamicPredecessor);
rooms = rooms.filter((r) => isRoomVisible(r));
if (this.prefilterConditions.length > 0) {
rooms = rooms.filter((r) => {
for (const filter of this.prefilterConditions) {
if (!filter.isVisible(r)) {
return false;
}
}
return true;
});
}
return rooms;
}
/**
* Regenerates the room whole room list, discarding any previous results.
*
* Note: This is only exposed externally for the tests. Do not call this from within
* the app.
* @param trigger Set to false to prevent a list update from being sent. Should only
* be used if the calling code will manually trigger the update.
*/
public regenerateAllLists({ trigger = true }): void {
logger.warn("Regenerating all room lists");
const rooms = this.getPlausibleRooms();
const sorts: ITagSortingMap = {};
const orders: IListOrderingMap = {};
const allTags = [...OrderedDefaultTagIDs];
for (const tagId of allTags) {
sorts[tagId] = this.calculateTagSorting(tagId);
orders[tagId] = this.calculateListOrder(tagId);
RoomListLayoutStore.instance.ensureLayoutExists(tagId);
}
this.algorithm.populateTags(sorts, orders);
this.algorithm.setKnownRooms(rooms);
this.initialListsGenerated = true;
if (trigger) this.updateFn.trigger();
}
/**
* Adds a filter condition to the room list store. Filters may be applied async,
* and thus might not cause an update to the store immediately.
* @param {IFilterCondition} filter The filter condition to add.
*/
public async addFilter(filter: IFilterCondition): Promise<void> {
filter.on(FILTER_CHANGED, this.onPrefilterUpdated);
this.prefilterConditions.push(filter);
const promise = this.recalculatePrefiltering();
promise.then(() => this.updateFn.trigger());
}
/**
* Removes a filter condition from the room list store. If the filter was
* not previously added to the room list store, this will no-op. The effects
* of removing a filter may be applied async and therefore might not cause
* an update right away.
* @param {IFilterCondition} filter The filter condition to remove.
*/
public removeFilter(filter: IFilterCondition): void {
let promise = Promise.resolve();
let removed = false;
const idx = this.prefilterConditions.indexOf(filter);
if (idx >= 0) {
filter.off(FILTER_CHANGED, this.onPrefilterUpdated);
this.prefilterConditions.splice(idx, 1);
promise = this.recalculatePrefiltering();
removed = true;
}
if (removed) {
promise.then(() => this.updateFn.trigger());
}
}
public getCount(tagId: TagID): number {
// The room list store knows about all the rooms, so just return the length.
return this.orderedLists[tagId].length || 0;
}
/**
* Manually update a room with a given cause. This should only be used if the
* room list store would otherwise be incapable of doing the update itself. Note
* that this may race with the room list's regular operation.
* @param {Room} room The room to update.
* @param {RoomUpdateCause} cause The cause to update for.
*/
public async manualRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<void> {
await this.handleRoomUpdate(room, cause);
this.updateFn.trigger();
}
}
export default class RoomListStore {
private static internalInstance: Interface;
public static get instance(): Interface {
if (!RoomListStore.internalInstance) {
const instance = new RoomListStoreClass(defaultDispatcher);
instance.start();
RoomListStore.internalInstance = instance;
}
return this.internalInstance;
}
}
window.mxRoomListStore = RoomListStore.instance;
@@ -1,63 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type RoomListStore as Interface } from "./Interface";
import { SpaceFilterCondition } from "./filters/SpaceFilterCondition";
import SpaceStore from "../spaces/SpaceStore";
import { MetaSpace, type SpaceKey, UPDATE_HOME_BEHAVIOUR, UPDATE_SELECTED_SPACE } from "../spaces";
/**
* Watches for changes in spaces to manage the filter on the provided RoomListStore
*/
export class SpaceWatcher {
private readonly filter = new SpaceFilterCondition();
// we track these separately to the SpaceStore as we need to observe transitions
private activeSpace: SpaceKey = SpaceStore.instance.activeSpace;
private allRoomsInHome: boolean = SpaceStore.instance.allRoomsInHome;
public constructor(private store: Interface) {
if (SpaceWatcher.needsFilter(this.activeSpace, this.allRoomsInHome)) {
this.updateFilter();
store.addFilter(this.filter);
}
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated);
SpaceStore.instance.on(UPDATE_HOME_BEHAVIOUR, this.onHomeBehaviourUpdated);
}
private static needsFilter(spaceKey: SpaceKey, allRoomsInHome: boolean): boolean {
return !(spaceKey === MetaSpace.Home && allRoomsInHome);
}
private onSelectedSpaceUpdated = (activeSpace: SpaceKey, allRoomsInHome = this.allRoomsInHome): void => {
if (activeSpace === this.activeSpace && allRoomsInHome === this.allRoomsInHome) return; // nop
const neededFilter = SpaceWatcher.needsFilter(this.activeSpace, this.allRoomsInHome);
const needsFilter = SpaceWatcher.needsFilter(activeSpace, allRoomsInHome);
this.activeSpace = activeSpace;
this.allRoomsInHome = allRoomsInHome;
if (needsFilter) {
this.updateFilter();
}
if (!neededFilter && needsFilter) {
this.store.addFilter(this.filter);
} else if (neededFilter && !needsFilter) {
this.store.removeFilter(this.filter);
}
};
private onHomeBehaviourUpdated = (allRoomsInHome: boolean): void => {
this.onSelectedSpaceUpdated(this.activeSpace, allRoomsInHome);
};
private updateFilter = (): void => {
this.filter.updateSpace(this.activeSpace);
};
}
@@ -1,731 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type Room } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
import { EventEmitter } from "events";
import { logger } from "matrix-js-sdk/src/logger";
import DMRoomMap from "../../../utils/DMRoomMap";
import { arrayDiff, arrayHasDiff } from "../../../utils/arrays";
import { DefaultTagID, type TagID } from "../../room-list-v3/skip-list/tag";
import { RoomUpdateCause } from "../models";
import {
type IListOrderingMap,
type IOrderingAlgorithmMap,
type ITagMap,
type ITagSortingMap,
type ListAlgorithm,
type SortAlgorithm,
} from "./models";
import { EffectiveMembership, splitRoomsByMembership } from "../../../utils/membership";
import { type OrderingAlgorithm } from "./list-ordering/OrderingAlgorithm";
import { getListAlgorithmInstance } from "./list-ordering";
import { isRoomVisible } from "../../room-list-v3/isRoomVisible";
import { CallStore, CallStoreEvent } from "../../CallStore";
import { getTagsForRoom, getTagsOfJoinedRoom } from "../../../utils/room/getTagsForRoom";
/**
* Fired when the Algorithm has determined a list has been updated.
*/
export const LIST_UPDATED_EVENT = "list_updated_event";
// These are the causes which require a room to be known in order for us to handle them. If
// a cause in this list is raised and we don't know about the room, we don't handle the update.
//
// Note: these typically happen when a new room is coming in, such as the user creating or
// joining the room. For these cases, we need to know about the room prior to handling it otherwise
// we'll make bad assumptions.
const CAUSES_REQUIRING_ROOM = [RoomUpdateCause.Timeline, RoomUpdateCause.ReadReceipt];
interface IStickyRoom {
room: Room;
position: number;
tag: TagID;
}
/**
* Represents a list ordering algorithm. This class will take care of tag
* management (which rooms go in which tags) and ask the implementation to
* deal with ordering mechanics.
*/
export class Algorithm extends EventEmitter {
private _cachedRooms: ITagMap = {};
private _cachedStickyRooms: ITagMap | null = {}; // a clone of the _cachedRooms, with the sticky room
private _stickyRoom: IStickyRoom | null = null;
private _lastStickyRoom: IStickyRoom | null = null; // only not-null when changing the sticky room
private sortAlgorithms: ITagSortingMap | null = null;
private listAlgorithms: IListOrderingMap | null = null;
private algorithms: IOrderingAlgorithmMap | null = null;
private rooms: Room[] = [];
private roomIdsToTags: {
[roomId: string]: TagID[];
} = {};
/**
* Set to true to suspend emissions of algorithm updates.
*/
public updatesInhibited = false;
public start(): void {
CallStore.instance.on(CallStoreEvent.ConnectedCalls, this.onConnectedCalls);
}
public stop(): void {
CallStore.instance.off(CallStoreEvent.ConnectedCalls, this.onConnectedCalls);
}
public get stickyRoom(): Room | null {
return this._stickyRoom ? this._stickyRoom.room : null;
}
public get hasTagSortingMap(): boolean {
return !!this.sortAlgorithms;
}
protected set cachedRooms(val: ITagMap) {
this._cachedRooms = val;
this.recalculateStickyRoom();
this.recalculateActiveCallRooms();
}
protected get cachedRooms(): ITagMap {
// 🐉 Here be dragons.
// Note: this is used by the underlying algorithm classes, so don't make it return
// the sticky room cache. If it ends up returning the sticky room cache, we end up
// corrupting our caches and confusing them.
return this._cachedRooms;
}
/**
* Awaitable version of the sticky room setter.
* @param val The new room to sticky.
*/
public setStickyRoom(val: Room | null): void {
try {
this.updateStickyRoom(val);
} catch (e) {
logger.warn("Failed to update sticky room", e);
}
}
public getTagSorting(tagId: TagID): SortAlgorithm | null {
if (!this.sortAlgorithms) return null;
return this.sortAlgorithms[tagId];
}
public setTagSorting(tagId: TagID, sort: SortAlgorithm): void {
if (!tagId) throw new Error("Tag ID must be defined");
if (!sort) throw new Error("Algorithm must be defined");
if (!this.sortAlgorithms) throw new Error("this.sortAlgorithms must be defined before calling setTagSorting");
if (!this.algorithms) throw new Error("this.algorithms must be defined before calling setTagSorting");
this.sortAlgorithms[tagId] = sort;
const algorithm: OrderingAlgorithm = this.algorithms[tagId];
algorithm.setSortAlgorithm(sort);
this._cachedRooms[tagId] = algorithm.orderedRooms;
this.recalculateStickyRoom(tagId); // update sticky room to make sure it appears if needed
this.recalculateActiveCallRooms(tagId);
}
public getListOrdering(tagId: TagID): ListAlgorithm | null {
if (!this.listAlgorithms) return null;
return this.listAlgorithms[tagId];
}
public setListOrdering(tagId: TagID, order: ListAlgorithm): void {
if (!tagId) throw new Error("Tag ID must be defined");
if (!order) throw new Error("Algorithm must be defined");
if (!this.sortAlgorithms) throw new Error("this.sortAlgorithms must be defined before calling setListOrdering");
if (!this.listAlgorithms) throw new Error("this.listAlgorithms must be defined before calling setListOrdering");
if (!this.algorithms) throw new Error("this.algorithms must be defined before calling setListOrdering");
this.listAlgorithms[tagId] = order;
const algorithm = getListAlgorithmInstance(order, tagId, this.sortAlgorithms[tagId]);
this.algorithms[tagId] = algorithm;
algorithm.setRooms(this._cachedRooms[tagId]);
this._cachedRooms[tagId] = algorithm.orderedRooms;
this.recalculateStickyRoom(tagId); // update sticky room to make sure it appears if needed
this.recalculateActiveCallRooms(tagId);
}
private updateStickyRoom(val: Room | null): void {
this.doUpdateStickyRoom(val);
this._lastStickyRoom = null; // clear to indicate we're done changing
}
private doUpdateStickyRoom(val: Room | null): void {
if (val?.isSpaceRoom() && val.getMyMembership() !== KnownMembership.Invite) {
// no-op sticky rooms for spaces - they're effectively virtual rooms
val = null;
}
if (val && !isRoomVisible(val)) {
val = null; // the room isn't visible - lie to the rest of this function
}
// Set the last sticky room to indicate that we're in a change. The code throughout the
// class can safely handle a null room, so this should be safe to do as a backup.
this._lastStickyRoom = this._stickyRoom || <IStickyRoom>{};
// It's possible to have no selected room. In that case, clear the sticky room
if (!val) {
if (this._stickyRoom) {
const stickyRoom = this._stickyRoom.room;
this._stickyRoom = null; // clear before we go to update the algorithm
// Lie to the algorithm and re-add the room to the algorithm
this.handleRoomUpdate(stickyRoom, RoomUpdateCause.NewRoom);
return;
}
return;
}
// When we do have a room though, we expect to be able to find it
let tag = this.roomIdsToTags[val.roomId]?.[0];
if (!tag) throw new Error(`${val.roomId} does not belong to a tag and cannot be sticky`);
// We specifically do NOT use the ordered rooms set as it contains the sticky room, which
// means we'll be off by 1 when the user is switching rooms. This leads to visual jumping
// when the user is moving south in the list (not north, because of math).
const tagList = this.getOrderedRoomsWithoutSticky()[tag] || []; // can be null if filtering
let position = tagList.indexOf(val);
// We do want to see if a tag change happened though - if this did happen then we'll want
// to force the position to zero (top) to ensure we can properly handle it.
const wasSticky = this._lastStickyRoom.room ? this._lastStickyRoom.room.roomId === val.roomId : false;
if (this._lastStickyRoom.tag && tag !== this._lastStickyRoom.tag && wasSticky && position < 0) {
logger.warn(`Sticky room ${val.roomId} changed tags during sticky room handling`);
position = 0;
}
// Sanity check the position to make sure the room is qualified for being sticky
if (position < 0) throw new Error(`${val.roomId} does not appear to be known and cannot be sticky`);
// 🐉 Here be dragons.
// Before we can go through with lying to the underlying algorithm about a room
// we need to ensure that when we do we're ready for the inevitable sticky room
// update we'll receive. To prepare for that, we first remove the sticky room and
// recalculate the state ourselves so that when the underlying algorithm calls for
// the same thing it no-ops. After we're done calling the algorithm, we'll issue
// a new update for ourselves.
const lastStickyRoom = this._stickyRoom;
this._stickyRoom = null; // clear before we update the algorithm
this.recalculateStickyRoom();
// When we do have the room, re-add the old room (if needed) to the algorithm
// and remove the sticky room from the algorithm. This is so the underlying
// algorithm doesn't try and confuse itself with the sticky room concept.
// We don't add the new room if the sticky room isn't changing because that's
// an easy way to cause duplication. We have to do room ID checks instead of
// referential checks as the references can differ through the lifecycle.
if (lastStickyRoom && lastStickyRoom.room && lastStickyRoom.room.roomId !== val.roomId) {
// Lie to the algorithm and re-add the room to the algorithm
this.handleRoomUpdate(lastStickyRoom.room, RoomUpdateCause.NewRoom);
}
// Lie to the algorithm and remove the room from it's field of view
this.handleRoomUpdate(val, RoomUpdateCause.RoomRemoved);
// handleRoomUpdate may have modified this._stickyRoom. Convince the
// compiler of this fact.
this._stickyRoom = this.stickyRoomMightBeModified();
// Check for tag & position changes while we're here. We also check the room to ensure
// it is still the same room.
if (this._stickyRoom) {
if (this._stickyRoom.room !== val) {
// Check the room IDs just in case
if (this._stickyRoom.room.roomId === val.roomId) {
logger.warn("Sticky room changed references");
} else {
throw new Error("Sticky room changed while the sticky room was changing");
}
}
logger.warn(
`Sticky room changed tag & position from ${tag} / ${position} ` +
`to ${this._stickyRoom.tag} / ${this._stickyRoom.position}`,
);
tag = this._stickyRoom.tag;
position = this._stickyRoom.position;
}
// Now that we're done lying to the algorithm, we need to update our position
// marker only if the user is moving further down the same list. If they're switching
// lists, or moving upwards, the position marker will splice in just fine but if
// they went downwards in the same list we'll be off by 1 due to the shifting rooms.
if (lastStickyRoom && lastStickyRoom.tag === tag && lastStickyRoom.position <= position) {
position++;
}
this._stickyRoom = {
room: val,
position: position,
tag: tag,
};
// We update the filtered rooms just in case, as otherwise users will end up visiting
// a room while filtering and it'll disappear. We don't update the filter earlier in
// this function simply because we don't have to.
this.recalculateStickyRoom();
this.recalculateActiveCallRooms(tag);
if (lastStickyRoom && lastStickyRoom.tag !== tag) this.recalculateActiveCallRooms(lastStickyRoom.tag);
// Finally, trigger an update
if (this.updatesInhibited) return;
this.emit(LIST_UPDATED_EVENT);
}
/**
* Hack to prevent Typescript claiming this._stickyRoom is always null.
*/
private stickyRoomMightBeModified(): IStickyRoom | null {
return this._stickyRoom;
}
private onConnectedCalls = (): void => {
// In case we're unsticking a room, sort it back into natural order
this.recalculateStickyRoom();
// Update the stickiness of rooms with calls
this.recalculateActiveCallRooms();
if (this.updatesInhibited) return;
// This isn't in response to any particular RoomListStore update,
// so notify the store that it needs to force-update
this.emit(LIST_UPDATED_EVENT, true);
};
private initCachedStickyRooms(): void {
this._cachedStickyRooms = {};
for (const tagId of Object.keys(this.cachedRooms)) {
this._cachedStickyRooms[tagId] = [...this.cachedRooms[tagId]]; // shallow clone
}
}
/**
* Recalculate the sticky room position. If this is being called in relation to
* a specific tag being updated, it should be given to this function to optimize
* the call.
* @param updatedTag The tag that was updated, if possible.
*/
protected recalculateStickyRoom(updatedTag: TagID | null = null): void {
// 🐉 Here be dragons.
// This function does far too much for what it should, and is called by many places.
// Not only is this responsible for ensuring the sticky room is held in place at all
// times, it is also responsible for ensuring our clone of the cachedRooms is up to
// date. If either of these desyncs, we see weird behaviour like duplicated rooms,
// outdated lists, and other nonsensical issues that aren't necessarily obvious.
if (!this._stickyRoom) {
// If there's no sticky room, just do nothing useful.
if (!!this._cachedStickyRooms) {
// Clear the cache if we won't be needing it
this._cachedStickyRooms = null;
if (this.updatesInhibited) return;
this.emit(LIST_UPDATED_EVENT);
}
return;
}
if (!this._cachedStickyRooms || !updatedTag) {
this.initCachedStickyRooms();
}
if (updatedTag) {
// Update the tag indicated by the caller, if possible. This is mostly to ensure
// our cache is up to date.
if (this._cachedStickyRooms) {
this._cachedStickyRooms[updatedTag] = [...this.cachedRooms[updatedTag]]; // shallow clone
}
}
// Now try to insert the sticky room, if we need to.
// We need to if there's no updated tag (we regenned the whole cache) or if the tag
// we might have updated from the cache is also our sticky room.
const sticky = this._stickyRoom;
if (sticky && (!updatedTag || updatedTag === sticky.tag) && this._cachedStickyRooms) {
this._cachedStickyRooms[sticky.tag].splice(sticky.position, 0, sticky.room);
}
// Finally, trigger an update
if (this.updatesInhibited) return;
this.emit(LIST_UPDATED_EVENT);
}
/**
* Recalculate the position of any rooms with calls. If this is being called in
* relation to a specific tag being updated, it should be given to this function to
* optimize the call.
*
* This expects to be called *after* the sticky rooms are updated, and sticks the
* room with the currently active call to the top of its tag.
*
* @param updatedTag The tag that was updated, if possible.
*/
protected recalculateActiveCallRooms(updatedTag: TagID | null = null): void {
if (!updatedTag) {
// Assume all tags need updating
// We're not modifying the map here, so can safely rely on the cached values
// rather than the explicitly sticky map.
for (const tagId of Object.keys(this.cachedRooms)) {
if (!tagId) {
throw new Error("Unexpected recursion: falsy tag");
}
this.recalculateActiveCallRooms(tagId);
}
return;
}
if (CallStore.instance.connectedCalls.size) {
// We operate on the sticky rooms map
if (!this._cachedStickyRooms) this.initCachedStickyRooms();
const rooms = this._cachedStickyRooms![updatedTag];
const activeRoomIds = new Set([...CallStore.instance.connectedCalls].map((call) => call.roomId));
const activeRooms: Room[] = [];
const inactiveRooms: Room[] = [];
for (const room of rooms) {
(activeRoomIds.has(room.roomId) ? activeRooms : inactiveRooms).push(room);
}
// Stick rooms with active calls to the top
this._cachedStickyRooms![updatedTag] = [...activeRooms, ...inactiveRooms];
}
}
/**
* Asks the Algorithm to regenerate all lists, using the tags given
* as reference for which lists to generate and which way to generate
* them.
* @param {ITagSortingMap} tagSortingMap The tags to generate.
* @param {IListOrderingMap} listOrderingMap The ordering of those tags.
*/
public populateTags(tagSortingMap: ITagSortingMap, listOrderingMap: IListOrderingMap): void {
if (!tagSortingMap) throw new Error(`Sorting map cannot be null or empty`);
if (!listOrderingMap) throw new Error(`Ordering ma cannot be null or empty`);
if (arrayHasDiff(Object.keys(tagSortingMap), Object.keys(listOrderingMap))) {
throw new Error(`Both maps must contain the exact same tags`);
}
this.sortAlgorithms = tagSortingMap;
this.listAlgorithms = listOrderingMap;
this.algorithms = {};
for (const tag of Object.keys(tagSortingMap)) {
this.algorithms[tag] = getListAlgorithmInstance(this.listAlgorithms[tag], tag, this.sortAlgorithms[tag]);
}
return this.setKnownRooms(this.rooms);
}
/**
* Gets an ordered set of rooms for the all known tags.
* @returns {ITagMap} The cached list of rooms, ordered,
* for each tag. May be empty, but never null/undefined.
*/
public getOrderedRooms(): ITagMap {
return this._cachedStickyRooms || this.cachedRooms;
}
/**
* This returns the same as getOrderedRooms(), but without the sticky room
* map as it causes issues for sticky room handling (see sticky room handling
* for more information).
* @returns {ITagMap} The cached list of rooms, ordered,
* for each tag. May be empty, but never null/undefined.
*/
private getOrderedRoomsWithoutSticky(): ITagMap {
return this.cachedRooms;
}
/**
* Seeds the Algorithm with a set of rooms. The algorithm will discard all
* previously known information and instead use these rooms instead.
* @param {Room[]} rooms The rooms to force the algorithm to use.
*/
public setKnownRooms(rooms: Room[]): void {
if (isNullOrUndefined(rooms)) throw new Error(`Array of rooms cannot be null`);
if (!this.sortAlgorithms) throw new Error(`Cannot set known rooms without a tag sorting map`);
if (!this.updatesInhibited) {
// We only log this if we're expecting to be publishing updates, which means that
// this could be an unexpected invocation. If we're inhibited, then this is probably
// an intentional invocation.
logger.warn("Resetting known rooms, initiating regeneration");
}
// Before we go any further we need to clear (but remember) the sticky room to
// avoid accidentally duplicating it in the list.
const oldStickyRoom = this._stickyRoom;
if (oldStickyRoom) this.updateStickyRoom(null);
this.rooms = rooms;
const newTags: ITagMap = {};
for (const tagId in this.sortAlgorithms) {
// noinspection JSUnfilteredForInLoop
newTags[tagId] = [];
}
// If we can avoid doing work, do so.
if (!rooms.length) {
this.generateFreshTags(newTags); // just in case it wants to do something
this.cachedRooms = newTags;
return;
}
// Split out the easy rooms first (leave and invite)
const memberships = splitRoomsByMembership(rooms);
for (const room of memberships[EffectiveMembership.Invite]) {
newTags[DefaultTagID.Invite].push(room);
}
for (const room of memberships[EffectiveMembership.Leave]) {
// We may not have had an archived section previously, so make sure its there.
if (newTags[DefaultTagID.Archived] === undefined) newTags[DefaultTagID.Archived] = [];
newTags[DefaultTagID.Archived].push(room);
}
// Now process all the joined rooms. This is a bit more complicated
for (const room of memberships[EffectiveMembership.Join]) {
const tags = getTagsOfJoinedRoom(room);
let inTag = false;
if (tags.length > 0) {
for (const tag of tags) {
if (!isNullOrUndefined(newTags[tag])) {
newTags[tag].push(room);
inTag = true;
}
}
}
if (!inTag) {
if (DMRoomMap.shared().getUserIdForRoomId(room.roomId)) {
newTags[DefaultTagID.DM].push(room);
} else {
newTags[DefaultTagID.Untagged].push(room);
}
}
}
this.generateFreshTags(newTags);
this.cachedRooms = newTags; // this recalculates the filtered rooms for us
this.updateTagsFromCache();
// Now that we've finished generation, we need to update the sticky room to what
// it was. It's entirely possible that it changed lists though, so if it did then
// we also have to update the position of it.
if (oldStickyRoom && oldStickyRoom.room) {
this.updateStickyRoom(oldStickyRoom.room);
if (this._stickyRoom && this._stickyRoom.room) {
// just in case the update doesn't go according to plan
if (this._stickyRoom.tag !== oldStickyRoom.tag) {
// We put the sticky room at the top of the list to treat it as an obvious tag change.
this._stickyRoom.position = 0;
this.recalculateStickyRoom(this._stickyRoom.tag);
}
}
}
}
/**
* Updates the roomsToTags map
*/
private updateTagsFromCache(): void {
const newMap: Algorithm["roomIdsToTags"] = {};
const tags = Object.keys(this.cachedRooms);
for (const tagId of tags) {
const rooms = this.cachedRooms[tagId];
for (const room of rooms) {
if (!newMap[room.roomId]) newMap[room.roomId] = [];
newMap[room.roomId].push(tagId);
}
}
this.roomIdsToTags = newMap;
}
/**
* Called when the Algorithm believes a complete regeneration of the existing
* lists is needed.
* @param {ITagMap} updatedTagMap The tag map which needs populating. Each tag
* will already have the rooms which belong to it - they just need ordering. Must
* be mutated in place.
*/
private generateFreshTags(updatedTagMap: ITagMap): void {
if (!this.algorithms) throw new Error("Not ready: no algorithms to determine tags from");
for (const tag of Object.keys(updatedTagMap)) {
const algorithm: OrderingAlgorithm = this.algorithms[tag];
if (!algorithm) throw new Error(`No algorithm for ${tag}`);
algorithm.setRooms(updatedTagMap[tag]);
updatedTagMap[tag] = algorithm.orderedRooms;
}
}
/**
* Asks the Algorithm to update its knowledge of a room. For example, when
* a user tags a room, joins/creates a room, or leaves a room the Algorithm
* should be told that the room's info might have changed. The Algorithm
* may no-op this request if no changes are required.
* @param {Room} room The room which might have affected sorting.
* @param {RoomUpdateCause} cause The reason for the update being triggered.
* @returns {Promise<boolean>} A boolean of whether or not getOrderedRooms()
* should be called after processing.
*/
public handleRoomUpdate(room: Room, cause: RoomUpdateCause): boolean {
if (!this.algorithms) throw new Error("Not ready: no algorithms to determine tags from");
// Note: check the isSticky against the room ID just in case the reference is wrong
const isSticky = this._stickyRoom?.room?.roomId === room.roomId;
if (cause === RoomUpdateCause.NewRoom) {
const isForLastSticky = this._lastStickyRoom?.room === room;
const roomTags = this.roomIdsToTags[room.roomId];
const hasTags = roomTags && roomTags.length > 0;
// Don't change the cause if the last sticky room is being re-added. If we fail to
// pass the cause through as NewRoom, we'll fail to lie to the algorithm and thus
// lose the room.
if (hasTags && !isForLastSticky) {
logger.warn(`${room.roomId} is reportedly new but is already known - assuming TagChange instead`);
cause = RoomUpdateCause.PossibleTagChange;
}
// Check to see if the room is known first
let knownRoomRef = this.rooms.includes(room);
if (hasTags && !knownRoomRef) {
logger.warn(`${room.roomId} might be a reference change - attempting to update reference`);
this.rooms = this.rooms.map((r) => (r.roomId === room.roomId ? room : r));
knownRoomRef = this.rooms.includes(room);
if (!knownRoomRef) {
logger.warn(`${room.roomId} is still not referenced. It may be sticky.`);
}
}
// If we have tags for a room and don't have the room referenced, something went horribly
// wrong - the reference should have been updated above.
if (hasTags && !knownRoomRef && !isSticky) {
throw new Error(`${room.roomId} is missing from room array but is known - trying to find duplicate`);
}
// Like above, update the reference to the sticky room if we need to
if (hasTags && isSticky && this._stickyRoom) {
// Go directly in and set the sticky room's new reference, being careful not
// to trigger a sticky room update ourselves.
this._stickyRoom.room = room;
}
// If after all that we're still a NewRoom update, add the room if applicable.
// We don't do this for the sticky room (because it causes duplication issues)
// or if we know about the reference (as it should be replaced).
if (cause === RoomUpdateCause.NewRoom && !isSticky && !knownRoomRef) {
this.rooms.push(room);
}
}
let didTagChange = false;
if (cause === RoomUpdateCause.PossibleTagChange) {
const oldTags = this.roomIdsToTags[room.roomId] || [];
const newTags = getTagsForRoom(room);
const diff = arrayDiff(oldTags, newTags);
if (diff.removed.length > 0 || diff.added.length > 0) {
for (const rmTag of diff.removed) {
const algorithm: OrderingAlgorithm = this.algorithms[rmTag];
if (!algorithm) throw new Error(`No algorithm for ${rmTag}`);
algorithm.handleRoomUpdate(room, RoomUpdateCause.RoomRemoved);
this._cachedRooms[rmTag] = algorithm.orderedRooms;
this.recalculateStickyRoom(rmTag); // update sticky room to make sure it moves if needed
this.recalculateActiveCallRooms(rmTag);
}
for (const addTag of diff.added) {
const algorithm: OrderingAlgorithm = this.algorithms[addTag];
if (!algorithm) throw new Error(`No algorithm for ${addTag}`);
algorithm.handleRoomUpdate(room, RoomUpdateCause.NewRoom);
this._cachedRooms[addTag] = algorithm.orderedRooms;
}
// Update the tag map so we don't regen it in a moment
this.roomIdsToTags[room.roomId] = newTags;
cause = RoomUpdateCause.Timeline;
didTagChange = true;
} else {
// This is a tag change update and no tags were changed, nothing to do!
return false;
}
if (didTagChange && isSticky) {
// Manually update the tag for the sticky room without triggering a sticky room
// update. The update will be handled implicitly by the sticky room handling and
// requires no changes on our part, if we're in the middle of a sticky room change.
if (this._lastStickyRoom) {
this._stickyRoom = {
room,
tag: this.roomIdsToTags[room.roomId][0],
position: 0, // right at the top as it changed tags
};
} else {
// We have to clear the lock as the sticky room change will trigger updates.
this.setStickyRoom(room);
}
}
}
// If the update is for a room change which might be the sticky room, prevent it. We
// need to make sure that the causes (NewRoom and RoomRemoved) are still triggered though
// as the sticky room relies on this.
if (cause !== RoomUpdateCause.NewRoom && cause !== RoomUpdateCause.RoomRemoved) {
if (this.stickyRoom === room) {
return false;
}
}
if (!this.roomIdsToTags[room.roomId]) {
if (CAUSES_REQUIRING_ROOM.includes(cause)) {
return false;
}
// Get the tags for the room and populate the cache
const roomTags = getTagsForRoom(room).filter((t) => !isNullOrUndefined(this.cachedRooms[t]));
// "This should never happen" condition - we specify DefaultTagID.Untagged in getTagsForRoom(),
// which means we should *always* have a tag to go off of.
if (!roomTags.length) throw new Error(`Tags cannot be determined for ${room.roomId}`);
this.roomIdsToTags[room.roomId] = roomTags;
}
const tags = this.roomIdsToTags[room.roomId];
if (!tags) {
logger.warn(`No tags known for "${room.name}" (${room.roomId})`);
return false;
}
let changed = didTagChange;
for (const tag of tags) {
const algorithm: OrderingAlgorithm = this.algorithms[tag];
if (!algorithm) throw new Error(`No algorithm for ${tag}`);
algorithm.handleRoomUpdate(room, cause);
this._cachedRooms[tag] = algorithm.orderedRooms;
// Flag that we've done something
this.recalculateStickyRoom(tag); // update sticky room to make sure it appears if needed
this.recalculateActiveCallRooms(tag);
changed = true;
}
return changed;
}
}
@@ -1,299 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2018, 2019 New Vector Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type Room } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { type TagID } from "../../../room-list-v3/skip-list/tag";
import { RoomUpdateCause } from "../../models";
import { type SortAlgorithm } from "../models";
import { sortRoomsWithAlgorithm } from "../tag-sorting";
import { OrderingAlgorithm } from "./OrderingAlgorithm";
import { NotificationLevel } from "../../../notifications/NotificationLevel";
import { RoomNotificationStateStore } from "../../../notifications/RoomNotificationStateStore";
type CategorizedRoomMap = {
[category in NotificationLevel]: Room[];
};
type CategoryIndex = Partial<{
[category in NotificationLevel]: number; // integer
}>;
// Caution: changing this means you'll need to update a bunch of assumptions and
// comments! Check the usage of Category carefully to figure out what needs changing
// if you're going to change this array's order.
const CATEGORY_ORDER = [
NotificationLevel.Unsent,
NotificationLevel.Highlight,
NotificationLevel.Notification,
NotificationLevel.Activity,
NotificationLevel.None, // idle
NotificationLevel.Muted,
];
/**
* An implementation of the "importance" algorithm for room list sorting. Where
* the tag sorting algorithm does not interfere, rooms will be ordered into
* categories of varying importance to the user. Alphabetical sorting does not
* interfere with this algorithm, however manual ordering does.
*
* The importance of a room is defined by the kind of notifications, if any, are
* present on the room. These are classified internally as Unsent, Red, Grey,
* Bold, and Idle. 'Unsent' rooms have unsent messages, Red rooms have mentions,
* grey have unread messages, bold is a less noisy version of grey, and idle
* means all activity has been seen by the user.
*
* The algorithm works by monitoring all room changes, including new messages in
* tracked rooms, to determine if it needs a new category or different placement
* within the same category. For more information, see the comments contained
* within the class.
*/
export class ImportanceAlgorithm extends OrderingAlgorithm {
// This tracks the category for the tag it represents by tracking the index of
// each category within the list, where zero is the top of the list. This then
// tracks when rooms change categories and splices the orderedRooms array as
// needed, preventing many ordering operations.
private indices: CategoryIndex = {};
public constructor(tagId: TagID, initialSortingAlgorithm: SortAlgorithm) {
super(tagId, initialSortingAlgorithm);
}
// noinspection JSMethodCanBeStatic
private categorizeRooms(rooms: Room[]): CategorizedRoomMap {
const map: CategorizedRoomMap = {
[NotificationLevel.Unsent]: [],
[NotificationLevel.Highlight]: [],
[NotificationLevel.Notification]: [],
[NotificationLevel.Activity]: [],
[NotificationLevel.None]: [],
[NotificationLevel.Muted]: [],
};
for (const room of rooms) {
const category = this.getRoomCategory(room);
map[category]?.push(room);
}
return map;
}
// noinspection JSMethodCanBeStatic
private getRoomCategory(room: Room): NotificationLevel {
// It's fine for us to call this a lot because it's cached, and we shouldn't be
// wasting anything by doing so as the store holds single references
const state = RoomNotificationStateStore.instance.getRoomState(room);
return this.isMutedToBottom && state.muted ? NotificationLevel.Muted : state.level;
}
public setRooms(rooms: Room[]): void {
const categorized = this.categorizeRooms(rooms);
for (const category of Object.keys(categorized)) {
const notificationColor = category as unknown as NotificationLevel;
const roomsToOrder = categorized[notificationColor];
categorized[notificationColor] = sortRoomsWithAlgorithm(roomsToOrder, this.tagId, this.sortingAlgorithm);
}
const newlyOrganized: Room[] = [];
const newIndices: CategoryIndex = {};
for (const category of CATEGORY_ORDER) {
newIndices[category] = newlyOrganized.length;
newlyOrganized.push(...categorized[category]);
}
this.indices = newIndices;
this.cachedOrderedRooms = newlyOrganized;
}
private getCategoryIndex(category: NotificationLevel): number {
const categoryIndex = this.indices[category];
if (categoryIndex === undefined) {
throw new Error(`Index of category ${category} not found`);
}
return categoryIndex;
}
private handleSplice(room: Room, cause: RoomUpdateCause): boolean {
if (cause === RoomUpdateCause.NewRoom) {
const category = this.getRoomCategory(room);
this.alterCategoryPositionBy(category, 1, this.indices);
this.cachedOrderedRooms.splice(this.getCategoryIndex(category), 0, room); // splice in the new room (pre-adjusted)
this.sortCategory(category);
} else if (cause === RoomUpdateCause.RoomRemoved) {
const roomIdx = this.getRoomIndex(room);
if (roomIdx === -1) {
logger.warn(`Tried to remove unknown room from ${this.tagId}: ${room.roomId}`);
return false; // no change
}
const oldCategory = this.getCategoryFromIndices(roomIdx, this.indices);
this.alterCategoryPositionBy(oldCategory, -1, this.indices);
this.cachedOrderedRooms.splice(roomIdx, 1); // remove the room
} else {
throw new Error(`Unhandled splice: ${cause}`);
}
// changes have been made if we made it here, so say so
return true;
}
public handleRoomUpdate(room: Room, cause: RoomUpdateCause): boolean {
if (cause === RoomUpdateCause.NewRoom || cause === RoomUpdateCause.RoomRemoved) {
return this.handleSplice(room, cause);
}
if (
cause !== RoomUpdateCause.Timeline &&
cause !== RoomUpdateCause.ReadReceipt &&
cause !== RoomUpdateCause.PossibleMuteChange
) {
throw new Error(`Unsupported update cause: ${cause}`);
}
// don't react to mute changes when we are not sorting by mute
if (cause === RoomUpdateCause.PossibleMuteChange && !this.isMutedToBottom) {
return false;
}
const category = this.getRoomCategory(room);
const roomIdx = this.getRoomIndex(room);
if (roomIdx === -1) {
throw new Error(`Room ${room.roomId} has no index in ${this.tagId}`);
}
// Try to avoid doing array operations if we don't have to: only move rooms within
// the categories if we're jumping categories
const oldCategory = this.getCategoryFromIndices(roomIdx, this.indices);
if (oldCategory !== category) {
// Move the room and update the indices
this.moveRoomIndexes(1, oldCategory, category, this.indices);
this.cachedOrderedRooms.splice(roomIdx, 1); // splice out the old index (fixed position)
this.cachedOrderedRooms.splice(this.getCategoryIndex(category), 0, room); // splice in the new room (pre-adjusted)
// Note: if moveRoomIndexes() is called after the splice then the insert operation
// will happen in the wrong place. Because we would have already adjusted the index
// for the category, we don't need to determine how the room is moving in the list.
// If we instead tried to insert before updating the indices, we'd have to determine
// whether the room was moving later (towards IDLE) or earlier (towards RED) from its
// current position, as it'll affect the category's start index after we remove the
// room from the array.
}
// Sort the category now that we've dumped the room in
this.sortCategory(category);
return true; // change made
}
private sortCategory(category: NotificationLevel): void {
// This should be relatively quick because the room is usually inserted at the top of the
// category, and most popular sorting algorithms will deal with trying to keep the active
// room at the top/start of the category. For the few algorithms that will have to move the
// thing quite far (alphabetic with a Z room for example), the list should already be sorted
// well enough that it can rip through the array and slot the changed room in quickly.
const nextCategoryStartIdx =
category === CATEGORY_ORDER[CATEGORY_ORDER.length - 1]
? Number.MAX_SAFE_INTEGER
: this.getCategoryIndex(CATEGORY_ORDER[CATEGORY_ORDER.indexOf(category) + 1]);
const startIdx = this.getCategoryIndex(category);
const numSort = nextCategoryStartIdx - startIdx; // splice() returns up to the max, so MAX_SAFE_INT is fine
const unsortedSlice = this.cachedOrderedRooms.splice(startIdx, numSort);
const sorted = sortRoomsWithAlgorithm(unsortedSlice, this.tagId, this.sortingAlgorithm);
this.cachedOrderedRooms.splice(startIdx, 0, ...sorted);
}
// noinspection JSMethodCanBeStatic
private getCategoryFromIndices(index: number, indices: CategoryIndex): NotificationLevel {
for (let i = 0; i < CATEGORY_ORDER.length; i++) {
const category = CATEGORY_ORDER[i];
const isLast = i === CATEGORY_ORDER.length - 1;
const startIdx = indices[category];
const endIdx = isLast ? Number.MAX_SAFE_INTEGER : indices[CATEGORY_ORDER[i + 1]];
if (startIdx === undefined || endIdx === undefined) continue;
if (index >= startIdx && index < endIdx) {
return category;
}
}
// "Should never happen" disclaimer goes here
throw new Error("Programming error: somehow you've ended up with an index that isn't in a category");
}
// noinspection JSMethodCanBeStatic
private moveRoomIndexes(
nRooms: number,
fromCategory: NotificationLevel,
toCategory: NotificationLevel,
indices: CategoryIndex,
): void {
// We have to update the index of the category *after* the from/toCategory variables
// in order to update the indices correctly. Because the room is moving from/to those
// categories, the next category's index will change - not the category we're modifying.
// We also need to update subsequent categories as they'll all shift by nRooms, so we
// loop over the order to achieve that.
this.alterCategoryPositionBy(fromCategory, -nRooms, indices);
this.alterCategoryPositionBy(toCategory, +nRooms, indices);
}
private alterCategoryPositionBy(category: NotificationLevel, n: number, indices: CategoryIndex): void {
// Note: when we alter a category's index, we actually have to modify the ones following
// the target and not the target itself.
// XXX: If this ever actually gets more than one room passed to it, it'll need more index
// handling. For instance, if 45 rooms are removed from the middle of a 50 room list, the
// index for the categories will be way off.
const nextOrderIndex = CATEGORY_ORDER.indexOf(category) + 1;
if (n > 0) {
for (let i = nextOrderIndex; i < CATEGORY_ORDER.length; i++) {
const nextCategory = CATEGORY_ORDER[i];
if (indices[nextCategory] === undefined) {
throw new Error(`Index of category ${category} not found`);
}
indices[nextCategory]! += Math.abs(n);
}
} else if (n < 0) {
for (let i = nextOrderIndex; i < CATEGORY_ORDER.length; i++) {
const nextCategory = CATEGORY_ORDER[i];
if (indices[nextCategory] === undefined) {
throw new Error(`Index of category ${category} not found`);
}
indices[nextCategory]! -= Math.abs(n);
}
}
// Do a quick check to see if we've completely broken the index
for (let i = 1; i < CATEGORY_ORDER.length; i++) {
const lastCat = CATEGORY_ORDER[i - 1];
const lastCatIndex = indices[lastCat];
const thisCat = CATEGORY_ORDER[i];
const thisCatIndex = indices[thisCat];
if (lastCatIndex === undefined || thisCatIndex === undefined || lastCatIndex > thisCatIndex) {
// "should never happen" disclaimer goes here
logger.warn(
`!! Room list index corruption: ${lastCat} (i:${indices[lastCat]}) is greater ` +
`than ${thisCat} (i:${indices[thisCat]}) - category indices are likely desynced from reality`,
);
// TODO: Regenerate index when this happens: https://github.com/vector-im/element-web/issues/14234
}
}
}
}
@@ -1,204 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type Room } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { type SortAlgorithm } from "../models";
import { sortRoomsWithAlgorithm } from "../tag-sorting";
import { OrderingAlgorithm } from "./OrderingAlgorithm";
import { type TagID } from "../../../room-list-v3/skip-list/tag";
import { RoomUpdateCause } from "../../models";
import { RoomNotificationStateStore } from "../../../notifications/RoomNotificationStateStore";
type NaturalCategorizedRoomMap = {
defaultRooms: Room[];
mutedRooms: Room[];
};
/**
* Uses the natural tag sorting algorithm order to determine tag ordering. No
* additional behavioural changes are present.
*/
export class NaturalAlgorithm extends OrderingAlgorithm {
private cachedCategorizedOrderedRooms: NaturalCategorizedRoomMap = {
defaultRooms: [],
mutedRooms: [],
};
public constructor(tagId: TagID, initialSortingAlgorithm: SortAlgorithm) {
super(tagId, initialSortingAlgorithm);
}
public setRooms(rooms: Room[]): void {
const { defaultRooms, mutedRooms } = this.categorizeRooms(rooms);
this.cachedCategorizedOrderedRooms = {
defaultRooms: sortRoomsWithAlgorithm(defaultRooms, this.tagId, this.sortingAlgorithm),
mutedRooms: sortRoomsWithAlgorithm(mutedRooms, this.tagId, this.sortingAlgorithm),
};
this.buildCachedOrderedRooms();
}
public handleRoomUpdate(room: Room, cause: RoomUpdateCause): boolean {
const isSplice = cause === RoomUpdateCause.NewRoom || cause === RoomUpdateCause.RoomRemoved;
const isInPlace =
cause === RoomUpdateCause.Timeline ||
cause === RoomUpdateCause.ReadReceipt ||
cause === RoomUpdateCause.PossibleMuteChange;
const isMuted = this.isMutedToBottom && this.getRoomIsMuted(room);
if (!isSplice && !isInPlace) {
throw new Error(`Unsupported update cause: ${cause}`);
}
if (cause === RoomUpdateCause.NewRoom) {
if (isMuted) {
this.cachedCategorizedOrderedRooms.mutedRooms = sortRoomsWithAlgorithm(
[...this.cachedCategorizedOrderedRooms.mutedRooms, room],
this.tagId,
this.sortingAlgorithm,
);
} else {
this.cachedCategorizedOrderedRooms.defaultRooms = sortRoomsWithAlgorithm(
[...this.cachedCategorizedOrderedRooms.defaultRooms, room],
this.tagId,
this.sortingAlgorithm,
);
}
this.buildCachedOrderedRooms();
return true;
} else if (cause === RoomUpdateCause.RoomRemoved) {
return this.removeRoom(room);
} else if (cause === RoomUpdateCause.PossibleMuteChange) {
if (this.isMutedToBottom) {
return this.onPossibleMuteChange(room);
} else {
return false;
}
}
// TODO: Optimize this to avoid useless operations: https://github.com/vector-im/element-web/issues/14457
// For example, we can skip updates to alphabetic (sometimes) and manually ordered tags
if (isMuted) {
this.cachedCategorizedOrderedRooms.mutedRooms = sortRoomsWithAlgorithm(
this.cachedCategorizedOrderedRooms.mutedRooms,
this.tagId,
this.sortingAlgorithm,
);
} else {
this.cachedCategorizedOrderedRooms.defaultRooms = sortRoomsWithAlgorithm(
this.cachedCategorizedOrderedRooms.defaultRooms,
this.tagId,
this.sortingAlgorithm,
);
}
this.buildCachedOrderedRooms();
return true;
}
/**
* Remove a room from the cached room list
* @param room Room to remove
* @returns {boolean} true when room list should update as result
*/
private removeRoom(room: Room): boolean {
const defaultIndex = this.cachedCategorizedOrderedRooms.defaultRooms.findIndex((r) => r.roomId === room.roomId);
if (defaultIndex > -1) {
this.cachedCategorizedOrderedRooms.defaultRooms.splice(defaultIndex, 1);
this.buildCachedOrderedRooms();
return true;
}
const mutedIndex = this.cachedCategorizedOrderedRooms.mutedRooms.findIndex((r) => r.roomId === room.roomId);
if (mutedIndex > -1) {
this.cachedCategorizedOrderedRooms.mutedRooms.splice(mutedIndex, 1);
this.buildCachedOrderedRooms();
return true;
}
logger.warn(`Tried to remove unknown room from ${this.tagId}: ${room.roomId}`);
// room was not in cached lists, no update
return false;
}
/**
* Sets cachedOrderedRooms from cachedCategorizedOrderedRooms
*/
private buildCachedOrderedRooms(): void {
this.cachedOrderedRooms = [
...this.cachedCategorizedOrderedRooms.defaultRooms,
...this.cachedCategorizedOrderedRooms.mutedRooms,
];
}
private getRoomIsMuted(room: Room): boolean {
// It's fine for us to call this a lot because it's cached, and we shouldn't be
// wasting anything by doing so as the store holds single references
const state = RoomNotificationStateStore.instance.getRoomState(room);
return state.muted;
}
private categorizeRooms(rooms: Room[]): NaturalCategorizedRoomMap {
if (!this.isMutedToBottom) {
return { defaultRooms: rooms, mutedRooms: [] };
}
return rooms.reduce<NaturalCategorizedRoomMap>(
(acc, room: Room) => {
if (this.getRoomIsMuted(room)) {
acc.mutedRooms.push(room);
} else {
acc.defaultRooms.push(room);
}
return acc;
},
{ defaultRooms: [], mutedRooms: [] } as NaturalCategorizedRoomMap,
);
}
private onPossibleMuteChange(room: Room): boolean {
const isMuted = this.getRoomIsMuted(room);
if (isMuted) {
const defaultIndex = this.cachedCategorizedOrderedRooms.defaultRooms.findIndex(
(r) => r.roomId === room.roomId,
);
// room has been muted
if (defaultIndex > -1) {
// remove from the default list
this.cachedCategorizedOrderedRooms.defaultRooms.splice(defaultIndex, 1);
// add to muted list and reorder
this.cachedCategorizedOrderedRooms.mutedRooms = sortRoomsWithAlgorithm(
[...this.cachedCategorizedOrderedRooms.mutedRooms, room],
this.tagId,
this.sortingAlgorithm,
);
// rebuild
this.buildCachedOrderedRooms();
return true;
}
} else {
const mutedIndex = this.cachedCategorizedOrderedRooms.mutedRooms.findIndex((r) => r.roomId === room.roomId);
// room has been unmuted
if (mutedIndex > -1) {
// remove from the muted list
this.cachedCategorizedOrderedRooms.mutedRooms.splice(mutedIndex, 1);
// add to default list and reorder
this.cachedCategorizedOrderedRooms.defaultRooms = sortRoomsWithAlgorithm(
[...this.cachedCategorizedOrderedRooms.defaultRooms, room],
this.tagId,
this.sortingAlgorithm,
);
// rebuild
this.buildCachedOrderedRooms();
return true;
}
}
return false;
}
}
@@ -1,84 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type Room } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { type RoomUpdateCause } from "../../models";
import { type TagID } from "../../../room-list-v3/skip-list/tag";
import { SortAlgorithm } from "../models";
/**
* Represents a list ordering algorithm. Subclasses should populate the
* `cachedOrderedRooms` field.
*/
export abstract class OrderingAlgorithm {
protected cachedOrderedRooms: Room[] = [];
// set by setSortAlgorithm() in ctor
protected sortingAlgorithm!: SortAlgorithm;
protected constructor(
protected tagId: TagID,
initialSortingAlgorithm: SortAlgorithm,
) {
// noinspection JSIgnoredPromiseFromCall
this.setSortAlgorithm(initialSortingAlgorithm); // we use the setter for validation
}
/**
* The rooms as ordered by the algorithm.
*/
public get orderedRooms(): Room[] {
return this.cachedOrderedRooms;
}
public get isMutedToBottom(): boolean {
return this.sortingAlgorithm === SortAlgorithm.Recent;
}
/**
* Sets the sorting algorithm to use within the list.
* @param newAlgorithm The new algorithm. Must be defined.
* @returns Resolves when complete.
*/
public setSortAlgorithm(newAlgorithm: SortAlgorithm): void {
if (!newAlgorithm) throw new Error("A sorting algorithm must be defined");
this.sortingAlgorithm = newAlgorithm;
// Force regeneration of the rooms
this.setRooms(this.orderedRooms);
}
/**
* Sets the rooms the algorithm should be handling, implying a reconstruction
* of the ordering.
* @param rooms The rooms to use going forward.
*/
public abstract setRooms(rooms: Room[]): void;
/**
* Handle a room update. The Algorithm will only call this for causes which
* the list ordering algorithm can handle within the same tag. For example,
* tag changes will not be sent here.
* @param room The room where the update happened.
* @param cause The cause of the update.
* @returns True if the update requires the Algorithm to update the presentation layers.
*/
public abstract handleRoomUpdate(room: Room, cause: RoomUpdateCause): boolean;
protected getRoomIndex(room: Room): number {
let roomIdx = this.cachedOrderedRooms.indexOf(room);
if (roomIdx === -1) {
// can only happen if the js-sdk's store goes sideways.
logger.warn(`Degrading performance to find missing room in "${this.tagId}": ${room.roomId}`);
roomIdx = this.cachedOrderedRooms.findIndex((r) => r.roomId === room.roomId);
}
return roomIdx;
}
}
@@ -1,41 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { ImportanceAlgorithm } from "./ImportanceAlgorithm";
import { ListAlgorithm, type SortAlgorithm } from "../models";
import { NaturalAlgorithm } from "./NaturalAlgorithm";
import { type TagID } from "../../../room-list-v3/skip-list/tag";
import { type OrderingAlgorithm } from "./OrderingAlgorithm";
interface AlgorithmFactory {
(tagId: TagID, initialSortingAlgorithm: SortAlgorithm): OrderingAlgorithm;
}
const ALGORITHM_FACTORIES: { [algorithm in ListAlgorithm]: AlgorithmFactory } = {
[ListAlgorithm.Natural]: (tagId, initSort) => new NaturalAlgorithm(tagId, initSort),
[ListAlgorithm.Importance]: (tagId, initSort) => new ImportanceAlgorithm(tagId, initSort),
};
/**
* Gets an instance of the defined algorithm
* @param {ListAlgorithm} algorithm The algorithm to get an instance of.
* @param {TagID} tagId The tag the algorithm is for.
* @param {SortAlgorithm} initSort The initial sorting algorithm for the ordering algorithm.
* @returns {Algorithm} The algorithm instance.
*/
export function getListAlgorithmInstance(
algorithm: ListAlgorithm,
tagId: TagID,
initSort: SortAlgorithm,
): OrderingAlgorithm {
if (!ALGORITHM_FACTORIES[algorithm]) {
throw new Error(`${algorithm} is not a known algorithm`);
}
return ALGORITHM_FACTORIES[algorithm](tagId, initSort);
}
@@ -1,45 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type Room } from "matrix-js-sdk/src/matrix";
import { type TagID } from "../../room-list-v3/skip-list/tag";
import { type OrderingAlgorithm } from "./list-ordering/OrderingAlgorithm";
export enum SortAlgorithm {
Alphabetic = "ALPHABETIC",
Recent = "RECENT",
}
export enum ListAlgorithm {
// Orders Red > Grey > Bold > Idle
Importance = "IMPORTANCE",
// Orders however the SortAlgorithm decides
Natural = "NATURAL",
}
export interface ITagSortingMap {
// @ts-ignore - TypeScript really wants this to be [tagId: string] but we know better.
[tagId: TagID]: SortAlgorithm;
}
export interface IListOrderingMap {
// @ts-ignore - TypeScript really wants this to be [tagId: string] but we know better.
[tagId: TagID]: ListAlgorithm;
}
export interface IOrderingAlgorithmMap {
// @ts-ignore - TypeScript really wants this to be [tagId: string] but we know better.
[tagId: TagID]: OrderingAlgorithm;
}
export interface ITagMap {
// @ts-ignore - TypeScript really wants this to be [tagId: string] but we know better.
[tagId: TagID]: Room[];
}
@@ -1,24 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type Room } from "matrix-js-sdk/src/matrix";
import { type TagID } from "../../../room-list-v3/skip-list/tag";
import { type IAlgorithm } from "./IAlgorithm";
/**
* Sorts rooms according to the browser's determination of alphabetic.
*/
export class AlphabeticAlgorithm implements IAlgorithm {
public sortRooms(rooms: Room[], tagId: TagID): Room[] {
const collator = new Intl.Collator();
return rooms.sort((a, b) => {
return collator.compare(a.name, b.name);
});
}
}
@@ -1,24 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type Room } from "matrix-js-sdk/src/matrix";
import { type TagID } from "../../../room-list-v3/skip-list/tag";
/**
* Represents a tag sorting algorithm.
*/
export interface IAlgorithm {
/**
* Sorts the given rooms according to the sorting rules of the algorithm.
* @param {Room[]} rooms The rooms to sort.
* @param {TagID} tagId The tag ID in which the rooms are being sorted.
* @returns {Room[]} Returns the sorted rooms.
*/
sortRooms(rooms: Room[], tagId: TagID): Room[];
}
@@ -1,128 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type Room, type MatrixEvent, EventType } from "matrix-js-sdk/src/matrix";
import { type TagID } from "../../../room-list-v3/skip-list/tag";
import { type IAlgorithm } from "./IAlgorithm";
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
import * as Unread from "../../../../Unread";
import { EffectiveMembership, getEffectiveMembership } from "../../../../utils/membership";
export function shouldCauseReorder(event: MatrixEvent): boolean {
const type = event.getType();
const content = event.getContent();
const prevContent = event.getPrevContent();
// Never ignore membership changes
if (type === EventType.RoomMember && prevContent.membership !== content.membership) return true;
// Ignore display name changes
if (type === EventType.RoomMember && prevContent.displayname !== content.displayname) return false;
// Ignore avatar changes
if (type === EventType.RoomMember && prevContent.avatar_url !== content.avatar_url) return false;
return true;
}
export const sortRooms = (rooms: Room[]): Room[] => {
// We cache the timestamp lookup to avoid iterating forever on the timeline
// of events. This cache only survives a single sort though.
// We wouldn't need this if `.sort()` didn't constantly try and compare all
// of the rooms to each other.
// TODO: We could probably improve the sorting algorithm here by finding changes.
// See https://github.com/vector-im/element-web/issues/14459
// For example, if we spent a little bit of time to determine which elements have
// actually changed (probably needs to be done higher up?) then we could do an
// insertion sort or similar on the limited set of changes.
// TODO: Don't assume we're using the same client as the peg
// See https://github.com/vector-im/element-web/issues/14458
let myUserId = "";
if (MatrixClientPeg.get()) {
myUserId = MatrixClientPeg.get()!.getSafeUserId();
}
const tsCache: { [roomId: string]: number } = {};
return rooms.sort((a, b) => {
const roomALastTs = tsCache[a.roomId] ?? getLastTs(a, myUserId);
const roomBLastTs = tsCache[b.roomId] ?? getLastTs(b, myUserId);
tsCache[a.roomId] = roomALastTs;
tsCache[b.roomId] = roomBLastTs;
return roomBLastTs - roomALastTs;
});
};
export const getLastTs = (r: Room, userId: string): number => {
const mainTimelineLastTs = ((): number => {
// Apparently we can have rooms without timelines, at least under testing
// environments. Just return MAX_INT when this happens.
if (!r?.timeline) {
return Number.MAX_SAFE_INTEGER;
}
// MSC4186: Simplified Sliding Sync sets this.
// If it's present, sort by it.
const bumpStamp = r.getBumpStamp();
if (bumpStamp) {
return bumpStamp;
}
// If the room hasn't been joined yet, it probably won't have a timeline to
// parse. We'll still fall back to the timeline if this fails, but chances
// are we'll at least have our own membership event to go off of.
const effectiveMembership = getEffectiveMembership(r.getMyMembership());
if (effectiveMembership !== EffectiveMembership.Join) {
const membershipEvent = r.currentState.getStateEvents(EventType.RoomMember, userId);
if (membershipEvent && !Array.isArray(membershipEvent)) {
return membershipEvent.getTs();
}
}
for (let i = r.timeline.length - 1; i >= 0; --i) {
const ev = r.timeline[i];
if (!ev.getTs()) continue; // skip events that don't have timestamps (tests only?)
if (
(ev.getSender() === userId && shouldCauseReorder(ev)) ||
Unread.eventTriggersUnreadCount(r.client, ev)
) {
return ev.getTs();
}
}
// we might only have events that don't trigger the unread indicator,
// in which case use the oldest event even if normally it wouldn't count.
// This is better than just assuming the last event was forever ago.
return r.timeline[0]?.getTs() ?? Number.MAX_SAFE_INTEGER;
})();
const threadLastEventTimestamps = r.getThreads().map((thread) => {
const event = thread.replyToEvent ?? thread.rootEvent;
return event?.getTs() ?? 0;
});
return Math.max(mainTimelineLastTs, ...threadLastEventTimestamps);
};
/**
* Sorts rooms according to the last event's timestamp in each room that seems
* useful to the user.
*/
export class RecentAlgorithm implements IAlgorithm {
public sortRooms(rooms: Room[], tagId: TagID): Room[] {
return sortRooms(rooms);
}
public getLastTs(room: Room, userId: string): number {
return getLastTs(room, userId);
}
}
@@ -1,44 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type Room } from "matrix-js-sdk/src/matrix";
import { SortAlgorithm } from "../models";
import { type IAlgorithm } from "./IAlgorithm";
import { type TagID } from "../../../room-list-v3/skip-list/tag";
import { RecentAlgorithm } from "./RecentAlgorithm";
import { AlphabeticAlgorithm } from "./AlphabeticAlgorithm";
const ALGORITHM_INSTANCES: { [algorithm in SortAlgorithm]: IAlgorithm } = {
[SortAlgorithm.Recent]: new RecentAlgorithm(),
[SortAlgorithm.Alphabetic]: new AlphabeticAlgorithm(),
};
/**
* Gets an instance of the defined algorithm
* @param {SortAlgorithm} algorithm The algorithm to get an instance of.
* @returns {IAlgorithm} The algorithm instance.
*/
export function getSortingAlgorithmInstance(algorithm: SortAlgorithm): IAlgorithm {
if (!ALGORITHM_INSTANCES[algorithm]) {
throw new Error(`${algorithm} is not a known algorithm`);
}
return ALGORITHM_INSTANCES[algorithm];
}
/**
* Sorts rooms in a given tag according to the algorithm given.
* @param {Room[]} rooms The rooms to sort.
* @param {TagID} tagId The tag in which the sorting is occurring.
* @param {SortAlgorithm} algorithm The algorithm to use for sorting.
* @returns {Room[]} Returns the sorted rooms.
*/
export function sortRoomsWithAlgorithm(rooms: Room[], tagId: TagID, algorithm: SortAlgorithm): Room[] {
return getSortingAlgorithmInstance(algorithm).sortRooms(rooms, tagId);
}
@@ -1,34 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type Room } from "matrix-js-sdk/src/matrix";
import { type EventEmitter } from "events";
export const FILTER_CHANGED = "filter_changed";
/**
* A filter condition for the room list, determining if a room
* should be shown or not.
*
* All filter conditions are expected to be stable executions,
* meaning that given the same input the same answer will be
* returned (thus allowing caching). As such, filter conditions
* can, but shouldn't, do heavier logic and not worry about being
* called constantly by the room list. When the condition changes
* such that different inputs lead to different answers (such
* as a change in the user's input), this emits FILTER_CHANGED.
*/
export interface IFilterCondition extends EventEmitter {
/**
* Determines if a given room should be visible under this
* condition.
* @param room The room to check.
* @returns True if the room should be visible.
*/
isVisible(room: Room): boolean;
}
@@ -1,72 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { EventEmitter } from "events";
import { type Room } from "matrix-js-sdk/src/matrix";
import { FILTER_CHANGED, type IFilterCondition } from "./IFilterCondition";
import { type IDestroyable } from "../../../utils/IDestroyable";
import SpaceStore from "../../spaces/SpaceStore";
import { isMetaSpace, MetaSpace, type SpaceKey } from "../../spaces";
import { setHasDiff } from "../../../utils/sets";
import SettingsStore from "../../../settings/SettingsStore";
/**
* A filter condition for the room list which reveals rooms which
* are a member of a given space or if no space is selected shows:
* + Orphaned rooms (ones not in any space you are a part of)
* + All DMs
*/
export class SpaceFilterCondition extends EventEmitter implements IFilterCondition, IDestroyable {
private roomIds = new Set<string>();
private userIds = new Set<string>();
private showPeopleInSpace = true;
private space: SpaceKey = MetaSpace.Home;
public isVisible(room: Room): boolean {
return SpaceStore.instance.isRoomInSpace(this.space, room.roomId);
}
private onStoreUpdate = async (forceUpdate = false): Promise<void> => {
const beforeRoomIds = this.roomIds;
// clone the set as it may be mutated by the space store internally
this.roomIds = new Set(SpaceStore.instance.getSpaceFilteredRoomIds(this.space));
const beforeUserIds = this.userIds;
// clone the set as it may be mutated by the space store internally
this.userIds = new Set(SpaceStore.instance.getSpaceFilteredUserIds(this.space));
const beforeShowPeopleInSpace = this.showPeopleInSpace;
this.showPeopleInSpace =
isMetaSpace(this.space[0]) || SettingsStore.getValue("Spaces.showPeopleInSpace", this.space);
if (
forceUpdate ||
beforeShowPeopleInSpace !== this.showPeopleInSpace ||
setHasDiff(beforeRoomIds, this.roomIds) ||
setHasDiff(beforeUserIds, this.userIds)
) {
this.emit(FILTER_CHANGED);
// XXX: Room List Store has a bug where updates to the pre-filter during a local echo of a
// tags transition seem to be ignored, so refire in the next tick to work around it
setTimeout(() => {
this.emit(FILTER_CHANGED);
});
}
};
public updateSpace(space: SpaceKey): void {
SpaceStore.instance.off(this.space, this.onStoreUpdate);
SpaceStore.instance.on((this.space = space), this.onStoreUpdate);
this.onStoreUpdate(true); // initial update from the change to the space
}
public destroy(): void {
SpaceStore.instance.off(this.space, this.onStoreUpdate);
}
}
-30
View File
@@ -1,30 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { DefaultTagID } from "../room-list-v3/skip-list/tag";
export const OrderedDefaultTagIDs = [
DefaultTagID.Invite,
DefaultTagID.Favourite,
DefaultTagID.DM,
DefaultTagID.Conference,
DefaultTagID.Untagged,
DefaultTagID.LowPriority,
DefaultTagID.ServerNotice,
DefaultTagID.Suggested,
DefaultTagID.Archived,
];
export enum RoomUpdateCause {
Timeline = "TIMELINE",
PossibleTagChange = "POSSIBLE_TAG_CHANGE",
PossibleMuteChange = "POSSIBLE_MUTE_CHANGE",
ReadReceipt = "READ_RECEIPT",
NewRoom = "NEW_ROOM",
RoomRemoved = "ROOM_REMOVED",
}
+10 -35
View File
@@ -24,7 +24,7 @@ import { logger } from "matrix-js-sdk/src/logger";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import defaultDispatcher from "../../dispatcher/dispatcher";
import RoomListStore from "../room-list/RoomListStore";
import RoomListStoreV3 from "../room-list-v3/RoomListStoreV3";
import SettingsStore from "../../settings/SettingsStore";
import DMRoomMap from "../../utils/DMRoomMap";
import { SpaceNotificationState } from "../notifications/SpaceNotificationState";
@@ -35,7 +35,6 @@ import { setDiff, setHasDiff } from "../../utils/sets";
import { Action } from "../../dispatcher/actions";
import { arrayHasDiff, arrayHasOrderChange, filterBoolean } from "../../utils/arrays";
import { reorderLexicographically } from "../../utils/stringOrderField";
import { TAG_ORDER } from "../../components/views/rooms/LegacyRoomList";
import { type SettingUpdatedPayload } from "../../dispatcher/payloads/SettingUpdatedPayload";
import {
isMetaSpace,
@@ -66,13 +65,7 @@ import { ModuleApi } from "../../modules/Api.ts";
const ACTIVE_SPACE_LS_KEY = "mx_active_space";
const metaSpaceOrder: MetaSpace[] = [
MetaSpace.Home,
MetaSpace.Favourites,
MetaSpace.People,
MetaSpace.Orphans,
MetaSpace.VideoRooms,
];
const metaSpaceOrder: MetaSpace[] = [MetaSpace.Home, MetaSpace.Orphans, MetaSpace.VideoRooms];
const MAX_SUGGESTED_ROOMS = 20;
@@ -167,20 +160,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient<EmptyObject> {
return this._storeReadyDeferred.promise;
}
/**
* Get the order of meta spaces to display in the space panel.
*
* This accessor should be removed when the "feature_new_room_list" labs flag is removed.
* "People" and "Favourites" will be removed from the "metaSpaceOrder" array and this filter will no longer be needed.
* @private
*/
private get metaSpaceOrder(): MetaSpace[] {
if (!SettingsStore.getValue("feature_new_room_list")) return metaSpaceOrder;
// People and Favourites are not shown when the new room list is enabled
return metaSpaceOrder.filter((space) => space !== MetaSpace.People && space !== MetaSpace.Favourites);
}
public get invitedSpaces(): Room[] {
return Array.from(this._invitedSpaces);
}
@@ -217,16 +196,12 @@ export class SpaceStoreClass extends AsyncStoreWithClient<EmptyObject> {
let roomId: string | undefined;
if (space === MetaSpace.Home && this.allRoomsInHome) {
const hasMentions = RoomNotificationStateStore.instance.globalState.hasMentions;
const lists = RoomListStore.instance.orderedLists;
tagLoop: for (let i = 0; i < TAG_ORDER.length; i++) {
const t = TAG_ORDER[i];
if (!lists[t]) continue;
for (const room of lists[t]) {
const state = RoomNotificationStateStore.instance.getRoomState(room);
if (hasMentions ? state.hasMentions : state.isUnread) {
roomId = room.roomId;
break tagLoop;
}
const rooms = RoomListStoreV3.instance.getSortedRoomsInActiveSpace().sections.flatMap((s) => s.rooms);
for (const room of rooms) {
const state = RoomNotificationStateStore.instance.getRoomState(room);
if (hasMentions ? state.hasMentions : state.isUnread) {
roomId = room.roomId;
break;
}
}
} else {
@@ -1198,7 +1173,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<EmptyObject> {
const oldMetaSpaces = this._enabledMetaSpaces;
const enabledMetaSpaces = SettingsStore.getValue("Spaces.enabledMetaSpaces");
this._enabledMetaSpaces = this.metaSpaceOrder.filter((k) => enabledMetaSpaces[k]);
this._enabledMetaSpaces = metaSpaceOrder.filter((k) => enabledMetaSpaces[k]);
this._allRoomsInHome = SettingsStore.getValue("Spaces.allRoomsInHome");
this.sendUserProperties();
@@ -1315,7 +1290,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<EmptyObject> {
case "Spaces.enabledMetaSpaces": {
const newValue = SettingsStore.getValue("Spaces.enabledMetaSpaces");
const enabledMetaSpaces = this.metaSpaceOrder.filter((k) => newValue[k]);
const enabledMetaSpaces = metaSpaceOrder.filter((k) => newValue[k]);
if (arrayHasDiff(this._enabledMetaSpaces, enabledMetaSpaces)) {
const hadPeopleOrHomeEnabled = this.enabledMetaSpaces.some((s) => {
return s === MetaSpace.Home || s === MetaSpace.People;
-22
View File
@@ -43,28 +43,6 @@ export enum EffectiveMembership {
Leave = "LEAVE",
}
export type MembershipSplit = {
[state in EffectiveMembership]: Room[];
};
export function splitRoomsByMembership(rooms: Room[]): MembershipSplit {
const split: MembershipSplit = {
[EffectiveMembership.Invite]: [],
[EffectiveMembership.Join]: [],
[EffectiveMembership.Leave]: [],
};
for (const room of rooms) {
const membership = room.getMyMembership();
// Filter out falsey relationship as this will be peeked rooms
if (!!membership) {
split[getEffectiveMembershipTag(room)].push(room);
}
}
return split;
}
export function getEffectiveMembership(membership: Membership): EffectiveMembership {
if (membership === KnownMembership.Invite) {
return EffectiveMembership.Invite;
-12
View File
@@ -15,7 +15,6 @@ import {
objectHasDiff,
objectKeyChanges,
objectShallowClone,
objectWithOnly,
} from "./objects";
describe("objects", () => {
@@ -30,17 +29,6 @@ describe("objects", () => {
});
});
describe("objectWithOnly", () => {
it("should exclusively use the given properties", () => {
const input = { hello: "world", test: true };
const output = { hello: "world" };
const props = ["hello", "doesnotexist"]; // we also make sure it doesn't explode on missing props
const result = objectWithOnly(input, <any>props); // any is to test the missing prop
expect(result).toBeDefined();
expect(result).toMatchObject(output);
});
});
describe("objectShallowClone", () => {
it("should create a new object", () => {
const input = { test: 1 };
-17
View File
@@ -30,23 +30,6 @@ export function objectExcluding<O extends object, P extends Array<keyof O>>(a: O
}, {} as O);
}
/**
* Gets a new object which represents the provided object, with only some properties
* included.
* @param a The object to clone properties of. Must be defined.
* @param props The property names to keep.
* @returns The new object with only the provided properties.
*/
export function objectWithOnly<O extends object, P extends Array<keyof O>>(a: O, props: P): { [k in P[number]]: O[k] } {
const existingProps = Object.keys(a) as (keyof O)[];
const diff = arrayDiff(existingProps, props);
if (diff.removed.length === 0) {
return objectShallowClone(a);
} else {
return objectExcluding(a, diff.removed) as { [k in P[number]]: O[k] };
}
}
/**
* Clones an object to a caller-controlled depth. When a propertyCloner is supplied, the
* object's properties will be passed through it with the return value used as the new
+4
View File
@@ -16,6 +16,7 @@ import { shouldPolyfill as shouldPolyFillIntlSegmenter } from "@formatjs/intl-se
// These are things that can run before the skin loads - be careful not to reference the react-sdk though.
import { parseAppUrl } from "./url_utils";
import "./modernizr.cjs";
import { polyfillTouchEvent } from "../@types/polyfill";
// Import shared components CSS
import "@element-hq/web-shared-components/dist/element-web-shared-components.css";
@@ -29,6 +30,9 @@ require("katex/dist/katex.css");
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("./localstorage-fix");
// Patch a fake window.TouchEvent for re-resizable's unguarded `instanceof TouchEvent`.
polyfillTouchEvent();
async function settled(...promises: Array<Promise<any>>): Promise<void> {
for (const prom of promises) {
try {
@@ -1,43 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 Mikhail Aheichyk
Copyright 2023 Nordeck IT + Consulting GmbH.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, type RenderResult, screen } from "jest-matrix-react";
import { mocked } from "jest-mock";
import LeftPanel from "../../../../src/components/structures/LeftPanel";
import ResizeNotifier from "../../../../src/utils/ResizeNotifier";
import { shouldShowComponent } from "../../../../src/customisations/helpers/UIComponents";
import { UIComponent } from "../../../../src/settings/UIFeature";
jest.mock("../../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(),
}));
describe("LeftPanel", () => {
function renderComponent(): RenderResult {
return render(<LeftPanel isMinimized={false} resizeNotifier={new ResizeNotifier()} />);
}
it("does not show filter container when disabled by UIComponent customisations", () => {
mocked(shouldShowComponent).mockReturnValue(false);
renderComponent();
expect(shouldShowComponent).toHaveBeenCalledWith(UIComponent.FilterContainer);
expect(screen.queryByRole("button", { name: /search/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Explore rooms" })).not.toBeInTheDocument();
});
it("renders filter container when enabled by UIComponent customisations", () => {
mocked(shouldShowComponent).mockReturnValue(true);
renderComponent();
expect(shouldShowComponent).toHaveBeenCalledWith(UIComponent.FilterContainer);
expect(screen.getByRole("button", { name: /search/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Explore rooms" })).toBeInTheDocument();
});
});
@@ -41,27 +41,6 @@ import { SETTINGS } from "../../../../src/settings/Settings";
import ToastStore from "../../../../src/stores/ToastStore";
import { ModuleApi } from "../../../../src/modules/Api";
// Create a mock resizer instance that can be shared across tests
const mockResizerInstance = {
attach: jest.fn(),
detach: jest.fn(),
forHandleWithId: jest.fn().mockReturnValue({ resize: jest.fn() }),
setClassNames: jest.fn(),
};
// Mock the Resizer module
jest.mock("../../../../src/resizer", () => {
const originalModule = jest.requireActual("../../../../src/resizer");
return {
...originalModule,
Resizer: jest.fn().mockImplementation((container, distributorBuilder, collapseConfig) => {
// Store the callbacks globally for test access
(global as any).__resizeCallbacks = collapseConfig;
return mockResizerInstance;
}),
};
});
describe("<LoggedInView />", () => {
const userId = "@alice:domain.org";
const mockClient = getMockClientWithEventEmitter({
@@ -86,7 +65,6 @@ describe("<LoggedInView />", () => {
matrixClient: mockClient,
onRegistered: jest.fn(),
resizeNotifier: new ResizeNotifier(),
collapseLhs: false,
hideToSRUsers: false,
config: {
brand: "Test",
@@ -535,100 +513,18 @@ describe("<LoggedInView />", () => {
});
});
describe("resizer preferences", () => {
let mockResize: jest.Mock;
let mockForHandleWithId: jest.Mock;
beforeEach(() => {
// Clear localStorage before each test
window.localStorage.clear();
mockResize = jest.fn();
mockForHandleWithId = jest.fn().mockReturnValue({ resize: mockResize });
// Update the shared mock instance for this test
mockResizerInstance.forHandleWithId = mockForHandleWithId;
// Clear any global callback state
delete (global as any).__resizeCallbacks;
});
it("should call resize with default size when localStorage contains NaN value", () => {
// Set invalid value in localStorage that will result in NaN
window.localStorage.setItem("mx_lhs_size", "not-a-number");
getComponent();
// Verify that when lhsSize is NaN, it defaults to 350 and calls resize
expect(mockForHandleWithId).toHaveBeenCalledWith("lp-resizer");
expect(mockResize).toHaveBeenCalledWith(350);
});
it("should use existing size when localStorage contains valid value", () => {
// Set valid value in localStorage
window.localStorage.setItem("mx_lhs_size", "400");
getComponent();
// Verify the resize method was called with the stored size (400)
expect(mockResize).toHaveBeenCalledWith(400);
});
it("should enforce minimum width for new room list when stored size is zero", async () => {
// Enable new room list feature
await SettingsStore.setValue("feature_new_room_list", null, SettingLevel.DEVICE, true);
// 0 represents the collapsed state for the old room list, which could have been set before the new room list was enabled
window.localStorage.setItem("mx_lhs_size", "0");
getComponent();
// Verify the resize method was called with the default size (350) when stored size is below minimum
expect(mockResize).toHaveBeenCalledWith(350);
});
it("should not set localStorage to 0 when resizing lp-resizer to minimum width for new room list", async () => {
// Enable new room list feature and mock SettingsStore
await SettingsStore.setValue("feature_new_room_list", null, SettingLevel.DEVICE, true);
const minimumWidth = 224; // NEW_ROOM_LIST_MIN_WIDTH
// Render the component
getComponent();
// Get the callbacks that were captured during resizer creation
const callbacks = (global as any).__resizeCallbacks;
// Create a mock DOM node for isItemCollapsed to check
const domNode = {
classList: {
contains: jest.fn().mockReturnValue(true), // Simulate the error where mx_LeftPanel_minimized is present
},
} as any;
callbacks.onResized(minimumWidth);
const isCollapsed = callbacks.isItemCollapsed(domNode);
callbacks.onCollapsed(isCollapsed); // Not collapsed for new room list
callbacks.onResizeStop();
// Verify localStorage was set to the minimum width (224), not 0
expect(window.localStorage.getItem("mx_lhs_size")).toBe("224");
});
});
describe("module-rendered fullscreen view (e.g. multiroom)", () => {
// A page_type for which a module registers a custom full-screen renderer.
const modulePageType = "io.element.test_fullscreen";
beforeEach(async () => {
await SettingsStore.setValue("feature_new_room_list", null, SettingLevel.DEVICE, true);
beforeEach(() => {
ModuleApi.instance.navigation.registerLocationRenderer(modulePageType, () => (
<div data-testid="module-content" />
));
});
afterEach(async () => {
afterEach(() => {
ModuleApi.instance.navigation.locationRenderers.delete(modulePageType);
await SettingsStore.setValue("feature_new_room_list", null, SettingLevel.DEVICE, false);
});
it("renders the resizable separator for a normal room view with the new room list", () => {
@@ -650,34 +546,4 @@ describe("<LoggedInView />", () => {
expect(container.querySelector(".mx_SpacePanel")).toBeInTheDocument();
});
});
describe("create a new resizer when page_type changes", () => {
afterEach(() => {
jest.clearAllMocks();
});
it("should call loadResizer when page_type changes", () => {
const component = getComponent({ page_type: "room" });
// Re-render with different page_type
component.rerender(<LoggedInView {...defaultProps} page_type="home" />);
// Verify that detach was called (from loadResizer)
expect(mockResizerInstance.detach).toHaveBeenCalledTimes(1);
// Verify that attach was called (from loadResizer)
// 1 (when page_type = "room") + 1 (when page_type = "home")
expect(mockResizerInstance.attach).toHaveBeenCalledTimes(2);
});
it("should not call loadResizer when page_type remains the same", () => {
const component = getComponent({ page_type: "room" });
// Re-render with same page_type but different other props
component.rerender(<LoggedInView {...defaultProps} page_type="room" currentRoomId="!different:room.id" />);
// Verify that resizer methods were not called
expect(mockResizerInstance.detach).not.toHaveBeenCalled();
expect(mockResizerInstance.attach).toHaveBeenCalledTimes(1);
});
});
});
@@ -68,7 +68,6 @@ import Modal from "../../../../src/Modal.tsx";
import { SetupEncryptionStore } from "../../../../src/stores/SetupEncryptionStore.ts";
import { ShareFormat } from "../../../../src/dispatcher/payloads/SharePayload.ts";
import { clearStorage } from "../../../../src/Lifecycle";
import RoomListStore from "../../../../src/stores/room-list/RoomListStore.ts";
import UserSettingsDialog from "../../../../src/components/views/dialogs/UserSettingsDialog.tsx";
import { SDKContextClass } from "../../../../src/contexts/SDKContextClass";
import { makeDelegatedAuthConfig } from "../../../test-utils/oidc.ts";
@@ -854,9 +853,6 @@ describe("<MatrixChat />", () => {
await clearAllModals();
await getComponentAndWaitForReady();
// Mock out the old room list store
jest.spyOn(RoomListStore.instance, "manualRoomUpdate").mockImplementation(async () => {});
// Register a mock function to the dispatcher
const fn = jest.fn();
defaultDispatcher.register(fn);
@@ -11,6 +11,7 @@ import React from "react";
import { render, screen, type RenderResult } from "jest-matrix-react";
import { mocked } from "jest-mock";
import { Room, type MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/matrix";
import userEvent from "@testing-library/user-event";
import { RoomResultContextMenus } from "../../../../../../src/components/views/dialogs/spotlight/RoomResultContextMenus";
import { filterConsole, stubClient } from "../../../../../test-utils";
@@ -54,4 +55,16 @@ describe("RoomResultContextMenus", () => {
expect(shouldShowComponent).toHaveBeenCalledWith(UIComponent.RoomOptionsMenu);
expect(screen.queryByRole("button", { name: "Room options" })).toBeInTheDocument();
});
it("opens the notification options menu positioned relative to its button", async () => {
const user = userEvent.setup();
mocked(shouldShowComponent).mockReturnValue(true);
renderRoomResultContextMenus();
const button = screen.getByRole("button", { name: "Notification options" });
// Opening the menu runs contextMenuBelow to position it beneath the button.
await user.click(button);
expect(screen.getByRole("menu")).toBeInTheDocument();
});
});
@@ -1,53 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { getByRole, render } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import React, { type ComponentProps } from "react";
import ExtraTile from "../../../../../src/components/views/rooms/ExtraTile";
describe("ExtraTile", () => {
function renderComponent(props: Partial<ComponentProps<typeof ExtraTile>> = {}) {
const defaultProps: ComponentProps<typeof ExtraTile> = {
isMinimized: false,
isSelected: false,
displayName: "test",
avatar: <React.Fragment />,
onClick: () => {},
};
return render(<ExtraTile {...defaultProps} {...props} />);
}
it("renders", () => {
const { asFragment } = renderComponent();
expect(asFragment()).toMatchSnapshot();
});
it("hides text when minimized", () => {
const { container } = renderComponent({
isMinimized: true,
displayName: "testDisplayName",
});
expect(container).not.toHaveTextContent("testDisplayName");
});
it("registers clicks", async () => {
const onClick = jest.fn();
const { container } = renderComponent({
onClick,
});
const btn = getByRole(container, "treeitem");
await userEvent.click(btn);
expect(onClick).toHaveBeenCalledTimes(1);
});
});
@@ -1,273 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 Mikhail Aheichyk
Copyright 2023 Nordeck IT + Consulting GmbH.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { cleanup, queryByRole, render, screen, within } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { mocked } from "jest-mock";
import { type Room } from "matrix-js-sdk/src/matrix";
import LegacyRoomList from "../../../../../src/components/views/rooms/LegacyRoomList";
import ResizeNotifier from "../../../../../src/utils/ResizeNotifier";
import { MetaSpace } from "../../../../../src/stores/spaces";
import { shouldShowComponent } from "../../../../../src/customisations/helpers/UIComponents";
import { UIComponent } from "../../../../../src/settings/UIFeature";
import dis from "../../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../../src/dispatcher/actions";
import * as testUtils from "../../../../test-utils";
import { mkSpace, stubClient } from "../../../../test-utils";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import SpaceStore from "../../../../../src/stores/spaces/SpaceStore";
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
import RoomListStore from "../../../../../src/stores/room-list/RoomListStore";
import { type ITagMap } from "../../../../../src/stores/room-list/algorithms/models";
import { DefaultTagID } from "../../../../../src/stores/room-list-v3/skip-list/tag";
jest.mock("../../../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(),
}));
jest.mock("../../../../../src/dispatcher/dispatcher");
const getUserIdForRoomId = jest.fn();
const getDMRoomsForUserId = jest.fn();
// @ts-ignore
DMRoomMap.sharedInstance = { getUserIdForRoomId, getDMRoomsForUserId };
describe("LegacyRoomList", () => {
stubClient();
const client = MatrixClientPeg.safeGet();
const store = SpaceStore.instance;
function getComponent(props: Partial<LegacyRoomList["props"]> = {}): JSX.Element {
return (
<LegacyRoomList
onKeyDown={jest.fn()}
onFocus={jest.fn()}
onBlur={jest.fn()}
onResize={jest.fn()}
resizeNotifier={new ResizeNotifier()}
isMinimized={false}
activeSpace={MetaSpace.Home}
{...props}
/>
);
}
describe("Rooms", () => {
describe("when meta space is active", () => {
beforeEach(() => {
store.setActiveSpace(MetaSpace.Home);
});
it("does not render add room button when UIComponent customisation disables CreateRooms and ExploreRooms", () => {
const disabled: UIComponent[] = [UIComponent.CreateRooms, UIComponent.ExploreRooms];
mocked(shouldShowComponent).mockImplementation((feature) => !disabled.includes(feature));
render(getComponent());
const roomsList = screen.getByRole("group", { name: "Rooms" });
expect(within(roomsList).queryByRole("button", { name: "Add room" })).not.toBeInTheDocument();
});
it("renders add room button with menu when UIComponent customisation allows CreateRooms or ExploreRooms", async () => {
let disabled: UIComponent[] = [];
mocked(shouldShowComponent).mockImplementation((feature) => !disabled.includes(feature));
const { rerender } = render(getComponent());
const roomsList = screen.getByRole("group", { name: "Rooms" });
const addRoomButton = within(roomsList).getByRole("button", { name: "Add room" });
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
await userEvent.click(addRoomButton);
const menu = screen.getByRole("menu");
expect(within(menu).getByRole("menuitem", { name: "New room" })).toBeInTheDocument();
expect(within(menu).getByRole("menuitem", { name: "Explore public rooms" })).toBeInTheDocument();
disabled = [UIComponent.CreateRooms];
rerender(getComponent());
expect(addRoomButton).toBeInTheDocument();
expect(menu).toBeInTheDocument();
expect(within(menu).queryByRole("menuitem", { name: "New room" })).not.toBeInTheDocument();
expect(within(menu).getByRole("menuitem", { name: "Explore public rooms" })).toBeInTheDocument();
disabled = [UIComponent.ExploreRooms];
rerender(getComponent());
expect(addRoomButton).toBeInTheDocument();
expect(menu).toBeInTheDocument();
expect(within(menu).getByRole("menuitem", { name: "New room" })).toBeInTheDocument();
expect(within(menu).queryByRole("menuitem", { name: "Explore public rooms" })).not.toBeInTheDocument();
});
it("renders add room button and clicks explore public rooms", async () => {
mocked(shouldShowComponent).mockReturnValue(true);
render(getComponent());
const roomsList = screen.getByRole("group", { name: "Rooms" });
await userEvent.click(within(roomsList).getByRole("button", { name: "Add room" }));
const menu = screen.getByRole("menu");
await userEvent.click(within(menu).getByRole("menuitem", { name: "Explore public rooms" }));
expect(dis.fire).toHaveBeenCalledWith(Action.ViewRoomDirectory);
});
});
describe("when room space is active", () => {
let rooms: Room[];
const mkSpaceForRooms = (spaceId: string, children: string[] = []) =>
mkSpace(client, spaceId, rooms, children);
const space1 = "!space1:server";
beforeEach(async () => {
rooms = [];
mkSpaceForRooms(space1);
mocked(client).getRoom.mockImplementation(
(roomId) => rooms.find((room) => room.roomId === roomId) || null,
);
await testUtils.setupAsyncStoreWithClient(store, client);
store.setActiveSpace(space1);
});
it("does not render add room button when UIComponent customisation disables CreateRooms and ExploreRooms", () => {
const disabled: UIComponent[] = [UIComponent.CreateRooms, UIComponent.ExploreRooms];
mocked(shouldShowComponent).mockImplementation((feature) => !disabled.includes(feature));
render(getComponent());
const roomsList = screen.getByRole("group", { name: "Rooms" });
expect(within(roomsList).queryByRole("button", { name: "Add room" })).not.toBeInTheDocument();
});
it("renders add room button with menu when UIComponent customisation allows CreateRooms or ExploreRooms", async () => {
let disabled: UIComponent[] = [];
mocked(shouldShowComponent).mockImplementation((feature) => !disabled.includes(feature));
const { rerender } = render(getComponent());
const roomsList = screen.getByRole("group", { name: "Rooms" });
const addRoomButton = within(roomsList).getByRole("button", { name: "Add room" });
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
await userEvent.click(addRoomButton);
const menu = screen.getByRole("menu");
expect(within(menu).getByRole("menuitem", { name: "Explore rooms" })).toBeInTheDocument();
expect(within(menu).getByRole("menuitem", { name: "New room" })).toBeInTheDocument();
expect(within(menu).getByRole("menuitem", { name: "Add existing room" })).toBeInTheDocument();
disabled = [UIComponent.CreateRooms];
rerender(getComponent());
expect(addRoomButton).toBeInTheDocument();
expect(menu).toBeInTheDocument();
expect(within(menu).getByRole("menuitem", { name: "Explore rooms" })).toBeInTheDocument();
expect(within(menu).queryByRole("menuitem", { name: "New room" })).not.toBeInTheDocument();
expect(within(menu).queryByRole("menuitem", { name: "Add existing room" })).not.toBeInTheDocument();
disabled = [UIComponent.ExploreRooms];
rerender(getComponent());
expect(addRoomButton).toBeInTheDocument();
expect(menu).toBeInTheDocument();
expect(within(menu).queryByRole("menuitem", { name: "Explore rooms" })).toBeInTheDocument();
expect(within(menu).getByRole("menuitem", { name: "New room" })).toBeInTheDocument();
expect(within(menu).getByRole("menuitem", { name: "Add existing room" })).toBeInTheDocument();
});
it("renders add room button and clicks explore rooms", async () => {
mocked(shouldShowComponent).mockReturnValue(true);
render(getComponent());
const roomsList = screen.getByRole("group", { name: "Rooms" });
await userEvent.click(within(roomsList).getByRole("button", { name: "Add room" }));
const menu = screen.getByRole("menu");
await userEvent.click(within(menu).getByRole("menuitem", { name: "Explore rooms" }));
expect(dis.dispatch).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: space1,
});
});
});
describe("when video meta space is active", () => {
const videoRoomPrivate = "!videoRoomPrivate_server";
const videoRoomPublic = "!videoRoomPublic_server";
const videoRoomKnock = "!videoRoomKnock_server";
beforeEach(async () => {
cleanup();
const rooms: Room[] = [];
testUtils.mkRoom(client, videoRoomPrivate, rooms);
testUtils.mkRoom(client, videoRoomPublic, rooms);
testUtils.mkRoom(client, videoRoomKnock, rooms);
mocked(client).getRoom.mockImplementation(
(roomId) => rooms.find((room) => room.roomId === roomId) || null,
);
mocked(client).getRooms.mockImplementation(() => rooms);
const videoRoomKnockRoom = client.getRoom(videoRoomKnock)!;
const videoRoomPrivateRoom = client.getRoom(videoRoomPrivate)!;
const videoRoomPublicRoom = client.getRoom(videoRoomPublic)!;
[videoRoomPrivateRoom, videoRoomPublicRoom, videoRoomKnockRoom].forEach((room) => {
(room.isCallRoom as jest.Mock).mockReturnValue(true);
});
const roomLists: ITagMap = {};
roomLists[DefaultTagID.Conference] = [videoRoomKnockRoom, videoRoomPublicRoom];
roomLists[DefaultTagID.Untagged] = [videoRoomPrivateRoom];
jest.spyOn(RoomListStore.instance, "orderedLists", "get").mockReturnValue(roomLists);
await testUtils.setupAsyncStoreWithClient(store, client);
store.setActiveSpace(MetaSpace.VideoRooms);
});
it("renders Conferences and Room but no People section", () => {
const renderResult = render(getComponent({ activeSpace: MetaSpace.VideoRooms }));
const roomsEl = renderResult.getByRole("treeitem", { name: "Rooms" });
const conferenceEl = renderResult.getByRole("treeitem", { name: "Conferences" });
const noInvites = screen.queryByRole("treeitem", { name: "Invites" });
const noFavourites = screen.queryByRole("treeitem", { name: "Favourites" });
const noPeople = screen.queryByRole("treeitem", { name: "People" });
const noLowPriority = screen.queryByRole("treeitem", { name: "Low priority" });
const noHistorical = screen.queryByRole("treeitem", { name: "Historical" });
expect(roomsEl).toBeVisible();
expect(conferenceEl).toBeVisible();
expect(noInvites).toBeFalsy();
expect(noFavourites).toBeFalsy();
expect(noPeople).toBeFalsy();
expect(noLowPriority).toBeFalsy();
expect(noHistorical).toBeFalsy();
});
it("renders Public and Knock rooms in Conferences section", () => {
const renderResult = render(getComponent({ activeSpace: MetaSpace.VideoRooms }));
const conferenceList = renderResult.getByRole("group", { name: "Conferences" });
expect(queryByRole(conferenceList, "treeitem", { name: videoRoomPublic })).toBeVisible();
expect(queryByRole(conferenceList, "treeitem", { name: videoRoomKnock })).toBeVisible();
expect(queryByRole(conferenceList, "treeitem", { name: videoRoomPrivate })).toBeFalsy();
const roomsList = renderResult.getByRole("group", { name: "Rooms" });
expect(queryByRole(roomsList, "treeitem", { name: videoRoomPrivate })).toBeVisible();
expect(queryByRole(roomsList, "treeitem", { name: videoRoomPublic })).toBeFalsy();
expect(queryByRole(roomsList, "treeitem", { name: videoRoomKnock })).toBeFalsy();
});
});
});
});
@@ -1,281 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { type MatrixClient, type Room, EventType } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import { act, render, screen, fireEvent, type RenderResult } from "jest-matrix-react";
import SpaceStore from "../../../../../src/stores/spaces/SpaceStore";
import { MetaSpace } from "../../../../../src/stores/spaces";
import _RoomListHeader from "../../../../../src/components/views/rooms/LegacyRoomListHeader";
import * as testUtils from "../../../../test-utils";
import { stubClient, mkSpace } from "../../../../test-utils";
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../../../src/settings/SettingLevel";
import { shouldShowComponent } from "../../../../../src/customisations/helpers/UIComponents";
import { UIComponent } from "../../../../../src/settings/UIFeature";
const RoomListHeader = testUtils.wrapInMatrixClientContext(_RoomListHeader);
jest.mock("../../../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(),
}));
const blockUIComponent = (component: UIComponent): void => {
mocked(shouldShowComponent).mockImplementation((feature) => feature !== component);
};
const setupSpace = (client: MatrixClient): Room => {
const testSpace: Room = mkSpace(client, "!space:server");
testSpace.name = "Test Space";
client.getRoom = () => testSpace;
return testSpace;
};
const setupMainMenu = async (client: MatrixClient, testSpace: Room): Promise<RenderResult> => {
await testUtils.setupAsyncStoreWithClient(SpaceStore.instance, client);
act(() => {
SpaceStore.instance.setActiveSpace(testSpace.roomId);
});
const wrapper = render(<RoomListHeader />);
expect(wrapper.getByText("Test Space")).toBeInTheDocument();
wrapper.getByLabelText("Test Space menu").click();
return wrapper;
};
const setupPlusMenu = async (client: MatrixClient, testSpace: Room): Promise<RenderResult> => {
await testUtils.setupAsyncStoreWithClient(SpaceStore.instance, client);
act(() => {
SpaceStore.instance.setActiveSpace(testSpace.roomId);
});
const wrapper = render(<RoomListHeader />);
expect(wrapper.getByText("Test Space")).toBeInTheDocument();
act(() => {
wrapper.getByLabelText("Add")?.click();
});
return wrapper;
};
const checkIsDisabled = (menuItem: HTMLElement): void => {
expect(menuItem).toHaveAttribute("disabled");
expect(menuItem).toHaveAttribute("aria-disabled", "true");
};
const checkMenuLabels = (items: NodeListOf<Element>, labelArray: Array<string>) => {
expect(items).toHaveLength(labelArray.length);
const checkLabel = (item: Element, label: string) => {
expect(item.querySelector(".mx_IconizedContextMenu_label")).toHaveTextContent(label);
};
labelArray.forEach((label, index) => {
console.log("index", index, "label", label);
checkLabel(items[index], label);
});
};
describe("RoomListHeader", () => {
let client: MatrixClient;
beforeEach(async () => {
// This tests the header from the old room list/left panel, the new one is now enabled by default,
// so needs to be explicitly disabled to test the old list here.
await SettingsStore.setValue("feature_new_room_list", null, SettingLevel.DEVICE, false);
jest.resetAllMocks();
const dmRoomMap = {
getUserIdForRoomId: jest.fn(),
getDMRoomsForUserId: jest.fn(),
} as unknown as DMRoomMap;
DMRoomMap.setShared(dmRoomMap);
stubClient();
client = MatrixClientPeg.safeGet();
mocked(shouldShowComponent).mockReturnValue(true); // show all UIComponents
});
it("renders a main menu for the home space", () => {
act(() => {
SpaceStore.instance.setActiveSpace(MetaSpace.Home);
});
const { container } = render(<RoomListHeader />);
expect(container).toHaveTextContent("Home");
fireEvent.click(screen.getByLabelText("Home options"));
const menu = screen.getByRole("menu");
const items = menu.querySelectorAll(".mx_IconizedContextMenu_item");
expect(items).toHaveLength(1);
expect(items[0]).toHaveTextContent("Show all rooms");
});
it("renders a main menu for spaces", async () => {
const testSpace = setupSpace(client);
await setupMainMenu(client, testSpace);
const menu = screen.getByRole("menu");
const items = menu.querySelectorAll(".mx_IconizedContextMenu_item");
checkMenuLabels(items, ["Space home", "Manage & explore rooms", "Preferences", "Settings", "Room", "Space"]);
});
it("renders a plus menu for spaces", async () => {
const testSpace = setupSpace(client);
await setupPlusMenu(client, testSpace);
const menu = screen.getByRole("menu");
const items = menu.querySelectorAll(".mx_IconizedContextMenu_item");
checkMenuLabels(items, ["New room", "Explore rooms", "Add existing room", "Add space"]);
});
it("closes menu if space changes from under it", async () => {
await SettingsStore.setValue("Spaces.enabledMetaSpaces", null, SettingLevel.DEVICE, {
[MetaSpace.Home]: true,
[MetaSpace.Favourites]: true,
});
const testSpace = setupSpace(client);
await setupMainMenu(client, testSpace);
act(() => {
SpaceStore.instance.setActiveSpace(MetaSpace.Favourites);
});
screen.getByText("Favourites");
expect(screen.queryByRole("menu")).toBeFalsy();
});
describe("UIComponents", () => {
describe("Main menu", () => {
it("does not render Add Space when user does not have permission to add spaces", async () => {
// User does not have permission to add spaces, anywhere
blockUIComponent(UIComponent.CreateSpaces);
const testSpace = setupSpace(client);
await setupMainMenu(client, testSpace);
const menu = screen.getByRole("menu");
const items = menu.querySelectorAll(".mx_IconizedContextMenu_item");
checkMenuLabels(items, [
"Space home",
"Manage & explore rooms",
"Preferences",
"Settings",
"Room",
// no add space
]);
});
it("does not render Add Room when user does not have permission to add rooms", async () => {
// User does not have permission to add rooms
blockUIComponent(UIComponent.CreateRooms);
const testSpace = setupSpace(client);
await setupMainMenu(client, testSpace);
const menu = screen.getByRole("menu");
const items = menu.querySelectorAll(".mx_IconizedContextMenu_item");
checkMenuLabels(items, [
"Space home",
"Explore rooms", // not Manage & explore rooms
"Preferences",
"Settings",
// no add room
"Space",
]);
});
});
describe("Plus menu", () => {
it("does not render Add Space when user does not have permission to add spaces", async () => {
// User does not have permission to add spaces, anywhere
blockUIComponent(UIComponent.CreateSpaces);
const testSpace = setupSpace(client);
await setupPlusMenu(client, testSpace);
const menu = screen.getByRole("menu");
const items = menu.querySelectorAll(".mx_IconizedContextMenu_item");
checkMenuLabels(items, [
"New room",
"Explore rooms",
"Add existing room",
// no Add space
]);
});
it("disables Add Room when user does not have permission to add rooms", async () => {
// User does not have permission to add rooms
blockUIComponent(UIComponent.CreateRooms);
const testSpace = setupSpace(client);
await setupPlusMenu(client, testSpace);
const menu = screen.getByRole("menu");
const items = menu.querySelectorAll<HTMLElement>(".mx_IconizedContextMenu_item");
checkMenuLabels(items, ["New room", "Explore rooms", "Add existing room", "Add space"]);
// "Add existing room" is disabled
checkIsDisabled(items[2]);
});
});
});
describe("adding children to space", () => {
it("if user cannot add children to space, MainMenu adding buttons are hidden", async () => {
const testSpace = setupSpace(client);
mocked(testSpace.currentState.maySendStateEvent).mockImplementation(
(stateEventType, userId) => stateEventType !== EventType.SpaceChild,
);
await setupMainMenu(client, testSpace);
const menu = screen.getByRole("menu");
const items = menu.querySelectorAll(".mx_IconizedContextMenu_item");
checkMenuLabels(items, [
"Space home",
"Explore rooms", // not Manage & explore rooms
"Preferences",
"Settings",
// no add room
// no add space
]);
});
it("if user cannot add children to space, PlusMenu add buttons are disabled", async () => {
const testSpace = setupSpace(client);
mocked(testSpace.currentState.maySendStateEvent).mockImplementation(
(stateEventType, userId) => stateEventType !== EventType.SpaceChild,
);
await setupPlusMenu(client, testSpace);
const menu = screen.getByRole("menu");
const items = menu.querySelectorAll<HTMLElement>(".mx_IconizedContextMenu_item");
checkMenuLabels(items, ["New room", "Explore rooms", "Add existing room", "Add space"]);
// "Add existing room" is disabled
checkIsDisabled(items[2]);
// "Add space" is disabled
checkIsDisabled(items[3]);
});
});
});
@@ -1,317 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, screen, act, type RenderResult } from "jest-matrix-react";
import { mocked, type Mocked } from "jest-mock";
import {
type MatrixClient,
PendingEventOrdering,
Room,
RoomStateEvent,
type Thread,
type RoomMember,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { Widget } from "matrix-widget-api";
import {
stubClient,
mkRoomMember,
MockedCall,
useMockedCalls,
setupAsyncStoreWithClient,
filterConsole,
flushPromises,
mkMessage,
useMockMediaDevices,
} from "../../../../test-utils";
import { CallStore } from "../../../../../src/stores/CallStore";
import RoomTile from "../../../../../src/components/views/rooms/RoomTile";
import { DefaultTagID } from "../../../../../src/stores/room-list-v3/skip-list/tag";
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
import PlatformPeg from "../../../../../src/PlatformPeg";
import type BasePlatform from "../../../../../src/BasePlatform";
import { WidgetMessagingStore } from "../../../../../src/stores/widgets/WidgetMessagingStore";
import { TestSDKContext } from "../../../TestSDKContext";
import { SDKContext } from "../../../../../src/contexts/SDKContext";
import { shouldShowComponent } from "../../../../../src/customisations/helpers/UIComponents";
import { UIComponent } from "../../../../../src/settings/UIFeature";
import { MessagePreviewStore } from "../../../../../src/stores/message-preview";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import { ConnectionState } from "../../../../../src/models/Call";
import { type WidgetMessaging } from "../../../../../src/stores/widgets/WidgetMessaging";
jest.mock("../../../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(),
}));
describe("RoomTile", () => {
jest.spyOn(PlatformPeg, "get").mockReturnValue({
overrideBrowserShortcuts: () => false,
} as unknown as BasePlatform);
useMockedCalls();
const renderRoomTile = (): RenderResult => {
return render(
<SDKContext.Provider value={sdkContext}>
<RoomTile
room={room}
showMessagePreview={showMessagePreview}
isMinimized={false}
tag={DefaultTagID.Untagged}
/>
</SDKContext.Provider>,
);
};
let client: Mocked<MatrixClient>;
let room: Room;
let sdkContext: TestSDKContext;
let showMessagePreview = false;
filterConsole(
// irrelevant for this test
"Room !1:example.org does not have an m.room.create event",
);
const addMessageToRoom = (ts: number) => {
const message = mkMessage({
event: true,
room: room.roomId,
msg: "test message",
user: client.getSafeUserId(),
ts,
});
room.timeline.push(message);
};
const addThreadMessageToRoom = (ts: number) => {
const message = mkMessage({
event: true,
room: room.roomId,
msg: "test thread reply",
user: client.getSafeUserId(),
ts,
});
// Mock thread reply for tests.
jest.spyOn(room, "getThreads").mockReturnValue([
// @ts-ignore
{
lastReply: () => message,
timeline: [],
} as Thread,
]);
};
beforeEach(() => {
useMockMediaDevices();
sdkContext = new TestSDKContext();
client = mocked(stubClient());
sdkContext.client = client;
DMRoomMap.makeShared(client);
room = new Room("!1:example.org", client, "@alice:example.org", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
client.getRoom.mockImplementation((roomId) => (roomId === room.roomId ? room : null));
client.getRooms.mockReturnValue([room]);
client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
});
afterEach(() => {
// @ts-ignore
MessagePreviewStore.instance.previews = new Map<string, Map<TagID | TAG_ANY, MessagePreview | null>>();
jest.clearAllMocks();
});
describe("when message previews are not enabled", () => {
it("should render the room", () => {
mocked(shouldShowComponent).mockReturnValue(true);
const { container } = renderRoomTile();
expect(container).toMatchSnapshot();
expect(container.querySelector(".mx_RoomTile_sticky")).not.toBeInTheDocument();
});
it("does not render the room options context menu when UIComponent customisations disable room options", () => {
mocked(shouldShowComponent).mockReturnValue(false);
renderRoomTile();
expect(shouldShowComponent).toHaveBeenCalledWith(UIComponent.RoomOptionsMenu);
expect(screen.queryByRole("button", { name: "Room options" })).not.toBeInTheDocument();
});
it("renders the room options context menu when UIComponent customisations enable room options", () => {
mocked(shouldShowComponent).mockReturnValue(true);
renderRoomTile();
expect(shouldShowComponent).toHaveBeenCalledWith(UIComponent.RoomOptionsMenu);
expect(screen.queryByRole("button", { name: "Room options" })).toBeInTheDocument();
});
it("does not render the room options context menu when knocked to the room", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((name) => {
return name === "feature_ask_to_join";
});
mocked(shouldShowComponent).mockReturnValue(true);
jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Knock);
const { container } = renderRoomTile();
expect(container.querySelector(".mx_RoomTile_sticky")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Room options" })).not.toBeInTheDocument();
});
it("does not render the room options context menu when knock has been denied", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((name) => {
return name === "feature_ask_to_join";
});
mocked(shouldShowComponent).mockReturnValue(true);
const roomMember = mkRoomMember(
room.roomId,
MatrixClientPeg.get()!.getSafeUserId(),
KnownMembership.Leave,
true,
{
membership: KnownMembership.Knock,
},
);
jest.spyOn(room, "getMember").mockReturnValue(roomMember);
const { container } = renderRoomTile();
expect(container.querySelector(".mx_RoomTile_sticky")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Room options" })).not.toBeInTheDocument();
});
describe("when a call starts", () => {
let call: MockedCall;
let widget: Widget;
beforeEach(() => {
setupAsyncStoreWithClient(CallStore.instance, client);
setupAsyncStoreWithClient(WidgetMessagingStore.instance, client);
MockedCall.create(room, "1");
const maybeCall = CallStore.instance.getCall(room.roomId);
if (!(maybeCall instanceof MockedCall)) throw new Error("Failed to create call");
call = maybeCall;
widget = new Widget(call.widget);
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, {
stop: () => {},
} as unknown as WidgetMessaging);
});
afterEach(() => {
call.destroy();
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
});
it("tracks connection state", async () => {
renderRoomTile();
screen.getByText("Video");
act(() => call.setConnectionState(ConnectionState.Connected));
screen.getByText("Joined");
await act(() => call.disconnect());
screen.getByText("Video");
});
it("tracks participants", () => {
renderRoomTile();
const alice: [RoomMember, Set<string>] = [
mkRoomMember(room.roomId, "@alice:example.org"),
new Set(["a"]),
];
const bob: [RoomMember, Set<string>] = [
mkRoomMember(room.roomId, "@bob:example.org"),
new Set(["b1", "b2"]),
];
const carol: [RoomMember, Set<string>] = [
mkRoomMember(room.roomId, "@carol:example.org"),
new Set(["c"]),
];
expect(screen.queryByLabelText(/participant/)).toBe(null);
act(() => {
call.participants = new Map([alice]);
});
expect(screen.getByLabelText("1 person joined").textContent).toBe("1");
act(() => {
call.participants = new Map([alice, bob, carol]);
});
expect(screen.getByLabelText("4 people joined").textContent).toBe("4");
act(() => {
call.participants = new Map();
});
expect(screen.queryByLabelText(/participant/)).toBe(null);
});
});
});
describe("when message previews are enabled", () => {
beforeEach(() => {
showMessagePreview = true;
});
it("should render a room without a message as expected", async () => {
const renderResult = renderRoomTile();
// flush promises here because the preview is created asynchronously
await flushPromises();
expect(renderResult.asFragment()).toMatchSnapshot();
});
describe("and there is a message in the room", () => {
beforeEach(() => {
addMessageToRoom(23);
});
it("should render as expected", async () => {
const renderResult = renderRoomTile();
expect(await screen.findByText("test message")).toBeInTheDocument();
expect(renderResult.asFragment()).toMatchSnapshot();
});
});
describe("and there is a message in a thread", () => {
beforeEach(() => {
addThreadMessageToRoom(23);
});
it("should render as expected", async () => {
const renderResult = renderRoomTile();
expect(await screen.findByText("test thread reply")).toBeInTheDocument();
expect(renderResult.asFragment()).toMatchSnapshot();
});
});
describe("and there is a message and a thread without a reply", () => {
beforeEach(() => {
addMessageToRoom(23);
// Mock thread reply for tests.
jest.spyOn(room, "getThreads").mockReturnValue([
// @ts-ignore
{
lastReply: () => null,
timeline: [],
findEventById: () => {},
} as Thread,
]);
});
it("should render the message preview", async () => {
renderRoomTile();
expect(await screen.findByText("test message")).toBeInTheDocument();
});
});
});
});
@@ -1,39 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`ExtraTile renders 1`] = `
<DocumentFragment>
<div
aria-label="test"
class="mx_AccessibleButton mx_ExtraTile mx_RoomTile"
role="treeitem"
tabindex="-1"
>
<div
class="mx_RoomTile_avatarContainer"
/>
<div
class="mx_RoomTile_details"
>
<div
class="mx_RoomTile_primaryDetails"
>
<div
class="mx_RoomTile_titleContainer"
>
<div
class="mx_RoomTile_title"
dir="auto"
tabindex="-1"
title="test"
>
test
</div>
</div>
<div
class="mx_RoomTile_badgeContainer"
/>
</div>
</div>
</div>
</DocumentFragment>
`;
@@ -1,378 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`RoomTile when message previews are enabled and there is a message in a thread should render as expected 1`] = `
<DocumentFragment>
<div
aria-describedby="mx_RoomTile_messagePreview_!1:example.org"
aria-label="!1:example.org"
aria-selected="false"
class="mx_AccessibleButton mx_RoomTile"
role="treeitem"
tabindex="-1"
>
<div
class="mx_DecoratedRoomAvatar"
>
<span
class="_avatar_va14e_8 mx_BaseAvatar _avatar-imageless_va14e_55"
data-color="3"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 32px;"
>
!
</span>
</div>
<div
class="mx_RoomTile_titleContainer"
>
<div
class="mx_RoomTile_title mx_RoomTile_titleWithSubtitle"
tabindex="-1"
title="!1:example.org"
>
<span
dir="auto"
>
!1:example.org
</span>
</div>
<div
class="mx_RoomTile_subtitle"
id="mx_RoomTile_messagePreview_!1:example.org"
title="test thread reply"
>
<span
class="mx_RoomTile_subtitle_text"
>
test thread reply
</span>
</div>
</div>
<div
aria-hidden="true"
class="mx_RoomTile_badgeContainer"
/>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Room options"
class="mx_AccessibleButton mx_RoomTile_menuButton"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Notification options"
class="mx_AccessibleButton mx_RoomTile_notificationsButton"
role="button"
tabindex="-1"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M20.293 17.293c.63.63.184 1.707-.707 1.707H4.414c-.89 0-1.337-1.077-.707-1.707L5 16v-6s0-7 7-7 7 7 7 7v6zM12 22a2 2 0 0 1-2-2h4a2 2 0 0 1-2 2"
/>
</svg>
</div>
</div>
</DocumentFragment>
`;
exports[`RoomTile when message previews are enabled and there is a message in the room should render as expected 1`] = `
<DocumentFragment>
<div
aria-describedby="mx_RoomTile_messagePreview_!1:example.org"
aria-label="!1:example.org Unread messages."
aria-selected="false"
class="mx_AccessibleButton mx_RoomTile"
role="treeitem"
tabindex="-1"
>
<div
class="mx_DecoratedRoomAvatar"
>
<span
class="_avatar_va14e_8 mx_BaseAvatar _avatar-imageless_va14e_55"
data-color="3"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 32px;"
>
!
</span>
</div>
<div
class="mx_RoomTile_titleContainer"
>
<div
class="mx_RoomTile_title mx_RoomTile_titleWithSubtitle mx_RoomTile_titleHasUnreadEvents"
tabindex="-1"
title="!1:example.org"
>
<span
dir="auto"
>
!1:example.org
</span>
</div>
<div
class="mx_RoomTile_subtitle"
id="mx_RoomTile_messagePreview_!1:example.org"
title="test message"
>
<span
class="mx_RoomTile_subtitle_text"
>
test message
</span>
</div>
</div>
<div
aria-hidden="true"
class="mx_RoomTile_badgeContainer"
>
<div
class="mx_NotificationBadge mx_NotificationBadge_visible mx_NotificationBadge_dot"
>
<span
class="mx_NotificationBadge_count"
/>
</div>
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Room options"
class="mx_AccessibleButton mx_RoomTile_menuButton"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Notification options"
class="mx_AccessibleButton mx_RoomTile_notificationsButton"
role="button"
tabindex="-1"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M20.293 17.293c.63.63.184 1.707-.707 1.707H4.414c-.89 0-1.337-1.077-.707-1.707L5 16v-6s0-7 7-7 7 7 7 7v6zM12 22a2 2 0 0 1-2-2h4a2 2 0 0 1-2 2"
/>
</svg>
</div>
</div>
</DocumentFragment>
`;
exports[`RoomTile when message previews are enabled should render a room without a message as expected 1`] = `
<DocumentFragment>
<div
aria-describedby="mx_RoomTile_messagePreview_!1:example.org"
aria-label="!1:example.org"
aria-selected="false"
class="mx_AccessibleButton mx_RoomTile"
role="treeitem"
tabindex="-1"
>
<div
class="mx_DecoratedRoomAvatar"
>
<span
class="_avatar_va14e_8 mx_BaseAvatar _avatar-imageless_va14e_55"
data-color="3"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 32px;"
>
!
</span>
</div>
<div
class="mx_RoomTile_titleContainer"
>
<div
class="mx_RoomTile_title"
tabindex="-1"
title="!1:example.org"
>
<span
dir="auto"
>
!1:example.org
</span>
</div>
</div>
<div
aria-hidden="true"
class="mx_RoomTile_badgeContainer"
/>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Room options"
class="mx_AccessibleButton mx_RoomTile_menuButton"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Notification options"
class="mx_AccessibleButton mx_RoomTile_notificationsButton"
role="button"
tabindex="-1"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M20.293 17.293c.63.63.184 1.707-.707 1.707H4.414c-.89 0-1.337-1.077-.707-1.707L5 16v-6s0-7 7-7 7 7 7 7v6zM12 22a2 2 0 0 1-2-2h4a2 2 0 0 1-2 2"
/>
</svg>
</div>
</div>
</DocumentFragment>
`;
exports[`RoomTile when message previews are not enabled should render the room 1`] = `
<div>
<div
aria-label="!1:example.org"
aria-selected="false"
class="mx_AccessibleButton mx_RoomTile"
role="treeitem"
tabindex="-1"
>
<div
class="mx_DecoratedRoomAvatar"
>
<span
class="_avatar_va14e_8 mx_BaseAvatar _avatar-imageless_va14e_55"
data-color="3"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 32px;"
>
!
</span>
</div>
<div
class="mx_RoomTile_titleContainer"
>
<div
class="mx_RoomTile_title"
tabindex="-1"
title="!1:example.org"
>
<span
dir="auto"
>
!1:example.org
</span>
</div>
</div>
<div
aria-hidden="true"
class="mx_RoomTile_badgeContainer"
/>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Room options"
class="mx_AccessibleButton mx_RoomTile_menuButton"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Notification options"
class="mx_AccessibleButton mx_RoomTile_notificationsButton"
role="button"
tabindex="-1"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M20.293 17.293c.63.63.184 1.707-.707 1.707H4.414c-.89 0-1.337-1.077-.707-1.707L5 16v-6s0-7 7-7 7 7 7 7v6zM12 22a2 2 0 0 1-2-2h4a2 2 0 0 1-2 2"
/>
</svg>
</div>
</div>
</div>
`;
@@ -4,7 +4,7 @@ exports[`<SpacePanel /> should show all activated MetaSpaces in the correct orde
<DocumentFragment>
<nav
aria-label="Spaces"
class="mx_SpacePanel collapsed newUi"
class="mx_SpacePanel collapsed"
>
<div
class="_wrapper_1yyfq_8 mx_UserMenu"
@@ -14,7 +14,6 @@ import RoomListStoreV3, {
} from "../../../src/stores/room-list-v3/RoomListStoreV3";
import { mkRoom, stubClient } from "../../test-utils/test-utils";
import { Room } from "../../../src/modules/models/Room";
import {} from "../../../src/stores/room-list/algorithms/Algorithm";
describe("StoresApi", () => {
describe("RoomListStoreApi", () => {
@@ -27,41 +27,6 @@ describe("BreadcrumbsStore", () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
});
it("does not meet room requirements if there are not enough rooms", () => {
// We don't have enough rooms, so we don't meet requirements
mocked(client.getVisibleRooms).mockReturnValue(fakeRooms(2));
expect(store.meetsRoomRequirement).toBe(false);
});
it("meets room requirements if there are enough rooms", () => {
// We do have enough rooms to show breadcrumbs
mocked(client.getVisibleRooms).mockReturnValue(fakeRooms(25));
expect(store.meetsRoomRequirement).toBe(true);
});
describe("And the feature_dynamic_room_predecessors is enabled", () => {
beforeEach(() => {
// Turn on feature_dynamic_room_predecessors setting
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(settingName) => settingName === "feature_dynamic_room_predecessors",
);
});
it("passes through the dynamic room precessors flag", () => {
mocked(client.getVisibleRooms).mockReturnValue(fakeRooms(25));
expect(store.meetsRoomRequirement).toBeTruthy();
expect(client.getVisibleRooms).toHaveBeenCalledWith(true);
});
});
describe("And the feature_dynamic_room_predecessors is not enabled", () => {
it("passes through the dynamic room precessors flag", () => {
mocked(client.getVisibleRooms).mockReturnValue(fakeRooms(25));
expect(store.meetsRoomRequirement).toBeTruthy();
expect(client.getVisibleRooms).toHaveBeenCalledWith(false);
});
});
describe("If the feature_dynamic_room_predecessors is not enabled", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
@@ -155,17 +120,6 @@ describe("BreadcrumbsStore", () => {
await flushPromises();
}
/**
* Create as many fake rooms in an array as you ask for.
*/
function fakeRooms(howMany: number): Room[] {
const ret: Room[] = [];
for (let i = 0; i < howMany; i++) {
ret.push(fakeRoom());
}
return ret;
}
let roomIdx = 0;
function fakeRoom(): Room {
@@ -37,7 +37,7 @@ import SettingsStore from "../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../src/settings/SettingLevel";
import { Action } from "../../../src/dispatcher/actions";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import RoomListStore from "../../../src/stores/room-list/RoomListStore";
import RoomListStoreV3 from "../../../src/stores/room-list-v3/RoomListStoreV3";
import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag";
import { RoomNotificationStateStore } from "../../../src/stores/notifications/RoomNotificationStateStore";
import { NotificationLevel } from "../../../src/stores/notifications/NotificationLevel";
@@ -141,8 +141,6 @@ describe("SpaceStore", () => {
});
afterEach(async () => {
// Disable the new room list feature flag
await SettingsStore.setValue("feature_new_room_list", null, SettingLevel.DEVICE, false);
await testUtils.resetAsyncStoreWithClient(store);
});
@@ -444,12 +442,6 @@ describe("SpaceStore", () => {
expect(store.isRoomInSpace(MetaSpace.Home, room1)).toBeTruthy();
});
it("favourites space does contain favourites even if they are also shown in a space", async () => {
expect(store.isRoomInSpace(MetaSpace.Favourites, fav1)).toBeTruthy();
expect(store.isRoomInSpace(MetaSpace.Favourites, fav2)).toBeTruthy();
expect(store.isRoomInSpace(MetaSpace.Favourites, fav3)).toBeTruthy();
});
it("people space does contain people even if they are also shown in a space", async () => {
expect(store.isRoomInSpace(MetaSpace.People, dm1)).toBeTruthy();
expect(store.isRoomInSpace(MetaSpace.People, dm2)).toBeTruthy();
@@ -573,27 +565,6 @@ describe("SpaceStore", () => {
});
});
it("dms are only added to Notification States for only the People Space", async () => {
[dm1, dm2, dm3].forEach((d) => {
expect(
store
.getNotificationState(MetaSpace.People)
.rooms.map((r) => r.roomId)
.includes(d),
).toBeTruthy();
});
[space1, space2, space3, MetaSpace.Home, MetaSpace.Orphans, MetaSpace.Favourites].forEach((s) => {
[dm1, dm2, dm3].forEach((d) => {
expect(
store
.getNotificationState(s)
.rooms.map((r) => r.roomId)
.includes(d),
).toBeFalsy();
});
});
});
it("orphan rooms are added to Notification States for only the Home Space", async () => {
await setShowAllRooms(false);
[orphan1, orphan2].forEach((d) => {
@@ -710,29 +681,6 @@ describe("SpaceStore", () => {
});
});
it("should add new DM Invites to the People Space Notification State", async () => {
mkRoom(dm1);
mocked(client.getRoom(dm1)!).getMyMembership.mockReturnValue(KnownMembership.Join);
mocked(client).getRoom.mockImplementation((roomId) => rooms.find((room) => room.roomId === roomId) || null);
await run();
mkRoom(dm2);
const cliDm2 = client.getRoom(dm2)!;
mocked(cliDm2).getMyMembership.mockReturnValue(KnownMembership.Invite);
mocked(client).getRoom.mockImplementation((roomId) => rooms.find((room) => room.roomId === roomId) || null);
client.emit(RoomEvent.MyMembership, cliDm2, KnownMembership.Invite);
[dm1, dm2].forEach((d) => {
expect(
store
.getNotificationState(MetaSpace.People)
.rooms.map((r) => r.roomId)
.includes(d),
).toBeTruthy();
});
});
describe("hierarchy resolution update tests", () => {
it("updates state when spaces are joined", async () => {
await run();
@@ -1199,16 +1147,15 @@ describe("SpaceStore", () => {
});
it("switch to first valid space when selected metaspace is disabled", async () => {
store.setActiveSpace(MetaSpace.People, false);
expect(store.activeSpace).toBe(MetaSpace.People);
viewRoom(room1);
store.setActiveSpace(MetaSpace.Orphans, false);
expect(store.activeSpace).toBe(MetaSpace.Orphans);
await SettingsStore.setValue("Spaces.enabledMetaSpaces", null, SettingLevel.DEVICE, {
[MetaSpace.Home]: false,
[MetaSpace.Favourites]: true,
[MetaSpace.People]: false,
[MetaSpace.Orphans]: true,
[MetaSpace.Home]: true,
[MetaSpace.Orphans]: false,
});
jest.runAllTimers();
expect(store.activeSpace).toBe(MetaSpace.Orphans);
expect(store.activeSpace).toBe(space1);
});
it("when switching rooms in the all rooms home space don't switch to related space", async () => {
@@ -1402,12 +1349,9 @@ describe("SpaceStore", () => {
removeListener();
});
it("Favourites and People meta spaces should not be returned when the feature_new_room_list labs flag is enabled", async () => {
// Enable the new room list
await SettingsStore.setValue("feature_new_room_list", null, SettingLevel.DEVICE, true);
it("Favourites and People meta spaces should not be returned", async () => {
await run();
// Favourites and People meta spaces should not be returned
// Favourites and People meta spaces are not part of the meta space order
expect(SpaceStore.instance.enabledMetaSpaces).toStrictEqual([MetaSpace.Home, MetaSpace.Orphans]);
});
@@ -1515,8 +1459,9 @@ describe("SpaceStore", () => {
const state = RoomNotificationStateStore.instance.getRoomState(room);
// @ts-ignore
state._level = NotificationLevel.Notification;
jest.spyOn(RoomListStore.instance, "orderedLists", "get").mockReturnValue({
[DefaultTagID.Untagged]: [room],
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: MetaSpace.Home,
sections: [{ tag: DefaultTagID.Untagged, rooms: [room] }],
});
// init the store
@@ -1,349 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import {
ConditionKind,
EventType,
type IPushRule,
MatrixEvent,
PendingEventOrdering,
PushRuleActionName,
Room,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { mocked } from "jest-mock";
import defaultDispatcher, { type MatrixDispatcher } from "../../../../src/dispatcher/dispatcher";
import { SettingLevel } from "../../../../src/settings/SettingLevel";
import SettingsStore, { type CallbackFn } from "../../../../src/settings/SettingsStore";
import { ListAlgorithm, SortAlgorithm } from "../../../../src/stores/room-list/algorithms/models";
import { OrderedDefaultTagIDs, RoomUpdateCause } from "../../../../src/stores/room-list/models";
import RoomListStore, { RoomListStoreClass } from "../../../../src/stores/room-list/RoomListStore";
import DMRoomMap from "../../../../src/utils/DMRoomMap";
import { flushPromises, stubClient, upsertRoomStateEvents } from "../../../test-utils";
import { DEFAULT_PUSH_RULES, makePushRule } from "../../../test-utils/pushRules";
describe("RoomListStore", () => {
const client = stubClient();
const newRoomId = "!roomid:example.com";
const roomNoPredecessorId = "!roomnopreid:example.com";
const oldRoomId = "!oldroomid:example.com";
const userId = "@user:example.com";
const createWithPredecessor = new MatrixEvent({
type: EventType.RoomCreate,
sender: userId,
room_id: newRoomId,
content: {
predecessor: { room_id: oldRoomId, event_id: "tombstone_event_id" },
},
event_id: "$create",
state_key: "",
});
const createNoPredecessor = new MatrixEvent({
type: EventType.RoomCreate,
sender: userId,
room_id: newRoomId,
content: {},
event_id: "$create",
state_key: "",
});
const predecessor = new MatrixEvent({
type: EventType.RoomPredecessor,
sender: userId,
room_id: newRoomId,
content: {
predecessor_room_id: oldRoomId,
last_known_event_id: "tombstone_event_id",
},
event_id: "$pred",
state_key: "",
});
const roomWithPredecessorEvent = new Room(newRoomId, client, userId, {});
upsertRoomStateEvents(roomWithPredecessorEvent, [predecessor]);
const roomWithCreatePredecessor = new Room(newRoomId, client, userId, {});
upsertRoomStateEvents(roomWithCreatePredecessor, [createWithPredecessor]);
const roomNoPredecessor = new Room(roomNoPredecessorId, client, userId, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
upsertRoomStateEvents(roomNoPredecessor, [createNoPredecessor]);
const oldRoom = new Room(oldRoomId, client, userId, {});
const normalRoom = new Room("!normal:server.org", client, userId);
client.getRoom = jest.fn().mockImplementation((roomId) => {
switch (roomId) {
case newRoomId:
return roomWithCreatePredecessor;
case oldRoomId:
return oldRoom;
case normalRoom.roomId:
return normalRoom;
default:
return null;
}
});
beforeAll(async () => {
await (RoomListStore.instance as RoomListStoreClass).makeReady(client);
});
it.each(OrderedDefaultTagIDs)("defaults to importance ordering for %s=", (tagId) => {
expect(RoomListStore.instance.getTagSorting(tagId)).toBe(SortAlgorithm.Recent);
});
it.each(OrderedDefaultTagIDs)("defaults to activity ordering for %s=", (tagId) => {
expect(RoomListStore.instance.getListOrder(tagId)).toBe(ListAlgorithm.Natural);
});
function createStore(): { store: RoomListStoreClass; handleRoomUpdate: jest.Mock<any, any> } {
const fakeDispatcher = { register: jest.fn() } as unknown as MatrixDispatcher;
const store = new RoomListStoreClass(fakeDispatcher);
// @ts-ignore accessing private member to set client
store.readyStore.matrixClient = client;
const handleRoomUpdate = jest.fn();
// @ts-ignore accessing private member to mock it
store.algorithm.handleRoomUpdate = handleRoomUpdate;
return { store, handleRoomUpdate };
}
it("Removes old room if it finds a predecessor in the create event", () => {
// Given a store we can spy on
const { store, handleRoomUpdate } = createStore();
mocked(client.getRoomUpgradeHistory).mockImplementation((roomId) =>
roomId === roomWithCreatePredecessor.roomId ? [oldRoom, roomWithCreatePredecessor] : [],
);
// When we tell it we joined a new room that has an old room as
// predecessor in the create event
const payload = {
oldMembership: KnownMembership.Invite,
membership: KnownMembership.Join,
room: roomWithCreatePredecessor,
};
store.onDispatchMyMembership(payload);
// Then the old room is removed
expect(handleRoomUpdate).toHaveBeenCalledWith(oldRoom, RoomUpdateCause.RoomRemoved);
// And the new room is added
expect(handleRoomUpdate).toHaveBeenCalledWith(roomWithCreatePredecessor, RoomUpdateCause.NewRoom);
});
it("Does not remove old room if there is no predecessor in the create event", () => {
// Given a store we can spy on
const { store, handleRoomUpdate } = createStore();
// When we tell it we joined a new room with no predecessor
const payload = {
oldMembership: KnownMembership.Invite,
membership: KnownMembership.Join,
room: roomNoPredecessor,
};
store.onDispatchMyMembership(payload);
// Then the new room is added
expect(handleRoomUpdate).toHaveBeenCalledWith(roomNoPredecessor, RoomUpdateCause.NewRoom);
// And no other updates happen
expect(handleRoomUpdate).toHaveBeenCalledTimes(1);
});
it("Lists all rooms that the client says are visible", () => {
// Given 3 rooms that are visible according to the client
const room1 = new Room("!r1:e.com", client, userId, { pendingEventOrdering: PendingEventOrdering.Detached });
const room2 = new Room("!r2:e.com", client, userId, { pendingEventOrdering: PendingEventOrdering.Detached });
const room3 = new Room("!r3:e.com", client, userId, { pendingEventOrdering: PendingEventOrdering.Detached });
room1.updateMyMembership(KnownMembership.Join);
room2.updateMyMembership(KnownMembership.Join);
room3.updateMyMembership(KnownMembership.Join);
DMRoomMap.makeShared(client);
const { store } = createStore();
client.getVisibleRooms = jest.fn().mockReturnValue([room1, room2, room3]);
// When we make the list of rooms
store.regenerateAllLists({ trigger: false });
// Then the list contains all 3
expect(store.orderedLists).toMatchObject({
"im.vector.fake.recent": [room1, room2, room3],
});
// We asked not to use MSC3946 when we asked the client for the visible rooms
expect(client.getVisibleRooms).toHaveBeenCalledWith(false);
expect(client.getVisibleRooms).toHaveBeenCalledTimes(1);
});
it("Watches the feature flag setting", () => {
jest.spyOn(SettingsStore, "watchSetting").mockReturnValue("dyn_pred_ref");
jest.spyOn(SettingsStore, "unwatchSetting");
// When we create a store
const { store } = createStore();
// Then we watch the feature flag
expect(SettingsStore.watchSetting).toHaveBeenCalledWith(
"feature_dynamic_room_predecessors",
null,
expect.any(Function),
);
// And when we unmount it
store.componentWillUnmount();
// Then we unwatch it.
expect(SettingsStore.unwatchSetting).toHaveBeenCalledWith("dyn_pred_ref");
});
it("Regenerates all lists when the feature flag is set", () => {
// Given a store allowing us to spy on any use of SettingsStore
let featureFlagValue = false;
jest.spyOn(SettingsStore, "getValue").mockImplementation(() => featureFlagValue);
let watchCallback: CallbackFn<any> | undefined;
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((_settingName, _roomId, callbackFn) => {
watchCallback = callbackFn;
return "dyn_pred_ref";
});
jest.spyOn(SettingsStore, "unwatchSetting");
const { store } = createStore();
client.getVisibleRooms = jest.fn().mockReturnValue([]);
// Sanity: no calculation has happened yet
expect(client.getVisibleRooms).toHaveBeenCalledTimes(0);
// When we calculate for the first time
store.regenerateAllLists({ trigger: false });
// Then we use the current feature flag value (false)
expect(client.getVisibleRooms).toHaveBeenCalledWith(false);
expect(client.getVisibleRooms).toHaveBeenCalledTimes(1);
// But when we update the feature flag
featureFlagValue = true;
watchCallback!(
"feature_dynamic_room_predecessors",
"",
SettingLevel.DEFAULT,
featureFlagValue,
featureFlagValue,
);
// Then we recalculate and passed the updated value (true)
expect(client.getVisibleRooms).toHaveBeenCalledWith(true);
expect(client.getVisibleRooms).toHaveBeenCalledTimes(2);
});
describe("When feature_dynamic_room_predecessors = true", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(settingName) => settingName === "feature_dynamic_room_predecessors",
);
});
afterEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReset();
});
it("Removes old room if it finds a predecessor in the m.predecessor event", () => {
// Given a store we can spy on
const { store, handleRoomUpdate } = createStore();
// When we tell it we joined a new room that has an old room as
// predecessor in the create event
const payload = {
oldMembership: KnownMembership.Invite,
membership: KnownMembership.Join,
room: roomWithPredecessorEvent,
};
store.onDispatchMyMembership(payload);
// Then the old room is removed
expect(handleRoomUpdate).toHaveBeenCalledWith(oldRoom, RoomUpdateCause.RoomRemoved);
// And the new room is added
expect(handleRoomUpdate).toHaveBeenCalledWith(roomWithPredecessorEvent, RoomUpdateCause.NewRoom);
});
it("Passes the feature flag on to the client when asking for visible rooms", () => {
// Given a store that we can ask for a room list
DMRoomMap.makeShared(client);
const { store } = createStore();
client.getVisibleRooms = jest.fn().mockReturnValue([]);
// When we make the list of rooms
store.regenerateAllLists({ trigger: false });
// We asked to use MSC3946 when we asked the client for the visible rooms
expect(client.getVisibleRooms).toHaveBeenCalledWith(true);
expect(client.getVisibleRooms).toHaveBeenCalledTimes(1);
});
});
describe("room updates", () => {
const makeStore = async () => {
const store = new RoomListStoreClass(defaultDispatcher);
await store.start();
return store;
};
describe("push rules updates", () => {
const makePushRulesEvent = (overrideRules: IPushRule[] = []): MatrixEvent => {
return new MatrixEvent({
type: EventType.PushRules,
content: {
global: {
...DEFAULT_PUSH_RULES.global,
override: overrideRules,
},
},
});
};
it("triggers a room update when room mutes have changed", async () => {
const rule = makePushRule(normalRoom.roomId, {
actions: [PushRuleActionName.DontNotify],
conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: normalRoom.roomId }],
});
const event = makePushRulesEvent([rule]);
const previousEvent = makePushRulesEvent();
const store = await makeStore();
// @ts-ignore private property alg
const algorithmSpy = jest.spyOn(store.algorithm, "handleRoomUpdate").mockReturnValue(undefined);
// @ts-ignore cheat and call protected fn
store.onAction({ action: "MatrixActions.accountData", event, previousEvent });
await flushPromises();
expect(algorithmSpy).toHaveBeenCalledWith(normalRoom, RoomUpdateCause.PossibleMuteChange);
});
it("handles when a muted room is unknown by the room list", async () => {
const rule = makePushRule(normalRoom.roomId, {
actions: [PushRuleActionName.DontNotify],
conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: normalRoom.roomId }],
});
const unknownRoomRule = makePushRule("!unknown:server.org", {
conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: "!unknown:server.org" }],
});
const event = makePushRulesEvent([unknownRoomRule, rule]);
const previousEvent = makePushRulesEvent();
const store = await makeStore();
// @ts-ignore private property alg
const algorithmSpy = jest.spyOn(store.algorithm, "handleRoomUpdate").mockReturnValue(undefined);
// @ts-ignore cheat and call protected fn
store.onAction({ action: "MatrixActions.accountData", event, previousEvent });
await flushPromises();
// only one call to update made for normalRoom
expect(algorithmSpy).toHaveBeenCalledTimes(1);
expect(algorithmSpy).toHaveBeenCalledWith(normalRoom, RoomUpdateCause.PossibleMuteChange);
});
});
});
});
@@ -1,218 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { type Room } from "matrix-js-sdk/src/matrix";
import { SpaceWatcher } from "../../../../src/stores/room-list/SpaceWatcher";
import type { RoomListStoreClass } from "../../../../src/stores/room-list/RoomListStore";
import SettingsStore from "../../../../src/settings/SettingsStore";
import SpaceStore from "../../../../src/stores/spaces/SpaceStore";
import { MetaSpace, UPDATE_HOME_BEHAVIOUR } from "../../../../src/stores/spaces";
import { stubClient, mkSpace, emitPromise, setupAsyncStoreWithClient } from "../../../test-utils";
import { SettingLevel } from "../../../../src/settings/SettingLevel";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import { SpaceFilterCondition } from "../../../../src/stores/room-list/filters/SpaceFilterCondition";
import DMRoomMap from "../../../../src/utils/DMRoomMap";
let filter: SpaceFilterCondition | null = null;
const mockRoomListStore = {
addFilter: (f: SpaceFilterCondition) => (filter = f),
removeFilter: (): void => {
filter = null;
},
} as unknown as RoomListStoreClass;
const getUserIdForRoomId = jest.fn();
const getDMRoomsForUserId = jest.fn();
// @ts-ignore
DMRoomMap.sharedInstance = { getUserIdForRoomId, getDMRoomsForUserId };
const space1 = "!space1:server";
const space2 = "!space2:server";
describe("SpaceWatcher", () => {
stubClient();
const store = SpaceStore.instance;
const client = mocked(MatrixClientPeg.safeGet());
let rooms: Room[] = [];
const mkSpaceForRooms = (spaceId: string, children: string[] = []) => mkSpace(client, spaceId, rooms, children);
const setShowAllRooms = async (value: boolean) => {
if (store.allRoomsInHome === value) return;
await SettingsStore.setValue("Spaces.allRoomsInHome", null, SettingLevel.DEVICE, value);
await emitPromise(store, UPDATE_HOME_BEHAVIOUR);
};
beforeEach(async () => {
filter = null;
store.removeAllListeners();
store.setActiveSpace(MetaSpace.Home);
client.getVisibleRooms.mockReturnValue((rooms = []));
mkSpaceForRooms(space1);
mkSpaceForRooms(space2);
await SettingsStore.setValue("Spaces.enabledMetaSpaces", null, SettingLevel.DEVICE, {
[MetaSpace.Home]: true,
[MetaSpace.Favourites]: true,
[MetaSpace.People]: true,
[MetaSpace.Orphans]: true,
});
client.getRoom.mockImplementation((roomId) => rooms.find((room) => room.roomId === roomId) || null);
await setupAsyncStoreWithClient(store, client);
});
it("initialises sanely with home behaviour", async () => {
await setShowAllRooms(false);
new SpaceWatcher(mockRoomListStore);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
});
it("initialises sanely with all behaviour", async () => {
await setShowAllRooms(true);
new SpaceWatcher(mockRoomListStore);
expect(filter).toBeNull();
});
it("sets space=Home filter for all -> home transition", async () => {
await setShowAllRooms(true);
new SpaceWatcher(mockRoomListStore);
await setShowAllRooms(false);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter!["space"]).toBe(MetaSpace.Home);
});
it("sets filter correctly for all -> space transition", async () => {
await setShowAllRooms(true);
new SpaceWatcher(mockRoomListStore);
SpaceStore.instance.setActiveSpace(space1);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter!["space"]).toBe(space1);
});
it("removes filter for home -> all transition", async () => {
await setShowAllRooms(false);
new SpaceWatcher(mockRoomListStore);
await setShowAllRooms(true);
expect(filter).toBeNull();
});
it("sets filter correctly for home -> space transition", async () => {
await setShowAllRooms(false);
new SpaceWatcher(mockRoomListStore);
SpaceStore.instance.setActiveSpace(space1);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter!["space"]).toBe(space1);
});
it("removes filter for space -> all transition", async () => {
await setShowAllRooms(true);
new SpaceWatcher(mockRoomListStore);
SpaceStore.instance.setActiveSpace(space1);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter!["space"]).toBe(space1);
SpaceStore.instance.setActiveSpace(MetaSpace.Home);
expect(filter).toBeNull();
});
// The new room list is now forced on, which removes the favourites and people meta spaces.
// So no need to test these transitions any more.
it("removes filter for orphans -> all transition", async () => {
await setShowAllRooms(true);
new SpaceWatcher(mockRoomListStore);
SpaceStore.instance.setActiveSpace(MetaSpace.Orphans);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter!["space"]).toBe(MetaSpace.Orphans);
SpaceStore.instance.setActiveSpace(MetaSpace.Home);
expect(filter).toBeNull();
});
it("updates filter correctly for space -> home transition", async () => {
await setShowAllRooms(false);
SpaceStore.instance.setActiveSpace(space1);
new SpaceWatcher(mockRoomListStore);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter!["space"]).toBe(space1);
SpaceStore.instance.setActiveSpace(MetaSpace.Home);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter!["space"]).toBe(MetaSpace.Home);
});
it("updates filter correctly for space -> orphans transition", async () => {
await setShowAllRooms(false);
SpaceStore.instance.setActiveSpace(space1);
new SpaceWatcher(mockRoomListStore);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter!["space"]).toBe(space1);
SpaceStore.instance.setActiveSpace(MetaSpace.Orphans);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter!["space"]).toBe(MetaSpace.Orphans);
});
it("updates filter correctly for space -> space transition", async () => {
await setShowAllRooms(false);
SpaceStore.instance.setActiveSpace(space1);
new SpaceWatcher(mockRoomListStore);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter!["space"]).toBe(space1);
SpaceStore.instance.setActiveSpace(space2);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter!["space"]).toBe(space2);
});
it("doesn't change filter when changing showAllRooms mode to true", async () => {
await setShowAllRooms(false);
SpaceStore.instance.setActiveSpace(space1);
new SpaceWatcher(mockRoomListStore);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter!["space"]).toBe(space1);
await setShowAllRooms(true);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter!["space"]).toBe(space1);
});
it("doesn't change filter when changing showAllRooms mode to false", async () => {
await setShowAllRooms(true);
SpaceStore.instance.setActiveSpace(space1);
new SpaceWatcher(mockRoomListStore);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter!["space"]).toBe(space1);
await setShowAllRooms(false);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter!["space"]).toBe(space1);
});
});
@@ -1,102 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { mocked, type MockedObject } from "jest-mock";
import { PendingEventOrdering, Room, RoomStateEvent } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { Widget } from "matrix-widget-api";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
import {
stubClient,
setupAsyncStoreWithClient,
useMockedCalls,
MockedCall,
useMockMediaDevices,
} from "../../../../test-utils";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
import { DefaultTagID } from "../../../../../src/stores/room-list-v3/skip-list/tag";
import { SortAlgorithm, ListAlgorithm } from "../../../../../src/stores/room-list/algorithms/models";
import "../../../../../src/stores/room-list/RoomListStore"; // must be imported before Algorithm to avoid cycles
import { Algorithm } from "../../../../../src/stores/room-list/algorithms/Algorithm";
import { CallStore } from "../../../../../src/stores/CallStore";
import { WidgetMessagingStore } from "../../../../../src/stores/widgets/WidgetMessagingStore";
import { ConnectionState } from "../../../../../src/models/Call";
import { type WidgetMessaging } from "../../../../../src/stores/widgets/WidgetMessaging";
describe("Algorithm", () => {
useMockedCalls();
let client: MockedObject<MatrixClient>;
let algorithm: Algorithm;
beforeEach(() => {
useMockMediaDevices();
stubClient();
client = mocked(MatrixClientPeg.safeGet());
DMRoomMap.makeShared(client);
algorithm = new Algorithm();
algorithm.start();
algorithm.populateTags(
{ [DefaultTagID.Untagged]: SortAlgorithm.Alphabetic },
{ [DefaultTagID.Untagged]: ListAlgorithm.Natural },
);
});
afterEach(() => {
algorithm.stop();
});
it("sticks rooms with calls to the top when they're connected", async () => {
const room = new Room("!1:example.org", client, "@alice:example.org", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
const roomWithCall = new Room("!2:example.org", client, "@alice:example.org", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
client.getRoom.mockImplementation((roomId) => {
switch (roomId) {
case room.roomId:
return room;
case roomWithCall.roomId:
return roomWithCall;
default:
return null;
}
});
client.getRooms.mockReturnValue([room, roomWithCall]);
client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
client.reEmitter.reEmit(roomWithCall, [RoomStateEvent.Events]);
for (const room of client.getRooms()) jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Join);
algorithm.setKnownRooms(client.getRooms());
setupAsyncStoreWithClient(CallStore.instance, client);
setupAsyncStoreWithClient(WidgetMessagingStore.instance, client);
MockedCall.create(roomWithCall, "1");
const call = CallStore.instance.getCall(roomWithCall.roomId);
if (!(call instanceof MockedCall)) throw new Error("Failed to create call");
const widget = new Widget(call.widget);
WidgetMessagingStore.instance.storeMessaging(widget, roomWithCall.roomId, {
stop: () => {},
} as unknown as WidgetMessaging);
// End of setup
expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([room, roomWithCall]);
call.setConnectionState(ConnectionState.Connected);
expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([roomWithCall, room]);
await call.disconnect();
expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([room, roomWithCall]);
});
});
@@ -1,177 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { Room, type MatrixClient } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { mkMessage, mkRoom, stubClient } from "../../../../test-utils";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import "../../../../../src/stores/room-list/RoomListStore";
import { RecentAlgorithm } from "../../../../../src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
import { makeThreadEvent, mkThread } from "../../../../test-utils/threads";
import { DefaultTagID } from "../../../../../src/stores/room-list-v3/skip-list/tag";
describe("RecentAlgorithm", () => {
let algorithm: RecentAlgorithm;
let cli: MatrixClient;
beforeEach(() => {
stubClient();
cli = MatrixClientPeg.safeGet();
algorithm = new RecentAlgorithm();
});
describe("getLastTs", () => {
it("returns the last ts", () => {
const room = new Room("room123", cli, "@john:matrix.org");
const event1 = mkMessage({
room: room.roomId,
msg: "Hello world!",
user: "@alice:matrix.org",
ts: 5,
event: true,
});
const event2 = mkMessage({
room: room.roomId,
msg: "Howdy!",
user: "@bob:matrix.org",
ts: 10,
event: true,
});
room.getMyMembership = () => KnownMembership.Join;
room.addLiveEvents([event1], { addToState: true });
expect(algorithm.getLastTs(room, "@jane:matrix.org")).toBe(5);
expect(algorithm.getLastTs(room, "@john:matrix.org")).toBe(5);
room.addLiveEvents([event2], { addToState: true });
expect(algorithm.getLastTs(room, "@jane:matrix.org")).toBe(10);
expect(algorithm.getLastTs(room, "@john:matrix.org")).toBe(10);
});
it("returns a fake ts for rooms without a timeline", () => {
const room = mkRoom(cli, "!new:example.org");
// @ts-ignore
room.timeline = undefined;
expect(algorithm.getLastTs(room, "@john:matrix.org")).toBe(Number.MAX_SAFE_INTEGER);
});
it("works when not a member", () => {
const room = mkRoom(cli, "!new:example.org");
room.getMyMembership.mockReturnValue(KnownMembership.Invite);
expect(algorithm.getLastTs(room, "@john:matrix.org")).toBe(Number.MAX_SAFE_INTEGER);
});
});
describe("sortRooms", () => {
it("orders rooms per last message ts", () => {
const room1 = new Room("room1", cli, "@bob:matrix.org");
const room2 = new Room("room2", cli, "@bob:matrix.org");
room1.getMyMembership = () => KnownMembership.Join;
room2.getMyMembership = () => KnownMembership.Join;
const evt = mkMessage({
room: room1.roomId,
msg: "Hello world!",
user: "@alice:matrix.org",
ts: 5,
event: true,
});
const evt2 = mkMessage({
room: room2.roomId,
msg: "Hello world!",
user: "@alice:matrix.org",
ts: 2,
event: true,
});
room1.addLiveEvents([evt], { addToState: true });
room2.addLiveEvents([evt2], { addToState: true });
expect(algorithm.sortRooms([room2, room1], DefaultTagID.Untagged)).toEqual([room1, room2]);
});
it("orders rooms without messages first", () => {
const room1 = new Room("room1", cli, "@bob:matrix.org");
const room2 = new Room("room2", cli, "@bob:matrix.org");
room1.getMyMembership = () => KnownMembership.Join;
room2.getMyMembership = () => KnownMembership.Join;
const evt = mkMessage({
room: room1.roomId,
msg: "Hello world!",
user: "@alice:matrix.org",
ts: 5,
event: true,
});
room1.addLiveEvents([evt], { addToState: true });
expect(algorithm.sortRooms([room2, room1], DefaultTagID.Untagged)).toEqual([room2, room1]);
const { events } = mkThread({
room: room1,
client: cli,
authorId: "@bob:matrix.org",
participantUserIds: ["@bob:matrix.org"],
ts: 12,
});
room1.addLiveEvents(events, { addToState: true });
});
it("orders rooms based on thread replies too", () => {
const room1 = new Room("room1", cli, "@bob:matrix.org");
const room2 = new Room("room2", cli, "@bob:matrix.org");
room1.getMyMembership = () => KnownMembership.Join;
room2.getMyMembership = () => KnownMembership.Join;
const { rootEvent, events: events1 } = mkThread({
room: room1,
client: cli,
authorId: "@bob:matrix.org",
participantUserIds: ["@bob:matrix.org"],
ts: 12,
length: 5,
});
room1.addLiveEvents(events1, { addToState: true });
const { events: events2 } = mkThread({
room: room2,
client: cli,
authorId: "@bob:matrix.org",
participantUserIds: ["@bob:matrix.org"],
ts: 14,
length: 10,
});
room2.addLiveEvents(events2, { addToState: true });
expect(algorithm.sortRooms([room1, room2], DefaultTagID.Untagged)).toEqual([room2, room1]);
const threadReply = makeThreadEvent({
user: "@bob:matrix.org",
room: room1.roomId,
event: true,
msg: `hello world`,
rootEventId: rootEvent.getId()!,
replyToEventId: rootEvent.getId()!,
// replies are 1ms after each other
ts: 50,
});
room1.addLiveEvents([threadReply], { addToState: true });
expect(algorithm.sortRooms([room1, room2], DefaultTagID.Untagged)).toEqual([room1, room2]);
});
});
});
@@ -1,363 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { ConditionKind, MatrixEvent, PushRuleActionName, Room, RoomEvent } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { RoomNotificationStateStore } from "../../../../../../src/stores/notifications/RoomNotificationStateStore";
import { ImportanceAlgorithm } from "../../../../../../src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm";
import { SortAlgorithm } from "../../../../../../src/stores/room-list/algorithms/models";
import * as RoomNotifs from "../../../../../../src/RoomNotifs";
import { DefaultTagID } from "../../../../../../src/stores/room-list-v3/skip-list/tag";
import { RoomUpdateCause } from "../../../../../../src/stores/room-list/models";
import { NotificationLevel } from "../../../../../../src/stores/notifications/NotificationLevel";
import { AlphabeticAlgorithm } from "../../../../../../src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm";
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../../../test-utils";
import { RecentAlgorithm } from "../../../../../../src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
import { DEFAULT_PUSH_RULES, makePushRule } from "../../../../../test-utils/pushRules";
describe("ImportanceAlgorithm", () => {
const userId = "@alice:server.org";
const tagId = DefaultTagID.Favourite;
const makeRoom = (id: string, name: string, order?: number): Room => {
const room = new Room(id, client, userId);
room.name = name;
const tagEvent = new MatrixEvent({
type: "m.tag",
content: {
tags: {
[tagId]: {
order,
},
},
},
});
room.addTags(tagEvent);
return room;
};
const client = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
});
const roomA = makeRoom("!aaa:server.org", "Alpha", 2);
const roomB = makeRoom("!bbb:server.org", "Bravo", 5);
const roomC = makeRoom("!ccc:server.org", "Charlie", 1);
const roomD = makeRoom("!ddd:server.org", "Delta", 4);
const roomE = makeRoom("!eee:server.org", "Echo", 3);
const roomX = makeRoom("!xxx:server.org", "Xylophone", 99);
const muteRoomARule = makePushRule(roomA.roomId, {
actions: [PushRuleActionName.DontNotify],
conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: roomA.roomId }],
});
const muteRoomBRule = makePushRule(roomB.roomId, {
actions: [PushRuleActionName.DontNotify],
conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: roomB.roomId }],
});
client.pushRules = {
global: {
...DEFAULT_PUSH_RULES.global,
override: [...DEFAULT_PUSH_RULES.global.override!, muteRoomARule, muteRoomBRule],
},
};
const unreadStates: Record<string, ReturnType<(typeof RoomNotifs)["determineUnreadState"]>> = {
red: { symbol: null, count: 1, level: NotificationLevel.Highlight, invited: false },
grey: { symbol: null, count: 1, level: NotificationLevel.Notification, invited: false },
none: { symbol: null, count: 0, level: NotificationLevel.None, invited: false },
};
beforeEach(() => {
jest.spyOn(RoomNotifs, "determineUnreadState").mockReturnValue({
symbol: null,
count: 0,
level: NotificationLevel.None,
invited: false,
});
});
const setupAlgorithm = (sortAlgorithm: SortAlgorithm, rooms?: Room[]) => {
const algorithm = new ImportanceAlgorithm(tagId, sortAlgorithm);
algorithm.setRooms(rooms || [roomA, roomB, roomC]);
return algorithm;
};
describe("When sortAlgorithm is alphabetical", () => {
const sortAlgorithm = SortAlgorithm.Alphabetic;
beforeEach(async () => {
// destroy roomMap so we can start fresh
// @ts-ignore private property
RoomNotificationStateStore.instance.roomMap = new Map<Room, RoomNotificationState>();
jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear();
jest.spyOn(RoomNotifs, "determineUnreadState")
.mockClear()
.mockImplementation((room) => {
switch (room) {
// b and e have red notifs
case roomB:
case roomE:
return unreadStates.red;
// c is grey
case roomC:
return unreadStates.grey;
default:
return unreadStates.none;
}
});
});
it("orders rooms by alpha when they have the same notif state", () => {
jest.spyOn(RoomNotifs, "determineUnreadState").mockReturnValue({
symbol: null,
count: 0,
level: NotificationLevel.None,
invited: false,
});
const algorithm = setupAlgorithm(sortAlgorithm);
// sorted according to alpha
expect(algorithm.orderedRooms).toEqual([roomA, roomB, roomC]);
});
it("orders rooms by notification state then alpha", () => {
const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]);
expect(algorithm.orderedRooms).toEqual([
// alpha within red
roomB,
roomE,
// grey
roomC,
// alpha within none
roomA,
roomD,
]);
});
describe("handleRoomUpdate", () => {
it("removes a room", () => {
const algorithm = setupAlgorithm(sortAlgorithm);
jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear();
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomA, RoomUpdateCause.RoomRemoved);
expect(shouldTriggerUpdate).toBe(true);
expect(algorithm.orderedRooms).toEqual([roomB, roomC]);
// no re-sorting on a remove
expect(AlphabeticAlgorithm.prototype.sortRooms).not.toHaveBeenCalled();
});
it("warns and returns without change when removing a room that is not indexed", () => {
jest.spyOn(logger, "warn").mockReturnValue(undefined);
const algorithm = setupAlgorithm(sortAlgorithm);
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomD, RoomUpdateCause.RoomRemoved);
expect(shouldTriggerUpdate).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(`Tried to remove unknown room from ${tagId}: ${roomD.roomId}`);
});
it("adds a new room", () => {
const algorithm = setupAlgorithm(sortAlgorithm);
jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear();
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.NewRoom);
expect(shouldTriggerUpdate).toBe(true);
// inserted according to notif state
expect(algorithm.orderedRooms).toEqual([roomB, roomE, roomC, roomA]);
// only sorted within category
expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomB, roomE], tagId);
});
it("throws for an unhandled update cause", () => {
const algorithm = setupAlgorithm(sortAlgorithm);
expect(() =>
algorithm.handleRoomUpdate(roomA, "something unexpected" as unknown as RoomUpdateCause),
).toThrow("Unsupported update cause: something unexpected");
});
it("ignores a mute change", () => {
// muted rooms are not pushed to the bottom when sort is alpha
const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]);
jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear();
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.PossibleMuteChange);
expect(shouldTriggerUpdate).toBe(false);
// no sorting
expect(AlphabeticAlgorithm.prototype.sortRooms).not.toHaveBeenCalled();
});
describe("time and read receipt updates", () => {
it("throws for when a room is not indexed", () => {
const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]);
expect(() => algorithm.handleRoomUpdate(roomX, RoomUpdateCause.Timeline)).toThrow(
`Room ${roomX.roomId} has no index in ${tagId}`,
);
});
it("re-sorts category when updated room has not changed category", () => {
const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]);
jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear();
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.Timeline);
expect(shouldTriggerUpdate).toBe(true);
expect(algorithm.orderedRooms).toEqual([roomB, roomE, roomC, roomA, roomD]);
// only sorted within category
expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledTimes(1);
expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomB, roomE], tagId);
});
it("re-sorts category when updated room has changed category", () => {
const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]);
jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear();
// change roomE to unreadState.none
jest.spyOn(RoomNotifs, "determineUnreadState").mockImplementation((room) => {
switch (room) {
// b and e have red notifs
case roomB:
return unreadStates.red;
// c is grey
case roomC:
return unreadStates.grey;
case roomE:
default:
return unreadStates.none;
}
});
// @ts-ignore don't bother mocking rest of emit properties
roomE.emit(RoomEvent.Timeline, new MatrixEvent({ type: "whatever", room_id: roomE.roomId }));
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.Timeline);
expect(shouldTriggerUpdate).toBe(true);
expect(algorithm.orderedRooms).toEqual([roomB, roomC, roomA, roomD, roomE]);
// only sorted within roomE's new category
expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledTimes(1);
expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomA, roomD, roomE], tagId);
});
});
});
});
describe("When sortAlgorithm is recent", () => {
const sortAlgorithm = SortAlgorithm.Recent;
// mock recent algorithm sorting
const fakeRecentOrder = [roomC, roomB, roomE, roomD, roomA];
beforeEach(async () => {
// destroy roomMap so we can start fresh
// @ts-ignore private property
RoomNotificationStateStore.instance.roomMap = new Map<Room, RoomNotificationState>();
jest.spyOn(RecentAlgorithm.prototype, "sortRooms")
.mockClear()
.mockImplementation((rooms: Room[]) =>
fakeRecentOrder.filter((sortedRoom) => rooms.includes(sortedRoom)),
);
jest.spyOn(RoomNotifs, "determineUnreadState")
.mockClear()
.mockImplementation((room) => {
switch (room) {
// b, c and e have red notifs
case roomB:
case roomE:
case roomC:
return unreadStates.red;
default:
return unreadStates.none;
}
});
});
it("orders rooms by recent when they have the same notif state", () => {
jest.spyOn(RoomNotifs, "determineUnreadState").mockReturnValue({
symbol: null,
count: 0,
level: NotificationLevel.None,
invited: false,
});
const algorithm = setupAlgorithm(sortAlgorithm);
// sorted according to recent
expect(algorithm.orderedRooms).toEqual([roomC, roomB, roomA]);
});
it("orders rooms by notification state then recent", () => {
const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]);
expect(algorithm.orderedRooms).toEqual([
// recent within red
roomC,
roomE,
// recent within none
roomD,
// muted
roomB,
roomA,
]);
});
describe("handleRoomUpdate", () => {
it("removes a room", () => {
const algorithm = setupAlgorithm(sortAlgorithm);
jest.spyOn(RecentAlgorithm.prototype, "sortRooms").mockClear();
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomA, RoomUpdateCause.RoomRemoved);
expect(shouldTriggerUpdate).toBe(true);
expect(algorithm.orderedRooms).toEqual([roomC, roomB]);
// no re-sorting on a remove
expect(RecentAlgorithm.prototype.sortRooms).not.toHaveBeenCalled();
});
it("warns and returns without change when removing a room that is not indexed", () => {
jest.spyOn(logger, "warn").mockReturnValue(undefined);
const algorithm = setupAlgorithm(sortAlgorithm);
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomD, RoomUpdateCause.RoomRemoved);
expect(shouldTriggerUpdate).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(`Tried to remove unknown room from ${tagId}: ${roomD.roomId}`);
});
it("adds a new room", () => {
const algorithm = setupAlgorithm(sortAlgorithm);
jest.spyOn(RecentAlgorithm.prototype, "sortRooms").mockClear();
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.NewRoom);
expect(shouldTriggerUpdate).toBe(true);
// inserted according to notif state and mute
expect(algorithm.orderedRooms).toEqual([roomC, roomE, roomB, roomA]);
// only sorted within category
expect(RecentAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomE, roomC], tagId);
});
it("re-sorts on a mute change", () => {
const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]);
jest.spyOn(RecentAlgorithm.prototype, "sortRooms").mockClear();
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.PossibleMuteChange);
expect(shouldTriggerUpdate).toBe(true);
expect(RecentAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomC, roomE], tagId);
});
});
});
});

Some files were not shown because too many files have changed in this diff Show More