UNPKG

matrix-react-sdk

Version:
396 lines (358 loc) 16.7 kB
/* 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 Please see LICENSE files in the repository root for full details. */ import { Room } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { MSC3575Filter, SlidingSyncEvent } from "matrix-js-sdk/src/sliding-sync"; import { Optional } from "matrix-events-sdk"; import { RoomUpdateCause, TagID, OrderedDefaultTagIDs, DefaultTagID } from "./models"; import { ITagMap, ListAlgorithm, SortAlgorithm } from "./algorithms/models"; import { ActionPayload } from "../../dispatcher/payloads"; import { MatrixDispatcher } from "../../dispatcher/dispatcher"; import { IFilterCondition } from "./filters/IFilterCondition"; import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; import { RoomListStore as Interface, RoomListStoreEvent } from "./Interface"; import { MetaSpace, SpaceKey, UPDATE_SELECTED_SPACE } from "../spaces"; import { LISTS_LOADING_EVENT } from "./RoomListStore"; import { UPDATE_EVENT } from "../AsyncStore"; import { SdkContextClass } from "../../contexts/SDKContext"; interface IState { // state is tracked in underlying classes } export const SlidingSyncSortToFilter: Record<SortAlgorithm, string[]> = { [SortAlgorithm.Alphabetic]: ["by_name", "by_recency"], [SortAlgorithm.Recent]: ["by_notification_level", "by_recency"], [SortAlgorithm.Manual]: ["by_recency"], }; const filterConditions: Record<TagID, MSC3575Filter> = { [DefaultTagID.Invite]: { is_invite: true, }, [DefaultTagID.Favourite]: { tags: ["m.favourite"], }, [DefaultTagID.DM]: { is_dm: true, is_invite: false, // If a DM has a Favourite & Low Prio tag then it'll be shown in those lists instead not_tags: ["m.favourite", "m.lowpriority"], }, [DefaultTagID.Untagged]: { is_dm: false, is_invite: false, not_room_types: ["m.space"], not_tags: ["m.favourite", "m.lowpriority"], // spaces filter added dynamically }, [DefaultTagID.LowPriority]: { tags: ["m.lowpriority"], // If a room has both Favourite & Low Prio tags then it'll be shown under Favourites not_tags: ["m.favourite"], }, // TODO https://github.com/vector-im/element-web/issues/23207 // DefaultTagID.ServerNotice, // DefaultTagID.Suggested, // DefaultTagID.Archived, }; export const LISTS_UPDATE_EVENT = RoomListStoreEvent.ListsUpdate; export class SlidingRoomListStoreClass extends AsyncStoreWithClient<IState> implements Interface { private tagIdToSortAlgo: Record<TagID, SortAlgorithm> = {}; private tagMap: ITagMap = {}; private counts: Record<TagID, number> = {}; private stickyRoomId: Optional<string>; public constructor( dis: MatrixDispatcher, private readonly context: SdkContextClass, ) { super(dis); this.setMaxListeners(20); // RoomList + LeftPanel + 8xRoomSubList + spares } public async setTagSorting(tagId: TagID, sort: SortAlgorithm): Promise<void> { logger.info("SlidingRoomListStore.setTagSorting ", tagId, sort); this.tagIdToSortAlgo[tagId] = sort; switch (sort) { case SortAlgorithm.Alphabetic: await this.context.slidingSyncManager.ensureListRegistered(tagId, { sort: SlidingSyncSortToFilter[SortAlgorithm.Alphabetic], }); break; case SortAlgorithm.Recent: await this.context.slidingSyncManager.ensureListRegistered(tagId, { sort: SlidingSyncSortToFilter[SortAlgorithm.Recent], }); break; case SortAlgorithm.Manual: logger.error("cannot enable manual sort in sliding sync mode"); break; default: logger.error("unknown sort mode: ", sort); } } public getTagSorting(tagId: TagID): SortAlgorithm { let algo = this.tagIdToSortAlgo[tagId]; if (!algo) { logger.warn("SlidingRoomListStore.getTagSorting: no sort algorithm for tag ", tagId); algo = SortAlgorithm.Recent; // why not, we have to do something.. } return algo; } public getCount(tagId: TagID): number { return this.counts[tagId] || 0; } public setListOrder(tagId: TagID, order: ListAlgorithm): void { // TODO: https://github.com/vector-im/element-web/issues/23207 } public getListOrder(tagId: TagID): ListAlgorithm { // TODO: handle unread msgs first? https://github.com/vector-im/element-web/issues/23207 return ListAlgorithm.Natural; } /** * 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> { // Do nothing, the filters are only used by SpaceWatcher to see if a room should appear // in the room list. We do not support arbitrary code for filters in sliding sync. } /** * 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 { // Do nothing, the filters are only used by SpaceWatcher to see if a room should appear // in the room list. We do not support arbitrary code for filters in sliding sync. } /** * 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[] { // check all lists for each tag we know about and see if the room is there const tags: TagID[] = []; for (const tagId in this.tagIdToSortAlgo) { const listData = this.context.slidingSyncManager.slidingSync?.getListData(tagId); if (!listData) { continue; } for (const roomIndex in listData.roomIndexToRoomId) { const roomId = listData.roomIndexToRoomId[roomIndex]; if (roomId === room.roomId) { tags.push(tagId); break; } } } return tags; } /** * 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> { // TODO: this is only used when you forget a room, not that important for now. } public get orderedLists(): ITagMap { return this.tagMap; } private refreshOrderedLists(tagId: string, roomIndexToRoomId: Record<number, string>): void { const tagMap = this.tagMap; // this room will not move due to it being viewed: it is sticky. This can be null to indicate // no sticky room if you aren't viewing a room. this.stickyRoomId = this.context.roomViewStore.getRoomId(); let stickyRoomNewIndex = -1; const stickyRoomOldIndex = (tagMap[tagId] || []).findIndex((room): boolean => { return room.roomId === this.stickyRoomId; }); // order from low to high const orderedRoomIndexes = Object.keys(roomIndexToRoomId) .map((numStr) => { return Number(numStr); }) .sort((a, b) => { return a - b; }); const seenRoomIds = new Set<string>(); const orderedRoomIds = orderedRoomIndexes.map((i) => { const rid = roomIndexToRoomId[i]; if (seenRoomIds.has(rid)) { logger.error("room " + rid + " already has an index position: duplicate room!"); } seenRoomIds.add(rid); if (!rid) { throw new Error("index " + i + " has no room ID: Map => " + JSON.stringify(roomIndexToRoomId)); } if (rid === this.stickyRoomId) { stickyRoomNewIndex = i; } return rid; }); logger.debug( `SlidingRoomListStore.refreshOrderedLists ${tagId} sticky: ${this.stickyRoomId}`, `${stickyRoomOldIndex} -> ${stickyRoomNewIndex}`, "rooms:", orderedRoomIds.length < 30 ? orderedRoomIds : orderedRoomIds.length, ); if (this.stickyRoomId && stickyRoomOldIndex >= 0 && stickyRoomNewIndex >= 0) { // this update will move this sticky room from old to new, which we do not want. // Instead, keep the sticky room ID index position as it is, swap it with // whatever was in its place. // Some scenarios with sticky room S and bump room B (other letters unimportant): // A, S, C, B S, A, B // B, A, S, C <---- without sticky rooms ---> B, S, A // B, S, A, C <- with sticky rooms applied -> S, B, A // In other words, we need to swap positions to keep it locked in place. const inWayRoomId = orderedRoomIds[stickyRoomOldIndex]; orderedRoomIds[stickyRoomOldIndex] = this.stickyRoomId; orderedRoomIds[stickyRoomNewIndex] = inWayRoomId; } // now set the rooms const rooms: Room[] = []; orderedRoomIds.forEach((roomId) => { const room = this.matrixClient?.getRoom(roomId); if (!room) { return; } rooms.push(room); }); tagMap[tagId] = rooms; this.tagMap = tagMap; } private onSlidingSyncListUpdate(tagId: string, joinCount: number, roomIndexToRoomId: Record<number, string>): void { this.counts[tagId] = joinCount; this.refreshOrderedLists(tagId, roomIndexToRoomId); // let the UI update this.emit(LISTS_UPDATE_EVENT); } private onRoomViewStoreUpdated(): void { // we only care about this to know when the user has clicked on a room to set the stickiness value if (this.context.roomViewStore.getRoomId() === this.stickyRoomId) { return; } let hasUpdatedAnyList = false; // every list with the OLD sticky room ID needs to be resorted because it now needs to take // its proper place as it is no longer sticky. The newly sticky room can remain the same though, // as we only actually care about its sticky status when we get list updates. const oldStickyRoom = this.stickyRoomId; // it's not safe to check the data in slidingSync as it is tracking the server's view of the // room list. There's an edge case whereby the sticky room has gone outside the window and so // would not be present in the roomIndexToRoomId map anymore, and hence clicking away from it // will make it disappear eventually. We need to check orderedLists as that is the actual // sorted renderable list of rooms which sticky rooms apply to. for (const tagId in this.orderedLists) { const list = this.orderedLists[tagId]; const room = list.find((room) => { return room.roomId === oldStickyRoom; }); if (room) { // resort it based on the slidingSync view of the list. This may cause this old sticky // room to cease to exist. const listData = this.context.slidingSyncManager.slidingSync?.getListData(tagId); if (!listData) { continue; } this.refreshOrderedLists(tagId, listData.roomIndexToRoomId); hasUpdatedAnyList = true; } } // in the event we didn't call refreshOrderedLists, it helps to still remember the sticky room ID. this.stickyRoomId = this.context.roomViewStore.getRoomId(); if (hasUpdatedAnyList) { this.emit(LISTS_UPDATE_EVENT); } } protected async onReady(): Promise<any> { logger.info("SlidingRoomListStore.onReady"); // permanent listeners: never get destroyed. Could be an issue if we want to test this in isolation. this.context.slidingSyncManager.slidingSync!.on(SlidingSyncEvent.List, this.onSlidingSyncListUpdate.bind(this)); this.context.roomViewStore.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdated.bind(this)); this.context.spaceStore.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated.bind(this)); if (this.context.spaceStore.activeSpace) { this.onSelectedSpaceUpdated(this.context.spaceStore.activeSpace, false); } // sliding sync has an initial response for spaces. Now request all the lists. // We do the spaces list _first_ to avoid potential flickering on DefaultTagID.Untagged list // which would be caused by initially having no `spaces` filter set, and then suddenly setting one. OrderedDefaultTagIDs.forEach((tagId) => { const filter = filterConditions[tagId]; if (!filter) { logger.info("SlidingRoomListStore.onReady unsupported list ", tagId); return; // we do not support this list yet. } const sort = SortAlgorithm.Recent; // default to recency sort, TODO: read from config this.tagIdToSortAlgo[tagId] = sort; this.emit(LISTS_LOADING_EVENT, tagId, true); this.context.slidingSyncManager .ensureListRegistered(tagId, { filters: filter, sort: SlidingSyncSortToFilter[sort], }) .then(() => { this.emit(LISTS_LOADING_EVENT, tagId, false); }); }); } private onSelectedSpaceUpdated = (activeSpace: SpaceKey, allRoomsInHome: boolean): void => { logger.info("SlidingRoomListStore.onSelectedSpaceUpdated", activeSpace); // update the untagged filter const tagId = DefaultTagID.Untagged; const filters = filterConditions[tagId]; const oldSpace = filters.spaces?.[0]; filters.spaces = activeSpace && activeSpace != MetaSpace.Home ? [activeSpace] : undefined; if (oldSpace !== activeSpace) { // include subspaces in this list this.context.spaceStore.traverseSpace( activeSpace, (roomId: string) => { if (roomId === activeSpace) { return; } if (!filters.spaces) { filters.spaces = []; } filters.spaces.push(roomId); // add subspace }, false, ); this.emit(LISTS_LOADING_EVENT, tagId, true); this.context.slidingSyncManager .ensureListRegistered(tagId, { filters: filters, }) .then(() => { this.emit(LISTS_LOADING_EVENT, tagId, false); }); } }; // Intended for test usage public async resetStore(): Promise<void> { // Test function } /** * 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 { // Test function } protected async onNotReady(): Promise<any> { await this.resetStore(); } protected async onAction(payload: ActionPayload): Promise<void> {} }