Merge branch 'develop' into travis/msc2876

This commit is contained in:
Travis Ralston 2021-05-14 14:09:57 -06:00
commit 98b2e120a7
173 changed files with 5448 additions and 1807 deletions

View file

@ -122,7 +122,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
}
private async appendRoom(room: Room) {
if (room.isSpaceRoom() && SettingsStore.getValue("feature_spaces")) return; // hide space rooms
if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) return; // hide space rooms
let updated = false;
const rooms = (this.state.rooms || []).slice(); // cheap clone

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {sortBy, throttle} from "lodash";
import {ListIteratee, Many, sortBy, throttle} from "lodash";
import {EventType, RoomType} from "matrix-js-sdk/src/@types/event";
import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
@ -31,28 +31,23 @@ import {RoomNotificationStateStore} from "./notifications/RoomNotificationStateS
import {DefaultTagID} from "./room-list/models";
import {EnhancedMap, mapDiff} from "../utils/maps";
import {setHasDiff} from "../utils/sets";
import {objectDiff} from "../utils/objects";
import {arrayHasDiff} from "../utils/arrays";
import {ISpaceSummaryEvent, ISpaceSummaryRoom} from "../components/structures/SpaceRoomDirectory";
import RoomViewStore from "./RoomViewStore";
type SpaceKey = string | symbol;
interface IState {}
const ACTIVE_SPACE_LS_KEY = "mx_active_space";
export const HOME_SPACE = Symbol("home-space");
export const SUGGESTED_ROOMS = Symbol("suggested-rooms");
export const UPDATE_TOP_LEVEL_SPACES = Symbol("top-level-spaces");
export const UPDATE_INVITED_SPACES = Symbol("invited-spaces");
export const UPDATE_SELECTED_SPACE = Symbol("selected-space");
// Space Room ID/HOME_SPACE will be emitted when a Space's children change
// Space Room ID will be emitted when a Space's children change
const MAX_SUGGESTED_ROOMS = 20;
const getSpaceContextKey = (space?: Room) => `mx_space_context_${space?.roomId || "home_space"}`;
const getSpaceContextKey = (space?: Room) => `mx_space_context_${space?.roomId || "ALL_ROOMS"}`;
const partitionSpacesAndRooms = (arr: Room[]): [Room[], Room[]] => { // [spaces, rooms]
return arr.reduce((result, room: Room) => {
@ -61,15 +56,18 @@ const partitionSpacesAndRooms = (arr: Room[]): [Room[], Room[]] => { // [spaces,
}, [[], []]);
};
const getOrder = (ev: MatrixEvent): string | null => {
const content = ev.getContent();
if (typeof content.order === "string" && Array.from(content.order).every((c: string) => {
// For sorting space children using a validated `order`, `m.room.create`'s `origin_server_ts`, `room_id`
export const getOrder = (order: string, creationTs: number, roomId: string): Array<Many<ListIteratee<any>>> => {
let validatedOrder: string = null;
if (typeof order === "string" && Array.from(order).every((c: string) => {
const charCode = c.charCodeAt(0);
return charCode >= 0x20 && charCode <= 0x7F;
})) {
return content.order;
validatedOrder = order;
}
return null;
return [validatedOrder, creationTs, roomId];
}
const getRoomFn: FetchRoomFn = (room: Room) => {
@ -83,15 +81,13 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
// The spaces representing the roots of the various tree-like hierarchies
private rootSpaces: Room[] = [];
// The list of rooms not present in any currently joined spaces
private orphanedRooms = new Set<string>();
// Map from room ID to set of spaces which list it as a child
private parentMap = new EnhancedMap<string, Set<string>>();
// Map from space key to SpaceNotificationState instance representing that space
private notificationStateMap = new Map<SpaceKey, SpaceNotificationState>();
// Map from spaceId to SpaceNotificationState instance representing that space
private notificationStateMap = new Map<string, SpaceNotificationState>();
// Map from space key to Set of room IDs that should be shown as part of that space's filter
private spaceFilteredRooms = new Map<string | symbol, Set<string>>();
// The space currently selected in the Space Panel - if null then `Home` is selected
private spaceFilteredRooms = new Map<string, Set<string>>();
// The space currently selected in the Space Panel - if null then All Rooms is selected
private _activeSpace?: Room = null;
private _suggestedRooms: ISpaceSummaryRoom[] = [];
private _invitedSpaces = new Set<Room>();
@ -112,6 +108,13 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
return this._suggestedRooms;
}
/**
* Sets the active space, updates room list filters,
* optionally switches the user's room back to where they were when they last viewed that space.
* @param space which space to switch to.
* @param contextSwitch whether to switch the user's context,
* should not be done when the space switch is done implicitly due to another event like switching room.
*/
public async setActiveSpace(space: Room | null, contextSwitch = true) {
if (space === this.activeSpace || (space && !space?.isSpaceRoom())) return;
@ -193,9 +196,16 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
private getChildren(spaceId: string): Room[] {
const room = this.matrixClient?.getRoom(spaceId);
const childEvents = room?.currentState.getStateEvents(EventType.SpaceChild).filter(ev => ev.getContent()?.via);
return sortBy(childEvents, getOrder)
.map(ev => this.matrixClient.getRoom(ev.getStateKey()))
.filter(room => room?.getMyMembership() === "join" || room?.getMyMembership() === "invite") || [];
return sortBy(childEvents, ev => {
const roomId = ev.getStateKey();
const childRoom = this.matrixClient?.getRoom(roomId);
const createTs = childRoom?.currentState.getStateEvents(EventType.RoomCreate, "")?.getTs();
return getOrder(ev.getContent().order, createTs, roomId);
}).map(ev => {
return this.matrixClient.getRoom(ev.getStateKey());
}).filter(room => {
return room?.getMyMembership() === "join" || room?.getMyMembership() === "invite";
}) || [];
}
public getChildRooms(spaceId: string): Room[] {
@ -227,7 +237,10 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
}
public getSpaceFilteredRoomIds = (space: Room | null): Set<string> => {
return this.spaceFilteredRooms.get(space?.roomId || HOME_SPACE) || new Set();
if (!space) {
return new Set(this.matrixClient.getVisibleRooms().map(r => r.roomId));
}
return this.spaceFilteredRooms.get(space.roomId) || new Set();
};
private rebuild = throttle(() => {
@ -258,7 +271,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
});
});
const [rootSpaces, orphanedRooms] = partitionSpacesAndRooms(Array.from(unseenChildren));
const [rootSpaces] = partitionSpacesAndRooms(Array.from(unseenChildren));
// somewhat algorithm to handle full-cycles
const detachedNodes = new Set<Room>(spaces);
@ -299,13 +312,12 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
// rootSpaces.push(space);
// });
this.orphanedRooms = new Set(orphanedRooms);
this.rootSpaces = rootSpaces;
this.parentMap = backrefs;
// if the currently selected space no longer exists, remove its selection
if (this._activeSpace && detachedNodes.has(this._activeSpace)) {
this.setActiveSpace(null);
this.setActiveSpace(null, false);
}
this.onRoomsUpdate(); // TODO only do this if a change has happened
@ -320,25 +332,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
this.rebuild();
}
private showInHomeSpace = (room: Room) => {
if (room.isSpaceRoom()) return false;
return !this.parentMap.get(room.roomId)?.size // put all orphaned rooms in the Home Space
|| DMRoomMap.shared().getUserIdForRoomId(room.roomId) // put all DMs in the Home Space
|| RoomListStore.instance.getTagsForRoom(room).includes(DefaultTagID.Favourite) // show all favourites
};
// Update a given room due to its tag changing (e.g DM-ness or Fav-ness)
// This can only change whether it shows up in the HOME_SPACE or not
private onRoomUpdate = (room: Room) => {
if (this.showInHomeSpace(room)) {
this.spaceFilteredRooms.get(HOME_SPACE)?.add(room.roomId);
this.emit(HOME_SPACE);
} else if (!this.orphanedRooms.has(room.roomId)) {
this.spaceFilteredRooms.get(HOME_SPACE)?.delete(room.roomId);
this.emit(HOME_SPACE);
}
};
private onSpaceMembersChange = (ev: MatrixEvent) => {
// skip this update if we do not have a DM with this user
if (DMRoomMap.shared().getDMRoomsForUserId(ev.getStateKey()).length < 1) return;
@ -352,16 +345,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
const oldFilteredRooms = this.spaceFilteredRooms;
this.spaceFilteredRooms = new Map();
// put all room invites in the Home Space
const invites = visibleRooms.filter(r => !r.isSpaceRoom() && r.getMyMembership() === "invite");
this.spaceFilteredRooms.set(HOME_SPACE, new Set<string>(invites.map(room => room.roomId)));
visibleRooms.forEach(room => {
if (this.showInHomeSpace(room)) {
this.spaceFilteredRooms.get(HOME_SPACE).add(room.roomId);
}
});
this.rootSpaces.forEach(s => {
// traverse each space tree in DFS to build up the supersets as you go up,
// reusing results from like subtrees.
@ -406,10 +389,33 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
this.spaceFilteredRooms.forEach((roomIds, s) => {
// Update NotificationStates
this.getNotificationState(s)?.setRooms(visibleRooms.filter(room => roomIds.has(room.roomId)));
this.getNotificationState(s)?.setRooms(visibleRooms.filter(room => {
if (roomIds.has(room.roomId)) {
return !DMRoomMap.shared().getUserIdForRoomId(room.roomId)
|| RoomListStore.instance.getTagsForRoom(room).includes(DefaultTagID.Favourite);
}
return false;
}));
});
}, 100, {trailing: true, leading: true});
private switchToRelatedSpace = (roomId: string) => {
if (this.suggestedRooms.find(r => r.room_id === roomId)) return;
let parent = this.getCanonicalParent(roomId);
if (!parent) {
parent = this.rootSpaces.find(s => this.spaceFilteredRooms.get(s.roomId)?.has(roomId));
}
if (!parent) {
const parents = Array.from(this.parentMap.get(roomId) || []);
parent = parents.find(p => this.matrixClient.getRoom(p));
}
// don't trigger a context switch when we are switching a space to match the chosen room
this.setActiveSpace(parent || null, false);
};
private onRoom = (room: Room, newMembership?: string, oldMembership?: string) => {
const membership = newMembership || room.getMyMembership();
@ -424,6 +430,11 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
if (numSuggestedRooms !== this._suggestedRooms.length) {
this.emit(SUGGESTED_ROOMS, this._suggestedRooms);
}
// if the room currently being viewed was just joined then switch to its related space
if (newMembership === "join" && room.roomId === RoomViewStore.getRoomId()) {
this.switchToRelatedSpace(room.roomId);
}
}
return;
}
@ -442,7 +453,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
if (membership === "join" && room.roomId === RoomViewStore.getRoomId()) {
// if the user was looking at the space and then joined: select that space
this.setActiveSpace(room);
this.setActiveSpace(room, false);
}
};
@ -463,8 +474,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
// TODO confirm this after implementing parenting behaviour
if (room.isSpaceRoom()) {
this.onSpaceUpdate();
} else {
this.onRoomUpdate(room);
}
this.emit(room.roomId);
break;
@ -477,38 +486,8 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
}
};
private onRoomAccountData = (ev: MatrixEvent, room: Room, lastEvent?: MatrixEvent) => {
if (ev.getType() === EventType.Tag && !room.isSpaceRoom()) {
// If the room was in favourites and now isn't or the opposite then update its position in the trees
const oldTags = lastEvent?.getContent()?.tags || {};
const newTags = ev.getContent()?.tags || {};
if (!!oldTags[DefaultTagID.Favourite] !== !!newTags[DefaultTagID.Favourite]) {
this.onRoomUpdate(room);
}
}
}
private onAccountData = (ev: MatrixEvent, lastEvent: MatrixEvent) => {
if (ev.getType() === EventType.Direct) {
const lastContent = lastEvent.getContent();
const content = ev.getContent();
const diff = objectDiff<Record<string, string[]>>(lastContent, content);
// filter out keys which changed by reference only by checking whether the sets differ
const changed = diff.changed.filter(k => arrayHasDiff(lastContent[k], content[k]));
// DM tag changes, refresh relevant rooms
new Set([...diff.added, ...diff.removed, ...changed]).forEach(roomId => {
const room = this.matrixClient?.getRoom(roomId);
if (room) {
this.onRoomUpdate(room);
}
});
}
};
protected async reset() {
this.rootSpaces = [];
this.orphanedRooms = new Set();
this.parentMap = new EnhancedMap();
this.notificationStateMap = new Map();
this.spaceFilteredRooms = new Map();
@ -523,8 +502,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
this.matrixClient.removeListener("Room", this.onRoom);
this.matrixClient.removeListener("Room.myMembership", this.onRoom);
this.matrixClient.removeListener("RoomState.events", this.onRoomState);
this.matrixClient.removeListener("Room.accountData", this.onRoomAccountData);
this.matrixClient.removeListener("accountData", this.onAccountData);
}
await this.reset();
}
@ -534,18 +511,13 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
this.matrixClient.on("Room", this.onRoom);
this.matrixClient.on("Room.myMembership", this.onRoom);
this.matrixClient.on("RoomState.events", this.onRoomState);
this.matrixClient.on("Room.accountData", this.onRoomAccountData);
this.matrixClient.on("accountData", this.onAccountData);
await this.onSpaceUpdate(); // trigger an initial update
// restore selected state from last session if any and still valid
const lastSpaceId = window.localStorage.getItem(ACTIVE_SPACE_LS_KEY);
if (lastSpaceId) {
const space = this.rootSpaces.find(s => s.roomId === lastSpaceId);
if (space) {
this.setActiveSpace(space);
}
this.setActiveSpace(this.matrixClient.getRoom(lastSpaceId));
}
}
@ -553,27 +525,18 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
if (!SettingsStore.getValue("feature_spaces")) return;
switch (payload.action) {
case "view_room": {
const room = this.matrixClient?.getRoom(payload.room_id);
// Don't auto-switch rooms when reacting to a context-switch
// as this is not helpful and can create loops of rooms/space switching
if (!room || payload.context_switch) break;
if (payload.context_switch) break;
if (room.isSpaceRoom()) {
const roomId = payload.room_id;
const room = this.matrixClient?.getRoom(roomId);
if (room?.isSpaceRoom()) {
// Don't context switch when navigating to the space room
// as it will cause you to end up in the wrong room
this.setActiveSpace(room, false);
} else if (!this.getSpaceFilteredRoomIds(this.activeSpace).has(room.roomId)) {
let parent = this.getCanonicalParent(room.roomId);
if (!parent) {
parent = this.rootSpaces.find(s => this.spaceFilteredRooms.get(s.roomId)?.has(room.roomId));
}
if (!parent) {
const parents = Array.from(this.parentMap.get(room.roomId) || []);
parent = parents.find(p => this.matrixClient.getRoom(p));
}
// don't trigger a context switch when we are switching a space to match the chosen room
this.setActiveSpace(parent || null, false);
} else if (this.activeSpace && !this.getSpaceFilteredRoomIds(this.activeSpace).has(roomId)) {
this.switchToRelatedSpace(roomId);
}
// Persist last viewed room from a space
@ -584,13 +547,13 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
}
case "after_leave_room":
if (this._activeSpace && payload.room_id === this._activeSpace.roomId) {
this.setActiveSpace(null);
this.setActiveSpace(null, false);
}
break;
}
}
public getNotificationState(key: SpaceKey): SpaceNotificationState {
public getNotificationState(key: string): SpaceNotificationState {
if (this.notificationStateMap.has(key)) {
return this.notificationStateMap.get(key);
}

View file

@ -0,0 +1,48 @@
/*
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
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.
*/
const getSpaceCollapsedKey = (roomId: string, parents: Set<string>): string => {
const separator = "/";
let path = "";
if (parents) {
for (const entry of parents.entries()) {
path += entry + separator;
}
}
return `mx_space_collapsed_${path + roomId}`;
};
export default class SpaceTreeLevelLayoutStore {
private static internalInstance: SpaceTreeLevelLayoutStore;
public static get instance(): SpaceTreeLevelLayoutStore {
if (!SpaceTreeLevelLayoutStore.internalInstance) {
SpaceTreeLevelLayoutStore.internalInstance = new SpaceTreeLevelLayoutStore();
}
return SpaceTreeLevelLayoutStore.internalInstance;
}
public setSpaceCollapsedState(roomId: string, parents: Set<string>, collapsed: boolean) {
// XXX: localStorage doesn't allow booleans
localStorage.setItem(getSpaceCollapsedKey(roomId, parents), collapsed.toString());
}
public getSpaceCollapsedState(roomId: string, parents: Set<string>, fallback: boolean): boolean {
const collapsedLocalStorage = localStorage.getItem(getSpaceCollapsedKey(roomId, parents));
// XXX: localStorage doesn't allow booleans
return collapsedLocalStorage ? collapsedLocalStorage === "true" : fallback;
}
}

View file

@ -426,6 +426,10 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
return; // don't do anything on rooms that aren't visible
}
if (cause === RoomUpdateCause.NewRoom && !this.prefilterConditions.every(c => c.isVisible(room))) {
return; // don't do anything on new rooms which ought not to be shown
}
const shouldUpdate = await this.algorithm.handleRoomUpdate(room, cause);
if (shouldUpdate) {
if (SettingsStore.getValue("advancedRoomListLogging")) {
@ -664,7 +668,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
* and thus might not cause an update to the store immediately.
* @param {IFilterCondition} filter The filter condition to add.
*/
public addFilter(filter: IFilterCondition): void {
public async addFilter(filter: IFilterCondition): Promise<void> {
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
console.log("Adding filter condition:", filter);
@ -676,13 +680,15 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
promise = this.recalculatePrefiltering();
} else {
this.filterConditions.push(filter);
// Runtime filters with spaces disable prefiltering for the search all spaces feature
if (SettingsStore.getValue("feature_spaces")) {
// this has to be awaited so that `setKnownRooms` is called in time for the `addFilterCondition` below
// this way the runtime filters are only evaluated on one dataset and not both.
await this.recalculatePrefiltering();
}
if (this.algorithm) {
this.algorithm.addFilterCondition(filter);
}
// Runtime filters with spaces disable prefiltering for the search all spaces effect
if (SettingsStore.getValue("feature_spaces")) {
promise = this.recalculatePrefiltering();
}
}
promise.then(() => this.updateFn.trigger());
}
@ -706,10 +712,10 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
if (this.algorithm) {
this.algorithm.removeFilterCondition(filter);
// Runtime filters with spaces disable prefiltering for the search all spaces effect
if (SettingsStore.getValue("feature_spaces")) {
promise = this.recalculatePrefiltering();
}
}
// Runtime filters with spaces disable prefiltering for the search all spaces feature
if (SettingsStore.getValue("feature_spaces")) {
promise = this.recalculatePrefiltering();
}
}
idx = this.prefilterConditions.indexOf(filter);

View file

@ -24,26 +24,34 @@ import SpaceStore, { UPDATE_SELECTED_SPACE } from "../SpaceStore";
* Watches for changes in spaces to manage the filter on the provided RoomListStore
*/
export class SpaceWatcher {
private filter = new SpaceFilterCondition();
private filter: SpaceFilterCondition;
private activeSpace: Room = SpaceStore.instance.activeSpace;
constructor(private store: RoomListStoreClass) {
this.updateFilter(); // get the filter into a consistent state
store.addFilter(this.filter);
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated);
}
private onSelectedSpaceUpdated = (activeSpace: Room) => {
private onSelectedSpaceUpdated = (activeSpace?: Room) => {
this.activeSpace = activeSpace;
this.updateFilter();
if (this.filter) {
if (activeSpace) {
this.updateFilter();
} else {
this.store.removeFilter(this.filter);
this.filter = null;
}
} else if (activeSpace) {
this.filter = new SpaceFilterCondition();
this.updateFilter();
this.store.addFilter(this.filter);
}
};
private updateFilter = () => {
if (this.activeSpace) {
SpaceStore.instance.traverseSpace(this.activeSpace.roomId, roomId => {
this.store.matrixClient?.getRoom(roomId)?.loadMembersIfNeeded();
});
}
SpaceStore.instance.traverseSpace(this.activeSpace.roomId, roomId => {
this.store.matrixClient?.getRoom(roomId)?.loadMembersIfNeeded();
});
this.filter.updateSpace(this.activeSpace);
};
}

View file

@ -34,6 +34,7 @@ import { OrderingAlgorithm } from "./list-ordering/OrderingAlgorithm";
import { getListAlgorithmInstance } from "./list-ordering";
import SettingsStore from "../../../settings/SettingsStore";
import { VisibilityProvider } from "../filters/VisibilityProvider";
import { MultiLock } from "../../../utils/MultiLock";
/**
* Fired when the Algorithm has determined a list has been updated.
@ -77,6 +78,7 @@ export class Algorithm extends EventEmitter {
} = {};
private allowedByFilter: Map<IFilterCondition, Room[]> = new Map<IFilterCondition, Room[]>();
private allowedRoomsByFilters: Set<Room> = new Set<Room>();
private handlerLock = new MultiLock();
/**
* Set to true to suspend emissions of algorithm updates.
@ -199,8 +201,10 @@ export class Algorithm extends EventEmitter {
}
private async doUpdateStickyRoom(val: Room) {
// no-op sticky rooms for spaces - they're effectively virtual rooms
if (val?.isSpaceRoom() && val.getMyMembership() !== "invite") val = null;
if (SettingsStore.getValue("feature_spaces") && val?.isSpaceRoom() && val.getMyMembership() !== "invite") {
// no-op sticky rooms for spaces - they're effectively virtual rooms
val = null;
}
// Note throughout: We need async so we can wait for handleRoomUpdate() to do its thing,
// otherwise we risk duplicating rooms.
@ -577,9 +581,8 @@ export class Algorithm extends EventEmitter {
await this.generateFreshTags(newTags);
this.cachedRooms = newTags;
this.cachedRooms = newTags; // this recalculates the filtered rooms for us
this.updateTagsFromCache();
this.recalculateFilteredRooms();
// Now that we've finished generation, we need to update the sticky room to what
// it was. It's entirely possible that it changed lists though, so if it did then
@ -678,191 +681,204 @@ export class Algorithm extends EventEmitter {
public async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean> {
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
console.log(`Handle room update for ${room.roomId} called with cause ${cause}`);
console.log(`Acquiring lock for ${room.roomId} with cause ${cause}`);
}
if (!this.algorithms) throw new Error("Not ready: no algorithms to determine tags from");
// Note: check the isSticky against the room ID just in case the reference is wrong
const isSticky = this._stickyRoom && this._stickyRoom.room && this._stickyRoom.room.roomId === room.roomId;
if (cause === RoomUpdateCause.NewRoom) {
const isForLastSticky = this._lastStickyRoom && this._lastStickyRoom.room === room;
const roomTags = this.roomIdsToTags[room.roomId];
const hasTags = roomTags && roomTags.length > 0;
// Don't change the cause if the last sticky room is being re-added. If we fail to
// pass the cause through as NewRoom, we'll fail to lie to the algorithm and thus
// lose the room.
if (hasTags && !isForLastSticky) {
console.warn(`${room.roomId} is reportedly new but is already known - assuming TagChange instead`);
cause = RoomUpdateCause.PossibleTagChange;
const release = await this.handlerLock.acquire(room.roomId);
try {
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
console.log(`Handle room update for ${room.roomId} called with cause ${cause}`);
}
if (!this.algorithms) throw new Error("Not ready: no algorithms to determine tags from");
// Check to see if the room is known first
let knownRoomRef = this.rooms.includes(room);
if (hasTags && !knownRoomRef) {
console.warn(`${room.roomId} might be a reference change - attempting to update reference`);
this.rooms = this.rooms.map(r => r.roomId === room.roomId ? room : r);
knownRoomRef = this.rooms.includes(room);
if (!knownRoomRef) {
console.warn(`${room.roomId} is still not referenced. It may be sticky.`);
// Note: check the isSticky against the room ID just in case the reference is wrong
const isSticky = this._stickyRoom && this._stickyRoom.room && this._stickyRoom.room.roomId === room.roomId;
if (cause === RoomUpdateCause.NewRoom) {
const isForLastSticky = this._lastStickyRoom && this._lastStickyRoom.room === room;
const roomTags = this.roomIdsToTags[room.roomId];
const hasTags = roomTags && roomTags.length > 0;
// Don't change the cause if the last sticky room is being re-added. If we fail to
// pass the cause through as NewRoom, we'll fail to lie to the algorithm and thus
// lose the room.
if (hasTags && !isForLastSticky) {
console.warn(`${room.roomId} is reportedly new but is already known - assuming TagChange instead`);
cause = RoomUpdateCause.PossibleTagChange;
}
// Check to see if the room is known first
let knownRoomRef = this.rooms.includes(room);
if (hasTags && !knownRoomRef) {
console.warn(`${room.roomId} might be a reference change - attempting to update reference`);
this.rooms = this.rooms.map(r => r.roomId === room.roomId ? room : r);
knownRoomRef = this.rooms.includes(room);
if (!knownRoomRef) {
console.warn(`${room.roomId} is still not referenced. It may be sticky.`);
}
}
// If we have tags for a room and don't have the room referenced, something went horribly
// wrong - the reference should have been updated above.
if (hasTags && !knownRoomRef && !isSticky) {
throw new Error(`${room.roomId} is missing from room array but is known`);
}
// Like above, update the reference to the sticky room if we need to
if (hasTags && isSticky) {
// Go directly in and set the sticky room's new reference, being careful not
// to trigger a sticky room update ourselves.
this._stickyRoom.room = room;
}
// If after all that we're still a NewRoom update, add the room if applicable.
// We don't do this for the sticky room (because it causes duplication issues)
// or if we know about the reference (as it should be replaced).
if (cause === RoomUpdateCause.NewRoom && !isSticky && !knownRoomRef) {
this.rooms.push(room);
}
}
// If we have tags for a room and don't have the room referenced, something went horribly
// wrong - the reference should have been updated above.
if (hasTags && !knownRoomRef && !isSticky) {
throw new Error(`${room.roomId} is missing from room array but is known - trying to find duplicate`);
}
let didTagChange = false;
if (cause === RoomUpdateCause.PossibleTagChange) {
const oldTags = this.roomIdsToTags[room.roomId] || [];
const newTags = this.getTagsForRoom(room);
const diff = arrayDiff(oldTags, newTags);
if (diff.removed.length > 0 || diff.added.length > 0) {
for (const rmTag of diff.removed) {
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
console.log(`Removing ${room.roomId} from ${rmTag}`);
}
const algorithm: OrderingAlgorithm = this.algorithms[rmTag];
if (!algorithm) throw new Error(`No algorithm for ${rmTag}`);
await algorithm.handleRoomUpdate(room, RoomUpdateCause.RoomRemoved);
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
}
for (const addTag of diff.added) {
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
console.log(`Adding ${room.roomId} to ${addTag}`);
}
const algorithm: OrderingAlgorithm = this.algorithms[addTag];
if (!algorithm) throw new Error(`No algorithm for ${addTag}`);
await algorithm.handleRoomUpdate(room, RoomUpdateCause.NewRoom);
this._cachedRooms[addTag] = algorithm.orderedRooms;
}
// Like above, update the reference to the sticky room if we need to
if (hasTags && isSticky) {
// Go directly in and set the sticky room's new reference, being careful not
// to trigger a sticky room update ourselves.
this._stickyRoom.room = room;
}
// Update the tag map so we don't regen it in a moment
this.roomIdsToTags[room.roomId] = newTags;
// If after all that we're still a NewRoom update, add the room if applicable.
// We don't do this for the sticky room (because it causes duplication issues)
// or if we know about the reference (as it should be replaced).
if (cause === RoomUpdateCause.NewRoom && !isSticky && !knownRoomRef) {
this.rooms.push(room);
}
}
let didTagChange = false;
if (cause === RoomUpdateCause.PossibleTagChange) {
const oldTags = this.roomIdsToTags[room.roomId] || [];
const newTags = this.getTagsForRoom(room);
const diff = arrayDiff(oldTags, newTags);
if (diff.removed.length > 0 || diff.added.length > 0) {
for (const rmTag of diff.removed) {
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
console.log(`Removing ${room.roomId} from ${rmTag}`);
console.log(`Changing update cause for ${room.roomId} to Timeline to sort rooms`);
}
const algorithm: OrderingAlgorithm = this.algorithms[rmTag];
if (!algorithm) throw new Error(`No algorithm for ${rmTag}`);
await algorithm.handleRoomUpdate(room, RoomUpdateCause.RoomRemoved);
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
}
for (const addTag of diff.added) {
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
console.log(`Adding ${room.roomId} to ${addTag}`);
}
const algorithm: OrderingAlgorithm = this.algorithms[addTag];
if (!algorithm) throw new Error(`No algorithm for ${addTag}`);
await algorithm.handleRoomUpdate(room, RoomUpdateCause.NewRoom);
this._cachedRooms[addTag] = algorithm.orderedRooms;
}
// Update the tag map so we don't regen it in a moment
this.roomIdsToTags[room.roomId] = newTags;
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
console.log(`Changing update cause for ${room.roomId} to Timeline to sort rooms`);
}
cause = RoomUpdateCause.Timeline;
didTagChange = true;
} else {
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
console.log(`Received no-op update for ${room.roomId} - changing to Timeline update`);
}
cause = RoomUpdateCause.Timeline;
}
if (didTagChange && isSticky) {
// Manually update the tag for the sticky room without triggering a sticky room
// update. The update will be handled implicitly by the sticky room handling and
// requires no changes on our part, if we're in the middle of a sticky room change.
if (this._lastStickyRoom) {
this._stickyRoom = {
room,
tag: this.roomIdsToTags[room.roomId][0],
position: 0, // right at the top as it changed tags
};
cause = RoomUpdateCause.Timeline;
didTagChange = true;
} else {
// We have to clear the lock as the sticky room change will trigger updates.
await this.setStickyRoom(room);
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
console.log(`Received no-op update for ${room.roomId} - changing to Timeline update`);
}
cause = RoomUpdateCause.Timeline;
}
if (didTagChange && isSticky) {
// Manually update the tag for the sticky room without triggering a sticky room
// update. The update will be handled implicitly by the sticky room handling and
// requires no changes on our part, if we're in the middle of a sticky room change.
if (this._lastStickyRoom) {
this._stickyRoom = {
room,
tag: this.roomIdsToTags[room.roomId][0],
position: 0, // right at the top as it changed tags
};
} else {
// We have to clear the lock as the sticky room change will trigger updates.
await this.setStickyRoom(room);
}
}
}
}
// If the update is for a room change which might be the sticky room, prevent it. We
// need to make sure that the causes (NewRoom and RoomRemoved) are still triggered though
// as the sticky room relies on this.
if (cause !== RoomUpdateCause.NewRoom && cause !== RoomUpdateCause.RoomRemoved) {
if (this.stickyRoom === room) {
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
console.warn(`[RoomListDebug] Received ${cause} update for sticky room ${room.roomId} - ignoring`);
// If the update is for a room change which might be the sticky room, prevent it. We
// need to make sure that the causes (NewRoom and RoomRemoved) are still triggered though
// as the sticky room relies on this.
if (cause !== RoomUpdateCause.NewRoom && cause !== RoomUpdateCause.RoomRemoved) {
if (this.stickyRoom === room) {
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
console.warn(
`[RoomListDebug] Received ${cause} update for sticky room ${room.roomId} - ignoring`,
);
}
return false;
}
return false;
}
}
if (!this.roomIdsToTags[room.roomId]) {
if (CAUSES_REQUIRING_ROOM.includes(cause)) {
if (!this.roomIdsToTags[room.roomId]) {
if (CAUSES_REQUIRING_ROOM.includes(cause)) {
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
console.warn(`Skipping tag update for ${room.roomId} because we don't know about the room`);
}
return false;
}
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
console.warn(`Skipping tag update for ${room.roomId} because we don't know about the room`);
console.log(`[RoomListDebug] Updating tags for room ${room.roomId} (${room.name})`);
}
// Get the tags for the room and populate the cache
const roomTags = this.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.
if (!roomTags.length) throw new Error(`Tags cannot be determined for ${room.roomId}`);
this.roomIdsToTags[room.roomId] = roomTags;
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
console.log(`[RoomListDebug] Updated tags for ${room.roomId}:`, roomTags);
}
return false;
}
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
console.log(`[RoomListDebug] Updating tags for room ${room.roomId} (${room.name})`);
console.log(`[RoomListDebug] Reached algorithmic handling for ${room.roomId} and cause ${cause}`);
}
// Get the tags for the room and populate the cache
const roomTags = this.getTagsForRoom(room).filter(t => !isNullOrUndefined(this.cachedRooms[t]));
const tags = this.roomIdsToTags[room.roomId];
if (!tags) {
console.warn(`No tags known for "${room.name}" (${room.roomId})`);
return false;
}
// "This should never happen" condition - we specify DefaultTagID.Untagged in getTagsForRoom(),
// which means we should *always* have a tag to go off of.
if (!roomTags.length) throw new Error(`Tags cannot be determined for ${room.roomId}`);
let changed = didTagChange;
for (const tag of tags) {
const algorithm: OrderingAlgorithm = this.algorithms[tag];
if (!algorithm) throw new Error(`No algorithm for ${tag}`);
this.roomIdsToTags[room.roomId] = roomTags;
await algorithm.handleRoomUpdate(room, cause);
this._cachedRooms[tag] = algorithm.orderedRooms;
// 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
changed = true;
}
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
console.log(`[RoomListDebug] Updated tags for ${room.roomId}:`, roomTags);
console.log(
`[RoomListDebug] Finished handling ${room.roomId} with cause ${cause} (changed=${changed})`,
);
}
return changed;
} finally {
release();
}
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
console.log(`[RoomListDebug] Reached algorithmic handling for ${room.roomId} and cause ${cause}`);
}
const tags = this.roomIdsToTags[room.roomId];
if (!tags) {
console.warn(`No tags known for "${room.name}" (${room.roomId})`);
return false;
}
let changed = didTagChange;
for (const tag of tags) {
const algorithm: OrderingAlgorithm = this.algorithms[tag];
if (!algorithm) throw new Error(`No algorithm for ${tag}`);
await algorithm.handleRoomUpdate(room, cause);
this._cachedRooms[tag] = algorithm.orderedRooms;
// 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
changed = true;
}
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
console.log(`[RoomListDebug] Finished handling ${room.roomId} with cause ${cause} (changed=${changed})`);
}
return changed;
}
}

View file

@ -21,79 +21,83 @@ import { MatrixClientPeg } from "../../../../MatrixClientPeg";
import * as Unread from "../../../../Unread";
import { EffectiveMembership, getEffectiveMembership } from "../../../../utils/membership";
export const sortRooms = (rooms: Room[]): Room[] => {
// We cache the timestamp lookup to avoid iterating forever on the timeline
// of events. This cache only survives a single sort though.
// We wouldn't need this if `.sort()` didn't constantly try and compare all
// of the rooms to each other.
// TODO: We could probably improve the sorting algorithm here by finding changes.
// See https://github.com/vector-im/element-web/issues/14459
// For example, if we spent a little bit of time to determine which elements have
// actually changed (probably needs to be done higher up?) then we could do an
// insertion sort or similar on the limited set of changes.
// TODO: Don't assume we're using the same client as the peg
// See https://github.com/vector-im/element-web/issues/14458
let myUserId = '';
if (MatrixClientPeg.get()) {
myUserId = MatrixClientPeg.get().getUserId();
}
const tsCache: { [roomId: string]: number } = {};
const getLastTs = (r: Room) => {
if (tsCache[r.roomId]) {
return tsCache[r.roomId];
}
const ts = (() => {
// Apparently we can have rooms without timelines, at least under testing
// environments. Just return MAX_INT when this happens.
if (!r || !r.timeline) {
return Number.MAX_SAFE_INTEGER;
}
// If the room hasn't been joined yet, it probably won't have a timeline to
// parse. We'll still fall back to the timeline if this fails, but chances
// are we'll at least have our own membership event to go off of.
const effectiveMembership = getEffectiveMembership(r.getMyMembership());
if (effectiveMembership !== EffectiveMembership.Join) {
const membershipEvent = r.currentState.getStateEvents("m.room.member", myUserId);
if (membershipEvent && !Array.isArray(membershipEvent)) {
return membershipEvent.getTs();
}
}
for (let i = r.timeline.length - 1; i >= 0; --i) {
const ev = r.timeline[i];
if (!ev.getTs()) continue; // skip events that don't have timestamps (tests only?)
if (ev.getSender() === myUserId || Unread.eventTriggersUnreadCount(ev)) {
return ev.getTs();
}
}
// we might only have events that don't trigger the unread indicator,
// in which case use the oldest event even if normally it wouldn't count.
// This is better than just assuming the last event was forever ago.
if (r.timeline.length && r.timeline[0].getTs()) {
return r.timeline[0].getTs();
} else {
return Number.MAX_SAFE_INTEGER;
}
})();
tsCache[r.roomId] = ts;
return ts;
};
return rooms.sort((a, b) => {
return getLastTs(b) - getLastTs(a);
});
};
/**
* Sorts rooms according to the last event's timestamp in each room that seems
* useful to the user.
*/
export class RecentAlgorithm implements IAlgorithm {
public async sortRooms(rooms: Room[], tagId: TagID): Promise<Room[]> {
// We cache the timestamp lookup to avoid iterating forever on the timeline
// of events. This cache only survives a single sort though.
// We wouldn't need this if `.sort()` didn't constantly try and compare all
// of the rooms to each other.
// TODO: We could probably improve the sorting algorithm here by finding changes.
// See https://github.com/vector-im/element-web/issues/14459
// For example, if we spent a little bit of time to determine which elements have
// actually changed (probably needs to be done higher up?) then we could do an
// insertion sort or similar on the limited set of changes.
// TODO: Don't assume we're using the same client as the peg
// See https://github.com/vector-im/element-web/issues/14458
let myUserId = '';
if (MatrixClientPeg.get()) {
myUserId = MatrixClientPeg.get().getUserId();
}
const tsCache: { [roomId: string]: number } = {};
const getLastTs = (r: Room) => {
if (tsCache[r.roomId]) {
return tsCache[r.roomId];
}
const ts = (() => {
// Apparently we can have rooms without timelines, at least under testing
// environments. Just return MAX_INT when this happens.
if (!r || !r.timeline) {
return Number.MAX_SAFE_INTEGER;
}
// If the room hasn't been joined yet, it probably won't have a timeline to
// parse. We'll still fall back to the timeline if this fails, but chances
// are we'll at least have our own membership event to go off of.
const effectiveMembership = getEffectiveMembership(r.getMyMembership());
if (effectiveMembership !== EffectiveMembership.Join) {
const membershipEvent = r.currentState.getStateEvents("m.room.member", myUserId);
if (membershipEvent && !Array.isArray(membershipEvent)) {
return membershipEvent.getTs();
}
}
for (let i = r.timeline.length - 1; i >= 0; --i) {
const ev = r.timeline[i];
if (!ev.getTs()) continue; // skip events that don't have timestamps (tests only?)
if (ev.getSender() === myUserId || Unread.eventTriggersUnreadCount(ev)) {
return ev.getTs();
}
}
// we might only have events that don't trigger the unread indicator,
// in which case use the oldest event even if normally it wouldn't count.
// This is better than just assuming the last event was forever ago.
if (r.timeline.length && r.timeline[0].getTs()) {
return r.timeline[0].getTs();
} else {
return Number.MAX_SAFE_INTEGER;
}
})();
tsCache[r.roomId] = ts;
return ts;
};
return rooms.sort((a, b) => {
return getLastTs(b) - getLastTs(a);
});
return sortRooms(rooms);
}
}

View file

@ -19,7 +19,7 @@ import { Room } from "matrix-js-sdk/src/models/room";
import { FILTER_CHANGED, FilterKind, IFilterCondition } from "./IFilterCondition";
import { IDestroyable } from "../../../utils/IDestroyable";
import SpaceStore, {HOME_SPACE} from "../../SpaceStore";
import SpaceStore from "../../SpaceStore";
import { setHasDiff } from "../../../utils/sets";
/**
@ -55,10 +55,12 @@ export class SpaceFilterCondition extends EventEmitter implements IFilterConditi
}
};
private getSpaceEventKey = (space: Room | null) => space ? space.roomId : HOME_SPACE;
private getSpaceEventKey = (space: Room) => space.roomId;
public updateSpace(space: Room) {
SpaceStore.instance.off(this.getSpaceEventKey(this.space), this.onStoreUpdate);
if (this.space) {
SpaceStore.instance.off(this.getSpaceEventKey(this.space), this.onStoreUpdate);
}
SpaceStore.instance.on(this.getSpaceEventKey(this.space = space), this.onStoreUpdate);
this.onStoreUpdate(); // initial update from the change to the space
}

View file

@ -50,7 +50,7 @@ export class VisibilityProvider {
}
// hide space rooms as they'll be shown in the SpacePanel
if (room.isSpaceRoom() && SettingsStore.getValue("feature_spaces")) {
if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) {
return false;
}