Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski 2025-12-02 16:26:14 +00:00
parent 3be8719aa1
commit d0235f83d8
No known key found for this signature in database
GPG Key ID: A2B008A5F49F5D0D
69 changed files with 9 additions and 6116 deletions

View File

@ -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 */

View File

@ -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;
}

View File

@ -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;

View File

@ -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 {

View File

@ -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,

View File

@ -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";

View File

@ -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 });
}

View File

@ -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",

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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>
);
}

View File

@ -1,25 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { useState } from "react";
export default function useHover(
ignoreHover: (ev: React.MouseEvent) => boolean,
): [boolean, { onMouseOver: () => void; onMouseLeave: () => void; onMouseMove: (ev: React.MouseEvent) => void }] {
const [hovered, setHoverState] = useState(false);
const props = {
onMouseOver: () => setHoverState(true),
onMouseLeave: () => setHoverState(false),
onMouseMove: (ev: React.MouseEvent): void => {
setHoverState(!ignoreHover(ev));
},
};
return [hovered, props];
}

View File

@ -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 {

View File

@ -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>;
}

View File

@ -1,111 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type TagID } from "./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,
};
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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);
};
}

View File

@ -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;
}
}

View File

@ -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
}
}
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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[];
}

View File

@ -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);
});
}
}

View File

@ -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[];
}

View File

@ -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);
});
}
}

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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
}
}

View File

@ -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",
}

View File

@ -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;
}

View File

@ -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");
}
}
}

View File

@ -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");
}
}
}

View File

@ -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) });
}
}
}
}

View File

@ -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
}
}
}

View File

@ -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,
});
}
}

View File

@ -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 });
}
}
}

View File

@ -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() ?? "";
}

View File

@ -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];
};

View File

@ -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 {

View File

@ -43,28 +43,6 @@ export enum EffectiveMembership {
Leave = "LEAVE",
}
export type MembershipSplit = {
[state in EffectiveMembership]: Room[];
};
export function splitRoomsByMembership(rooms: Room[]): MembershipSplit {
const split: MembershipSplit = {
[EffectiveMembership.Invite]: [],
[EffectiveMembership.Join]: [],
[EffectiveMembership.Leave]: [],
};
for (const room of rooms) {
const membership = room.getMyMembership();
// Filter out falsey relationship as this will be peeked rooms
if (!!membership) {
split[getEffectiveMembershipTag(room)].push(room);
}
}
return split;
}
export function getEffectiveMembership(membership: Membership): EffectiveMembership {
if (membership === KnownMembership.Invite) {
return EffectiveMembership.Invite;

View File

@ -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";

View File

@ -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";

View File

@ -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([]),

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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);
});
});

View File

@ -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>
`;

View File

@ -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>
`;

View File

@ -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", () => {

View File

@ -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";

View File

@ -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";

View File

@ -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();
});
});
});

View File

@ -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);
});
});

View File

@ -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]);
});
});

View File

@ -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]);
});
});
});

View File

@ -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);
});
});
});
});

View File

@ -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);
});
});
});
});

View File

@ -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);
});
});
});
});

View File

@ -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);
});
});
});

View File

@ -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");
});
});

View File

@ -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"`);
});
});
});

View File

@ -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]);
});
});

View File

@ -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";