Merge branch 'develop' of git+ssh://github.com/matrix-org/matrix-react-sdk into develop
This commit is contained in:
commit
c02d03cc5b
146 changed files with 8365 additions and 1072 deletions
|
@ -122,6 +122,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
|
|||
}
|
||||
|
||||
private async appendRoom(room: Room) {
|
||||
if (room.isSpaceRoom() && SettingsStore.getValue("feature_spaces")) return; // hide space rooms
|
||||
let updated = false;
|
||||
const rooms = (this.state.rooms || []).slice(); // cheap clone
|
||||
|
||||
|
|
|
@ -31,6 +31,11 @@ export enum RightPanelPhases {
|
|||
GroupRoomList = 'GroupRoomList',
|
||||
GroupRoomInfo = 'GroupRoomInfo',
|
||||
GroupMemberInfo = 'GroupMemberInfo',
|
||||
|
||||
// Space stuff
|
||||
SpaceMemberList = "SpaceMemberList",
|
||||
SpaceMemberInfo = "SpaceMemberInfo",
|
||||
Space3pidMemberInfo = "Space3pidMemberInfo",
|
||||
}
|
||||
|
||||
// These are the phases that are safe to persist (the ones that don't require additional
|
||||
|
@ -43,3 +48,10 @@ export const RIGHT_PANEL_PHASES_NO_ARGS = [
|
|||
RightPanelPhases.GroupMemberList,
|
||||
RightPanelPhases.GroupRoomList,
|
||||
];
|
||||
|
||||
// Subset of phases visible in the Space View
|
||||
export const RIGHT_PANEL_SPACE_PHASES = [
|
||||
RightPanelPhases.SpaceMemberList,
|
||||
RightPanelPhases.Space3pidMemberInfo,
|
||||
RightPanelPhases.SpaceMemberInfo,
|
||||
];
|
||||
|
|
462
src/stores/SpaceStore.tsx
Normal file
462
src/stores/SpaceStore.tsx
Normal file
|
@ -0,0 +1,462 @@
|
|||
/*
|
||||
Copyright 2021 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 {throttle, sortBy} from "lodash";
|
||||
import {EventType} 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";
|
||||
|
||||
import {AsyncStoreWithClient} from "./AsyncStoreWithClient";
|
||||
import defaultDispatcher from "../dispatcher/dispatcher";
|
||||
import {ActionPayload} from "../dispatcher/payloads";
|
||||
import RoomListStore from "./room-list/RoomListStore";
|
||||
import SettingsStore from "../settings/SettingsStore";
|
||||
import DMRoomMap from "../utils/DMRoomMap";
|
||||
import {FetchRoomFn} from "./notifications/ListNotificationState";
|
||||
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 {objectDiff} from "../utils/objects";
|
||||
import {arrayHasDiff} from "../utils/arrays";
|
||||
|
||||
type SpaceKey = string | symbol;
|
||||
|
||||
interface IState {}
|
||||
|
||||
const ACTIVE_SPACE_LS_KEY = "mx_active_space";
|
||||
|
||||
export const HOME_SPACE = Symbol("home-space");
|
||||
|
||||
export const UPDATE_TOP_LEVEL_SPACES = Symbol("top-level-spaces");
|
||||
export const UPDATE_SELECTED_SPACE = Symbol("selected-space");
|
||||
// Space Room ID/HOME_SPACE will be emitted when a Space's children change
|
||||
|
||||
const partitionSpacesAndRooms = (arr: Room[]): [Room[], Room[]] => { // [spaces, rooms]
|
||||
return arr.reduce((result, room: Room) => {
|
||||
result[room.isSpaceRoom() ? 0 : 1].push(room);
|
||||
return result;
|
||||
}, [[], []]);
|
||||
};
|
||||
|
||||
const getOrder = (ev: MatrixEvent): string | null => {
|
||||
const content = ev.getContent();
|
||||
if (typeof content.order === "string" && Array.from(content.order).every((c: string) => {
|
||||
const charCode = c.charCodeAt(0);
|
||||
return charCode >= 0x20 && charCode <= 0x7F;
|
||||
})) {
|
||||
return content.order;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const getRoomFn: FetchRoomFn = (room: Room) => {
|
||||
return RoomNotificationStateStore.instance.getRoomState(room);
|
||||
};
|
||||
|
||||
export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
||||
constructor() {
|
||||
super(defaultDispatcher, {});
|
||||
}
|
||||
|
||||
// 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 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 _activeSpace?: Room = null;
|
||||
|
||||
public get spacePanelSpaces(): Room[] {
|
||||
return this.rootSpaces;
|
||||
}
|
||||
|
||||
public get activeSpace(): Room | null {
|
||||
return this._activeSpace || null;
|
||||
}
|
||||
|
||||
public setActiveSpace(space: Room | null) {
|
||||
if (space === this.activeSpace) return;
|
||||
|
||||
this._activeSpace = space;
|
||||
this.emit(UPDATE_SELECTED_SPACE, this.activeSpace);
|
||||
|
||||
// persist space selected
|
||||
if (space) {
|
||||
window.localStorage.setItem(ACTIVE_SPACE_LS_KEY, space.roomId);
|
||||
} else {
|
||||
window.localStorage.removeItem(ACTIVE_SPACE_LS_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
public addRoomToSpace(space: Room, roomId: string, via: string[], autoJoin = false) {
|
||||
return this.matrixClient.sendStateEvent(space.roomId, EventType.SpaceChild, {
|
||||
via,
|
||||
auto_join: autoJoin,
|
||||
}, roomId);
|
||||
}
|
||||
|
||||
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") || [];
|
||||
}
|
||||
|
||||
public getChildRooms(spaceId: string): Room[] {
|
||||
return this.getChildren(spaceId).filter(r => !r.isSpaceRoom());
|
||||
}
|
||||
|
||||
public getChildSpaces(spaceId: string): Room[] {
|
||||
return this.getChildren(spaceId).filter(r => r.isSpaceRoom());
|
||||
}
|
||||
|
||||
public getParents(roomId: string, canonicalOnly = false): Room[] {
|
||||
const room = this.matrixClient?.getRoom(roomId);
|
||||
return room?.currentState.getStateEvents(EventType.SpaceParent)
|
||||
.filter(ev => {
|
||||
const content = ev.getContent();
|
||||
if (!content?.via) return false;
|
||||
// TODO apply permissions check to verify that the parent mapping is valid
|
||||
if (canonicalOnly && !content?.canonical) return false;
|
||||
return true;
|
||||
})
|
||||
.map(ev => this.matrixClient.getRoom(ev.getStateKey()))
|
||||
.filter(Boolean) || [];
|
||||
}
|
||||
|
||||
public getCanonicalParent(roomId: string): Room | null {
|
||||
const parents = this.getParents(roomId, true);
|
||||
return sortBy(parents, r => r.roomId)?.[0] || null;
|
||||
}
|
||||
|
||||
public getSpaces = () => {
|
||||
return this.matrixClient.getRooms().filter(r => r.isSpaceRoom() && r.getMyMembership() === "join");
|
||||
};
|
||||
|
||||
public getSpaceFilteredRoomIds = (space: Room | null): Set<string> => {
|
||||
return this.spaceFilteredRooms.get(space?.roomId || HOME_SPACE) || new Set();
|
||||
};
|
||||
|
||||
public rebuild = throttle(() => { // exported for tests
|
||||
const visibleRooms = this.matrixClient.getVisibleRooms();
|
||||
|
||||
// Sort spaces by room ID to force the loop breaking to be deterministic
|
||||
const spaces = sortBy(this.getSpaces(), space => space.roomId);
|
||||
const unseenChildren = new Set<Room>([...visibleRooms, ...spaces]);
|
||||
|
||||
const backrefs = new EnhancedMap<string, Set<string>>();
|
||||
|
||||
// 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));
|
||||
|
||||
// untested 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);
|
||||
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.onRoomsUpdate(); // TODO only do this if a change has happened
|
||||
this.emit(UPDATE_TOP_LEVEL_SPACES, this.spacePanelSpaces);
|
||||
}, 100, {trailing: true, leading: true});
|
||||
|
||||
onSpaceUpdate = () => {
|
||||
this.rebuild();
|
||||
}
|
||||
|
||||
private showInHomeSpace = (room: Room) => {
|
||||
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 onRoomsUpdate = throttle(() => {
|
||||
// TODO resolve some updates as deltas
|
||||
const visibleRooms = this.matrixClient.getVisibleRooms();
|
||||
|
||||
const oldFilteredRooms = this.spaceFilteredRooms;
|
||||
this.spaceFilteredRooms = new Map();
|
||||
|
||||
// put all invites (rooms & spaces) in the Home Space
|
||||
const invites = this.matrixClient.getRooms().filter(r => 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.
|
||||
const fn = (spaceId: string, parentPath: 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);
|
||||
}
|
||||
|
||||
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?.getJoinedMembers().forEach(member => {
|
||||
DMRoomMap.shared().getDMRoomsForUserId(member.userId).forEach(roomId => {
|
||||
roomIds.add(roomId);
|
||||
});
|
||||
});
|
||||
|
||||
const newPath = new Set(parentPath).add(spaceId);
|
||||
childSpaces.forEach(childSpace => {
|
||||
fn(childSpace.roomId, newPath)?.forEach(roomId => {
|
||||
roomIds.add(roomId);
|
||||
});
|
||||
});
|
||||
this.spaceFilteredRooms.set(spaceId, roomIds);
|
||||
return roomIds;
|
||||
};
|
||||
|
||||
fn(s.roomId, new Set());
|
||||
});
|
||||
|
||||
const diff = mapDiff(oldFilteredRooms, this.spaceFilteredRooms);
|
||||
// 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 => {
|
||||
this.emit(k);
|
||||
});
|
||||
|
||||
this.spaceFilteredRooms.forEach((roomIds, s) => {
|
||||
// Update NotificationStates
|
||||
const rooms = this.matrixClient.getRooms().filter(room => roomIds.has(room.roomId));
|
||||
this.getNotificationState(s)?.setRooms(rooms);
|
||||
});
|
||||
}, 100, {trailing: true, leading: true});
|
||||
|
||||
private onRoom = (room: Room) => {
|
||||
if (room?.isSpaceRoom()) {
|
||||
this.onSpaceUpdate();
|
||||
this.emit(room.roomId);
|
||||
} else {
|
||||
// this.onRoomUpdate(room);
|
||||
this.onRoomsUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
private onRoomState = (ev: MatrixEvent) => {
|
||||
const room = this.matrixClient.getRoom(ev.getRoomId());
|
||||
if (!room) return;
|
||||
|
||||
if (ev.getType() === EventType.SpaceChild && room.isSpaceRoom()) {
|
||||
this.onSpaceUpdate();
|
||||
this.emit(room.roomId);
|
||||
} else if (ev.getType() === 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 {
|
||||
this.onRoomUpdate(room);
|
||||
}
|
||||
this.emit(room.roomId);
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
if (!!ev.getContent()[DefaultTagID.Favourite] !== !!lastEvent.getContent()[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 onNotReady() {
|
||||
if (!SettingsStore.getValue("feature_spaces")) return;
|
||||
if (this.matrixClient) {
|
||||
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({});
|
||||
}
|
||||
|
||||
protected async onReady() {
|
||||
if (!SettingsStore.getValue("feature_spaces")) return;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected async onAction(payload: ActionPayload) {
|
||||
switch (payload.action) {
|
||||
case "view_room": {
|
||||
const room = this.matrixClient?.getRoom(payload.room_id);
|
||||
|
||||
if (room?.getMyMembership() === "join") {
|
||||
if (room.isSpaceRoom()) {
|
||||
this.setActiveSpace(room);
|
||||
} else if (!this.spaceFilteredRooms.get(this._activeSpace?.roomId || HOME_SPACE).has(room.roomId)) {
|
||||
// TODO maybe reverse these first 2 clauses once space panel active is fixed
|
||||
let parent = this.rootSpaces.find(s => this.spaceFilteredRooms.get(s.roomId)?.has(room.roomId));
|
||||
if (!parent) {
|
||||
parent = this.getCanonicalParent(room.roomId);
|
||||
}
|
||||
if (!parent) {
|
||||
const parents = Array.from(this.parentMap.get(room.roomId) || []);
|
||||
parent = parents.find(p => this.matrixClient.getRoom(p));
|
||||
}
|
||||
if (parent) {
|
||||
this.setActiveSpace(parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "after_leave_room":
|
||||
if (this._activeSpace && payload.room_id === this._activeSpace.roomId) {
|
||||
this.setActiveSpace(null);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public getNotificationState(key: SpaceKey): SpaceNotificationState {
|
||||
if (this.notificationStateMap.has(key)) {
|
||||
return this.notificationStateMap.get(key);
|
||||
}
|
||||
|
||||
const state = new SpaceNotificationState(key, getRoomFn);
|
||||
this.notificationStateMap.set(key, state);
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default class SpaceStore {
|
||||
private static internalInstance = new SpaceStoreClass();
|
||||
|
||||
public static get instance(): SpaceStoreClass {
|
||||
return SpaceStore.internalInstance;
|
||||
}
|
||||
}
|
||||
|
||||
window.mxSpaceStore = SpaceStore.instance;
|
82
src/stores/notifications/SpaceNotificationState.ts
Normal file
82
src/stores/notifications/SpaceNotificationState.ts
Normal file
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
Copyright 2021 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 { Room } from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import { NotificationColor } from "./NotificationColor";
|
||||
import { arrayDiff } from "../../utils/arrays";
|
||||
import { RoomNotificationState } from "./RoomNotificationState";
|
||||
import { NOTIFICATION_STATE_UPDATE, NotificationState } from "./NotificationState";
|
||||
import { FetchRoomFn } from "./ListNotificationState";
|
||||
|
||||
export class SpaceNotificationState extends NotificationState {
|
||||
private rooms: Room[] = [];
|
||||
private states: { [spaceId: string]: RoomNotificationState } = {};
|
||||
|
||||
constructor(private spaceId: string | symbol, private getRoomFn: FetchRoomFn) {
|
||||
super();
|
||||
}
|
||||
|
||||
public get symbol(): string {
|
||||
return null; // This notification state doesn't support symbols
|
||||
}
|
||||
|
||||
public setRooms(rooms: Room[]) {
|
||||
const oldRooms = this.rooms;
|
||||
const diff = arrayDiff(oldRooms, rooms);
|
||||
this.rooms = rooms;
|
||||
for (const oldRoom of diff.removed) {
|
||||
const state = this.states[oldRoom.roomId];
|
||||
if (!state) continue; // We likely just didn't have a badge (race condition)
|
||||
delete this.states[oldRoom.roomId];
|
||||
state.off(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
|
||||
}
|
||||
for (const newRoom of diff.added) {
|
||||
const state = this.getRoomFn(newRoom);
|
||||
state.on(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
|
||||
this.states[newRoom.roomId] = state;
|
||||
}
|
||||
|
||||
this.calculateTotalState();
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
super.destroy();
|
||||
for (const state of Object.values(this.states)) {
|
||||
state.off(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
|
||||
}
|
||||
this.states = {};
|
||||
}
|
||||
|
||||
private onRoomNotificationStateUpdate = () => {
|
||||
this.calculateTotalState();
|
||||
};
|
||||
|
||||
private calculateTotalState() {
|
||||
const snapshot = this.snapshot();
|
||||
|
||||
this._count = 0;
|
||||
this._color = NotificationColor.None;
|
||||
for (const state of Object.values(this.states)) {
|
||||
this._count += state.count;
|
||||
this._color = Math.max(this.color, state.color);
|
||||
}
|
||||
|
||||
// finally, publish an update if needed
|
||||
this.emitIfUpdated(snapshot);
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +35,7 @@ import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
|
|||
import { NameFilterCondition } from "./filters/NameFilterCondition";
|
||||
import { RoomNotificationStateStore } from "../notifications/RoomNotificationStateStore";
|
||||
import { VisibilityProvider } from "./filters/VisibilityProvider";
|
||||
import { SpaceWatcher } from "./SpaceWatcher";
|
||||
|
||||
interface IState {
|
||||
tagsEnabled?: boolean;
|
||||
|
@ -56,10 +57,11 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
|
|||
private initialListsGenerated = false;
|
||||
private algorithm = new Algorithm();
|
||||
private filterConditions: IFilterCondition[] = [];
|
||||
private tagWatcher = new TagWatcher(this);
|
||||
private tagWatcher: TagWatcher;
|
||||
private spaceWatcher: SpaceWatcher;
|
||||
private updateFn = new MarkedExecution(() => {
|
||||
for (const tagId of Object.keys(this.unfilteredLists)) {
|
||||
RoomNotificationStateStore.instance.getListState(tagId).setRooms(this.unfilteredLists[tagId]);
|
||||
for (const tagId of Object.keys(this.orderedLists)) {
|
||||
RoomNotificationStateStore.instance.getListState(tagId).setRooms(this.orderedLists[tagId]);
|
||||
}
|
||||
this.emit(LISTS_UPDATE_EVENT);
|
||||
});
|
||||
|
@ -77,6 +79,15 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
|
|||
RoomViewStore.addListener(() => this.handleRVSUpdate({}));
|
||||
this.algorithm.on(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated);
|
||||
this.algorithm.on(FILTER_CHANGED, this.onAlgorithmFilterUpdated);
|
||||
this.setupWatchers();
|
||||
}
|
||||
|
||||
private setupWatchers() {
|
||||
if (SettingsStore.getValue("feature_spaces")) {
|
||||
this.spaceWatcher = new SpaceWatcher(this);
|
||||
} else {
|
||||
this.tagWatcher = new TagWatcher(this);
|
||||
}
|
||||
}
|
||||
|
||||
public get unfilteredLists(): ITagMap {
|
||||
|
@ -92,9 +103,9 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
|
|||
// Intended for test usage
|
||||
public async resetStore() {
|
||||
await this.reset();
|
||||
this.tagWatcher = new TagWatcher(this);
|
||||
this.filterConditions = [];
|
||||
this.initialListsGenerated = false;
|
||||
this.setupWatchers();
|
||||
|
||||
this.algorithm.off(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated);
|
||||
this.algorithm.off(FILTER_CHANGED, this.onAlgorithmListUpdated);
|
||||
|
@ -554,8 +565,12 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
|
|||
public async regenerateAllLists({trigger = true}) {
|
||||
console.warn("Regenerating all room lists");
|
||||
|
||||
const rooms = this.matrixClient.getVisibleRooms()
|
||||
.filter(r => VisibilityProvider.instance.isRoomVisible(r));
|
||||
const rooms = [
|
||||
...this.matrixClient.getVisibleRooms(),
|
||||
// also show space invites in the room list
|
||||
...this.matrixClient.getRooms().filter(r => r.isSpaceRoom() && r.getMyMembership() === "invite"),
|
||||
].filter(r => VisibilityProvider.instance.isRoomVisible(r));
|
||||
|
||||
const customTags = new Set<TagID>();
|
||||
if (this.state.tagsEnabled) {
|
||||
for (const room of rooms) {
|
||||
|
|
39
src/stores/room-list/SpaceWatcher.ts
Normal file
39
src/stores/room-list/SpaceWatcher.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
Copyright 2021 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 { Room } from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import { RoomListStoreClass } from "./RoomListStore";
|
||||
import { SpaceFilterCondition } from "./filters/SpaceFilterCondition";
|
||||
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 activeSpace: Room = SpaceStore.instance.activeSpace;
|
||||
|
||||
constructor(private store: RoomListStoreClass) {
|
||||
this.filter.updateSpace(this.activeSpace); // get the filter into a consistent state
|
||||
store.addFilter(this.filter);
|
||||
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated);
|
||||
}
|
||||
|
||||
private onSelectedSpaceUpdated = (activeSpace) => {
|
||||
this.filter.updateSpace(this.activeSpace = activeSpace);
|
||||
};
|
||||
}
|
|
@ -186,6 +186,9 @@ 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;
|
||||
|
||||
// Note throughout: We need async so we can wait for handleRoomUpdate() to do its thing,
|
||||
// otherwise we risk duplicating rooms.
|
||||
|
||||
|
|
|
@ -19,17 +19,17 @@ import { FILTER_CHANGED, FilterPriority, IFilterCondition } from "./IFilterCondi
|
|||
import { Group } from "matrix-js-sdk/src/models/group";
|
||||
import { EventEmitter } from "events";
|
||||
import GroupStore from "../../GroupStore";
|
||||
import { arrayHasDiff } from "../../../utils/arrays";
|
||||
import { IDestroyable } from "../../../utils/IDestroyable";
|
||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import { setHasDiff } from "../../../utils/sets";
|
||||
|
||||
/**
|
||||
* A filter condition for the room list which reveals rooms which
|
||||
* are a member of a given community.
|
||||
*/
|
||||
export class CommunityFilterCondition extends EventEmitter implements IFilterCondition, IDestroyable {
|
||||
private roomIds: string[] = [];
|
||||
private userIds: string[] = [];
|
||||
private roomIds = new Set<string>();
|
||||
private userIds = new Set<string>();
|
||||
|
||||
constructor(private community: Group) {
|
||||
super();
|
||||
|
@ -45,19 +45,18 @@ export class CommunityFilterCondition extends EventEmitter implements IFilterCon
|
|||
}
|
||||
|
||||
public isVisible(room: Room): boolean {
|
||||
return this.roomIds.includes(room.roomId) ||
|
||||
this.userIds.includes(DMRoomMap.shared().getUserIdForRoomId(room.roomId));
|
||||
return this.roomIds.has(room.roomId) || this.userIds.has(DMRoomMap.shared().getUserIdForRoomId(room.roomId));
|
||||
}
|
||||
|
||||
private onStoreUpdate = async (): Promise<any> => {
|
||||
// We don't actually know if the room list changed for the community, so just check it again.
|
||||
const beforeRoomIds = this.roomIds;
|
||||
this.roomIds = (await GroupStore.getGroupRooms(this.community.groupId)).map(r => r.roomId);
|
||||
this.roomIds = new Set((await GroupStore.getGroupRooms(this.community.groupId)).map(r => r.roomId));
|
||||
|
||||
const beforeUserIds = this.userIds;
|
||||
this.userIds = (await GroupStore.getGroupMembers(this.community.groupId)).map(u => u.userId);
|
||||
this.userIds = new Set((await GroupStore.getGroupMembers(this.community.groupId)).map(u => u.userId));
|
||||
|
||||
if (arrayHasDiff(beforeRoomIds, this.roomIds) || arrayHasDiff(beforeUserIds, this.userIds)) {
|
||||
if (setHasDiff(beforeRoomIds, this.roomIds) || setHasDiff(beforeUserIds, this.userIds)) {
|
||||
this.emit(FILTER_CHANGED);
|
||||
}
|
||||
};
|
||||
|
|
69
src/stores/room-list/filters/SpaceFilterCondition.ts
Normal file
69
src/stores/room-list/filters/SpaceFilterCondition.ts
Normal file
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
Copyright 2021 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 { EventEmitter } from "events";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import { FILTER_CHANGED, FilterPriority, IFilterCondition } from "./IFilterCondition";
|
||||
import { IDestroyable } from "../../../utils/IDestroyable";
|
||||
import SpaceStore, {HOME_SPACE} from "../../SpaceStore";
|
||||
import { setHasDiff } from "../../../utils/sets";
|
||||
|
||||
/**
|
||||
* A filter condition for the room list which reveals rooms which
|
||||
* are a member of a given space or if no space is selected shows:
|
||||
* + Orphaned rooms (ones not in any space you are a part of)
|
||||
* + All DMs
|
||||
*/
|
||||
export class SpaceFilterCondition extends EventEmitter implements IFilterCondition, IDestroyable {
|
||||
private roomIds = new Set<Room>();
|
||||
private space: Room = null;
|
||||
|
||||
public get relativePriority(): FilterPriority {
|
||||
// Lowest priority so we can coarsely find rooms.
|
||||
return FilterPriority.Lowest;
|
||||
}
|
||||
|
||||
public isVisible(room: Room): boolean {
|
||||
return this.roomIds.has(room.roomId);
|
||||
}
|
||||
|
||||
private onStoreUpdate = async (): Promise<void> => {
|
||||
const beforeRoomIds = this.roomIds;
|
||||
this.roomIds = SpaceStore.instance.getSpaceFilteredRoomIds(this.space);
|
||||
|
||||
if (setHasDiff(beforeRoomIds, this.roomIds)) {
|
||||
// XXX: Room List Store has a bug where rooms which are synced after the filter is set
|
||||
// are excluded from the filter, this is a workaround for it.
|
||||
this.emit(FILTER_CHANGED);
|
||||
setTimeout(() => {
|
||||
this.emit(FILTER_CHANGED);
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
private getSpaceEventKey = (space: Room | null) => space ? space.roomId : HOME_SPACE;
|
||||
|
||||
public updateSpace(space: Room) {
|
||||
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
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
SpaceStore.instance.off(this.getSpaceEventKey(this.space), this.onStoreUpdate);
|
||||
}
|
||||
}
|
|
@ -18,6 +18,7 @@ import {Room} from "matrix-js-sdk/src/models/room";
|
|||
import CallHandler from "../../../CallHandler";
|
||||
import { RoomListCustomisations } from "../../../customisations/RoomList";
|
||||
import VoipUserMapper from "../../../VoipUserMapper";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
|
||||
export class VisibilityProvider {
|
||||
private static internalInstance: VisibilityProvider;
|
||||
|
@ -37,22 +38,23 @@ export class VisibilityProvider {
|
|||
}
|
||||
|
||||
public isRoomVisible(room: Room): boolean {
|
||||
let isVisible = true; // Returned at the end of this function
|
||||
let forced = false; // When true, this function won't bother calling the customisation points
|
||||
|
||||
if (
|
||||
CallHandler.sharedInstance().getSupportsVirtualRooms() &&
|
||||
VoipUserMapper.sharedInstance().isVirtualRoom(room)
|
||||
) {
|
||||
isVisible = false;
|
||||
forced = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// hide space rooms as they'll be shown in the SpacePanel
|
||||
if (room.isSpaceRoom() && SettingsStore.getValue("feature_spaces")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isVisibleFn = RoomListCustomisations.isRoomVisible;
|
||||
if (!forced && isVisibleFn) {
|
||||
isVisible = isVisibleFn(room);
|
||||
if (isVisibleFn) {
|
||||
return isVisibleFn(room);
|
||||
}
|
||||
|
||||
return isVisible;
|
||||
return true; // default
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue