Space preferences for whether or not you see DMs in a Space (#7250)

This commit is contained in:
Michael Telatynski 2021-12-17 09:26:32 +00:00 committed by GitHub
parent 5ee356daaa
commit 39c4b78371
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 911 additions and 350 deletions

View file

@ -17,7 +17,7 @@ limitations under the License.
import { RoomListStoreClass } from "./RoomListStore";
import { SpaceFilterCondition } from "./filters/SpaceFilterCondition";
import SpaceStore from "../spaces/SpaceStore";
import { MetaSpace, SpaceKey, UPDATE_HOME_BEHAVIOUR, UPDATE_SELECTED_SPACE } from "../spaces";
import { isMetaSpace, MetaSpace, SpaceKey, UPDATE_HOME_BEHAVIOUR, UPDATE_SELECTED_SPACE } from "../spaces";
/**
* Watches for changes in spaces to manage the filter on the provided RoomListStore
@ -66,7 +66,7 @@ export class SpaceWatcher {
};
private updateFilter = () => {
if (this.activeSpace[0] === "!") {
if (!isMetaSpace(this.activeSpace)) {
SpaceStore.instance.traverseSpace(this.activeSpace, roomId => {
this.store.matrixClient?.getRoom(roomId)?.loadMembersIfNeeded();
});

View file

@ -22,6 +22,7 @@ import { IDestroyable } from "../../../utils/IDestroyable";
import SpaceStore from "../../spaces/SpaceStore";
import { MetaSpace, SpaceKey } from "../../spaces";
import { setHasDiff } from "../../../utils/sets";
import SettingsStore from "../../../settings/SettingsStore";
/**
* A filter condition for the room list which reveals rooms which
@ -31,6 +32,8 @@ import { setHasDiff } from "../../../utils/sets";
*/
export class SpaceFilterCondition extends EventEmitter implements IFilterCondition, IDestroyable {
private roomIds = new Set<string>();
private userIds = new Set<string>();
private showPeopleInSpace = true;
private space: SpaceKey = MetaSpace.Home;
public get kind(): FilterKind {
@ -38,7 +41,7 @@ export class SpaceFilterCondition extends EventEmitter implements IFilterConditi
}
public isVisible(room: Room): boolean {
return this.roomIds.has(room.roomId);
return SpaceStore.instance.isRoomInSpace(this.space, room.roomId);
}
private onStoreUpdate = async (): Promise<void> => {
@ -46,7 +49,18 @@ export class SpaceFilterCondition extends EventEmitter implements IFilterConditi
// clone the set as it may be mutated by the space store internally
this.roomIds = new Set(SpaceStore.instance.getSpaceFilteredRoomIds(this.space));
if (setHasDiff(beforeRoomIds, this.roomIds)) {
const beforeUserIds = this.userIds;
// clone the set as it may be mutated by the space store internally
this.userIds = new Set(SpaceStore.instance.getSpaceFilteredUserIds(this.space));
const beforeShowPeopleInSpace = this.showPeopleInSpace;
this.showPeopleInSpace = this.space[0] !== "!" ||
SettingsStore.getValue("Spaces.showPeopleInSpace", this.space);
if (beforeShowPeopleInSpace !== this.showPeopleInSpace ||
setHasDiff(beforeRoomIds, this.roomIds) ||
setHasDiff(beforeUserIds, this.userIds)
) {
this.emit(FILTER_CHANGED);
// XXX: Room List Store has a bug where updates to the pre-filter during a local echo of a
// tags transition seem to be ignored, so refire in the next tick to work around it

View file

@ -20,6 +20,7 @@ import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { IRoomCapability } from "matrix-js-sdk/src/client";
import { logger } from "matrix-js-sdk/src/logger";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import defaultDispatcher from "../../dispatcher/dispatcher";
@ -32,15 +33,15 @@ import { SpaceNotificationState } from "../notifications/SpaceNotificationState"
import { RoomNotificationStateStore } from "../notifications/RoomNotificationStateStore";
import { DefaultTagID } from "../room-list/models";
import { EnhancedMap, mapDiff } from "../../utils/maps";
import { setHasDiff } from "../../utils/sets";
import { setDiff, setHasDiff } from "../../utils/sets";
import RoomViewStore from "../RoomViewStore";
import { Action } from "../../dispatcher/actions";
import { arrayHasDiff, arrayHasOrderChange } from "../../utils/arrays";
import { objectDiff } from "../../utils/objects";
import { reorderLexicographically } from "../../utils/stringOrderField";
import { TAG_ORDER } from "../../components/views/rooms/RoomList";
import { SettingUpdatedPayload } from "../../dispatcher/payloads/SettingUpdatedPayload";
import {
isMetaSpace,
ISuggestedRoom,
MetaSpace,
SpaceKey,
@ -51,6 +52,7 @@ import {
UPDATE_TOP_LEVEL_SPACES,
} from ".";
import { getCachedRoomIDForAlias } from "../../RoomAliasCache";
import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership";
interface IState {}
@ -93,14 +95,14 @@ const getRoomFn: FetchRoomFn = (room: Room) => {
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 SpaceKey to SpaceNotificationState instance representing that space
private notificationStateMap = new Map<SpaceKey, 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<SpaceKey, Set<string>>();
private spaceFilteredRooms = new Map<SpaceKey, Set<string>>(); // won't contain MetaSpace.People
// Map from space ID to Set of user IDs that should be shown as part of that space's filter
private spaceFilteredUsers = new Map<Room["roomId"], Set<string>>();
// The space currently selected in the Space Panel
private _activeSpace?: SpaceKey = MetaSpace.Home; // set properly by onReady
private _suggestedRooms: ISuggestedRoom[] = [];
@ -115,6 +117,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
SettingsStore.monitorSetting("Spaces.allRoomsInHome", null);
SettingsStore.monitorSetting("Spaces.enabledMetaSpaces", null);
SettingsStore.monitorSetting("Spaces.showPeopleInSpace", null);
}
public get invitedSpaces(): Room[] {
@ -134,7 +137,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
}
public get activeSpaceRoom(): Room | null {
if (this._activeSpace[0] !== "!") return null;
if (isMetaSpace(this._activeSpace)) return null;
return this.matrixClient?.getRoom(this._activeSpace);
}
@ -147,7 +150,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
}
public setActiveRoomInSpace(space: SpaceKey): void {
if (space[0] === "!" && !this.matrixClient?.getRoom(space)?.isSpaceRoom()) return;
if (!isMetaSpace(space) && !this.matrixClient?.getRoom(space)?.isSpaceRoom()) return;
if (space !== this.activeSpace) this.setActiveSpace(space);
if (space) {
@ -195,7 +198,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
if (!space || !this.matrixClient || space === this.activeSpace) return;
let cliSpace: Room;
if (space[0] === "!") {
if (!isMetaSpace(space)) {
cliSpace = this.matrixClient.getRoom(space);
if (!cliSpace?.isSpaceRoom()) return;
} else if (!this.enabledMetaSpaces.includes(space as MetaSpace)) {
@ -215,7 +218,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
// else view space home or home depending on what is being clicked on
if (cliSpace?.getMyMembership() !== "invite" &&
this.matrixClient.getRoom(roomId)?.getMyMembership() === "join" &&
this.getSpaceFilteredRoomIds(space).has(roomId)
this.isRoomInSpace(space, roomId)
) {
defaultDispatcher.dispatch({
action: "view_room",
@ -349,6 +352,36 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
return this.parentMap.get(roomId) || new Set();
}
public isRoomInSpace(space: SpaceKey, roomId: string): boolean {
if (space === MetaSpace.Home && this.allRoomsInHome) {
return true;
}
if (this.spaceFilteredRooms.get(space)?.has(roomId)) {
return true;
}
const dmPartner = DMRoomMap.shared().getUserIdForRoomId(roomId);
if (!dmPartner) {
return false;
}
// beyond this point we know this is a DM
if (space === MetaSpace.Home || space === MetaSpace.People) {
// these spaces contain all DMs
return true;
}
if (!isMetaSpace(space) &&
this.spaceFilteredUsers.get(space)?.has(dmPartner) &&
SettingsStore.getValue("Spaces.showPeopleInSpace", space)
) {
return true;
}
return false;
}
public getSpaceFilteredRoomIds = (space: SpaceKey): Set<string> => {
if (space === MetaSpace.Home && this.allRoomsInHome) {
return new Set(this.matrixClient.getVisibleRooms().map(r => r.roomId));
@ -356,162 +389,147 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
return this.spaceFilteredRooms.get(space) || new Set();
};
private rebuild = throttle(() => {
if (!this.matrixClient) return;
const [visibleSpaces, visibleRooms] = partitionSpacesAndRooms(this.matrixClient.getVisibleRooms());
const [joinedSpaces, invitedSpaces] = visibleSpaces.reduce((arr, s) => {
if (s.getMyMembership() === "join") {
arr[0].push(s);
} else if (s.getMyMembership() === "invite") {
arr[1].push(s);
}
return arr;
}, [[], []]);
// exclude invited spaces from unseenChildren as they will be forcibly shown at the top level of the treeview
const unseenChildren = new Set<Room>([...visibleRooms, ...joinedSpaces]);
const backrefs = new EnhancedMap<string, Set<string>>();
// Sort spaces by room ID to force the cycle breaking to be deterministic
const spaces = sortBy(joinedSpaces, space => space.roomId);
// TODO handle cleaning up links when a Space is removed
spaces.forEach(space => {
const children = this.getChildren(space.roomId);
children.forEach(child => {
unseenChildren.delete(child);
backrefs.getOrCreate(child.roomId, new Set()).add(space.roomId);
});
});
const [rootSpaces, orphanedRooms] = partitionSpacesAndRooms(Array.from(unseenChildren));
// somewhat algorithm to handle full-cycles
const detachedNodes = new Set<Room>(spaces);
const markTreeChildren = (rootSpace: Room, unseen: Set<Room>) => {
const stack = [rootSpace];
while (stack.length) {
const op = stack.pop();
unseen.delete(op);
this.getChildSpaces(op.roomId).forEach(space => {
if (unseen.has(space)) {
stack.push(space);
}
});
}
};
rootSpaces.forEach(rootSpace => {
markTreeChildren(rootSpace, detachedNodes);
});
// Handle spaces forming fully cyclical relationships.
// In order, assume each detachedNode is a root unless it has already
// been claimed as the child of prior detached node.
// Work from a copy of the detachedNodes set as it will be mutated as part of this operation.
Array.from(detachedNodes).forEach(detachedNode => {
if (!detachedNodes.has(detachedNode)) return;
// declare this detached node a new root, find its children, without ever looping back to it
detachedNodes.delete(detachedNode);
rootSpaces.push(detachedNode);
markTreeChildren(detachedNode, detachedNodes);
// TODO only consider a detached node a root space if it has no *parents other than the ones forming cycles
});
// TODO neither of these handle an A->B->C->A with an additional C->D
// detachedNodes.forEach(space => {
// rootSpaces.push(space);
// });
this.orphanedRooms = new Set(orphanedRooms.map(r => r.roomId));
this.rootSpaces = this.sortRootSpaces(rootSpaces);
this.parentMap = backrefs;
// if the currently selected space no longer exists, remove its selection
if (this._activeSpace[0] === "!" && detachedNodes.has(this.matrixClient.getRoom(this._activeSpace))) {
this.goToFirstSpace();
public getSpaceFilteredUserIds = (space: SpaceKey): Set<string> => {
if (space === MetaSpace.Home && this.allRoomsInHome) {
return undefined;
}
this.onRoomsUpdate(); // TODO only do this if a change has happened
this.emit(UPDATE_TOP_LEVEL_SPACES, this.spacePanelSpaces, this.enabledMetaSpaces);
// build initial state of invited spaces as we would have missed the emitted events about the room at launch
this._invitedSpaces = new Set(this.sortRootSpaces(invitedSpaces));
this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces);
}, 100, { trailing: true, leading: true });
private onSpaceUpdate = () => {
this.rebuild();
if (isMetaSpace(space)) return undefined;
return this.spaceFilteredUsers.get(space) || new Set();
};
private showInHomeSpace = (room: Room) => {
if (this.allRoomsInHome) return true;
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
};
// 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) => {
const enabledMetaSpaces = new Set(this.enabledMetaSpaces);
// TODO more metaspace stuffs
if (enabledMetaSpaces.has(MetaSpace.Home)) {
if (this.showInHomeSpace(room)) {
this.spaceFilteredRooms.get(MetaSpace.Home)?.add(room.roomId);
this.emit(MetaSpace.Home);
} else if (!this.orphanedRooms.has(room.roomId)) {
this.spaceFilteredRooms.get(MetaSpace.Home)?.delete(room.roomId);
this.emit(MetaSpace.Home);
}
}
};
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;
this.onRoomsUpdate();
};
private onRoomsUpdate = throttle(() => {
// TODO resolve some updates as deltas
const visibleRooms = this.matrixClient.getVisibleRooms();
const oldFilteredRooms = this.spaceFilteredRooms;
this.spaceFilteredRooms = new Map();
const enabledMetaSpaces = new Set(this.enabledMetaSpaces);
// populate the Home metaspace if it is enabled and is not set to all rooms
if (enabledMetaSpaces.has(MetaSpace.Home) && !this.allRoomsInHome) {
// put all room invites in the Home Space
const invites = visibleRooms.filter(r => !r.isSpaceRoom() && r.getMyMembership() === "invite");
this.spaceFilteredRooms.set(MetaSpace.Home, new Set(invites.map(r => r.roomId)));
visibleRooms.forEach(room => {
if (this.showInHomeSpace(room)) {
this.spaceFilteredRooms.get(MetaSpace.Home).add(room.roomId);
private markTreeChildren = (rootSpace: Room, unseen: Set<Room>): void => {
const stack = [rootSpace];
while (stack.length) {
const space = stack.pop();
unseen.delete(space);
this.getChildSpaces(space.roomId).forEach(space => {
if (unseen.has(space)) {
stack.push(space);
}
});
}
};
private findRootSpaces = (joinedSpaces: Room[]): Room[] => {
// exclude invited spaces from unseenChildren as they will be forcibly shown at the top level of the treeview
const unseenSpaces = new Set(joinedSpaces);
joinedSpaces.forEach(space => {
this.getChildSpaces(space.roomId).forEach(subspace => {
unseenSpaces.delete(subspace);
});
});
// Consider any spaces remaining in unseenSpaces as root,
// given they are not children of any known spaces.
// The hierarchy from these roots may not yet be exhaustive due to the possibility of full-cycles.
const rootSpaces = Array.from(unseenSpaces);
// Next we need to determine the roots of any remaining full-cycles.
// We sort spaces by room ID to force the cycle breaking to be deterministic.
const detachedNodes = new Set<Room>(sortBy(joinedSpaces, space => space.roomId));
// Mark any nodes which are children of our existing root spaces as attached.
rootSpaces.forEach(rootSpace => {
this.markTreeChildren(rootSpace, detachedNodes);
});
// Handle spaces forming fully cyclical relationships.
// In order, assume each remaining detachedNode is a root unless it has already
// been claimed as the child of prior detached node.
// Work from a copy of the detachedNodes set as it will be mutated as part of this operation.
// TODO consider sorting by number of in-refs to favour nodes with fewer parents.
Array.from(detachedNodes).forEach(detachedNode => {
if (!detachedNodes.has(detachedNode)) return; // already claimed, skip
// declare this detached node a new root, find its children, without ever looping back to it
rootSpaces.push(detachedNode); // consider this node a new root space
this.markTreeChildren(detachedNode, detachedNodes); // declare this node and its children attached
});
return rootSpaces;
};
private rebuildSpaceHierarchy = () => {
const visibleSpaces = this.matrixClient.getVisibleRooms().filter(r => r.isSpaceRoom());
const [joinedSpaces, invitedSpaces] = visibleSpaces.reduce(([joined, invited], s) => {
switch (getEffectiveMembership(s.getMyMembership())) {
case EffectiveMembership.Join:
joined.push(s);
break;
case EffectiveMembership.Invite:
invited.push(s);
break;
}
return [joined, invited];
}, [[], []] as [Room[], Room[]]);
const rootSpaces = this.findRootSpaces(joinedSpaces);
const oldRootSpaces = this.rootSpaces;
this.rootSpaces = this.sortRootSpaces(rootSpaces);
this.onRoomsUpdate();
if (arrayHasOrderChange(oldRootSpaces, this.rootSpaces)) {
this.emit(UPDATE_TOP_LEVEL_SPACES, this.spacePanelSpaces, this.enabledMetaSpaces);
}
const oldInvitedSpaces = this._invitedSpaces;
this._invitedSpaces = new Set(this.sortRootSpaces(invitedSpaces));
if (setHasDiff(oldInvitedSpaces, this._invitedSpaces)) {
this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces);
}
};
private rebuildParentMap = () => {
const joinedSpaces = this.matrixClient.getVisibleRooms().filter(r => {
return r.isSpaceRoom() && r.getMyMembership() === "join";
});
this.parentMap = new EnhancedMap<string, Set<string>>();
joinedSpaces.forEach(space => {
const children = this.getChildren(space.roomId);
children.forEach(child => {
this.parentMap.getOrCreate(child.roomId, new Set()).add(space.roomId);
});
});
};
private rebuildHomeSpace = () => {
if (this.allRoomsInHome) {
// this is a special-case to not have to maintain a set of all rooms
this.spaceFilteredRooms.delete(MetaSpace.Home);
} else {
const rooms = new Set(this.matrixClient.getVisibleRooms().filter(this.showInHomeSpace).map(r => r.roomId));
this.spaceFilteredRooms.set(MetaSpace.Home, rooms);
}
if (this.activeSpace === MetaSpace.Home) {
this.switchSpaceIfNeeded();
}
};
private rebuildMetaSpaces = () => {
const enabledMetaSpaces = new Set(this.enabledMetaSpaces);
const visibleRooms = this.matrixClient.getVisibleRooms();
if (enabledMetaSpaces.has(MetaSpace.Home)) {
this.rebuildHomeSpace();
} else {
this.spaceFilteredRooms.delete(MetaSpace.Home);
}
// populate the Favourites metaspace if it is enabled
if (enabledMetaSpaces.has(MetaSpace.Favourites)) {
const favourites = visibleRooms.filter(r => r.tags[DefaultTagID.Favourite]);
this.spaceFilteredRooms.set(MetaSpace.Favourites, new Set(favourites.map(r => r.roomId)));
} else {
this.spaceFilteredRooms.delete(MetaSpace.Favourites);
}
// populate the People metaspace if it is enabled
if (enabledMetaSpaces.has(MetaSpace.People)) {
const people = visibleRooms.filter(r => DMRoomMap.shared().getUserIdForRoomId(r.roomId));
this.spaceFilteredRooms.set(MetaSpace.People, new Set(people.map(r => r.roomId)));
}
// The People metaspace doesn't need maintaining
// populate the Orphans metaspace if it is enabled
if (enabledMetaSpaces.has(MetaSpace.Orphans)) {
// Populate the orphans space if the Home space is enabled as it is a superset of it.
// Home is effectively a super set of People + Orphans with the addition of having all invites too.
if (enabledMetaSpaces.has(MetaSpace.Orphans) || enabledMetaSpaces.has(MetaSpace.Home)) {
const orphans = visibleRooms.filter(r => {
// filter out DMs and rooms with >0 parents
return !this.parentMap.get(r.roomId)?.size && !DMRoomMap.shared().getUserIdForRoomId(r.roomId);
@ -519,6 +537,150 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
this.spaceFilteredRooms.set(MetaSpace.Orphans, new Set(orphans.map(r => r.roomId)));
}
if (isMetaSpace(this.activeSpace)) {
this.switchSpaceIfNeeded();
}
};
private updateNotificationStates = (spaces?: SpaceKey[]) => {
const enabledMetaSpaces = new Set(this.enabledMetaSpaces);
const visibleRooms = this.matrixClient.getVisibleRooms();
let dmBadgeSpace: MetaSpace;
// only show badges on dms on the most relevant space if such exists
if (enabledMetaSpaces.has(MetaSpace.People)) {
dmBadgeSpace = MetaSpace.People;
} else if (enabledMetaSpaces.has(MetaSpace.Home)) {
dmBadgeSpace = MetaSpace.Home;
}
if (!spaces) {
spaces = [...this.spaceFilteredRooms.keys()];
if (dmBadgeSpace === MetaSpace.People) {
spaces.push(MetaSpace.People);
}
if (enabledMetaSpaces.has(MetaSpace.Home) && !this.allRoomsInHome) {
spaces.push(MetaSpace.Home);
}
}
spaces.forEach((s) => {
if (this.allRoomsInHome && s === MetaSpace.Home) return; // we'll be using the global notification state, skip
// Update NotificationStates
this.getNotificationState(s).setRooms(visibleRooms.filter(room => {
if (s === MetaSpace.People) {
return this.isRoomInSpace(MetaSpace.People, room.roomId);
}
if (room.isSpaceRoom() || !this.spaceFilteredRooms.get(s).has(room.roomId)) return false;
if (dmBadgeSpace && DMRoomMap.shared().getUserIdForRoomId(room.roomId)) {
return s === dmBadgeSpace;
}
return true;
}));
});
if (dmBadgeSpace !== MetaSpace.People) {
this.notificationStateMap.delete(MetaSpace.People);
}
};
private showInHomeSpace = (room: Room): boolean => {
if (this.allRoomsInHome) return true;
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
room.getMyMembership() === "invite"; // put all invites in the Home Space
};
private static isInSpace(member: RoomMember): boolean {
return member.membership === "join" || member.membership === "invite";
}
private static getSpaceMembers(space: Room): string[] {
return space.getMembers().filter(SpaceStoreClass.isInSpace).map(m => m.userId);
}
// Method for resolving the impact of a single user's membership change in the given Space and its hierarchy
private onMemberUpdate = (space: Room, userId: string) => {
const inSpace = SpaceStoreClass.isInSpace(space.getMember(userId));
if (this.spaceFilteredUsers.get(space.roomId).has(userId)) {
if (inSpace) return; // nothing to do, user was already joined to subspace
if (this.getChildSpaces(space.roomId).some(s => this.spaceFilteredUsers.get(s.roomId).has(userId))) {
return; // nothing to do, this user leaving will have no effect as they are in a subspace
}
} else if (!inSpace) {
return; // nothing to do, user already not in the list
}
const seen = new Set<string>();
const stack = [space.roomId];
while (stack.length) {
const spaceId = stack.pop();
seen.add(spaceId);
if (inSpace) {
// add to our list and to that of all of our parents
this.spaceFilteredUsers.get(spaceId).add(userId);
} else {
// remove from our list and that of all of our parents until we hit a parent with this user
this.spaceFilteredUsers.get(spaceId).delete(userId);
}
this.getKnownParents(spaceId).forEach(parentId => {
if (seen.has(parentId)) return;
const parent = this.matrixClient.getRoom(parentId);
// because spaceFilteredUsers is cumulative, if we are removing from lower in the hierarchy,
// but the member is present higher in the hierarchy we must take care not to wrongly over-remove them.
if (inSpace || !SpaceStoreClass.isInSpace(parent.getMember(userId))) {
stack.push(parentId);
}
});
}
this.switchSpaceIfNeeded();
};
private onMembersUpdate = (space: Room, seen = new Set<string>()) => {
// Update this space's membership list
const userIds = new Set(SpaceStoreClass.getSpaceMembers(space));
// We only need to look one level with children
// as any further descendants will already be in their parent's superset
this.getChildSpaces(space.roomId).forEach(subspace => {
SpaceStoreClass.getSpaceMembers(subspace).forEach(userId => {
userIds.add(userId);
});
});
this.spaceFilteredUsers.set(space.roomId, userIds);
this.emit(space.roomId);
// Traverse all parents and update them too
this.getKnownParents(space.roomId).forEach(parentId => {
if (seen.has(parentId)) return;
const parent = this.matrixClient.getRoom(parentId);
if (parent) {
const newSeen = new Set(seen);
newSeen.add(parentId);
this.onMembersUpdate(parent, newSeen);
}
});
};
private onRoomsUpdate = () => {
const visibleRooms = this.matrixClient.getVisibleRooms();
const oldFilteredRooms = this.spaceFilteredRooms;
const oldFilteredUsers = this.spaceFilteredUsers;
this.spaceFilteredRooms = new Map();
this.spaceFilteredUsers = new Map();
this.rebuildParentMap();
this.rebuildMetaSpaces();
const hiddenChildren = new EnhancedMap<string, Set<string>>();
visibleRooms.forEach(room => {
if (room.getMyMembership() !== "join") return;
@ -530,31 +692,26 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
this.rootSpaces.forEach(s => {
// traverse each space tree in DFS to build up the supersets as you go up,
// reusing results from like subtrees.
const fn = (spaceId: string, parentPath: Set<string>): Set<string> => {
const fn = (spaceId: string, parentPath: Set<string>): [Set<string>, Set<string>] => {
if (parentPath.has(spaceId)) return; // prevent cycles
// reuse existing results if multiple similar branches exist
if (this.spaceFilteredRooms.has(spaceId)) {
return this.spaceFilteredRooms.get(spaceId);
if (this.spaceFilteredRooms.has(spaceId) && this.spaceFilteredUsers.has(spaceId)) {
return [this.spaceFilteredRooms.get(spaceId), this.spaceFilteredUsers.get(spaceId)];
}
const [childSpaces, childRooms] = partitionSpacesAndRooms(this.getChildren(spaceId));
const roomIds = new Set(childRooms.map(r => r.roomId));
const space = this.matrixClient?.getRoom(spaceId);
// Add relevant DMs
space?.getMembers().forEach(member => {
if (member.membership !== "join" && member.membership !== "invite") return;
DMRoomMap.shared().getDMRoomsForUserId(member.userId).forEach(roomId => {
roomIds.add(roomId);
});
});
const userIds = new Set(space?.getMembers().filter(m => {
return m.membership === "join" || m.membership === "invite";
}).map(m => m.userId));
const newPath = new Set(parentPath).add(spaceId);
childSpaces.forEach(childSpace => {
fn(childSpace.roomId, newPath)?.forEach(roomId => {
roomIds.add(roomId);
});
const [rooms, users] = fn(childSpace.roomId, newPath) ?? [];
rooms?.forEach(roomId => roomIds.add(roomId));
users?.forEach(userId => userIds.add(userId));
});
hiddenChildren.get(spaceId)?.forEach(roomId => {
roomIds.add(roomId);
@ -565,42 +722,59 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
return this.matrixClient.getRoomUpgradeHistory(roomId, true).map(r => r.roomId);
}));
this.spaceFilteredRooms.set(spaceId, expandedRoomIds);
return expandedRoomIds;
this.spaceFilteredUsers.set(spaceId, userIds);
return [expandedRoomIds, userIds];
};
fn(s.roomId, new Set());
});
const diff = mapDiff(oldFilteredRooms, this.spaceFilteredRooms);
const roomDiff = mapDiff(oldFilteredRooms, this.spaceFilteredRooms);
const userDiff = mapDiff(oldFilteredUsers, this.spaceFilteredUsers);
// filter out keys which changed by reference only by checking whether the sets differ
const changed = diff.changed.filter(k => setHasDiff(oldFilteredRooms.get(k), this.spaceFilteredRooms.get(k)));
[...diff.added, ...diff.removed, ...changed].forEach(k => {
const roomsChanged = roomDiff.changed.filter(k => {
return setHasDiff(oldFilteredRooms.get(k), this.spaceFilteredRooms.get(k));
});
const usersChanged = userDiff.changed.filter(k => {
return setHasDiff(oldFilteredUsers.get(k), this.spaceFilteredUsers.get(k));
});
const changeSet = new Set([
...roomDiff.added,
...userDiff.added,
...roomDiff.removed,
...userDiff.removed,
...roomsChanged,
...usersChanged,
]);
changeSet.forEach(k => {
this.emit(k);
});
let dmBadgeSpace: MetaSpace;
// only show badges on dms on the most relevant space if such exists
if (enabledMetaSpaces.has(MetaSpace.People)) {
dmBadgeSpace = MetaSpace.People;
} else if (enabledMetaSpaces.has(MetaSpace.Home)) {
dmBadgeSpace = MetaSpace.Home;
if (changeSet.has(this.activeSpace)) {
this.switchSpaceIfNeeded();
}
this.spaceFilteredRooms.forEach((roomIds, s) => {
if (this.allRoomsInHome && s === MetaSpace.Home) return; // we'll be using the global notification state, skip
const notificationStatesToUpdate = [...changeSet];
if (this.enabledMetaSpaces.includes(MetaSpace.People) &&
userDiff.added.length + userDiff.removed.length + usersChanged.length > 0
) {
notificationStatesToUpdate.push(MetaSpace.People);
}
this.updateNotificationStates(notificationStatesToUpdate);
};
// Update NotificationStates
this.getNotificationState(s).setRooms(visibleRooms.filter(room => {
if (!roomIds.has(room.roomId) || room.isSpaceRoom()) return false;
private switchSpaceIfNeeded = throttle(() => {
const roomId = RoomViewStore.getRoomId();
if (this.isRoomInSpace(this.activeSpace, roomId)) return;
if (dmBadgeSpace && DMRoomMap.shared().getUserIdForRoomId(room.roomId)) {
return s === dmBadgeSpace;
}
return true;
}));
});
}, 100, { trailing: true, leading: true });
if (this.matrixClient.getRoom(roomId)?.isSpaceRoom()) {
this.goToFirstSpace(true);
} else {
this.switchToRelatedSpace(roomId);
}
}, 100, { leading: true, trailing: true });
private switchToRelatedSpace = (roomId: string) => {
if (this.suggestedRooms.find(r => r.room_id === roomId)) return;
@ -616,11 +790,15 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
// otherwise, try to find a metaspace which contains this room
if (!parent) {
// search meta spaces in reverse as Home is the first and least specific one
parent = [...this.enabledMetaSpaces].reverse().find(s => this.getSpaceFilteredRoomIds(s).has(roomId));
parent = [...this.enabledMetaSpaces].reverse().find(s => this.isRoomInSpace(s, roomId));
}
// don't trigger a context switch when we are switching a space to match the chosen room
this.setActiveSpace(parent ?? MetaSpace.Home, false); // TODO
if (parent) {
this.setActiveSpace(parent, false);
} else {
this.goToFirstSpace();
}
};
private onRoom = (room: Room, newMembership?: string, oldMembership?: string) => {
@ -632,10 +810,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
const membership = newMembership || roomMembership;
if (!room.isSpaceRoom()) {
// this.onRoomUpdate(room);
// this.onRoomsUpdate();
// ideally we only need onRoomsUpdate here but it doesn't rebuild parentMap so always adds new rooms to Home
this.rebuild();
this.onRoomsUpdate();
if (membership === "join") {
// the user just joined a room, remove it from the suggested list if it was there
@ -655,13 +830,21 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
// Space
if (membership === "invite") {
const len = this._invitedSpaces.size;
this._invitedSpaces.add(room);
this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces);
if (len !== this._invitedSpaces.size) {
this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces);
}
} else if (oldMembership === "invite" && membership !== "join") {
this._invitedSpaces.delete(room);
this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces);
if (this._invitedSpaces.delete(room)) {
this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces);
}
} else {
this.onSpaceUpdate();
this.rebuildSpaceHierarchy();
// fire off updates to all parent listeners
this.parentMap.get(room.roomId)?.forEach((parentId) => {
this.emit(parentId);
});
this.emit(room.roomId);
}
@ -687,28 +870,36 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
if (!room) return;
switch (ev.getType()) {
case EventType.SpaceChild:
case EventType.SpaceChild: {
const target = this.matrixClient.getRoom(ev.getStateKey());
if (room.isSpaceRoom()) {
this.onSpaceUpdate();
if (target?.isSpaceRoom()) {
this.rebuildSpaceHierarchy();
this.emit(target.roomId);
} else {
this.onRoomsUpdate();
}
this.emit(room.roomId);
}
if (room.roomId === this.activeSpace && // current space
this.matrixClient.getRoom(ev.getStateKey())?.getMyMembership() !== "join" && // target not joined
target?.getMyMembership() !== "join" && // target not joined
ev.getPrevContent().suggested !== ev.getContent().suggested // suggested flag changed
) {
this.loadSuggestedRooms(room);
}
break;
}
case EventType.SpaceParent:
// TODO rebuild the space parent and not the room - check permissions?
// TODO confirm this after implementing parenting behaviour
if (room.isSpaceRoom()) {
this.onSpaceUpdate();
} else if (!this.allRoomsInHome) {
this.onRoomUpdate(room);
this.rebuildSpaceHierarchy();
} else {
this.onRoomsUpdate();
}
this.emit(room.roomId);
break;
@ -724,8 +915,12 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
// listening for m.room.member events in onRoomState above doesn't work as the Member object isn't updated by then
private onRoomStateMembers = (ev: MatrixEvent) => {
const room = this.matrixClient.getRoom(ev.getRoomId());
if (room?.isSpaceRoom()) {
this.onSpaceMembersChange(ev);
const userId = ev.getStateKey();
if (room?.isSpaceRoom() && // only consider space rooms
DMRoomMap.shared().getDMRoomsForUserId(userId).length > 0 && // only consider members we have a DM with
ev.getPrevContent().membership !== ev.getContent().membership // only consider when membership changes
) {
this.onMemberUpdate(room, userId);
}
};
@ -744,35 +939,73 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
const oldTags = lastEv?.getContent()?.tags || {};
const newTags = ev.getContent()?.tags || {};
if (!!oldTags[DefaultTagID.Favourite] !== !!newTags[DefaultTagID.Favourite]) {
this.onRoomUpdate(room);
this.onRoomFavouriteChange(room);
}
}
};
private onAccountData = (ev: MatrixEvent, prevEvent?: MatrixEvent) => {
if (!this.allRoomsInHome && ev.getType() === EventType.Direct) {
const lastContent = prevEvent?.getContent() ?? {};
const content = ev.getContent();
private onRoomFavouriteChange(room: Room) {
if (this.enabledMetaSpaces.includes(MetaSpace.Favourites)) {
if (room.tags[DefaultTagID.Favourite]) {
this.spaceFilteredRooms.get(MetaSpace.Favourites).add(room.roomId);
} else {
this.spaceFilteredRooms.get(MetaSpace.Favourites).delete(room.roomId);
}
this.emit(MetaSpace.Favourites);
}
}
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 => {
private onRoomDmChange(room: Room, isDm: boolean): void {
const enabledMetaSpaces = new Set(this.enabledMetaSpaces);
if (!this.allRoomsInHome && enabledMetaSpaces.has(MetaSpace.Home)) {
const homeRooms = this.spaceFilteredRooms.get(MetaSpace.Home);
if (this.showInHomeSpace(room)) {
homeRooms?.add(room.roomId);
} else if (!this.spaceFilteredRooms.get(MetaSpace.Orphans).has(room.roomId)) {
this.spaceFilteredRooms.get(MetaSpace.Home)?.delete(room.roomId);
}
this.emit(MetaSpace.Home);
}
if (enabledMetaSpaces.has(MetaSpace.People)) {
this.emit(MetaSpace.People);
}
if (enabledMetaSpaces.has(MetaSpace.Orphans) || enabledMetaSpaces.has(MetaSpace.Home)) {
if (isDm && this.spaceFilteredRooms.get(MetaSpace.Orphans).delete(room.roomId)) {
this.emit(MetaSpace.Orphans);
this.emit(MetaSpace.Home);
}
}
}
private onAccountData = (ev: MatrixEvent, prevEv?: MatrixEvent) => {
if (ev.getType() === EventType.Direct) {
const previousRooms = new Set(Object.values(prevEv?.getContent<Record<string, string[]>>() ?? {}).flat());
const currentRooms = new Set(Object.values(ev.getContent<Record<string, string[]>>()).flat());
const diff = setDiff(previousRooms, currentRooms);
[...diff.added, ...diff.removed].forEach(roomId => {
const room = this.matrixClient?.getRoom(roomId);
if (room) {
this.onRoomUpdate(room);
this.onRoomDmChange(room, currentRooms.has(roomId));
}
});
if (diff.removed.length > 0) {
this.switchSpaceIfNeeded();
}
}
};
protected async reset() {
this.rootSpaces = [];
this.orphanedRooms = new Set();
this.parentMap = new EnhancedMap();
this.notificationStateMap = new Map();
this.spaceFilteredRooms = new Map();
this.spaceFilteredUsers = new Map();
this._activeSpace = MetaSpace.Home; // set properly by onReady
this._suggestedRooms = [];
this._invitedSpaces = new Set();
@ -809,17 +1042,18 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
const enabledMetaSpaces = SettingsStore.getValue("Spaces.enabledMetaSpaces");
this._enabledMetaSpaces = metaSpaceOrder.filter(k => enabledMetaSpaces[k]) as MetaSpace[];
await this.onSpaceUpdate(); // trigger an initial update
this.rebuildSpaceHierarchy(); // 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 && (
lastSpaceId[0] === "!" ? this.matrixClient.getRoom(lastSpaceId) : enabledMetaSpaces[lastSpaceId]
)) {
const valid = (lastSpaceId && !isMetaSpace(lastSpaceId))
? this.matrixClient.getRoom(lastSpaceId)
: enabledMetaSpaces[lastSpaceId];
if (valid) {
// don't context switch here as it may break permalinks
this.setActiveSpace(lastSpaceId, false);
} else {
this.goToFirstSpace();
this.switchSpaceIfNeeded();
}
}
@ -828,7 +1062,8 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
}
protected async onAction(payload: ActionPayload) {
if (!spacesEnabled) return;
if (!spacesEnabled || !this.matrixClient) return;
switch (payload.action) {
case "view_room": {
// Don't auto-switch rooms when reacting to a context-switch
@ -842,12 +1077,12 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
if (!roomId) return; // we'll get re-fired with the room ID shortly
const room = this.matrixClient?.getRoom(roomId);
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.roomId, false);
} else if (!this.getSpaceFilteredRoomIds(this.activeSpace).has(roomId)) {
} else if (!this.isRoomInSpace(this.activeSpace, roomId)) {
this.switchToRelatedSpace(roomId);
}
@ -866,9 +1101,9 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
break;
case "after_leave_room":
if (this._activeSpace[0] === "!" && payload.room_id === this._activeSpace) {
if (!isMetaSpace(this._activeSpace) && payload.room_id === this._activeSpace) {
// User has left the current space, go to first space
this.goToFirstSpace();
this.goToFirstSpace(true);
}
break;
@ -892,7 +1127,9 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
if (this.allRoomsInHome !== newValue) {
this._allRoomsInHome = newValue;
this.emit(UPDATE_HOME_BEHAVIOUR, this.allRoomsInHome);
this.rebuild(); // rebuild everything
if (this.enabledMetaSpaces.includes(MetaSpace.Home)) {
this.rebuildHomeSpace();
}
}
break;
}
@ -901,18 +1138,39 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
const newValue = SettingsStore.getValue("Spaces.enabledMetaSpaces");
const enabledMetaSpaces = metaSpaceOrder.filter(k => newValue[k]) as MetaSpace[];
if (arrayHasDiff(this._enabledMetaSpaces, enabledMetaSpaces)) {
const hadPeopleOrHomeEnabled = this.enabledMetaSpaces.some(s => {
return s === MetaSpace.Home || s === MetaSpace.People;
});
this._enabledMetaSpaces = enabledMetaSpaces;
// if a metaspace currently being viewed was remove, go to another one
if (this.activeSpace[0] !== "!" &&
!enabledMetaSpaces.includes(this.activeSpace as MetaSpace)
) {
this.goToFirstSpace();
const hasPeopleOrHomeEnabled = this.enabledMetaSpaces.some(s => {
return s === MetaSpace.Home || s === MetaSpace.People;
});
// if a metaspace currently being viewed was removed, go to another one
if (isMetaSpace(this.activeSpace) && !newValue[this.activeSpace]) {
this.switchSpaceIfNeeded();
}
this.rebuildMetaSpaces();
if (hadPeopleOrHomeEnabled !== hasPeopleOrHomeEnabled) {
// in this case we have to rebuild everything as DM badges will move to/from real spaces
this.updateNotificationStates();
} else {
this.updateNotificationStates(enabledMetaSpaces);
}
this.emit(UPDATE_TOP_LEVEL_SPACES, this.spacePanelSpaces, this.enabledMetaSpaces);
this.rebuild(); // rebuild everything
}
break;
}
case "Spaces.showPeopleInSpace":
// getSpaceFilteredUserIds will return the appropriate value
this.emit(settingUpdatedPayload.roomId);
if (!this.enabledMetaSpaces.some(s => s === MetaSpace.Home || s === MetaSpace.People)) {
this.updateNotificationStates([settingUpdatedPayload.roomId]);
}
break;
}
}
}

View file

@ -53,3 +53,10 @@ export type SpaceKey = MetaSpace | Room["roomId"];
export interface ISuggestedRoom extends IHierarchyRoom {
viaServers: string[];
}
export function isMetaSpace(spaceKey: SpaceKey): boolean {
return spaceKey === MetaSpace.Home ||
spaceKey === MetaSpace.Favourites ||
spaceKey === MetaSpace.People ||
spaceKey === MetaSpace.Orphans;
}