diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 28f22780a2..4aa6df5488 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -38,6 +38,7 @@ import UserActivity from "../UserActivity"; import {ModalWidgetStore} from "../stores/ModalWidgetStore"; import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore"; import VoipUserMapper from "../VoipUserMapper"; +import {SpaceStoreClass} from "../stores/SpaceStore"; declare global { interface Window { @@ -68,6 +69,7 @@ declare global { mxUserActivity: UserActivity; mxModalWidgetStore: ModalWidgetStore; mxVoipUserMapper: VoipUserMapper; + mxSpaceStore: SpaceStoreClass; } interface Document { diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx new file mode 100644 index 0000000000..d675879138 --- /dev/null +++ b/src/stores/SpaceStore.tsx @@ -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 { + 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(); + // Map from room ID to set of spaces which list it as a child + private parentMap = new EnhancedMap>(); + // Map from space key to SpaceNotificationState instance representing that space + private notificationStateMap = new Map(); + // Map from space key to Set of room IDs that should be shown as part of that space's filter + private spaceFilteredRooms = new Map>(); + // 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 => { + 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([...visibleRooms, ...spaces]); + + const backrefs = new EnhancedMap>(); + + // 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(spaces); + + const markTreeChildren = (rootSpace: Room, unseen: Set) => { + 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(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): Set => { + 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>(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; diff --git a/src/stores/notifications/SpaceNotificationState.ts b/src/stores/notifications/SpaceNotificationState.ts new file mode 100644 index 0000000000..61a9701a07 --- /dev/null +++ b/src/stores/notifications/SpaceNotificationState.ts @@ -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); + } +} +