From ff6f64e0f5bbc57d690922f6362b2621def5b24f Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Mon, 29 Jun 2026 18:37:22 +0200 Subject: [PATCH 01/17] 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). --- apps/web/src/stores/spaces/SpaceStore.ts | 19 +++++++------------ .../test/unit-tests/stores/SpaceStore-test.ts | 7 ++++--- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/apps/web/src/stores/spaces/SpaceStore.ts b/apps/web/src/stores/spaces/SpaceStore.ts index 2ccbb750a67..e417a3be0ef 100644 --- a/apps/web/src/stores/spaces/SpaceStore.ts +++ b/apps/web/src/stores/spaces/SpaceStore.ts @@ -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, @@ -217,16 +216,12 @@ export class SpaceStoreClass extends AsyncStoreWithClient { 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 { diff --git a/apps/web/test/unit-tests/stores/SpaceStore-test.ts b/apps/web/test/unit-tests/stores/SpaceStore-test.ts index 2058edaa4aa..bef426f3b75 100644 --- a/apps/web/test/unit-tests/stores/SpaceStore-test.ts +++ b/apps/web/test/unit-tests/stores/SpaceStore-test.ts @@ -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"; @@ -1515,8 +1515,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 From f7301f8f6e1b3dc77c4460cc1b136bf2d6502232 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Mon, 29 Jun 2026 18:37:22 +0200 Subject: [PATCH 02/17] 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). --- apps/web/res/css/_components.pcss | 6 - apps/web/res/css/structures/_LeftPanel.pcss | 128 --- apps/web/res/css/structures/_RoomSearch.pcss | 93 -- .../res/css/views/rooms/_LegacyRoomList.pcss | 11 - .../views/rooms/_LegacyRoomListHeader.pcss | 75 -- .../res/css/views/rooms/_RoomBreadcrumbs.pcss | 43 - .../web/res/css/views/rooms/_RoomSublist.pcss | 389 -------- apps/web/res/css/views/rooms/_RoomTile.pcss | 158 ---- apps/web/src/@types/polyfill.ts | 48 - .../context_menu/StyledMenuItemCheckbox.tsx | 75 -- .../context_menu/StyledMenuItemRadio.tsx | 77 -- .../src/components/structures/ContextMenu.tsx | 2 - .../src/components/structures/LeftPanel.tsx | 384 +------- .../src/components/structures/RoomSearch.tsx | 54 -- .../spotlight/RoomResultContextMenus.tsx | 10 +- .../src/components/views/rooms/ExtraTile.tsx | 87 -- .../components/views/rooms/LegacyRoomList.tsx | 706 --------------- .../views/rooms/LegacyRoomListHeader.tsx | 441 --------- .../views/rooms/RoomBreadcrumbs.tsx | 142 --- .../components/views/rooms/RoomSublist.tsx | 857 ------------------ .../src/components/views/rooms/RoomTile.tsx | 481 ---------- .../views/rooms/RoomTileCallSummary.tsx | 38 - .../views/rooms/RoomTileSubtitle.tsx | 51 -- apps/web/src/hooks/useHover.ts | 25 - apps/web/src/utils/objects.test.ts | 12 - apps/web/src/utils/objects.ts | 17 - .../components/structures/LeftPanel-test.tsx | 43 - .../components/views/rooms/ExtraTile-test.tsx | 53 -- .../views/rooms/LegacyRoomList-test.tsx | 273 ------ .../views/rooms/RoomListHeader-test.tsx | 281 ------ .../components/views/rooms/RoomTile-test.tsx | 317 ------- .../__snapshots__/ExtraTile-test.tsx.snap | 39 - .../__snapshots__/RoomTile-test.tsx.snap | 378 -------- 33 files changed, 13 insertions(+), 5781 deletions(-) delete mode 100644 apps/web/res/css/structures/_RoomSearch.pcss delete mode 100644 apps/web/res/css/views/rooms/_LegacyRoomList.pcss delete mode 100644 apps/web/res/css/views/rooms/_LegacyRoomListHeader.pcss delete mode 100644 apps/web/res/css/views/rooms/_RoomBreadcrumbs.pcss delete mode 100644 apps/web/res/css/views/rooms/_RoomSublist.pcss delete mode 100644 apps/web/res/css/views/rooms/_RoomTile.pcss delete mode 100644 apps/web/src/@types/polyfill.ts delete mode 100644 apps/web/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx delete mode 100644 apps/web/src/accessibility/context_menu/StyledMenuItemRadio.tsx delete mode 100644 apps/web/src/components/structures/RoomSearch.tsx delete mode 100644 apps/web/src/components/views/rooms/ExtraTile.tsx delete mode 100644 apps/web/src/components/views/rooms/LegacyRoomList.tsx delete mode 100644 apps/web/src/components/views/rooms/LegacyRoomListHeader.tsx delete mode 100644 apps/web/src/components/views/rooms/RoomBreadcrumbs.tsx delete mode 100644 apps/web/src/components/views/rooms/RoomSublist.tsx delete mode 100644 apps/web/src/components/views/rooms/RoomTile.tsx delete mode 100644 apps/web/src/components/views/rooms/RoomTileCallSummary.tsx delete mode 100644 apps/web/src/components/views/rooms/RoomTileSubtitle.tsx delete mode 100644 apps/web/src/hooks/useHover.ts delete mode 100644 apps/web/test/unit-tests/components/structures/LeftPanel-test.tsx delete mode 100644 apps/web/test/unit-tests/components/views/rooms/ExtraTile-test.tsx delete mode 100644 apps/web/test/unit-tests/components/views/rooms/LegacyRoomList-test.tsx delete mode 100644 apps/web/test/unit-tests/components/views/rooms/RoomListHeader-test.tsx delete mode 100644 apps/web/test/unit-tests/components/views/rooms/RoomTile-test.tsx delete mode 100644 apps/web/test/unit-tests/components/views/rooms/__snapshots__/ExtraTile-test.tsx.snap delete mode 100644 apps/web/test/unit-tests/components/views/rooms/__snapshots__/RoomTile-test.tsx.snap diff --git a/apps/web/res/css/_components.pcss b/apps/web/res/css/_components.pcss index 46be865ad6b..054cd7e42bf 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -72,7 +72,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 +258,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 +274,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"; diff --git a/apps/web/res/css/structures/_LeftPanel.pcss b/apps/web/res/css/structures/_LeftPanel.pcss index e72579064af..81c61479adf 100644 --- a/apps/web/res/css/structures/_LeftPanel.pcss +++ b/apps/web/res/css/structures/_LeftPanel.pcss @@ -66,8 +66,6 @@ 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; @@ -75,110 +73,6 @@ Please see LICENSE files in the repository root for full details. /* 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,28 +83,6 @@ 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; - } - } } } } diff --git a/apps/web/res/css/structures/_RoomSearch.pcss b/apps/web/res/css/structures/_RoomSearch.pcss deleted file mode 100644 index beb60e7ef95..00000000000 --- a/apps/web/res/css/structures/_RoomSearch.pcss +++ /dev/null @@ -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; - } - } -} diff --git a/apps/web/res/css/views/rooms/_LegacyRoomList.pcss b/apps/web/res/css/views/rooms/_LegacyRoomList.pcss deleted file mode 100644 index d890b561eca..00000000000 --- a/apps/web/res/css/views/rooms/_LegacyRoomList.pcss +++ /dev/null @@ -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 */ -} diff --git a/apps/web/res/css/views/rooms/_LegacyRoomListHeader.pcss b/apps/web/res/css/views/rooms/_LegacyRoomListHeader.pcss deleted file mode 100644 index ec93c4e746d..00000000000 --- a/apps/web/res/css/views/rooms/_LegacyRoomListHeader.pcss +++ /dev/null @@ -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; - } - } - } -} diff --git a/apps/web/res/css/views/rooms/_RoomBreadcrumbs.pcss b/apps/web/res/css/views/rooms/_RoomBreadcrumbs.pcss deleted file mode 100644 index 7dd150cd27b..00000000000 --- a/apps/web/res/css/views/rooms/_RoomBreadcrumbs.pcss +++ /dev/null @@ -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; - } -} diff --git a/apps/web/res/css/views/rooms/_RoomSublist.pcss b/apps/web/res/css/views/rooms/_RoomSublist.pcss deleted file mode 100644 index f1118ebed67..00000000000 --- a/apps/web/res/css/views/rooms/_RoomSublist.pcss +++ /dev/null @@ -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 */ -} diff --git a/apps/web/res/css/views/rooms/_RoomTile.pcss b/apps/web/res/css/views/rooms/_RoomTile.pcss deleted file mode 100644 index babb70aec2a..00000000000 --- a/apps/web/res/css/views/rooms/_RoomTile.pcss +++ /dev/null @@ -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; - } - } -} diff --git a/apps/web/src/@types/polyfill.ts b/apps/web/src/@types/polyfill.ts deleted file mode 100644 index 5e08725889f..00000000000 --- a/apps/web/src/@types/polyfill.ts +++ /dev/null @@ -1,48 +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. -*/ - -// This is intended to fix re-resizer because of its unguarded `instanceof TouchEvent` checks. -export function polyfillTouchEvent(): void { - // Firefox doesn't have touch events without touch devices being present, so create a fake - // one we can rely on lying about. - if (!window.TouchEvent) { - // We have no intention of actually using this, so just lie. - window.TouchEvent = class TouchEvent extends UIEvent { - public get altKey(): boolean { - return false; - } - public get changedTouches(): any { - return []; - } - public get ctrlKey(): boolean { - return false; - } - public get metaKey(): boolean { - return false; - } - public get shiftKey(): boolean { - return false; - } - public get targetTouches(): any { - return []; - } - public get touches(): any { - return []; - } - public get rotation(): number { - return 0.0; - } - public get scale(): number { - return 0.0; - } - public constructor(eventType: string, params?: any) { - super(eventType, params); - } - }; - } -} diff --git a/apps/web/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx b/apps/web/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx deleted file mode 100644 index 82175199aea..00000000000 --- a/apps/web/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx +++ /dev/null @@ -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 { - 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 = ({ children, onChange, onClose, ...props }) => { - const [onFocus, isActive, ref] = useRovingTabIndex(); - - 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 ( - - {children} - - ); -}; diff --git a/apps/web/src/accessibility/context_menu/StyledMenuItemRadio.tsx b/apps/web/src/accessibility/context_menu/StyledMenuItemRadio.tsx deleted file mode 100644 index 62213b33c64..00000000000 --- a/apps/web/src/accessibility/context_menu/StyledMenuItemRadio.tsx +++ /dev/null @@ -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 { - 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 = ({ children, label, onChange, onClose, ...props }) => { - const [onFocus, isActive, ref] = useRovingTabIndex(); - - 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 ( - - {children} - - ); -}; diff --git a/apps/web/src/components/structures/ContextMenu.tsx b/apps/web/src/components/structures/ContextMenu.tsx index c0fde685b31..887b9bb47d1 100644 --- a/apps/web/src/components/structures/ContextMenu.tsx +++ b/apps/web/src/components/structures/ContextMenu.tsx @@ -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"; diff --git a/apps/web/src/components/structures/LeftPanel.tsx b/apps/web/src/components/structures/LeftPanel.tsx index 630d2a9e165..7d7ba4ed228 100644 --- a/apps/web/src/components/structures/LeftPanel.tsx +++ b/apps/web/src/components/structures/LeftPanel.tsx @@ -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,42 @@ interface IProps { resizeNotifier: ResizeNotifier; } -enum BreadcrumbsMode { - Disabled, - Legacy, -} - interface IState { - showBreadcrumbs: BreadcrumbsMode; activeSpace: SpaceKey; - supportsPstnProtocol: boolean; } export default class LeftPanel extends React.Component { - private listContainerRef = createRef(); - private roomListRef = createRef(); - 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(".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(".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 ( - - - - ); - } - } - - 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 = ( - - - - ); - } - - let rightButton: JSX.Element | undefined; - if (this.state.activeSpace === MetaSpace.Home && shouldShowComponent(UIComponent.ExploreRooms)) { - rightButton = ( - - - - ); - } - - return ( -
- - - {dialPadButton} - {rightButton} -
- ); - } - public render(): React.ReactNode { - const useNewRoomList = SettingsStore.getValue("feature_new_room_list"); const containerClasses = classNames({ mx_LeftPanel: true, - mx_LeftPanel_newRoomList: useNewRoomList, + mx_LeftPanel_newRoomList: true, mx_LeftPanel_minimized: this.props.isMinimized, }); - const roomListClasses = classNames("mx_LeftPanel_actualRoomListContainer", "mx_AutoHideScrollbar"); - if (useNewRoomList) { - return ( -
-
- -
-
- ); - } - - const roomList = ( - - ); - return (
- {shouldShowComponent(UIComponent.FilterContainer) && this.renderSearchDialExplore()} - {this.renderBreadcrumbs()} - {!this.props.isMinimized && } - +
); diff --git a/apps/web/src/components/structures/RoomSearch.tsx b/apps/web/src/components/structures/RoomSearch.tsx deleted file mode 100644 index f099032a637..00000000000 --- a/apps/web/src/components/structures/RoomSearch.tsx +++ /dev/null @@ -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 { - 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 = ( - - {IS_MAC ? "⌘ K" : _t(ALTERNATE_KEY_NAME[Key.CONTROL]) + " K"} - - ); - - return ( - - - {!this.props.isMinimized && ( -
{_t("action|search")}
- )} - {shortcutPrompt} -
- ); - } -} diff --git a/apps/web/src/components/views/dialogs/spotlight/RoomResultContextMenus.tsx b/apps/web/src/components/views/dialogs/spotlight/RoomResultContextMenus.tsx index c11bd777e2e..838fbcc45d0 100644 --- a/apps/web/src/components/views/dialogs/spotlight/RoomResultContextMenus.tsx +++ b/apps/web/src/components/views/dialogs/spotlight/RoomResultContextMenus.tsx @@ -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: diff --git a/apps/web/src/components/views/rooms/ExtraTile.tsx b/apps/web/src/components/views/rooms/ExtraTile.tsx deleted file mode 100644 index 05bbb312231..00000000000 --- a/apps/web/src/components/views/rooms/ExtraTile.tsx +++ /dev/null @@ -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 = ; - } - - 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 = ( -
-
- {name} -
-
- ); - if (isMinimized) nameContainer = null; - - return ( - -
{avatar}
-
-
- {nameContainer} -
{badge}
-
-
-
- ); -} diff --git a/apps/web/src/components/views/rooms/LegacyRoomList.tsx b/apps/web/src/components/views/rooms/LegacyRoomList.tsx deleted file mode 100644 index 9b156f8cb97..00000000000 --- a/apps/web/src/components/views/rooms/LegacyRoomList.tsx +++ /dev/null @@ -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/SDKContext.ts"; -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; - 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 = ({ tabIndex, dispatcher = defaultDispatcher }) => { - const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); - 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 = ( - - - {showCreateRooms && ( - } - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - closeMenu(); - defaultDispatcher.dispatch({ action: Action.CreateChat }); - PosthogTrackers.trackInteraction( - "WebRoomListRoomsSublistPlusMenuCreateChatItem", - e, - ); - }} - /> - )} - {showInviteUsers && ( - } - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - closeMenu(); - showSpaceInvite(activeSpace); - }} - disabled={!canInvite} - title={canInvite ? undefined : _t("spaces|error_no_permission_invite")} - /> - )} - - - ); - } - - return ( - <> - - - - - {contextMenu} - - ); - } else if (!activeSpace && showCreateRooms) { - return ( - { - dispatcher.dispatch({ action: Action.CreateChat }); - PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuCreateChatItem", e); - }} - className="mx_RoomSublist_auxButton" - aria-label={_t("action|start_chat")} - title={_t("action|start_chat")} - > - - - ); - } - - return null; -}; - -const UntaggedAuxButton: React.FC = ({ tabIndex }) => { - const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); - const activeSpace = useEventEmitterState(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 = ( - - } - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - closeMenu(); - defaultDispatcher.dispatch({ - action: Action.ViewRoom, - room_id: activeSpace.roomId, - metricsTrigger: undefined, // other - }); - PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuExploreRoomsItem", e); - }} - /> - {showCreateRoom ? ( - <> - } - 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 && ( - } - 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")} - > - - - )} - } - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - closeMenu(); - showAddExistingRooms(activeSpace); - }} - disabled={!canAddRooms} - title={canAddRooms ? undefined : _t("spaces|error_no_permission_add_room")} - /> - - ) : null} - - ); - } else if (menuDisplayed) { - contextMenuContent = ( - - {showCreateRoom && ( - <> - } - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - closeMenu(); - defaultDispatcher.dispatch({ action: Action.CreateRoom }); - PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuCreateRoomItem", e); - }} - /> - {videoRoomsEnabled && ( - } - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - closeMenu(); - defaultDispatcher.dispatch({ - action: Action.CreateRoom, - type: elementCallVideoRoomsEnabled - ? RoomType.UnstableCall - : RoomType.ElementVideo, - }); - }} - > - - - )} - - )} - {showExploreRooms ? ( - } - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - closeMenu(); - PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuExploreRoomsItem", e); - defaultDispatcher.fire(Action.ViewRoomDirectory); - }} - /> - ) : null} - - ); - } - - let contextMenu: JSX.Element | null = null; - if (menuDisplayed && handle.current) { - contextMenu = ( - - {contextMenuContent} - - ); - } - - if (showCreateRoom || showExploreRooms) { - return ( - <> - - - - - {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 { - private dispatcherRef?: string; - private treeRef = createRef(); - - public static contextType = MatrixClientContext; - declare public context: React.ContextType; - - 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({ - 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[] { - return this.state.suggestedRooms.map((room) => { - const name = room.name || room.canonical_alias || room.aliases?.[0] || _t("empty_room"); - const avatar = ( - - ); - const viewRoom = (ev: SyntheticEvent): void => { - defaultDispatcher.dispatch({ - 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 ( - - ); - }); - } - - 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[] | 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 ( - - ); - }); - } - - public focus(): void { - // focus the first focusable element in this aria treeview widget - const treeItems = this.treeRef.current?.querySelectorAll('[role="treeitem"]'); - if (!treeItems) return; - [...treeItems].find((e) => e.offsetParent !== null)?.focus(); - } - - public render(): React.ReactNode { - const sublists = this.renderSublists(); - return ( - - {({ onKeyDownHandler }) => ( -
{ - 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} -
- )} -
- ); - } -} diff --git a/apps/web/src/components/views/rooms/LegacyRoomListHeader.tsx b/apps/web/src/components/views/rooms/LegacyRoomListHeader.tsx deleted file mode 100644 index 6c9c15a7780..00000000000 --- a/apps/web/src/components/views/rooms/LegacyRoomListHeader.tsx +++ /dev/null @@ -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> => { - const cli = useContext(MatrixClientContext); - const [actions, setActions] = useState(new Map>()); - - 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 = ({ onVisibilityChange }) => { - const cli = useContext(MatrixClientContext); - const [mainMenuDisplayed, mainMenuHandle, openMainMenu, closeMainMenu] = useContextMenu(); - const [plusMenuDisplayed, plusMenuHandle, openPlusMenu, closePlusMenu] = useContextMenu(); - 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 = ( - - ); - } else if (plusMenuDisplayed && activeSpace) { - let inviteOption: JSX.Element | undefined; - if (shouldShowSpaceInvite(activeSpace)) { - inviteOption = ( - } - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - showSpaceInvite(activeSpace); - closePlusMenu(); - }} - /> - ); - } - - let newRoomOptions: JSX.Element | undefined; - if (activeSpace?.currentState.maySendStateEvent(EventType.RoomAvatar, cli.getUserId()!)) { - newRoomOptions = ( - <> - } - label={_t("action|new_room")} - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - showCreateNewRoom(activeSpace); - PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateRoomItem", e); - closePlusMenu(); - }} - /> - {videoRoomsEnabled && ( - } - label={_t("action|new_video_room")} - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - showCreateNewRoom( - activeSpace, - elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo, - ); - closePlusMenu(); - }} - > - - - )} - - ); - } - - contextMenu = ( - - - {inviteOption} - {newRoomOptions} - } - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - defaultDispatcher.dispatch({ - action: Action.ViewRoom, - room_id: activeSpace.roomId, - metricsTrigger: undefined, // other - }); - closePlusMenu(); - PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuExploreRoomsItem", e); - }} - /> - } - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - showAddExistingRooms(activeSpace); - closePlusMenu(); - }} - disabled={!canAddSubRooms} - title={!canAddSubRooms ? _t("spaces|error_no_permission_add_room") : undefined} - /> - {canCreateSpaces && ( - } - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - showCreateNewSubspace(activeSpace); - closePlusMenu(); - }} - disabled={!canAddSubSpaces} - title={!canAddSubSpaces ? _t("spaces|error_no_permission_add_space") : undefined} - > - - - )} - - - ); - } else if (plusMenuDisplayed) { - let newRoomOpts: JSX.Element | undefined; - let joinRoomOpt: JSX.Element | undefined; - - if (canCreateRooms) { - newRoomOpts = ( - <> - } - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - defaultDispatcher.dispatch({ action: Action.CreateChat }); - PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateChatItem", e); - closePlusMenu(); - }} - /> - } - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - defaultDispatcher.dispatch({ action: Action.CreateRoom }); - PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateRoomItem", e); - closePlusMenu(); - }} - /> - {videoRoomsEnabled && ( - } - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - defaultDispatcher.dispatch({ - action: Action.CreateRoom, - type: elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo, - }); - closePlusMenu(); - }} - > - - - )} - - ); - } - if (canExploreRooms) { - joinRoomOpt = ( - } - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - defaultDispatcher.dispatch({ action: Action.ViewRoomDirectory }); - PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuExploreRoomsItem", e); - closePlusMenu(); - }} - /> - ); - } - - contextMenu = ( - - - {newRoomOpts} - {joinRoomOpt} - - - ); - } - - 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 =
{title}
; - if (canShowMainMenu) { - const commonProps = { - ref: mainMenuHandle, - onClick: openMainMenu, - isExpanded: mainMenuDisplayed, - className: "mx_LegacyRoomListHeader_contextMenuButton", - children: ( - <> - {title} {mainMenuDisplayed ? : } - - ), - }; - - if (!!activeSpace) { - contextMenuButton = ( - - ); - } else { - contextMenuButton = ; - } - } - - return ( - - ); -}; - -export default LegacyRoomListHeader; diff --git a/apps/web/src/components/views/rooms/RoomBreadcrumbs.tsx b/apps/web/src/components/views/rooms/RoomBreadcrumbs.tsx deleted file mode 100644 index c677b32edca..00000000000 --- a/apps/web/src/components/views/rooms/RoomBreadcrumbs.tsx +++ /dev/null @@ -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 ( - - - - ); -}; - -export default class RoomBreadcrumbs extends React.PureComponent { - private unmounted = false; - private toolbar = createRef(); - - 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({ - action: Action.ViewRoom, - room_id: room.roomId, - metricsTrigger: "WebHorizontalBreadcrumbs", - metricsViaKeyboard: viaKeyboard, - }); - }; - - public render(): React.ReactElement { - const tiles = BreadcrumbsStore.instance.rooms.map((r, i) => ( - this.viewRoom(r, i, ev.type !== "click")} - /> - )); - - if (tiles.length > 0) { - // NOTE: The CSSTransition timeout MUST match the timeout in our CSS! - return ( - - - {tiles.slice(this.state.skipFirst ? 1 : 0)} - - - ); - } else { - return ( -
-
{_t("room_list|breadcrumbs_empty")}
-
- ); - } - } -} diff --git a/apps/web/src/components/views/rooms/RoomSublist.tsx b/apps/web/src/components/views/rooms/RoomSublist.tsx deleted file mode 100644 index 4051bc18a4c..00000000000 --- a/apps/web/src/components/views/rooms/RoomSublist.tsx +++ /dev/null @@ -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; - isMinimized: boolean; - tagId: TagID; - showSkeleton?: boolean; - alwaysVisible?: boolean; - forceExpanded?: boolean; - resizeNotifier: ResizeNotifier; - extraTiles?: ReactComponentElement[] | 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; - -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 { - private headerButton = createRef(); - private sublistRef = createRef(); - private tilesRef = createRef(); - 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[] | 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, prevState: Readonly): 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, nextState: Readonly): 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 => { - // 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(".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 => { - 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({ - 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( - , - ); - } - } - - 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 = ( - -
-
- {_t("common|appearance")} - - {_t("room_list|sort_unread_first")} - - - {_t("room_list|show_previews")} - -
-
- ); - } - - contextMenu = ( - -
-
- {_t("room_list|sort_by")} - this.onTagSortChanged(SortAlgorithm.Recent)} - checked={!isAlphabetical} - name={`mx_${this.props.tagId}_sortBy`} - > - {_t("room_list|sort_by_activity")} - - this.onTagSortChanged(SortAlgorithm.Alphabetic)} - checked={isAlphabetical} - name={`mx_${this.props.tagId}_sortBy`} - > - {_t("room_list|sort_by_alphabet")} - -
- {otherSections} -
-
- ); - } - - return ( - - - - - {contextMenu} - - ); - } - - private renderHeader(): React.ReactElement { - return ( - - {({ 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 = ( - - ); - - let addRoomButton: JSX.Element | undefined; - if (this.props.AuxButtonComponent) { - const AuxButtonComponent = this.props.AuxButtonComponent; - addRoomButton = ; - } - - const collapsed = !this.state.isExpanded && !this.props.forceExpanded; - const classes = classNames({ - mx_RoomSublist_headerContainer: true, - mx_RoomSublist_headerContainer_withAux: !!addRoomButton, - }); - - const badgeContainer =
{badge}
; - - // 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 ( -
-
-
- - - {collapsed ? : } - - {this.props.label} - - {this.renderMenu()} - {this.props.isMinimized ? null : badgeContainer} - {this.props.isMinimized ? null : addRoomButton} -
-
- {this.props.isMinimized ? badgeContainer : null} - {this.props.isMinimized ? addRoomButton : null} -
- ); - }} -
- ); - } - - 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 =
; - } else if (visibleTiles.length > 0 && this.props.forceExpanded) { - content = ( -
-
- {visibleTiles} -
-
- ); - } 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 = {label}; - if (this.props.isMinimized) showMoreText = null; - showNButton = ( - - - {showMoreText} - - ); - } 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 = {label}; - if (this.props.isMinimized) showLessText = null; - showNButton = ( - - - {showLessText} - - ); - } - - // 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 = ( - -
- {visibleTiles} -
- {showNButton} -
- ); - } else if (this.props.showSkeleton && this.state.isExpanded) { - content =
; - } - - return ( -
- {this.renderHeader()} - {content} -
- ); - } -} diff --git a/apps/web/src/components/views/rooms/RoomTile.tsx b/apps/web/src/components/views/rooms/RoomTile.tsx deleted file mode 100644 index 5c4fb91af18..00000000000 --- a/apps/web/src/components/views/rooms/RoomTile.tsx +++ /dev/null @@ -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/SDKContext"; -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; - -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 { - private dispatcherRef?: string; - private roomTileRef = createRef(); - 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, prevState: Readonly): 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 { - 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 => { - ev.preventDefault(); - ev.stopPropagation(); - - const action = getKeyBindingsManager().getAccessibilityAction(ev as React.KeyboardEvent); - const clearSearch = ([KeyBindingAction.Enter, KeyBindingAction.Space] as Array).includes( - action, - ); - - defaultDispatcher.dispatch({ - 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 ( - - - {getNotificationIcon(state!)} - - {this.state.notificationsMenuPosition && ( - - )} - - ); - } - - private renderGeneralMenu(): React.ReactElement | null { - if (!this.showContextMenu) return null; // no menu to show - return ( - - - - - {this.state.generalMenuPosition && ( - - 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) - } - /> - )} - - ); - } - - /** - * 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 = ( - - ); - } - - const subtitle = this.shouldRenderSubtitle ? ( - - ) : null; - - const titleClasses = classNames({ - mx_RoomTile_title: true, - mx_RoomTile_titleWithSubtitle: !!subtitle, - mx_RoomTile_titleHasUnreadEvents: this.notificationState.isUnread, - }); - - const titleContainer = this.props.isMinimized ? null : ( -
-
- {name} -
- {subtitle} -
- ); - - 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 ( - - {({ onFocus, isActive, ref }) => ( - - - {titleContainer} - {badge} - {this.renderGeneralMenu()} - {this.renderNotificationsMenu(isActive)} - - )} - - ); - } -} - -export default RoomTile; diff --git a/apps/web/src/components/views/rooms/RoomTileCallSummary.tsx b/apps/web/src/components/views/rooms/RoomTileCallSummary.tsx deleted file mode 100644 index 32e3b609e50..00000000000 --- a/apps/web/src/components/views/rooms/RoomTileCallSummary.tsx +++ /dev/null @@ -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 = ({ 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 ; -}; diff --git a/apps/web/src/components/views/rooms/RoomTileSubtitle.tsx b/apps/web/src/components/views/rooms/RoomTileSubtitle.tsx deleted file mode 100644 index 6e11c91e0e2..00000000000 --- a/apps/web/src/components/views/rooms/RoomTileSubtitle.tsx +++ /dev/null @@ -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 = ({ call, messagePreview, roomId, showMessagePreview }) => { - if (call) { - return ( -
- -
- ); - } - - if (showMessagePreview && messagePreview) { - const className = classNames("mx_RoomTile_subtitle", { - "mx_RoomTile_subtitle--thread-reply": messagePreview.isThreadReply, - }); - - const icon = messagePreview.isThreadReply ? : null; - - return ( -
- {icon} - {messagePreview.text} -
- ); - } - - return null; -}; diff --git a/apps/web/src/hooks/useHover.ts b/apps/web/src/hooks/useHover.ts deleted file mode 100644 index 1a8aecd942f..00000000000 --- a/apps/web/src/hooks/useHover.ts +++ /dev/null @@ -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]; -} diff --git a/apps/web/src/utils/objects.test.ts b/apps/web/src/utils/objects.test.ts index 662fe7580cd..5a909951a7a 100644 --- a/apps/web/src/utils/objects.test.ts +++ b/apps/web/src/utils/objects.test.ts @@ -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, 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 }; diff --git a/apps/web/src/utils/objects.ts b/apps/web/src/utils/objects.ts index 60bf7024047..16b9b6d1ad3 100644 --- a/apps/web/src/utils/objects.ts +++ b/apps/web/src/utils/objects.ts @@ -30,23 +30,6 @@ export function objectExcluding>(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>(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 diff --git a/apps/web/test/unit-tests/components/structures/LeftPanel-test.tsx b/apps/web/test/unit-tests/components/structures/LeftPanel-test.tsx deleted file mode 100644 index 6e03f8b1433..00000000000 --- a/apps/web/test/unit-tests/components/structures/LeftPanel-test.tsx +++ /dev/null @@ -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(); - } - - 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(); - }); -}); diff --git a/apps/web/test/unit-tests/components/views/rooms/ExtraTile-test.tsx b/apps/web/test/unit-tests/components/views/rooms/ExtraTile-test.tsx deleted file mode 100644 index a1a957dbce3..00000000000 --- a/apps/web/test/unit-tests/components/views/rooms/ExtraTile-test.tsx +++ /dev/null @@ -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> = {}) { - const defaultProps: ComponentProps = { - isMinimized: false, - isSelected: false, - displayName: "test", - avatar: , - onClick: () => {}, - }; - return render(); - } - - 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); - }); -}); diff --git a/apps/web/test/unit-tests/components/views/rooms/LegacyRoomList-test.tsx b/apps/web/test/unit-tests/components/views/rooms/LegacyRoomList-test.tsx deleted file mode 100644 index b043e6d590e..00000000000 --- a/apps/web/test/unit-tests/components/views/rooms/LegacyRoomList-test.tsx +++ /dev/null @@ -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 = {}): JSX.Element { - return ( - - ); - } - - 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(); - }); - }); - }); -}); diff --git a/apps/web/test/unit-tests/components/views/rooms/RoomListHeader-test.tsx b/apps/web/test/unit-tests/components/views/rooms/RoomListHeader-test.tsx deleted file mode 100644 index b5fd449f50e..00000000000 --- a/apps/web/test/unit-tests/components/views/rooms/RoomListHeader-test.tsx +++ /dev/null @@ -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 => { - await testUtils.setupAsyncStoreWithClient(SpaceStore.instance, client); - act(() => { - SpaceStore.instance.setActiveSpace(testSpace.roomId); - }); - - const wrapper = render(); - - expect(wrapper.getByText("Test Space")).toBeInTheDocument(); - wrapper.getByLabelText("Test Space menu").click(); - - return wrapper; -}; - -const setupPlusMenu = async (client: MatrixClient, testSpace: Room): Promise => { - await testUtils.setupAsyncStoreWithClient(SpaceStore.instance, client); - act(() => { - SpaceStore.instance.setActiveSpace(testSpace.roomId); - }); - - const wrapper = render(); - - 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, labelArray: Array) => { - 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(); - - 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(".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(".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]); - }); - }); -}); diff --git a/apps/web/test/unit-tests/components/views/rooms/RoomTile-test.tsx b/apps/web/test/unit-tests/components/views/rooms/RoomTile-test.tsx deleted file mode 100644 index 0b583d1de24..00000000000 --- a/apps/web/test/unit-tests/components/views/rooms/RoomTile-test.tsx +++ /dev/null @@ -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( - - - , - ); - }; - - let client: Mocked; - 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>(); - 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] = [ - mkRoomMember(room.roomId, "@alice:example.org"), - new Set(["a"]), - ]; - const bob: [RoomMember, Set] = [ - mkRoomMember(room.roomId, "@bob:example.org"), - new Set(["b1", "b2"]), - ]; - const carol: [RoomMember, Set] = [ - 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(); - }); - }); - }); -}); diff --git a/apps/web/test/unit-tests/components/views/rooms/__snapshots__/ExtraTile-test.tsx.snap b/apps/web/test/unit-tests/components/views/rooms/__snapshots__/ExtraTile-test.tsx.snap deleted file mode 100644 index e489a11bd5b..00000000000 --- a/apps/web/test/unit-tests/components/views/rooms/__snapshots__/ExtraTile-test.tsx.snap +++ /dev/null @@ -1,39 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[`ExtraTile renders 1`] = ` - -
-
-
-
-
-
- test -
-
-
-
-
-
- -`; diff --git a/apps/web/test/unit-tests/components/views/rooms/__snapshots__/RoomTile-test.tsx.snap b/apps/web/test/unit-tests/components/views/rooms/__snapshots__/RoomTile-test.tsx.snap deleted file mode 100644 index bc712380a3f..00000000000 --- a/apps/web/test/unit-tests/components/views/rooms/__snapshots__/RoomTile-test.tsx.snap +++ /dev/null @@ -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`] = ` - -
-
- - ! - -
-
-
- - !1:​example.org - -
-
- - test thread reply - -
-
- - -`; - -exports[`RoomTile when message previews are enabled and there is a message in the room should render as expected 1`] = ` - -
-
- - ! - -
-
-
- - !1:​example.org - -
-
- - test message - -
-
- - - -
-
-`; - -exports[`RoomTile when message previews are enabled should render a room without a message as expected 1`] = ` - -
-
- - ! - -
-
-
- - !1:​example.org - -
-
- - -`; - -exports[`RoomTile when message previews are not enabled should render the room 1`] = ` -
-
-
- - ! - -
-
-
- - !1:​example.org - -
-
- -
-`; From c1981b314eb74482c9c8928a95f388d11ad39bb5 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Mon, 29 Jun 2026 18:37:22 +0200 Subject: [PATCH 03/17] 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. --- apps/web/src/@types/global.d.ts | 4 - .../src/components/structures/MatrixChat.tsx | 6 +- .../src/stores/room-list-v3/skip-list/tag.ts | 1 - apps/web/src/stores/room-list/Interface.ts | 103 --- apps/web/src/stores/room-list/ListLayout.ts | 111 --- .../stores/room-list/RoomListLayoutStore.ts | 65 -- .../web/src/stores/room-list/RoomListStore.ts | 632 --------------- apps/web/src/stores/room-list/SpaceWatcher.ts | 63 -- .../stores/room-list/algorithms/Algorithm.ts | 731 ------------------ .../list-ordering/ImportanceAlgorithm.ts | 299 ------- .../list-ordering/NaturalAlgorithm.ts | 204 ----- .../list-ordering/OrderingAlgorithm.ts | 84 -- .../algorithms/list-ordering/index.ts | 41 - .../src/stores/room-list/algorithms/models.ts | 45 -- .../tag-sorting/AlphabeticAlgorithm.ts | 24 - .../algorithms/tag-sorting/IAlgorithm.ts | 24 - .../algorithms/tag-sorting/RecentAlgorithm.ts | 128 --- .../room-list/algorithms/tag-sorting/index.ts | 44 -- .../room-list/filters/IFilterCondition.ts | 34 - .../room-list/filters/SpaceFilterCondition.ts | 72 -- apps/web/src/stores/room-list/models.ts | 30 - apps/web/src/utils/membership.ts | 22 - .../components/structures/MatrixChat-test.tsx | 4 - .../test/unit-tests/modules/StoresApi-test.ts | 1 - .../stores/room-list/RoomListStore-test.ts | 349 --------- .../stores/room-list/SpaceWatcher-test.ts | 218 ------ .../room-list/algorithms/Algorithm-test.ts | 102 --- .../algorithms/RecentAlgorithm-test.ts | 177 ----- .../list-ordering/ImportanceAlgorithm-test.ts | 363 --------- .../list-ordering/NaturalAlgorithm-test.ts | 282 ------- .../filters/SpaceFilterCondition-test.ts | 198 ----- 31 files changed, 1 insertion(+), 4460 deletions(-) delete mode 100644 apps/web/src/stores/room-list/Interface.ts delete mode 100644 apps/web/src/stores/room-list/ListLayout.ts delete mode 100644 apps/web/src/stores/room-list/RoomListLayoutStore.ts delete mode 100644 apps/web/src/stores/room-list/RoomListStore.ts delete mode 100644 apps/web/src/stores/room-list/SpaceWatcher.ts delete mode 100644 apps/web/src/stores/room-list/algorithms/Algorithm.ts delete mode 100644 apps/web/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts delete mode 100644 apps/web/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts delete mode 100644 apps/web/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts delete mode 100644 apps/web/src/stores/room-list/algorithms/list-ordering/index.ts delete mode 100644 apps/web/src/stores/room-list/algorithms/models.ts delete mode 100644 apps/web/src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm.ts delete mode 100644 apps/web/src/stores/room-list/algorithms/tag-sorting/IAlgorithm.ts delete mode 100644 apps/web/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts delete mode 100644 apps/web/src/stores/room-list/algorithms/tag-sorting/index.ts delete mode 100644 apps/web/src/stores/room-list/filters/IFilterCondition.ts delete mode 100644 apps/web/src/stores/room-list/filters/SpaceFilterCondition.ts delete mode 100644 apps/web/src/stores/room-list/models.ts delete mode 100644 apps/web/test/unit-tests/stores/room-list/RoomListStore-test.ts delete mode 100644 apps/web/test/unit-tests/stores/room-list/SpaceWatcher-test.ts delete mode 100644 apps/web/test/unit-tests/stores/room-list/algorithms/Algorithm-test.ts delete mode 100644 apps/web/test/unit-tests/stores/room-list/algorithms/RecentAlgorithm-test.ts delete mode 100644 apps/web/test/unit-tests/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm-test.ts delete mode 100644 apps/web/test/unit-tests/stores/room-list/algorithms/list-ordering/NaturalAlgorithm-test.ts delete mode 100644 apps/web/test/unit-tests/stores/room-list/filters/SpaceFilterCondition-test.ts diff --git a/apps/web/src/@types/global.d.ts b/apps/web/src/@types/global.d.ts index 99cd6d4ba42..4a5d5e89cfb 100644 --- a/apps/web/src/@types/global.d.ts +++ b/apps/web/src/@types/global.d.ts @@ -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; diff --git a/apps/web/src/components/structures/MatrixChat.tsx b/apps/web/src/components/structures/MatrixChat.tsx index 466aafa94e6..308700b2e73 100644 --- a/apps/web/src/components/structures/MatrixChat.tsx +++ b/apps/web/src/components/structures/MatrixChat.tsx @@ -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"; @@ -1363,9 +1361,7 @@ export default class MatrixChat extends React.PureComponent { } 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({ action: Action.AfterForgetRoom, room }); } }) diff --git a/apps/web/src/stores/room-list-v3/skip-list/tag.ts b/apps/web/src/stores/room-list-v3/skip-list/tag.ts index 03a9315fc7a..f81255ca6a5 100644 --- a/apps/web/src/stores/room-list-v3/skip-list/tag.ts +++ b/apps/web/src/stores/room-list-v3/skip-list/tag.ts @@ -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", } /** diff --git a/apps/web/src/stores/room-list/Interface.ts b/apps/web/src/stores/room-list/Interface.ts deleted file mode 100644 index d4265ef0767..00000000000 --- a/apps/web/src/stores/room-list/Interface.ts +++ /dev/null @@ -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; - - /** - * 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; -} diff --git a/apps/web/src/stores/room-list/ListLayout.ts b/apps/web/src/stores/room-list/ListLayout.ts deleted file mode 100644 index 1723bc5c325..00000000000 --- a/apps/web/src/stores/room-list/ListLayout.ts +++ /dev/null @@ -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 = 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, - }; - } -} diff --git a/apps/web/src/stores/room-list/RoomListLayoutStore.ts b/apps/web/src/stores/room-list/RoomListLayoutStore.ts deleted file mode 100644 index 16c7fba070f..00000000000 --- a/apps/web/src/stores/room-list/RoomListLayoutStore.ts +++ /dev/null @@ -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 { - private static internalInstance: RoomListLayoutStore; - - private readonly layoutMap = new Map(); - - 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 { - logger.warn("Resetting layouts for room list"); - for (const layout of this.layoutMap.values()) { - layout.reset(); - } - } - - protected async onNotReady(): Promise { - // 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 {} -} - -window.mxRoomListLayoutStore = RoomListLayoutStore.instance; diff --git a/apps/web/src/stores/room-list/RoomListStore.ts b/apps/web/src/stores/room-list/RoomListStore.ts deleted file mode 100644 index fc2343d4411..00000000000 --- a/apps/web/src/stores/room-list/RoomListStore.ts +++ /dev/null @@ -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/SDKContext"; -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 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 { - 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 { - 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 { - await this.makeReady(); - } - - protected async onNotReady(): Promise { - await this.resetStore(); - } - - protected async onAction(payload: ActionPayload): Promise { - // 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 { - // 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 = 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 = 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 => { - 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 => { - 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 = 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 = 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(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 { - // 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 { - 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 { - 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 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 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 => { - 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 { - 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 { - 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; diff --git a/apps/web/src/stores/room-list/SpaceWatcher.ts b/apps/web/src/stores/room-list/SpaceWatcher.ts deleted file mode 100644 index 83d2b1a5db6..00000000000 --- a/apps/web/src/stores/room-list/SpaceWatcher.ts +++ /dev/null @@ -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); - }; -} diff --git a/apps/web/src/stores/room-list/algorithms/Algorithm.ts b/apps/web/src/stores/room-list/algorithms/Algorithm.ts deleted file mode 100644 index 52fe9b26ffe..00000000000 --- a/apps/web/src/stores/room-list/algorithms/Algorithm.ts +++ /dev/null @@ -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 || {}; - - // 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} 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; - } -} diff --git a/apps/web/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts b/apps/web/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts deleted file mode 100644 index 1e37c61c23a..00000000000 --- a/apps/web/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts +++ /dev/null @@ -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 - } - } - } -} diff --git a/apps/web/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts b/apps/web/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts deleted file mode 100644 index ce15aaff1cf..00000000000 --- a/apps/web/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts +++ /dev/null @@ -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( - (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; - } -} diff --git a/apps/web/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts b/apps/web/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts deleted file mode 100644 index 6cb4473524c..00000000000 --- a/apps/web/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts +++ /dev/null @@ -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; - } -} diff --git a/apps/web/src/stores/room-list/algorithms/list-ordering/index.ts b/apps/web/src/stores/room-list/algorithms/list-ordering/index.ts deleted file mode 100644 index 50110cda5db..00000000000 --- a/apps/web/src/stores/room-list/algorithms/list-ordering/index.ts +++ /dev/null @@ -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); -} diff --git a/apps/web/src/stores/room-list/algorithms/models.ts b/apps/web/src/stores/room-list/algorithms/models.ts deleted file mode 100644 index 99d5874e2d5..00000000000 --- a/apps/web/src/stores/room-list/algorithms/models.ts +++ /dev/null @@ -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[]; -} diff --git a/apps/web/src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm.ts b/apps/web/src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm.ts deleted file mode 100644 index 2f1bbbfa9fd..00000000000 --- a/apps/web/src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm.ts +++ /dev/null @@ -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); - }); - } -} diff --git a/apps/web/src/stores/room-list/algorithms/tag-sorting/IAlgorithm.ts b/apps/web/src/stores/room-list/algorithms/tag-sorting/IAlgorithm.ts deleted file mode 100644 index 69f3222c9b0..00000000000 --- a/apps/web/src/stores/room-list/algorithms/tag-sorting/IAlgorithm.ts +++ /dev/null @@ -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[]; -} diff --git a/apps/web/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts b/apps/web/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts deleted file mode 100644 index eacb4e52537..00000000000 --- a/apps/web/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts +++ /dev/null @@ -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); - } -} diff --git a/apps/web/src/stores/room-list/algorithms/tag-sorting/index.ts b/apps/web/src/stores/room-list/algorithms/tag-sorting/index.ts deleted file mode 100644 index 236fde6b8d4..00000000000 --- a/apps/web/src/stores/room-list/algorithms/tag-sorting/index.ts +++ /dev/null @@ -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); -} diff --git a/apps/web/src/stores/room-list/filters/IFilterCondition.ts b/apps/web/src/stores/room-list/filters/IFilterCondition.ts deleted file mode 100644 index 8dd77fd4b95..00000000000 --- a/apps/web/src/stores/room-list/filters/IFilterCondition.ts +++ /dev/null @@ -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; -} diff --git a/apps/web/src/stores/room-list/filters/SpaceFilterCondition.ts b/apps/web/src/stores/room-list/filters/SpaceFilterCondition.ts deleted file mode 100644 index 02e20e91031..00000000000 --- a/apps/web/src/stores/room-list/filters/SpaceFilterCondition.ts +++ /dev/null @@ -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(); - private userIds = new Set(); - 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 => { - 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); - } -} diff --git a/apps/web/src/stores/room-list/models.ts b/apps/web/src/stores/room-list/models.ts deleted file mode 100644 index 90c721a9e22..00000000000 --- a/apps/web/src/stores/room-list/models.ts +++ /dev/null @@ -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", -} diff --git a/apps/web/src/utils/membership.ts b/apps/web/src/utils/membership.ts index 9880043e3d7..79d54cd658e 100644 --- a/apps/web/src/utils/membership.ts +++ b/apps/web/src/utils/membership.ts @@ -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; diff --git a/apps/web/test/unit-tests/components/structures/MatrixChat-test.tsx b/apps/web/test/unit-tests/components/structures/MatrixChat-test.tsx index 65a4c0cf9c4..e2bcc878f53 100644 --- a/apps/web/test/unit-tests/components/structures/MatrixChat-test.tsx +++ b/apps/web/test/unit-tests/components/structures/MatrixChat-test.tsx @@ -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/SDKContext.ts"; import { makeDelegatedAuthConfig } from "../../../test-utils/oidc.ts"; @@ -854,9 +853,6 @@ describe("", () => { 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); diff --git a/apps/web/test/unit-tests/modules/StoresApi-test.ts b/apps/web/test/unit-tests/modules/StoresApi-test.ts index ba8a0c83da9..ccdfdf1de6e 100644 --- a/apps/web/test/unit-tests/modules/StoresApi-test.ts +++ b/apps/web/test/unit-tests/modules/StoresApi-test.ts @@ -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", () => { diff --git a/apps/web/test/unit-tests/stores/room-list/RoomListStore-test.ts b/apps/web/test/unit-tests/stores/room-list/RoomListStore-test.ts deleted file mode 100644 index 268162909b7..00000000000 --- a/apps/web/test/unit-tests/stores/room-list/RoomListStore-test.ts +++ /dev/null @@ -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 } { - 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 | 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); - }); - }); - }); -}); diff --git a/apps/web/test/unit-tests/stores/room-list/SpaceWatcher-test.ts b/apps/web/test/unit-tests/stores/room-list/SpaceWatcher-test.ts deleted file mode 100644 index 4f0c7ee118b..00000000000 --- a/apps/web/test/unit-tests/stores/room-list/SpaceWatcher-test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/apps/web/test/unit-tests/stores/room-list/algorithms/Algorithm-test.ts b/apps/web/test/unit-tests/stores/room-list/algorithms/Algorithm-test.ts deleted file mode 100644 index b7ba92b4cec..00000000000 --- a/apps/web/test/unit-tests/stores/room-list/algorithms/Algorithm-test.ts +++ /dev/null @@ -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; - 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]); - }); -}); diff --git a/apps/web/test/unit-tests/stores/room-list/algorithms/RecentAlgorithm-test.ts b/apps/web/test/unit-tests/stores/room-list/algorithms/RecentAlgorithm-test.ts deleted file mode 100644 index 33adc42129d..00000000000 --- a/apps/web/test/unit-tests/stores/room-list/algorithms/RecentAlgorithm-test.ts +++ /dev/null @@ -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]); - }); - }); -}); diff --git a/apps/web/test/unit-tests/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm-test.ts b/apps/web/test/unit-tests/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm-test.ts deleted file mode 100644 index 5e63dbb8313..00000000000 --- a/apps/web/test/unit-tests/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm-test.ts +++ /dev/null @@ -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> = { - 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(); - - 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(); - - 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); - }); - }); - }); -}); diff --git a/apps/web/test/unit-tests/stores/room-list/algorithms/list-ordering/NaturalAlgorithm-test.ts b/apps/web/test/unit-tests/stores/room-list/algorithms/list-ordering/NaturalAlgorithm-test.ts deleted file mode 100644 index dd6a2928410..00000000000 --- a/apps/web/test/unit-tests/stores/room-list/algorithms/list-ordering/NaturalAlgorithm-test.ts +++ /dev/null @@ -1,282 +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, EventType, MatrixEvent, PushRuleActionName, Room, ClientEvent } from "matrix-js-sdk/src/matrix"; -import { logger } from "matrix-js-sdk/src/logger"; - -import { NaturalAlgorithm } from "../../../../../../src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm"; -import { SortAlgorithm } from "../../../../../../src/stores/room-list/algorithms/models"; -import { DefaultTagID } from "../../../../../../src/stores/room-list-v3/skip-list/tag"; -import { RoomUpdateCause } from "../../../../../../src/stores/room-list/models"; -import { AlphabeticAlgorithm } from "../../../../../../src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm"; -import { RecentAlgorithm } from "../../../../../../src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm"; -import { RoomNotificationStateStore } from "../../../../../../src/stores/notifications/RoomNotificationStateStore"; -import * as RoomNotifs from "../../../../../../src/RoomNotifs"; -import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../../../test-utils"; -import { DEFAULT_PUSH_RULES, makePushRule } from "../../../../../test-utils/pushRules"; -import { NotificationLevel } from "../../../../../../src/stores/notifications/NotificationLevel"; - -describe("NaturalAlgorithm", () => { - const userId = "@alice:server.org"; - const tagId = DefaultTagID.Favourite; - - const makeRoom = (id: string, name: string): Room => { - const room = new Room(id, client, userId); - room.name = name; - return room; - }; - - const client = getMockClientWithEventEmitter({ - ...mockClientMethodsUser(userId), - }); - const roomA = makeRoom("!aaa:server.org", "Alpha"); - const roomB = makeRoom("!bbb:server.org", "Bravo"); - const roomC = makeRoom("!ccc:server.org", "Charlie"); - const roomD = makeRoom("!ddd:server.org", "Delta"); - const roomE = makeRoom("!eee:server.org", "Echo"); - const roomX = makeRoom("!xxx:server.org", "Xylophone"); - - const muteRoomARule = makePushRule(roomA.roomId, { - actions: [PushRuleActionName.DontNotify], - conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: roomA.roomId }], - }); - const muteRoomDRule = makePushRule(roomD.roomId, { - actions: [PushRuleActionName.DontNotify], - conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: roomD.roomId }], - }); - client.pushRules = { - global: { - ...DEFAULT_PUSH_RULES.global, - override: [...DEFAULT_PUSH_RULES.global!.override!, muteRoomARule, muteRoomDRule], - }, - }; - - const setupAlgorithm = (sortAlgorithm: SortAlgorithm, rooms?: Room[]) => { - const algorithm = new NaturalAlgorithm(tagId, sortAlgorithm); - algorithm.setRooms(rooms || [roomA, roomB, roomC]); - return algorithm; - }; - - describe("When sortAlgorithm is alphabetical", () => { - const sortAlgorithm = SortAlgorithm.Alphabetic; - - beforeEach(async () => { - jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear(); - }); - - it("orders rooms by alpha", () => { - const algorithm = setupAlgorithm(sortAlgorithm); - - // sorted according to alpha - expect(algorithm.orderedRooms).toEqual([roomA, roomB, roomC]); - }); - - 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]); - }); - - it("warns 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); - expect(algorithm.orderedRooms).toEqual([roomA, roomB, roomC, roomE]); - // only sorted within category - expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledWith( - [roomA, roomB, roomC, roomE], - tagId, - ); - }); - - it("adds a new muted room", () => { - const algorithm = setupAlgorithm(sortAlgorithm, [roomA, roomB, roomE]); - jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear(); - - const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomD, RoomUpdateCause.NewRoom); - - expect(shouldTriggerUpdate).toBe(true); - // muted room mixed in main category - expect(algorithm.orderedRooms).toEqual([roomA, roomB, roomD, roomE]); - // only sorted within category - expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledTimes(1); - }); - - it("ignores a mute change update", () => { - const algorithm = setupAlgorithm(sortAlgorithm); - jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear(); - - const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomA, RoomUpdateCause.PossibleMuteChange); - - expect(shouldTriggerUpdate).toBe(false); - expect(AlphabeticAlgorithm.prototype.sortRooms).not.toHaveBeenCalled(); - }); - - 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"); - }); - - describe("time and read receipt updates", () => { - it("handles when a room is not indexed", () => { - const algorithm = setupAlgorithm(sortAlgorithm); - - const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomX, RoomUpdateCause.Timeline); - - // for better or worse natural alg sets this to true - expect(shouldTriggerUpdate).toBe(true); - expect(algorithm.orderedRooms).toEqual([roomA, roomB, roomC]); - }); - - it("re-sorts rooms when timeline updates", () => { - const algorithm = setupAlgorithm(sortAlgorithm); - jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear(); - - const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomA, RoomUpdateCause.Timeline); - - expect(shouldTriggerUpdate).toBe(true); - expect(algorithm.orderedRooms).toEqual([roomA, roomB, roomC]); - // only sorted within category - expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledTimes(1); - expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomA, roomB, roomC], tagId); - }); - }); - }); - }); - - describe("When sortAlgorithm is recent", () => { - const sortAlgorithm = SortAlgorithm.Recent; - - // mock recent algorithm sorting - const fakeRecentOrder = [roomC, roomA, roomB, roomD, roomE]; - - beforeEach(async () => { - // destroy roomMap so we can start fresh - // @ts-ignore private property - RoomNotificationStateStore.instance.roomMap = new Map(); - - jest.spyOn(RecentAlgorithm.prototype, "sortRooms") - .mockClear() - .mockImplementation((rooms: Room[]) => - fakeRecentOrder.filter((sortedRoom) => rooms.includes(sortedRoom)), - ); - - jest.spyOn(RoomNotifs, "determineUnreadState").mockReturnValue({ - symbol: null, - count: 0, - level: NotificationLevel.None, - invited: false, - }); - }); - - it("orders rooms by recent with muted rooms to the bottom", () => { - const algorithm = setupAlgorithm(sortAlgorithm); - - // sorted according to recent - expect(algorithm.orderedRooms).toEqual([roomC, 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 mute then recentness - expect(algorithm.orderedRooms).toEqual([roomC, roomB, roomE, roomA]); - // only sorted within category, muted roomA is not resorted - expect(RecentAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomC, roomB, roomE], tagId); - }); - - it("does not re-sort on possible mute change when room did not change effective mutedness", () => { - 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(false); - expect(RecentAlgorithm.prototype.sortRooms).not.toHaveBeenCalled(); - }); - - it("re-sorts on a mute change", () => { - const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]); - jest.spyOn(RecentAlgorithm.prototype, "sortRooms").mockClear(); - - // mute roomE - const muteRoomERule = makePushRule(roomE.roomId, { - actions: [PushRuleActionName.DontNotify], - conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: roomE.roomId }], - }); - const pushRulesEvent = new MatrixEvent({ type: EventType.PushRules }); - client.pushRules!.global!.override!.push(muteRoomERule); - client.emit(ClientEvent.AccountData, pushRulesEvent); - - const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.PossibleMuteChange); - - expect(shouldTriggerUpdate).toBe(true); - expect(algorithm.orderedRooms).toEqual([ - // unmuted, sorted by recent - roomC, - roomB, - // muted, sorted by recent - roomA, - roomD, - roomE, - ]); - // only sorted muted category - expect(RecentAlgorithm.prototype.sortRooms).toHaveBeenCalledTimes(1); - expect(RecentAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomA, roomD, roomE], tagId); - }); - }); - }); -}); diff --git a/apps/web/test/unit-tests/stores/room-list/filters/SpaceFilterCondition-test.ts b/apps/web/test/unit-tests/stores/room-list/filters/SpaceFilterCondition-test.ts deleted file mode 100644 index 915094c844b..00000000000 --- a/apps/web/test/unit-tests/stores/room-list/filters/SpaceFilterCondition-test.ts +++ /dev/null @@ -1,198 +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 } from "jest-mock"; -import { type Room } from "matrix-js-sdk/src/matrix"; - -import SettingsStore from "../../../../../src/settings/SettingsStore"; -import { FILTER_CHANGED } from "../../../../../src/stores/room-list/filters/IFilterCondition"; -import { SpaceFilterCondition } from "../../../../../src/stores/room-list/filters/SpaceFilterCondition"; -import { MetaSpace, type SpaceKey } from "../../../../../src/stores/spaces"; -import SpaceStore from "../../../../../src/stores/spaces/SpaceStore"; - -jest.mock("../../../../../src/settings/SettingsStore"); -jest.mock("../../../../../src/stores/spaces/SpaceStore", () => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const EventEmitter = require("events"); - class MockSpaceStore extends EventEmitter { - isRoomInSpace = jest.fn(); - getSpaceFilteredUserIds = jest.fn().mockReturnValue(new Set([])); - getSpaceFilteredRoomIds = jest.fn().mockReturnValue(new Set([])); - } - return { instance: new MockSpaceStore() }; -}); - -const SettingsStoreMock = mocked(SettingsStore); -const SpaceStoreInstanceMock = mocked(SpaceStore.instance); - -jest.useFakeTimers(); - -describe("SpaceFilterCondition", () => { - const space1 = "!space1:server"; - const space2 = "!space2:server"; - const room1Id = "!r1:server"; - const room2Id = "!r2:server"; - const room3Id = "!r3:server"; - const user1Id = "@u1:server"; - const user2Id = "@u2:server"; - const user3Id = "@u3:server"; - const makeMockGetValue = - (settings: Record = {}) => - (settingName: string, space: SpaceKey) => - settings[settingName]?.[space] || false; - - beforeEach(() => { - jest.resetAllMocks(); - SettingsStoreMock.getValue.mockClear().mockImplementation(makeMockGetValue()); - SpaceStoreInstanceMock.getSpaceFilteredUserIds.mockReturnValue(new Set([])); - SpaceStoreInstanceMock.isRoomInSpace.mockReturnValue(true); - }); - - const initFilter = (space: SpaceKey): SpaceFilterCondition => { - const filter = new SpaceFilterCondition(); - filter.updateSpace(space); - jest.runOnlyPendingTimers(); - return filter; - }; - - describe("isVisible", () => { - const room1 = { roomId: room1Id } as unknown as Room; - it("calls isRoomInSpace correctly", () => { - const filter = initFilter(space1); - - expect(filter.isVisible(room1)).toEqual(true); - expect(SpaceStoreInstanceMock.isRoomInSpace).toHaveBeenCalledWith(space1, room1Id); - }); - }); - - describe("onStoreUpdate", () => { - it("emits filter changed event when updateSpace is called even without changes", async () => { - const filter = new SpaceFilterCondition(); - const emitSpy = jest.spyOn(filter, "emit"); - filter.updateSpace(space1); - jest.runOnlyPendingTimers(); - expect(emitSpy).toHaveBeenCalledWith(FILTER_CHANGED); - }); - - describe("showPeopleInSpace setting", () => { - it("emits filter changed event when setting changes", async () => { - // init filter with setting true for space1 - SettingsStoreMock.getValue.mockImplementation( - makeMockGetValue({ - ["Spaces.showPeopleInSpace"]: { [space1]: true }, - }), - ); - const filter = initFilter(space1); - const emitSpy = jest.spyOn(filter, "emit"); - - SettingsStoreMock.getValue.mockClear().mockImplementation( - makeMockGetValue({ - ["Spaces.showPeopleInSpace"]: { [space1]: false }, - }), - ); - - SpaceStoreInstanceMock.emit(space1); - jest.runOnlyPendingTimers(); - expect(emitSpy).toHaveBeenCalledWith(FILTER_CHANGED); - }); - - it("emits filter changed event when setting is false and space changes to a meta space", async () => { - // init filter with setting true for space1 - SettingsStoreMock.getValue.mockImplementation( - makeMockGetValue({ - ["Spaces.showPeopleInSpace"]: { [space1]: false }, - }), - ); - const filter = initFilter(space1); - const emitSpy = jest.spyOn(filter, "emit"); - - filter.updateSpace(MetaSpace.Home); - jest.runOnlyPendingTimers(); - expect(emitSpy).toHaveBeenCalledWith(FILTER_CHANGED); - }); - }); - - it("does not emit filter changed event on store update when nothing changed", async () => { - const filter = initFilter(space1); - const emitSpy = jest.spyOn(filter, "emit"); - SpaceStoreInstanceMock.emit(space1); - jest.runOnlyPendingTimers(); - expect(emitSpy).not.toHaveBeenCalledWith(FILTER_CHANGED); - }); - - it("removes listener when updateSpace is called", async () => { - const filter = initFilter(space1); - filter.updateSpace(space2); - jest.runOnlyPendingTimers(); - const emitSpy = jest.spyOn(filter, "emit"); - - // update mock so filter would emit change if it was listening to space1 - SpaceStoreInstanceMock.getSpaceFilteredRoomIds.mockReturnValue(new Set([room1Id])); - SpaceStoreInstanceMock.emit(space1); - jest.runOnlyPendingTimers(); - // no filter changed event - expect(emitSpy).not.toHaveBeenCalledWith(FILTER_CHANGED); - }); - - it("removes listener when destroy is called", async () => { - const filter = initFilter(space1); - filter.destroy(); - jest.runOnlyPendingTimers(); - const emitSpy = jest.spyOn(filter, "emit"); - - // update mock so filter would emit change if it was listening to space1 - SpaceStoreInstanceMock.getSpaceFilteredRoomIds.mockReturnValue(new Set([room1Id])); - SpaceStoreInstanceMock.emit(space1); - jest.runOnlyPendingTimers(); - // no filter changed event - expect(emitSpy).not.toHaveBeenCalledWith(FILTER_CHANGED); - }); - - describe("when directChildRoomIds change", () => { - beforeEach(() => { - SpaceStoreInstanceMock.getSpaceFilteredRoomIds.mockReturnValue(new Set([room1Id, room2Id])); - }); - const filterChangedCases = [ - ["room added", [room1Id, room2Id, room3Id]], - ["room removed", [room1Id]], - ["room swapped", [room1Id, room3Id]], // same number of rooms with changes - ]; - - it.each(filterChangedCases)("%s", (_d, rooms) => { - const filter = initFilter(space1); - const emitSpy = jest.spyOn(filter, "emit"); - - SpaceStoreInstanceMock.getSpaceFilteredRoomIds.mockReturnValue(new Set(rooms)); - SpaceStoreInstanceMock.emit(space1); - jest.runOnlyPendingTimers(); - expect(emitSpy).toHaveBeenCalledWith(FILTER_CHANGED); - }); - }); - - describe("when user ids change", () => { - beforeEach(() => { - SpaceStoreInstanceMock.getSpaceFilteredUserIds.mockReturnValue(new Set([user1Id, user2Id])); - }); - const filterChangedCases = [ - ["user added", [user1Id, user2Id, user3Id]], - ["user removed", [user1Id]], - ["user swapped", [user1Id, user3Id]], // same number of rooms with changes - ]; - - it.each(filterChangedCases)("%s", (_d, rooms) => { - const filter = initFilter(space1); - const emitSpy = jest.spyOn(filter, "emit"); - - SpaceStoreInstanceMock.getSpaceFilteredUserIds.mockReturnValue(new Set(rooms)); - SpaceStoreInstanceMock.emit(space1); - jest.runOnlyPendingTimers(); - expect(emitSpy).toHaveBeenCalledWith(FILTER_CHANGED); - }); - }); - }); -}); From 83898870861e2273c6d94f6a52f65b51d1655e30 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Mon, 29 Jun 2026 18:37:22 +0200 Subject: [PATCH 04/17] 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. --- apps/web/res/css/_components.pcss | 1 - .../res/css/structures/_BackdropPanel.pcss | 29 ------- .../css/structures/_QuickSettingsButton.pcss | 2 - apps/web/res/css/structures/_SpacePanel.pcss | 8 +- .../src/accessibility/LandmarkNavigation.ts | 13 +--- .../components/structures/BackdropPanel.tsx | 33 -------- .../components/structures/LoggedInView.tsx | 77 +++++-------------- .../tabs/user/PreferencesUserSettingsTab.tsx | 9 +-- .../tabs/user/SidebarUserSettingsTab.tsx | 42 +--------- .../views/spaces/QuickSettingsButton.tsx | 64 +-------------- .../components/views/spaces/SpacePanel.tsx | 3 - apps/web/src/i18n/strings/en_EN.json | 1 - apps/web/src/settings/Settings.tsx | 10 --- .../message-preview/MessagePreviewStore.ts | 11 --- apps/web/src/stores/spaces/SpaceStore.ts | 26 +------ .../structures/LoggedInView-test.tsx | 27 ++----- .../__snapshots__/SpacePanel-test.tsx.snap | 2 +- .../test/unit-tests/stores/SpaceStore-test.ts | 72 ++--------------- docs/labs.md | 4 - 19 files changed, 46 insertions(+), 388 deletions(-) delete mode 100644 apps/web/res/css/structures/_BackdropPanel.pcss delete mode 100644 apps/web/src/components/structures/BackdropPanel.tsx diff --git a/apps/web/res/css/_components.pcss b/apps/web/res/css/_components.pcss index 054cd7e42bf..f1496fd5952 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -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"; diff --git a/apps/web/res/css/structures/_BackdropPanel.pcss b/apps/web/res/css/structures/_BackdropPanel.pcss deleted file mode 100644 index ee12886880b..00000000000 --- a/apps/web/res/css/structures/_BackdropPanel.pcss +++ /dev/null @@ -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; -} diff --git a/apps/web/res/css/structures/_QuickSettingsButton.pcss b/apps/web/res/css/structures/_QuickSettingsButton.pcss index b681cecb305..8df560c9ef4 100644 --- a/apps/web/res/css/structures/_QuickSettingsButton.pcss +++ b/apps/web/res/css/structures/_QuickSettingsButton.pcss @@ -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); } diff --git a/apps/web/res/css/structures/_SpacePanel.pcss b/apps/web/res/css/structures/_SpacePanel.pcss index a3c8b827010..a7c452c3ca2 100644 --- a/apps/web/res/css/structures/_SpacePanel.pcss +++ b/apps/web/res/css/structures/_SpacePanel.pcss @@ -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; diff --git a/apps/web/src/accessibility/LandmarkNavigation.ts b/apps/web/src/accessibility/LandmarkNavigation.ts index 632c7ec6134..acb6812ca3e 100644 --- a/apps/web/src/accessibility/LandmarkNavigation.ts +++ b/apps/web/src/accessibility/LandmarkNavigation.ts @@ -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 HTMLElement | null | undefined> = { [Landmark.ACTIVE_SPACE_BUTTON]: () => document.querySelector(".mx_SpaceButton_active"), - [Landmark.ROOM_SEARCH]: () => - SettingsStore.getValue("feature_new_room_list") - ? document.querySelector("#room-list-search-button") - : document.querySelector(".mx_RoomSearch"), + [Landmark.ROOM_SEARCH]: () => document.querySelector("#room-list-search-button"), [Landmark.ROOM_LIST]: () => - SettingsStore.getValue("feature_new_room_list") - ? document.querySelector(".mx_RoomListItemView_selected") || - document.querySelector(".mx_RoomListItemView") - : document.querySelector(".mx_RoomTile_selected") || - document.querySelector(".mx_RoomTile"), + document.querySelector(".mx_RoomListItemView_selected") || + document.querySelector(".mx_RoomListItemView"), [Landmark.MESSAGE_COMPOSER_OR_HOME]: () => { const isComposerOpen = !!document.querySelector(".mx_MessageComposer"); diff --git a/apps/web/src/components/structures/BackdropPanel.tsx b/apps/web/src/components/structures/BackdropPanel.tsx deleted file mode 100644 index f3a44521fa1..00000000000 --- a/apps/web/src/components/structures/BackdropPanel.tsx +++ /dev/null @@ -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 = ({ 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 ( -
- -
- ); -}; diff --git a/apps/web/src/components/structures/LoggedInView.tsx b/apps/web/src/components/structures/LoggedInView.tsx index a5256dde075..a9fd89f2c75 100644 --- a/apps/web/src/components/structures/LoggedInView.tsx +++ b/apps/web/src/components/structures/LoggedInView.tsx @@ -53,7 +53,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"; @@ -299,25 +298,12 @@ class LoggedInView extends React.Component { private createResizer(): Resizer { 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 toggleSize = NEW_ROOM_LIST_MIN_WIDTH; 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" }); - } + onCollapsed: () => { + // The room list does not support collapsing. }, onResized: (size) => { panelSize = size; @@ -327,13 +313,12 @@ class LoggedInView extends React.Component { 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); + 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"); + isItemCollapsed: () => { + // The room list does not support collapsing. + return false; }, handler: this.resizeHandler.current ?? undefined, }; @@ -347,11 +332,9 @@ class LoggedInView extends React.Component { } 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)) { + // If the user has not set a size, or if the size is less than the minimum width, set a default size. + if (isNaN(lhsSize) || lhsSize < NEW_ROOM_LIST_MIN_WIDTH) { lhsSize = 350; } this.resizer?.forHandleWithId("lp-resizer")?.resize(lhsSize); @@ -763,38 +746,19 @@ class LoggedInView extends React.Component { "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", "mx_LeftPanel_newRoomList"); const audioFeedArraysForCalls = this.state.activeCalls.map((call) => { return ; }); - const shouldUseMinimizedUI = !useNewRoomList && this.props.collapseLhs; - const leftPanel = (
- +
- {!useNewRoomList && ( - - )} - {!useNewRoomList && } - {!useNewRoomList && } {!moduleRenderer && ( -
- +
+
)}
@@ -805,9 +769,9 @@ class LoggedInView extends React.Component { 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 = ( @@ -825,13 +789,12 @@ class LoggedInView extends React.Component { ); } 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 && } + {leftPanel} {!moduleRenderer && } {roomView} diff --git a/apps/web/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx b/apps/web/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx index 976e1ec2e6d..563045274af 100644 --- a/apps/web/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx +++ b/apps/web/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx @@ -117,8 +117,6 @@ const SpellCheckSection: React.FC = () => { }; export default class PreferencesUserSettingsTab extends React.Component { - 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 { @@ -274,11 +271,7 @@ export default class PreferencesUserSettingsTab extends React.Component - {!newRoomListEnabled && this.renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS)} - {/* The settings is on device level where the other room list settings are on account level */} - {newRoomListEnabled && ( - - )} + diff --git a/apps/web/src/components/views/settings/tabs/user/SidebarUserSettingsTab.tsx b/apps/web/src/components/views/settings/tabs/user/SidebarUserSettingsTab.tsx index 041e437661e..1b494b885f3 100644 --- a/apps/web/src/components/views/settings/tabs/user/SidebarUserSettingsTab.tsx +++ b/apps/web/src/components/views/settings/tabs/user/SidebarUserSettingsTab.tsx @@ -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 ( @@ -103,36 +93,6 @@ const SidebarUserSettingsTab: React.FC = () => { {_t("settings|sidebar|metaspaces_home_all_rooms")} - {!newRoomListEnabled && ( - <> - - - {_t("common|favourites")} - - - - - {_t("common|people")} - - - )} - = ({ isPanelCollapsed = false }) => { const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); - 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 = ( )} - {!newRoomListEnabled && ( - <> -

- - {_t("quick_settings|metaspace_section")} -

- - - {_t("common|favourites")} - - - - {_t("common|people")} - - { - closeMenu(); - defaultDispatcher.dispatch({ - action: Action.ViewUserSettings, - initialTabId: UserTab.Sidebar, - }); - }} - > - - {_t("quick_settings|sidebar_settings")} - - - )}
); diff --git a/apps/web/src/components/views/spaces/SpacePanel.tsx b/apps/web/src/components/views/spaces/SpacePanel.tsx index 58ec07cd988..20754a429be 100644 --- a/apps/web/src/components/views/spaces/SpacePanel.tsx +++ b/apps/web/src/components/views/spaces/SpacePanel.tsx @@ -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 = () => {