diff --git a/apps/web/src/components/viewmodels/right_panel/RoomSummaryCardViewModel.tsx b/apps/web/src/components/viewmodels/right_panel/RoomSummaryCardViewModel.tsx index 1106fa7e86..c111680951 100644 --- a/apps/web/src/components/viewmodels/right_panel/RoomSummaryCardViewModel.tsx +++ b/apps/web/src/components/viewmodels/right_panel/RoomSummaryCardViewModel.tsx @@ -34,6 +34,7 @@ import { Key } from "../../../Keyboard"; import { usePinnedEvents } from "../../../hooks/usePinnedEvents"; import { tagRoom } from "../../../utils/room/tagRoom"; import { inviteToRoom } from "../../../utils/room/inviteToRoom"; +import { getTagsForRoom } from "../../../utils/room/getTagsForRoom"; export interface RoomSummaryCardState { isDirectMessage: boolean; @@ -171,9 +172,7 @@ export function useRoomSummaryCardViewModel( // value to check if the user can invite to the room const canInviteToState = useEventEmitterState(room, RoomStateEvent.Update, () => canInviteTo(room)); - const roomTags = useEventEmitterState(RoomListStore.instance, LISTS_UPDATE_EVENT, () => - RoomListStore.instance.getTagsForRoom(room), - ); + const roomTags = useEventEmitterState(RoomListStore.instance, LISTS_UPDATE_EVENT, () => getTagsForRoom(room)); const isFavorite = roomTags.includes(DefaultTagID.Favourite); const isDirectMessage = useIsDirectMessage(room); diff --git a/apps/web/src/components/views/context_menus/RoomGeneralContextMenu.tsx b/apps/web/src/components/views/context_menus/RoomGeneralContextMenu.tsx index 3f5bfd4a3f..dd3143bd49 100644 --- a/apps/web/src/components/views/context_menus/RoomGeneralContextMenu.tsx +++ b/apps/web/src/components/views/context_menus/RoomGeneralContextMenu.tsx @@ -44,6 +44,7 @@ import { shouldShowComponent } from "../../../customisations/helpers/UIComponent import { UIComponent } from "../../../settings/UIFeature"; import { DeveloperToolsOption } from "./DeveloperToolsOption"; import { useSettingValue } from "../../../hooks/useSettings"; +import { getTagsForRoom } from "../../../utils/room/getTagsForRoom"; export interface RoomGeneralContextMenuProps extends IContextMenuProps { room: Room; @@ -121,9 +122,7 @@ export const RoomGeneralContextMenu: React.FC = ({ ...props }) => { const cli = useContext(MatrixClientContext); - const roomTags = useEventEmitterState(RoomListStore.instance, LISTS_UPDATE_EVENT, () => - RoomListStore.instance.getTagsForRoom(room), - ); + const roomTags = useEventEmitterState(RoomListStore.instance, LISTS_UPDATE_EVENT, () => getTagsForRoom(room)); const isDm = DMRoomMap.shared().getUserIdForRoomId(room.roomId); const wrapHandler = ( handler: (ev: ButtonEvent) => void, @@ -148,7 +147,7 @@ export const RoomGeneralContextMenu: React.FC = ({ if (!cli) return; if (tagId === DefaultTagID.Favourite || tagId === DefaultTagID.LowPriority) { 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 addTag = isApplied ? null : tagId; dis.dispatch(RoomListActions.tagRoom(cli, room, removeTag, addTag, 0)); diff --git a/apps/web/src/stores/notifications/SpaceNotificationState.ts b/apps/web/src/stores/notifications/SpaceNotificationState.ts index d0cb03621d..860b447ee7 100644 --- a/apps/web/src/stores/notifications/SpaceNotificationState.ts +++ b/apps/web/src/stores/notifications/SpaceNotificationState.ts @@ -14,8 +14,8 @@ import { arrayDiff } from "../../utils/arrays"; import { type RoomNotificationState } from "./RoomNotificationState"; import { NotificationState, NotificationStateEvents } from "./NotificationState"; import { DefaultTagID } from "../room-list-v3/skip-list/tag"; -import RoomListStore from "../room-list/RoomListStore"; import { RoomNotificationStateStore } from "./RoomNotificationStateStore"; +import { getTagsForRoom } from "../../utils/room/getTagsForRoom"; export class SpaceNotificationState extends NotificationState { public rooms: Room[] = []; // exposed only for tests @@ -72,7 +72,7 @@ export class SpaceNotificationState extends NotificationState { this._level = NotificationLevel.None; for (const [roomId, state] of Object.entries(this.states)) { 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 if (roomTags.includes(DefaultTagID.LowPriority) && state.level === NotificationLevel.Activity) continue; diff --git a/apps/web/src/stores/room-list/Interface.ts b/apps/web/src/stores/room-list/Interface.ts index 6e06ccdc25..d4265ef076 100644 --- a/apps/web/src/stores/room-list/Interface.ts +++ b/apps/web/src/stores/room-list/Interface.ts @@ -92,15 +92,6 @@ export interface RoomListStore extends EventEmitter { */ 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 diff --git a/apps/web/src/stores/room-list/RoomListStore.ts b/apps/web/src/stores/room-list/RoomListStore.ts index da3cff0887..d35328bdad 100644 --- a/apps/web/src/stores/room-list/RoomListStore.ts +++ b/apps/web/src/stores/room-list/RoomListStore.ts @@ -35,7 +35,7 @@ import { type RoomListStore as Interface, RoomListStoreEvent } from "./Interface import { UPDATE_EVENT } from "../AsyncStore"; import { SdkContextClass } from "../../contexts/SDKContext"; import { getChangedOverrideRoomMutePushRules } from "../room-list-v3/utils"; -import { 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_LOADING_EVENT = RoomListStoreEvent.ListsLoading; // unused; used by SlidingRoomListStore @@ -597,19 +597,6 @@ export class RoomListStoreClass extends AsyncStoreWithClient 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 { // The room list store knows about all the rooms, so just return the length. return this.orderedLists[tagId].length || 0; diff --git a/apps/web/src/stores/room-list/algorithms/Algorithm.ts b/apps/web/src/stores/room-list/algorithms/Algorithm.ts index a30555cdd5..52fe9b26ff 100644 --- a/apps/web/src/stores/room-list/algorithms/Algorithm.ts +++ b/apps/web/src/stores/room-list/algorithms/Algorithm.ts @@ -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. */ -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 { isNullOrUndefined } from "matrix-js-sdk/src/utils"; import { EventEmitter } from "events"; @@ -24,16 +24,12 @@ import { type ListAlgorithm, type SortAlgorithm, } from "./models"; -import { - EffectiveMembership, - getEffectiveMembership, - getEffectiveMembershipTag, - splitRoomsByMembership, -} from "../../../utils/membership"; +import { EffectiveMembership, splitRoomsByMembership } from "../../../utils/membership"; import { type OrderingAlgorithm } from "./list-ordering/OrderingAlgorithm"; import { getListAlgorithmInstance } from "./list-ordering"; import { isRoomVisible } from "../../room-list-v3/isRoomVisible"; import { CallStore, CallStoreEvent } from "../../CallStore"; +import { getTagsForRoom, getTagsOfJoinedRoom } from "../../../utils/room/getTagsForRoom"; /** * Fired when the Algorithm has determined a list has been updated. @@ -499,7 +495,7 @@ export class Algorithm extends EventEmitter { // Now process all the joined rooms. This is a bit more complicated for (const room of memberships[EffectiveMembership.Join]) { - const tags = this.getTagsOfJoinedRoom(room); + const tags = getTagsOfJoinedRoom(room); let inTag = false; 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 */ @@ -677,7 +637,7 @@ export class Algorithm extends EventEmitter { let didTagChange = false; if (cause === RoomUpdateCause.PossibleTagChange) { const oldTags = this.roomIdsToTags[room.roomId] || []; - const newTags = this.getTagsForRoom(room); + const newTags = getTagsForRoom(room); const diff = arrayDiff(oldTags, newTags); if (diff.removed.length > 0 || diff.added.length > 0) { 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 - 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(), // which means we should *always* have a tag to go off of. diff --git a/apps/web/src/utils/room/getTagsForRoom.ts b/apps/web/src/utils/room/getTagsForRoom.ts new file mode 100644 index 0000000000..449c5f0889 --- /dev/null +++ b/apps/web/src/utils/room/getTagsForRoom.ts @@ -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; +} diff --git a/apps/web/src/utils/room/tagRoom.ts b/apps/web/src/utils/room/tagRoom.ts index 9731c72bb4..6e3d6e4a52 100644 --- a/apps/web/src/utils/room/tagRoom.ts +++ b/apps/web/src/utils/room/tagRoom.ts @@ -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 { 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 RoomListActions from "../../actions/RoomListActions"; import dis from "../../dispatcher/dispatcher"; +import { getTagsForRoom } from "./getTagsForRoom"; /** * Toggle tag for a given room @@ -22,7 +22,7 @@ import dis from "../../dispatcher/dispatcher"; export function tagRoom(room: Room, tagId: TagID): void { if (tagId === DefaultTagID.Favourite || tagId === DefaultTagID.LowPriority) { 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 addTag = isApplied ? null : tagId; dis.dispatch(RoomListActions.tagRoom(room.client, room, removeTag, addTag, 0)); diff --git a/apps/web/test/unit-tests/components/viewmodels/right_panel/RoomSummaryCardViewModel-test.tsx b/apps/web/test/unit-tests/components/viewmodels/right_panel/RoomSummaryCardViewModel-test.tsx index 331f11b51f..193dab7d0b 100644 --- a/apps/web/test/unit-tests/components/viewmodels/right_panel/RoomSummaryCardViewModel-test.tsx +++ b/apps/web/test/unit-tests/components/viewmodels/right_panel/RoomSummaryCardViewModel-test.tsx @@ -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 { 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-v3/skip-list/tag"; import RightPanelStore from "../../../../../src/stores/right-panel/RightPanelStore"; 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 DMRoomMap from "../../../../../src/utils/DMRoomMap"; import * as hooks from "../../../../../src/hooks/useAccountData"; +import * as getTagsForRoomUtils from "../../../../../src/utils/room/getTagsForRoom"; jest.mock("../../../../../src/utils/room/inviteToRoom", () => ({ inviteToRoom: jest.fn(), @@ -43,7 +43,7 @@ describe("useRoomSummaryCardViewModel", () => { getUserIdForRoomId: jest.fn(), } as unknown as DMRoomMap); - jest.spyOn(RoomListStore.instance, "getTagsForRoom").mockReturnValue([]); + jest.spyOn(getTagsForRoomUtils, "getTagsForRoom").mockReturnValue([]); }); afterEach(() => { @@ -195,14 +195,14 @@ describe("useRoomSummaryCardViewModel", () => { describe("favorite room state", () => { it("should identify favorite rooms", () => { - jest.spyOn(RoomListStore.instance, "getTagsForRoom").mockReturnValue([DefaultTagID.Favourite]); + jest.spyOn(getTagsForRoomUtils, "getTagsForRoom").mockReturnValue([DefaultTagID.Favourite]); const { result } = render(); expect(result.current.isFavorite).toBe(true); }); it("should identify non-favorite rooms", () => { - jest.spyOn(RoomListStore.instance, "getTagsForRoom").mockReturnValue([]); + jest.spyOn(getTagsForRoomUtils, "getTagsForRoom").mockReturnValue([]); const { result } = render(); expect(result.current.isFavorite).toBe(false); diff --git a/apps/web/test/unit-tests/components/views/context_menus/RoomGeneralContextMenu-test.tsx b/apps/web/test/unit-tests/components/views/context_menus/RoomGeneralContextMenu-test.tsx index 9b65ac0346..7e35bd9f79 100644 --- a/apps/web/test/unit-tests/components/views/context_menus/RoomGeneralContextMenu-test.tsx +++ b/apps/web/test/unit-tests/components/views/context_menus/RoomGeneralContextMenu-test.tsx @@ -22,13 +22,13 @@ import { import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; 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 { mkMessage, stubClient } from "../../../../test-utils/test-utils"; import { shouldShowComponent } from "../../../../../src/customisations/helpers/UIComponents"; import { UIComponent } from "../../../../../src/settings/UIFeature"; import SettingsStore from "../../../../../src/settings/SettingsStore"; import { clearAllModals } from "../../../../test-utils"; +import * as getTagsForRoomUtils from "../../../../../src/utils/room/getTagsForRoom"; jest.mock("../../../../../src/customisations/helpers/UIComponents", () => ({ shouldShowComponent: jest.fn(), @@ -74,7 +74,7 @@ describe("RoomGeneralContextMenu", () => { } as unknown as DMRoomMap; DMRoomMap.setShared(dmRoomMap); - jest.spyOn(RoomListStore.instance, "getTagsForRoom").mockReturnValueOnce([ + jest.spyOn(getTagsForRoomUtils, "getTagsForRoom").mockReturnValueOnce([ DefaultTagID.DM, DefaultTagID.Favourite, ]); @@ -87,7 +87,7 @@ describe("RoomGeneralContextMenu", () => { }); 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({}); expect(container).toMatchSnapshot(); diff --git a/apps/web/test/unit-tests/stores/room-list/RoomListStore-test.ts b/apps/web/test/unit-tests/stores/room-list/RoomListStore-test.ts index d6139a6a21..ec771a1d82 100644 --- a/apps/web/test/unit-tests/stores/room-list/RoomListStore-test.ts +++ b/apps/web/test/unit-tests/stores/room-list/RoomListStore-test.ts @@ -10,7 +10,6 @@ import { ConditionKind, EventType, type IPushRule, - JoinRule, MatrixEvent, PendingEventOrdering, PushRuleActionName, @@ -23,11 +22,10 @@ import defaultDispatcher, { type MatrixDispatcher } from "../../../../src/dispat 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 } from "../../../../src/stores/room-list-v3/skip-list/tag"; import { OrderedDefaultTagIDs, RoomUpdateCause } from "../../../../src/stores/room-list/models"; import RoomListStore, { RoomListStoreClass } from "../../../../src/stores/room-list/RoomListStore"; import DMRoomMap from "../../../../src/utils/DMRoomMap"; -import { flushPromises, stubClient, upsertRoomStateEvents, mkRoom } from "../../../test-utils"; +import { flushPromises, stubClient, upsertRoomStateEvents } from "../../../test-utils"; import { DEFAULT_PUSH_RULES, makePushRule } from "../../../test-utils/pushRules"; 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(); - }); - }); }); diff --git a/apps/web/test/unit-tests/utils/room/getTagsForRoom-test.ts b/apps/web/test/unit-tests/utils/room/getTagsForRoom-test.ts new file mode 100644 index 0000000000..bec448834a --- /dev/null +++ b/apps/web/test/unit-tests/utils/room/getTagsForRoom-test.ts @@ -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); + }); + }); +}); diff --git a/apps/web/test/unit-tests/utils/room/tagRoom-test.ts b/apps/web/test/unit-tests/utils/room/tagRoom-test.ts index af4ac46a0f..4a017a9617 100644 --- a/apps/web/test/unit-tests/utils/room/tagRoom-test.ts +++ b/apps/web/test/unit-tests/utils/room/tagRoom-test.ts @@ -11,9 +11,9 @@ 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-v3/skip-list/tag"; -import RoomListStore from "../../../../src/stores/room-list/RoomListStore"; import { tagRoom } from "../../../../src/utils/room/tagRoom"; import { getMockClientWithEventEmitter } from "../../../test-utils"; +import * as getTagsForRoomUtils from "../../../../src/utils/room/getTagsForRoom"; describe("tagRoom()", () => { const userId = "@alice:server.org"; @@ -25,7 +25,7 @@ describe("tagRoom()", () => { }); const room = new Room(roomId, client, userId); - jest.spyOn(RoomListStore.instance, "getTagsForRoom").mockReturnValue(tags); + jest.spyOn(getTagsForRoomUtils, "getTagsForRoom").mockReturnValue(tags); return room; };