From d0235f83d83a6ce6a23070ed0644effbcde98075 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 2 Dec 2025 16:26:14 +0000 Subject: [PATCH] Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- res/css/structures/_LeftPanel.pcss | 8 - res/css/views/right_panel/_UserInfo.pcss | 4 - src/@types/global.d.ts | 4 - src/actions/RoomListActions.ts | 3 - src/components/structures/LeftPanel.tsx | 196 +---- src/components/structures/LoggedInView.tsx | 2 - src/components/structures/MatrixChat.tsx | 4 - .../avatars/RoomAvatarViewModel.tsx | 1 - .../right_panel/RoomSummaryCardViewModel.tsx | 2 - .../context_menus/RoomGeneralContextMenu.tsx | 1 - src/components/views/dialogs/InviteDialog.tsx | 1 - src/components/views/rooms/ExtraTile.tsx | 87 -- src/hooks/useHover.ts | 25 - .../notifications/SpaceNotificationState.ts | 1 - src/stores/room-list/Interface.ts | 108 --- src/stores/room-list/ListLayout.ts | 111 --- src/stores/room-list/RoomListLayoutStore.ts | 65 -- src/stores/room-list/RoomListStore.ts | 643 --------------- src/stores/room-list/SpaceWatcher.ts | 63 -- src/stores/room-list/algorithms/Algorithm.ts | 770 ------------------ .../list-ordering/ImportanceAlgorithm.ts | 311 ------- .../list-ordering/NaturalAlgorithm.ts | 203 ----- .../list-ordering/OrderingAlgorithm.ts | 83 -- .../algorithms/list-ordering/index.ts | 41 - src/stores/room-list/algorithms/models.ts | 46 -- .../tag-sorting/AlphabeticAlgorithm.ts | 24 - .../algorithms/tag-sorting/IAlgorithm.ts | 24 - .../algorithms/tag-sorting/ManualAlgorithm.ts | 24 - .../algorithms/tag-sorting/RecentAlgorithm.ts | 128 --- .../room-list/algorithms/tag-sorting/index.ts | 46 -- .../room-list/filters/IFilterCondition.ts | 34 - .../room-list/filters/SpaceFilterCondition.ts | 72 -- .../room-list/filters/VisibilityProvider.ts | 48 -- src/stores/room-list/models.ts | 42 - src/stores/room-list/previews/IPreview.ts | 25 - .../previews/LegacyCallAnswerEventPreview.ts | 28 - .../previews/LegacyCallHangupEvent.ts | 28 - .../previews/LegacyCallInviteEventPreview.ts | 32 - .../previews/PollStartEventPreview.ts | 57 -- .../previews/ReactionEventPreview.ts | 48 -- .../room-list/previews/StickerEventPreview.ts | 27 - src/stores/room-list/previews/utils.ts | 33 - src/stores/room-list/utils/roomMute.ts | 46 -- src/stores/spaces/SpaceStore.ts | 35 +- src/utils/membership.ts | 22 - src/utils/room/tagRoom.ts | 1 - .../components/structures/MatrixChat-test.tsx | 1 - .../avatars/RoomAvatarViewModel-test.tsx | 1 - .../RoomSummaryCardViewModel-test.tsx | 2 - .../RoomListItemMenuViewModel-test.tsx | 1 - .../RoomGeneralContextMenu-test.tsx | 2 - .../components/views/rooms/ExtraTile-test.tsx | 53 -- .../__snapshots__/ExtraTile-test.tsx.snap | 39 - .../__snapshots__/RoomTile-test.tsx.snap | 282 ------- test/unit-tests/modules/StoresApi-test.ts | 1 - test/unit-tests/stores/SpaceStore-test.ts | 2 - .../room-list-v3/RoomListStoreV3-test.ts | 2 - .../stores/room-list/RoomListStore-test.ts | 397 --------- .../stores/room-list/SpaceWatcher-test.ts | 218 ----- .../room-list/algorithms/Algorithm-test.ts | 102 --- .../algorithms/RecentAlgorithm-test.ts | 177 ---- .../list-ordering/ImportanceAlgorithm-test.ts | 428 ---------- .../list-ordering/NaturalAlgorithm-test.ts | 281 ------- .../filters/SpaceFilterCondition-test.ts | 198 ----- .../filters/VisibilityProvider-test.ts | 74 -- .../previews/PollStartEventPreview-test.ts | 36 - .../previews/ReactionEventPreview-test.ts | 131 --- .../stores/room-list/utils/roomMute-test.ts | 88 -- test/unit-tests/utils/room/tagRoom-test.ts | 2 - 69 files changed, 9 insertions(+), 6116 deletions(-) delete mode 100644 src/components/views/rooms/ExtraTile.tsx delete mode 100644 src/hooks/useHover.ts delete mode 100644 src/stores/room-list/Interface.ts delete mode 100644 src/stores/room-list/ListLayout.ts delete mode 100644 src/stores/room-list/RoomListLayoutStore.ts delete mode 100644 src/stores/room-list/RoomListStore.ts delete mode 100644 src/stores/room-list/SpaceWatcher.ts delete mode 100644 src/stores/room-list/algorithms/Algorithm.ts delete mode 100644 src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts delete mode 100644 src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts delete mode 100644 src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts delete mode 100644 src/stores/room-list/algorithms/list-ordering/index.ts delete mode 100644 src/stores/room-list/algorithms/models.ts delete mode 100644 src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm.ts delete mode 100644 src/stores/room-list/algorithms/tag-sorting/IAlgorithm.ts delete mode 100644 src/stores/room-list/algorithms/tag-sorting/ManualAlgorithm.ts delete mode 100644 src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts delete mode 100644 src/stores/room-list/algorithms/tag-sorting/index.ts delete mode 100644 src/stores/room-list/filters/IFilterCondition.ts delete mode 100644 src/stores/room-list/filters/SpaceFilterCondition.ts delete mode 100644 src/stores/room-list/filters/VisibilityProvider.ts delete mode 100644 src/stores/room-list/models.ts delete mode 100644 src/stores/room-list/previews/IPreview.ts delete mode 100644 src/stores/room-list/previews/LegacyCallAnswerEventPreview.ts delete mode 100644 src/stores/room-list/previews/LegacyCallHangupEvent.ts delete mode 100644 src/stores/room-list/previews/LegacyCallInviteEventPreview.ts delete mode 100644 src/stores/room-list/previews/PollStartEventPreview.ts delete mode 100644 src/stores/room-list/previews/ReactionEventPreview.ts delete mode 100644 src/stores/room-list/previews/StickerEventPreview.ts delete mode 100644 src/stores/room-list/previews/utils.ts delete mode 100644 src/stores/room-list/utils/roomMute.ts delete mode 100644 test/unit-tests/components/views/rooms/ExtraTile-test.tsx delete mode 100644 test/unit-tests/components/views/rooms/__snapshots__/ExtraTile-test.tsx.snap delete mode 100644 test/unit-tests/components/views/rooms/__snapshots__/RoomTile-test.tsx.snap delete mode 100644 test/unit-tests/stores/room-list/RoomListStore-test.ts delete mode 100644 test/unit-tests/stores/room-list/SpaceWatcher-test.ts delete mode 100644 test/unit-tests/stores/room-list/algorithms/Algorithm-test.ts delete mode 100644 test/unit-tests/stores/room-list/algorithms/RecentAlgorithm-test.ts delete mode 100644 test/unit-tests/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm-test.ts delete mode 100644 test/unit-tests/stores/room-list/algorithms/list-ordering/NaturalAlgorithm-test.ts delete mode 100644 test/unit-tests/stores/room-list/filters/SpaceFilterCondition-test.ts delete mode 100644 test/unit-tests/stores/room-list/filters/VisibilityProvider-test.ts delete mode 100644 test/unit-tests/stores/room-list/previews/PollStartEventPreview-test.ts delete mode 100644 test/unit-tests/stores/room-list/previews/ReactionEventPreview-test.ts delete mode 100644 test/unit-tests/stores/room-list/utils/roomMute-test.ts diff --git a/res/css/structures/_LeftPanel.pcss b/res/css/structures/_LeftPanel.pcss index f33f2b4f0b..0522bc4cb1 100644 --- a/res/css/structures/_LeftPanel.pcss +++ b/res/css/structures/_LeftPanel.pcss @@ -110,10 +110,6 @@ Please see LICENSE files in the repository root for full details. display: flex; align-items: center; - & + .mx_LegacyRoomListHeader { - margin-top: 12px; - } - .mx_LeftPanel_dialPadButton { width: 32px; height: 32px; @@ -176,10 +172,6 @@ Please see LICENSE files in the repository root for full details. mask-image: url("@vector-im/compound-design-tokens/icons/time.svg"); } } - - .mx_LegacyRoomListHeader:first-child { - margin-top: 12px; - } } /* These styles override the defaults for the minimized (66px) layout */ diff --git a/res/css/views/right_panel/_UserInfo.pcss b/res/css/views/right_panel/_UserInfo.pcss index 70cc4ba064..1f8a2daecc 100644 --- a/res/css/views/right_panel/_UserInfo.pcss +++ b/res/css/views/right_panel/_UserInfo.pcss @@ -28,10 +28,6 @@ Please see LICENSE files in the repository root for full details. margin-bottom: $spacing-8; } - .mx_RoomTile_titleContainer { - width: 154px; - } - .mx_RoomTile_badge { display: none; } diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 9d43a13ca4..3a8817bf60 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -16,9 +16,7 @@ import type ContentMessages from "../ContentMessages"; import { type IMatrixClientPeg } from "../MatrixClientPeg"; import type ToastStore from "../stores/ToastStore"; import type DeviceListener from "../DeviceListener"; -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"; @@ -97,9 +95,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/src/actions/RoomListActions.ts b/src/actions/RoomListActions.ts index 255518a7dc..49abe359e0 100644 --- a/src/actions/RoomListActions.ts +++ b/src/actions/RoomListActions.ts @@ -15,9 +15,6 @@ import Modal from "../Modal"; import * as Rooms from "../Rooms"; import { _t } from "../languageHandler"; import { type AsyncActionPayload } from "../dispatcher/payloads"; -import RoomListStore from "../stores/room-list/RoomListStore"; -import { SortAlgorithm } from "../stores/room-list/algorithms/models"; -import { DefaultTagID, type TagID } from "../stores/room-list/models"; import ErrorDialog from "../components/views/dialogs/ErrorDialog"; export default class RoomListActions { diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index d432a0002e..88f5e28c39 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -6,22 +6,17 @@ 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, { createRef } from "react"; +import React from "react"; import classNames from "classnames"; import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../LegacyCallHandler"; import type ResizeNotifier from "../../utils/ResizeNotifier"; import SpaceStore from "../../stores/spaces/SpaceStore"; import { type SpaceKey, UPDATE_SELECTED_SPACE } from "../../stores/spaces"; -import UIStore from "../../stores/UIStore"; import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore"; -import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore"; -import { UPDATE_EVENT } from "../../stores/AsyncStore"; import type PageType from "../../PageTypes"; import { RoomListPanel } from "../views/rooms/RoomListPanel"; -const HEADER_HEIGHT = 32; // As defined by CSS - interface IProps { isMinimized: boolean; pageType: PageType; @@ -40,9 +35,6 @@ interface IState { } export default class LeftPanel extends React.Component { - private listContainerRef = createRef(); - private isDoingStickyHeaders = false; - public constructor(props: IProps) { super(props); @@ -58,34 +50,13 @@ export default class LeftPanel extends React.Component { } 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 => { @@ -96,171 +67,6 @@ export default class LeftPanel extends React.Component { this.setState({ activeSpace }); }; - 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); - }; - public render(): React.ReactNode { const containerClasses = classNames({ mx_LeftPanel: true, diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 71f27d7844..d84a7c1c67 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -33,12 +33,10 @@ import { SettingLevel } from "../../settings/SettingLevel"; import ResizeHandle from "../views/elements/ResizeHandle"; import { CollapseDistributor, Resizer } from "../../resizer"; import PlatformPeg from "../../PlatformPeg"; -import { DefaultTagID } from "../../stores/room-list/models"; import { hideToast as hideServerLimitToast, showToast as showServerLimitToast } from "../../toasts/ServerLimitToast"; import { Action } from "../../dispatcher/actions"; import LeftPanel from "./LeftPanel"; import { type ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPayload"; -import RoomListStore from "../../stores/room-list/RoomListStore"; import NonUrgentToastContainer from "./NonUrgentToastContainer"; import { type IOOBData, type IThreepidInvite } from "../../stores/ThreepidInviteStore"; import Modal from "../../Modal"; diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 3764051384..d325a012e3 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -76,8 +76,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"; @@ -1354,8 +1352,6 @@ 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 dis.dispatch({ action: Action.AfterForgetRoom, room }); } diff --git a/src/components/viewmodels/avatars/RoomAvatarViewModel.tsx b/src/components/viewmodels/avatars/RoomAvatarViewModel.tsx index 3832616a9c..2ea0baf58c 100644 --- a/src/components/viewmodels/avatars/RoomAvatarViewModel.tsx +++ b/src/components/viewmodels/avatars/RoomAvatarViewModel.tsx @@ -10,7 +10,6 @@ import { useEffect, useState } from "react"; import { useTypedEventEmitter } from "../../../hooks/useEventEmitter"; import { useDmMember, usePresence, type Presence } from "../../views/avatars/WithPresenceIndicator"; -import { DefaultTagID } from "../../../stores/room-list/models"; export enum AvatarBadgeDecoration { LowPriority = "LowPriority", diff --git a/src/components/viewmodels/right_panel/RoomSummaryCardViewModel.tsx b/src/components/viewmodels/right_panel/RoomSummaryCardViewModel.tsx index 143f3fca18..b29b7f2897 100644 --- a/src/components/viewmodels/right_panel/RoomSummaryCardViewModel.tsx +++ b/src/components/viewmodels/right_panel/RoomSummaryCardViewModel.tsx @@ -17,9 +17,7 @@ import { useAccountData } from "../../../hooks/useAccountData"; import { useDispatcher } from "../../../hooks/useDispatcher"; import defaultDispatcher from "../../../dispatcher/dispatcher"; import { Action } from "../../../dispatcher/actions"; -import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore"; import { canInviteTo } from "../../../utils/room/canInviteTo"; -import { DefaultTagID } from "../../../stores/room-list/models"; import { useEventEmitterState } from "../../../hooks/useEventEmitter"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases"; diff --git a/src/components/views/context_menus/RoomGeneralContextMenu.tsx b/src/components/views/context_menus/RoomGeneralContextMenu.tsx index 7c21d09853..75e1500493 100644 --- a/src/components/views/context_menus/RoomGeneralContextMenu.tsx +++ b/src/components/views/context_menus/RoomGeneralContextMenu.tsx @@ -20,7 +20,6 @@ import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { _t } from "../../../languageHandler"; import { NotificationLevel } from "../../../stores/notifications/NotificationLevel"; import { DefaultTagID, type TagID } from "../../../stores/room-list/models"; -import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore"; import DMRoomMap from "../../../utils/DMRoomMap"; import { clearRoomNotification, setMarkedUnreadState } from "../../../utils/notifications"; import { type IProps as IContextMenuProps } from "../../structures/ContextMenu"; diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index 9931dd9368..509da1ccba 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -27,7 +27,6 @@ import IdentityAuthClient from "../../../IdentityAuthClient"; import { type IInviteResult, inviteMultipleToRoom, showAnyInviteErrors } from "../../../RoomInvite"; import { Action } from "../../../dispatcher/actions"; import { DefaultTagID } from "../../../stores/room-list/models"; -import RoomListStore from "../../../stores/room-list/RoomListStore"; import SettingsStore from "../../../settings/SettingsStore"; import { UIFeature } from "../../../settings/UIFeature"; import { mediaFromMxc } from "../../../customisations/Media"; diff --git a/src/components/views/rooms/ExtraTile.tsx b/src/components/views/rooms/ExtraTile.tsx deleted file mode 100644 index 05bbb31223..0000000000 --- a/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/src/hooks/useHover.ts b/src/hooks/useHover.ts deleted file mode 100644 index 1a8aecd942..0000000000 --- a/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/src/stores/notifications/SpaceNotificationState.ts b/src/stores/notifications/SpaceNotificationState.ts index 0faf8bc3e6..f3e802bf3e 100644 --- a/src/stores/notifications/SpaceNotificationState.ts +++ b/src/stores/notifications/SpaceNotificationState.ts @@ -13,7 +13,6 @@ import { arrayDiff } from "../../utils/arrays"; import { type RoomNotificationState } from "./RoomNotificationState"; import { NotificationState, NotificationStateEvents } from "./NotificationState"; import { DefaultTagID } from "../room-list/models"; -import RoomListStore from "../room-list/RoomListStore"; import { RoomNotificationStateStore } from "./RoomNotificationStateStore"; export class SpaceNotificationState extends NotificationState { diff --git a/src/stores/room-list/Interface.ts b/src/stores/room-list/Interface.ts deleted file mode 100644 index c50a8e8746..0000000000 --- a/src/stores/room-list/Interface.ts +++ /dev/null @@ -1,108 +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, type TagID } from "./models"; -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", -} - -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; - - /** - * Gets the tags for a room identified by the store. The returned set - * should never be empty, and will contain DefaultTagID.Untagged if - * the store is not aware of any tags. - * @param room The room to get the tags for. - * @returns The tags for the room. - */ - getTagsForRoom(room: Room): TagID[]; - - /** - * 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/src/stores/room-list/ListLayout.ts b/src/stores/room-list/ListLayout.ts deleted file mode 100644 index a4468dfffe..0000000000 --- a/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 "./models"; - -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/src/stores/room-list/RoomListLayoutStore.ts b/src/stores/room-list/RoomListLayoutStore.ts deleted file mode 100644 index c7b2b7f31e..0000000000 --- a/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 "./models"; -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/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts deleted file mode 100644 index 3acd29b900..0000000000 --- a/src/stores/room-list/RoomListStore.ts +++ /dev/null @@ -1,643 +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 { DefaultTagID, OrderedDefaultTagIDs, RoomUpdateCause, type TagID } 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 { VisibilityProvider } from "./filters/VisibilityProvider"; -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 "./utils/roomMute"; - -export const LISTS_UPDATE_EVENT = RoomListStoreEvent.ListsUpdate; - -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 (!VisibilityProvider.instance.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) => VisibilityProvider.instance.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()); - } - } - - /** - * Gets the tags for a room identified by the store. The returned set - * should never be empty, and will contain DefaultTagID.Untagged if - * the store is not aware of any tags. - * @param room The room to get the tags for. - * @returns The tags for the room. - */ - public getTagsForRoom(room: Room): TagID[] { - const algorithmTags = this.algorithm.getTagsForRoom(room); - if (!algorithmTags) return [DefaultTagID.Untagged]; - return algorithmTags; - } - - 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/src/stores/room-list/SpaceWatcher.ts b/src/stores/room-list/SpaceWatcher.ts deleted file mode 100644 index 83d2b1a5db..0000000000 --- a/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/src/stores/room-list/algorithms/Algorithm.ts b/src/stores/room-list/algorithms/Algorithm.ts deleted file mode 100644 index 5419a6e47c..0000000000 --- a/src/stores/room-list/algorithms/Algorithm.ts +++ /dev/null @@ -1,770 +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 { JoinRule, 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, RoomUpdateCause, type TagID } from "../models"; -import { - type IListOrderingMap, - type IOrderingAlgorithmMap, - type ITagMap, - type ITagSortingMap, - type ListAlgorithm, - type SortAlgorithm, -} from "./models"; -import { - EffectiveMembership, - getEffectiveMembership, - getEffectiveMembershipTag, - splitRoomsByMembership, -} from "../../../utils/membership"; -import { type OrderingAlgorithm } from "./list-ordering/OrderingAlgorithm"; -import { getListAlgorithmInstance } from "./list-ordering"; -import { VisibilityProvider } from "../filters/VisibilityProvider"; -import { CallStore, CallStoreEvent } from "../../CallStore"; - -/** - * 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 && !VisibilityProvider.instance.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 = this.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); - } - } - } - } - - public getTagsForRoom(room: Room): TagID[] { - const tags: TagID[] = []; - - if (!getEffectiveMembership(room.getMyMembership())) return []; // peeked room has no tags - - const membership = getEffectiveMembershipTag(room); - - if (membership === EffectiveMembership.Invite) { - tags.push(DefaultTagID.Invite); - } else if (membership === EffectiveMembership.Leave) { - tags.push(DefaultTagID.Archived); - } else { - tags.push(...this.getTagsOfJoinedRoom(room)); - } - - if (!tags.length) tags.push(DefaultTagID.Untagged); - - return tags; - } - - private getTagsOfJoinedRoom(room: Room): TagID[] { - let tags = Object.keys(room.tags || {}); - - if (tags.length === 0) { - // Check to see if it's a DM if it isn't anything else - if (DMRoomMap.shared().getUserIdForRoomId(room.roomId)) { - tags = [DefaultTagID.DM]; - } - } - if (room.isCallRoom() && (room.getJoinRule() === JoinRule.Public || room.getJoinRule() === JoinRule.Knock)) { - tags.push(DefaultTagID.Conference); - } - - return tags; - } - - /** - * 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 = this.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 = this.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/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts b/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts deleted file mode 100644 index 5bd26c273f..0000000000 --- a/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts +++ /dev/null @@ -1,311 +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 { RoomUpdateCause, type TagID } from "../../models"; -import { 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 { - if (this.sortingAlgorithm === SortAlgorithm.Manual) { - this.cachedOrderedRooms = sortRoomsWithAlgorithm(rooms, this.tagId, this.sortingAlgorithm); - } else { - // Every other sorting type affects the categories, not the whole tag. - 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; - } - - if (this.sortingAlgorithm === SortAlgorithm.Manual) { - return false; // Nothing to do here. - } - - 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/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts b/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts deleted file mode 100644 index 08014822ea..0000000000 --- a/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts +++ /dev/null @@ -1,203 +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 { RoomUpdateCause, type TagID } 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/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts b/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts deleted file mode 100644 index 0f95fe78c2..0000000000 --- a/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts +++ /dev/null @@ -1,83 +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, type TagID } from "../../models"; -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/src/stores/room-list/algorithms/list-ordering/index.ts b/src/stores/room-list/algorithms/list-ordering/index.ts deleted file mode 100644 index c492ddf4a9..0000000000 --- a/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 "../../models"; -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/src/stores/room-list/algorithms/models.ts b/src/stores/room-list/algorithms/models.ts deleted file mode 100644 index 710c4f15a8..0000000000 --- a/src/stores/room-list/algorithms/models.ts +++ /dev/null @@ -1,46 +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 "../models"; -import { type OrderingAlgorithm } from "./list-ordering/OrderingAlgorithm"; - -export enum SortAlgorithm { - Manual = "MANUAL", - 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/src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm.ts b/src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm.ts deleted file mode 100644 index 759d578c96..0000000000 --- a/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 "../../models"; -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/src/stores/room-list/algorithms/tag-sorting/IAlgorithm.ts b/src/stores/room-list/algorithms/tag-sorting/IAlgorithm.ts deleted file mode 100644 index cc0d0b5cc7..0000000000 --- a/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 "../../models"; - -/** - * 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/src/stores/room-list/algorithms/tag-sorting/ManualAlgorithm.ts b/src/stores/room-list/algorithms/tag-sorting/ManualAlgorithm.ts deleted file mode 100644 index b880b6baaf..0000000000 --- a/src/stores/room-list/algorithms/tag-sorting/ManualAlgorithm.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 "../../models"; -import { type IAlgorithm } from "./IAlgorithm"; - -/** - * Sorts rooms according to the tag's `order` property on the room. - */ -export class ManualAlgorithm implements IAlgorithm { - public sortRooms(rooms: Room[], tagId: TagID): Room[] { - const getOrderProp = (r: Room): number => r.tags[tagId].order || 0; - return rooms.sort((a, b) => { - return getOrderProp(a) - getOrderProp(b); - }); - } -} diff --git a/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts b/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts deleted file mode 100644 index e41eeee816..0000000000 --- a/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 "../../models"; -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/src/stores/room-list/algorithms/tag-sorting/index.ts b/src/stores/room-list/algorithms/tag-sorting/index.ts deleted file mode 100644 index bd728c821b..0000000000 --- a/src/stores/room-list/algorithms/tag-sorting/index.ts +++ /dev/null @@ -1,46 +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 { ManualAlgorithm } from "./ManualAlgorithm"; -import { type IAlgorithm } from "./IAlgorithm"; -import { type TagID } from "../../models"; -import { RecentAlgorithm } from "./RecentAlgorithm"; -import { AlphabeticAlgorithm } from "./AlphabeticAlgorithm"; - -const ALGORITHM_INSTANCES: { [algorithm in SortAlgorithm]: IAlgorithm } = { - [SortAlgorithm.Recent]: new RecentAlgorithm(), - [SortAlgorithm.Alphabetic]: new AlphabeticAlgorithm(), - [SortAlgorithm.Manual]: new ManualAlgorithm(), -}; - -/** - * 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/src/stores/room-list/filters/IFilterCondition.ts b/src/stores/room-list/filters/IFilterCondition.ts deleted file mode 100644 index 8dd77fd4b9..0000000000 --- a/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/src/stores/room-list/filters/SpaceFilterCondition.ts b/src/stores/room-list/filters/SpaceFilterCondition.ts deleted file mode 100644 index 02e20e9103..0000000000 --- a/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/src/stores/room-list/filters/VisibilityProvider.ts b/src/stores/room-list/filters/VisibilityProvider.ts deleted file mode 100644 index 178a1ed553..0000000000 --- a/src/stores/room-list/filters/VisibilityProvider.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. - */ - -import { type Room } from "matrix-js-sdk/src/matrix"; - -import { RoomListCustomisations } from "../../../customisations/RoomList"; -import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom"; - -export class VisibilityProvider { - private static internalInstance: VisibilityProvider; - - private constructor() {} - - public static get instance(): VisibilityProvider { - if (!VisibilityProvider.internalInstance) { - VisibilityProvider.internalInstance = new VisibilityProvider(); - } - return VisibilityProvider.internalInstance; - } - - public isRoomVisible(room?: Room): boolean { - if (!room) { - return false; - } - - // hide space rooms as they'll be shown in the SpacePanel - if (room.isSpaceRoom()) { - return false; - } - - if (isLocalRoom(room)) { - // local rooms shouldn't show up anywhere - return false; - } - - const isVisibleFn = RoomListCustomisations.isRoomVisible; - if (isVisibleFn) { - return isVisibleFn(room); - } - - return true; // default - } -} diff --git a/src/stores/room-list/models.ts b/src/stores/room-list/models.ts deleted file mode 100644 index 78823cbc42..0000000000 --- a/src/stores/room-list/models.ts +++ /dev/null @@ -1,42 +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. -*/ - -export enum DefaultTagID { - Invite = "im.vector.fake.invite", - Untagged = "im.vector.fake.recent", // legacy: used to just be 'recent rooms' but now it's all untagged rooms - Archived = "im.vector.fake.archived", - LowPriority = "m.lowpriority", - Favourite = "m.favourite", - DM = "im.vector.fake.direct", - Conference = "im.vector.fake.conferences", - ServerNotice = "m.server_notice", - Suggested = "im.vector.fake.suggested", -} - -export const OrderedDefaultTagIDs = [ - DefaultTagID.Invite, - DefaultTagID.Favourite, - DefaultTagID.DM, - DefaultTagID.Conference, - DefaultTagID.Untagged, - DefaultTagID.LowPriority, - DefaultTagID.ServerNotice, - DefaultTagID.Suggested, - DefaultTagID.Archived, -]; - -export type TagID = string | DefaultTagID; - -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/src/stores/room-list/previews/IPreview.ts b/src/stores/room-list/previews/IPreview.ts deleted file mode 100644 index bdc5912821..0000000000 --- a/src/stores/room-list/previews/IPreview.ts +++ /dev/null @@ -1,25 +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 MatrixEvent } from "matrix-js-sdk/src/matrix"; - -import { type TagID } from "../models"; - -/** - * Represents an event preview. - */ -export interface IPreview { - /** - * Gets the text which represents the event as a preview. - * @param event The event to preview. - * @param tagId Optional. The tag where the room the event was sent in resides. - * @param isThread Optional. Whether the preview being generated is for a thread summary. - * @returns The preview. - */ - getTextFor(event: MatrixEvent, tagId?: TagID, isThread?: boolean): string | null; -} diff --git a/src/stores/room-list/previews/LegacyCallAnswerEventPreview.ts b/src/stores/room-list/previews/LegacyCallAnswerEventPreview.ts deleted file mode 100644 index 8dda1f64bb..0000000000 --- a/src/stores/room-list/previews/LegacyCallAnswerEventPreview.ts +++ /dev/null @@ -1,28 +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 MatrixEvent } from "matrix-js-sdk/src/matrix"; - -import { type IPreview } from "./IPreview"; -import { type TagID } from "../models"; -import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils"; -import { _t } from "../../../languageHandler"; - -export class LegacyCallAnswerEventPreview implements IPreview { - public getTextFor(event: MatrixEvent, tagId?: TagID): string { - if (shouldPrefixMessagesIn(event.getRoomId()!, tagId)) { - if (isSelf(event)) { - return _t("event_preview|m.call.answer|you"); - } else { - return _t("event_preview|m.call.answer|user", { senderName: getSenderName(event) }); - } - } else { - return _t("event_preview|m.call.answer|dm"); - } - } -} diff --git a/src/stores/room-list/previews/LegacyCallHangupEvent.ts b/src/stores/room-list/previews/LegacyCallHangupEvent.ts deleted file mode 100644 index 3088c9590c..0000000000 --- a/src/stores/room-list/previews/LegacyCallHangupEvent.ts +++ /dev/null @@ -1,28 +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 MatrixEvent } from "matrix-js-sdk/src/matrix"; - -import { type IPreview } from "./IPreview"; -import { type TagID } from "../models"; -import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils"; -import { _t } from "../../../languageHandler"; - -export class LegacyCallHangupEvent implements IPreview { - public getTextFor(event: MatrixEvent, tagId?: TagID): string { - if (shouldPrefixMessagesIn(event.getRoomId()!, tagId)) { - if (isSelf(event)) { - return _t("event_preview|m.call.hangup|you"); - } else { - return _t("event_preview|m.call.hangup|user", { senderName: getSenderName(event) }); - } - } else { - return _t("timeline|m.call.hangup|dm"); - } - } -} diff --git a/src/stores/room-list/previews/LegacyCallInviteEventPreview.ts b/src/stores/room-list/previews/LegacyCallInviteEventPreview.ts deleted file mode 100644 index 14ff23456e..0000000000 --- a/src/stores/room-list/previews/LegacyCallInviteEventPreview.ts +++ /dev/null @@ -1,32 +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 MatrixEvent } from "matrix-js-sdk/src/matrix"; - -import { type IPreview } from "./IPreview"; -import { type TagID } from "../models"; -import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils"; -import { _t } from "../../../languageHandler"; - -export class LegacyCallInviteEventPreview implements IPreview { - public getTextFor(event: MatrixEvent, tagId?: TagID): string { - if (shouldPrefixMessagesIn(event.getRoomId()!, tagId)) { - if (isSelf(event)) { - return _t("event_preview|m.call.invite|you"); - } else { - return _t("event_preview|m.call.invite|user", { senderName: getSenderName(event) }); - } - } else { - if (isSelf(event)) { - return _t("event_preview|m.call.invite|dm_send"); - } else { - return _t("event_preview|m.call.invite|dm_receive", { senderName: getSenderName(event) }); - } - } - } -} diff --git a/src/stores/room-list/previews/PollStartEventPreview.ts b/src/stores/room-list/previews/PollStartEventPreview.ts deleted file mode 100644 index d614e736a3..0000000000 --- a/src/stores/room-list/previews/PollStartEventPreview.ts +++ /dev/null @@ -1,57 +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 MatrixEvent, type PollStartEventContent } from "matrix-js-sdk/src/matrix"; -import { InvalidEventError } from "matrix-js-sdk/src/extensible_events_v1/InvalidEventError"; -import { PollStartEvent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent"; - -import { type IPreview } from "./IPreview"; -import { type TagID } from "../models"; -import { _t, sanitizeForTranslation } from "../../../languageHandler"; -import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils"; -import MatrixClientContext from "../../../contexts/MatrixClientContext"; - -export class PollStartEventPreview implements IPreview { - public static contextType = MatrixClientContext; - declare public context: React.ContextType; - - public getTextFor(event: MatrixEvent, tagId?: TagID, isThread?: boolean): string | null { - let eventContent = event.getContent(); - - if (event.isRelation("m.replace")) { - // It's an edit, generate the preview on the new text - eventContent = event.getContent()["m.new_content"]; - } - - // Check we have the information we need, and bail out if not - if (!eventContent) { - return null; - } - - try { - const poll = new PollStartEvent({ - type: event.getType(), - content: eventContent as PollStartEventContent, - }); - - let question = poll.question.text.trim(); - question = sanitizeForTranslation(question); - - if (isThread || isSelf(event) || !shouldPrefixMessagesIn(event.getRoomId()!, tagId)) { - return question; - } else { - return _t("event_preview|m.text", { senderName: getSenderName(event), message: question }); - } - } catch (e) { - if (e instanceof InvalidEventError) { - return null; - } - throw e; // re-throw unknown errors - } - } -} diff --git a/src/stores/room-list/previews/ReactionEventPreview.ts b/src/stores/room-list/previews/ReactionEventPreview.ts deleted file mode 100644 index 15cb0c3489..0000000000 --- a/src/stores/room-list/previews/ReactionEventPreview.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. -*/ - -import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; - -import { type IPreview } from "./IPreview"; -import { type TagID } from "../models"; -import { getSenderName, isSelf } from "./utils"; -import { _t } from "../../../languageHandler"; -import { MatrixClientPeg } from "../../../MatrixClientPeg"; -import { MessagePreviewStore } from "../MessagePreviewStore"; - -export class ReactionEventPreview implements IPreview { - public getTextFor(event: MatrixEvent, tagId?: TagID, isThread?: boolean): string | null { - const roomId = event.getRoomId(); - if (!roomId) return null; // not a room event - - const relation = event.getRelation(); - if (!relation) return null; // invalid reaction (probably redacted) - - const reaction = relation.key; - if (!reaction) return null; // invalid reaction (unknown format) - - const cli = MatrixClientPeg.get(); - const room = cli?.getRoom(roomId); - const relatedEvent = relation.event_id ? room?.findEventById(relation.event_id) : null; - if (!relatedEvent) return null; - - const message = MessagePreviewStore.instance.generatePreviewForEvent(relatedEvent); - if (isSelf(event)) { - return _t("event_preview|m.reaction|you", { - reaction, - message, - }); - } - - return _t("event_preview|m.reaction|user", { - sender: getSenderName(event), - reaction, - message, - }); - } -} diff --git a/src/stores/room-list/previews/StickerEventPreview.ts b/src/stores/room-list/previews/StickerEventPreview.ts deleted file mode 100644 index 701b97ffc8..0000000000 --- a/src/stores/room-list/previews/StickerEventPreview.ts +++ /dev/null @@ -1,27 +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 MatrixEvent } from "matrix-js-sdk/src/matrix"; - -import { type IPreview } from "./IPreview"; -import { type TagID } from "../models"; -import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils"; -import { _t } from "../../../languageHandler"; - -export class StickerEventPreview implements IPreview { - public getTextFor(event: MatrixEvent, tagId?: TagID, isThread?: boolean): string | null { - const stickerName = event.getContent()["body"]; - if (!stickerName) return null; - - if (isThread || isSelf(event) || !shouldPrefixMessagesIn(event.getRoomId()!, tagId)) { - return stickerName; - } else { - return _t("event_preview|m.sticker", { senderName: getSenderName(event), stickerName }); - } - } -} diff --git a/src/stores/room-list/previews/utils.ts b/src/stores/room-list/previews/utils.ts deleted file mode 100644 index d9ce0512de..0000000000 --- a/src/stores/room-list/previews/utils.ts +++ /dev/null @@ -1,33 +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 MatrixEvent } from "matrix-js-sdk/src/matrix"; - -import { MatrixClientPeg } from "../../../MatrixClientPeg"; -import { DefaultTagID, type TagID } from "../models"; - -export function isSelf(event: MatrixEvent): boolean { - const selfUserId = MatrixClientPeg.safeGet().getSafeUserId(); - if (event.getType() === "m.room.member") { - return event.getStateKey() === selfUserId; - } - return event.getSender() === selfUserId; -} - -export function shouldPrefixMessagesIn(roomId: string, tagId?: TagID): boolean { - if (tagId !== DefaultTagID.DM) return true; - - // We don't prefix anything in 1:1s - const room = MatrixClientPeg.safeGet().getRoom(roomId); - if (!room) return true; - return room.currentState.getJoinedMemberCount() !== 2; -} - -export function getSenderName(event: MatrixEvent): string { - return event.sender?.name ?? event.getSender() ?? ""; -} diff --git a/src/stores/room-list/utils/roomMute.ts b/src/stores/room-list/utils/roomMute.ts deleted file mode 100644 index 47135edcba..0000000000 --- a/src/stores/room-list/utils/roomMute.ts +++ /dev/null @@ -1,46 +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 { type MatrixEvent, EventType, type IPushRules } from "matrix-js-sdk/src/matrix"; - -import { type ActionPayload } from "../../../dispatcher/payloads"; -import { isRuleMaybeRoomMuteRule } from "../../../RoomNotifs"; -import { arrayDiff } from "../../../utils/arrays"; - -/** - * Gets any changed push rules that are room specific overrides - * that mute notifications - * @param actionPayload - * @returns {string[]} ruleIds of added or removed rules - */ -export const getChangedOverrideRoomMutePushRules = (actionPayload: ActionPayload): string[] | undefined => { - if ( - actionPayload.action !== "MatrixActions.accountData" || - actionPayload.event?.getType() !== EventType.PushRules - ) { - return undefined; - } - const event = actionPayload.event as MatrixEvent; - const prevEvent = actionPayload.previousEvent as MatrixEvent | undefined; - - if (!event || !prevEvent) { - return undefined; - } - - const roomPushRules = (event.getContent() as IPushRules)?.global?.override?.filter(isRuleMaybeRoomMuteRule); - const prevRoomPushRules = (prevEvent?.getContent() as IPushRules)?.global?.override?.filter( - isRuleMaybeRoomMuteRule, - ); - - const { added, removed } = arrayDiff( - prevRoomPushRules?.map((rule) => rule.rule_id) || [], - roomPushRules?.map((rule) => rule.rule_id) || [], - ); - - return [...added, ...removed]; -}; diff --git a/src/stores/spaces/SpaceStore.ts b/src/stores/spaces/SpaceStore.ts index bf27c111dc..91f11351a3 100644 --- a/src/stores/spaces/SpaceStore.ts +++ b/src/stores/spaces/SpaceStore.ts @@ -24,12 +24,11 @@ import { logger } from "matrix-js-sdk/src/logger"; import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; import defaultDispatcher from "../../dispatcher/dispatcher"; -import RoomListStore from "../room-list/RoomListStore"; import SettingsStore from "../../settings/SettingsStore"; import DMRoomMap from "../../utils/DMRoomMap"; import { SpaceNotificationState } from "../notifications/SpaceNotificationState"; import { RoomNotificationStateStore } from "../notifications/RoomNotificationStateStore"; -import { DefaultTagID, type TagID } from "../room-list/models"; +import { DefaultTagID } from "../room-list/models"; import { EnhancedMap, mapDiff } from "../../utils/maps"; import { setDiff, setHasDiff } from "../../utils/sets"; import { Action } from "../../dispatcher/actions"; @@ -62,24 +61,10 @@ import { type SwitchSpacePayload } from "../../dispatcher/payloads/SwitchSpacePa import { type AfterLeaveRoomPayload } from "../../dispatcher/payloads/AfterLeaveRoomPayload"; import { SdkContextClass } from "../../contexts/SDKContext"; import { ModuleApi } from "../../modules/Api.ts"; +import RoomListStoreV3 from "../room-list-v3/RoomListStoreV3.ts"; const ACTIVE_SPACE_LS_KEY = "mx_active_space"; -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 metaSpaceOrder: MetaSpace[] = [MetaSpace.Home, MetaSpace.Orphans, MetaSpace.VideoRooms]; const MAX_SUGGESTED_ROOMS = 20; @@ -211,16 +196,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.getSortedRooms(); + 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/src/utils/membership.ts b/src/utils/membership.ts index 9880043e3d..79d54cd658 100644 --- a/src/utils/membership.ts +++ b/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/src/utils/room/tagRoom.ts b/src/utils/room/tagRoom.ts index 716dd959a8..f23b1f43e0 100644 --- a/src/utils/room/tagRoom.ts +++ b/src/utils/room/tagRoom.ts @@ -9,7 +9,6 @@ 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 RoomListStore from "../../stores/room-list/RoomListStore"; import { DefaultTagID, type TagID } from "../../stores/room-list/models"; import RoomListActions from "../../actions/RoomListActions"; import dis from "../../dispatcher/dispatcher"; diff --git a/test/unit-tests/components/structures/MatrixChat-test.tsx b/test/unit-tests/components/structures/MatrixChat-test.tsx index f950b5a44f..a8fc62278a 100644 --- a/test/unit-tests/components/structures/MatrixChat-test.tsx +++ b/test/unit-tests/components/structures/MatrixChat-test.tsx @@ -70,7 +70,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"; diff --git a/test/unit-tests/components/viewmodels/avatars/RoomAvatarViewModel-test.tsx b/test/unit-tests/components/viewmodels/avatars/RoomAvatarViewModel-test.tsx index 5f29f3ff33..9d4139cb4f 100644 --- a/test/unit-tests/components/viewmodels/avatars/RoomAvatarViewModel-test.tsx +++ b/test/unit-tests/components/viewmodels/avatars/RoomAvatarViewModel-test.tsx @@ -15,7 +15,6 @@ import { import { createTestClient, mkStubRoom } from "../../../../test-utils"; import DMRoomMap from "../../../../../src/utils/DMRoomMap"; import * as PresenceIndicatorModule from "../../../../../src/components/views/avatars/WithPresenceIndicator"; -import { DefaultTagID } from "../../../../../src/stores/room-list/models"; jest.mock("../../../../../src/utils/room/getJoinedNonFunctionalMembers", () => ({ getJoinedNonFunctionalMembers: jest.fn().mockReturnValue([]), diff --git a/test/unit-tests/components/viewmodels/right_panel/RoomSummaryCardViewModel-test.tsx b/test/unit-tests/components/viewmodels/right_panel/RoomSummaryCardViewModel-test.tsx index 0c5745a086..80bf222867 100644 --- a/test/unit-tests/components/viewmodels/right_panel/RoomSummaryCardViewModel-test.tsx +++ b/test/unit-tests/components/viewmodels/right_panel/RoomSummaryCardViewModel-test.tsx @@ -11,8 +11,6 @@ import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix"; import { useRoomSummaryCardViewModel } from "../../../../../src/components/viewmodels/right_panel/RoomSummaryCardViewModel"; import { mkStubRoom, stubClient, withClientContextRenderOptions } from "../../../../test-utils"; import defaultDispatcher from "../../../../../src/dispatcher/dispatcher"; -import RoomListStore from "../../../../../src/stores/room-list/RoomListStore"; -import { DefaultTagID } from "../../../../../src/stores/room-list/models"; import RightPanelStore from "../../../../../src/stores/right-panel/RightPanelStore"; import { RightPanelPhases } from "../../../../../src/stores/right-panel/RightPanelStorePhases"; import Modal from "../../../../../src/Modal"; diff --git a/test/unit-tests/components/viewmodels/roomlist/RoomListItemMenuViewModel-test.tsx b/test/unit-tests/components/viewmodels/roomlist/RoomListItemMenuViewModel-test.tsx index ddb3cba609..1e6d393ef7 100644 --- a/test/unit-tests/components/viewmodels/roomlist/RoomListItemMenuViewModel-test.tsx +++ b/test/unit-tests/components/viewmodels/roomlist/RoomListItemMenuViewModel-test.tsx @@ -16,7 +16,6 @@ import { hasAccessToOptionsMenu, } from "../../../../../src/components/viewmodels/roomlist/utils"; import DMRoomMap from "../../../../../src/utils/DMRoomMap"; -import { DefaultTagID } from "../../../../../src/stores/room-list/models"; import { useUnreadNotifications } from "../../../../../src/hooks/useUnreadNotifications"; import { NotificationLevel } from "../../../../../src/stores/notifications/NotificationLevel"; import { clearRoomNotification, setMarkedUnreadState } from "../../../../../src/utils/notifications"; diff --git a/test/unit-tests/components/views/context_menus/RoomGeneralContextMenu-test.tsx b/test/unit-tests/components/views/context_menus/RoomGeneralContextMenu-test.tsx index 6c71acc09e..0d7cc187f2 100644 --- a/test/unit-tests/components/views/context_menus/RoomGeneralContextMenu-test.tsx +++ b/test/unit-tests/components/views/context_menus/RoomGeneralContextMenu-test.tsx @@ -21,8 +21,6 @@ import { } from "../../../../../src/components/views/context_menus/RoomGeneralContextMenu"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; -import { DefaultTagID } from "../../../../../src/stores/room-list/models"; -import RoomListStore from "../../../../../src/stores/room-list/RoomListStore"; import DMRoomMap from "../../../../../src/utils/DMRoomMap"; import { mkMessage, stubClient } from "../../../../test-utils/test-utils"; import { shouldShowComponent } from "../../../../../src/customisations/helpers/UIComponents"; diff --git a/test/unit-tests/components/views/rooms/ExtraTile-test.tsx b/test/unit-tests/components/views/rooms/ExtraTile-test.tsx deleted file mode 100644 index a1a957dbce..0000000000 --- a/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/test/unit-tests/components/views/rooms/__snapshots__/ExtraTile-test.tsx.snap b/test/unit-tests/components/views/rooms/__snapshots__/ExtraTile-test.tsx.snap deleted file mode 100644 index e489a11bd5..0000000000 --- a/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/test/unit-tests/components/views/rooms/__snapshots__/RoomTile-test.tsx.snap b/test/unit-tests/components/views/rooms/__snapshots__/RoomTile-test.tsx.snap deleted file mode 100644 index a75c5829c7..0000000000 --- a/test/unit-tests/components/views/rooms/__snapshots__/RoomTile-test.tsx.snap +++ /dev/null @@ -1,282 +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 - -
-
-