mirror of
https://github.com/vector-im/element-web.git
synced 2026-05-05 20:26:19 +02:00
Iterate
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
parent
3be8719aa1
commit
d0235f83d8
@ -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 */
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
4
src/@types/global.d.ts
vendored
4
src/@types/global.d.ts
vendored
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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<IProps, IState> {
|
||||
private listContainerRef = createRef<HTMLDivElement>();
|
||||
private isDoingStickyHeaders = false;
|
||||
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
@ -58,34 +50,13 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
||||
}
|
||||
|
||||
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<IProps, IState> {
|
||||
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<HTMLDivElement>(".mx_RoomSublist:not(.mx_RoomSublist_hidden)");
|
||||
|
||||
// We track which styles we want on a target before making the changes to avoid
|
||||
// excessive layout updates.
|
||||
const targetStyles = new Map<
|
||||
HTMLDivElement,
|
||||
{
|
||||
stickyTop?: boolean;
|
||||
stickyBottom?: boolean;
|
||||
makeInvisible?: boolean;
|
||||
}
|
||||
>();
|
||||
|
||||
let lastTopHeader: HTMLDivElement | undefined;
|
||||
let firstBottomHeader: HTMLDivElement | undefined;
|
||||
for (const sublist of sublists) {
|
||||
const header = sublist.querySelector<HTMLDivElement>(".mx_RoomSublist_stickable");
|
||||
if (!header) continue; // this should never occur
|
||||
header.style.removeProperty("display"); // always clear display:none first
|
||||
|
||||
// When an element is <=40% off screen, make it take over
|
||||
const offScreenFactor = 0.4;
|
||||
const isOffTop = sublist.offsetTop + offScreenFactor * HEADER_HEIGHT <= topEdge;
|
||||
const isOffBottom = sublist.offsetTop + offScreenFactor * HEADER_HEIGHT >= bottomEdge;
|
||||
|
||||
if (isOffTop || sublist === sublists[0]) {
|
||||
targetStyles.set(header, { stickyTop: true });
|
||||
if (lastTopHeader) {
|
||||
lastTopHeader.style.display = "none";
|
||||
targetStyles.set(lastTopHeader, { makeInvisible: true });
|
||||
}
|
||||
lastTopHeader = header;
|
||||
} else if (isOffBottom && !firstBottomHeader) {
|
||||
targetStyles.set(header, { stickyBottom: true });
|
||||
firstBottomHeader = header;
|
||||
} else {
|
||||
targetStyles.set(header, {}); // nothing == clear
|
||||
}
|
||||
}
|
||||
|
||||
// Run over the style changes and make them reality. We check to see if we're about to
|
||||
// cause a no-op update, as adding/removing properties that are/aren't there cause
|
||||
// layout updates.
|
||||
for (const header of targetStyles.keys()) {
|
||||
const style = targetStyles.get(header)!;
|
||||
|
||||
if (style.makeInvisible) {
|
||||
// we will have already removed the 'display: none', so add it back.
|
||||
header.style.display = "none";
|
||||
continue; // nothing else to do, even if sticky somehow
|
||||
}
|
||||
|
||||
if (style.stickyTop) {
|
||||
if (!header.classList.contains("mx_RoomSublist_headerContainer_stickyTop")) {
|
||||
header.classList.add("mx_RoomSublist_headerContainer_stickyTop");
|
||||
}
|
||||
|
||||
const newTop = `${list.parentElement.offsetTop}px`;
|
||||
if (header.style.top !== newTop) {
|
||||
header.style.top = newTop;
|
||||
}
|
||||
} else {
|
||||
if (header.classList.contains("mx_RoomSublist_headerContainer_stickyTop")) {
|
||||
header.classList.remove("mx_RoomSublist_headerContainer_stickyTop");
|
||||
}
|
||||
if (header.style.top) {
|
||||
header.style.removeProperty("top");
|
||||
}
|
||||
}
|
||||
|
||||
if (style.stickyBottom) {
|
||||
if (!header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) {
|
||||
header.classList.add("mx_RoomSublist_headerContainer_stickyBottom");
|
||||
}
|
||||
|
||||
const offset =
|
||||
UIStore.instance.windowHeight - (list.parentElement.offsetTop + list.parentElement.offsetHeight);
|
||||
const newBottom = `${offset}px`;
|
||||
if (header.style.bottom !== newBottom) {
|
||||
header.style.bottom = newBottom;
|
||||
}
|
||||
} else {
|
||||
if (header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) {
|
||||
header.classList.remove("mx_RoomSublist_headerContainer_stickyBottom");
|
||||
}
|
||||
if (header.style.bottom) {
|
||||
header.style.removeProperty("bottom");
|
||||
}
|
||||
}
|
||||
|
||||
if (style.stickyTop || style.stickyBottom) {
|
||||
if (!header.classList.contains("mx_RoomSublist_headerContainer_sticky")) {
|
||||
header.classList.add("mx_RoomSublist_headerContainer_sticky");
|
||||
}
|
||||
|
||||
const listDimensions = UIStore.instance.getElementDimensions("ListContainer");
|
||||
if (listDimensions) {
|
||||
const headerRightMargin = 15; // calculated from margins and widths to align with non-sticky tiles
|
||||
const headerStickyWidth = listDimensions.width - headerRightMargin;
|
||||
const newWidth = `${headerStickyWidth}px`;
|
||||
if (header.style.width !== newWidth) {
|
||||
header.style.width = newWidth;
|
||||
}
|
||||
}
|
||||
} else if (!style.stickyTop && !style.stickyBottom) {
|
||||
if (header.classList.contains("mx_RoomSublist_headerContainer_sticky")) {
|
||||
header.classList.remove("mx_RoomSublist_headerContainer_sticky");
|
||||
}
|
||||
|
||||
if (header.style.width) {
|
||||
header.style.removeProperty("width");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// add appropriate sticky classes to wrapper so it has
|
||||
// the necessary top/bottom padding to put the sticky header in
|
||||
const listWrapper = list.parentElement; // .mx_LeftPanel_roomListWrapper
|
||||
if (!listWrapper) return;
|
||||
if (lastTopHeader) {
|
||||
listWrapper.classList.add("mx_LeftPanel_roomListWrapper_stickyTop");
|
||||
} else {
|
||||
listWrapper.classList.remove("mx_LeftPanel_roomListWrapper_stickyTop");
|
||||
}
|
||||
if (firstBottomHeader) {
|
||||
listWrapper.classList.add("mx_LeftPanel_roomListWrapper_stickyBottom");
|
||||
} else {
|
||||
listWrapper.classList.remove("mx_LeftPanel_roomListWrapper_stickyBottom");
|
||||
}
|
||||
}
|
||||
|
||||
private onScroll = (ev: Event): void => {
|
||||
const list = ev.target as HTMLDivElement;
|
||||
this.handleStickyHeaders(list);
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const containerClasses = classNames({
|
||||
mx_LeftPanel: true,
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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<IProps, IState> {
|
||||
}
|
||||
|
||||
if (room) {
|
||||
// Legacy room list store needs to be told to manually remove this room
|
||||
RoomListStore.instance.manualRoomUpdate(room, RoomUpdateCause.RoomRemoved);
|
||||
// New room list store will remove the room on the following dispatch
|
||||
dis.dispatch<AfterForgetRoomPayload>({ action: Action.AfterForgetRoom, room });
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -1,87 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020-2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX } from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex";
|
||||
import NotificationBadge from "./NotificationBadge";
|
||||
import { type NotificationState } from "../../../stores/notifications/NotificationState";
|
||||
import { type ButtonEvent } from "../elements/AccessibleButton";
|
||||
import useHover from "../../../hooks/useHover";
|
||||
|
||||
interface ExtraTileProps {
|
||||
isMinimized: boolean;
|
||||
isSelected: boolean;
|
||||
displayName: string;
|
||||
avatar: React.ReactElement;
|
||||
notificationState?: NotificationState;
|
||||
onClick: (ev: ButtonEvent) => void;
|
||||
}
|
||||
|
||||
export default function ExtraTile({
|
||||
isSelected,
|
||||
isMinimized,
|
||||
notificationState,
|
||||
displayName,
|
||||
onClick,
|
||||
avatar,
|
||||
}: ExtraTileProps): JSX.Element {
|
||||
const [, { onMouseOver, onMouseLeave }] = useHover(() => false);
|
||||
|
||||
// XXX: We copy classes because it's easier
|
||||
const classes = classNames({
|
||||
mx_ExtraTile: true,
|
||||
mx_RoomTile: true,
|
||||
mx_RoomTile_selected: isSelected,
|
||||
mx_RoomTile_minimized: isMinimized,
|
||||
});
|
||||
|
||||
let badge: JSX.Element | null = null;
|
||||
if (notificationState) {
|
||||
badge = <NotificationBadge notification={notificationState} />;
|
||||
}
|
||||
|
||||
let name = displayName;
|
||||
if (typeof name !== "string") name = "";
|
||||
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
|
||||
|
||||
const nameClasses = classNames({
|
||||
mx_RoomTile_title: true,
|
||||
mx_RoomTile_titleHasUnreadEvents: notificationState?.isUnread,
|
||||
});
|
||||
|
||||
let nameContainer: JSX.Element | null = (
|
||||
<div className="mx_RoomTile_titleContainer">
|
||||
<div title={name} className={nameClasses} tabIndex={-1} dir="auto">
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (isMinimized) nameContainer = null;
|
||||
|
||||
return (
|
||||
<RovingAccessibleButton
|
||||
className={classes}
|
||||
onMouseEnter={onMouseOver}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onClick={onClick}
|
||||
role="treeitem"
|
||||
title={name}
|
||||
disableTooltip={!isMinimized}
|
||||
>
|
||||
<div className="mx_RoomTile_avatarContainer">{avatar}</div>
|
||||
<div className="mx_RoomTile_details">
|
||||
<div className="mx_RoomTile_primaryDetails">
|
||||
{nameContainer}
|
||||
<div className="mx_RoomTile_badgeContainer">{badge}</div>
|
||||
</div>
|
||||
</div>
|
||||
</RovingAccessibleButton>
|
||||
);
|
||||
}
|
||||
@ -1,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];
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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<void>;
|
||||
|
||||
/**
|
||||
* Removes a filter condition from the room list store. If the filter was
|
||||
* not previously added to the room list store, this will no-op. The effects
|
||||
* of removing a filter may be applied async and therefore might not cause
|
||||
* an update right away.
|
||||
* @param {IFilterCondition} filter The filter condition to remove.
|
||||
*/
|
||||
removeFilter(filter: IFilterCondition): void;
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
}
|
||||
@ -1,111 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type TagID } from "./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 = <ISerializedListLayout>JSON.parse(serialized);
|
||||
this._n = parsed.numTiles;
|
||||
this._previews = parsed.showPreviews;
|
||||
this._collapsed = parsed.collapsed;
|
||||
}
|
||||
}
|
||||
|
||||
public get isCollapsed(): boolean {
|
||||
return this._collapsed;
|
||||
}
|
||||
|
||||
public set isCollapsed(v: boolean) {
|
||||
this._collapsed = v;
|
||||
this.save();
|
||||
}
|
||||
|
||||
public get showPreviews(): boolean {
|
||||
return this._previews;
|
||||
}
|
||||
|
||||
public set showPreviews(v: boolean) {
|
||||
this._previews = v;
|
||||
this.save();
|
||||
}
|
||||
|
||||
public get tileHeight(): number {
|
||||
return TILE_HEIGHT_PX;
|
||||
}
|
||||
|
||||
private get key(): string {
|
||||
return `mx_sublist_layout_${this.tagId}_boxed`;
|
||||
}
|
||||
|
||||
public get visibleTiles(): number {
|
||||
if (this._n === 0) return this.defaultVisibleTiles;
|
||||
return Math.max(this._n, this.minVisibleTiles);
|
||||
}
|
||||
|
||||
public set visibleTiles(v: number) {
|
||||
this._n = v;
|
||||
this.save();
|
||||
}
|
||||
|
||||
public get minVisibleTiles(): number {
|
||||
return 1;
|
||||
}
|
||||
|
||||
public get defaultVisibleTiles(): number {
|
||||
// This number is what "feels right", and mostly subject to design's opinion.
|
||||
return 8;
|
||||
}
|
||||
|
||||
public tilesWithPadding(n: number, paddingPx: number): number {
|
||||
return this.pixelsToTiles(this.tilesToPixelsWithPadding(n, paddingPx));
|
||||
}
|
||||
|
||||
public tilesToPixelsWithPadding(n: number, paddingPx: number): number {
|
||||
return this.tilesToPixels(n) + paddingPx;
|
||||
}
|
||||
|
||||
public tilesToPixels(n: number): number {
|
||||
return n * this.tileHeight;
|
||||
}
|
||||
|
||||
public pixelsToTiles(px: number): number {
|
||||
return px / this.tileHeight;
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
localStorage.removeItem(this.key);
|
||||
}
|
||||
|
||||
private save(): void {
|
||||
localStorage.setItem(this.key, JSON.stringify(this.serialize()));
|
||||
}
|
||||
|
||||
private serialize(): ISerializedListLayout {
|
||||
return {
|
||||
numTiles: this.visibleTiles,
|
||||
showPreviews: this.showPreviews,
|
||||
collapsed: this.isCollapsed,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,65 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { type EmptyObject } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { type TagID } from "./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<EmptyObject> {
|
||||
private static internalInstance: RoomListLayoutStore;
|
||||
|
||||
private readonly layoutMap = new Map<TagID, ListLayout>();
|
||||
|
||||
public constructor() {
|
||||
super(defaultDispatcher);
|
||||
}
|
||||
|
||||
public static get instance(): RoomListLayoutStore {
|
||||
if (!this.internalInstance) {
|
||||
this.internalInstance = new RoomListLayoutStore();
|
||||
this.internalInstance.start();
|
||||
}
|
||||
return RoomListLayoutStore.internalInstance;
|
||||
}
|
||||
|
||||
public ensureLayoutExists(tagId: TagID): void {
|
||||
if (!this.layoutMap.has(tagId)) {
|
||||
this.layoutMap.set(tagId, new ListLayout(tagId));
|
||||
}
|
||||
}
|
||||
|
||||
public getLayoutFor(tagId: TagID): ListLayout {
|
||||
if (!this.layoutMap.has(tagId)) {
|
||||
this.layoutMap.set(tagId, new ListLayout(tagId));
|
||||
}
|
||||
return this.layoutMap.get(tagId)!;
|
||||
}
|
||||
|
||||
// Note: this primarily exists for debugging, and isn't really intended to be used by anything.
|
||||
public async resetLayouts(): Promise<void> {
|
||||
logger.warn("Resetting layouts for room list");
|
||||
for (const layout of this.layoutMap.values()) {
|
||||
layout.reset();
|
||||
}
|
||||
}
|
||||
|
||||
protected async onNotReady(): Promise<any> {
|
||||
// On logout, clear the map.
|
||||
this.layoutMap.clear();
|
||||
}
|
||||
|
||||
// We don't need this function, but our contract says we do
|
||||
protected async onAction(payload: ActionPayload): Promise<void> {}
|
||||
}
|
||||
|
||||
window.mxRoomListLayoutStore = RoomListLayoutStore.instance;
|
||||
@ -1,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<EmptyObject> implements Interface {
|
||||
/**
|
||||
* Set to true if you're running tests on the store. Should not be touched in
|
||||
* any other environment.
|
||||
*/
|
||||
public static TEST_MODE = false;
|
||||
|
||||
private initialListsGenerated = false;
|
||||
private msc3946ProcessDynamicPredecessor: boolean;
|
||||
private msc3946SettingWatcherRef: string;
|
||||
private algorithm = new Algorithm();
|
||||
private prefilterConditions: IFilterCondition[] = [];
|
||||
private updateFn = new MarkedExecution(() => {
|
||||
for (const tagId of Object.keys(this.orderedLists)) {
|
||||
RoomNotificationStateStore.instance.getListState(tagId).setRooms(this.orderedLists[tagId]);
|
||||
}
|
||||
this.emit(LISTS_UPDATE_EVENT);
|
||||
});
|
||||
|
||||
public constructor(dis: MatrixDispatcher) {
|
||||
super(dis);
|
||||
this.setMaxListeners(20); // RoomList + LeftPanel + 8xRoomSubList + spares
|
||||
this.algorithm.start();
|
||||
|
||||
this.msc3946ProcessDynamicPredecessor = SettingsStore.getValue("feature_dynamic_room_predecessors");
|
||||
this.msc3946SettingWatcherRef = SettingsStore.watchSetting(
|
||||
"feature_dynamic_room_predecessors",
|
||||
null,
|
||||
(_settingName, _roomId, _level, _newValAtLevel, newVal) => {
|
||||
this.msc3946ProcessDynamicPredecessor = newVal;
|
||||
this.regenerateAllLists({ trigger: true });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
SettingsStore.unwatchSetting(this.msc3946SettingWatcherRef);
|
||||
}
|
||||
|
||||
private setupWatchers(): void {
|
||||
// TODO: Maybe destroy this if this class supports destruction
|
||||
new SpaceWatcher(this);
|
||||
}
|
||||
|
||||
public get orderedLists(): ITagMap {
|
||||
if (!this.algorithm) return {}; // No tags yet.
|
||||
return this.algorithm.getOrderedRooms();
|
||||
}
|
||||
|
||||
// Intended for test usage
|
||||
public async resetStore(): Promise<void> {
|
||||
await this.reset();
|
||||
this.prefilterConditions = [];
|
||||
this.initialListsGenerated = false;
|
||||
|
||||
this.algorithm.off(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated);
|
||||
this.algorithm.off(FILTER_CHANGED, this.onAlgorithmListUpdated);
|
||||
this.algorithm.stop();
|
||||
this.algorithm = new Algorithm();
|
||||
this.algorithm.on(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated);
|
||||
this.algorithm.on(FILTER_CHANGED, this.onAlgorithmListUpdated);
|
||||
|
||||
// Reset state without causing updates as the client will have been destroyed
|
||||
// and downstream code will throw NPE errors.
|
||||
await this.reset(null, true);
|
||||
}
|
||||
|
||||
// Public for test usage. Do not call this.
|
||||
public async makeReady(forcedClient?: MatrixClient): Promise<void> {
|
||||
if (forcedClient) {
|
||||
this.readyStore.useUnitTestClient(forcedClient);
|
||||
}
|
||||
|
||||
SdkContextClass.instance.roomViewStore.addListener(UPDATE_EVENT, () => this.handleRVSUpdate({}));
|
||||
this.algorithm.on(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated);
|
||||
this.algorithm.on(FILTER_CHANGED, this.onAlgorithmFilterUpdated);
|
||||
this.setupWatchers();
|
||||
|
||||
// Update any settings here, as some may have happened before we were logically ready.
|
||||
logger.log("Regenerating room lists: Startup");
|
||||
this.updateAlgorithmInstances();
|
||||
this.regenerateAllLists({ trigger: false });
|
||||
this.handleRVSUpdate({ trigger: false }); // fake an RVS update to adjust sticky room, if needed
|
||||
|
||||
this.updateFn.mark(); // we almost certainly want to trigger an update.
|
||||
this.updateFn.trigger();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles suspected RoomViewStore changes.
|
||||
* @param trigger Set to false to prevent a list update from being sent. Should only
|
||||
* be used if the calling code will manually trigger the update.
|
||||
*/
|
||||
private handleRVSUpdate({ trigger = true }): void {
|
||||
if (!this.matrixClient) return; // We assume there won't be RVS updates without a client
|
||||
|
||||
const activeRoomId = SdkContextClass.instance.roomViewStore.getRoomId();
|
||||
if (!activeRoomId && this.algorithm.stickyRoom) {
|
||||
this.algorithm.setStickyRoom(null);
|
||||
} else if (activeRoomId) {
|
||||
const activeRoom = this.matrixClient.getRoom(activeRoomId);
|
||||
if (!activeRoom) {
|
||||
logger.warn(`${activeRoomId} is current in RVS but missing from client - clearing sticky room`);
|
||||
this.algorithm.setStickyRoom(null);
|
||||
} else if (activeRoom !== this.algorithm.stickyRoom) {
|
||||
this.algorithm.setStickyRoom(activeRoom);
|
||||
}
|
||||
}
|
||||
|
||||
if (trigger) this.updateFn.trigger();
|
||||
}
|
||||
|
||||
protected async onReady(): Promise<any> {
|
||||
await this.makeReady();
|
||||
}
|
||||
|
||||
protected async onNotReady(): Promise<any> {
|
||||
await this.resetStore();
|
||||
}
|
||||
|
||||
protected async onAction(payload: ActionPayload): Promise<void> {
|
||||
// If we're not remotely ready, don't even bother scheduling the dispatch handling.
|
||||
// This is repeated in the handler just in case things change between a decision here and
|
||||
// when the timer fires.
|
||||
const logicallyReady = this.matrixClient && this.initialListsGenerated;
|
||||
if (!logicallyReady) return;
|
||||
|
||||
// When we're running tests we can't reliably use setImmediate out of timing concerns.
|
||||
// As such, we use a more synchronous model.
|
||||
if (RoomListStoreClass.TEST_MODE) {
|
||||
await this.onDispatchAsync(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
// We do this to intentionally break out of the current event loop task, allowing
|
||||
// us to instead wait for a more convenient time to run our updates.
|
||||
setTimeout(() => this.onDispatchAsync(payload));
|
||||
}
|
||||
|
||||
protected async onDispatchAsync(payload: ActionPayload): Promise<void> {
|
||||
// Everything here requires a MatrixClient or some sort of logical readiness.
|
||||
if (!this.matrixClient || !this.initialListsGenerated) return;
|
||||
|
||||
if (!this.algorithm) {
|
||||
// This shouldn't happen because `initialListsGenerated` implies we have an algorithm.
|
||||
throw new Error("Room list store has no algorithm to process dispatcher update with");
|
||||
}
|
||||
|
||||
if (payload.action === "MatrixActions.Room.receipt") {
|
||||
// First see if the receipt event is for our own user. If it was, trigger
|
||||
// a room update (we probably read the room on a different device).
|
||||
if (readReceiptChangeIsFor(payload.event, this.matrixClient)) {
|
||||
const room = payload.room;
|
||||
if (!room) {
|
||||
logger.warn(`Own read receipt was in unknown room ${room.roomId}`);
|
||||
return;
|
||||
}
|
||||
await this.handleRoomUpdate(room, RoomUpdateCause.ReadReceipt);
|
||||
this.updateFn.trigger();
|
||||
return;
|
||||
}
|
||||
} else if (payload.action === "MatrixActions.Room.tags") {
|
||||
const roomPayload = <any>payload; // TODO: Type out the dispatcher types
|
||||
await this.handleRoomUpdate(roomPayload.room, RoomUpdateCause.PossibleTagChange);
|
||||
this.updateFn.trigger();
|
||||
} else if (payload.action === "MatrixActions.Room.timeline") {
|
||||
const eventPayload = <IRoomTimelineActionPayload>payload;
|
||||
|
||||
// Ignore non-live events (backfill) and notification timeline set events (without a room)
|
||||
if (!eventPayload.isLiveEvent || !eventPayload.isLiveUnfilteredRoomTimelineEvent || !eventPayload.room) {
|
||||
return;
|
||||
}
|
||||
|
||||
const roomId = eventPayload.event.getRoomId();
|
||||
const room = this.matrixClient.getRoom(roomId);
|
||||
const tryUpdate = async (updatedRoom: Room): Promise<void> => {
|
||||
if (
|
||||
eventPayload.event.getType() === EventType.RoomTombstone &&
|
||||
eventPayload.event.getStateKey() === ""
|
||||
) {
|
||||
const newRoom = this.matrixClient?.getRoom(eventPayload.event.getContent()["replacement_room"]);
|
||||
if (newRoom) {
|
||||
// If we have the new room, then the new room check will have seen the predecessor
|
||||
// and did the required updates, so do nothing here.
|
||||
return;
|
||||
}
|
||||
}
|
||||
// If the join rule changes we need to update the tags for the room.
|
||||
// A conference tag is determined by the room public join rule.
|
||||
if (eventPayload.event.getType() === EventType.RoomJoinRules)
|
||||
await this.handleRoomUpdate(updatedRoom, RoomUpdateCause.PossibleTagChange);
|
||||
else await this.handleRoomUpdate(updatedRoom, RoomUpdateCause.Timeline);
|
||||
|
||||
this.updateFn.trigger();
|
||||
};
|
||||
if (!room) {
|
||||
logger.warn(`Live timeline event ${eventPayload.event.getId()} received without associated room`);
|
||||
logger.warn(`Queuing failed room update for retry as a result.`);
|
||||
window.setTimeout(async (): Promise<void> => {
|
||||
const updatedRoom = this.matrixClient?.getRoom(roomId);
|
||||
|
||||
if (updatedRoom) {
|
||||
await tryUpdate(updatedRoom);
|
||||
}
|
||||
}, 100); // 100ms should be enough for the room to show up
|
||||
return;
|
||||
} else {
|
||||
await tryUpdate(room);
|
||||
}
|
||||
} else if (payload.action === "MatrixActions.Event.decrypted") {
|
||||
const eventPayload = <any>payload; // TODO: Type out the dispatcher types
|
||||
const roomId = eventPayload.event.getRoomId();
|
||||
if (!roomId) {
|
||||
return;
|
||||
}
|
||||
const room = this.matrixClient.getRoom(roomId);
|
||||
if (!room) {
|
||||
logger.warn(`Event ${eventPayload.event.getId()} was decrypted in an unknown room ${roomId}`);
|
||||
return;
|
||||
}
|
||||
await this.handleRoomUpdate(room, RoomUpdateCause.Timeline);
|
||||
this.updateFn.trigger();
|
||||
} else if (payload.action === "MatrixActions.accountData" && payload.event_type === EventType.Direct) {
|
||||
const eventPayload = <any>payload; // TODO: Type out the dispatcher types
|
||||
const dmMap = eventPayload.event.getContent();
|
||||
for (const userId of Object.keys(dmMap)) {
|
||||
const roomIds = dmMap[userId];
|
||||
for (const roomId of roomIds) {
|
||||
const room = this.matrixClient.getRoom(roomId);
|
||||
if (!room) {
|
||||
logger.warn(`${roomId} was found in DMs but the room is not in the store`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// We expect this RoomUpdateCause to no-op if there's no change, and we don't expect
|
||||
// the user to have hundreds of rooms to update in one event. As such, we just hammer
|
||||
// away at updates until the problem is solved. If we were expecting more than a couple
|
||||
// of rooms to be updated at once, we would consider batching the rooms up.
|
||||
await this.handleRoomUpdate(room, RoomUpdateCause.PossibleTagChange);
|
||||
}
|
||||
}
|
||||
this.updateFn.trigger();
|
||||
} else if (payload.action === "MatrixActions.Room.myMembership") {
|
||||
this.onDispatchMyMembership(<any>payload);
|
||||
return;
|
||||
}
|
||||
|
||||
const possibleMuteChangeRoomIds = getChangedOverrideRoomMutePushRules(payload);
|
||||
if (possibleMuteChangeRoomIds) {
|
||||
for (const roomId of possibleMuteChangeRoomIds) {
|
||||
const room = roomId && this.matrixClient.getRoom(roomId);
|
||||
if (room) {
|
||||
await this.handleRoomUpdate(room, RoomUpdateCause.PossibleMuteChange);
|
||||
}
|
||||
}
|
||||
this.updateFn.trigger();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a MatrixActions.Room.myMembership event from the dispatcher.
|
||||
*
|
||||
* Public for test.
|
||||
*/
|
||||
public async onDispatchMyMembership(membershipPayload: any): Promise<void> {
|
||||
// TODO: Type out the dispatcher types so membershipPayload is not any
|
||||
const oldMembership = getEffectiveMembership(membershipPayload.oldMembership);
|
||||
const newMembership = getEffectiveMembershipTag(membershipPayload.room, membershipPayload.membership);
|
||||
if (oldMembership !== EffectiveMembership.Join && newMembership === EffectiveMembership.Join) {
|
||||
// If we're joining an upgraded room, we'll want to make sure we don't proliferate the dead room in the list.
|
||||
const room: Room = membershipPayload.room;
|
||||
const roomUpgradeHistory = room.client.getRoomUpgradeHistory(
|
||||
room.roomId,
|
||||
true,
|
||||
this.msc3946ProcessDynamicPredecessor,
|
||||
);
|
||||
const predecessors = roomUpgradeHistory.slice(0, roomUpgradeHistory.indexOf(room));
|
||||
for (const predecessor of predecessors) {
|
||||
const isSticky = this.algorithm.stickyRoom === predecessor;
|
||||
if (isSticky) {
|
||||
this.algorithm.setStickyRoom(null);
|
||||
}
|
||||
// Note: we hit the algorithm instead of our handleRoomUpdate() function to
|
||||
// avoid redundant updates.
|
||||
this.algorithm.handleRoomUpdate(predecessor, RoomUpdateCause.RoomRemoved);
|
||||
}
|
||||
|
||||
await this.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.NewRoom);
|
||||
this.updateFn.trigger();
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldMembership !== EffectiveMembership.Invite && newMembership === EffectiveMembership.Invite) {
|
||||
await this.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.NewRoom);
|
||||
this.updateFn.trigger();
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's not a join, it's transitioning into a different list (possibly historical)
|
||||
if (oldMembership !== newMembership) {
|
||||
await this.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.PossibleTagChange);
|
||||
this.updateFn.trigger();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<any> {
|
||||
if (!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<void> {
|
||||
if (!this.algorithm) return;
|
||||
if (!this.algorithm.hasTagSortingMap) return; // we're still loading
|
||||
|
||||
// Inhibit updates because we're about to lie heavily to the algorithm
|
||||
this.algorithm.updatesInhibited = true;
|
||||
|
||||
// Figure out which rooms are about to be valid, and the state of affairs
|
||||
const rooms = this.getPlausibleRooms();
|
||||
const currentSticky = this.algorithm.stickyRoom;
|
||||
const stickyIsStillPresent = currentSticky && rooms.includes(currentSticky);
|
||||
|
||||
// Reset the sticky room before resetting the known rooms so the algorithm
|
||||
// doesn't freak out.
|
||||
this.algorithm.setStickyRoom(null);
|
||||
this.algorithm.setKnownRooms(rooms);
|
||||
|
||||
// Set the sticky room back, if needed, now that we have updated the store.
|
||||
// This will use relative stickyness to the new room set.
|
||||
if (stickyIsStillPresent) {
|
||||
this.algorithm.setStickyRoom(currentSticky);
|
||||
}
|
||||
|
||||
// Finally, mark an update and resume updates from the algorithm
|
||||
this.updateFn.mark();
|
||||
this.algorithm.updatesInhibited = false;
|
||||
}
|
||||
|
||||
public setTagSorting(tagId: TagID, sort: SortAlgorithm): void {
|
||||
this.setAndPersistTagSorting(tagId, sort);
|
||||
// We'll always need an update after changing the sort order, so mark for update and trigger
|
||||
// immediately.
|
||||
this.updateFn.mark();
|
||||
this.updateFn.trigger();
|
||||
}
|
||||
|
||||
private setAndPersistTagSorting(tagId: TagID, sort: SortAlgorithm): void {
|
||||
this.algorithm.setTagSorting(tagId, sort);
|
||||
// TODO: Per-account? https://github.com/vector-im/element-web/issues/14114
|
||||
localStorage.setItem(`mx_tagSort_${tagId}`, sort);
|
||||
}
|
||||
|
||||
public getTagSorting(tagId: TagID): SortAlgorithm | null {
|
||||
return this.algorithm.getTagSorting(tagId);
|
||||
}
|
||||
|
||||
// noinspection JSMethodCanBeStatic
|
||||
private getStoredTagSorting(tagId: TagID): SortAlgorithm {
|
||||
// TODO: Per-account? https://github.com/vector-im/element-web/issues/14114
|
||||
return <SortAlgorithm>localStorage.getItem(`mx_tagSort_${tagId}`);
|
||||
}
|
||||
|
||||
// logic must match calculateListOrder
|
||||
private calculateTagSorting(tagId: TagID): SortAlgorithm {
|
||||
const definedSort = this.getTagSorting(tagId);
|
||||
const storedSort = this.getStoredTagSorting(tagId);
|
||||
|
||||
// We use the following order to determine which of the 4 flags to use:
|
||||
// Stored > Settings > Defined > Default
|
||||
|
||||
let tagSort = SortAlgorithm.Recent;
|
||||
if (storedSort) {
|
||||
tagSort = storedSort;
|
||||
} else if (definedSort) {
|
||||
tagSort = definedSort;
|
||||
} // else default (already set)
|
||||
|
||||
return tagSort;
|
||||
}
|
||||
|
||||
public setListOrder(tagId: TagID, order: ListAlgorithm): void {
|
||||
this.setAndPersistListOrder(tagId, order);
|
||||
this.updateFn.trigger();
|
||||
}
|
||||
|
||||
private setAndPersistListOrder(tagId: TagID, order: ListAlgorithm): void {
|
||||
this.algorithm.setListOrdering(tagId, order);
|
||||
// TODO: Per-account? https://github.com/vector-im/element-web/issues/14114
|
||||
localStorage.setItem(`mx_listOrder_${tagId}`, order);
|
||||
}
|
||||
|
||||
public getListOrder(tagId: TagID): ListAlgorithm | null {
|
||||
return this.algorithm.getListOrdering(tagId);
|
||||
}
|
||||
|
||||
// noinspection JSMethodCanBeStatic
|
||||
private getStoredListOrder(tagId: TagID): ListAlgorithm {
|
||||
// TODO: Per-account? https://github.com/vector-im/element-web/issues/14114
|
||||
return <ListAlgorithm>localStorage.getItem(`mx_listOrder_${tagId}`);
|
||||
}
|
||||
|
||||
// logic must match calculateTagSorting
|
||||
private calculateListOrder(tagId: TagID): ListAlgorithm {
|
||||
const defaultOrder = ListAlgorithm.Natural;
|
||||
const definedOrder = this.getListOrder(tagId);
|
||||
const storedOrder = this.getStoredListOrder(tagId);
|
||||
|
||||
// We use the following order to determine which of the 4 flags to use:
|
||||
// Stored > Settings > Defined > Default
|
||||
|
||||
let listOrder = defaultOrder;
|
||||
if (storedOrder) {
|
||||
listOrder = storedOrder;
|
||||
} else if (definedOrder) {
|
||||
listOrder = definedOrder;
|
||||
} // else default (already set)
|
||||
|
||||
return listOrder;
|
||||
}
|
||||
|
||||
private updateAlgorithmInstances(): void {
|
||||
// We'll require an update, so mark for one. Marking now also prevents the calls
|
||||
// to setTagSorting and setListOrder from causing triggers.
|
||||
this.updateFn.mark();
|
||||
|
||||
for (const tag of Object.keys(this.orderedLists)) {
|
||||
const definedSort = this.getTagSorting(tag);
|
||||
const definedOrder = this.getListOrder(tag);
|
||||
|
||||
const tagSort = this.calculateTagSorting(tag);
|
||||
const listOrder = this.calculateListOrder(tag);
|
||||
|
||||
if (tagSort !== definedSort) {
|
||||
this.setAndPersistTagSorting(tag, tagSort);
|
||||
}
|
||||
if (listOrder !== definedOrder) {
|
||||
this.setAndPersistListOrder(tag, listOrder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onAlgorithmListUpdated = (forceUpdate: boolean): void => {
|
||||
this.updateFn.mark();
|
||||
if (forceUpdate) this.updateFn.trigger();
|
||||
};
|
||||
|
||||
private onAlgorithmFilterUpdated = (): void => {
|
||||
// The filter can happen off-cycle, so trigger an update. The filter will have
|
||||
// already caused a mark.
|
||||
this.updateFn.trigger();
|
||||
};
|
||||
|
||||
private onPrefilterUpdated = async (): Promise<void> => {
|
||||
await this.recalculatePrefiltering();
|
||||
this.updateFn.trigger();
|
||||
};
|
||||
|
||||
private getPlausibleRooms(): Room[] {
|
||||
if (!this.matrixClient) return [];
|
||||
|
||||
let rooms = this.matrixClient.getVisibleRooms(this.msc3946ProcessDynamicPredecessor);
|
||||
rooms = rooms.filter((r) => 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<void> {
|
||||
filter.on(FILTER_CHANGED, this.onPrefilterUpdated);
|
||||
this.prefilterConditions.push(filter);
|
||||
const promise = this.recalculatePrefiltering();
|
||||
promise.then(() => this.updateFn.trigger());
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a filter condition from the room list store. If the filter was
|
||||
* not previously added to the room list store, this will no-op. The effects
|
||||
* of removing a filter may be applied async and therefore might not cause
|
||||
* an update right away.
|
||||
* @param {IFilterCondition} filter The filter condition to remove.
|
||||
*/
|
||||
public removeFilter(filter: IFilterCondition): void {
|
||||
let promise = Promise.resolve();
|
||||
let removed = false;
|
||||
const idx = this.prefilterConditions.indexOf(filter);
|
||||
if (idx >= 0) {
|
||||
filter.off(FILTER_CHANGED, this.onPrefilterUpdated);
|
||||
this.prefilterConditions.splice(idx, 1);
|
||||
promise = this.recalculatePrefiltering();
|
||||
removed = true;
|
||||
}
|
||||
|
||||
if (removed) {
|
||||
promise.then(() => this.updateFn.trigger());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
await this.handleRoomUpdate(room, cause);
|
||||
this.updateFn.trigger();
|
||||
}
|
||||
}
|
||||
|
||||
export default class RoomListStore {
|
||||
private static internalInstance: Interface;
|
||||
|
||||
public static get instance(): Interface {
|
||||
if (!RoomListStore.internalInstance) {
|
||||
const instance = new RoomListStoreClass(defaultDispatcher);
|
||||
instance.start();
|
||||
RoomListStore.internalInstance = instance;
|
||||
}
|
||||
|
||||
return this.internalInstance;
|
||||
}
|
||||
}
|
||||
|
||||
window.mxRoomListStore = RoomListStore.instance;
|
||||
@ -1,63 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type RoomListStore as Interface } from "./Interface";
|
||||
import { SpaceFilterCondition } from "./filters/SpaceFilterCondition";
|
||||
import SpaceStore from "../spaces/SpaceStore";
|
||||
import { MetaSpace, type SpaceKey, UPDATE_HOME_BEHAVIOUR, UPDATE_SELECTED_SPACE } from "../spaces";
|
||||
|
||||
/**
|
||||
* Watches for changes in spaces to manage the filter on the provided RoomListStore
|
||||
*/
|
||||
export class SpaceWatcher {
|
||||
private readonly filter = new SpaceFilterCondition();
|
||||
// we track these separately to the SpaceStore as we need to observe transitions
|
||||
private activeSpace: SpaceKey = SpaceStore.instance.activeSpace;
|
||||
private allRoomsInHome: boolean = SpaceStore.instance.allRoomsInHome;
|
||||
|
||||
public constructor(private store: Interface) {
|
||||
if (SpaceWatcher.needsFilter(this.activeSpace, this.allRoomsInHome)) {
|
||||
this.updateFilter();
|
||||
store.addFilter(this.filter);
|
||||
}
|
||||
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated);
|
||||
SpaceStore.instance.on(UPDATE_HOME_BEHAVIOUR, this.onHomeBehaviourUpdated);
|
||||
}
|
||||
|
||||
private static needsFilter(spaceKey: SpaceKey, allRoomsInHome: boolean): boolean {
|
||||
return !(spaceKey === MetaSpace.Home && allRoomsInHome);
|
||||
}
|
||||
|
||||
private onSelectedSpaceUpdated = (activeSpace: SpaceKey, allRoomsInHome = this.allRoomsInHome): void => {
|
||||
if (activeSpace === this.activeSpace && allRoomsInHome === this.allRoomsInHome) return; // nop
|
||||
|
||||
const neededFilter = SpaceWatcher.needsFilter(this.activeSpace, this.allRoomsInHome);
|
||||
const needsFilter = SpaceWatcher.needsFilter(activeSpace, allRoomsInHome);
|
||||
|
||||
this.activeSpace = activeSpace;
|
||||
this.allRoomsInHome = allRoomsInHome;
|
||||
|
||||
if (needsFilter) {
|
||||
this.updateFilter();
|
||||
}
|
||||
|
||||
if (!neededFilter && needsFilter) {
|
||||
this.store.addFilter(this.filter);
|
||||
} else if (neededFilter && !needsFilter) {
|
||||
this.store.removeFilter(this.filter);
|
||||
}
|
||||
};
|
||||
|
||||
private onHomeBehaviourUpdated = (allRoomsInHome: boolean): void => {
|
||||
this.onSelectedSpaceUpdated(this.activeSpace, allRoomsInHome);
|
||||
};
|
||||
|
||||
private updateFilter = (): void => {
|
||||
this.filter.updateSpace(this.activeSpace);
|
||||
};
|
||||
}
|
||||
@ -1,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 || <IStickyRoom>{};
|
||||
|
||||
// It's possible to have no selected room. In that case, clear the sticky room
|
||||
if (!val) {
|
||||
if (this._stickyRoom) {
|
||||
const stickyRoom = this._stickyRoom.room;
|
||||
this._stickyRoom = null; // clear before we go to update the algorithm
|
||||
|
||||
// Lie to the algorithm and re-add the room to the algorithm
|
||||
this.handleRoomUpdate(stickyRoom, RoomUpdateCause.NewRoom);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// When we do have a room though, we expect to be able to find it
|
||||
let tag = this.roomIdsToTags[val.roomId]?.[0];
|
||||
if (!tag) throw new Error(`${val.roomId} does not belong to a tag and cannot be sticky`);
|
||||
|
||||
// We specifically do NOT use the ordered rooms set as it contains the sticky room, which
|
||||
// means we'll be off by 1 when the user is switching rooms. This leads to visual jumping
|
||||
// when the user is moving south in the list (not north, because of math).
|
||||
const tagList = this.getOrderedRoomsWithoutSticky()[tag] || []; // can be null if filtering
|
||||
let position = tagList.indexOf(val);
|
||||
|
||||
// We do want to see if a tag change happened though - if this did happen then we'll want
|
||||
// to force the position to zero (top) to ensure we can properly handle it.
|
||||
const wasSticky = this._lastStickyRoom.room ? this._lastStickyRoom.room.roomId === val.roomId : false;
|
||||
if (this._lastStickyRoom.tag && tag !== this._lastStickyRoom.tag && wasSticky && position < 0) {
|
||||
logger.warn(`Sticky room ${val.roomId} changed tags during sticky room handling`);
|
||||
position = 0;
|
||||
}
|
||||
|
||||
// Sanity check the position to make sure the room is qualified for being sticky
|
||||
if (position < 0) throw new Error(`${val.roomId} does not appear to be known and cannot be sticky`);
|
||||
|
||||
// 🐉 Here be dragons.
|
||||
// Before we can go through with lying to the underlying algorithm about a room
|
||||
// we need to ensure that when we do we're ready for the inevitable sticky room
|
||||
// update we'll receive. To prepare for that, we first remove the sticky room and
|
||||
// recalculate the state ourselves so that when the underlying algorithm calls for
|
||||
// the same thing it no-ops. After we're done calling the algorithm, we'll issue
|
||||
// a new update for ourselves.
|
||||
const lastStickyRoom = this._stickyRoom;
|
||||
this._stickyRoom = null; // clear before we update the algorithm
|
||||
this.recalculateStickyRoom();
|
||||
|
||||
// When we do have the room, re-add the old room (if needed) to the algorithm
|
||||
// and remove the sticky room from the algorithm. This is so the underlying
|
||||
// algorithm doesn't try and confuse itself with the sticky room concept.
|
||||
// We don't add the new room if the sticky room isn't changing because that's
|
||||
// an easy way to cause duplication. We have to do room ID checks instead of
|
||||
// referential checks as the references can differ through the lifecycle.
|
||||
if (lastStickyRoom && lastStickyRoom.room && lastStickyRoom.room.roomId !== val.roomId) {
|
||||
// Lie to the algorithm and re-add the room to the algorithm
|
||||
this.handleRoomUpdate(lastStickyRoom.room, RoomUpdateCause.NewRoom);
|
||||
}
|
||||
// Lie to the algorithm and remove the room from it's field of view
|
||||
this.handleRoomUpdate(val, RoomUpdateCause.RoomRemoved);
|
||||
|
||||
// handleRoomUpdate may have modified this._stickyRoom. Convince the
|
||||
// compiler of this fact.
|
||||
this._stickyRoom = this.stickyRoomMightBeModified();
|
||||
|
||||
// Check for tag & position changes while we're here. We also check the room to ensure
|
||||
// it is still the same room.
|
||||
if (this._stickyRoom) {
|
||||
if (this._stickyRoom.room !== val) {
|
||||
// Check the room IDs just in case
|
||||
if (this._stickyRoom.room.roomId === val.roomId) {
|
||||
logger.warn("Sticky room changed references");
|
||||
} else {
|
||||
throw new Error("Sticky room changed while the sticky room was changing");
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`Sticky room changed tag & position from ${tag} / ${position} ` +
|
||||
`to ${this._stickyRoom.tag} / ${this._stickyRoom.position}`,
|
||||
);
|
||||
|
||||
tag = this._stickyRoom.tag;
|
||||
position = this._stickyRoom.position;
|
||||
}
|
||||
|
||||
// Now that we're done lying to the algorithm, we need to update our position
|
||||
// marker only if the user is moving further down the same list. If they're switching
|
||||
// lists, or moving upwards, the position marker will splice in just fine but if
|
||||
// they went downwards in the same list we'll be off by 1 due to the shifting rooms.
|
||||
if (lastStickyRoom && lastStickyRoom.tag === tag && lastStickyRoom.position <= position) {
|
||||
position++;
|
||||
}
|
||||
|
||||
this._stickyRoom = {
|
||||
room: val,
|
||||
position: position,
|
||||
tag: tag,
|
||||
};
|
||||
|
||||
// We update the filtered rooms just in case, as otherwise users will end up visiting
|
||||
// a room while filtering and it'll disappear. We don't update the filter earlier in
|
||||
// this function simply because we don't have to.
|
||||
this.recalculateStickyRoom();
|
||||
this.recalculateActiveCallRooms(tag);
|
||||
if (lastStickyRoom && lastStickyRoom.tag !== tag) this.recalculateActiveCallRooms(lastStickyRoom.tag);
|
||||
|
||||
// Finally, trigger an update
|
||||
if (this.updatesInhibited) return;
|
||||
this.emit(LIST_UPDATED_EVENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hack to prevent Typescript claiming this._stickyRoom is always null.
|
||||
*/
|
||||
private stickyRoomMightBeModified(): IStickyRoom | null {
|
||||
return this._stickyRoom;
|
||||
}
|
||||
|
||||
private onConnectedCalls = (): void => {
|
||||
// In case we're unsticking a room, sort it back into natural order
|
||||
this.recalculateStickyRoom();
|
||||
|
||||
// Update the stickiness of rooms with calls
|
||||
this.recalculateActiveCallRooms();
|
||||
|
||||
if (this.updatesInhibited) return;
|
||||
// This isn't in response to any particular RoomListStore update,
|
||||
// so notify the store that it needs to force-update
|
||||
this.emit(LIST_UPDATED_EVENT, true);
|
||||
};
|
||||
|
||||
private initCachedStickyRooms(): void {
|
||||
this._cachedStickyRooms = {};
|
||||
for (const tagId of Object.keys(this.cachedRooms)) {
|
||||
this._cachedStickyRooms[tagId] = [...this.cachedRooms[tagId]]; // shallow clone
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate the sticky room position. If this is being called in relation to
|
||||
* a specific tag being updated, it should be given to this function to optimize
|
||||
* the call.
|
||||
* @param updatedTag The tag that was updated, if possible.
|
||||
*/
|
||||
protected recalculateStickyRoom(updatedTag: TagID | null = null): void {
|
||||
// 🐉 Here be dragons.
|
||||
// This function does far too much for what it should, and is called by many places.
|
||||
// Not only is this responsible for ensuring the sticky room is held in place at all
|
||||
// times, it is also responsible for ensuring our clone of the cachedRooms is up to
|
||||
// date. If either of these desyncs, we see weird behaviour like duplicated rooms,
|
||||
// outdated lists, and other nonsensical issues that aren't necessarily obvious.
|
||||
|
||||
if (!this._stickyRoom) {
|
||||
// If there's no sticky room, just do nothing useful.
|
||||
if (!!this._cachedStickyRooms) {
|
||||
// Clear the cache if we won't be needing it
|
||||
this._cachedStickyRooms = null;
|
||||
if (this.updatesInhibited) return;
|
||||
this.emit(LIST_UPDATED_EVENT);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._cachedStickyRooms || !updatedTag) {
|
||||
this.initCachedStickyRooms();
|
||||
}
|
||||
|
||||
if (updatedTag) {
|
||||
// Update the tag indicated by the caller, if possible. This is mostly to ensure
|
||||
// our cache is up to date.
|
||||
if (this._cachedStickyRooms) {
|
||||
this._cachedStickyRooms[updatedTag] = [...this.cachedRooms[updatedTag]]; // shallow clone
|
||||
}
|
||||
}
|
||||
|
||||
// Now try to insert the sticky room, if we need to.
|
||||
// We need to if there's no updated tag (we regenned the whole cache) or if the tag
|
||||
// we might have updated from the cache is also our sticky room.
|
||||
const sticky = this._stickyRoom;
|
||||
if (sticky && (!updatedTag || updatedTag === sticky.tag) && this._cachedStickyRooms) {
|
||||
this._cachedStickyRooms[sticky.tag].splice(sticky.position, 0, sticky.room);
|
||||
}
|
||||
|
||||
// Finally, trigger an update
|
||||
if (this.updatesInhibited) return;
|
||||
this.emit(LIST_UPDATED_EVENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate the position of any rooms with calls. If this is being called in
|
||||
* relation to a specific tag being updated, it should be given to this function to
|
||||
* optimize the call.
|
||||
*
|
||||
* This expects to be called *after* the sticky rooms are updated, and sticks the
|
||||
* room with the currently active call to the top of its tag.
|
||||
*
|
||||
* @param updatedTag The tag that was updated, if possible.
|
||||
*/
|
||||
protected recalculateActiveCallRooms(updatedTag: TagID | null = null): void {
|
||||
if (!updatedTag) {
|
||||
// Assume all tags need updating
|
||||
// We're not modifying the map here, so can safely rely on the cached values
|
||||
// rather than the explicitly sticky map.
|
||||
for (const tagId of Object.keys(this.cachedRooms)) {
|
||||
if (!tagId) {
|
||||
throw new Error("Unexpected recursion: falsy tag");
|
||||
}
|
||||
this.recalculateActiveCallRooms(tagId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (CallStore.instance.connectedCalls.size) {
|
||||
// We operate on the sticky rooms map
|
||||
if (!this._cachedStickyRooms) this.initCachedStickyRooms();
|
||||
const rooms = this._cachedStickyRooms![updatedTag];
|
||||
|
||||
const activeRoomIds = new Set([...CallStore.instance.connectedCalls].map((call) => call.roomId));
|
||||
const activeRooms: Room[] = [];
|
||||
const inactiveRooms: Room[] = [];
|
||||
|
||||
for (const room of rooms) {
|
||||
(activeRoomIds.has(room.roomId) ? activeRooms : inactiveRooms).push(room);
|
||||
}
|
||||
|
||||
// Stick rooms with active calls to the top
|
||||
this._cachedStickyRooms![updatedTag] = [...activeRooms, ...inactiveRooms];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the Algorithm to regenerate all lists, using the tags given
|
||||
* as reference for which lists to generate and which way to generate
|
||||
* them.
|
||||
* @param {ITagSortingMap} tagSortingMap The tags to generate.
|
||||
* @param {IListOrderingMap} listOrderingMap The ordering of those tags.
|
||||
*/
|
||||
public populateTags(tagSortingMap: ITagSortingMap, listOrderingMap: IListOrderingMap): void {
|
||||
if (!tagSortingMap) throw new Error(`Sorting map cannot be null or empty`);
|
||||
if (!listOrderingMap) throw new Error(`Ordering ma cannot be null or empty`);
|
||||
if (arrayHasDiff(Object.keys(tagSortingMap), Object.keys(listOrderingMap))) {
|
||||
throw new Error(`Both maps must contain the exact same tags`);
|
||||
}
|
||||
this.sortAlgorithms = tagSortingMap;
|
||||
this.listAlgorithms = listOrderingMap;
|
||||
this.algorithms = {};
|
||||
for (const tag of Object.keys(tagSortingMap)) {
|
||||
this.algorithms[tag] = getListAlgorithmInstance(this.listAlgorithms[tag], tag, this.sortAlgorithms[tag]);
|
||||
}
|
||||
return this.setKnownRooms(this.rooms);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an ordered set of rooms for the all known tags.
|
||||
* @returns {ITagMap} The cached list of rooms, ordered,
|
||||
* for each tag. May be empty, but never null/undefined.
|
||||
*/
|
||||
public getOrderedRooms(): ITagMap {
|
||||
return this._cachedStickyRooms || this.cachedRooms;
|
||||
}
|
||||
|
||||
/**
|
||||
* This returns the same as getOrderedRooms(), but without the sticky room
|
||||
* map as it causes issues for sticky room handling (see sticky room handling
|
||||
* for more information).
|
||||
* @returns {ITagMap} The cached list of rooms, ordered,
|
||||
* for each tag. May be empty, but never null/undefined.
|
||||
*/
|
||||
private getOrderedRoomsWithoutSticky(): ITagMap {
|
||||
return this.cachedRooms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds the Algorithm with a set of rooms. The algorithm will discard all
|
||||
* previously known information and instead use these rooms instead.
|
||||
* @param {Room[]} rooms The rooms to force the algorithm to use.
|
||||
*/
|
||||
public setKnownRooms(rooms: Room[]): void {
|
||||
if (isNullOrUndefined(rooms)) throw new Error(`Array of rooms cannot be null`);
|
||||
if (!this.sortAlgorithms) throw new Error(`Cannot set known rooms without a tag sorting map`);
|
||||
|
||||
if (!this.updatesInhibited) {
|
||||
// We only log this if we're expecting to be publishing updates, which means that
|
||||
// this could be an unexpected invocation. If we're inhibited, then this is probably
|
||||
// an intentional invocation.
|
||||
logger.warn("Resetting known rooms, initiating regeneration");
|
||||
}
|
||||
|
||||
// Before we go any further we need to clear (but remember) the sticky room to
|
||||
// avoid accidentally duplicating it in the list.
|
||||
const oldStickyRoom = this._stickyRoom;
|
||||
if (oldStickyRoom) this.updateStickyRoom(null);
|
||||
|
||||
this.rooms = rooms;
|
||||
|
||||
const newTags: ITagMap = {};
|
||||
for (const tagId in this.sortAlgorithms) {
|
||||
// noinspection JSUnfilteredForInLoop
|
||||
newTags[tagId] = [];
|
||||
}
|
||||
|
||||
// If we can avoid doing work, do so.
|
||||
if (!rooms.length) {
|
||||
this.generateFreshTags(newTags); // just in case it wants to do something
|
||||
this.cachedRooms = newTags;
|
||||
return;
|
||||
}
|
||||
|
||||
// Split out the easy rooms first (leave and invite)
|
||||
const memberships = splitRoomsByMembership(rooms);
|
||||
|
||||
for (const room of memberships[EffectiveMembership.Invite]) {
|
||||
newTags[DefaultTagID.Invite].push(room);
|
||||
}
|
||||
for (const room of memberships[EffectiveMembership.Leave]) {
|
||||
// We may not have had an archived section previously, so make sure its there.
|
||||
if (newTags[DefaultTagID.Archived] === undefined) newTags[DefaultTagID.Archived] = [];
|
||||
newTags[DefaultTagID.Archived].push(room);
|
||||
}
|
||||
|
||||
// Now process all the joined rooms. This is a bit more complicated
|
||||
for (const room of memberships[EffectiveMembership.Join]) {
|
||||
const tags = 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<boolean>} A boolean of whether or not getOrderedRooms()
|
||||
* should be called after processing.
|
||||
*/
|
||||
public handleRoomUpdate(room: Room, cause: RoomUpdateCause): boolean {
|
||||
if (!this.algorithms) throw new Error("Not ready: no algorithms to determine tags from");
|
||||
|
||||
// Note: check the isSticky against the room ID just in case the reference is wrong
|
||||
const isSticky = this._stickyRoom?.room?.roomId === room.roomId;
|
||||
if (cause === RoomUpdateCause.NewRoom) {
|
||||
const isForLastSticky = this._lastStickyRoom?.room === room;
|
||||
const roomTags = this.roomIdsToTags[room.roomId];
|
||||
const hasTags = roomTags && roomTags.length > 0;
|
||||
|
||||
// Don't change the cause if the last sticky room is being re-added. If we fail to
|
||||
// pass the cause through as NewRoom, we'll fail to lie to the algorithm and thus
|
||||
// lose the room.
|
||||
if (hasTags && !isForLastSticky) {
|
||||
logger.warn(`${room.roomId} is reportedly new but is already known - assuming TagChange instead`);
|
||||
cause = RoomUpdateCause.PossibleTagChange;
|
||||
}
|
||||
|
||||
// Check to see if the room is known first
|
||||
let knownRoomRef = this.rooms.includes(room);
|
||||
if (hasTags && !knownRoomRef) {
|
||||
logger.warn(`${room.roomId} might be a reference change - attempting to update reference`);
|
||||
this.rooms = this.rooms.map((r) => (r.roomId === room.roomId ? room : r));
|
||||
knownRoomRef = this.rooms.includes(room);
|
||||
if (!knownRoomRef) {
|
||||
logger.warn(`${room.roomId} is still not referenced. It may be sticky.`);
|
||||
}
|
||||
}
|
||||
|
||||
// If we have tags for a room and don't have the room referenced, something went horribly
|
||||
// wrong - the reference should have been updated above.
|
||||
if (hasTags && !knownRoomRef && !isSticky) {
|
||||
throw new Error(`${room.roomId} is missing from room array but is known - trying to find duplicate`);
|
||||
}
|
||||
|
||||
// Like above, update the reference to the sticky room if we need to
|
||||
if (hasTags && isSticky && this._stickyRoom) {
|
||||
// Go directly in and set the sticky room's new reference, being careful not
|
||||
// to trigger a sticky room update ourselves.
|
||||
this._stickyRoom.room = room;
|
||||
}
|
||||
|
||||
// If after all that we're still a NewRoom update, add the room if applicable.
|
||||
// We don't do this for the sticky room (because it causes duplication issues)
|
||||
// or if we know about the reference (as it should be replaced).
|
||||
if (cause === RoomUpdateCause.NewRoom && !isSticky && !knownRoomRef) {
|
||||
this.rooms.push(room);
|
||||
}
|
||||
}
|
||||
|
||||
let didTagChange = false;
|
||||
if (cause === RoomUpdateCause.PossibleTagChange) {
|
||||
const oldTags = this.roomIdsToTags[room.roomId] || [];
|
||||
const newTags = 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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<NaturalCategorizedRoomMap>(
|
||||
(acc, room: Room) => {
|
||||
if (this.getRoomIsMuted(room)) {
|
||||
acc.mutedRooms.push(room);
|
||||
} else {
|
||||
acc.defaultRooms.push(room);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ defaultRooms: [], mutedRooms: [] } as NaturalCategorizedRoomMap,
|
||||
);
|
||||
}
|
||||
|
||||
private onPossibleMuteChange(room: Room): boolean {
|
||||
const isMuted = this.getRoomIsMuted(room);
|
||||
if (isMuted) {
|
||||
const defaultIndex = this.cachedCategorizedOrderedRooms.defaultRooms.findIndex(
|
||||
(r) => r.roomId === room.roomId,
|
||||
);
|
||||
|
||||
// room has been muted
|
||||
if (defaultIndex > -1) {
|
||||
// remove from the default list
|
||||
this.cachedCategorizedOrderedRooms.defaultRooms.splice(defaultIndex, 1);
|
||||
// add to muted list and reorder
|
||||
this.cachedCategorizedOrderedRooms.mutedRooms = sortRoomsWithAlgorithm(
|
||||
[...this.cachedCategorizedOrderedRooms.mutedRooms, room],
|
||||
this.tagId,
|
||||
this.sortingAlgorithm,
|
||||
);
|
||||
// rebuild
|
||||
this.buildCachedOrderedRooms();
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
const mutedIndex = this.cachedCategorizedOrderedRooms.mutedRooms.findIndex((r) => r.roomId === room.roomId);
|
||||
|
||||
// room has been unmuted
|
||||
if (mutedIndex > -1) {
|
||||
// remove from the muted list
|
||||
this.cachedCategorizedOrderedRooms.mutedRooms.splice(mutedIndex, 1);
|
||||
// add to default list and reorder
|
||||
this.cachedCategorizedOrderedRooms.defaultRooms = sortRoomsWithAlgorithm(
|
||||
[...this.cachedCategorizedOrderedRooms.defaultRooms, room],
|
||||
this.tagId,
|
||||
this.sortingAlgorithm,
|
||||
);
|
||||
// rebuild
|
||||
this.buildCachedOrderedRooms();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -1,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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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[];
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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[];
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -1,34 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { type EventEmitter } from "events";
|
||||
|
||||
export const FILTER_CHANGED = "filter_changed";
|
||||
|
||||
/**
|
||||
* A filter condition for the room list, determining if a room
|
||||
* should be shown or not.
|
||||
*
|
||||
* All filter conditions are expected to be stable executions,
|
||||
* meaning that given the same input the same answer will be
|
||||
* returned (thus allowing caching). As such, filter conditions
|
||||
* can, but shouldn't, do heavier logic and not worry about being
|
||||
* called constantly by the room list. When the condition changes
|
||||
* such that different inputs lead to different answers (such
|
||||
* as a change in the user's input), this emits FILTER_CHANGED.
|
||||
*/
|
||||
export interface IFilterCondition extends EventEmitter {
|
||||
/**
|
||||
* Determines if a given room should be visible under this
|
||||
* condition.
|
||||
* @param room The room to check.
|
||||
* @returns True if the room should be visible.
|
||||
*/
|
||||
isVisible(room: Room): boolean;
|
||||
}
|
||||
@ -1,72 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "events";
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { FILTER_CHANGED, type IFilterCondition } from "./IFilterCondition";
|
||||
import { type IDestroyable } from "../../../utils/IDestroyable";
|
||||
import SpaceStore from "../../spaces/SpaceStore";
|
||||
import { isMetaSpace, MetaSpace, type SpaceKey } from "../../spaces";
|
||||
import { setHasDiff } from "../../../utils/sets";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
|
||||
/**
|
||||
* A filter condition for the room list which reveals rooms which
|
||||
* are a member of a given space or if no space is selected shows:
|
||||
* + Orphaned rooms (ones not in any space you are a part of)
|
||||
* + All DMs
|
||||
*/
|
||||
export class SpaceFilterCondition extends EventEmitter implements IFilterCondition, IDestroyable {
|
||||
private roomIds = new Set<string>();
|
||||
private userIds = new Set<string>();
|
||||
private showPeopleInSpace = true;
|
||||
private space: SpaceKey = MetaSpace.Home;
|
||||
|
||||
public isVisible(room: Room): boolean {
|
||||
return SpaceStore.instance.isRoomInSpace(this.space, room.roomId);
|
||||
}
|
||||
|
||||
private onStoreUpdate = async (forceUpdate = false): Promise<void> => {
|
||||
const beforeRoomIds = this.roomIds;
|
||||
// clone the set as it may be mutated by the space store internally
|
||||
this.roomIds = new Set(SpaceStore.instance.getSpaceFilteredRoomIds(this.space));
|
||||
|
||||
const beforeUserIds = this.userIds;
|
||||
// clone the set as it may be mutated by the space store internally
|
||||
this.userIds = new Set(SpaceStore.instance.getSpaceFilteredUserIds(this.space));
|
||||
|
||||
const beforeShowPeopleInSpace = this.showPeopleInSpace;
|
||||
this.showPeopleInSpace =
|
||||
isMetaSpace(this.space[0]) || SettingsStore.getValue("Spaces.showPeopleInSpace", this.space);
|
||||
|
||||
if (
|
||||
forceUpdate ||
|
||||
beforeShowPeopleInSpace !== this.showPeopleInSpace ||
|
||||
setHasDiff(beforeRoomIds, this.roomIds) ||
|
||||
setHasDiff(beforeUserIds, this.userIds)
|
||||
) {
|
||||
this.emit(FILTER_CHANGED);
|
||||
// XXX: Room List Store has a bug where updates to the pre-filter during a local echo of a
|
||||
// tags transition seem to be ignored, so refire in the next tick to work around it
|
||||
setTimeout(() => {
|
||||
this.emit(FILTER_CHANGED);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
public updateSpace(space: SpaceKey): void {
|
||||
SpaceStore.instance.off(this.space, this.onStoreUpdate);
|
||||
SpaceStore.instance.on((this.space = space), this.onStoreUpdate);
|
||||
this.onStoreUpdate(true); // initial update from the change to the space
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
SpaceStore.instance.off(this.space, this.onStoreUpdate);
|
||||
}
|
||||
}
|
||||
@ -1,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
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<typeof MatrixClientContext>;
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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() ?? "";
|
||||
}
|
||||
@ -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];
|
||||
};
|
||||
@ -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<EmptyObject> {
|
||||
let roomId: string | undefined;
|
||||
if (space === MetaSpace.Home && this.allRoomsInHome) {
|
||||
const hasMentions = RoomNotificationStateStore.instance.globalState.hasMentions;
|
||||
const lists = RoomListStore.instance.orderedLists;
|
||||
tagLoop: for (let i = 0; i < TAG_ORDER.length; i++) {
|
||||
const t = TAG_ORDER[i];
|
||||
if (!lists[t]) continue;
|
||||
for (const room of lists[t]) {
|
||||
const state = RoomNotificationStateStore.instance.getRoomState(room);
|
||||
if (hasMentions ? state.hasMentions : state.isUnread) {
|
||||
roomId = room.roomId;
|
||||
break tagLoop;
|
||||
}
|
||||
const rooms = RoomListStoreV3.instance.getSortedRooms();
|
||||
for (const room of rooms) {
|
||||
const state = RoomNotificationStateStore.instance.getRoomState(room);
|
||||
if (hasMentions ? state.hasMentions : state.isUnread) {
|
||||
roomId = room.roomId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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([]),
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -1,53 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { getByRole, render } from "jest-matrix-react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import React, { type ComponentProps } from "react";
|
||||
|
||||
import ExtraTile from "../../../../../src/components/views/rooms/ExtraTile";
|
||||
|
||||
describe("ExtraTile", () => {
|
||||
function renderComponent(props: Partial<ComponentProps<typeof ExtraTile>> = {}) {
|
||||
const defaultProps: ComponentProps<typeof ExtraTile> = {
|
||||
isMinimized: false,
|
||||
isSelected: false,
|
||||
displayName: "test",
|
||||
avatar: <React.Fragment />,
|
||||
onClick: () => {},
|
||||
};
|
||||
return render(<ExtraTile {...defaultProps} {...props} />);
|
||||
}
|
||||
|
||||
it("renders", () => {
|
||||
const { asFragment } = renderComponent();
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("hides text when minimized", () => {
|
||||
const { container } = renderComponent({
|
||||
isMinimized: true,
|
||||
displayName: "testDisplayName",
|
||||
});
|
||||
expect(container).not.toHaveTextContent("testDisplayName");
|
||||
});
|
||||
|
||||
it("registers clicks", async () => {
|
||||
const onClick = jest.fn();
|
||||
|
||||
const { container } = renderComponent({
|
||||
onClick,
|
||||
});
|
||||
|
||||
const btn = getByRole(container, "treeitem");
|
||||
|
||||
await userEvent.click(btn);
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@ -1,39 +0,0 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`ExtraTile renders 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
aria-label="test"
|
||||
class="mx_AccessibleButton mx_ExtraTile mx_RoomTile"
|
||||
role="treeitem"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="mx_RoomTile_avatarContainer"
|
||||
/>
|
||||
<div
|
||||
class="mx_RoomTile_details"
|
||||
>
|
||||
<div
|
||||
class="mx_RoomTile_primaryDetails"
|
||||
>
|
||||
<div
|
||||
class="mx_RoomTile_titleContainer"
|
||||
>
|
||||
<div
|
||||
class="mx_RoomTile_title"
|
||||
dir="auto"
|
||||
tabindex="-1"
|
||||
title="test"
|
||||
>
|
||||
test
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mx_RoomTile_badgeContainer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
@ -1,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`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
aria-describedby="mx_RoomTile_messagePreview_!1:example.org"
|
||||
aria-label="!1:example.org"
|
||||
aria-selected="false"
|
||||
class="mx_AccessibleButton mx_RoomTile"
|
||||
role="treeitem"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="mx_DecoratedRoomAvatar"
|
||||
>
|
||||
<span
|
||||
class="_avatar_zysgz_8 mx_BaseAvatar _avatar-imageless_zysgz_55"
|
||||
data-color="3"
|
||||
data-testid="avatar-img"
|
||||
data-type="round"
|
||||
role="presentation"
|
||||
style="--cpd-avatar-size: 32px;"
|
||||
>
|
||||
!
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="mx_RoomTile_titleContainer"
|
||||
>
|
||||
<div
|
||||
class="mx_RoomTile_title mx_RoomTile_titleWithSubtitle"
|
||||
tabindex="-1"
|
||||
title="!1:example.org"
|
||||
>
|
||||
<span
|
||||
dir="auto"
|
||||
>
|
||||
!1:example.org
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="mx_RoomTile_subtitle"
|
||||
id="mx_RoomTile_messagePreview_!1:example.org"
|
||||
title="test thread reply"
|
||||
>
|
||||
<span
|
||||
class="mx_RoomTile_subtitle_text"
|
||||
>
|
||||
test thread reply
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="mx_RoomTile_badgeContainer"
|
||||
/>
|
||||
<div
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="Room options"
|
||||
class="mx_AccessibleButton mx_RoomTile_menuButton"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
/>
|
||||
<div
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="Notification options"
|
||||
class="mx_AccessibleButton mx_RoomTile_notificationsButton"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
/>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`RoomTile when message previews are enabled and there is a message in the room should render as expected 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
aria-describedby="mx_RoomTile_messagePreview_!1:example.org"
|
||||
aria-label="!1:example.org Unread messages."
|
||||
aria-selected="false"
|
||||
class="mx_AccessibleButton mx_RoomTile"
|
||||
role="treeitem"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="mx_DecoratedRoomAvatar"
|
||||
>
|
||||
<span
|
||||
class="_avatar_zysgz_8 mx_BaseAvatar _avatar-imageless_zysgz_55"
|
||||
data-color="3"
|
||||
data-testid="avatar-img"
|
||||
data-type="round"
|
||||
role="presentation"
|
||||
style="--cpd-avatar-size: 32px;"
|
||||
>
|
||||
!
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="mx_RoomTile_titleContainer"
|
||||
>
|
||||
<div
|
||||
class="mx_RoomTile_title mx_RoomTile_titleWithSubtitle mx_RoomTile_titleHasUnreadEvents"
|
||||
tabindex="-1"
|
||||
title="!1:example.org"
|
||||
>
|
||||
<span
|
||||
dir="auto"
|
||||
>
|
||||
!1:example.org
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="mx_RoomTile_subtitle"
|
||||
id="mx_RoomTile_messagePreview_!1:example.org"
|
||||
title="test message"
|
||||
>
|
||||
<span
|
||||
class="mx_RoomTile_subtitle_text"
|
||||
>
|
||||
test message
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="mx_RoomTile_badgeContainer"
|
||||
>
|
||||
<div
|
||||
class="mx_NotificationBadge mx_NotificationBadge_visible mx_NotificationBadge_dot"
|
||||
>
|
||||
<span
|
||||
class="mx_NotificationBadge_count"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="Room options"
|
||||
class="mx_AccessibleButton mx_RoomTile_menuButton"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
/>
|
||||
<div
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="Notification options"
|
||||
class="mx_AccessibleButton mx_RoomTile_notificationsButton"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
/>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`RoomTile when message previews are enabled should render a room without a message as expected 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
aria-describedby="mx_RoomTile_messagePreview_!1:example.org"
|
||||
aria-label="!1:example.org"
|
||||
aria-selected="false"
|
||||
class="mx_AccessibleButton mx_RoomTile"
|
||||
role="treeitem"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="mx_DecoratedRoomAvatar"
|
||||
>
|
||||
<span
|
||||
class="_avatar_zysgz_8 mx_BaseAvatar _avatar-imageless_zysgz_55"
|
||||
data-color="3"
|
||||
data-testid="avatar-img"
|
||||
data-type="round"
|
||||
role="presentation"
|
||||
style="--cpd-avatar-size: 32px;"
|
||||
>
|
||||
!
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="mx_RoomTile_titleContainer"
|
||||
>
|
||||
<div
|
||||
class="mx_RoomTile_title"
|
||||
tabindex="-1"
|
||||
title="!1:example.org"
|
||||
>
|
||||
<span
|
||||
dir="auto"
|
||||
>
|
||||
!1:example.org
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="mx_RoomTile_badgeContainer"
|
||||
/>
|
||||
<div
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="Room options"
|
||||
class="mx_AccessibleButton mx_RoomTile_menuButton"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
/>
|
||||
<div
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="Notification options"
|
||||
class="mx_AccessibleButton mx_RoomTile_notificationsButton"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
/>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`RoomTile when message previews are not enabled should render the room 1`] = `
|
||||
<div>
|
||||
<div
|
||||
aria-label="!1:example.org"
|
||||
aria-selected="false"
|
||||
class="mx_AccessibleButton mx_RoomTile"
|
||||
role="treeitem"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="mx_DecoratedRoomAvatar"
|
||||
>
|
||||
<span
|
||||
class="_avatar_zysgz_8 mx_BaseAvatar _avatar-imageless_zysgz_55"
|
||||
data-color="3"
|
||||
data-testid="avatar-img"
|
||||
data-type="round"
|
||||
role="presentation"
|
||||
style="--cpd-avatar-size: 32px;"
|
||||
>
|
||||
!
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="mx_RoomTile_titleContainer"
|
||||
>
|
||||
<div
|
||||
class="mx_RoomTile_title"
|
||||
tabindex="-1"
|
||||
title="!1:example.org"
|
||||
>
|
||||
<span
|
||||
dir="auto"
|
||||
>
|
||||
!1:example.org
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="mx_RoomTile_badgeContainer"
|
||||
/>
|
||||
<div
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="Room options"
|
||||
class="mx_AccessibleButton mx_RoomTile_menuButton"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
/>
|
||||
<div
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="Notification options"
|
||||
class="mx_AccessibleButton mx_RoomTile_notificationsButton"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@ -14,7 +14,6 @@ import RoomListStoreV3, {
|
||||
} from "../../../src/stores/room-list-v3/RoomListStoreV3";
|
||||
import { mkRoom, stubClient } from "../../test-utils/test-utils";
|
||||
import { Room } from "../../../src/modules/models/Room";
|
||||
import {} from "../../../src/stores/room-list/algorithms/Algorithm";
|
||||
|
||||
describe("StoresApi", () => {
|
||||
describe("RoomListStoreApi", () => {
|
||||
|
||||
@ -37,8 +37,6 @@ import SettingsStore from "../../../src/settings/SettingsStore";
|
||||
import { SettingLevel } from "../../../src/settings/SettingLevel";
|
||||
import { Action } from "../../../src/dispatcher/actions";
|
||||
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
|
||||
import RoomListStore from "../../../src/stores/room-list/RoomListStore";
|
||||
import { DefaultTagID } from "../../../src/stores/room-list/models";
|
||||
import { RoomNotificationStateStore } from "../../../src/stores/notifications/RoomNotificationStateStore";
|
||||
import { NotificationLevel } from "../../../src/stores/notifications/NotificationLevel";
|
||||
import { storeRoomAliasInCache } from "../../../src/RoomAliasCache.ts";
|
||||
|
||||
@ -20,14 +20,12 @@ import { AlphabeticSorter } from "../../../../src/stores/room-list-v3/skip-list/
|
||||
import dispatcher from "../../../../src/dispatcher/dispatcher";
|
||||
import SpaceStore from "../../../../src/stores/spaces/SpaceStore";
|
||||
import { MetaSpace, UPDATE_SELECTED_SPACE } from "../../../../src/stores/spaces";
|
||||
import { DefaultTagID } from "../../../../src/stores/room-list/models";
|
||||
import { FilterKey } from "../../../../src/stores/room-list-v3/skip-list/filters";
|
||||
import { RoomNotificationStateStore } from "../../../../src/stores/notifications/RoomNotificationStateStore";
|
||||
import DMRoomMap from "../../../../src/utils/DMRoomMap";
|
||||
import { SortingAlgorithm } from "../../../../src/stores/room-list-v3/skip-list/sorters";
|
||||
import SettingsStore from "../../../../src/settings/SettingsStore";
|
||||
import * as utils from "../../../../src/utils/notifications";
|
||||
import * as roomMute from "../../../../src/stores/room-list/utils/roomMute";
|
||||
import { Action } from "../../../../src/dispatcher/actions";
|
||||
import { SettingLevel } from "../../../../src/settings/SettingLevel.ts";
|
||||
|
||||
|
||||
@ -1,397 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
ConditionKind,
|
||||
EventType,
|
||||
type IPushRule,
|
||||
JoinRule,
|
||||
MatrixEvent,
|
||||
PendingEventOrdering,
|
||||
PushRuleActionName,
|
||||
Room,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
import { mocked } from "jest-mock";
|
||||
|
||||
import defaultDispatcher, { type MatrixDispatcher } from "../../../../src/dispatcher/dispatcher";
|
||||
import { SettingLevel } from "../../../../src/settings/SettingLevel";
|
||||
import SettingsStore, { type CallbackFn } from "../../../../src/settings/SettingsStore";
|
||||
import { ListAlgorithm, SortAlgorithm } from "../../../../src/stores/room-list/algorithms/models";
|
||||
import { DefaultTagID, OrderedDefaultTagIDs, RoomUpdateCause } from "../../../../src/stores/room-list/models";
|
||||
import RoomListStore, { RoomListStoreClass } from "../../../../src/stores/room-list/RoomListStore";
|
||||
import DMRoomMap from "../../../../src/utils/DMRoomMap";
|
||||
import { flushPromises, stubClient, upsertRoomStateEvents, mkRoom } from "../../../test-utils";
|
||||
import { DEFAULT_PUSH_RULES, makePushRule } from "../../../test-utils/pushRules";
|
||||
|
||||
describe("RoomListStore", () => {
|
||||
const client = stubClient();
|
||||
const newRoomId = "!roomid:example.com";
|
||||
const roomNoPredecessorId = "!roomnopreid:example.com";
|
||||
const oldRoomId = "!oldroomid:example.com";
|
||||
const userId = "@user:example.com";
|
||||
const createWithPredecessor = new MatrixEvent({
|
||||
type: EventType.RoomCreate,
|
||||
sender: userId,
|
||||
room_id: newRoomId,
|
||||
content: {
|
||||
predecessor: { room_id: oldRoomId, event_id: "tombstone_event_id" },
|
||||
},
|
||||
event_id: "$create",
|
||||
state_key: "",
|
||||
});
|
||||
const createNoPredecessor = new MatrixEvent({
|
||||
type: EventType.RoomCreate,
|
||||
sender: userId,
|
||||
room_id: newRoomId,
|
||||
content: {},
|
||||
event_id: "$create",
|
||||
state_key: "",
|
||||
});
|
||||
const predecessor = new MatrixEvent({
|
||||
type: EventType.RoomPredecessor,
|
||||
sender: userId,
|
||||
room_id: newRoomId,
|
||||
content: {
|
||||
predecessor_room_id: oldRoomId,
|
||||
last_known_event_id: "tombstone_event_id",
|
||||
},
|
||||
event_id: "$pred",
|
||||
state_key: "",
|
||||
});
|
||||
const roomWithPredecessorEvent = new Room(newRoomId, client, userId, {});
|
||||
upsertRoomStateEvents(roomWithPredecessorEvent, [predecessor]);
|
||||
const roomWithCreatePredecessor = new Room(newRoomId, client, userId, {});
|
||||
upsertRoomStateEvents(roomWithCreatePredecessor, [createWithPredecessor]);
|
||||
const roomNoPredecessor = new Room(roomNoPredecessorId, client, userId, {
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
});
|
||||
upsertRoomStateEvents(roomNoPredecessor, [createNoPredecessor]);
|
||||
const oldRoom = new Room(oldRoomId, client, userId, {});
|
||||
const normalRoom = new Room("!normal:server.org", client, userId);
|
||||
client.getRoom = jest.fn().mockImplementation((roomId) => {
|
||||
switch (roomId) {
|
||||
case newRoomId:
|
||||
return roomWithCreatePredecessor;
|
||||
case oldRoomId:
|
||||
return oldRoom;
|
||||
case normalRoom.roomId:
|
||||
return normalRoom;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
await (RoomListStore.instance as RoomListStoreClass).makeReady(client);
|
||||
});
|
||||
|
||||
it.each(OrderedDefaultTagIDs)("defaults to importance ordering for %s=", (tagId) => {
|
||||
expect(RoomListStore.instance.getTagSorting(tagId)).toBe(SortAlgorithm.Recent);
|
||||
});
|
||||
|
||||
it.each(OrderedDefaultTagIDs)("defaults to activity ordering for %s=", (tagId) => {
|
||||
expect(RoomListStore.instance.getListOrder(tagId)).toBe(ListAlgorithm.Natural);
|
||||
});
|
||||
|
||||
function createStore(): { store: RoomListStoreClass; handleRoomUpdate: jest.Mock<any, any> } {
|
||||
const fakeDispatcher = { register: jest.fn() } as unknown as MatrixDispatcher;
|
||||
const store = new RoomListStoreClass(fakeDispatcher);
|
||||
// @ts-ignore accessing private member to set client
|
||||
store.readyStore.matrixClient = client;
|
||||
const handleRoomUpdate = jest.fn();
|
||||
// @ts-ignore accessing private member to mock it
|
||||
store.algorithm.handleRoomUpdate = handleRoomUpdate;
|
||||
|
||||
return { store, handleRoomUpdate };
|
||||
}
|
||||
|
||||
it("Removes old room if it finds a predecessor in the create event", () => {
|
||||
// Given a store we can spy on
|
||||
const { store, handleRoomUpdate } = createStore();
|
||||
|
||||
mocked(client.getRoomUpgradeHistory).mockImplementation((roomId) =>
|
||||
roomId === roomWithCreatePredecessor.roomId ? [oldRoom, roomWithCreatePredecessor] : [],
|
||||
);
|
||||
|
||||
// When we tell it we joined a new room that has an old room as
|
||||
// predecessor in the create event
|
||||
const payload = {
|
||||
oldMembership: KnownMembership.Invite,
|
||||
membership: KnownMembership.Join,
|
||||
room: roomWithCreatePredecessor,
|
||||
};
|
||||
store.onDispatchMyMembership(payload);
|
||||
|
||||
// Then the old room is removed
|
||||
expect(handleRoomUpdate).toHaveBeenCalledWith(oldRoom, RoomUpdateCause.RoomRemoved);
|
||||
|
||||
// And the new room is added
|
||||
expect(handleRoomUpdate).toHaveBeenCalledWith(roomWithCreatePredecessor, RoomUpdateCause.NewRoom);
|
||||
});
|
||||
|
||||
it("Does not remove old room if there is no predecessor in the create event", () => {
|
||||
// Given a store we can spy on
|
||||
const { store, handleRoomUpdate } = createStore();
|
||||
|
||||
// When we tell it we joined a new room with no predecessor
|
||||
const payload = {
|
||||
oldMembership: KnownMembership.Invite,
|
||||
membership: KnownMembership.Join,
|
||||
room: roomNoPredecessor,
|
||||
};
|
||||
store.onDispatchMyMembership(payload);
|
||||
|
||||
// Then the new room is added
|
||||
expect(handleRoomUpdate).toHaveBeenCalledWith(roomNoPredecessor, RoomUpdateCause.NewRoom);
|
||||
// And no other updates happen
|
||||
expect(handleRoomUpdate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Lists all rooms that the client says are visible", () => {
|
||||
// Given 3 rooms that are visible according to the client
|
||||
const room1 = new Room("!r1:e.com", client, userId, { pendingEventOrdering: PendingEventOrdering.Detached });
|
||||
const room2 = new Room("!r2:e.com", client, userId, { pendingEventOrdering: PendingEventOrdering.Detached });
|
||||
const room3 = new Room("!r3:e.com", client, userId, { pendingEventOrdering: PendingEventOrdering.Detached });
|
||||
room1.updateMyMembership(KnownMembership.Join);
|
||||
room2.updateMyMembership(KnownMembership.Join);
|
||||
room3.updateMyMembership(KnownMembership.Join);
|
||||
DMRoomMap.makeShared(client);
|
||||
const { store } = createStore();
|
||||
client.getVisibleRooms = jest.fn().mockReturnValue([room1, room2, room3]);
|
||||
|
||||
// When we make the list of rooms
|
||||
store.regenerateAllLists({ trigger: false });
|
||||
|
||||
// Then the list contains all 3
|
||||
expect(store.orderedLists).toMatchObject({
|
||||
"im.vector.fake.recent": [room1, room2, room3],
|
||||
});
|
||||
|
||||
// We asked not to use MSC3946 when we asked the client for the visible rooms
|
||||
expect(client.getVisibleRooms).toHaveBeenCalledWith(false);
|
||||
expect(client.getVisibleRooms).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Watches the feature flag setting", () => {
|
||||
jest.spyOn(SettingsStore, "watchSetting").mockReturnValue("dyn_pred_ref");
|
||||
jest.spyOn(SettingsStore, "unwatchSetting");
|
||||
|
||||
// When we create a store
|
||||
const { store } = createStore();
|
||||
|
||||
// Then we watch the feature flag
|
||||
expect(SettingsStore.watchSetting).toHaveBeenCalledWith(
|
||||
"feature_dynamic_room_predecessors",
|
||||
null,
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
// And when we unmount it
|
||||
store.componentWillUnmount();
|
||||
|
||||
// Then we unwatch it.
|
||||
expect(SettingsStore.unwatchSetting).toHaveBeenCalledWith("dyn_pred_ref");
|
||||
});
|
||||
|
||||
it("Regenerates all lists when the feature flag is set", () => {
|
||||
// Given a store allowing us to spy on any use of SettingsStore
|
||||
let featureFlagValue = false;
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation(() => featureFlagValue);
|
||||
|
||||
let watchCallback: CallbackFn | undefined;
|
||||
jest.spyOn(SettingsStore, "watchSetting").mockImplementation(
|
||||
(_settingName: string, _roomId: string | null, callbackFn: CallbackFn) => {
|
||||
watchCallback = callbackFn;
|
||||
return "dyn_pred_ref";
|
||||
},
|
||||
);
|
||||
jest.spyOn(SettingsStore, "unwatchSetting");
|
||||
|
||||
const { store } = createStore();
|
||||
client.getVisibleRooms = jest.fn().mockReturnValue([]);
|
||||
// Sanity: no calculation has happened yet
|
||||
expect(client.getVisibleRooms).toHaveBeenCalledTimes(0);
|
||||
|
||||
// When we calculate for the first time
|
||||
store.regenerateAllLists({ trigger: false });
|
||||
|
||||
// Then we use the current feature flag value (false)
|
||||
expect(client.getVisibleRooms).toHaveBeenCalledWith(false);
|
||||
expect(client.getVisibleRooms).toHaveBeenCalledTimes(1);
|
||||
|
||||
// But when we update the feature flag
|
||||
featureFlagValue = true;
|
||||
watchCallback!(
|
||||
"feature_dynamic_room_predecessors",
|
||||
"",
|
||||
SettingLevel.DEFAULT,
|
||||
featureFlagValue,
|
||||
featureFlagValue,
|
||||
);
|
||||
|
||||
// Then we recalculate and passed the updated value (true)
|
||||
expect(client.getVisibleRooms).toHaveBeenCalledWith(true);
|
||||
expect(client.getVisibleRooms).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
describe("When feature_dynamic_room_predecessors = true", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation(
|
||||
(settingName) => settingName === "feature_dynamic_room_predecessors",
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockReset();
|
||||
});
|
||||
|
||||
it("Removes old room if it finds a predecessor in the m.predecessor event", () => {
|
||||
// Given a store we can spy on
|
||||
const { store, handleRoomUpdate } = createStore();
|
||||
|
||||
// When we tell it we joined a new room that has an old room as
|
||||
// predecessor in the create event
|
||||
const payload = {
|
||||
oldMembership: KnownMembership.Invite,
|
||||
membership: KnownMembership.Join,
|
||||
room: roomWithPredecessorEvent,
|
||||
};
|
||||
store.onDispatchMyMembership(payload);
|
||||
|
||||
// Then the old room is removed
|
||||
expect(handleRoomUpdate).toHaveBeenCalledWith(oldRoom, RoomUpdateCause.RoomRemoved);
|
||||
|
||||
// And the new room is added
|
||||
expect(handleRoomUpdate).toHaveBeenCalledWith(roomWithPredecessorEvent, RoomUpdateCause.NewRoom);
|
||||
});
|
||||
|
||||
it("Passes the feature flag on to the client when asking for visible rooms", () => {
|
||||
// Given a store that we can ask for a room list
|
||||
DMRoomMap.makeShared(client);
|
||||
const { store } = createStore();
|
||||
client.getVisibleRooms = jest.fn().mockReturnValue([]);
|
||||
|
||||
// When we make the list of rooms
|
||||
store.regenerateAllLists({ trigger: false });
|
||||
|
||||
// We asked to use MSC3946 when we asked the client for the visible rooms
|
||||
expect(client.getVisibleRooms).toHaveBeenCalledWith(true);
|
||||
expect(client.getVisibleRooms).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("room updates", () => {
|
||||
const makeStore = async () => {
|
||||
const store = new RoomListStoreClass(defaultDispatcher);
|
||||
await store.start();
|
||||
return store;
|
||||
};
|
||||
|
||||
describe("push rules updates", () => {
|
||||
const makePushRulesEvent = (overrideRules: IPushRule[] = []): MatrixEvent => {
|
||||
return new MatrixEvent({
|
||||
type: EventType.PushRules,
|
||||
content: {
|
||||
global: {
|
||||
...DEFAULT_PUSH_RULES.global,
|
||||
override: overrideRules,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
it("triggers a room update when room mutes have changed", async () => {
|
||||
const rule = makePushRule(normalRoom.roomId, {
|
||||
actions: [PushRuleActionName.DontNotify],
|
||||
conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: normalRoom.roomId }],
|
||||
});
|
||||
const event = makePushRulesEvent([rule]);
|
||||
const previousEvent = makePushRulesEvent();
|
||||
|
||||
const store = await makeStore();
|
||||
// @ts-ignore private property alg
|
||||
const algorithmSpy = jest.spyOn(store.algorithm, "handleRoomUpdate").mockReturnValue(undefined);
|
||||
// @ts-ignore cheat and call protected fn
|
||||
store.onAction({ action: "MatrixActions.accountData", event, previousEvent });
|
||||
await flushPromises();
|
||||
|
||||
expect(algorithmSpy).toHaveBeenCalledWith(normalRoom, RoomUpdateCause.PossibleMuteChange);
|
||||
});
|
||||
|
||||
it("handles when a muted room is unknown by the room list", async () => {
|
||||
const rule = makePushRule(normalRoom.roomId, {
|
||||
actions: [PushRuleActionName.DontNotify],
|
||||
conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: normalRoom.roomId }],
|
||||
});
|
||||
const unknownRoomRule = makePushRule("!unknown:server.org", {
|
||||
conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: "!unknown:server.org" }],
|
||||
});
|
||||
const event = makePushRulesEvent([unknownRoomRule, rule]);
|
||||
const previousEvent = makePushRulesEvent();
|
||||
|
||||
const store = await makeStore();
|
||||
// @ts-ignore private property alg
|
||||
const algorithmSpy = jest.spyOn(store.algorithm, "handleRoomUpdate").mockReturnValue(undefined);
|
||||
|
||||
// @ts-ignore cheat and call protected fn
|
||||
store.onAction({ action: "MatrixActions.accountData", event, previousEvent });
|
||||
await flushPromises();
|
||||
|
||||
// only one call to update made for normalRoom
|
||||
expect(algorithmSpy).toHaveBeenCalledTimes(1);
|
||||
expect(algorithmSpy).toHaveBeenCalledWith(normalRoom, RoomUpdateCause.PossibleMuteChange);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Correctly tags rooms", () => {
|
||||
it("renders Public and Knock rooms in Conferences section", () => {
|
||||
const videoRoomPrivate = "!videoRoomPrivate_server";
|
||||
const videoRoomPublic = "!videoRoomPublic_server";
|
||||
const videoRoomKnock = "!videoRoomKnock_server";
|
||||
|
||||
const rooms: Room[] = [];
|
||||
mkRoom(client, videoRoomPrivate, rooms);
|
||||
mkRoom(client, videoRoomPublic, rooms);
|
||||
mkRoom(client, videoRoomKnock, rooms);
|
||||
|
||||
mocked(client).getRoom.mockImplementation((roomId) => rooms.find((room) => room.roomId === roomId) || null);
|
||||
mocked(client).getRooms.mockImplementation(() => rooms);
|
||||
|
||||
const videoRoomKnockRoom = client.getRoom(videoRoomKnock);
|
||||
(videoRoomKnockRoom!.getJoinRule as jest.Mock).mockReturnValue(JoinRule.Knock);
|
||||
|
||||
const videoRoomPrivateRoom = client.getRoom(videoRoomPrivate);
|
||||
(videoRoomPrivateRoom!.getJoinRule as jest.Mock).mockReturnValue(JoinRule.Invite);
|
||||
|
||||
const videoRoomPublicRoom = client.getRoom(videoRoomPublic);
|
||||
(videoRoomPublicRoom!.getJoinRule as jest.Mock).mockReturnValue(JoinRule.Public);
|
||||
|
||||
[videoRoomPrivateRoom, videoRoomPublicRoom, videoRoomKnockRoom].forEach((room) => {
|
||||
(room!.isCallRoom as jest.Mock).mockReturnValue(true);
|
||||
});
|
||||
|
||||
expect(
|
||||
RoomListStore.instance
|
||||
.getTagsForRoom(client.getRoom(videoRoomPublic)!)
|
||||
.includes(DefaultTagID.Conference),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
RoomListStore.instance
|
||||
.getTagsForRoom(client.getRoom(videoRoomKnock)!)
|
||||
.includes(DefaultTagID.Conference),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
RoomListStore.instance
|
||||
.getTagsForRoom(client.getRoom(videoRoomPrivate)!)
|
||||
.includes(DefaultTagID.Conference),
|
||||
).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,218 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { mocked } from "jest-mock";
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { SpaceWatcher } from "../../../../src/stores/room-list/SpaceWatcher";
|
||||
import type { RoomListStoreClass } from "../../../../src/stores/room-list/RoomListStore";
|
||||
import SettingsStore from "../../../../src/settings/SettingsStore";
|
||||
import SpaceStore from "../../../../src/stores/spaces/SpaceStore";
|
||||
import { MetaSpace, UPDATE_HOME_BEHAVIOUR } from "../../../../src/stores/spaces";
|
||||
import { stubClient, mkSpace, emitPromise, setupAsyncStoreWithClient } from "../../../test-utils";
|
||||
import { SettingLevel } from "../../../../src/settings/SettingLevel";
|
||||
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
|
||||
import { SpaceFilterCondition } from "../../../../src/stores/room-list/filters/SpaceFilterCondition";
|
||||
import DMRoomMap from "../../../../src/utils/DMRoomMap";
|
||||
|
||||
let filter: SpaceFilterCondition | null = null;
|
||||
|
||||
const mockRoomListStore = {
|
||||
addFilter: (f: SpaceFilterCondition) => (filter = f),
|
||||
removeFilter: (): void => {
|
||||
filter = null;
|
||||
},
|
||||
} as unknown as RoomListStoreClass;
|
||||
|
||||
const getUserIdForRoomId = jest.fn();
|
||||
const getDMRoomsForUserId = jest.fn();
|
||||
// @ts-ignore
|
||||
DMRoomMap.sharedInstance = { getUserIdForRoomId, getDMRoomsForUserId };
|
||||
|
||||
const space1 = "!space1:server";
|
||||
const space2 = "!space2:server";
|
||||
|
||||
describe("SpaceWatcher", () => {
|
||||
stubClient();
|
||||
const store = SpaceStore.instance;
|
||||
const client = mocked(MatrixClientPeg.safeGet());
|
||||
|
||||
let rooms: Room[] = [];
|
||||
const mkSpaceForRooms = (spaceId: string, children: string[] = []) => mkSpace(client, spaceId, rooms, children);
|
||||
|
||||
const setShowAllRooms = async (value: boolean) => {
|
||||
if (store.allRoomsInHome === value) return;
|
||||
await SettingsStore.setValue("Spaces.allRoomsInHome", null, SettingLevel.DEVICE, value);
|
||||
await emitPromise(store, UPDATE_HOME_BEHAVIOUR);
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
filter = null;
|
||||
store.removeAllListeners();
|
||||
store.setActiveSpace(MetaSpace.Home);
|
||||
client.getVisibleRooms.mockReturnValue((rooms = []));
|
||||
|
||||
mkSpaceForRooms(space1);
|
||||
mkSpaceForRooms(space2);
|
||||
|
||||
await SettingsStore.setValue("Spaces.enabledMetaSpaces", null, SettingLevel.DEVICE, {
|
||||
[MetaSpace.Home]: true,
|
||||
[MetaSpace.Favourites]: true,
|
||||
[MetaSpace.People]: true,
|
||||
[MetaSpace.Orphans]: true,
|
||||
});
|
||||
|
||||
client.getRoom.mockImplementation((roomId) => rooms.find((room) => room.roomId === roomId) || null);
|
||||
await setupAsyncStoreWithClient(store, client);
|
||||
});
|
||||
|
||||
it("initialises sanely with home behaviour", async () => {
|
||||
await setShowAllRooms(false);
|
||||
new SpaceWatcher(mockRoomListStore);
|
||||
|
||||
expect(filter).toBeInstanceOf(SpaceFilterCondition);
|
||||
});
|
||||
|
||||
it("initialises sanely with all behaviour", async () => {
|
||||
await setShowAllRooms(true);
|
||||
new SpaceWatcher(mockRoomListStore);
|
||||
|
||||
expect(filter).toBeNull();
|
||||
});
|
||||
|
||||
it("sets space=Home filter for all -> home transition", async () => {
|
||||
await setShowAllRooms(true);
|
||||
new SpaceWatcher(mockRoomListStore);
|
||||
|
||||
await setShowAllRooms(false);
|
||||
|
||||
expect(filter).toBeInstanceOf(SpaceFilterCondition);
|
||||
expect(filter!["space"]).toBe(MetaSpace.Home);
|
||||
});
|
||||
|
||||
it("sets filter correctly for all -> space transition", async () => {
|
||||
await setShowAllRooms(true);
|
||||
new SpaceWatcher(mockRoomListStore);
|
||||
|
||||
SpaceStore.instance.setActiveSpace(space1);
|
||||
|
||||
expect(filter).toBeInstanceOf(SpaceFilterCondition);
|
||||
expect(filter!["space"]).toBe(space1);
|
||||
});
|
||||
|
||||
it("removes filter for home -> all transition", async () => {
|
||||
await setShowAllRooms(false);
|
||||
new SpaceWatcher(mockRoomListStore);
|
||||
|
||||
await setShowAllRooms(true);
|
||||
|
||||
expect(filter).toBeNull();
|
||||
});
|
||||
|
||||
it("sets filter correctly for home -> space transition", async () => {
|
||||
await setShowAllRooms(false);
|
||||
new SpaceWatcher(mockRoomListStore);
|
||||
|
||||
SpaceStore.instance.setActiveSpace(space1);
|
||||
|
||||
expect(filter).toBeInstanceOf(SpaceFilterCondition);
|
||||
expect(filter!["space"]).toBe(space1);
|
||||
});
|
||||
|
||||
it("removes filter for space -> all transition", async () => {
|
||||
await setShowAllRooms(true);
|
||||
new SpaceWatcher(mockRoomListStore);
|
||||
|
||||
SpaceStore.instance.setActiveSpace(space1);
|
||||
expect(filter).toBeInstanceOf(SpaceFilterCondition);
|
||||
expect(filter!["space"]).toBe(space1);
|
||||
SpaceStore.instance.setActiveSpace(MetaSpace.Home);
|
||||
|
||||
expect(filter).toBeNull();
|
||||
});
|
||||
|
||||
// The new room list is now forced on, which removes the favourites and people meta spaces.
|
||||
// So no need to test these transitions any more.
|
||||
|
||||
it("removes filter for orphans -> all transition", async () => {
|
||||
await setShowAllRooms(true);
|
||||
new SpaceWatcher(mockRoomListStore);
|
||||
|
||||
SpaceStore.instance.setActiveSpace(MetaSpace.Orphans);
|
||||
expect(filter).toBeInstanceOf(SpaceFilterCondition);
|
||||
expect(filter!["space"]).toBe(MetaSpace.Orphans);
|
||||
SpaceStore.instance.setActiveSpace(MetaSpace.Home);
|
||||
|
||||
expect(filter).toBeNull();
|
||||
});
|
||||
|
||||
it("updates filter correctly for space -> home transition", async () => {
|
||||
await setShowAllRooms(false);
|
||||
SpaceStore.instance.setActiveSpace(space1);
|
||||
|
||||
new SpaceWatcher(mockRoomListStore);
|
||||
expect(filter).toBeInstanceOf(SpaceFilterCondition);
|
||||
expect(filter!["space"]).toBe(space1);
|
||||
SpaceStore.instance.setActiveSpace(MetaSpace.Home);
|
||||
|
||||
expect(filter).toBeInstanceOf(SpaceFilterCondition);
|
||||
expect(filter!["space"]).toBe(MetaSpace.Home);
|
||||
});
|
||||
|
||||
it("updates filter correctly for space -> orphans transition", async () => {
|
||||
await setShowAllRooms(false);
|
||||
SpaceStore.instance.setActiveSpace(space1);
|
||||
|
||||
new SpaceWatcher(mockRoomListStore);
|
||||
expect(filter).toBeInstanceOf(SpaceFilterCondition);
|
||||
expect(filter!["space"]).toBe(space1);
|
||||
SpaceStore.instance.setActiveSpace(MetaSpace.Orphans);
|
||||
|
||||
expect(filter).toBeInstanceOf(SpaceFilterCondition);
|
||||
expect(filter!["space"]).toBe(MetaSpace.Orphans);
|
||||
});
|
||||
|
||||
it("updates filter correctly for space -> space transition", async () => {
|
||||
await setShowAllRooms(false);
|
||||
SpaceStore.instance.setActiveSpace(space1);
|
||||
|
||||
new SpaceWatcher(mockRoomListStore);
|
||||
expect(filter).toBeInstanceOf(SpaceFilterCondition);
|
||||
expect(filter!["space"]).toBe(space1);
|
||||
SpaceStore.instance.setActiveSpace(space2);
|
||||
|
||||
expect(filter).toBeInstanceOf(SpaceFilterCondition);
|
||||
expect(filter!["space"]).toBe(space2);
|
||||
});
|
||||
|
||||
it("doesn't change filter when changing showAllRooms mode to true", async () => {
|
||||
await setShowAllRooms(false);
|
||||
SpaceStore.instance.setActiveSpace(space1);
|
||||
|
||||
new SpaceWatcher(mockRoomListStore);
|
||||
expect(filter).toBeInstanceOf(SpaceFilterCondition);
|
||||
expect(filter!["space"]).toBe(space1);
|
||||
await setShowAllRooms(true);
|
||||
|
||||
expect(filter).toBeInstanceOf(SpaceFilterCondition);
|
||||
expect(filter!["space"]).toBe(space1);
|
||||
});
|
||||
|
||||
it("doesn't change filter when changing showAllRooms mode to false", async () => {
|
||||
await setShowAllRooms(true);
|
||||
SpaceStore.instance.setActiveSpace(space1);
|
||||
|
||||
new SpaceWatcher(mockRoomListStore);
|
||||
expect(filter).toBeInstanceOf(SpaceFilterCondition);
|
||||
expect(filter!["space"]).toBe(space1);
|
||||
await setShowAllRooms(false);
|
||||
|
||||
expect(filter).toBeInstanceOf(SpaceFilterCondition);
|
||||
expect(filter!["space"]).toBe(space1);
|
||||
});
|
||||
});
|
||||
@ -1,102 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { mocked, type MockedObject } from "jest-mock";
|
||||
import { PendingEventOrdering, Room, RoomStateEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
import { Widget } from "matrix-widget-api";
|
||||
|
||||
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import type { ClientWidgetApi } from "matrix-widget-api";
|
||||
import {
|
||||
stubClient,
|
||||
setupAsyncStoreWithClient,
|
||||
useMockedCalls,
|
||||
MockedCall,
|
||||
useMockMediaDevices,
|
||||
} from "../../../../test-utils";
|
||||
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
|
||||
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
|
||||
import { DefaultTagID } from "../../../../../src/stores/room-list/models";
|
||||
import { SortAlgorithm, ListAlgorithm } from "../../../../../src/stores/room-list/algorithms/models";
|
||||
import "../../../../../src/stores/room-list/RoomListStore"; // must be imported before Algorithm to avoid cycles
|
||||
import { Algorithm } from "../../../../../src/stores/room-list/algorithms/Algorithm";
|
||||
import { CallStore } from "../../../../../src/stores/CallStore";
|
||||
import { WidgetMessagingStore } from "../../../../../src/stores/widgets/WidgetMessagingStore";
|
||||
import { ConnectionState } from "../../../../../src/models/Call";
|
||||
|
||||
describe("Algorithm", () => {
|
||||
useMockedCalls();
|
||||
|
||||
let client: MockedObject<MatrixClient>;
|
||||
let algorithm: Algorithm;
|
||||
|
||||
beforeEach(() => {
|
||||
useMockMediaDevices();
|
||||
stubClient();
|
||||
client = mocked(MatrixClientPeg.safeGet());
|
||||
DMRoomMap.makeShared(client);
|
||||
|
||||
algorithm = new Algorithm();
|
||||
algorithm.start();
|
||||
algorithm.populateTags(
|
||||
{ [DefaultTagID.Untagged]: SortAlgorithm.Alphabetic },
|
||||
{ [DefaultTagID.Untagged]: ListAlgorithm.Natural },
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
algorithm.stop();
|
||||
});
|
||||
|
||||
it("sticks rooms with calls to the top when they're connected", async () => {
|
||||
const room = new Room("!1:example.org", client, "@alice:example.org", {
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
});
|
||||
const roomWithCall = new Room("!2:example.org", client, "@alice:example.org", {
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
});
|
||||
|
||||
client.getRoom.mockImplementation((roomId) => {
|
||||
switch (roomId) {
|
||||
case room.roomId:
|
||||
return room;
|
||||
case roomWithCall.roomId:
|
||||
return roomWithCall;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
client.getRooms.mockReturnValue([room, roomWithCall]);
|
||||
client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
|
||||
client.reEmitter.reEmit(roomWithCall, [RoomStateEvent.Events]);
|
||||
|
||||
for (const room of client.getRooms()) jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Join);
|
||||
algorithm.setKnownRooms(client.getRooms());
|
||||
|
||||
setupAsyncStoreWithClient(CallStore.instance, client);
|
||||
setupAsyncStoreWithClient(WidgetMessagingStore.instance, client);
|
||||
|
||||
MockedCall.create(roomWithCall, "1");
|
||||
const call = CallStore.instance.getCall(roomWithCall.roomId);
|
||||
if (!(call instanceof MockedCall)) throw new Error("Failed to create call");
|
||||
|
||||
const widget = new Widget(call.widget);
|
||||
WidgetMessagingStore.instance.storeMessaging(widget, roomWithCall.roomId, {
|
||||
stop: () => {},
|
||||
} as unknown as ClientWidgetApi);
|
||||
|
||||
// End of setup
|
||||
|
||||
expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([room, roomWithCall]);
|
||||
call.setConnectionState(ConnectionState.Connected);
|
||||
expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([roomWithCall, room]);
|
||||
await call.disconnect();
|
||||
expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([room, roomWithCall]);
|
||||
});
|
||||
});
|
||||
@ -1,177 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { Room, type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
|
||||
import { mkMessage, mkRoom, stubClient } from "../../../../test-utils";
|
||||
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
|
||||
import "../../../../../src/stores/room-list/RoomListStore";
|
||||
import { RecentAlgorithm } from "../../../../../src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
|
||||
import { makeThreadEvent, mkThread } from "../../../../test-utils/threads";
|
||||
import { DefaultTagID } from "../../../../../src/stores/room-list/models";
|
||||
|
||||
describe("RecentAlgorithm", () => {
|
||||
let algorithm: RecentAlgorithm;
|
||||
let cli: MatrixClient;
|
||||
|
||||
beforeEach(() => {
|
||||
stubClient();
|
||||
cli = MatrixClientPeg.safeGet();
|
||||
algorithm = new RecentAlgorithm();
|
||||
});
|
||||
|
||||
describe("getLastTs", () => {
|
||||
it("returns the last ts", () => {
|
||||
const room = new Room("room123", cli, "@john:matrix.org");
|
||||
|
||||
const event1 = mkMessage({
|
||||
room: room.roomId,
|
||||
msg: "Hello world!",
|
||||
user: "@alice:matrix.org",
|
||||
ts: 5,
|
||||
event: true,
|
||||
});
|
||||
const event2 = mkMessage({
|
||||
room: room.roomId,
|
||||
msg: "Howdy!",
|
||||
user: "@bob:matrix.org",
|
||||
ts: 10,
|
||||
event: true,
|
||||
});
|
||||
|
||||
room.getMyMembership = () => KnownMembership.Join;
|
||||
|
||||
room.addLiveEvents([event1], { addToState: true });
|
||||
expect(algorithm.getLastTs(room, "@jane:matrix.org")).toBe(5);
|
||||
expect(algorithm.getLastTs(room, "@john:matrix.org")).toBe(5);
|
||||
|
||||
room.addLiveEvents([event2], { addToState: true });
|
||||
|
||||
expect(algorithm.getLastTs(room, "@jane:matrix.org")).toBe(10);
|
||||
expect(algorithm.getLastTs(room, "@john:matrix.org")).toBe(10);
|
||||
});
|
||||
|
||||
it("returns a fake ts for rooms without a timeline", () => {
|
||||
const room = mkRoom(cli, "!new:example.org");
|
||||
// @ts-ignore
|
||||
room.timeline = undefined;
|
||||
expect(algorithm.getLastTs(room, "@john:matrix.org")).toBe(Number.MAX_SAFE_INTEGER);
|
||||
});
|
||||
|
||||
it("works when not a member", () => {
|
||||
const room = mkRoom(cli, "!new:example.org");
|
||||
room.getMyMembership.mockReturnValue(KnownMembership.Invite);
|
||||
expect(algorithm.getLastTs(room, "@john:matrix.org")).toBe(Number.MAX_SAFE_INTEGER);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortRooms", () => {
|
||||
it("orders rooms per last message ts", () => {
|
||||
const room1 = new Room("room1", cli, "@bob:matrix.org");
|
||||
const room2 = new Room("room2", cli, "@bob:matrix.org");
|
||||
|
||||
room1.getMyMembership = () => KnownMembership.Join;
|
||||
room2.getMyMembership = () => KnownMembership.Join;
|
||||
|
||||
const evt = mkMessage({
|
||||
room: room1.roomId,
|
||||
msg: "Hello world!",
|
||||
user: "@alice:matrix.org",
|
||||
ts: 5,
|
||||
event: true,
|
||||
});
|
||||
const evt2 = mkMessage({
|
||||
room: room2.roomId,
|
||||
msg: "Hello world!",
|
||||
user: "@alice:matrix.org",
|
||||
ts: 2,
|
||||
event: true,
|
||||
});
|
||||
|
||||
room1.addLiveEvents([evt], { addToState: true });
|
||||
room2.addLiveEvents([evt2], { addToState: true });
|
||||
|
||||
expect(algorithm.sortRooms([room2, room1], DefaultTagID.Untagged)).toEqual([room1, room2]);
|
||||
});
|
||||
|
||||
it("orders rooms without messages first", () => {
|
||||
const room1 = new Room("room1", cli, "@bob:matrix.org");
|
||||
const room2 = new Room("room2", cli, "@bob:matrix.org");
|
||||
|
||||
room1.getMyMembership = () => KnownMembership.Join;
|
||||
room2.getMyMembership = () => KnownMembership.Join;
|
||||
|
||||
const evt = mkMessage({
|
||||
room: room1.roomId,
|
||||
msg: "Hello world!",
|
||||
user: "@alice:matrix.org",
|
||||
ts: 5,
|
||||
event: true,
|
||||
});
|
||||
|
||||
room1.addLiveEvents([evt], { addToState: true });
|
||||
|
||||
expect(algorithm.sortRooms([room2, room1], DefaultTagID.Untagged)).toEqual([room2, room1]);
|
||||
|
||||
const { events } = mkThread({
|
||||
room: room1,
|
||||
client: cli,
|
||||
authorId: "@bob:matrix.org",
|
||||
participantUserIds: ["@bob:matrix.org"],
|
||||
ts: 12,
|
||||
});
|
||||
|
||||
room1.addLiveEvents(events, { addToState: true });
|
||||
});
|
||||
|
||||
it("orders rooms based on thread replies too", () => {
|
||||
const room1 = new Room("room1", cli, "@bob:matrix.org");
|
||||
const room2 = new Room("room2", cli, "@bob:matrix.org");
|
||||
|
||||
room1.getMyMembership = () => KnownMembership.Join;
|
||||
room2.getMyMembership = () => KnownMembership.Join;
|
||||
|
||||
const { rootEvent, events: events1 } = mkThread({
|
||||
room: room1,
|
||||
client: cli,
|
||||
authorId: "@bob:matrix.org",
|
||||
participantUserIds: ["@bob:matrix.org"],
|
||||
ts: 12,
|
||||
length: 5,
|
||||
});
|
||||
room1.addLiveEvents(events1, { addToState: true });
|
||||
|
||||
const { events: events2 } = mkThread({
|
||||
room: room2,
|
||||
client: cli,
|
||||
authorId: "@bob:matrix.org",
|
||||
participantUserIds: ["@bob:matrix.org"],
|
||||
ts: 14,
|
||||
length: 10,
|
||||
});
|
||||
room2.addLiveEvents(events2, { addToState: true });
|
||||
|
||||
expect(algorithm.sortRooms([room1, room2], DefaultTagID.Untagged)).toEqual([room2, room1]);
|
||||
|
||||
const threadReply = makeThreadEvent({
|
||||
user: "@bob:matrix.org",
|
||||
room: room1.roomId,
|
||||
event: true,
|
||||
msg: `hello world`,
|
||||
rootEventId: rootEvent.getId()!,
|
||||
replyToEventId: rootEvent.getId()!,
|
||||
// replies are 1ms after each other
|
||||
ts: 50,
|
||||
});
|
||||
room1.addLiveEvents([threadReply], { addToState: true });
|
||||
|
||||
expect(algorithm.sortRooms([room1, room2], DefaultTagID.Untagged)).toEqual([room1, room2]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,428 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { ConditionKind, MatrixEvent, PushRuleActionName, Room, RoomEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { RoomNotificationStateStore } from "../../../../../../src/stores/notifications/RoomNotificationStateStore";
|
||||
import { ImportanceAlgorithm } from "../../../../../../src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm";
|
||||
import { SortAlgorithm } from "../../../../../../src/stores/room-list/algorithms/models";
|
||||
import * as RoomNotifs from "../../../../../../src/RoomNotifs";
|
||||
import { DefaultTagID, RoomUpdateCause } from "../../../../../../src/stores/room-list/models";
|
||||
import { NotificationLevel } from "../../../../../../src/stores/notifications/NotificationLevel";
|
||||
import { AlphabeticAlgorithm } from "../../../../../../src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm";
|
||||
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../../../test-utils";
|
||||
import { RecentAlgorithm } from "../../../../../../src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
|
||||
import { DEFAULT_PUSH_RULES, makePushRule } from "../../../../../test-utils/pushRules";
|
||||
|
||||
describe("ImportanceAlgorithm", () => {
|
||||
const userId = "@alice:server.org";
|
||||
const tagId = DefaultTagID.Favourite;
|
||||
|
||||
const makeRoom = (id: string, name: string, order?: number): Room => {
|
||||
const room = new Room(id, client, userId);
|
||||
room.name = name;
|
||||
const tagEvent = new MatrixEvent({
|
||||
type: "m.tag",
|
||||
content: {
|
||||
tags: {
|
||||
[tagId]: {
|
||||
order,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
room.addTags(tagEvent);
|
||||
return room;
|
||||
};
|
||||
|
||||
const client = getMockClientWithEventEmitter({
|
||||
...mockClientMethodsUser(userId),
|
||||
});
|
||||
const roomA = makeRoom("!aaa:server.org", "Alpha", 2);
|
||||
const roomB = makeRoom("!bbb:server.org", "Bravo", 5);
|
||||
const roomC = makeRoom("!ccc:server.org", "Charlie", 1);
|
||||
const roomD = makeRoom("!ddd:server.org", "Delta", 4);
|
||||
const roomE = makeRoom("!eee:server.org", "Echo", 3);
|
||||
const roomX = makeRoom("!xxx:server.org", "Xylophone", 99);
|
||||
|
||||
const muteRoomARule = makePushRule(roomA.roomId, {
|
||||
actions: [PushRuleActionName.DontNotify],
|
||||
conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: roomA.roomId }],
|
||||
});
|
||||
const muteRoomBRule = makePushRule(roomB.roomId, {
|
||||
actions: [PushRuleActionName.DontNotify],
|
||||
conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: roomB.roomId }],
|
||||
});
|
||||
client.pushRules = {
|
||||
global: {
|
||||
...DEFAULT_PUSH_RULES.global,
|
||||
override: [...DEFAULT_PUSH_RULES.global.override!, muteRoomARule, muteRoomBRule],
|
||||
},
|
||||
};
|
||||
|
||||
const unreadStates: Record<string, ReturnType<(typeof RoomNotifs)["determineUnreadState"]>> = {
|
||||
red: { symbol: null, count: 1, level: NotificationLevel.Highlight, invited: false },
|
||||
grey: { symbol: null, count: 1, level: NotificationLevel.Notification, invited: false },
|
||||
none: { symbol: null, count: 0, level: NotificationLevel.None, invited: false },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(RoomNotifs, "determineUnreadState").mockReturnValue({
|
||||
symbol: null,
|
||||
count: 0,
|
||||
level: NotificationLevel.None,
|
||||
invited: false,
|
||||
});
|
||||
});
|
||||
|
||||
const setupAlgorithm = (sortAlgorithm: SortAlgorithm, rooms?: Room[]) => {
|
||||
const algorithm = new ImportanceAlgorithm(tagId, sortAlgorithm);
|
||||
algorithm.setRooms(rooms || [roomA, roomB, roomC]);
|
||||
return algorithm;
|
||||
};
|
||||
|
||||
describe("When sortAlgorithm is manual", () => {
|
||||
const sortAlgorithm = SortAlgorithm.Manual;
|
||||
it("orders rooms by tag order without categorizing", () => {
|
||||
jest.spyOn(RoomNotificationStateStore.instance, "getRoomState");
|
||||
const algorithm = setupAlgorithm(sortAlgorithm);
|
||||
|
||||
// didn't check notif state
|
||||
expect(RoomNotificationStateStore.instance.getRoomState).not.toHaveBeenCalled();
|
||||
// sorted according to room tag order
|
||||
expect(algorithm.orderedRooms).toEqual([roomC, roomA, roomB]);
|
||||
});
|
||||
|
||||
describe("handleRoomUpdate", () => {
|
||||
// XXX: This doesn't work because manual ordered rooms dont get categoryindices
|
||||
// possibly related https://github.com/vector-im/element-web/issues/25099
|
||||
it.skip("removes a room", () => {
|
||||
const algorithm = setupAlgorithm(sortAlgorithm);
|
||||
|
||||
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomA, RoomUpdateCause.RoomRemoved);
|
||||
|
||||
expect(shouldTriggerUpdate).toBe(true);
|
||||
expect(algorithm.orderedRooms).toEqual([roomC, roomB]);
|
||||
});
|
||||
|
||||
// XXX: This doesn't work because manual ordered rooms dont get categoryindices
|
||||
it.skip("adds a new room", () => {
|
||||
const algorithm = setupAlgorithm(sortAlgorithm);
|
||||
|
||||
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomD, RoomUpdateCause.NewRoom);
|
||||
|
||||
expect(shouldTriggerUpdate).toBe(true);
|
||||
expect(algorithm.orderedRooms).toEqual([roomC, roomB, roomD, roomE]);
|
||||
});
|
||||
|
||||
it("does nothing and returns false for a timeline update", () => {
|
||||
const algorithm = setupAlgorithm(sortAlgorithm);
|
||||
|
||||
const beforeRooms = algorithm.orderedRooms;
|
||||
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomA, RoomUpdateCause.Timeline);
|
||||
|
||||
expect(shouldTriggerUpdate).toBe(false);
|
||||
// strict equal
|
||||
expect(algorithm.orderedRooms).toBe(beforeRooms);
|
||||
});
|
||||
|
||||
it("does nothing and returns false for a read receipt update", () => {
|
||||
const algorithm = setupAlgorithm(sortAlgorithm);
|
||||
|
||||
const beforeRooms = algorithm.orderedRooms;
|
||||
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomA, RoomUpdateCause.ReadReceipt);
|
||||
|
||||
expect(shouldTriggerUpdate).toBe(false);
|
||||
// strict equal
|
||||
expect(algorithm.orderedRooms).toBe(beforeRooms);
|
||||
});
|
||||
|
||||
it("throws for an unhandle update cause", () => {
|
||||
const algorithm = setupAlgorithm(sortAlgorithm);
|
||||
|
||||
expect(() =>
|
||||
algorithm.handleRoomUpdate(roomA, "something unexpected" as unknown as RoomUpdateCause),
|
||||
).toThrow("Unsupported update cause: something unexpected");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("When sortAlgorithm is alphabetical", () => {
|
||||
const sortAlgorithm = SortAlgorithm.Alphabetic;
|
||||
|
||||
beforeEach(async () => {
|
||||
// destroy roomMap so we can start fresh
|
||||
// @ts-ignore private property
|
||||
RoomNotificationStateStore.instance.roomMap = new Map<Room, RoomNotificationState>();
|
||||
|
||||
jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear();
|
||||
jest.spyOn(RoomNotifs, "determineUnreadState")
|
||||
.mockClear()
|
||||
.mockImplementation((room) => {
|
||||
switch (room) {
|
||||
// b and e have red notifs
|
||||
case roomB:
|
||||
case roomE:
|
||||
return unreadStates.red;
|
||||
// c is grey
|
||||
case roomC:
|
||||
return unreadStates.grey;
|
||||
default:
|
||||
return unreadStates.none;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("orders rooms by alpha when they have the same notif state", () => {
|
||||
jest.spyOn(RoomNotifs, "determineUnreadState").mockReturnValue({
|
||||
symbol: null,
|
||||
count: 0,
|
||||
level: NotificationLevel.None,
|
||||
invited: false,
|
||||
});
|
||||
const algorithm = setupAlgorithm(sortAlgorithm);
|
||||
|
||||
// sorted according to alpha
|
||||
expect(algorithm.orderedRooms).toEqual([roomA, roomB, roomC]);
|
||||
});
|
||||
|
||||
it("orders rooms by notification state then alpha", () => {
|
||||
const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]);
|
||||
|
||||
expect(algorithm.orderedRooms).toEqual([
|
||||
// alpha within red
|
||||
roomB,
|
||||
roomE,
|
||||
// grey
|
||||
roomC,
|
||||
// alpha within none
|
||||
roomA,
|
||||
roomD,
|
||||
]);
|
||||
});
|
||||
|
||||
describe("handleRoomUpdate", () => {
|
||||
it("removes a room", () => {
|
||||
const algorithm = setupAlgorithm(sortAlgorithm);
|
||||
jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear();
|
||||
|
||||
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomA, RoomUpdateCause.RoomRemoved);
|
||||
|
||||
expect(shouldTriggerUpdate).toBe(true);
|
||||
expect(algorithm.orderedRooms).toEqual([roomB, roomC]);
|
||||
// no re-sorting on a remove
|
||||
expect(AlphabeticAlgorithm.prototype.sortRooms).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("warns and returns without change when removing a room that is not indexed", () => {
|
||||
jest.spyOn(logger, "warn").mockReturnValue(undefined);
|
||||
const algorithm = setupAlgorithm(sortAlgorithm);
|
||||
|
||||
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomD, RoomUpdateCause.RoomRemoved);
|
||||
|
||||
expect(shouldTriggerUpdate).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(`Tried to remove unknown room from ${tagId}: ${roomD.roomId}`);
|
||||
});
|
||||
|
||||
it("adds a new room", () => {
|
||||
const algorithm = setupAlgorithm(sortAlgorithm);
|
||||
jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear();
|
||||
|
||||
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.NewRoom);
|
||||
|
||||
expect(shouldTriggerUpdate).toBe(true);
|
||||
// inserted according to notif state
|
||||
expect(algorithm.orderedRooms).toEqual([roomB, roomE, roomC, roomA]);
|
||||
// only sorted within category
|
||||
expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomB, roomE], tagId);
|
||||
});
|
||||
|
||||
it("throws for an unhandled update cause", () => {
|
||||
const algorithm = setupAlgorithm(sortAlgorithm);
|
||||
|
||||
expect(() =>
|
||||
algorithm.handleRoomUpdate(roomA, "something unexpected" as unknown as RoomUpdateCause),
|
||||
).toThrow("Unsupported update cause: something unexpected");
|
||||
});
|
||||
|
||||
it("ignores a mute change", () => {
|
||||
// muted rooms are not pushed to the bottom when sort is alpha
|
||||
const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]);
|
||||
jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear();
|
||||
|
||||
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.PossibleMuteChange);
|
||||
|
||||
expect(shouldTriggerUpdate).toBe(false);
|
||||
// no sorting
|
||||
expect(AlphabeticAlgorithm.prototype.sortRooms).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("time and read receipt updates", () => {
|
||||
it("throws for when a room is not indexed", () => {
|
||||
const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]);
|
||||
|
||||
expect(() => algorithm.handleRoomUpdate(roomX, RoomUpdateCause.Timeline)).toThrow(
|
||||
`Room ${roomX.roomId} has no index in ${tagId}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("re-sorts category when updated room has not changed category", () => {
|
||||
const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]);
|
||||
jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear();
|
||||
|
||||
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.Timeline);
|
||||
|
||||
expect(shouldTriggerUpdate).toBe(true);
|
||||
expect(algorithm.orderedRooms).toEqual([roomB, roomE, roomC, roomA, roomD]);
|
||||
// only sorted within category
|
||||
expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledTimes(1);
|
||||
expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomB, roomE], tagId);
|
||||
});
|
||||
|
||||
it("re-sorts category when updated room has changed category", () => {
|
||||
const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]);
|
||||
jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear();
|
||||
|
||||
// change roomE to unreadState.none
|
||||
jest.spyOn(RoomNotifs, "determineUnreadState").mockImplementation((room) => {
|
||||
switch (room) {
|
||||
// b and e have red notifs
|
||||
case roomB:
|
||||
return unreadStates.red;
|
||||
// c is grey
|
||||
case roomC:
|
||||
return unreadStates.grey;
|
||||
case roomE:
|
||||
default:
|
||||
return unreadStates.none;
|
||||
}
|
||||
});
|
||||
// @ts-ignore don't bother mocking rest of emit properties
|
||||
roomE.emit(RoomEvent.Timeline, new MatrixEvent({ type: "whatever", room_id: roomE.roomId }));
|
||||
|
||||
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.Timeline);
|
||||
|
||||
expect(shouldTriggerUpdate).toBe(true);
|
||||
expect(algorithm.orderedRooms).toEqual([roomB, roomC, roomA, roomD, roomE]);
|
||||
|
||||
// only sorted within roomE's new category
|
||||
expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledTimes(1);
|
||||
expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomA, roomD, roomE], tagId);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("When sortAlgorithm is recent", () => {
|
||||
const sortAlgorithm = SortAlgorithm.Recent;
|
||||
|
||||
// mock recent algorithm sorting
|
||||
const fakeRecentOrder = [roomC, roomB, roomE, roomD, roomA];
|
||||
|
||||
beforeEach(async () => {
|
||||
// destroy roomMap so we can start fresh
|
||||
// @ts-ignore private property
|
||||
RoomNotificationStateStore.instance.roomMap = new Map<Room, RoomNotificationState>();
|
||||
|
||||
jest.spyOn(RecentAlgorithm.prototype, "sortRooms")
|
||||
.mockClear()
|
||||
.mockImplementation((rooms: Room[]) =>
|
||||
fakeRecentOrder.filter((sortedRoom) => rooms.includes(sortedRoom)),
|
||||
);
|
||||
jest.spyOn(RoomNotifs, "determineUnreadState")
|
||||
.mockClear()
|
||||
.mockImplementation((room) => {
|
||||
switch (room) {
|
||||
// b, c and e have red notifs
|
||||
case roomB:
|
||||
case roomE:
|
||||
case roomC:
|
||||
return unreadStates.red;
|
||||
default:
|
||||
return unreadStates.none;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("orders rooms by recent when they have the same notif state", () => {
|
||||
jest.spyOn(RoomNotifs, "determineUnreadState").mockReturnValue({
|
||||
symbol: null,
|
||||
count: 0,
|
||||
level: NotificationLevel.None,
|
||||
invited: false,
|
||||
});
|
||||
const algorithm = setupAlgorithm(sortAlgorithm);
|
||||
|
||||
// sorted according to recent
|
||||
expect(algorithm.orderedRooms).toEqual([roomC, roomB, roomA]);
|
||||
});
|
||||
|
||||
it("orders rooms by notification state then recent", () => {
|
||||
const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]);
|
||||
|
||||
expect(algorithm.orderedRooms).toEqual([
|
||||
// recent within red
|
||||
roomC,
|
||||
roomE,
|
||||
// recent within none
|
||||
roomD,
|
||||
// muted
|
||||
roomB,
|
||||
roomA,
|
||||
]);
|
||||
});
|
||||
|
||||
describe("handleRoomUpdate", () => {
|
||||
it("removes a room", () => {
|
||||
const algorithm = setupAlgorithm(sortAlgorithm);
|
||||
jest.spyOn(RecentAlgorithm.prototype, "sortRooms").mockClear();
|
||||
|
||||
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomA, RoomUpdateCause.RoomRemoved);
|
||||
|
||||
expect(shouldTriggerUpdate).toBe(true);
|
||||
expect(algorithm.orderedRooms).toEqual([roomC, roomB]);
|
||||
// no re-sorting on a remove
|
||||
expect(RecentAlgorithm.prototype.sortRooms).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("warns and returns without change when removing a room that is not indexed", () => {
|
||||
jest.spyOn(logger, "warn").mockReturnValue(undefined);
|
||||
const algorithm = setupAlgorithm(sortAlgorithm);
|
||||
|
||||
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomD, RoomUpdateCause.RoomRemoved);
|
||||
|
||||
expect(shouldTriggerUpdate).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(`Tried to remove unknown room from ${tagId}: ${roomD.roomId}`);
|
||||
});
|
||||
|
||||
it("adds a new room", () => {
|
||||
const algorithm = setupAlgorithm(sortAlgorithm);
|
||||
jest.spyOn(RecentAlgorithm.prototype, "sortRooms").mockClear();
|
||||
|
||||
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.NewRoom);
|
||||
|
||||
expect(shouldTriggerUpdate).toBe(true);
|
||||
// inserted according to notif state and mute
|
||||
expect(algorithm.orderedRooms).toEqual([roomC, roomE, roomB, roomA]);
|
||||
// only sorted within category
|
||||
expect(RecentAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomE, roomC], tagId);
|
||||
});
|
||||
|
||||
it("re-sorts on a mute change", () => {
|
||||
const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]);
|
||||
jest.spyOn(RecentAlgorithm.prototype, "sortRooms").mockClear();
|
||||
|
||||
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.PossibleMuteChange);
|
||||
|
||||
expect(shouldTriggerUpdate).toBe(true);
|
||||
expect(RecentAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomC, roomE], tagId);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,281 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { ConditionKind, EventType, MatrixEvent, PushRuleActionName, Room, ClientEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { NaturalAlgorithm } from "../../../../../../src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm";
|
||||
import { SortAlgorithm } from "../../../../../../src/stores/room-list/algorithms/models";
|
||||
import { DefaultTagID, RoomUpdateCause } from "../../../../../../src/stores/room-list/models";
|
||||
import { AlphabeticAlgorithm } from "../../../../../../src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm";
|
||||
import { RecentAlgorithm } from "../../../../../../src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
|
||||
import { RoomNotificationStateStore } from "../../../../../../src/stores/notifications/RoomNotificationStateStore";
|
||||
import * as RoomNotifs from "../../../../../../src/RoomNotifs";
|
||||
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../../../test-utils";
|
||||
import { DEFAULT_PUSH_RULES, makePushRule } from "../../../../../test-utils/pushRules";
|
||||
import { NotificationLevel } from "../../../../../../src/stores/notifications/NotificationLevel";
|
||||
|
||||
describe("NaturalAlgorithm", () => {
|
||||
const userId = "@alice:server.org";
|
||||
const tagId = DefaultTagID.Favourite;
|
||||
|
||||
const makeRoom = (id: string, name: string): Room => {
|
||||
const room = new Room(id, client, userId);
|
||||
room.name = name;
|
||||
return room;
|
||||
};
|
||||
|
||||
const client = getMockClientWithEventEmitter({
|
||||
...mockClientMethodsUser(userId),
|
||||
});
|
||||
const roomA = makeRoom("!aaa:server.org", "Alpha");
|
||||
const roomB = makeRoom("!bbb:server.org", "Bravo");
|
||||
const roomC = makeRoom("!ccc:server.org", "Charlie");
|
||||
const roomD = makeRoom("!ddd:server.org", "Delta");
|
||||
const roomE = makeRoom("!eee:server.org", "Echo");
|
||||
const roomX = makeRoom("!xxx:server.org", "Xylophone");
|
||||
|
||||
const muteRoomARule = makePushRule(roomA.roomId, {
|
||||
actions: [PushRuleActionName.DontNotify],
|
||||
conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: roomA.roomId }],
|
||||
});
|
||||
const muteRoomDRule = makePushRule(roomD.roomId, {
|
||||
actions: [PushRuleActionName.DontNotify],
|
||||
conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: roomD.roomId }],
|
||||
});
|
||||
client.pushRules = {
|
||||
global: {
|
||||
...DEFAULT_PUSH_RULES.global,
|
||||
override: [...DEFAULT_PUSH_RULES.global!.override!, muteRoomARule, muteRoomDRule],
|
||||
},
|
||||
};
|
||||
|
||||
const setupAlgorithm = (sortAlgorithm: SortAlgorithm, rooms?: Room[]) => {
|
||||
const algorithm = new NaturalAlgorithm(tagId, sortAlgorithm);
|
||||
algorithm.setRooms(rooms || [roomA, roomB, roomC]);
|
||||
return algorithm;
|
||||
};
|
||||
|
||||
describe("When sortAlgorithm is alphabetical", () => {
|
||||
const sortAlgorithm = SortAlgorithm.Alphabetic;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear();
|
||||
});
|
||||
|
||||
it("orders rooms by alpha", () => {
|
||||
const algorithm = setupAlgorithm(sortAlgorithm);
|
||||
|
||||
// sorted according to alpha
|
||||
expect(algorithm.orderedRooms).toEqual([roomA, roomB, roomC]);
|
||||
});
|
||||
|
||||
describe("handleRoomUpdate", () => {
|
||||
it("removes a room", () => {
|
||||
const algorithm = setupAlgorithm(sortAlgorithm);
|
||||
jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear();
|
||||
|
||||
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomA, RoomUpdateCause.RoomRemoved);
|
||||
|
||||
expect(shouldTriggerUpdate).toBe(true);
|
||||
expect(algorithm.orderedRooms).toEqual([roomB, roomC]);
|
||||
});
|
||||
|
||||
it("warns when removing a room that is not indexed", () => {
|
||||
jest.spyOn(logger, "warn").mockReturnValue(undefined);
|
||||
const algorithm = setupAlgorithm(sortAlgorithm);
|
||||
|
||||
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomD, RoomUpdateCause.RoomRemoved);
|
||||
|
||||
expect(shouldTriggerUpdate).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(`Tried to remove unknown room from ${tagId}: ${roomD.roomId}`);
|
||||
});
|
||||
|
||||
it("adds a new room", () => {
|
||||
const algorithm = setupAlgorithm(sortAlgorithm);
|
||||
jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear();
|
||||
|
||||
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.NewRoom);
|
||||
|
||||
expect(shouldTriggerUpdate).toBe(true);
|
||||
expect(algorithm.orderedRooms).toEqual([roomA, roomB, roomC, roomE]);
|
||||
// only sorted within category
|
||||
expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledWith(
|
||||
[roomA, roomB, roomC, roomE],
|
||||
tagId,
|
||||
);
|
||||
});
|
||||
|
||||
it("adds a new muted room", () => {
|
||||
const algorithm = setupAlgorithm(sortAlgorithm, [roomA, roomB, roomE]);
|
||||
jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear();
|
||||
|
||||
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomD, RoomUpdateCause.NewRoom);
|
||||
|
||||
expect(shouldTriggerUpdate).toBe(true);
|
||||
// muted room mixed in main category
|
||||
expect(algorithm.orderedRooms).toEqual([roomA, roomB, roomD, roomE]);
|
||||
// only sorted within category
|
||||
expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores a mute change update", () => {
|
||||
const algorithm = setupAlgorithm(sortAlgorithm);
|
||||
jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear();
|
||||
|
||||
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomA, RoomUpdateCause.PossibleMuteChange);
|
||||
|
||||
expect(shouldTriggerUpdate).toBe(false);
|
||||
expect(AlphabeticAlgorithm.prototype.sortRooms).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws for an unhandled update cause", () => {
|
||||
const algorithm = setupAlgorithm(sortAlgorithm);
|
||||
|
||||
expect(() =>
|
||||
algorithm.handleRoomUpdate(roomA, "something unexpected" as unknown as RoomUpdateCause),
|
||||
).toThrow("Unsupported update cause: something unexpected");
|
||||
});
|
||||
|
||||
describe("time and read receipt updates", () => {
|
||||
it("handles when a room is not indexed", () => {
|
||||
const algorithm = setupAlgorithm(sortAlgorithm);
|
||||
|
||||
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomX, RoomUpdateCause.Timeline);
|
||||
|
||||
// for better or worse natural alg sets this to true
|
||||
expect(shouldTriggerUpdate).toBe(true);
|
||||
expect(algorithm.orderedRooms).toEqual([roomA, roomB, roomC]);
|
||||
});
|
||||
|
||||
it("re-sorts rooms when timeline updates", () => {
|
||||
const algorithm = setupAlgorithm(sortAlgorithm);
|
||||
jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear();
|
||||
|
||||
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomA, RoomUpdateCause.Timeline);
|
||||
|
||||
expect(shouldTriggerUpdate).toBe(true);
|
||||
expect(algorithm.orderedRooms).toEqual([roomA, roomB, roomC]);
|
||||
// only sorted within category
|
||||
expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledTimes(1);
|
||||
expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomA, roomB, roomC], tagId);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("When sortAlgorithm is recent", () => {
|
||||
const sortAlgorithm = SortAlgorithm.Recent;
|
||||
|
||||
// mock recent algorithm sorting
|
||||
const fakeRecentOrder = [roomC, roomA, roomB, roomD, roomE];
|
||||
|
||||
beforeEach(async () => {
|
||||
// destroy roomMap so we can start fresh
|
||||
// @ts-ignore private property
|
||||
RoomNotificationStateStore.instance.roomMap = new Map<Room, RoomNotificationState>();
|
||||
|
||||
jest.spyOn(RecentAlgorithm.prototype, "sortRooms")
|
||||
.mockClear()
|
||||
.mockImplementation((rooms: Room[]) =>
|
||||
fakeRecentOrder.filter((sortedRoom) => rooms.includes(sortedRoom)),
|
||||
);
|
||||
|
||||
jest.spyOn(RoomNotifs, "determineUnreadState").mockReturnValue({
|
||||
symbol: null,
|
||||
count: 0,
|
||||
level: NotificationLevel.None,
|
||||
invited: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("orders rooms by recent with muted rooms to the bottom", () => {
|
||||
const algorithm = setupAlgorithm(sortAlgorithm);
|
||||
|
||||
// sorted according to recent
|
||||
expect(algorithm.orderedRooms).toEqual([roomC, roomB, roomA]);
|
||||
});
|
||||
|
||||
describe("handleRoomUpdate", () => {
|
||||
it("removes a room", () => {
|
||||
const algorithm = setupAlgorithm(sortAlgorithm);
|
||||
jest.spyOn(RecentAlgorithm.prototype, "sortRooms").mockClear();
|
||||
|
||||
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomA, RoomUpdateCause.RoomRemoved);
|
||||
|
||||
expect(shouldTriggerUpdate).toBe(true);
|
||||
expect(algorithm.orderedRooms).toEqual([roomC, roomB]);
|
||||
// no re-sorting on a remove
|
||||
expect(RecentAlgorithm.prototype.sortRooms).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("warns and returns without change when removing a room that is not indexed", () => {
|
||||
jest.spyOn(logger, "warn").mockReturnValue(undefined);
|
||||
const algorithm = setupAlgorithm(sortAlgorithm);
|
||||
|
||||
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomD, RoomUpdateCause.RoomRemoved);
|
||||
|
||||
expect(shouldTriggerUpdate).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(`Tried to remove unknown room from ${tagId}: ${roomD.roomId}`);
|
||||
});
|
||||
|
||||
it("adds a new room", () => {
|
||||
const algorithm = setupAlgorithm(sortAlgorithm);
|
||||
jest.spyOn(RecentAlgorithm.prototype, "sortRooms").mockClear();
|
||||
|
||||
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.NewRoom);
|
||||
|
||||
expect(shouldTriggerUpdate).toBe(true);
|
||||
// inserted according to mute then recentness
|
||||
expect(algorithm.orderedRooms).toEqual([roomC, roomB, roomE, roomA]);
|
||||
// only sorted within category, muted roomA is not resorted
|
||||
expect(RecentAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomC, roomB, roomE], tagId);
|
||||
});
|
||||
|
||||
it("does not re-sort on possible mute change when room did not change effective mutedness", () => {
|
||||
const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]);
|
||||
jest.spyOn(RecentAlgorithm.prototype, "sortRooms").mockClear();
|
||||
|
||||
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.PossibleMuteChange);
|
||||
|
||||
expect(shouldTriggerUpdate).toBe(false);
|
||||
expect(RecentAlgorithm.prototype.sortRooms).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-sorts on a mute change", () => {
|
||||
const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]);
|
||||
jest.spyOn(RecentAlgorithm.prototype, "sortRooms").mockClear();
|
||||
|
||||
// mute roomE
|
||||
const muteRoomERule = makePushRule(roomE.roomId, {
|
||||
actions: [PushRuleActionName.DontNotify],
|
||||
conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: roomE.roomId }],
|
||||
});
|
||||
const pushRulesEvent = new MatrixEvent({ type: EventType.PushRules });
|
||||
client.pushRules!.global!.override!.push(muteRoomERule);
|
||||
client.emit(ClientEvent.AccountData, pushRulesEvent);
|
||||
|
||||
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.PossibleMuteChange);
|
||||
|
||||
expect(shouldTriggerUpdate).toBe(true);
|
||||
expect(algorithm.orderedRooms).toEqual([
|
||||
// unmuted, sorted by recent
|
||||
roomC,
|
||||
roomB,
|
||||
// muted, sorted by recent
|
||||
roomA,
|
||||
roomD,
|
||||
roomE,
|
||||
]);
|
||||
// only sorted muted category
|
||||
expect(RecentAlgorithm.prototype.sortRooms).toHaveBeenCalledTimes(1);
|
||||
expect(RecentAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomA, roomD, roomE], tagId);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,198 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { mocked } from "jest-mock";
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
||||
import { FILTER_CHANGED } from "../../../../../src/stores/room-list/filters/IFilterCondition";
|
||||
import { SpaceFilterCondition } from "../../../../../src/stores/room-list/filters/SpaceFilterCondition";
|
||||
import { MetaSpace, type SpaceKey } from "../../../../../src/stores/spaces";
|
||||
import SpaceStore from "../../../../../src/stores/spaces/SpaceStore";
|
||||
|
||||
jest.mock("../../../../../src/settings/SettingsStore");
|
||||
jest.mock("../../../../../src/stores/spaces/SpaceStore", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const EventEmitter = require("events");
|
||||
class MockSpaceStore extends EventEmitter {
|
||||
isRoomInSpace = jest.fn();
|
||||
getSpaceFilteredUserIds = jest.fn().mockReturnValue(new Set<string>([]));
|
||||
getSpaceFilteredRoomIds = jest.fn().mockReturnValue(new Set<string>([]));
|
||||
}
|
||||
return { instance: new MockSpaceStore() };
|
||||
});
|
||||
|
||||
const SettingsStoreMock = mocked(SettingsStore);
|
||||
const SpaceStoreInstanceMock = mocked(SpaceStore.instance);
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
describe("SpaceFilterCondition", () => {
|
||||
const space1 = "!space1:server";
|
||||
const space2 = "!space2:server";
|
||||
const room1Id = "!r1:server";
|
||||
const room2Id = "!r2:server";
|
||||
const room3Id = "!r3:server";
|
||||
const user1Id = "@u1:server";
|
||||
const user2Id = "@u2:server";
|
||||
const user3Id = "@u3:server";
|
||||
const makeMockGetValue =
|
||||
(settings: Record<string, any> = {}) =>
|
||||
(settingName: string, space: SpaceKey) =>
|
||||
settings[settingName]?.[space] || false;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
SettingsStoreMock.getValue.mockClear().mockImplementation(makeMockGetValue());
|
||||
SpaceStoreInstanceMock.getSpaceFilteredUserIds.mockReturnValue(new Set([]));
|
||||
SpaceStoreInstanceMock.isRoomInSpace.mockReturnValue(true);
|
||||
});
|
||||
|
||||
const initFilter = (space: SpaceKey): SpaceFilterCondition => {
|
||||
const filter = new SpaceFilterCondition();
|
||||
filter.updateSpace(space);
|
||||
jest.runOnlyPendingTimers();
|
||||
return filter;
|
||||
};
|
||||
|
||||
describe("isVisible", () => {
|
||||
const room1 = { roomId: room1Id } as unknown as Room;
|
||||
it("calls isRoomInSpace correctly", () => {
|
||||
const filter = initFilter(space1);
|
||||
|
||||
expect(filter.isVisible(room1)).toEqual(true);
|
||||
expect(SpaceStoreInstanceMock.isRoomInSpace).toHaveBeenCalledWith(space1, room1Id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onStoreUpdate", () => {
|
||||
it("emits filter changed event when updateSpace is called even without changes", async () => {
|
||||
const filter = new SpaceFilterCondition();
|
||||
const emitSpy = jest.spyOn(filter, "emit");
|
||||
filter.updateSpace(space1);
|
||||
jest.runOnlyPendingTimers();
|
||||
expect(emitSpy).toHaveBeenCalledWith(FILTER_CHANGED);
|
||||
});
|
||||
|
||||
describe("showPeopleInSpace setting", () => {
|
||||
it("emits filter changed event when setting changes", async () => {
|
||||
// init filter with setting true for space1
|
||||
SettingsStoreMock.getValue.mockImplementation(
|
||||
makeMockGetValue({
|
||||
["Spaces.showPeopleInSpace"]: { [space1]: true },
|
||||
}),
|
||||
);
|
||||
const filter = initFilter(space1);
|
||||
const emitSpy = jest.spyOn(filter, "emit");
|
||||
|
||||
SettingsStoreMock.getValue.mockClear().mockImplementation(
|
||||
makeMockGetValue({
|
||||
["Spaces.showPeopleInSpace"]: { [space1]: false },
|
||||
}),
|
||||
);
|
||||
|
||||
SpaceStoreInstanceMock.emit(space1);
|
||||
jest.runOnlyPendingTimers();
|
||||
expect(emitSpy).toHaveBeenCalledWith(FILTER_CHANGED);
|
||||
});
|
||||
|
||||
it("emits filter changed event when setting is false and space changes to a meta space", async () => {
|
||||
// init filter with setting true for space1
|
||||
SettingsStoreMock.getValue.mockImplementation(
|
||||
makeMockGetValue({
|
||||
["Spaces.showPeopleInSpace"]: { [space1]: false },
|
||||
}),
|
||||
);
|
||||
const filter = initFilter(space1);
|
||||
const emitSpy = jest.spyOn(filter, "emit");
|
||||
|
||||
filter.updateSpace(MetaSpace.Home);
|
||||
jest.runOnlyPendingTimers();
|
||||
expect(emitSpy).toHaveBeenCalledWith(FILTER_CHANGED);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not emit filter changed event on store update when nothing changed", async () => {
|
||||
const filter = initFilter(space1);
|
||||
const emitSpy = jest.spyOn(filter, "emit");
|
||||
SpaceStoreInstanceMock.emit(space1);
|
||||
jest.runOnlyPendingTimers();
|
||||
expect(emitSpy).not.toHaveBeenCalledWith(FILTER_CHANGED);
|
||||
});
|
||||
|
||||
it("removes listener when updateSpace is called", async () => {
|
||||
const filter = initFilter(space1);
|
||||
filter.updateSpace(space2);
|
||||
jest.runOnlyPendingTimers();
|
||||
const emitSpy = jest.spyOn(filter, "emit");
|
||||
|
||||
// update mock so filter would emit change if it was listening to space1
|
||||
SpaceStoreInstanceMock.getSpaceFilteredRoomIds.mockReturnValue(new Set([room1Id]));
|
||||
SpaceStoreInstanceMock.emit(space1);
|
||||
jest.runOnlyPendingTimers();
|
||||
// no filter changed event
|
||||
expect(emitSpy).not.toHaveBeenCalledWith(FILTER_CHANGED);
|
||||
});
|
||||
|
||||
it("removes listener when destroy is called", async () => {
|
||||
const filter = initFilter(space1);
|
||||
filter.destroy();
|
||||
jest.runOnlyPendingTimers();
|
||||
const emitSpy = jest.spyOn(filter, "emit");
|
||||
|
||||
// update mock so filter would emit change if it was listening to space1
|
||||
SpaceStoreInstanceMock.getSpaceFilteredRoomIds.mockReturnValue(new Set([room1Id]));
|
||||
SpaceStoreInstanceMock.emit(space1);
|
||||
jest.runOnlyPendingTimers();
|
||||
// no filter changed event
|
||||
expect(emitSpy).not.toHaveBeenCalledWith(FILTER_CHANGED);
|
||||
});
|
||||
|
||||
describe("when directChildRoomIds change", () => {
|
||||
beforeEach(() => {
|
||||
SpaceStoreInstanceMock.getSpaceFilteredRoomIds.mockReturnValue(new Set([room1Id, room2Id]));
|
||||
});
|
||||
const filterChangedCases = [
|
||||
["room added", [room1Id, room2Id, room3Id]],
|
||||
["room removed", [room1Id]],
|
||||
["room swapped", [room1Id, room3Id]], // same number of rooms with changes
|
||||
];
|
||||
|
||||
it.each(filterChangedCases)("%s", (_d, rooms) => {
|
||||
const filter = initFilter(space1);
|
||||
const emitSpy = jest.spyOn(filter, "emit");
|
||||
|
||||
SpaceStoreInstanceMock.getSpaceFilteredRoomIds.mockReturnValue(new Set(rooms));
|
||||
SpaceStoreInstanceMock.emit(space1);
|
||||
jest.runOnlyPendingTimers();
|
||||
expect(emitSpy).toHaveBeenCalledWith(FILTER_CHANGED);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when user ids change", () => {
|
||||
beforeEach(() => {
|
||||
SpaceStoreInstanceMock.getSpaceFilteredUserIds.mockReturnValue(new Set([user1Id, user2Id]));
|
||||
});
|
||||
const filterChangedCases = [
|
||||
["user added", [user1Id, user2Id, user3Id]],
|
||||
["user removed", [user1Id]],
|
||||
["user swapped", [user1Id, user3Id]], // same number of rooms with changes
|
||||
];
|
||||
|
||||
it.each(filterChangedCases)("%s", (_d, rooms) => {
|
||||
const filter = initFilter(space1);
|
||||
const emitSpy = jest.spyOn(filter, "emit");
|
||||
|
||||
SpaceStoreInstanceMock.getSpaceFilteredUserIds.mockReturnValue(new Set(rooms));
|
||||
SpaceStoreInstanceMock.emit(space1);
|
||||
jest.runOnlyPendingTimers();
|
||||
expect(emitSpy).toHaveBeenCalledWith(FILTER_CHANGED);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,74 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { mocked } from "jest-mock";
|
||||
import { type Room, RoomType } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { VisibilityProvider } from "../../../../../src/stores/room-list/filters/VisibilityProvider";
|
||||
import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../../../../../src/models/LocalRoom";
|
||||
import { RoomListCustomisations } from "../../../../../src/customisations/RoomList";
|
||||
import { createTestClient } from "../../../../test-utils";
|
||||
|
||||
jest.mock("../../../../../src/customisations/RoomList", () => ({
|
||||
RoomListCustomisations: {
|
||||
isRoomVisible: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const createRoom = (isSpaceRoom = false): Room => {
|
||||
return {
|
||||
isSpaceRoom: () => isSpaceRoom,
|
||||
getType: () => (isSpaceRoom ? RoomType.Space : undefined),
|
||||
} as unknown as Room;
|
||||
};
|
||||
|
||||
const createLocalRoom = (): LocalRoom => {
|
||||
const room = new LocalRoom(LOCAL_ROOM_ID_PREFIX + "test", createTestClient(), "@test:example.com");
|
||||
room.isSpaceRoom = () => false;
|
||||
return room;
|
||||
};
|
||||
|
||||
describe("VisibilityProvider", () => {
|
||||
describe("instance", () => {
|
||||
it("should return an instance", () => {
|
||||
const visibilityProvider = VisibilityProvider.instance;
|
||||
expect(visibilityProvider).toBeInstanceOf(VisibilityProvider);
|
||||
expect(VisibilityProvider.instance).toBe(visibilityProvider);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRoomVisible", () => {
|
||||
it("should return false without room", () => {
|
||||
expect(VisibilityProvider.instance.isRoomVisible()).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for a space room", () => {
|
||||
const room = createRoom(true);
|
||||
expect(VisibilityProvider.instance.isRoomVisible(room)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for a local room", () => {
|
||||
const room = createLocalRoom();
|
||||
expect(VisibilityProvider.instance.isRoomVisible(room)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false if visibility customisation returns false", () => {
|
||||
mocked(RoomListCustomisations.isRoomVisible!).mockReturnValue(false);
|
||||
const room = createRoom();
|
||||
expect(VisibilityProvider.instance.isRoomVisible(room)).toBe(false);
|
||||
expect(RoomListCustomisations.isRoomVisible).toHaveBeenCalledWith(room);
|
||||
});
|
||||
|
||||
it("should return true if visibility customisation returns true", () => {
|
||||
mocked(RoomListCustomisations.isRoomVisible!).mockReturnValue(true);
|
||||
const room = createRoom();
|
||||
expect(VisibilityProvider.instance.isRoomVisible(room)).toBe(true);
|
||||
expect(RoomListCustomisations.isRoomVisible).toHaveBeenCalledWith(room);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,36 +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 MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { PollStartEventPreview } from "../../../../../src/stores/room-list/previews/PollStartEventPreview";
|
||||
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
|
||||
import { makePollStartEvent } from "../../../../test-utils";
|
||||
|
||||
jest.spyOn(MatrixClientPeg, "get").mockReturnValue({
|
||||
getUserId: () => "@me:example.com",
|
||||
getSafeUserId: () => "@me:example.com",
|
||||
} as unknown as MatrixClient);
|
||||
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue({
|
||||
getUserId: () => "@me:example.com",
|
||||
getSafeUserId: () => "@me:example.com",
|
||||
} as unknown as MatrixClient);
|
||||
|
||||
describe("PollStartEventPreview", () => {
|
||||
it("shows the question for a poll I created", async () => {
|
||||
const pollStartEvent = makePollStartEvent("My Question", "@me:example.com");
|
||||
const preview = new PollStartEventPreview();
|
||||
expect(preview.getTextFor(pollStartEvent)).toBe("My Question");
|
||||
});
|
||||
|
||||
it("shows the sender and question for a poll created by someone else", async () => {
|
||||
const pollStartEvent = makePollStartEvent("Your Question", "@yo:example.com");
|
||||
const preview = new PollStartEventPreview();
|
||||
expect(preview.getTextFor(pollStartEvent)).toBe("@yo:example.com: Your Question");
|
||||
});
|
||||
});
|
||||
@ -1,131 +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 { RelationType, Room, RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import { mocked } from "jest-mock";
|
||||
|
||||
import { mkEvent, stubClient } from "../../../../test-utils";
|
||||
import { ReactionEventPreview } from "../../../../../src/stores/room-list/previews/ReactionEventPreview";
|
||||
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
|
||||
|
||||
describe("ReactionEventPreview", () => {
|
||||
const preview = new ReactionEventPreview();
|
||||
const userId = "@user:example.com";
|
||||
const roomId = "!room:example.com";
|
||||
|
||||
beforeAll(() => {
|
||||
stubClient();
|
||||
});
|
||||
|
||||
describe("getTextFor", () => {
|
||||
it("should return null for non-relations", () => {
|
||||
const event = mkEvent({
|
||||
event: true,
|
||||
content: {},
|
||||
user: userId,
|
||||
type: "m.room.message",
|
||||
room: roomId,
|
||||
});
|
||||
expect(preview.getTextFor(event)).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for non-reactions", () => {
|
||||
const event = mkEvent({
|
||||
event: true,
|
||||
content: {
|
||||
"body": "",
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Thread,
|
||||
event_id: "$foo:bar",
|
||||
},
|
||||
},
|
||||
user: userId,
|
||||
type: "m.room.message",
|
||||
room: roomId,
|
||||
});
|
||||
expect(preview.getTextFor(event)).toBeNull();
|
||||
});
|
||||
|
||||
it("should use 'You' for your own reactions", () => {
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
const room = new Room(roomId, cli, userId);
|
||||
mocked(cli.getRoom).mockReturnValue(room);
|
||||
|
||||
const message = mkEvent({
|
||||
event: true,
|
||||
content: {
|
||||
"body": "duck duck goose",
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Thread,
|
||||
event_id: "$foo:bar",
|
||||
},
|
||||
},
|
||||
user: userId,
|
||||
type: "m.room.message",
|
||||
room: roomId,
|
||||
});
|
||||
|
||||
room.getUnfilteredTimelineSet().addLiveEvent(message, { addToState: true });
|
||||
|
||||
const event = mkEvent({
|
||||
event: true,
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Annotation,
|
||||
key: "🪿",
|
||||
event_id: message.getId(),
|
||||
},
|
||||
},
|
||||
user: cli.getSafeUserId(),
|
||||
type: "m.reaction",
|
||||
room: roomId,
|
||||
});
|
||||
expect(preview.getTextFor(event)).toMatchInlineSnapshot(`"You reacted 🪿 to duck duck goose"`);
|
||||
});
|
||||
|
||||
it("should use display name for your others' reactions", () => {
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
const room = new Room(roomId, cli, userId);
|
||||
mocked(cli.getRoom).mockReturnValue(room);
|
||||
|
||||
const message = mkEvent({
|
||||
event: true,
|
||||
content: {
|
||||
"body": "duck duck goose",
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Thread,
|
||||
event_id: "$foo:bar",
|
||||
},
|
||||
},
|
||||
user: userId,
|
||||
type: "m.room.message",
|
||||
room: roomId,
|
||||
});
|
||||
|
||||
room.getUnfilteredTimelineSet().addLiveEvent(message, { addToState: true });
|
||||
|
||||
const event = mkEvent({
|
||||
event: true,
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Annotation,
|
||||
key: "🪿",
|
||||
event_id: message.getId(),
|
||||
},
|
||||
},
|
||||
user: userId,
|
||||
type: "m.reaction",
|
||||
room: roomId,
|
||||
});
|
||||
event.sender = new RoomMember(roomId, userId);
|
||||
event.sender.name = "Bob";
|
||||
|
||||
expect(preview.getTextFor(event)).toMatchInlineSnapshot(`"Bob reacted 🪿 to duck duck goose"`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,88 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { ConditionKind, EventType, type IPushRule, MatrixEvent, PushRuleActionName } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { getChangedOverrideRoomMutePushRules } from "../../../../../src/stores/room-list/utils/roomMute";
|
||||
import { DEFAULT_PUSH_RULES, getDefaultRuleWithKind, makePushRule } from "../../../../test-utils/pushRules";
|
||||
|
||||
describe("getChangedOverrideRoomMutePushRules()", () => {
|
||||
const makePushRulesEvent = (overrideRules: IPushRule[] = []): MatrixEvent => {
|
||||
return new MatrixEvent({
|
||||
type: EventType.PushRules,
|
||||
content: {
|
||||
global: {
|
||||
...DEFAULT_PUSH_RULES.global,
|
||||
override: overrideRules,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
it("returns undefined when dispatched action is not accountData", () => {
|
||||
const action = { action: "MatrixActions.Event.decrypted", event: new MatrixEvent({}) };
|
||||
expect(getChangedOverrideRoomMutePushRules(action)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when dispatched action is not pushrules", () => {
|
||||
const action = { action: "MatrixActions.accountData", event: new MatrixEvent({ type: "not-push-rules" }) };
|
||||
expect(getChangedOverrideRoomMutePushRules(action)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when actions event is falsy", () => {
|
||||
const action = { action: "MatrixActions.accountData" };
|
||||
expect(getChangedOverrideRoomMutePushRules(action)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when actions previousEvent is falsy", () => {
|
||||
const pushRulesEvent = makePushRulesEvent();
|
||||
const action = { action: "MatrixActions.accountData", event: pushRulesEvent };
|
||||
expect(getChangedOverrideRoomMutePushRules(action)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("filters out non-room specific rules", () => {
|
||||
// an override rule that exists in default rules
|
||||
const { rule } = getDefaultRuleWithKind(".m.rule.contains_display_name");
|
||||
const updatedRule = {
|
||||
...rule,
|
||||
actions: [PushRuleActionName.DontNotify],
|
||||
enabled: false,
|
||||
};
|
||||
const previousEvent = makePushRulesEvent([rule]);
|
||||
const pushRulesEvent = makePushRulesEvent([updatedRule]);
|
||||
const action = { action: "MatrixActions.accountData", event: pushRulesEvent, previousEvent: previousEvent };
|
||||
// contains_display_name changed, but is not room-specific
|
||||
expect(getChangedOverrideRoomMutePushRules(action)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns ruleIds for added room rules", () => {
|
||||
const roomId1 = "!room1:server.org";
|
||||
const rule = makePushRule(roomId1, {
|
||||
actions: [PushRuleActionName.DontNotify],
|
||||
conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: roomId1 }],
|
||||
});
|
||||
const previousEvent = makePushRulesEvent();
|
||||
const pushRulesEvent = makePushRulesEvent([rule]);
|
||||
const action = { action: "MatrixActions.accountData", event: pushRulesEvent, previousEvent: previousEvent };
|
||||
// contains_display_name changed, but is not room-specific
|
||||
expect(getChangedOverrideRoomMutePushRules(action)).toEqual([rule.rule_id]);
|
||||
});
|
||||
|
||||
it("returns ruleIds for removed room rules", () => {
|
||||
const roomId1 = "!room1:server.org";
|
||||
const rule = makePushRule(roomId1, {
|
||||
actions: [PushRuleActionName.DontNotify],
|
||||
conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: roomId1 }],
|
||||
});
|
||||
const previousEvent = makePushRulesEvent([rule]);
|
||||
const pushRulesEvent = makePushRulesEvent();
|
||||
const action = { action: "MatrixActions.accountData", event: pushRulesEvent, previousEvent: previousEvent };
|
||||
// contains_display_name changed, but is not room-specific
|
||||
expect(getChangedOverrideRoomMutePushRules(action)).toEqual([rule.rule_id]);
|
||||
});
|
||||
});
|
||||
@ -10,8 +10,6 @@ import { Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import RoomListActions from "../../../../src/actions/RoomListActions";
|
||||
import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
|
||||
import { DefaultTagID, type TagID } from "../../../../src/stores/room-list/models";
|
||||
import RoomListStore from "../../../../src/stores/room-list/RoomListStore";
|
||||
import { tagRoom } from "../../../../src/utils/room/tagRoom";
|
||||
import { getMockClientWithEventEmitter } from "../../../test-utils";
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user