diff --git a/apps/web/src/components/views/dialogs/AddExistingToSpaceDialog.tsx b/apps/web/src/components/views/dialogs/AddExistingToSpaceDialog.tsx index 76caa5c90b..113274a94d 100644 --- a/apps/web/src/components/views/dialogs/AddExistingToSpaceDialog.tsx +++ b/apps/web/src/components/views/dialogs/AddExistingToSpaceDialog.tsx @@ -27,7 +27,7 @@ import DMRoomMap from "../../../utils/DMRoomMap"; import { calculateRoomVia } from "../../../utils/permalinks/Permalinks"; import StyledCheckbox from "../elements/StyledCheckbox"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import { sortRooms } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm"; +import { sortRoomsByRecency } from "../../../utils/room/sortRoomsByRecency"; import ProgressBar from "../elements/ProgressBar"; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; import QueryMatcher from "../../../autocomplete/QueryMatcher"; @@ -172,7 +172,7 @@ export const AddExistingToSpace: React.FC = ({ } const joinRule = space.getJoinRule(); - return sortRooms(rooms).reduce<[spaces: Room[], rooms: Room[], dms: Room[]]>( + return sortRoomsByRecency(rooms, cli.getSafeUserId()).reduce<[spaces: Room[], rooms: Room[], dms: Room[]]>( (arr, room) => { if (room.isSpaceRoom()) { if (room !== space && !existingSubspacesSet.has(room)) { @@ -190,7 +190,7 @@ export const AddExistingToSpace: React.FC = ({ }, [[], [], []], ); - }, [visibleRooms, space, lcQuery, existingRoomsSet, existingSubspacesSet]); + }, [visibleRooms, space, lcQuery, existingRoomsSet, existingSubspacesSet, cli]); const addRooms = async (): Promise => { setError(false); diff --git a/apps/web/src/components/views/dialogs/ForwardDialog.tsx b/apps/web/src/components/views/dialogs/ForwardDialog.tsx index e26b4278ab..ae5b43ad50 100644 --- a/apps/web/src/components/views/dialogs/ForwardDialog.tsx +++ b/apps/web/src/components/views/dialogs/ForwardDialog.tsx @@ -38,7 +38,7 @@ import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; import NotificationBadge from "../rooms/NotificationBadge"; import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; -import { sortRooms } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm"; +import { sortRoomsByRecency } from "../../../utils/room/sortRoomsByRecency"; import QueryMatcher from "../../../autocomplete/QueryMatcher"; import TruncatedList from "../elements/TruncatedList"; import { Action } from "../../../dispatcher/actions"; @@ -284,10 +284,11 @@ const ForwardDialog: React.FC = ({ matrixClient: cli, event, permalinkCr let rooms = useMemo( () => - sortRooms( + sortRoomsByRecency( cli .getVisibleRooms(msc3946DynamicRoomPredecessors) .filter((room) => room.getMyMembership() === KnownMembership.Join && !room.isSpaceRoom()), + cli.getSafeUserId(), ), [cli, msc3946DynamicRoomPredecessors], ); diff --git a/apps/web/src/components/views/dialogs/spotlight/SpotlightDialog.tsx b/apps/web/src/components/views/dialogs/spotlight/SpotlightDialog.tsx index d8874a4d95..31cddccee6 100644 --- a/apps/web/src/components/views/dialogs/spotlight/SpotlightDialog.tsx +++ b/apps/web/src/components/views/dialogs/spotlight/SpotlightDialog.tsx @@ -70,7 +70,7 @@ import SettingsStore from "../../../../settings/SettingsStore"; import { BreadcrumbsStore } from "../../../../stores/BreadcrumbsStore"; import { type RoomNotificationState } from "../../../../stores/notifications/RoomNotificationState"; import { RoomNotificationStateStore } from "../../../../stores/notifications/RoomNotificationStateStore"; -import { RecentAlgorithm } from "../../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm"; +import { compareRoomsByRecency } from "../../../../utils/room/sortRoomsByRecency"; import { SdkContextClass } from "../../../../contexts/SDKContext"; import { getMetaSpaceName, MetaSpace } from "../../../../stores/spaces"; import SpaceStore from "../../../../stores/spaces/SpaceStore"; @@ -252,8 +252,6 @@ const toMemberResult = (member: Member | RoomMember, alreadyFiltered: boolean): query: [member.userId.toLowerCase(), member.name.toLowerCase()].filter(Boolean), }); -const recentAlgorithm = new RecentAlgorithm(); - export const useWebSearchMetrics = (numResults: number, queryLength: number, viaSpotlight: boolean): void => { useEffect(() => { if (!queryLength) return; @@ -498,7 +496,6 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n } // Sort results by most recent activity - const myUserId = cli.getSafeUserId(); for (const resultArray of Object.values(results)) { resultArray.sort((a: Result, b: Result) => { @@ -507,7 +504,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n if (!isRoomResult(b)) return -1; if (!isRoomResult(a)) return -1; - return recentAlgorithm.getLastTs(b.room, myUserId) - recentAlgorithm.getLastTs(a.room, myUserId); + return compareRoomsByRecency(a.room, b.room, myUserId); } else if (isMemberResult(a) || isMemberResult(b)) { // Member results should appear just after room results if (!isMemberResult(b)) return -1; @@ -520,7 +517,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n } return results; - }, [trimmedQuery, filter, cli, possibleResults, userDirectorySearchResults, memberComparator]); + }, [cli, trimmedQuery, filter, possibleResults, userDirectorySearchResults, memberComparator]); const numResults = sum(Object.values(results).map((it) => it.length)); useWebSearchMetrics(numResults, query.length, true); diff --git a/apps/web/src/utils/room/sortRoomsByRecency.ts b/apps/web/src/utils/room/sortRoomsByRecency.ts new file mode 100644 index 0000000000..682f2989b7 --- /dev/null +++ b/apps/web/src/utils/room/sortRoomsByRecency.ts @@ -0,0 +1,45 @@ +/* +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 type { Room } from "matrix-js-sdk/src/matrix"; +import { getLastTimestamp } from "../../stores/room-list-v3/skip-list/sorters/utils/getLastTimestamp"; + +/** + * Sort an arbitrary list of rooms by recency (most recent activity first). + * + * Unlike the main room list sorting, this is a pure recency sort: muted and + * low-priority rooms are NOT moved to the bottom. The provided array is not + * mutated; a sorted copy is returned. The given list is sorted as-is and is + * not filtered by the active space. + * @param rooms The rooms to sort. + * @param userId The mxId of the current user. + */ +export function sortRoomsByRecency(rooms: Room[], userId: string): Room[] { + const cache = new Map(); + const ts = (room: Room): number => { + let value = cache.get(room.roomId); + if (value === undefined) { + value = getLastTimestamp(room, userId); + cache.set(room.roomId, value); + } + return value; + }; + return [...rooms].sort((a, b) => ts(b) - ts(a)); +} + +/** + * Compare two rooms by recency, most recent activity first. + * + * Intended for sorting mixed result sets where rooms need to be ordered + * relative to each other (e.g. spotlight search results). + * @param roomA The first room. + * @param roomB The second room. + * @param userId The mxId of the current user. + */ +export function compareRoomsByRecency(roomA: Room, roomB: Room, userId: string): number { + return getLastTimestamp(roomB, userId) - getLastTimestamp(roomA, userId); +} diff --git a/apps/web/test/unit-tests/utils/room/sortRoomsByRecency-test.ts b/apps/web/test/unit-tests/utils/room/sortRoomsByRecency-test.ts new file mode 100644 index 0000000000..e9ae1fa62e --- /dev/null +++ b/apps/web/test/unit-tests/utils/room/sortRoomsByRecency-test.ts @@ -0,0 +1,57 @@ +/* +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 { type Room } from "matrix-js-sdk/src/matrix"; + +import { createTestClient } from "../../../test-utils"; +import { getMockedRooms } from "../../stores/room-list-v3/skip-list/getMockedRooms"; +import { DefaultTagID } from "../../../../src/stores/room-list-v3/skip-list/tag"; +import { compareRoomsByRecency, sortRoomsByRecency } from "../../../../src/utils/room/sortRoomsByRecency"; + +describe("sortRoomsByRecency", () => { + let userId: string; + let rooms: Room[]; + + beforeEach(() => { + const client = createTestClient(); + userId = client.getSafeUserId(); + rooms = getMockedRooms(client); + }); + + describe("sortRoomsByRecency", () => { + it("sorts an arbitrary list by recency without mutating the input", () => { + const input = [rooms[0], rooms[5], rooms[2]]; + const inputCopy = [...input]; + + const sorted = sortRoomsByRecency(input, userId); + + // ts: room5 (6) > room2 (3) > room0 (1) + expect(sorted).toEqual([rooms[5], rooms[2], rooms[0]]); + // The input array is not mutated. + expect(input).toEqual(inputCopy); + }); + + it("does not move muted or low-priority rooms (pure recency)", () => { + const recent = rooms[99]; // highest ts + recent.tags = { [DefaultTagID.LowPriority]: { order: 0 } }; + + const sorted = sortRoomsByRecency([rooms[0], rooms[50], recent], userId); + + // A pure recency sort keeps the most recent room first even though it is + // low priority (the full RecencySorter would sink it). + expect(sorted[0]).toBe(recent); + }); + }); + + describe("compareRoomsByRecency", () => { + it("orders the more recent room first", () => { + // rooms[10] (ts 11) is more recent than rooms[3] (ts 4) + expect(compareRoomsByRecency(rooms[10], rooms[3], userId)).toBeLessThan(0); + expect(compareRoomsByRecency(rooms[3], rooms[10], userId)).toBeGreaterThan(0); + }); + }); +});