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:
@@ -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 }) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Vendored
-4
@@ -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);
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
+13
@@ -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>
|
||||
`;
|
||||
+1
-1
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
-363
@@ -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
Reference in New Issue
Block a user