From fa2403d39a71e4eb96119425140f0a44c6a4294e Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Mon, 18 Apr 2022 17:45:41 -0400 Subject: [PATCH] Stick connected video rooms to the top of the room list --- src/stores/room-list/RoomListStore.ts | 5 +- src/stores/room-list/algorithms/Algorithm.ts | 89 +++++++++++++++++-- .../room-list/algorithms/Algorithm-test.ts | 64 +++++++++++++ 3 files changed, 149 insertions(+), 9 deletions(-) create mode 100644 test/stores/room-list/algorithms/Algorithm-test.ts diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index 125b045c9a4..dc7f405c62d 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -70,6 +70,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient { constructor() { super(defaultDispatcher); this.setMaxListeners(20); // RoomList + LeftPanel + 8xRoomSubList + spares + this.algorithm.start(); } private setupWatchers() { @@ -96,6 +97,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient { 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); @@ -479,8 +481,9 @@ export class RoomListStoreClass extends AsyncStoreWithClient { } } - private onAlgorithmListUpdated = () => { + private onAlgorithmListUpdated = (forceUpdate: boolean) => { this.updateFn.mark(); + if (forceUpdate) this.updateFn.trigger(); }; private onAlgorithmFilterUpdated = () => { diff --git a/src/stores/room-list/algorithms/Algorithm.ts b/src/stores/room-list/algorithms/Algorithm.ts index 55e877ed5d4..edd406fd07b 100644 --- a/src/stores/room-list/algorithms/Algorithm.ts +++ b/src/stores/room-list/algorithms/Algorithm.ts @@ -35,6 +35,7 @@ import { EffectiveMembership, getEffectiveMembership, splitRoomsByMembership } f import { OrderingAlgorithm } from "./list-ordering/OrderingAlgorithm"; import { getListAlgorithmInstance } from "./list-ordering"; import { VisibilityProvider } from "../filters/VisibilityProvider"; +import VideoChannelStore, { VideoChannelEvent } from "../../VideoChannelStore"; /** * Fired when the Algorithm has determined a list has been updated. @@ -84,8 +85,14 @@ export class Algorithm extends EventEmitter { */ public updatesInhibited = false; - public constructor() { - super(); + public start() { + VideoChannelStore.instance.on(VideoChannelEvent.Connect, this.updateVideoRoom); + VideoChannelStore.instance.on(VideoChannelEvent.Disconnect, this.updateVideoRoom); + } + + public stop() { + VideoChannelStore.instance.off(VideoChannelEvent.Connect, this.updateVideoRoom); + VideoChannelStore.instance.off(VideoChannelEvent.Disconnect, this.updateVideoRoom); } public get stickyRoom(): Room { @@ -108,6 +115,7 @@ export class Algorithm extends EventEmitter { this._cachedRooms = val; this.recalculateFilteredRooms(); this.recalculateStickyRoom(); + this.recalculateVideoRoom(); } protected get cachedRooms(): ITagMap { @@ -145,6 +153,7 @@ export class Algorithm extends EventEmitter { this._cachedRooms[tagId] = algorithm.orderedRooms; this.recalculateFilteredRoomsForTag(tagId); // update filter to re-sort the list this.recalculateStickyRoom(tagId); // update sticky room to make sure it appears if needed + this.recalculateVideoRoom(tagId); } public getListOrdering(tagId: TagID): ListAlgorithm { @@ -164,6 +173,7 @@ export class Algorithm extends EventEmitter { this._cachedRooms[tagId] = algorithm.orderedRooms; this.recalculateFilteredRoomsForTag(tagId); // update filter to re-sort the list this.recalculateStickyRoom(tagId); // update sticky room to make sure it appears if needed + this.recalculateVideoRoom(tagId); } public addFilterCondition(filterCondition: IFilterCondition): void { @@ -311,12 +321,30 @@ export class Algorithm extends EventEmitter { this.recalculateFilteredRoomsForTag(tag); if (lastStickyRoom && lastStickyRoom.tag !== tag) this.recalculateFilteredRoomsForTag(lastStickyRoom.tag); this.recalculateStickyRoom(); + this.recalculateVideoRoom(tag); + if (lastStickyRoom && lastStickyRoom.tag !== tag) this.recalculateVideoRoom(lastStickyRoom.tag); // Finally, trigger an update if (this.updatesInhibited) return; this.emit(LIST_UPDATED_EVENT); } + /** + * Update the stickiness of video rooms. + */ + public updateVideoRoom = () => { + // In case we're unsticking a video room, sort it back into natural order + this.recalculateFilteredRooms(); + this.recalculateStickyRoom(); + + this.recalculateVideoRoom(); + + 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); + }; + protected recalculateFilteredRooms() { if (!this.hasFilters) { return; @@ -374,6 +402,13 @@ export class Algorithm extends EventEmitter { } } + private initCachedStickyRooms() { + 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 @@ -400,17 +435,13 @@ export class Algorithm extends EventEmitter { } if (!this._cachedStickyRooms || !updatedTag) { - const stickiedTagMap: ITagMap = {}; - for (const tagId of Object.keys(this.cachedRooms)) { - stickiedTagMap[tagId] = this.cachedRooms[tagId].map(r => r); // shallow clone - } - this._cachedStickyRooms = stickiedTagMap; + this.initCachedStickyRooms(); } if (updatedTag) { // Update the tag indicated by the caller, if possible. This is mostly to ensure // our cache is up to date. - this._cachedStickyRooms[updatedTag] = this.cachedRooms[updatedTag].map(r => r); // shallow clone + this._cachedStickyRooms[updatedTag] = [...this.cachedRooms[updatedTag]]; // shallow clone } // Now try to insert the sticky room, if we need to. @@ -426,6 +457,46 @@ export class Algorithm extends EventEmitter { this.emit(LIST_UPDATED_EVENT); } + /** + * Recalculate the position of any video rooms. 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 + * currently connected video room to the top of its tag. + * + * @param updatedTag The tag that was updated, if possible. + */ + protected recalculateVideoRoom(updatedTag: TagID = 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.recalculateVideoRoom(tagId); + } + return; + } + + const videoRoomId = VideoChannelStore.instance.connected ? VideoChannelStore.instance.roomId : null; + + if (videoRoomId) { + // We operate directly on the sticky rooms map + if (!this._cachedStickyRooms) this.initCachedStickyRooms(); + const rooms = this._cachedStickyRooms[updatedTag]; + const videoRoomIdxInTag = rooms.findIndex(r => r.roomId === videoRoomId); + if (videoRoomIdxInTag < 0) return; // no-op + + const videoRoom = rooms[videoRoomIdxInTag]; + rooms.splice(videoRoomIdxInTag, 1); + rooms.unshift(videoRoom); + this._cachedStickyRooms[updatedTag] = rooms; // re-set because references aren't always safe + } + } + /** * Asks the Algorithm to regenerate all lists, using the tags given * as reference for which lists to generate and which way to generate @@ -706,6 +777,7 @@ export class Algorithm extends EventEmitter { this._cachedRooms[rmTag] = algorithm.orderedRooms; this.recalculateFilteredRoomsForTag(rmTag); // update filter to re-sort the list this.recalculateStickyRoom(rmTag); // update sticky room to make sure it moves if needed + this.recalculateVideoRoom(rmTag); } for (const addTag of diff.added) { const algorithm: OrderingAlgorithm = this.algorithms[addTag]; @@ -782,6 +854,7 @@ export class Algorithm extends EventEmitter { // Flag that we've done something this.recalculateFilteredRoomsForTag(tag); // update filter to re-sort the list this.recalculateStickyRoom(tag); // update sticky room to make sure it appears if needed + this.recalculateVideoRoom(tag); changed = true; } diff --git a/test/stores/room-list/algorithms/Algorithm-test.ts b/test/stores/room-list/algorithms/Algorithm-test.ts new file mode 100644 index 00000000000..d83f6ccca6c --- /dev/null +++ b/test/stores/room-list/algorithms/Algorithm-test.ts @@ -0,0 +1,64 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { stubClient, stubVideoChannelStore, mkRoom } 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"; + +describe("Algorithm", () => { + let videoChannelStore; + let algorithm; + let textRoom; + let videoRoom; + beforeEach(() => { + stubClient(); + const cli = MatrixClientPeg.get(); + DMRoomMap.makeShared(); + videoChannelStore = stubVideoChannelStore(); + algorithm = new Algorithm(); + algorithm.start(); + + textRoom = mkRoom(cli, "!text:example.org"); + videoRoom = mkRoom(cli, "!video:example.org"); + videoRoom.isElementVideoRoom.mockReturnValue(true); + algorithm.populateTags( + { [DefaultTagID.Untagged]: SortAlgorithm.Alphabetic }, + { [DefaultTagID.Untagged]: ListAlgorithm.Natural }, + ); + algorithm.setKnownRooms([textRoom, videoRoom]); + }); + + afterEach(() => { + algorithm.stop(); + }); + + it("sticks video rooms to the top when they connect", () => { + expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([textRoom, videoRoom]); + videoChannelStore.connect("!video:example.org"); + expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([videoRoom, textRoom]); + }); + + it("unsticks video rooms from the top when they disconnect", () => { + videoChannelStore.connect("!video:example.org"); + expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([videoRoom, textRoom]); + videoChannelStore.disconnect(); + expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([textRoom, videoRoom]); + }); +});