Room list: extract getTagsForRoom from old room list store (#32716)

* refactor: extract `getTagsForRoom` from old rls

`getTagsForRoom` doesn't rely on the rls state. We can extract it safely
to an external function.

The function is not moved into the rls v3 because the rls is for the
room list and not for other ui elements.

* refactor: remove dead code

* test: add more tests for `getTagsForRoom`

* refactor: `getTagsForRoom` in old rls

* doc: add missing tsdoc for `room`
This commit is contained in:
Florian Duros
2026-03-04 17:14:48 +00:00
committed by GitHub
parent ac37bebf22
commit 1c66f0ba01
13 changed files with 231 additions and 137 deletions
@@ -34,6 +34,7 @@ import { Key } from "../../../Keyboard";
import { usePinnedEvents } from "../../../hooks/usePinnedEvents"; import { usePinnedEvents } from "../../../hooks/usePinnedEvents";
import { tagRoom } from "../../../utils/room/tagRoom"; import { tagRoom } from "../../../utils/room/tagRoom";
import { inviteToRoom } from "../../../utils/room/inviteToRoom"; import { inviteToRoom } from "../../../utils/room/inviteToRoom";
import { getTagsForRoom } from "../../../utils/room/getTagsForRoom";
export interface RoomSummaryCardState { export interface RoomSummaryCardState {
isDirectMessage: boolean; isDirectMessage: boolean;
@@ -171,9 +172,7 @@ export function useRoomSummaryCardViewModel(
// value to check if the user can invite to the room // value to check if the user can invite to the room
const canInviteToState = useEventEmitterState(room, RoomStateEvent.Update, () => canInviteTo(room)); const canInviteToState = useEventEmitterState(room, RoomStateEvent.Update, () => canInviteTo(room));
const roomTags = useEventEmitterState(RoomListStore.instance, LISTS_UPDATE_EVENT, () => const roomTags = useEventEmitterState(RoomListStore.instance, LISTS_UPDATE_EVENT, () => getTagsForRoom(room));
RoomListStore.instance.getTagsForRoom(room),
);
const isFavorite = roomTags.includes(DefaultTagID.Favourite); const isFavorite = roomTags.includes(DefaultTagID.Favourite);
const isDirectMessage = useIsDirectMessage(room); const isDirectMessage = useIsDirectMessage(room);
@@ -44,6 +44,7 @@ import { shouldShowComponent } from "../../../customisations/helpers/UIComponent
import { UIComponent } from "../../../settings/UIFeature"; import { UIComponent } from "../../../settings/UIFeature";
import { DeveloperToolsOption } from "./DeveloperToolsOption"; import { DeveloperToolsOption } from "./DeveloperToolsOption";
import { useSettingValue } from "../../../hooks/useSettings"; import { useSettingValue } from "../../../hooks/useSettings";
import { getTagsForRoom } from "../../../utils/room/getTagsForRoom";
export interface RoomGeneralContextMenuProps extends IContextMenuProps { export interface RoomGeneralContextMenuProps extends IContextMenuProps {
room: Room; room: Room;
@@ -121,9 +122,7 @@ export const RoomGeneralContextMenu: React.FC<RoomGeneralContextMenuProps> = ({
...props ...props
}) => { }) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
const roomTags = useEventEmitterState(RoomListStore.instance, LISTS_UPDATE_EVENT, () => const roomTags = useEventEmitterState(RoomListStore.instance, LISTS_UPDATE_EVENT, () => getTagsForRoom(room));
RoomListStore.instance.getTagsForRoom(room),
);
const isDm = DMRoomMap.shared().getUserIdForRoomId(room.roomId); const isDm = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
const wrapHandler = ( const wrapHandler = (
handler: (ev: ButtonEvent) => void, handler: (ev: ButtonEvent) => void,
@@ -148,7 +147,7 @@ export const RoomGeneralContextMenu: React.FC<RoomGeneralContextMenuProps> = ({
if (!cli) return; if (!cli) return;
if (tagId === DefaultTagID.Favourite || tagId === DefaultTagID.LowPriority) { if (tagId === DefaultTagID.Favourite || tagId === DefaultTagID.LowPriority) {
const inverseTag = tagId === DefaultTagID.Favourite ? DefaultTagID.LowPriority : DefaultTagID.Favourite; const inverseTag = tagId === DefaultTagID.Favourite ? DefaultTagID.LowPriority : DefaultTagID.Favourite;
const isApplied = RoomListStore.instance.getTagsForRoom(room).includes(tagId); const isApplied = getTagsForRoom(room).includes(tagId);
const removeTag = isApplied ? tagId : inverseTag; const removeTag = isApplied ? tagId : inverseTag;
const addTag = isApplied ? null : tagId; const addTag = isApplied ? null : tagId;
dis.dispatch(RoomListActions.tagRoom(cli, room, removeTag, addTag, 0)); dis.dispatch(RoomListActions.tagRoom(cli, room, removeTag, addTag, 0));
@@ -14,8 +14,8 @@ import { arrayDiff } from "../../utils/arrays";
import { type RoomNotificationState } from "./RoomNotificationState"; import { type RoomNotificationState } from "./RoomNotificationState";
import { NotificationState, NotificationStateEvents } from "./NotificationState"; import { NotificationState, NotificationStateEvents } from "./NotificationState";
import { DefaultTagID } from "../room-list-v3/skip-list/tag"; import { DefaultTagID } from "../room-list-v3/skip-list/tag";
import RoomListStore from "../room-list/RoomListStore";
import { RoomNotificationStateStore } from "./RoomNotificationStateStore"; import { RoomNotificationStateStore } from "./RoomNotificationStateStore";
import { getTagsForRoom } from "../../utils/room/getTagsForRoom";
export class SpaceNotificationState extends NotificationState { export class SpaceNotificationState extends NotificationState {
public rooms: Room[] = []; // exposed only for tests public rooms: Room[] = []; // exposed only for tests
@@ -72,7 +72,7 @@ export class SpaceNotificationState extends NotificationState {
this._level = NotificationLevel.None; this._level = NotificationLevel.None;
for (const [roomId, state] of Object.entries(this.states)) { for (const [roomId, state] of Object.entries(this.states)) {
const room = this.rooms.find((r) => r.roomId === roomId); const room = this.rooms.find((r) => r.roomId === roomId);
const roomTags = room ? RoomListStore.instance.getTagsForRoom(room) : []; const roomTags = room ? getTagsForRoom(room) : [];
// We ignore unreads in LowPriority rooms, see https://github.com/vector-im/element-web/issues/16836 // We ignore unreads in LowPriority rooms, see https://github.com/vector-im/element-web/issues/16836
if (roomTags.includes(DefaultTagID.LowPriority) && state.level === NotificationLevel.Activity) continue; if (roomTags.includes(DefaultTagID.LowPriority) && state.level === NotificationLevel.Activity) continue;
@@ -92,15 +92,6 @@ export interface RoomListStore extends EventEmitter {
*/ */
removeFilter(filter: IFilterCondition): void; 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 * 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 * room list store would otherwise be incapable of doing the update itself. Note
+1 -14
View File
@@ -35,7 +35,7 @@ import { type RoomListStore as Interface, RoomListStoreEvent } from "./Interface
import { UPDATE_EVENT } from "../AsyncStore"; import { UPDATE_EVENT } from "../AsyncStore";
import { SdkContextClass } from "../../contexts/SDKContext"; import { SdkContextClass } from "../../contexts/SDKContext";
import { getChangedOverrideRoomMutePushRules } from "../room-list-v3/utils"; import { getChangedOverrideRoomMutePushRules } from "../room-list-v3/utils";
import { DefaultTagID, type TagID } from "../room-list-v3/skip-list/tag"; import { type TagID } from "../room-list-v3/skip-list/tag";
export const LISTS_UPDATE_EVENT = RoomListStoreEvent.ListsUpdate; export const LISTS_UPDATE_EVENT = RoomListStoreEvent.ListsUpdate;
export const LISTS_LOADING_EVENT = RoomListStoreEvent.ListsLoading; // unused; used by SlidingRoomListStore export const LISTS_LOADING_EVENT = RoomListStoreEvent.ListsLoading; // unused; used by SlidingRoomListStore
@@ -597,19 +597,6 @@ export class RoomListStoreClass extends AsyncStoreWithClient<EmptyObject> implem
} }
} }
/**
* 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 { public getCount(tagId: TagID): number {
// The room list store knows about all the rooms, so just return the length. // The room list store knows about all the rooms, so just return the length.
return this.orderedLists[tagId].length || 0; return this.orderedLists[tagId].length || 0;
@@ -6,7 +6,7 @@ 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. Please see LICENSE files in the repository root for full details.
*/ */
import { JoinRule, type Room } from "matrix-js-sdk/src/matrix"; import { type Room } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types"; import { KnownMembership } from "matrix-js-sdk/src/types";
import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
@@ -24,16 +24,12 @@ import {
type ListAlgorithm, type ListAlgorithm,
type SortAlgorithm, type SortAlgorithm,
} from "./models"; } from "./models";
import { import { EffectiveMembership, splitRoomsByMembership } from "../../../utils/membership";
EffectiveMembership,
getEffectiveMembership,
getEffectiveMembershipTag,
splitRoomsByMembership,
} from "../../../utils/membership";
import { type OrderingAlgorithm } from "./list-ordering/OrderingAlgorithm"; import { type OrderingAlgorithm } from "./list-ordering/OrderingAlgorithm";
import { getListAlgorithmInstance } from "./list-ordering"; import { getListAlgorithmInstance } from "./list-ordering";
import { isRoomVisible } from "../../room-list-v3/isRoomVisible"; import { isRoomVisible } from "../../room-list-v3/isRoomVisible";
import { CallStore, CallStoreEvent } from "../../CallStore"; import { CallStore, CallStoreEvent } from "../../CallStore";
import { getTagsForRoom, getTagsOfJoinedRoom } from "../../../utils/room/getTagsForRoom";
/** /**
* Fired when the Algorithm has determined a list has been updated. * Fired when the Algorithm has determined a list has been updated.
@@ -499,7 +495,7 @@ export class Algorithm extends EventEmitter {
// Now process all the joined rooms. This is a bit more complicated // Now process all the joined rooms. This is a bit more complicated
for (const room of memberships[EffectiveMembership.Join]) { for (const room of memberships[EffectiveMembership.Join]) {
const tags = this.getTagsOfJoinedRoom(room); const tags = getTagsOfJoinedRoom(room);
let inTag = false; let inTag = false;
if (tags.length > 0) { if (tags.length > 0) {
@@ -541,42 +537,6 @@ export class Algorithm extends EventEmitter {
} }
} }
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 * Updates the roomsToTags map
*/ */
@@ -677,7 +637,7 @@ export class Algorithm extends EventEmitter {
let didTagChange = false; let didTagChange = false;
if (cause === RoomUpdateCause.PossibleTagChange) { if (cause === RoomUpdateCause.PossibleTagChange) {
const oldTags = this.roomIdsToTags[room.roomId] || []; const oldTags = this.roomIdsToTags[room.roomId] || [];
const newTags = this.getTagsForRoom(room); const newTags = getTagsForRoom(room);
const diff = arrayDiff(oldTags, newTags); const diff = arrayDiff(oldTags, newTags);
if (diff.removed.length > 0 || diff.added.length > 0) { if (diff.removed.length > 0 || diff.added.length > 0) {
for (const rmTag of diff.removed) { for (const rmTag of diff.removed) {
@@ -737,7 +697,7 @@ export class Algorithm extends EventEmitter {
} }
// Get the tags for the room and populate the cache // Get the tags for the room and populate the cache
const roomTags = this.getTagsForRoom(room).filter((t) => !isNullOrUndefined(this.cachedRooms[t])); const roomTags = getTagsForRoom(room).filter((t) => !isNullOrUndefined(this.cachedRooms[t]));
// "This should never happen" condition - we specify DefaultTagID.Untagged in getTagsForRoom(), // "This should never happen" condition - we specify DefaultTagID.Untagged in getTagsForRoom(),
// which means we should *always* have a tag to go off of. // which means we should *always* have a tag to go off of.
+56
View File
@@ -0,0 +1,56 @@
/*
* Copyright 2026 Element Creations 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 { JoinRule, type Room } from "matrix-js-sdk/src/matrix";
import { DefaultTagID, type TagID } from "../../stores/room-list-v3/skip-list/tag";
import { EffectiveMembership, getEffectiveMembershipTag } from "../membership";
import DMRoomMap from "../DMRoomMap";
/**
* Get the tags for a room.
* @param room - the room to get the tags for
* @returns an array of tags for the room. If the room has no tags, it will return an array with the DefaultTagID.Untagged tag.
*/
export function getTagsForRoom(room: Room): TagID[] {
const tags: TagID[] = [];
const membership = getEffectiveMembershipTag(room);
if (membership === EffectiveMembership.Invite) {
tags.push(DefaultTagID.Invite);
} else if (membership === EffectiveMembership.Leave) {
tags.push(DefaultTagID.Archived);
} else {
tags.push(...getTagsOfJoinedRoom(room));
}
if (!tags.length) tags.push(DefaultTagID.Untagged);
return tags;
}
/**
* Get the tags for a room that the user has joined. It checks for user defined tags first, then checks if it's a DM, and finally checks if it's a conference room.
* @param room - the room to get the tags for
* @returns an array of tags for the room. If the room has no user defined tags, is not a DM, and is not a conference room, it will return an empty array.
*/
export function 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;
}
+2 -2
View File
@@ -9,10 +9,10 @@ Please see LICENSE files in the repository root for full details.
import { type Room } from "matrix-js-sdk/src/matrix"; import { type Room } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import RoomListStore from "../../stores/room-list/RoomListStore";
import { DefaultTagID, type TagID } from "../../stores/room-list-v3/skip-list/tag"; import { DefaultTagID, type TagID } from "../../stores/room-list-v3/skip-list/tag";
import RoomListActions from "../../actions/RoomListActions"; import RoomListActions from "../../actions/RoomListActions";
import dis from "../../dispatcher/dispatcher"; import dis from "../../dispatcher/dispatcher";
import { getTagsForRoom } from "./getTagsForRoom";
/** /**
* Toggle tag for a given room * Toggle tag for a given room
@@ -22,7 +22,7 @@ import dis from "../../dispatcher/dispatcher";
export function tagRoom(room: Room, tagId: TagID): void { export function tagRoom(room: Room, tagId: TagID): void {
if (tagId === DefaultTagID.Favourite || tagId === DefaultTagID.LowPriority) { if (tagId === DefaultTagID.Favourite || tagId === DefaultTagID.LowPriority) {
const inverseTag = tagId === DefaultTagID.Favourite ? DefaultTagID.LowPriority : DefaultTagID.Favourite; const inverseTag = tagId === DefaultTagID.Favourite ? DefaultTagID.LowPriority : DefaultTagID.Favourite;
const isApplied = RoomListStore.instance.getTagsForRoom(room).includes(tagId); const isApplied = getTagsForRoom(room).includes(tagId);
const removeTag = isApplied ? tagId : inverseTag; const removeTag = isApplied ? tagId : inverseTag;
const addTag = isApplied ? null : tagId; const addTag = isApplied ? null : tagId;
dis.dispatch(RoomListActions.tagRoom(room.client, room, removeTag, addTag, 0)); dis.dispatch(RoomListActions.tagRoom(room.client, room, removeTag, addTag, 0));
@@ -11,7 +11,6 @@ import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import { useRoomSummaryCardViewModel } from "../../../../../src/components/viewmodels/right_panel/RoomSummaryCardViewModel"; import { useRoomSummaryCardViewModel } from "../../../../../src/components/viewmodels/right_panel/RoomSummaryCardViewModel";
import { mkStubRoom, stubClient, withClientContextRenderOptions } from "../../../../test-utils"; import { mkStubRoom, stubClient, withClientContextRenderOptions } from "../../../../test-utils";
import defaultDispatcher from "../../../../../src/dispatcher/dispatcher"; import defaultDispatcher from "../../../../../src/dispatcher/dispatcher";
import RoomListStore from "../../../../../src/stores/room-list/RoomListStore";
import { DefaultTagID } from "../../../../../src/stores/room-list-v3/skip-list/tag"; import { DefaultTagID } from "../../../../../src/stores/room-list-v3/skip-list/tag";
import RightPanelStore from "../../../../../src/stores/right-panel/RightPanelStore"; import RightPanelStore from "../../../../../src/stores/right-panel/RightPanelStore";
import { RightPanelPhases } from "../../../../../src/stores/right-panel/RightPanelStorePhases"; import { RightPanelPhases } from "../../../../../src/stores/right-panel/RightPanelStorePhases";
@@ -23,6 +22,7 @@ import { ReportRoomDialog } from "../../../../../src/components/views/dialogs/Re
import { inviteToRoom } from "../../../../../src/utils/room/inviteToRoom"; import { inviteToRoom } from "../../../../../src/utils/room/inviteToRoom";
import DMRoomMap from "../../../../../src/utils/DMRoomMap"; import DMRoomMap from "../../../../../src/utils/DMRoomMap";
import * as hooks from "../../../../../src/hooks/useAccountData"; import * as hooks from "../../../../../src/hooks/useAccountData";
import * as getTagsForRoomUtils from "../../../../../src/utils/room/getTagsForRoom";
jest.mock("../../../../../src/utils/room/inviteToRoom", () => ({ jest.mock("../../../../../src/utils/room/inviteToRoom", () => ({
inviteToRoom: jest.fn(), inviteToRoom: jest.fn(),
@@ -43,7 +43,7 @@ describe("useRoomSummaryCardViewModel", () => {
getUserIdForRoomId: jest.fn(), getUserIdForRoomId: jest.fn(),
} as unknown as DMRoomMap); } as unknown as DMRoomMap);
jest.spyOn(RoomListStore.instance, "getTagsForRoom").mockReturnValue([]); jest.spyOn(getTagsForRoomUtils, "getTagsForRoom").mockReturnValue([]);
}); });
afterEach(() => { afterEach(() => {
@@ -195,14 +195,14 @@ describe("useRoomSummaryCardViewModel", () => {
describe("favorite room state", () => { describe("favorite room state", () => {
it("should identify favorite rooms", () => { it("should identify favorite rooms", () => {
jest.spyOn(RoomListStore.instance, "getTagsForRoom").mockReturnValue([DefaultTagID.Favourite]); jest.spyOn(getTagsForRoomUtils, "getTagsForRoom").mockReturnValue([DefaultTagID.Favourite]);
const { result } = render(); const { result } = render();
expect(result.current.isFavorite).toBe(true); expect(result.current.isFavorite).toBe(true);
}); });
it("should identify non-favorite rooms", () => { it("should identify non-favorite rooms", () => {
jest.spyOn(RoomListStore.instance, "getTagsForRoom").mockReturnValue([]); jest.spyOn(getTagsForRoomUtils, "getTagsForRoom").mockReturnValue([]);
const { result } = render(); const { result } = render();
expect(result.current.isFavorite).toBe(false); expect(result.current.isFavorite).toBe(false);
@@ -22,13 +22,13 @@ import {
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import { DefaultTagID } from "../../../../../src/stores/room-list-v3/skip-list/tag"; import { DefaultTagID } from "../../../../../src/stores/room-list-v3/skip-list/tag";
import RoomListStore from "../../../../../src/stores/room-list/RoomListStore";
import DMRoomMap from "../../../../../src/utils/DMRoomMap"; import DMRoomMap from "../../../../../src/utils/DMRoomMap";
import { mkMessage, stubClient } from "../../../../test-utils/test-utils"; import { mkMessage, stubClient } from "../../../../test-utils/test-utils";
import { shouldShowComponent } from "../../../../../src/customisations/helpers/UIComponents"; import { shouldShowComponent } from "../../../../../src/customisations/helpers/UIComponents";
import { UIComponent } from "../../../../../src/settings/UIFeature"; import { UIComponent } from "../../../../../src/settings/UIFeature";
import SettingsStore from "../../../../../src/settings/SettingsStore"; import SettingsStore from "../../../../../src/settings/SettingsStore";
import { clearAllModals } from "../../../../test-utils"; import { clearAllModals } from "../../../../test-utils";
import * as getTagsForRoomUtils from "../../../../../src/utils/room/getTagsForRoom";
jest.mock("../../../../../src/customisations/helpers/UIComponents", () => ({ jest.mock("../../../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(), shouldShowComponent: jest.fn(),
@@ -74,7 +74,7 @@ describe("RoomGeneralContextMenu", () => {
} as unknown as DMRoomMap; } as unknown as DMRoomMap;
DMRoomMap.setShared(dmRoomMap); DMRoomMap.setShared(dmRoomMap);
jest.spyOn(RoomListStore.instance, "getTagsForRoom").mockReturnValueOnce([ jest.spyOn(getTagsForRoomUtils, "getTagsForRoom").mockReturnValueOnce([
DefaultTagID.DM, DefaultTagID.DM,
DefaultTagID.Favourite, DefaultTagID.Favourite,
]); ]);
@@ -87,7 +87,7 @@ describe("RoomGeneralContextMenu", () => {
}); });
it("renders an empty context menu for archived rooms", async () => { it("renders an empty context menu for archived rooms", async () => {
jest.spyOn(RoomListStore.instance, "getTagsForRoom").mockReturnValueOnce([DefaultTagID.Archived]); jest.spyOn(getTagsForRoomUtils, "getTagsForRoom").mockReturnValueOnce([DefaultTagID.Archived]);
const { container } = getComponent({}); const { container } = getComponent({});
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
@@ -10,7 +10,6 @@ import {
ConditionKind, ConditionKind,
EventType, EventType,
type IPushRule, type IPushRule,
JoinRule,
MatrixEvent, MatrixEvent,
PendingEventOrdering, PendingEventOrdering,
PushRuleActionName, PushRuleActionName,
@@ -23,11 +22,10 @@ import defaultDispatcher, { type MatrixDispatcher } from "../../../../src/dispat
import { SettingLevel } from "../../../../src/settings/SettingLevel"; import { SettingLevel } from "../../../../src/settings/SettingLevel";
import SettingsStore, { type CallbackFn } from "../../../../src/settings/SettingsStore"; import SettingsStore, { type CallbackFn } from "../../../../src/settings/SettingsStore";
import { ListAlgorithm, SortAlgorithm } from "../../../../src/stores/room-list/algorithms/models"; import { ListAlgorithm, SortAlgorithm } from "../../../../src/stores/room-list/algorithms/models";
import { DefaultTagID } from "../../../../src/stores/room-list-v3/skip-list/tag";
import { OrderedDefaultTagIDs, RoomUpdateCause } from "../../../../src/stores/room-list/models"; import { OrderedDefaultTagIDs, RoomUpdateCause } from "../../../../src/stores/room-list/models";
import RoomListStore, { RoomListStoreClass } from "../../../../src/stores/room-list/RoomListStore"; import RoomListStore, { RoomListStoreClass } from "../../../../src/stores/room-list/RoomListStore";
import DMRoomMap from "../../../../src/utils/DMRoomMap"; import DMRoomMap from "../../../../src/utils/DMRoomMap";
import { flushPromises, stubClient, upsertRoomStateEvents, mkRoom } from "../../../test-utils"; import { flushPromises, stubClient, upsertRoomStateEvents } from "../../../test-utils";
import { DEFAULT_PUSH_RULES, makePushRule } from "../../../test-utils/pushRules"; import { DEFAULT_PUSH_RULES, makePushRule } from "../../../test-utils/pushRules";
describe("RoomListStore", () => { describe("RoomListStore", () => {
@@ -350,49 +348,4 @@ describe("RoomListStore", () => {
}); });
}); });
}); });
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();
});
});
}); });
@@ -0,0 +1,149 @@
/*
* Copyright 2026 Element Creations 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 { JoinRule, type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { mocked } from "jest-mock";
import { createTestClient, mkRoom } from "../../../test-utils";
import { DefaultTagID } from "../../../../src/stores/room-list-v3/skip-list/tag";
import { getTagsForRoom } from "../../../../src/utils/room/getTagsForRoom";
import DMRoomMap from "../../../../src/utils/DMRoomMap";
describe("getTagsForRoom", () => {
let client: MatrixClient;
let rooms: Room[];
beforeEach(() => {
client = createTestClient();
rooms = [];
const dmRoomMap = {
getUserIdForRoomId: jest.fn().mockReturnValue(undefined),
} as unknown as DMRoomMap;
DMRoomMap.setShared(dmRoomMap);
});
function makeRoom(roomId: string): Room {
mkRoom(client, roomId, rooms);
mocked(client).getRoom.mockImplementation((id) => rooms.find((r) => r.roomId === id) ?? null);
mocked(client).getRooms.mockImplementation(() => rooms);
return client.getRoom(roomId)!;
}
it("should return [Invite] for a room where the user is invited", () => {
const room = makeRoom("!invited:server");
(room.getMyMembership as jest.Mock).mockReturnValue(KnownMembership.Invite);
const tags = getTagsForRoom(room);
expect(tags).toEqual([DefaultTagID.Invite]);
});
it.each([KnownMembership.Leave, KnownMembership.Ban])(
"should return [Archived] for a room where the user has %s",
(membership) => {
const room = makeRoom(`!${membership.toLowerCase()}:server`);
(room.getMyMembership as jest.Mock).mockReturnValue(membership);
const tags = getTagsForRoom(room);
expect(tags).toEqual([DefaultTagID.Archived]);
},
);
describe("joined rooms", () => {
describe("with no user-defined tags and not a DM", () => {
it("should return [Untagged] when the room has no tags and is not a DM", () => {
const room = makeRoom("!plain:server");
(room.getMyMembership as jest.Mock).mockReturnValue(KnownMembership.Join);
(room as any).tags = {};
const tags = getTagsForRoom(room);
expect(tags).toEqual([DefaultTagID.Untagged]);
});
});
it("should return [DM] when the room is a DM", () => {
const room = makeRoom("!dm:server");
(room.getMyMembership as jest.Mock).mockReturnValue(KnownMembership.Join);
(room as any).tags = {};
mocked(DMRoomMap.shared().getUserIdForRoomId as jest.Mock).mockReturnValue("@alice:server");
const tags = getTagsForRoom(room);
expect(tags).toContain(DefaultTagID.DM);
expect(tags).not.toContain(DefaultTagID.Untagged);
});
describe("rooms with user-defined tags", () => {
it("should return the user-defined tags", () => {
const room = makeRoom("!tagged:server");
(room.getMyMembership as jest.Mock).mockReturnValue(KnownMembership.Join);
(room as any).tags = { "m.favourite": {}, "u.alice": {} };
const tags = getTagsForRoom(room);
expect(tags).toContain("m.favourite");
expect(tags).toContain("u.alice");
expect(tags).not.toContain(DefaultTagID.Untagged);
});
it("should not check DM status when user-defined tags are already present", () => {
const room = makeRoom("!tagged-dm:server");
(room.getMyMembership as jest.Mock).mockReturnValue(KnownMembership.Join);
(room as any).tags = { "m.lowpriority": {} };
// Even if the room is a DM, user-defined tags take priority
mocked(DMRoomMap.shared().getUserIdForRoomId as jest.Mock).mockReturnValue("@alice:server");
const tags = getTagsForRoom(room);
expect(tags).toContain("m.lowpriority");
expect(tags).not.toContain(DefaultTagID.DM);
});
});
});
describe("conference (call) rooms", () => {
it.each([JoinRule.Public, JoinRule.Knock])(
"should include Conference tag for a call room with %s join rule",
(joinRule) => {
const room = makeRoom(`!call:${joinRule}:server`);
(room.getMyMembership as jest.Mock).mockReturnValue(KnownMembership.Join);
(room.isCallRoom as jest.Mock).mockReturnValue(true);
(room.getJoinRule as jest.Mock).mockReturnValue(joinRule);
const tags = getTagsForRoom(room);
expect(tags).toContain(DefaultTagID.Conference);
},
);
it.each([JoinRule.Invite, JoinRule.Private])(
"should not include Conference tag for a call room with %s join rule",
(joinRule) => {
const room = makeRoom(`!call:${joinRule}:server`);
(room.getMyMembership as jest.Mock).mockReturnValue(KnownMembership.Join);
(room.isCallRoom as jest.Mock).mockReturnValue(true);
(room.getJoinRule as jest.Mock).mockReturnValue(joinRule);
const tags = getTagsForRoom(room);
expect(tags).not.toContain(DefaultTagID.Conference);
},
);
it("should include Conference alongside Untagged for a public call room with no other tags", () => {
const room = makeRoom("!callPublicPlain:server");
(room.getMyMembership as jest.Mock).mockReturnValue(KnownMembership.Join);
(room as any).tags = {};
(room.isCallRoom as jest.Mock).mockReturnValue(true);
(room.getJoinRule as jest.Mock).mockReturnValue(JoinRule.Public);
const tags = getTagsForRoom(room);
// Conference is added to the tag list before the Untagged fallback check,
// so tags.length is already 1 — Untagged is not appended.
expect(tags).toContain(DefaultTagID.Conference);
expect(tags).not.toContain(DefaultTagID.Untagged);
});
});
});
@@ -11,9 +11,9 @@ import { Room } from "matrix-js-sdk/src/matrix";
import RoomListActions from "../../../../src/actions/RoomListActions"; import RoomListActions from "../../../../src/actions/RoomListActions";
import defaultDispatcher from "../../../../src/dispatcher/dispatcher"; import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
import { DefaultTagID, type TagID } from "../../../../src/stores/room-list-v3/skip-list/tag"; import { DefaultTagID, type TagID } from "../../../../src/stores/room-list-v3/skip-list/tag";
import RoomListStore from "../../../../src/stores/room-list/RoomListStore";
import { tagRoom } from "../../../../src/utils/room/tagRoom"; import { tagRoom } from "../../../../src/utils/room/tagRoom";
import { getMockClientWithEventEmitter } from "../../../test-utils"; import { getMockClientWithEventEmitter } from "../../../test-utils";
import * as getTagsForRoomUtils from "../../../../src/utils/room/getTagsForRoom";
describe("tagRoom()", () => { describe("tagRoom()", () => {
const userId = "@alice:server.org"; const userId = "@alice:server.org";
@@ -25,7 +25,7 @@ describe("tagRoom()", () => {
}); });
const room = new Room(roomId, client, userId); const room = new Room(roomId, client, userId);
jest.spyOn(RoomListStore.instance, "getTagsForRoom").mockReturnValue(tags); jest.spyOn(getTagsForRoomUtils, "getTagsForRoom").mockReturnValue(tags);
return room; return room;
}; };