Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Stick connected video rooms to the top of the room list #8353

Merged
merged 2 commits into from
Apr 22, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/stores/room-list/RoomListStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
constructor() {
super(defaultDispatcher);
this.setMaxListeners(20); // RoomList + LeftPanel + 8xRoomSubList + spares
this.algorithm.start();
}

private setupWatchers() {
Expand All @@ -96,6 +97,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {

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);
Expand Down Expand Up @@ -479,8 +481,9 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
}
}

private onAlgorithmListUpdated = () => {
private onAlgorithmListUpdated = (forceUpdate: boolean) => {
this.updateFn.mark();
if (forceUpdate) this.updateFn.trigger();
};

private onAlgorithmFilterUpdated = () => {
Expand Down
89 changes: 81 additions & 8 deletions src/stores/room-list/algorithms/Algorithm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -108,6 +115,7 @@ export class Algorithm extends EventEmitter {
this._cachedRooms = val;
this.recalculateFilteredRooms();
this.recalculateStickyRoom();
this.recalculateVideoRoom();
}

protected get cachedRooms(): ITagMap {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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;
}

Expand Down
64 changes: 64 additions & 0 deletions test/stores/room-list/algorithms/Algorithm-test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});