Absorb the matrix-react-sdk repository (#28192)

Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
Co-authored-by: github-merge-queue <github-merge-queue@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Florian Duros <florian.duros@ormaz.fr>
Co-authored-by: Kim Brose <kim.brose@nordeck.net>
Co-authored-by: Florian Duros <florianduros@element.io>
Co-authored-by: R Midhun Suresh <hi@midhun.dev>
Co-authored-by: dbkr <986903+dbkr@users.noreply.github.com>
Co-authored-by: ElementRobot <releases@riot.im>
Co-authored-by: dbkr <dbkr@users.noreply.github.com>
Co-authored-by: David Baker <dbkr@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
Co-authored-by: David Langley <davidl@element.io>
Co-authored-by: Michael Weimann <michaelw@matrix.org>
Co-authored-by: Timshel <Timshel@users.noreply.github.com>
Co-authored-by: Sahil Silare <32628578+sahil9001@users.noreply.github.com>
Co-authored-by: Will Hunt <will@half-shot.uk>
Co-authored-by: Hubert Chathi <hubert@uhoreg.ca>
Co-authored-by: Andrew Ferrazzutti <andrewf@element.io>
Co-authored-by: Robin <robin@robin.town>
Co-authored-by: Tulir Asokan <tulir@maunium.net>
This commit is contained in:
Michael Telatynski
2024-10-16 13:31:55 +01:00
committed by GitHub
parent 2b99496025
commit c05c429803
3280 changed files with 586617 additions and 905 deletions

View File

@@ -0,0 +1,35 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
const PASSWORD_TIMEOUT = 5 * 60 * 1000; // five minutes
/**
* Store for the account password.
* This password can be used for a short time after login
* to avoid requestin the password all the time for instance during e2ee setup.
*/
export class AccountPasswordStore {
private password?: string;
private passwordTimeoutId?: ReturnType<typeof setTimeout>;
public setPassword(password: string): void {
this.password = password;
clearTimeout(this.passwordTimeoutId);
this.passwordTimeoutId = setTimeout(this.clearPassword, PASSWORD_TIMEOUT);
}
public getPassword(): string | undefined {
return this.password;
}
public clearPassword = (): void => {
clearTimeout(this.passwordTimeoutId);
this.passwordTimeoutId = undefined;
this.password = undefined;
};
}

View File

@@ -0,0 +1,126 @@
/*
Copyright 2018-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import EventEmitter from "events";
import { MatrixEvent, RoomStateEvent, RoomState } from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "../MatrixClientPeg";
import WidgetUtils from "../utils/WidgetUtils";
import { WidgetMessagingStore } from "./widgets/WidgetMessagingStore";
export enum ActiveWidgetStoreEvent {
// Indicates a change in the currently persistent widget
Persistence = "persistence",
// Indicate changes in the currently docked widgets
Dock = "dock",
Undock = "undock",
}
/**
* Stores information about the widgets active in the app right now:
* * What widget is set to remain always-on-screen, if any
* Only one widget may be 'always on screen' at any one time.
* * Reference counts to keep track of whether a widget is kept docked or alive
* by any components
*/
export default class ActiveWidgetStore extends EventEmitter {
private static internalInstance: ActiveWidgetStore;
private persistentWidgetId: string | null = null;
private persistentRoomId: string | null = null;
private dockedWidgetsByUid = new Map<string, number>();
public static get instance(): ActiveWidgetStore {
if (!ActiveWidgetStore.internalInstance) {
ActiveWidgetStore.internalInstance = new ActiveWidgetStore();
}
return ActiveWidgetStore.internalInstance;
}
public start(): void {
MatrixClientPeg.safeGet().on(RoomStateEvent.Events, this.onRoomStateEvents);
}
public stop(): void {
MatrixClientPeg.get()?.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
}
private onRoomStateEvents = (ev: MatrixEvent, { roomId }: RoomState): void => {
// XXX: This listens for state events in order to remove the active widget.
// Everything else relies on views listening for events and calling setters
// on this class which is terrible. This store should just listen for events
// and keep itself up to date.
// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
if (ev.getType() === "im.vector.modular.widgets") {
this.destroyPersistentWidget(ev.getStateKey()!, roomId);
}
};
public destroyPersistentWidget(widgetId: string, roomId: string | null): void {
if (!this.getWidgetPersistence(widgetId, roomId)) return;
// We first need to set the widget persistence to false
this.setWidgetPersistence(widgetId, roomId, false);
// Then we can stop the messaging. Stopping the messaging emits - we might move the widget out of sight.
// If we would do this before setting the persistence to false, it would stay in the DOM (hidden) because
// its still persistent. We need to avoid this.
WidgetMessagingStore.instance.stopMessagingByUid(WidgetUtils.calcWidgetUid(widgetId, roomId ?? undefined));
}
public setWidgetPersistence(widgetId: string, roomId: string | null, val: boolean): void {
const isPersisted = this.getWidgetPersistence(widgetId, roomId);
if (isPersisted && !val) {
this.persistentWidgetId = null;
this.persistentRoomId = null;
} else if (!isPersisted && val) {
this.persistentWidgetId = widgetId;
this.persistentRoomId = roomId;
}
this.emit(ActiveWidgetStoreEvent.Persistence);
}
public getWidgetPersistence(widgetId: string, roomId: string | null): boolean {
return this.persistentWidgetId === widgetId && this.persistentRoomId === roomId;
}
public getPersistentWidgetId(): string | null {
return this.persistentWidgetId;
}
public getPersistentRoomId(): string | null {
return this.persistentRoomId;
}
// Registers the given widget as being docked somewhere in the UI (not a PiP),
// to allow its lifecycle to be tracked.
public dockWidget(widgetId: string, roomId: string | null): void {
const uid = WidgetUtils.calcWidgetUid(widgetId, roomId ?? undefined);
const refs = this.dockedWidgetsByUid.get(uid) ?? 0;
this.dockedWidgetsByUid.set(uid, refs + 1);
if (refs === 0) this.emit(ActiveWidgetStoreEvent.Dock);
}
public undockWidget(widgetId: string, roomId: string | null): void {
const uid = WidgetUtils.calcWidgetUid(widgetId, roomId ?? undefined);
const refs = this.dockedWidgetsByUid.get(uid);
if (refs) this.dockedWidgetsByUid.set(uid, refs - 1);
if (refs === 1) this.emit(ActiveWidgetStoreEvent.Undock);
}
// Determines whether the given widget is docked anywhere in the UI (not a PiP)
public isDocked(widgetId: string, roomId: string | null): boolean {
const uid = WidgetUtils.calcWidgetUid(widgetId, roomId ?? undefined);
const refs = this.dockedWidgetsByUid.get(uid) ?? 0;
return refs > 0;
}
// Determines whether the given widget is being kept alive in the UI, including PiPs
public isLive(widgetId: string, roomId: string | null): boolean {
return this.isDocked(widgetId, roomId) || this.getWidgetPersistence(widgetId, roomId);
}
}
window.mxActiveWidgetStore = ActiveWidgetStore.instance;

105
src/stores/AsyncStore.ts Normal file
View File

@@ -0,0 +1,105 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { EventEmitter } from "events";
import AwaitLock from "await-lock";
import { ActionPayload } from "../dispatcher/payloads";
import { MatrixDispatcher } from "../dispatcher/dispatcher";
/**
* The event/channel to listen for in an AsyncStore.
*/
export const UPDATE_EVENT = "update";
/**
* Represents a minimal store which works similar to Flux stores. Instead
* of everything needing to happen in a dispatch cycle, everything can
* happen async to that cycle.
*
* The store operates by using Object.assign() to mutate state - it sends the
* state objects (current and new) through the function onto a new empty
* object. Because of this, it is recommended to break out your state to be as
* safe as possible. The state mutations are also locked, preventing concurrent
* writes.
*
* All updates to the store happen on the UPDATE_EVENT event channel with the
* one argument being the instance of the store.
*
* To update the state, use updateState() and preferably await the result to
* help prevent lock conflicts.
*/
export abstract class AsyncStore<T extends Object> extends EventEmitter {
private storeState: Readonly<T>;
private lock = new AwaitLock();
private readonly dispatcherRef: string;
/**
* Creates a new AsyncStore using the given dispatcher.
* @param {Dispatcher<ActionPayload>} dispatcher The dispatcher to rely upon.
* @param {T} initialState The initial state for the store.
*/
protected constructor(
private dispatcher: MatrixDispatcher,
initialState: T = <T>{},
) {
super();
this.dispatcherRef = dispatcher.register(this.onDispatch.bind(this));
this.storeState = initialState;
}
/**
* The current state of the store. Cannot be mutated.
*/
protected get state(): T {
return this.storeState;
}
/**
* Stops the store's listening functions, such as the listener to the dispatcher.
*/
protected stop(): void {
if (this.dispatcherRef) this.dispatcher.unregister(this.dispatcherRef);
}
/**
* Updates the state of the store.
* @param {T|*} newState The state to update in the store using Object.assign()
*/
protected async updateState(newState: T | Object): Promise<void> {
await this.lock.acquireAsync();
try {
this.storeState = Object.freeze(Object.assign(<T>{}, this.storeState, newState));
this.emit(UPDATE_EVENT, this);
} finally {
await this.lock.release();
}
}
/**
* Resets the store's to the provided state or an empty object.
* @param {T|*} newState The new state of the store.
* @param {boolean} quiet If true, the function will not raise an UPDATE_EVENT.
*/
protected async reset(newState: T | Object | null = null, quiet = false): Promise<void> {
await this.lock.acquireAsync();
try {
this.storeState = Object.freeze(<T>(newState || {}));
if (!quiet) this.emit(UPDATE_EVENT, this);
} finally {
await this.lock.release();
}
}
/**
* Called when the dispatcher broadcasts a dispatch event.
* @param {ActionPayload} payload The event being dispatched.
*/
protected abstract onDispatch(payload: ActionPayload): void;
}

View File

@@ -0,0 +1,60 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { AsyncStore } from "./AsyncStore";
import { ActionPayload } from "../dispatcher/payloads";
import { ReadyWatchingStore } from "./ReadyWatchingStore";
import { MatrixDispatcher } from "../dispatcher/dispatcher";
export abstract class AsyncStoreWithClient<T extends Object> extends AsyncStore<T> {
protected readyStore: ReadyWatchingStore;
protected constructor(dispatcher: MatrixDispatcher, initialState: T = <T>{}) {
super(dispatcher, initialState);
// Create an anonymous class to avoid code duplication
const asyncStore = this; // eslint-disable-line @typescript-eslint/no-this-alias
this.readyStore = new (class extends ReadyWatchingStore {
public get mxClient(): MatrixClient | null {
return this.matrixClient;
}
protected async onReady(): Promise<any> {
return asyncStore.onReady();
}
protected async onNotReady(): Promise<any> {
return asyncStore.onNotReady();
}
})(dispatcher);
}
public async start(): Promise<void> {
await this.readyStore.start();
}
public get matrixClient(): MatrixClient | null {
return this.readyStore.mxClient;
}
protected async onReady(): Promise<void> {
// Default implementation is to do nothing.
}
protected async onNotReady(): Promise<void> {
// Default implementation is to do nothing.
}
protected abstract onAction(payload: ActionPayload): Promise<void>;
protected async onDispatch(payload: ActionPayload): Promise<void> {
await this.onAction(payload);
}
}

View File

@@ -0,0 +1,202 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import {
ClientEvent,
MatrixEvent,
MatrixEventEvent,
SyncStateData,
SyncState,
ToDeviceMessageId,
} from "matrix-js-sdk/src/matrix";
import { sleep } from "matrix-js-sdk/src/utils";
import { v4 as uuidv4 } from "uuid";
import { logger } from "matrix-js-sdk/src/logger";
import SdkConfig from "../SdkConfig";
import sendBugReport from "../rageshake/submit-rageshake";
import defaultDispatcher from "../dispatcher/dispatcher";
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
import { ActionPayload } from "../dispatcher/payloads";
import SettingsStore from "../settings/SettingsStore";
import { Action } from "../dispatcher/actions";
// Minimum interval of 1 minute between reports
const RAGESHAKE_INTERVAL = 60000;
// Before rageshaking, wait 5 seconds and see if the message has successfully decrypted
const GRACE_PERIOD = 5000;
// Event type for to-device messages requesting sender auto-rageshakes
const AUTO_RS_REQUEST = "im.vector.auto_rs_request";
interface IState {
reportedSessionIds: Set<string>;
lastRageshakeTime: number;
initialSyncCompleted: boolean;
}
/**
* Watches for decryption errors to auto-report if the relevant lab is
* enabled, and keeps track of session IDs that have already been
* reported.
*/
export default class AutoRageshakeStore extends AsyncStoreWithClient<IState> {
private static readonly internalInstance = (() => {
const instance = new AutoRageshakeStore();
instance.start();
return instance;
})();
private constructor() {
super(defaultDispatcher, {
reportedSessionIds: new Set<string>(),
lastRageshakeTime: 0,
initialSyncCompleted: false,
});
this.onDecryptionAttempt = this.onDecryptionAttempt.bind(this);
this.onDeviceMessage = this.onDeviceMessage.bind(this);
this.onSyncStateChange = this.onSyncStateChange.bind(this);
}
public static get instance(): AutoRageshakeStore {
return AutoRageshakeStore.internalInstance;
}
protected async onAction(payload: ActionPayload): Promise<void> {
switch (payload.action) {
case Action.ReportKeyBackupNotEnabled:
this.onReportKeyBackupNotEnabled();
}
}
protected async onReady(): Promise<void> {
if (!SettingsStore.getValue("automaticDecryptionErrorReporting")) return;
if (this.matrixClient) {
this.matrixClient.on(MatrixEventEvent.Decrypted, this.onDecryptionAttempt);
this.matrixClient.on(ClientEvent.ToDeviceEvent, this.onDeviceMessage);
this.matrixClient.on(ClientEvent.Sync, this.onSyncStateChange);
}
}
protected async onNotReady(): Promise<void> {
if (this.matrixClient) {
this.matrixClient.removeListener(ClientEvent.ToDeviceEvent, this.onDeviceMessage);
this.matrixClient.removeListener(MatrixEventEvent.Decrypted, this.onDecryptionAttempt);
this.matrixClient.removeListener(ClientEvent.Sync, this.onSyncStateChange);
}
}
private async onDecryptionAttempt(ev: MatrixEvent): Promise<void> {
if (!this.state.initialSyncCompleted) {
return;
}
const wireContent = ev.getWireContent();
const sessionId = wireContent.session_id;
if (ev.isDecryptionFailure() && !this.state.reportedSessionIds.has(sessionId)) {
await sleep(GRACE_PERIOD);
if (!ev.isDecryptionFailure()) {
return;
}
const newReportedSessionIds = new Set(this.state.reportedSessionIds);
await this.updateState({ reportedSessionIds: newReportedSessionIds.add(sessionId) });
const now = new Date().getTime();
if (now - this.state.lastRageshakeTime < RAGESHAKE_INTERVAL) {
logger.info(
`Not sending recipient-side autorageshake for event ${ev.getId()}/session ${sessionId}: last rageshake was too recent`,
);
return;
}
await this.updateState({ lastRageshakeTime: now });
const senderUserId = ev.getSender()!;
const eventInfo = {
event_id: ev.getId(),
room_id: ev.getRoomId(),
session_id: sessionId,
device_id: wireContent.device_id,
user_id: senderUserId,
sender_key: wireContent.sender_key,
};
logger.info(`Sending recipient-side autorageshake for event ${ev.getId()}/session ${sessionId}`);
// XXX: the rageshake server returns the URL for the github issue... which is typically absent for
// auto-uisis, because we've disabled creation of GH issues for them. So the `recipient_rageshake`
// field is broken.
const rageshakeURL = await sendBugReport(SdkConfig.get().bug_report_endpoint_url, {
userText: "Auto-reporting decryption error (recipient)",
sendLogs: true,
labels: ["Z-UISI", "web", "uisi-recipient"],
customApp: SdkConfig.get().uisi_autorageshake_app,
customFields: { auto_uisi: JSON.stringify(eventInfo) },
});
const messageContent = {
...eventInfo,
recipient_rageshake: rageshakeURL,
[ToDeviceMessageId]: uuidv4(),
};
this.matrixClient?.sendToDevice(
AUTO_RS_REQUEST,
new Map([[senderUserId, new Map([[messageContent.device_id, messageContent]])]]),
);
}
}
private async onSyncStateChange(
_state: SyncState,
_prevState: SyncState | null,
data?: SyncStateData,
): Promise<void> {
if (!this.state.initialSyncCompleted) {
await this.updateState({ initialSyncCompleted: !!data?.nextSyncToken });
}
}
private async onDeviceMessage(ev: MatrixEvent): Promise<void> {
if (ev.getType() !== AUTO_RS_REQUEST) return;
const messageContent = ev.getContent();
const recipientRageshake = messageContent["recipient_rageshake"] || "";
const now = new Date().getTime();
if (now - this.state.lastRageshakeTime > RAGESHAKE_INTERVAL) {
await this.updateState({ lastRageshakeTime: now });
logger.info(
`Sending sender-side autorageshake for event ${messageContent["event_id"]}/session ${messageContent["session_id"]}`,
);
await sendBugReport(SdkConfig.get().bug_report_endpoint_url, {
userText: `Auto-reporting decryption error (sender)\nRecipient rageshake: ${recipientRageshake}`,
sendLogs: true,
labels: ["Z-UISI", "web", "uisi-sender"],
customApp: SdkConfig.get().uisi_autorageshake_app,
customFields: {
recipient_rageshake: recipientRageshake,
auto_uisi: JSON.stringify(messageContent),
},
});
} else {
logger.info(
`Not sending sender-side autorageshake for event ${messageContent["event_id"]}/session ${messageContent["session_id"]}: last rageshake was too recent`,
);
}
}
private async onReportKeyBackupNotEnabled(): Promise<void> {
if (!SettingsStore.getValue("automaticKeyBackNotEnabledReporting")) return;
await sendBugReport(SdkConfig.get().bug_report_endpoint_url, {
userText: `Auto-reporting key backup not enabled`,
sendLogs: true,
labels: ["web", Action.ReportKeyBackupNotEnabled],
});
}
}
window.mxAutoRageshakeStore = AutoRageshakeStore.instance;

View File

@@ -0,0 +1,190 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Room, RoomEvent, ClientEvent } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
import SettingsStore from "../settings/SettingsStore";
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
import defaultDispatcher from "../dispatcher/dispatcher";
import { arrayHasDiff, filterBoolean } from "../utils/arrays";
import { SettingLevel } from "../settings/SettingLevel";
import { Action } from "../dispatcher/actions";
import { SettingUpdatedPayload } from "../dispatcher/payloads/SettingUpdatedPayload";
import { ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload";
import { JoinRoomPayload } from "../dispatcher/payloads/JoinRoomPayload";
const MAX_ROOMS = 20; // arbitrary
const AUTOJOIN_WAIT_THRESHOLD_MS = 90000; // 90s, the time we wait for an autojoined room to show up
interface IState {
enabled?: boolean;
rooms?: Room[];
}
export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
private static readonly internalInstance = (() => {
const instance = new BreadcrumbsStore();
instance.start();
return instance;
})();
private waitingRooms: { roomId: string; addedTs: number }[] = [];
private constructor() {
super(defaultDispatcher);
SettingsStore.monitorSetting("breadcrumb_rooms", null);
SettingsStore.monitorSetting("breadcrumbs", null);
}
public static get instance(): BreadcrumbsStore {
return BreadcrumbsStore.internalInstance;
}
public get rooms(): Room[] {
return this.state.rooms || [];
}
public get visible(): boolean {
return !!this.state.enabled && this.meetsRoomRequirement;
}
/**
* Do we have enough rooms to justify showing the breadcrumbs?
* (Or is the labs feature enabled?)
*
* @returns true if there are at least 20 visible rooms.
*/
public get meetsRoomRequirement(): boolean {
const msc3946ProcessDynamicPredecessor = SettingsStore.getValue("feature_dynamic_room_predecessors");
return !!this.matrixClient && this.matrixClient.getVisibleRooms(msc3946ProcessDynamicPredecessor).length >= 20;
}
protected async onAction(payload: SettingUpdatedPayload | ViewRoomPayload | JoinRoomPayload): Promise<void> {
if (!this.matrixClient) return;
if (payload.action === Action.SettingUpdated) {
if (payload.settingName === "breadcrumb_rooms") {
await this.updateRooms();
} else if (payload.settingName === "breadcrumbs") {
await this.updateState({ enabled: SettingsStore.getValue("breadcrumbs", null) });
}
} else if (payload.action === Action.ViewRoom) {
if (payload.auto_join && payload.room_id && !this.matrixClient.getRoom(payload.room_id)) {
// Queue the room instead of pushing it immediately. We're probably just
// waiting for a room join to complete.
this.waitingRooms.push({ roomId: payload.room_id, addedTs: Date.now() });
} else {
// The tests might not result in a valid room object.
const room = this.matrixClient.getRoom(payload.room_id);
const membership = room?.getMyMembership();
if (room && membership === KnownMembership.Join) await this.appendRoom(room);
}
} else if (payload.action === Action.JoinRoom) {
const room = this.matrixClient.getRoom(payload.roomId);
if (room) await this.appendRoom(room);
}
}
protected async onReady(): Promise<void> {
await this.updateRooms();
await this.updateState({ enabled: SettingsStore.getValue("breadcrumbs", null) });
if (this.matrixClient) {
this.matrixClient.on(RoomEvent.MyMembership, this.onMyMembership);
this.matrixClient.on(ClientEvent.Room, this.onRoom);
}
}
protected async onNotReady(): Promise<void> {
if (this.matrixClient) {
this.matrixClient.removeListener(RoomEvent.MyMembership, this.onMyMembership);
this.matrixClient.removeListener(ClientEvent.Room, this.onRoom);
}
}
private onMyMembership = async (room: Room): Promise<void> => {
// Only turn on breadcrumbs is the user hasn't explicitly turned it off again.
const settingValueRaw = SettingsStore.getValue("breadcrumbs", null, /*excludeDefault=*/ true);
if (this.meetsRoomRequirement && isNullOrUndefined(settingValueRaw)) {
await SettingsStore.setValue("breadcrumbs", null, SettingLevel.ACCOUNT, true);
}
};
private onRoom = async (room: Room): Promise<void> => {
const waitingRoom = this.waitingRooms.find((r) => r.roomId === room.roomId);
if (!waitingRoom) return;
this.waitingRooms.splice(this.waitingRooms.indexOf(waitingRoom), 1);
if (Date.now() - waitingRoom.addedTs > AUTOJOIN_WAIT_THRESHOLD_MS) return; // Too long ago.
await this.appendRoom(room);
};
private async updateRooms(): Promise<void> {
let roomIds = SettingsStore.getValue<string[]>("breadcrumb_rooms");
if (!roomIds || roomIds.length === 0) roomIds = [];
const rooms = filterBoolean(roomIds.map((r) => this.matrixClient?.getRoom(r)));
const currentRooms = this.state.rooms || [];
if (!arrayHasDiff(rooms, currentRooms)) return; // no change (probably echo)
await this.updateState({ rooms });
}
private async appendRoom(room: Room): Promise<void> {
let updated = false;
const rooms = (this.state.rooms || []).slice(); // cheap clone
const msc3946ProcessDynamicPredecessor = SettingsStore.getValue("feature_dynamic_room_predecessors");
// If the room is upgraded, use that room instead. We'll also splice out
// any children of the room.
const history = this.matrixClient?.getRoomUpgradeHistory(room.roomId, false, msc3946ProcessDynamicPredecessor);
if (history && history.length > 1) {
room = history[history.length - 1]; // Last room is most recent in history
// Take out any room that isn't the most recent room
for (let i = 0; i < history.length - 1; i++) {
const idx = rooms.findIndex((r) => r.roomId === history[i].roomId);
if (idx !== -1) {
rooms.splice(idx, 1);
updated = true;
}
}
}
// Remove the existing room, if it is present
const existingIdx = rooms.findIndex((r) => r.roomId === room.roomId);
// If we're focusing on the first room no-op
if (existingIdx !== 0) {
if (existingIdx !== -1) {
rooms.splice(existingIdx, 1);
}
// Splice the room to the start of the list
rooms.splice(0, 0, room);
updated = true;
}
if (rooms.length > MAX_ROOMS) {
// This looks weird, but it's saying to start at the MAX_ROOMS point in the
// list and delete everything after it.
rooms.splice(MAX_ROOMS, rooms.length - MAX_ROOMS);
updated = true;
}
if (updated) {
// Update the breadcrumbs
await this.updateState({ rooms });
const roomIds = rooms.map((r) => r.roomId);
if (roomIds.length > 0) {
await SettingsStore.setValue("breadcrumb_rooms", null, SettingLevel.ACCOUNT, roomIds);
}
}
}
}

193
src/stores/CallStore.ts Normal file
View File

@@ -0,0 +1,193 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { logger } from "matrix-js-sdk/src/logger";
import { GroupCallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/groupCallEventHandler";
import { MatrixRTCSession, MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc";
import type { GroupCall, Room } from "matrix-js-sdk/src/matrix";
import defaultDispatcher from "../dispatcher/dispatcher";
import { UPDATE_EVENT } from "./AsyncStore";
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
import WidgetStore from "./WidgetStore";
import SettingsStore from "../settings/SettingsStore";
import { SettingLevel } from "../settings/SettingLevel";
import { Call, CallEvent, ConnectionState } from "../models/Call";
export enum CallStoreEvent {
// Signals a change in the call associated with a given room
Call = "call",
// Signals a change in the active calls
ConnectedCalls = "connected_calls",
}
export class CallStore extends AsyncStoreWithClient<{}> {
private static _instance: CallStore;
public static get instance(): CallStore {
if (!this._instance) {
this._instance = new CallStore();
this._instance.start();
}
return this._instance;
}
private constructor() {
super(defaultDispatcher);
this.setMaxListeners(100); // One for each RoomTile
}
protected async onAction(): Promise<void> {
// nothing to do
}
protected async onReady(): Promise<any> {
if (!this.matrixClient) return;
// We assume that the calls present in a room are a function of room
// widgets and group calls, so we initialize the room map here and then
// update it whenever those change
for (const room of this.matrixClient.getRooms()) {
this.updateRoom(room);
}
this.matrixClient.on(GroupCallEventHandlerEvent.Incoming, this.onGroupCall);
this.matrixClient.on(GroupCallEventHandlerEvent.Outgoing, this.onGroupCall);
this.matrixClient.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, this.onRTCSessionStart);
WidgetStore.instance.on(UPDATE_EVENT, this.onWidgets);
// If the room ID of a previously connected call is still in settings at
// this time, that's a sign that we failed to disconnect from it
// properly, and need to clean up after ourselves
const uncleanlyDisconnectedRoomIds = SettingsStore.getValue<string[]>("activeCallRoomIds");
if (uncleanlyDisconnectedRoomIds.length) {
await Promise.all([
...uncleanlyDisconnectedRoomIds.map(async (uncleanlyDisconnectedRoomId): Promise<void> => {
logger.log(`Cleaning up call state for room ${uncleanlyDisconnectedRoomId}`);
await this.getCall(uncleanlyDisconnectedRoomId)?.clean();
}),
SettingsStore.setValue("activeCallRoomIds", null, SettingLevel.DEVICE, []),
]);
}
}
protected async onNotReady(): Promise<any> {
for (const [call, listenerMap] of this.callListeners) {
// It's important that we remove the listeners before destroying the
// call, because otherwise the call's onDestroy callback would fire
// and immediately repopulate the map
for (const [event, listener] of listenerMap) call.off(event, listener);
call.destroy();
}
this.callListeners.clear();
this.calls.clear();
this._connectedCalls.clear();
if (this.matrixClient) {
this.matrixClient.off(GroupCallEventHandlerEvent.Incoming, this.onGroupCall);
this.matrixClient.off(GroupCallEventHandlerEvent.Outgoing, this.onGroupCall);
this.matrixClient.off(GroupCallEventHandlerEvent.Ended, this.onGroupCall);
this.matrixClient.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, this.onRTCSessionStart);
}
WidgetStore.instance.off(UPDATE_EVENT, this.onWidgets);
}
private _connectedCalls: Set<Call> = new Set();
/**
* The calls to which the user is currently connected.
*/
public get connectedCalls(): Set<Call> {
return this._connectedCalls;
}
private set connectedCalls(value: Set<Call>) {
this._connectedCalls = value;
this.emit(CallStoreEvent.ConnectedCalls, value);
// The room IDs are persisted to settings so we can detect unclean disconnects
SettingsStore.setValue(
"activeCallRoomIds",
null,
SettingLevel.DEVICE,
[...value].map((call) => call.roomId),
);
}
private calls = new Map<string, Call>(); // Key is room ID
private callListeners = new Map<Call, Map<CallEvent, (...args: unknown[]) => unknown>>();
private updateRoom(room: Room): void {
if (!this.calls.has(room.roomId)) {
const call = Call.get(room);
if (call) {
const onConnectionState = (state: ConnectionState): void => {
if (state === ConnectionState.Connected) {
this.connectedCalls = new Set([...this.connectedCalls, call]);
} else if (state === ConnectionState.Disconnected) {
this.connectedCalls = new Set([...this.connectedCalls].filter((c) => c !== call));
}
};
const onDestroy = (): void => {
this.calls.delete(room.roomId);
for (const [event, listener] of this.callListeners.get(call)!) call.off(event, listener);
this.updateRoom(room);
};
call.on(CallEvent.ConnectionState, onConnectionState);
call.on(CallEvent.Destroy, onDestroy);
this.calls.set(room.roomId, call);
this.callListeners.set(
call,
new Map<CallEvent, (...args: any[]) => unknown>([
[CallEvent.ConnectionState, onConnectionState],
[CallEvent.Destroy, onDestroy],
]),
);
}
this.emit(CallStoreEvent.Call, call, room.roomId);
}
}
/**
* Gets the call associated with the given room, if any.
* @param {string} roomId The room's ID.
* @returns {Call | null} The call.
*/
public getCall(roomId: string): Call | null {
return this.calls.get(roomId) ?? null;
}
/**
* Gets the active call associated with the given room, if any.
* @param roomId The room's ID.
* @returns The active call.
*/
public getActiveCall(roomId: string): Call | null {
const call = this.getCall(roomId);
return call !== null && this.connectedCalls.has(call) ? call : null;
}
private onWidgets = (roomId: string | null): void => {
if (!this.matrixClient) return;
if (roomId === null) {
// This store happened to start before the widget store was done
// loading all rooms, so we need to initialize each room again
for (const room of this.matrixClient.getRooms()) {
this.updateRoom(room);
}
} else {
const room = this.matrixClient.getRoom(roomId);
// Widget updates can arrive before the room does, empirically
if (room !== null) this.updateRoom(room);
}
};
private onGroupCall = (groupCall: GroupCall): void => this.updateRoom(groupCall.room);
private onRTCSessionStart = (roomId: string, session: MatrixRTCSession): void => {
this.updateRoom(session.room);
};
}

View File

@@ -0,0 +1,126 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2017-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { SyncState } from "matrix-js-sdk/src/matrix";
import { MINIMUM_MATRIX_VERSION, SUPPORTED_MATRIX_VERSIONS } from "matrix-js-sdk/src/version-support";
import { logger } from "matrix-js-sdk/src/logger";
import { Action } from "../dispatcher/actions";
import dis from "../dispatcher/dispatcher";
import { ActionPayload } from "../dispatcher/payloads";
import { DoAfterSyncPreparedPayload } from "../dispatcher/payloads/DoAfterSyncPreparedPayload";
import { AsyncStore } from "./AsyncStore";
import { MatrixClientPeg } from "../MatrixClientPeg";
import ToastStore from "./ToastStore";
import { _t } from "../languageHandler";
import SdkConfig from "../SdkConfig";
import GenericToast from "../components/views/toasts/GenericToast";
interface IState {
deferredAction: ActionPayload | null;
}
const INITIAL_STATE: IState = {
deferredAction: null,
};
/**
* A class for storing application state to do with authentication. This is a simple
* store that listens for actions and updates its state accordingly, informing any
* listeners (views) of state changes.
*/
class LifecycleStore extends AsyncStore<IState> {
public constructor() {
super(dis, INITIAL_STATE);
}
protected onDispatch(payload: ActionPayload | DoAfterSyncPreparedPayload<ActionPayload>): void {
switch (payload.action) {
case Action.DoAfterSyncPrepared:
this.updateState({
deferredAction: payload.deferred_action,
});
break;
case "cancel_after_sync_prepared":
this.updateState({
deferredAction: null,
});
break;
case "MatrixActions.sync": {
if (payload.state === SyncState.Syncing && payload.prevState !== SyncState.Syncing) {
// We've reconnected to the server: update server version support
// This is async but we don't care about the result, so just fire & forget.
checkServerVersions();
}
if (payload.state !== "PREPARED") {
break;
}
if (!this.state.deferredAction) break;
const deferredAction = Object.assign({}, this.state.deferredAction);
this.updateState({
deferredAction: null,
});
dis.dispatch(deferredAction);
break;
}
case "on_client_not_viable":
case Action.OnLoggedOut:
this.reset();
break;
}
}
}
async function checkServerVersions(): Promise<void> {
try {
const client = MatrixClientPeg.get();
if (!client) return;
for (const version of SUPPORTED_MATRIX_VERSIONS) {
// Check if the server supports this spec version. (`isVersionSupported` caches the response, so this loop will
// only make a single HTTP request).
// Note that although we do this on a reconnect, we cache the server's versions in memory
// indefinitely, so it will only ever trigger the toast on the first connection after a fresh
// restart of the client.
if (await client.isVersionSupported(version)) {
// we found a compatible spec version
return;
}
}
// This is retrospective doc having debated about the exactly what this toast is for, but
// our guess is that it's a nudge to update, or ask your HS admin to update your Homeserver
// after a new version of Element has come out, in a way that doesn't lock you out of all
// your messages.
const toastKey = "LEGACY_SERVER";
ToastStore.sharedInstance().addOrReplaceToast({
key: toastKey,
title: _t("unsupported_server_title"),
props: {
description: _t("unsupported_server_description", {
version: MINIMUM_MATRIX_VERSION,
brand: SdkConfig.get().brand,
}),
primaryLabel: _t("action|ok"),
onPrimaryClick: () => {
ToastStore.sharedInstance().dismissToast(toastKey);
},
},
component: GenericToast,
priority: 98,
});
} catch (e) {
logger.warn("Failed to check server versions", e);
}
}
let singletonLifecycleStore: LifecycleStore | null = null;
if (!singletonLifecycleStore) {
singletonLifecycleStore = new LifecycleStore();
}
export default singletonLifecycleStore!;

View File

@@ -0,0 +1,252 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Room, RoomMember } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import SettingsStore from "../settings/SettingsStore";
import { SdkContextClass } from "../contexts/SDKContext";
import SdkConfig from "../SdkConfig";
// Regex applied to filter our punctuation in member names before applying sort, to fuzzy it a little
// matches all ASCII punctuation: !"#$%&'()*+,-./:;<=>?@[\]^_`{|}~
const SORT_REGEX = /[\x21-\x2F\x3A-\x40\x5B-\x60\x7B-\x7E]+/g;
/**
* A class for storing application state for MemberList.
*/
export class MemberListStore {
// cache of Display Name -> name to sort based on. This strips out special symbols like @.
private readonly sortNames = new Map<string, string>();
// list of room IDs that have been lazy loaded
private readonly loadedRooms = new Set<string>();
private collator?: Intl.Collator;
public constructor(private readonly stores: SdkContextClass) {}
/**
* Load the member list. Call this whenever the list may have changed.
* @param roomId The room to load the member list in
* @param searchQuery Optional search query to filter the list.
* @returns A list of filtered and sorted room members, grouped by membership.
*/
public async loadMemberList(
roomId: string,
searchQuery?: string,
): Promise<Record<"joined" | "invited", RoomMember[]>> {
if (!this.stores.client) {
return {
joined: [],
invited: [],
};
}
const language = SettingsStore.getValue("language");
this.collator = new Intl.Collator(language, { sensitivity: "base", ignorePunctuation: false });
const members = await this.loadMembers(roomId);
// Filter then sort as it's more efficient than sorting tons of members we will just filter out later.
// Also sort each group, as there's no point comparing invited/joined users when they aren't in the same list!
const membersByMembership = this.filterMembers(members, searchQuery);
membersByMembership.joined.sort((a: RoomMember, b: RoomMember) => {
return this.sortMembers(a, b);
});
membersByMembership.invited.sort((a: RoomMember, b: RoomMember) => {
return this.sortMembers(a, b);
});
return {
joined: membersByMembership.joined,
invited: membersByMembership.invited,
};
}
private async loadMembers(roomId: string): Promise<Array<RoomMember>> {
const room = this.stores.client!.getRoom(roomId);
if (!room) {
return [];
}
if (!this.isLazyLoadingEnabled(roomId) || this.loadedRooms.has(roomId)) {
// nice and easy, we must already have all the members so just return them.
return this.loadMembersInRoom(room);
}
// lazy loading is enabled. There are two kinds of lazy loading:
// - With storage: most members are in indexedDB, we just need a small delta via /members.
// Valid for normal sync in normal windows.
// - Without storage: nothing in indexedDB, we need to load all via /members. Valid for
// Sliding Sync and incognito windows (non-Sliding Sync).
if (!this.isLazyMemberStorageEnabled()) {
// pull straight from the server. Don't use a since token as we don't have earlier deltas
// accumulated.
room.currentState.markOutOfBandMembersStarted();
const response = await this.stores.client!.members(roomId, undefined, KnownMembership.Leave);
const memberEvents = response.chunk.map(this.stores.client!.getEventMapper());
room.currentState.setOutOfBandMembers(memberEvents);
} else {
// load using traditional lazy loading
try {
await room.loadMembersIfNeeded();
} catch (ex) {
/* already logged in RoomView */
}
}
// remember that we have loaded the members so we don't hit /members all the time. We
// will forget this on refresh which is fine as we only store the data in-memory.
this.loadedRooms.add(roomId);
return this.loadMembersInRoom(room);
}
private loadMembersInRoom(room: Room): Array<RoomMember> {
const allMembers = Object.values(room.currentState.members);
allMembers.forEach((member) => {
// work around a race where you might have a room member object
// before the user object exists. This may or may not cause
// https://github.com/vector-im/vector-web/issues/186
if (!member.user) {
member.user = this.stores.client!.getUser(member.userId) || undefined;
}
// XXX: this user may have no lastPresenceTs value!
// the right solution here is to fix the race rather than leave it as 0
});
return allMembers;
}
/**
* Check if this room should be lazy loaded. Lazy loading means fetching the member list in
* a delayed or incremental fashion. It means the `Room` object doesn't have all the members.
* @param roomId The room to check if lazy loading is enabled
* @returns True if enabled
*/
private isLazyLoadingEnabled(roomId: string): boolean {
if (SettingsStore.getValue("feature_sliding_sync")) {
// only unencrypted rooms use lazy loading
return !this.stores.client!.isRoomEncrypted(roomId);
}
return this.stores.client!.hasLazyLoadMembersEnabled();
}
/**
* Check if lazy member storage is supported.
* @returns True if there is storage for lazy loading members
*/
private isLazyMemberStorageEnabled(): boolean {
if (SettingsStore.getValue("feature_sliding_sync")) {
return false;
}
return this.stores.client!.hasLazyLoadMembersEnabled();
}
public isPresenceEnabled(): boolean {
if (!this.stores.client) {
return true;
}
const enablePresenceByHsUrl = SdkConfig.get("enable_presence_by_hs_url");
return enablePresenceByHsUrl?.[this.stores.client!.baseUrl] ?? true;
}
/**
* Filter out members based on an optional search query. Groups by membership state.
* @param members The list of members to filter.
* @param query The textual query to filter based on.
* @returns An object with a list of joined and invited users respectively.
*/
private filterMembers(members: Array<RoomMember>, query?: string): Record<"joined" | "invited", RoomMember[]> {
const result: Record<"joined" | "invited", RoomMember[]> = {
joined: [],
invited: [],
};
members.forEach((m) => {
if (m.membership !== KnownMembership.Join && m.membership !== KnownMembership.Invite) {
return; // bail early for left/banned users
}
if (query) {
query = query.toLowerCase();
const matchesName = m.name.toLowerCase().includes(query);
const matchesId = m.userId.toLowerCase().includes(query);
if (!matchesName && !matchesId) {
return;
}
}
switch (m.membership) {
case KnownMembership.Join:
result.joined.push(m);
break;
case KnownMembership.Invite:
result.invited.push(m);
break;
}
});
return result;
}
/**
* Sort algorithm for room members.
* @param memberA
* @param memberB
* @returns Negative if A comes before B, 0 if A and B are equivalent, Positive is A comes after B.
*/
private sortMembers(memberA: RoomMember, memberB: RoomMember): number {
// order by presence, with "active now" first.
// ...and then by power level
// ...and then by last active
// ...and then alphabetically.
// We could tiebreak instead by "last recently spoken in this room" if we wanted to.
const userA = memberA.user;
const userB = memberB.user;
if (!userA && !userB) return 0;
if (userA && !userB) return -1;
if (!userA && userB) return 1;
const showPresence = this.isPresenceEnabled();
// First by presence
if (showPresence) {
const convertPresence = (p: string): string => (p === "unavailable" ? "online" : p);
const presenceIndex = (p: string): number => {
const order = ["active", "online", "offline"];
const idx = order.indexOf(convertPresence(p));
return idx === -1 ? order.length : idx; // unknown states at the end
};
const idxA = presenceIndex(userA!.currentlyActive ? "active" : userA!.presence);
const idxB = presenceIndex(userB!.currentlyActive ? "active" : userB!.presence);
if (idxA !== idxB) {
return idxA - idxB;
}
}
// Second by power level
if (memberA.powerLevel !== memberB.powerLevel) {
return memberB.powerLevel - memberA.powerLevel;
}
// Third by last active
if (showPresence && userA!.getLastActiveTs() !== userB!.getLastActiveTs()) {
return userB!.getLastActiveTs() - userA!.getLastActiveTs();
}
// Fourth by name (alphabetical)
return this.collator!.compare(this.canonicalisedName(memberA.name), this.canonicalisedName(memberB.name));
}
/**
* Calculate the canonicalised name for the input name.
* @param name The member display name
* @returns The name to sort on
*/
private canonicalisedName(name: string): string {
let result = this.sortNames.get(name);
if (result) {
return result;
}
result = (name[0] === "@" ? name.slice(1) : name).replace(SORT_REGEX, "");
this.sortNames.set(name, result);
return result;
}
}

View File

@@ -0,0 +1,100 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { IModalWidgetOpenRequestData, IModalWidgetReturnData, Widget } from "matrix-widget-api";
import { logger } from "matrix-js-sdk/src/logger";
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
import defaultDispatcher from "../dispatcher/dispatcher";
import { ActionPayload } from "../dispatcher/payloads";
import Modal, { IHandle, IModal } from "../Modal";
import ModalWidgetDialog from "../components/views/dialogs/ModalWidgetDialog";
import { WidgetMessagingStore } from "./widgets/WidgetMessagingStore";
interface IState {
modal?: IModal<any>;
openedFromId?: string;
}
export class ModalWidgetStore extends AsyncStoreWithClient<IState> {
private static readonly internalInstance = (() => {
const instance = new ModalWidgetStore();
instance.start();
return instance;
})();
private modalInstance: IHandle<typeof ModalWidgetDialog> | null = null;
private openSourceWidgetId: string | null = null;
private openSourceWidgetRoomId: string | null = null;
private constructor() {
super(defaultDispatcher, {});
}
public static get instance(): ModalWidgetStore {
return ModalWidgetStore.internalInstance;
}
protected async onAction(payload: ActionPayload): Promise<any> {
// nothing
}
public canOpenModalWidget = (): boolean => {
return !this.modalInstance;
};
public openModalWidget = (
requestData: IModalWidgetOpenRequestData,
sourceWidget: Widget,
widgetRoomId?: string,
): void => {
if (this.modalInstance) return;
this.openSourceWidgetId = sourceWidget.id;
this.openSourceWidgetRoomId = widgetRoomId ?? null;
this.modalInstance = Modal.createDialog(
ModalWidgetDialog,
{
widgetDefinition: { ...requestData },
widgetRoomId,
sourceWidgetId: sourceWidget.id,
onFinished: (success, data) => {
this.closeModalWidget(sourceWidget, widgetRoomId, success && data ? data : { "m.exited": true });
this.openSourceWidgetId = null;
this.openSourceWidgetRoomId = null;
this.modalInstance = null;
},
},
undefined,
/* priority = */ false,
/* static = */ true,
);
};
public closeModalWidget = (
sourceWidget: Widget,
widgetRoomId: string | undefined,
data: IModalWidgetReturnData,
): void => {
if (!this.modalInstance) return;
if (this.openSourceWidgetId === sourceWidget.id && this.openSourceWidgetRoomId === widgetRoomId) {
this.openSourceWidgetId = null;
this.openSourceWidgetRoomId = null;
this.modalInstance.close();
this.modalInstance = null;
const sourceMessaging = WidgetMessagingStore.instance.getMessaging(sourceWidget, widgetRoomId);
if (!sourceMessaging) {
logger.error("No source widget messaging for modal widget");
return;
}
sourceMessaging.notifyModalWidgetClose(data);
}
};
}
window.mxModalWidgetStore = ModalWidgetStore.instance;

View File

@@ -0,0 +1,43 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import EventEmitter from "events";
import { ComponentClass } from "../@types/common";
import { UPDATE_EVENT } from "./AsyncStore";
export type ToastReference = symbol;
export default class NonUrgentToastStore extends EventEmitter {
private static _instance: NonUrgentToastStore;
private toasts = new Map<ToastReference, ComponentClass>();
public static get instance(): NonUrgentToastStore {
if (!NonUrgentToastStore._instance) {
NonUrgentToastStore._instance = new NonUrgentToastStore();
}
return NonUrgentToastStore._instance;
}
public get components(): ComponentClass[] {
return Array.from(this.toasts.values());
}
public addToast(c: ComponentClass): ToastReference {
const ref: ToastReference = Symbol();
this.toasts.set(ref, c);
this.emit(UPDATE_EVENT);
return ref;
}
public removeToast(ref: ToastReference): void {
this.toasts.delete(ref);
this.emit(UPDATE_EVENT);
}
}

View File

@@ -0,0 +1,627 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { debounce } from "lodash";
import {
Beacon,
BeaconIdentifier,
BeaconEvent,
MatrixEvent,
Room,
RoomMember,
RoomState,
RoomStateEvent,
ContentHelpers,
MBeaconInfoEventContent,
M_BEACON,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { logger } from "matrix-js-sdk/src/logger";
import defaultDispatcher from "../dispatcher/dispatcher";
import { ActionPayload } from "../dispatcher/payloads";
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
import { arrayDiff } from "../utils/arrays";
import {
ClearWatchCallback,
GeolocationError,
mapGeolocationPositionToTimedGeo,
sortBeaconsByLatestCreation,
TimedGeoUri,
watchPosition,
getCurrentPosition,
} from "../utils/beacon";
import { doMaybeLocalRoomAction } from "../utils/local-room";
import SettingsStore from "../settings/SettingsStore";
const isOwnBeacon = (beacon: Beacon, userId: string): boolean => beacon.beaconInfoOwner === userId;
export enum OwnBeaconStoreEvent {
LivenessChange = "OwnBeaconStore.LivenessChange",
MonitoringLivePosition = "OwnBeaconStore.MonitoringLivePosition",
LocationPublishError = "LocationPublishError",
BeaconUpdateError = "BeaconUpdateError",
}
const MOVING_UPDATE_INTERVAL = 5000;
const STATIC_UPDATE_INTERVAL = 30000;
const BAIL_AFTER_CONSECUTIVE_ERROR_COUNT = 2;
type OwnBeaconStoreState = {
beacons: Map<BeaconIdentifier, Beacon>;
beaconLocationPublishErrorCounts: Map<BeaconIdentifier, number>;
beaconUpdateErrors: Map<BeaconIdentifier, Error>;
beaconsByRoomId: Map<Room["roomId"], Set<BeaconIdentifier>>;
liveBeaconIds: BeaconIdentifier[];
};
const CREATED_BEACONS_KEY = "mx_live_beacon_created_id";
const removeLocallyCreateBeaconEventId = (eventId: string): void => {
const ids = getLocallyCreatedBeaconEventIds();
window.localStorage.setItem(CREATED_BEACONS_KEY, JSON.stringify(ids.filter((id) => id !== eventId)));
};
const storeLocallyCreateBeaconEventId = (eventId: string): void => {
const ids = getLocallyCreatedBeaconEventIds();
window.localStorage.setItem(CREATED_BEACONS_KEY, JSON.stringify([...ids, eventId]));
};
const getLocallyCreatedBeaconEventIds = (): string[] => {
let ids: string[];
try {
ids = JSON.parse(window.localStorage.getItem(CREATED_BEACONS_KEY) ?? "[]");
if (!Array.isArray(ids)) {
throw new Error("Invalid stored value");
}
} catch (error) {
logger.error("Failed to retrieve locally created beacon event ids", error);
ids = [];
}
return ids;
};
export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
private static readonly internalInstance = (() => {
const instance = new OwnBeaconStore();
instance.start();
return instance;
})();
// users beacons, keyed by event type
public readonly beacons = new Map<BeaconIdentifier, Beacon>();
public readonly beaconsByRoomId = new Map<Room["roomId"], Set<BeaconIdentifier>>();
/**
* Track over the wire errors for published positions
* Counts consecutive wire errors per beacon
* Reset on successful publish of location
*/
public readonly beaconLocationPublishErrorCounts = new Map<BeaconIdentifier, number>();
public readonly beaconUpdateErrors = new Map<BeaconIdentifier, unknown>();
/**
* ids of live beacons
* ordered by creation time descending
*/
private liveBeaconIds: BeaconIdentifier[] = [];
private locationInterval?: number;
private clearPositionWatch?: ClearWatchCallback;
/**
* Track when the last position was published
* So we can manually get position on slow interval
* when the target is stationary
*/
private lastPublishedPositionTimestamp?: number;
/**
* Ref returned from watchSetting for the MSC3946 labs flag
*/
private dynamicWatcherRef: string | undefined;
public constructor() {
super(defaultDispatcher);
}
public static get instance(): OwnBeaconStore {
return OwnBeaconStore.internalInstance;
}
/**
* True when we have live beacons
* and geolocation.watchPosition is active
*/
public get isMonitoringLiveLocation(): boolean {
return !!this.clearPositionWatch;
}
protected async onNotReady(): Promise<void> {
if (this.matrixClient) {
this.matrixClient.removeListener(BeaconEvent.LivenessChange, this.onBeaconLiveness);
this.matrixClient.removeListener(BeaconEvent.New, this.onNewBeacon);
this.matrixClient.removeListener(BeaconEvent.Update, this.onUpdateBeacon);
this.matrixClient.removeListener(BeaconEvent.Destroy, this.onDestroyBeacon);
this.matrixClient.removeListener(RoomStateEvent.Members, this.onRoomStateMembers);
}
SettingsStore.unwatchSetting(this.dynamicWatcherRef ?? "");
this.clearBeacons();
}
private clearBeacons(): void {
this.beacons.forEach((beacon) => beacon.destroy());
this.stopPollingLocation();
this.beacons.clear();
this.beaconsByRoomId.clear();
this.liveBeaconIds = [];
this.beaconLocationPublishErrorCounts.clear();
this.beaconUpdateErrors.clear();
}
protected async onReady(): Promise<void> {
if (this.matrixClient) {
this.matrixClient.on(BeaconEvent.LivenessChange, this.onBeaconLiveness);
this.matrixClient.on(BeaconEvent.New, this.onNewBeacon);
this.matrixClient.on(BeaconEvent.Update, this.onUpdateBeacon);
this.matrixClient.on(BeaconEvent.Destroy, this.onDestroyBeacon);
this.matrixClient.on(RoomStateEvent.Members, this.onRoomStateMembers);
}
this.dynamicWatcherRef = SettingsStore.watchSetting(
"feature_dynamic_room_predecessors",
null,
this.reinitialiseBeaconState,
);
this.initialiseBeaconState();
}
protected async onAction(payload: ActionPayload): Promise<void> {
// we don't actually do anything here
}
public hasLiveBeacons = (roomId?: string): boolean => {
return !!this.getLiveBeaconIds(roomId).length;
};
/**
* Some live beacon has a wire error
* Optionally filter by room
*/
public hasLocationPublishErrors = (roomId?: string): boolean => {
return this.getLiveBeaconIds(roomId).some(this.beaconHasLocationPublishError);
};
/**
* If a beacon has failed to publish position
* past the allowed consecutive failure count (BAIL_AFTER_CONSECUTIVE_ERROR_COUNT)
* Then consider it to have an error
*/
public beaconHasLocationPublishError = (beaconId: string): boolean => {
const counts = this.beaconLocationPublishErrorCounts.get(beaconId);
return counts !== undefined && counts >= BAIL_AFTER_CONSECUTIVE_ERROR_COUNT;
};
public resetLocationPublishError = (beaconId: string): void => {
this.incrementBeaconLocationPublishErrorCount(beaconId, false);
// always publish to all live beacons together
// instead of just one that was changed
// to keep lastPublishedTimestamp simple
// and extra published locations don't hurt
this.publishCurrentLocationToBeacons();
};
public getLiveBeaconIds = (roomId?: string): string[] => {
if (!roomId) {
return this.liveBeaconIds;
}
return this.liveBeaconIds.filter((beaconId) => this.beaconsByRoomId.get(roomId)?.has(beaconId));
};
public getLiveBeaconIdsWithLocationPublishError = (roomId?: string): string[] => {
return this.getLiveBeaconIds(roomId).filter(this.beaconHasLocationPublishError);
};
public getBeaconById = (beaconId: string): Beacon | undefined => {
return this.beacons.get(beaconId);
};
public stopBeacon = async (beaconIdentifier: string): Promise<void> => {
const beacon = this.beacons.get(beaconIdentifier);
// if no beacon, or beacon is already explicitly set isLive: false
// do nothing
if (!beacon?.beaconInfo?.live) {
return;
}
await this.updateBeaconEvent(beacon, { live: false });
// prune from local store
removeLocallyCreateBeaconEventId(beacon.beaconInfoId);
};
/**
* Listeners
*/
private onNewBeacon = (_event: MatrixEvent, beacon: Beacon): void => {
if (!this.matrixClient || !isOwnBeacon(beacon, this.matrixClient.getUserId()!)) {
return;
}
this.addBeacon(beacon);
this.checkLiveness();
};
/**
* This will be called when a beacon is replaced
*/
private onUpdateBeacon = (_event: MatrixEvent, beacon: Beacon): void => {
if (!this.matrixClient || !isOwnBeacon(beacon, this.matrixClient.getUserId()!)) {
return;
}
this.checkLiveness();
beacon.monitorLiveness();
};
private onDestroyBeacon = (beaconIdentifier: BeaconIdentifier): void => {
// check if we care about this beacon
if (!this.beacons.has(beaconIdentifier)) {
return;
}
this.checkLiveness();
};
private onBeaconLiveness = (isLive: boolean, beacon: Beacon): void => {
// check if we care about this beacon
if (!this.beacons.has(beacon.identifier)) {
return;
}
// beacon expired, update beacon to un-alive state
if (!isLive) {
this.stopBeacon(beacon.identifier);
}
this.checkLiveness();
this.emit(OwnBeaconStoreEvent.LivenessChange, this.getLiveBeaconIds());
};
/**
* Check for changes in membership in rooms with beacons
* and stop monitoring beacons in rooms user is no longer member of
*/
private onRoomStateMembers = (_event: MatrixEvent, roomState: RoomState, member: RoomMember): void => {
// no beacons for this room, ignore
if (
!this.matrixClient ||
!this.beaconsByRoomId.has(roomState.roomId) ||
member.userId !== this.matrixClient.getUserId()
) {
return;
}
// TODO check powerlevels here
// in PSF-797
// stop watching beacons in rooms where user is no longer a member
if (member.membership === KnownMembership.Leave || member.membership === KnownMembership.Ban) {
this.beaconsByRoomId.get(roomState.roomId)?.forEach(this.removeBeacon);
this.beaconsByRoomId.delete(roomState.roomId);
}
};
/**
* State management
*/
/**
* Live beacon ids that do not have wire errors
*/
private get healthyLiveBeaconIds(): string[] {
return this.liveBeaconIds.filter(
(beaconId) => !this.beaconHasLocationPublishError(beaconId) && !this.beaconUpdateErrors.has(beaconId),
);
}
/**
* @internal public for test only
*/
public reinitialiseBeaconState = (): void => {
this.clearBeacons();
this.initialiseBeaconState();
};
private initialiseBeaconState = (): void => {
if (!this.matrixClient) return;
const userId = this.matrixClient.getSafeUserId();
const visibleRooms = this.matrixClient.getVisibleRooms(
SettingsStore.getValue("feature_dynamic_room_predecessors"),
);
visibleRooms.forEach((room) => {
const roomState = room.currentState;
const beacons = roomState.beacons;
const ownBeaconsArray = [...beacons.values()].filter((beacon) => isOwnBeacon(beacon, userId));
ownBeaconsArray.forEach((beacon) => this.addBeacon(beacon));
});
this.checkLiveness();
};
private addBeacon = (beacon: Beacon): void => {
this.beacons.set(beacon.identifier, beacon);
if (!this.beaconsByRoomId.has(beacon.roomId)) {
this.beaconsByRoomId.set(beacon.roomId, new Set<string>());
}
this.beaconsByRoomId.get(beacon.roomId)!.add(beacon.identifier);
beacon.monitorLiveness();
};
/**
* Remove listeners for a given beacon
* remove from state
* and update liveness if changed
*/
private removeBeacon = (beaconId: string): void => {
if (!this.beacons.has(beaconId)) {
return;
}
this.beacons.get(beaconId)!.destroy();
this.beacons.delete(beaconId);
this.checkLiveness();
};
private checkLiveness = (): void => {
const locallyCreatedBeaconEventIds = getLocallyCreatedBeaconEventIds();
const prevLiveBeaconIds = this.getLiveBeaconIds();
this.liveBeaconIds = [...this.beacons.values()]
.filter(
(beacon) =>
beacon.isLive &&
// only beacons created on this device should be shared to
locallyCreatedBeaconEventIds.includes(beacon.beaconInfoId),
)
.sort(sortBeaconsByLatestCreation)
.map((beacon) => beacon.identifier);
const diff = arrayDiff(prevLiveBeaconIds, this.liveBeaconIds);
if (diff.added.length || diff.removed.length) {
this.emit(OwnBeaconStoreEvent.LivenessChange, this.liveBeaconIds);
}
// publish current location immediately
// when there are new live beacons
// and we already have a live monitor
// so first position is published quickly
// even when target is stationary
//
// when there is no existing live monitor
// it will be created below by togglePollingLocation
// and publish first position quickly
if (diff.added.length && this.isMonitoringLiveLocation) {
this.publishCurrentLocationToBeacons();
}
// if overall liveness changed
if (!!prevLiveBeaconIds?.length !== !!this.liveBeaconIds.length) {
this.togglePollingLocation();
}
};
public createLiveBeacon = async (
roomId: Room["roomId"],
beaconInfoContent: MBeaconInfoEventContent,
): Promise<void> => {
if (!this.matrixClient) return;
// explicitly stop any live beacons this user has
// to ensure they remain stopped
// if the new replacing beacon is redacted
const existingLiveBeaconIdsForRoom = this.getLiveBeaconIds(roomId);
await Promise.all(existingLiveBeaconIdsForRoom.map((beaconId) => this.stopBeacon(beaconId)));
// eslint-disable-next-line camelcase
const { event_id } = await doMaybeLocalRoomAction(
roomId,
(actualRoomId: string) => this.matrixClient!.unstable_createLiveBeacon(actualRoomId, beaconInfoContent),
this.matrixClient,
);
storeLocallyCreateBeaconEventId(event_id);
};
/**
* Geolocation
*/
private togglePollingLocation = (): void => {
if (!!this.liveBeaconIds.length) {
this.startPollingLocation();
} else {
this.stopPollingLocation();
}
};
private startPollingLocation = async (): Promise<void> => {
// clear any existing interval
this.stopPollingLocation();
try {
this.clearPositionWatch = watchPosition(this.onWatchedPosition, this.onGeolocationError);
} catch (error) {
if (error instanceof Error) {
this.onGeolocationError(error.message as GeolocationError);
} else {
console.error("Unexpected error", error);
}
// don't set locationInterval if geolocation failed to setup
return;
}
this.locationInterval = window.setInterval(() => {
if (!this.lastPublishedPositionTimestamp) {
return;
}
// if position was last updated STATIC_UPDATE_INTERVAL ms ago or more
// get our position and publish it
if (this.lastPublishedPositionTimestamp <= Date.now() - STATIC_UPDATE_INTERVAL) {
this.publishCurrentLocationToBeacons();
}
}, STATIC_UPDATE_INTERVAL);
this.emit(OwnBeaconStoreEvent.MonitoringLivePosition);
};
private stopPollingLocation = (): void => {
clearInterval(this.locationInterval);
this.locationInterval = undefined;
this.lastPublishedPositionTimestamp = undefined;
if (this.clearPositionWatch) {
this.clearPositionWatch();
this.clearPositionWatch = undefined;
}
this.emit(OwnBeaconStoreEvent.MonitoringLivePosition);
};
private onWatchedPosition = (position: GeolocationPosition): void => {
const timedGeoPosition = mapGeolocationPositionToTimedGeo(position);
// if this is our first position, publish immediately
if (!this.lastPublishedPositionTimestamp) {
this.publishLocationToBeacons(timedGeoPosition);
} else {
this.debouncedPublishLocationToBeacons(timedGeoPosition);
}
};
private onGeolocationError = async (error: GeolocationError): Promise<void> => {
logger.error("Geolocation failed", error);
// other errors are considered non-fatal
// and self recovering
if (![GeolocationError.Unavailable, GeolocationError.PermissionDenied].includes(error)) {
return;
}
this.stopPollingLocation();
// kill live beacons when location permissions are revoked
await Promise.all(this.liveBeaconIds.map(this.stopBeacon));
};
/**
* Gets the current location
* (as opposed to using watched location)
* and publishes it to all live beacons
*/
private publishCurrentLocationToBeacons = async (): Promise<void> => {
try {
const position = await getCurrentPosition();
this.publishLocationToBeacons(mapGeolocationPositionToTimedGeo(position));
} catch (error) {
if (error instanceof Error) {
this.onGeolocationError(error.message as GeolocationError);
} else {
console.error("Unexpected error", error);
}
}
};
/**
* MatrixClient api
*/
/**
* Updates beacon with provided content update
* Records error in beaconUpdateErrors
* rethrows
*/
private updateBeaconEvent = async (
beacon: Beacon,
update: Partial<ContentHelpers.BeaconInfoState>,
): Promise<void> => {
const { description, timeout, timestamp, live, assetType } = {
...beacon.beaconInfo,
...update,
};
const updateContent = ContentHelpers.makeBeaconInfoContent(timeout, live, description, assetType, timestamp);
try {
await this.matrixClient!.unstable_setLiveBeacon(beacon.roomId, updateContent);
// cleanup any errors
const hadError = this.beaconUpdateErrors.has(beacon.identifier);
if (hadError) {
this.beaconUpdateErrors.delete(beacon.identifier);
this.emit(OwnBeaconStoreEvent.BeaconUpdateError, beacon.identifier, false);
}
} catch (error) {
logger.error("Failed to update beacon", error);
this.beaconUpdateErrors.set(beacon.identifier, error);
this.emit(OwnBeaconStoreEvent.BeaconUpdateError, beacon.identifier, true);
throw error;
}
};
/**
* Sends m.location events to all live beacons
* Sets last published beacon
*/
private publishLocationToBeacons = async (position: TimedGeoUri): Promise<void> => {
this.lastPublishedPositionTimestamp = Date.now();
await Promise.all(
this.healthyLiveBeaconIds.map((beaconId) =>
this.beacons.has(beaconId) ? this.sendLocationToBeacon(this.beacons.get(beaconId)!, position) : null,
),
);
};
private debouncedPublishLocationToBeacons = debounce(this.publishLocationToBeacons, MOVING_UPDATE_INTERVAL);
/**
* Sends m.location event to referencing given beacon
*/
private sendLocationToBeacon = async (beacon: Beacon, { geoUri, timestamp }: TimedGeoUri): Promise<void> => {
const content = ContentHelpers.makeBeaconContent(geoUri, timestamp, beacon.beaconInfoId);
try {
await this.matrixClient!.sendEvent(beacon.roomId, M_BEACON.name, content);
this.incrementBeaconLocationPublishErrorCount(beacon.identifier, false);
} catch (error) {
logger.error(error);
this.incrementBeaconLocationPublishErrorCount(beacon.identifier, true);
}
};
/**
* Manage beacon wire error count
* - clear count for beacon when not error
* - increment count for beacon when is error
* - emit if beacon error count crossed threshold
*/
private incrementBeaconLocationPublishErrorCount = (beaconId: string, isError: boolean): void => {
const hadError = this.beaconHasLocationPublishError(beaconId);
if (isError) {
// increment error count
this.beaconLocationPublishErrorCounts.set(
beaconId,
(this.beaconLocationPublishErrorCounts.get(beaconId) ?? 0) + 1,
);
} else {
// clear any error count
this.beaconLocationPublishErrorCounts.delete(beaconId);
}
if (this.beaconHasLocationPublishError(beaconId) !== hadError) {
this.emit(OwnBeaconStoreEvent.LocationPublishError, beaconId);
}
};
}

View File

@@ -0,0 +1,177 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MatrixEvent, RoomStateEvent, MatrixError, User, UserEvent, EventType } from "matrix-js-sdk/src/matrix";
import { throttle } from "lodash";
import { ActionPayload } from "../dispatcher/payloads";
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
import defaultDispatcher from "../dispatcher/dispatcher";
import { MatrixClientPeg } from "../MatrixClientPeg";
import { _t } from "../languageHandler";
import { mediaFromMxc } from "../customisations/Media";
interface IState {
displayName?: string;
avatarUrl?: string;
fetchedAt?: number;
}
const KEY_DISPLAY_NAME = "mx_profile_displayname";
const KEY_AVATAR_URL = "mx_profile_avatar_url";
export class OwnProfileStore extends AsyncStoreWithClient<IState> {
private static readonly internalInstance = (() => {
const instance = new OwnProfileStore();
instance.start();
return instance;
})();
private monitoredUser: User | null = null;
public constructor() {
// seed from localstorage because otherwise we won't get these values until a whole network
// round-trip after the client is ready, and we often load widgets in that time, and we'd
// and up passing them an incorrect display name
super(defaultDispatcher, {
displayName: window.localStorage.getItem(KEY_DISPLAY_NAME) || undefined,
avatarUrl: window.localStorage.getItem(KEY_AVATAR_URL) || undefined,
});
}
public static get instance(): OwnProfileStore {
return OwnProfileStore.internalInstance;
}
/**
* Gets the display name for the user, or null if not present.
*/
public get displayName(): string | null {
if (!this.matrixClient) return this.state.displayName || null;
if (this.matrixClient.isGuest()) {
return _t("common|guest");
} else if (this.state.displayName) {
return this.state.displayName;
} else {
return this.matrixClient.getUserId();
}
}
public get isProfileInfoFetched(): boolean {
return !!this.state.fetchedAt;
}
/**
* Gets the MXC URI of the user's avatar, or null if not present.
*/
public get avatarMxc(): string | null {
return this.state.avatarUrl || null;
}
/**
* Gets the user's avatar as an HTTP URL of the given size. If the user's
* avatar is not present, this returns null.
* @param size The size of the avatar. If zero, a full res copy of the avatar
* will be returned as an HTTP URL.
* @returns The HTTP URL of the user's avatar
*/
public getHttpAvatarUrl(size = 0): string | null {
if (!this.avatarMxc) return null;
const media = mediaFromMxc(this.avatarMxc);
if (!size || size <= 0) {
return media.srcHttp;
} else {
return media.getSquareThumbnailHttp(size);
}
}
protected async onNotReady(): Promise<void> {
this.onProfileUpdate.cancel();
if (this.monitoredUser) {
this.monitoredUser.removeListener(UserEvent.DisplayName, this.onProfileUpdate);
this.monitoredUser.removeListener(UserEvent.AvatarUrl, this.onProfileUpdate);
}
this.matrixClient?.removeListener(RoomStateEvent.Events, this.onStateEvents);
await this.reset({});
}
protected async onReady(): Promise<void> {
if (!this.matrixClient) return;
const myUserId = this.matrixClient.getSafeUserId();
this.monitoredUser = this.matrixClient.getUser(myUserId);
if (this.monitoredUser) {
this.monitoredUser.on(UserEvent.DisplayName, this.onProfileUpdate);
this.monitoredUser.on(UserEvent.AvatarUrl, this.onProfileUpdate);
}
// We also have to listen for membership events for ourselves as the above User events
// are fired only with presence, which matrix.org (and many others) has disabled.
this.matrixClient.on(RoomStateEvent.Events, this.onStateEvents);
await this.onProfileUpdate(); // trigger an initial update
}
protected async onAction(payload: ActionPayload): Promise<void> {
// we don't actually do anything here
}
private onProfileUpdate = throttle(
async (): Promise<void> => {
if (!this.matrixClient) return;
// We specifically do not use the User object we stored for profile info as it
// could easily be wrong (such as per-room instead of global profile).
let profileInfo: { displayname?: string; avatar_url?: string } = {
displayname: undefined,
avatar_url: undefined,
};
try {
profileInfo = await this.matrixClient.getProfileInfo(this.matrixClient.getSafeUserId());
} catch (error: unknown) {
if (!(error instanceof MatrixError) || error.errcode !== "M_NOT_FOUND") {
/**
* Raise any other error than M_NOT_FOUND.
* M_NOT_FOUND could occur if there is no user profile.
* {@link https://spec.matrix.org/v1.7/client-server-api/#get_matrixclientv3profileuserid}
* We should then assume an empty profile, emit UPDATE_EVENT etc..
*/
throw error;
}
}
if (profileInfo.displayname) {
window.localStorage.setItem(KEY_DISPLAY_NAME, profileInfo.displayname);
} else {
window.localStorage.removeItem(KEY_DISPLAY_NAME);
}
if (profileInfo.avatar_url) {
window.localStorage.setItem(KEY_AVATAR_URL, profileInfo.avatar_url);
} else {
window.localStorage.removeItem(KEY_AVATAR_URL);
}
await this.updateState({
displayName: profileInfo.displayname,
avatarUrl: profileInfo.avatar_url,
fetchedAt: Date.now(),
});
},
200,
{ trailing: true, leading: true },
);
private onStateEvents = async (ev: MatrixEvent): Promise<void> => {
const myUserId = MatrixClientPeg.safeGet().getUserId();
if (ev.getType() === EventType.RoomMember && ev.getSender() === myUserId && ev.getStateKey() === myUserId) {
await this.onProfileUpdate();
}
};
}

View File

@@ -0,0 +1,87 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 2021, 2022 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
* Please see LICENSE files in the repository root for full details.
*/
import { MatrixClient, SyncState } from "matrix-js-sdk/src/matrix";
import { EventEmitter } from "events";
import { MatrixClientPeg } from "../MatrixClientPeg";
import { ActionPayload } from "../dispatcher/payloads";
import { IDestroyable } from "../utils/IDestroyable";
import { Action } from "../dispatcher/actions";
import { MatrixDispatcher } from "../dispatcher/dispatcher";
export abstract class ReadyWatchingStore extends EventEmitter implements IDestroyable {
protected matrixClient: MatrixClient | null = null;
private dispatcherRef: string | null = null;
public constructor(protected readonly dispatcher: MatrixDispatcher) {
super();
}
public async start(): Promise<void> {
this.dispatcherRef = this.dispatcher.register(this.onAction);
// MatrixClientPeg can be undefined in tests because of circular dependencies with other stores
const matrixClient = MatrixClientPeg?.get();
if (matrixClient) {
this.matrixClient = matrixClient;
await this.onReady();
}
}
public get mxClient(): MatrixClient | null {
return this.matrixClient; // for external readonly access
}
public useUnitTestClient(cli: MatrixClient): void {
this.matrixClient = cli;
}
public destroy(): void {
if (this.dispatcherRef !== null) this.dispatcher.unregister(this.dispatcherRef);
}
protected async onReady(): Promise<void> {
// Default implementation is to do nothing.
}
protected async onNotReady(): Promise<void> {
// Default implementation is to do nothing.
}
protected onDispatcherAction(payload: ActionPayload): void {
// Default implementation is to do nothing.
}
private onAction = async (payload: ActionPayload): Promise<void> => {
this.onDispatcherAction(payload);
if (payload.action === "MatrixActions.sync") {
// Only set the client on the transition into the PREPARED state.
// Everything after this is unnecessary (we only need to know once we have a client)
// and we intentionally don't set the client before this point to avoid stores
// updating for every event emitted during the cached sync.
if (
payload.prevState !== SyncState.Prepared &&
payload.state === SyncState.Prepared &&
this.matrixClient !== payload.matrixClient
) {
if (this.matrixClient) {
await this.onNotReady();
}
this.matrixClient = payload.matrixClient;
await this.onReady();
}
} else if (payload.action === "on_client_not_viable" || payload.action === Action.OnLoggedOut) {
if (this.matrixClient) {
await this.onNotReady();
this.matrixClient = null;
}
}
};
}

View File

@@ -0,0 +1,168 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 2024 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
* Please see LICENSE files in the repository root for full details.
*/
import { TypedEventEmitter } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { cloneDeep } from "lodash";
import SettingsStore from "../settings/SettingsStore";
import { SettingLevel } from "../settings/SettingLevel";
import { Features } from "../settings/Settings";
/**
* The features are shown in the array order.
*/
const FEATURES = ["threadsActivityCentre", "pinningMessageList"] as const;
/**
* All the features that can be shown in the release announcements.
*/
export type Feature = (typeof FEATURES)[number];
/**
* The stored settings for the release announcements.
* The boolean is at true when the user has viewed the feature
*/
type StoredSettings = Record<Feature, boolean>;
/**
* The events emitted by the ReleaseAnnouncementStore.
*/
type ReleaseAnnouncementStoreEvents = "releaseAnnouncementChanged";
/**
* The handlers for the ReleaseAnnouncementStore events.
*/
type HandlerMap = {
releaseAnnouncementChanged: (newFeature: Feature | null) => void;
};
/**
* The ReleaseAnnouncementStore is responsible for managing the release announcements.
* It keeps track of the viewed release announcements and emits events when the release announcement changes.
*/
export class ReleaseAnnouncementStore extends TypedEventEmitter<ReleaseAnnouncementStoreEvents, HandlerMap> {
/**
* The singleton instance of the ReleaseAnnouncementStore.
* @private
*/
private static internalInstance: ReleaseAnnouncementStore;
/**
* The index of the feature to show.
* @private
*/
private index = 0;
/**
* The singleton instance of the ReleaseAnnouncementStore.
*/
public static get instance(): ReleaseAnnouncementStore {
if (!ReleaseAnnouncementStore.internalInstance) {
ReleaseAnnouncementStore.internalInstance = new ReleaseAnnouncementStore();
}
return ReleaseAnnouncementStore.internalInstance;
}
/**
* Should be used only for testing purposes.
* @internal
*/
public constructor() {
super();
SettingsStore.watchSetting("releaseAnnouncementData", null, () => {
this.emit("releaseAnnouncementChanged", this.getReleaseAnnouncement());
});
}
/**
* Get the viewed release announcements from the settings.
* @private
*/
private getViewedReleaseAnnouncements(): StoredSettings {
// Clone the settings to avoid to mutate the internal stored value in the SettingsStore
return cloneDeep(SettingsStore.getValue<StoredSettings>("releaseAnnouncementData"));
}
/**
* Check if the release announcement is enabled.
* @private
*/
private isReleaseAnnouncementEnabled(): boolean {
return SettingsStore.getValue<boolean>(Features.ReleaseAnnouncement);
}
/**
* Get the release announcement that should be displayed
* @returns The feature to announce or null if there is no feature to announce
*/
public getReleaseAnnouncement(): Feature | null {
// Do nothing if the release announcement is disabled
const isReleaseAnnouncementEnabled = this.isReleaseAnnouncementEnabled();
if (!isReleaseAnnouncementEnabled) return null;
const viewedReleaseAnnouncements = this.getViewedReleaseAnnouncements();
// Find the first feature that has not been viewed
for (let i = this.index; i < FEATURES.length; i++) {
if (!viewedReleaseAnnouncements[FEATURES[i]]) {
this.index = i;
return FEATURES[this.index];
}
}
// All features have been viewed
return null;
}
/**
* Mark the current release announcement as viewed.
* This will update the account settings
* @private
*/
private async markReleaseAnnouncementAsViewed(): Promise<void> {
// Do nothing if the release announcement is disabled
const isReleaseAnnouncementEnabled = this.isReleaseAnnouncementEnabled();
if (!isReleaseAnnouncementEnabled) return;
const viewedReleaseAnnouncements = this.getViewedReleaseAnnouncements();
// If the index is out of bounds, do nothing
// Normally it shouldn't happen, but it's better to be safe
const feature = FEATURES[this.index];
if (!feature) return;
// Mark the feature as viewed
viewedReleaseAnnouncements[FEATURES[this.index]] = true;
this.index++;
// Do sanity check if we can store the new value in the settings
const isSupported = SettingsStore.isLevelSupported(SettingLevel.ACCOUNT);
if (!isSupported) return;
const canSetValue = SettingsStore.canSetValue("releaseAnnouncementData", null, SettingLevel.ACCOUNT);
if (canSetValue) {
try {
await SettingsStore.setValue(
"releaseAnnouncementData",
null,
SettingLevel.ACCOUNT,
viewedReleaseAnnouncements,
);
} catch (e) {
logger.log("Failed to set release announcement settings", e);
}
}
}
/**
* Mark the current release announcement as viewed and move to the next release announcement.
* This will update the account settings and emit the `releaseAnnouncementChanged` event
*/
public async nextReleaseAnnouncement(): Promise<void> {
await this.markReleaseAnnouncementAsViewed();
this.emit("releaseAnnouncementChanged", this.getReleaseAnnouncement());
}
}

View File

@@ -0,0 +1,48 @@
/*
Copyright 2017-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
export interface ScrollState {
focussedEvent?: string;
pixelOffset?: number;
}
/**
* Stores where the user has scrolled to in each room
*/
export class RoomScrollStateStore {
// A map from room id to scroll state.
//
// If there is no special scroll state (ie, we are following the live
// timeline), the scroll state is null. Otherwise, it is an object with
// the following properties:
//
// focussedEvent: the ID of the 'focussed' event. Typically this is
// the last event fully visible in the viewport, though if we
// have done an explicit scroll to an explicit event, it will be
// that event.
//
// pixelOffset: the number of pixels the window is scrolled down
// from the focussedEvent.
private scrollStateMap = new Map<string, ScrollState>();
public getScrollState(roomId: string): ScrollState | undefined {
return this.scrollStateMap.get(roomId);
}
public setScrollState(roomId: string, scrollState: ScrollState | null): void {
if (scrollState === null) {
this.scrollStateMap.delete(roomId);
} else {
this.scrollStateMap.set(roomId, scrollState);
}
}
}
if (window.mxRoomScrollStateStore === undefined) {
window.mxRoomScrollStateStore = new RoomScrollStateStore();
}
export default window.mxRoomScrollStateStore!;

View File

@@ -0,0 +1,846 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2022 The Matrix.org Foundation C.I.C.
Copyright 2017, 2018 New Vector Ltd
Copyright 2017 Vector Creations Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ReactNode } from "react";
import * as utils from "matrix-js-sdk/src/utils";
import { MatrixError, JoinRule, Room, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { logger } from "matrix-js-sdk/src/logger";
import { ViewRoom as ViewRoomEvent } from "@matrix-org/analytics-events/types/typescript/ViewRoom";
import { JoinedRoom as JoinedRoomEvent } from "@matrix-org/analytics-events/types/typescript/JoinedRoom";
import { Optional } from "matrix-events-sdk";
import EventEmitter from "events";
import { RoomViewLifecycle, ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
import { MatrixDispatcher } from "../dispatcher/dispatcher";
import { MatrixClientPeg } from "../MatrixClientPeg";
import Modal from "../Modal";
import { _t } from "../languageHandler";
import { getCachedRoomIDForAlias, storeRoomAliasInCache } from "../RoomAliasCache";
import { Action } from "../dispatcher/actions";
import { retry } from "../utils/promise";
import { TimelineRenderingType } from "../contexts/RoomContext";
import { ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload";
import DMRoomMap from "../utils/DMRoomMap";
import { isMetaSpace, MetaSpace } from "./spaces";
import { JoinRoomPayload } from "../dispatcher/payloads/JoinRoomPayload";
import { JoinRoomReadyPayload } from "../dispatcher/payloads/JoinRoomReadyPayload";
import { JoinRoomErrorPayload } from "../dispatcher/payloads/JoinRoomErrorPayload";
import { ViewRoomErrorPayload } from "../dispatcher/payloads/ViewRoomErrorPayload";
import ErrorDialog from "../components/views/dialogs/ErrorDialog";
import { ActiveRoomChangedPayload } from "../dispatcher/payloads/ActiveRoomChangedPayload";
import SettingsStore from "../settings/SettingsStore";
import { awaitRoomDownSync } from "../utils/RoomUpgrade";
import { UPDATE_EVENT } from "./AsyncStore";
import { SdkContextClass } from "../contexts/SDKContext";
import { CallStore } from "./CallStore";
import { ThreadPayload } from "../dispatcher/payloads/ThreadPayload";
import {
doClearCurrentVoiceBroadcastPlaybackIfStopped,
doMaybeSetCurrentVoiceBroadcastPlayback,
VoiceBroadcastRecording,
VoiceBroadcastRecordingsStoreEvent,
} from "../voice-broadcast";
import { IRoomStateEventsActionPayload } from "../actions/MatrixActionCreators";
import { showCantStartACallDialog } from "../voice-broadcast/utils/showCantStartACallDialog";
import { pauseNonLiveBroadcastFromOtherRoom } from "../voice-broadcast/utils/pauseNonLiveBroadcastFromOtherRoom";
import { ActionPayload } from "../dispatcher/payloads";
import { CancelAskToJoinPayload } from "../dispatcher/payloads/CancelAskToJoinPayload";
import { SubmitAskToJoinPayload } from "../dispatcher/payloads/SubmitAskToJoinPayload";
import { ModuleRunner } from "../modules/ModuleRunner";
import { setMarkedUnreadState } from "../utils/notifications";
const NUM_JOIN_RETRY = 5;
interface State {
/**
* Whether we're joining the currently viewed (see isJoining())
*/
joining: boolean;
/**
* Any error that has occurred during joining
*/
joinError: Error | null;
/**
* The ID of the room currently being viewed
*/
roomId: string | null;
/**
* The ID of the thread currently being viewed
*/
threadId: string | null;
/**
* The ID of the room being subscribed to (in Sliding Sync)
*/
subscribingRoomId: string | null;
/**
* The event to scroll to when the room is first viewed
*/
initialEventId: string | null;
initialEventPixelOffset: number | null;
/**
* Whether to highlight the initial event
*/
isInitialEventHighlighted: boolean;
/**
* Whether to scroll the initial event into view
*/
initialEventScrollIntoView: boolean;
/**
* The alias of the room (or null if not originally specified in view_room)
*/
roomAlias: string | null;
/**
* Whether the current room is loading
*/
roomLoading: boolean;
/**
* Any error that has occurred during loading
*/
roomLoadError: MatrixError | null;
replyingToEvent: MatrixEvent | null;
shouldPeek: boolean;
viaServers: string[];
wasContextSwitch: boolean;
/**
* Whether we're viewing a call or call lobby in this room
*/
viewingCall: boolean;
/**
* If we want the call to skip the lobby and immediately join
*/
skipLobby?: boolean;
promptAskToJoin: boolean;
viewRoomOpts: ViewRoomOpts;
}
const INITIAL_STATE: State = {
joining: false,
joinError: null,
roomId: null,
threadId: null,
subscribingRoomId: null,
initialEventId: null,
initialEventPixelOffset: null,
isInitialEventHighlighted: false,
initialEventScrollIntoView: true,
roomAlias: null,
roomLoading: false,
roomLoadError: null,
replyingToEvent: null,
shouldPeek: false,
viaServers: [],
wasContextSwitch: false,
viewingCall: false,
promptAskToJoin: false,
viewRoomOpts: { buttons: [] },
};
type Listener = (isActive: boolean) => void;
/**
* A class for storing application state for RoomView.
*/
export class RoomViewStore extends EventEmitter {
// initialize state as a copy of the initial state. We need to copy else one RVS can talk to
// another RVS via INITIAL_STATE as they share the same underlying object. Mostly relevant for tests.
private state = utils.deepCopy(INITIAL_STATE);
private dis?: MatrixDispatcher;
private dispatchToken?: string;
public constructor(
dis: MatrixDispatcher,
private readonly stores: SdkContextClass,
) {
super();
this.resetDispatcher(dis);
this.stores.voiceBroadcastRecordingsStore.addListener(
VoiceBroadcastRecordingsStoreEvent.CurrentChanged,
this.onCurrentBroadcastRecordingChanged,
);
}
public addRoomListener(roomId: string, fn: Listener): void {
this.on(roomId, fn);
}
public removeRoomListener(roomId: string, fn: Listener): void {
this.off(roomId, fn);
}
private emitForRoom(roomId: string, isActive: boolean): void {
this.emit(roomId, isActive);
}
private onCurrentBroadcastRecordingChanged = (recording: VoiceBroadcastRecording | null): void => {
if (recording === null) {
const room = this.stores.client?.getRoom(this.state.roomId || undefined);
if (room) {
this.doMaybeSetCurrentVoiceBroadcastPlayback(room);
}
}
};
private setState(newState: Partial<State>): void {
// If values haven't changed, there's nothing to do.
// This only tries a shallow comparison, so unchanged objects will slip
// through, but that's probably okay for now.
let stateChanged = false;
for (const key of Object.keys(newState)) {
if (this.state[key as keyof State] !== newState[key as keyof State]) {
stateChanged = true;
break;
}
}
if (!stateChanged) {
return;
}
if (newState.viewingCall) {
// Pause current broadcast, if any
this.stores.voiceBroadcastPlaybacksStore.getCurrent()?.pause();
if (this.stores.voiceBroadcastRecordingsStore.getCurrent()) {
showCantStartACallDialog();
newState.viewingCall = false;
}
}
const lastRoomId = this.state.roomId;
this.state = Object.assign(this.state, newState);
if (lastRoomId !== this.state.roomId) {
if (lastRoomId) this.emitForRoom(lastRoomId, false);
if (this.state.roomId) this.emitForRoom(this.state.roomId, true);
// Fired so we can reduce dependency on event emitters to this store, which is relatively
// central to the application and can easily cause import cycles.
this.dis?.dispatch<ActiveRoomChangedPayload>({
action: Action.ActiveRoomChanged,
oldRoomId: lastRoomId,
newRoomId: this.state.roomId,
});
}
this.emit(UPDATE_EVENT);
}
private doMaybeSetCurrentVoiceBroadcastPlayback(room: Room): void {
if (!this.stores.client) return;
doMaybeSetCurrentVoiceBroadcastPlayback(
room,
this.stores.client,
this.stores.voiceBroadcastPlaybacksStore,
this.stores.voiceBroadcastRecordingsStore,
);
}
private onRoomStateEvents(event: MatrixEvent): void {
const roomId = event.getRoomId?.();
// no room or not current room
if (!roomId || roomId !== this.state.roomId) return;
const room = this.stores.client?.getRoom(roomId);
if (room) {
this.doMaybeSetCurrentVoiceBroadcastPlayback(room);
}
}
private onDispatch(payload: ActionPayload): void {
// eslint-disable-line @typescript-eslint/naming-convention
switch (payload.action) {
// view_room:
// - room_alias: '#somealias:matrix.org'
// - room_id: '!roomid123:matrix.org'
// - event_id: '$213456782:matrix.org'
// - event_offset: 100
// - highlighted: true
case Action.ViewRoom:
this.viewRoom(payload as ViewRoomPayload);
break;
case Action.ViewThread:
this.viewThread(payload as ThreadPayload);
break;
// for these events blank out the roomId as we are no longer in the RoomView
case "view_welcome_page":
case Action.ViewHomePage:
this.setState({
roomId: null,
roomAlias: null,
viaServers: [],
wasContextSwitch: false,
viewingCall: false,
});
doClearCurrentVoiceBroadcastPlaybackIfStopped(this.stores.voiceBroadcastPlaybacksStore);
break;
case "MatrixActions.RoomState.events":
this.onRoomStateEvents((payload as IRoomStateEventsActionPayload).event);
break;
case Action.ViewRoomError:
this.viewRoomError(payload as ViewRoomErrorPayload);
break;
case "will_join":
this.setState({
joining: true,
});
break;
case "cancel_join":
this.setState({
joining: false,
});
break;
// join_room:
// - opts: options for joinRoom
case Action.JoinRoom:
this.joinRoom(payload as JoinRoomPayload);
break;
case Action.JoinRoomError:
this.joinRoomError(payload as JoinRoomErrorPayload);
break;
case Action.JoinRoomReady: {
if (this.state.roomId === payload.roomId) {
this.setState({ shouldPeek: false });
}
awaitRoomDownSync(MatrixClientPeg.safeGet(), payload.roomId).then((room) => {
const numMembers = room.getJoinedMemberCount();
const roomSize =
numMembers > 1000
? "MoreThanAThousand"
: numMembers > 100
? "OneHundredAndOneToAThousand"
: numMembers > 10
? "ElevenToOneHundred"
: numMembers > 2
? "ThreeToTen"
: numMembers > 1
? "Two"
: "One";
this.stores.posthogAnalytics.trackEvent<JoinedRoomEvent>({
eventName: "JoinedRoom",
trigger: payload.metricsTrigger,
roomSize,
isDM: !!DMRoomMap.shared().getUserIdForRoomId(room.roomId),
isSpace: room.isSpaceRoom(),
});
});
break;
}
case "on_client_not_viable":
case Action.OnLoggedOut:
this.reset();
break;
case "reply_to_event":
// Thread timeline view handles its own reply-to-state
if (TimelineRenderingType.Thread !== payload.context) {
// If currently viewed room does not match the room in which we wish to reply then change rooms this
// can happen when performing a search across all rooms. Persist the data from this event for both
// room and search timeline rendering types, search will get auto-closed by RoomView at this time.
if (payload.event && payload.event.getRoomId() !== this.state.roomId) {
this.dis?.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: payload.event.getRoomId(),
replyingToEvent: payload.event,
metricsTrigger: undefined, // room doesn't change
});
} else {
this.setState({
replyingToEvent: payload.event,
});
}
}
break;
case Action.PromptAskToJoin: {
this.setState({ promptAskToJoin: true });
break;
}
case Action.SubmitAskToJoin: {
this.submitAskToJoin(payload as SubmitAskToJoinPayload);
break;
}
case Action.CancelAskToJoin: {
this.cancelAskToJoin(payload as CancelAskToJoinPayload);
break;
}
case Action.RoomLoaded: {
this.setViewRoomOpts();
break;
}
}
}
private async viewRoom(payload: ViewRoomPayload): Promise<void> {
if (payload.room_id) {
const room = MatrixClientPeg.safeGet().getRoom(payload.room_id);
if (payload.metricsTrigger !== null && payload.room_id !== this.state.roomId) {
let activeSpace: ViewRoomEvent["activeSpace"];
if (this.stores.spaceStore.activeSpace === MetaSpace.Home) {
activeSpace = "Home";
} else if (isMetaSpace(this.stores.spaceStore.activeSpace)) {
activeSpace = "Meta";
} else {
activeSpace =
this.stores.spaceStore.activeSpaceRoom?.getJoinRule() === JoinRule.Public
? "Public"
: "Private";
}
this.stores.posthogAnalytics.trackEvent<ViewRoomEvent>({
eventName: "ViewRoom",
trigger: payload.metricsTrigger,
viaKeyboard: payload.metricsViaKeyboard,
isDM: !!DMRoomMap.shared().getUserIdForRoomId(payload.room_id),
isSpace: room?.isSpaceRoom(),
activeSpace,
});
}
if (SettingsStore.getValue("feature_sliding_sync") && this.state.roomId !== payload.room_id) {
if (this.state.subscribingRoomId && this.state.subscribingRoomId !== payload.room_id) {
// unsubscribe from this room, but don't await it as we don't care when this gets done.
this.stores.slidingSyncManager.setRoomVisible(this.state.subscribingRoomId, false);
}
this.setState({
subscribingRoomId: payload.room_id,
roomId: payload.room_id,
initialEventId: null,
initialEventPixelOffset: null,
initialEventScrollIntoView: true,
roomAlias: null,
roomLoading: true,
roomLoadError: null,
viaServers: payload.via_servers,
wasContextSwitch: payload.context_switch,
viewingCall: payload.view_call ?? false,
});
// set this room as the room subscription. We need to await for it as this will fetch
// all room state for this room, which is required before we get the state below.
await this.stores.slidingSyncManager.setRoomVisible(payload.room_id, true);
// Whilst we were subscribing another room was viewed, so stop what we're doing and
// unsubscribe
if (this.state.subscribingRoomId !== payload.room_id) {
this.stores.slidingSyncManager.setRoomVisible(payload.room_id, false);
return;
}
// Re-fire the payload: we won't re-process it because the prev room ID == payload room ID now
this.dis?.dispatch({
...payload,
});
return;
}
const newState: Partial<State> = {
roomId: payload.room_id,
roomAlias: payload.room_alias ?? null,
initialEventId: payload.event_id ?? null,
isInitialEventHighlighted: payload.highlighted ?? false,
initialEventScrollIntoView: payload.scroll_into_view ?? true,
roomLoading: false,
roomLoadError: null,
// should peek by default
shouldPeek: payload.should_peek === undefined ? true : payload.should_peek,
// have we sent a join request for this room and are waiting for a response?
joining: payload.joining || false,
// Reset replyingToEvent because we don't want cross-room because bad UX
replyingToEvent: null,
viaServers: payload.via_servers ?? [],
wasContextSwitch: payload.context_switch ?? false,
skipLobby: payload.skipLobby,
viewingCall:
payload.view_call ??
(payload.room_id === this.state.roomId
? this.state.viewingCall
: CallStore.instance.getActiveCall(payload.room_id) !== null),
};
// Allow being given an event to be replied to when switching rooms but sanity check its for this room
if (payload.replyingToEvent?.getRoomId() === payload.room_id) {
newState.replyingToEvent = payload.replyingToEvent;
} else if (this.state.replyingToEvent?.getRoomId() === payload.room_id) {
// if the reply-to matches the desired room, e.g visiting a permalink then maintain replyingToEvent
// See https://github.com/vector-im/element-web/issues/21462
newState.replyingToEvent = this.state.replyingToEvent;
}
this.setState(newState);
if (payload.auto_join) {
this.dis?.dispatch<JoinRoomPayload>({
...payload,
action: Action.JoinRoom,
roomId: payload.room_id,
metricsTrigger: payload.metricsTrigger as JoinRoomPayload["metricsTrigger"],
});
}
if (room) {
pauseNonLiveBroadcastFromOtherRoom(room, this.stores.voiceBroadcastPlaybacksStore);
this.doMaybeSetCurrentVoiceBroadcastPlayback(room);
await setMarkedUnreadState(room, MatrixClientPeg.safeGet(), false);
}
} else if (payload.room_alias) {
// Try the room alias to room ID navigation cache first to avoid
// blocking room navigation on the homeserver.
let roomId = getCachedRoomIDForAlias(payload.room_alias);
if (!roomId) {
// Room alias cache miss, so let's ask the homeserver. Resolve the alias
// and then do a second dispatch with the room ID acquired.
this.setState({
roomId: null,
initialEventId: null,
initialEventPixelOffset: null,
isInitialEventHighlighted: false,
initialEventScrollIntoView: true,
roomAlias: payload.room_alias,
roomLoading: true,
roomLoadError: null,
viaServers: payload.via_servers,
wasContextSwitch: payload.context_switch,
viewingCall: payload.view_call ?? false,
skipLobby: payload.skipLobby,
});
try {
const result = await MatrixClientPeg.safeGet().getRoomIdForAlias(payload.room_alias);
storeRoomAliasInCache(payload.room_alias, result.room_id);
roomId = result.room_id;
} catch (err) {
logger.error("RVS failed to get room id for alias: ", err);
this.dis?.dispatch<ViewRoomErrorPayload>({
action: Action.ViewRoomError,
room_id: null,
room_alias: payload.room_alias,
err: err instanceof MatrixError ? err : undefined,
});
return;
}
}
// Re-fire the payload with the newly found room_id
this.dis?.dispatch({
...payload,
room_id: roomId,
});
}
}
private viewThread(payload: ThreadPayload): void {
this.setState({
threadId: payload.thread_id,
});
}
private viewRoomError(payload: ViewRoomErrorPayload): void {
this.setState({
roomId: payload.room_id,
roomAlias: payload.room_alias,
roomLoading: false,
roomLoadError: payload.err,
});
}
private async joinRoom(payload: JoinRoomPayload): Promise<void> {
this.setState({
joining: true,
});
// take a copy of roomAlias & roomId as they may change by the time the join is complete
const { roomAlias, roomId = payload.roomId } = this.state;
const address = roomAlias || roomId!;
const viaServers = this.state.viaServers || [];
try {
const cli = MatrixClientPeg.safeGet();
await retry<Room, MatrixError>(
() =>
cli.joinRoom(address, {
viaServers,
...(payload.opts || {}),
}),
NUM_JOIN_RETRY,
(err) => {
// if we received a Gateway timeout or Cloudflare timeout then retry
return err.httpStatus === 504 || err.httpStatus === 524;
},
);
// We do *not* clear the 'joining' flag because the Room object and/or our 'joined' member event may not
// have come down the sync stream yet, and that's the point at which we'd consider the user joined to the
// room.
this.dis?.dispatch<JoinRoomReadyPayload>({
action: Action.JoinRoomReady,
roomId: roomId!,
metricsTrigger: payload.metricsTrigger,
});
} catch (err) {
this.dis?.dispatch({
action: Action.JoinRoomError,
roomId,
err,
canAskToJoin: payload.canAskToJoin,
});
if (payload.canAskToJoin) {
this.dis?.dispatch({ action: Action.PromptAskToJoin });
}
}
}
private getInvitingUserId(roomId: string): string | undefined {
const cli = MatrixClientPeg.safeGet();
const room = cli.getRoom(roomId);
if (room?.getMyMembership() === KnownMembership.Invite) {
const myMember = room.getMember(cli.getSafeUserId());
const inviteEvent = myMember ? myMember.events.member : null;
return inviteEvent?.getSender();
}
}
public showJoinRoomError(err: MatrixError, roomId: string): void {
let description: ReactNode = err.message ? err.message : JSON.stringify(err);
logger.log("Failed to join room:", description);
if (err.name === "ConnectionError") {
description = _t("room|error_join_connection");
} else if (err.errcode === "M_INCOMPATIBLE_ROOM_VERSION") {
description = (
<div>
{_t("room|error_join_incompatible_version_1")}
<br />
{_t("room|error_join_incompatible_version_2")}
</div>
);
} else if (err.httpStatus === 404) {
const invitingUserId = this.getInvitingUserId(roomId);
// provide a better error message for invites
if (invitingUserId) {
// if the inviting user is on the same HS, there can only be one cause: they left.
if (invitingUserId.endsWith(`:${MatrixClientPeg.safeGet().getDomain()}`)) {
description = _t("room|error_join_404_invite_same_hs");
} else {
description = _t("room|error_join_404_invite");
}
}
// provide a more detailed error than "No known servers" when attempting to
// join using a room ID and no via servers
if (roomId === this.state.roomId && this.state.viaServers.length === 0) {
description = (
<div>
{_t("room|error_join_404_1")}
<br />
<br />
{_t("room|error_join_404_2")}
</div>
);
}
}
Modal.createDialog(ErrorDialog, {
title: _t("room|error_join_title"),
description,
});
}
private joinRoomError(payload: JoinRoomErrorPayload): void {
this.setState({
joining: false,
joinError: payload.err,
});
if (payload.err && !payload.canAskToJoin) {
this.showJoinRoomError(payload.err, payload.roomId);
}
}
public reset(): void {
this.state = Object.assign({}, INITIAL_STATE);
}
/**
* Reset which dispatcher should be used to listen for actions. The old dispatcher will be
* unregistered.
* @param dis The new dispatcher to use.
*/
public resetDispatcher(dis: MatrixDispatcher): void {
if (this.dispatchToken) {
this.dis?.unregister(this.dispatchToken);
}
this.dis = dis;
if (dis) {
// Some tests mock the dispatcher file resulting in an empty defaultDispatcher
// so rather than dying here, just ignore it. When we no longer mock files like this,
// we should remove the null check.
this.dispatchToken = this.dis.register(this.onDispatch.bind(this));
}
}
// The room ID of the room currently being viewed
public getRoomId(): Optional<string> {
return this.state.roomId;
}
public getThreadId(): Optional<string> {
return this.state.threadId;
}
// The event to scroll to when the room is first viewed
public getInitialEventId(): Optional<string> {
return this.state.initialEventId;
}
// Whether to highlight the initial event
public isInitialEventHighlighted(): boolean {
return this.state.isInitialEventHighlighted;
}
// Whether to avoid jumping to the initial event
public initialEventScrollIntoView(): boolean {
return this.state.initialEventScrollIntoView;
}
// The room alias of the room (or null if not originally specified in view_room)
public getRoomAlias(): Optional<string> {
return this.state.roomAlias;
}
// Whether the current room is loading (true whilst resolving an alias)
public isRoomLoading(): boolean {
return this.state.roomLoading;
}
// Any error that has occurred during loading
public getRoomLoadError(): Optional<MatrixError> {
return this.state.roomLoadError;
}
// True if we're expecting the user to be joined to the room currently being
// viewed. Note that this is left true after the join request has finished,
// since we should still consider a join to be in progress until the room
// & member events come down the sync.
//
// This flag remains true after the room has been successfully joined,
// (this store doesn't listen for the appropriate member events)
// so you should always observe the joined state from the member event
// if a room object is present.
// ie. The correct logic is:
// if (room) {
// if (myMember.membership == 'joined') {
// // user is joined to the room
// } else {
// // Not joined
// }
// } else {
// if (this.stores.roomViewStore.isJoining()) {
// // show spinner
// } else {
// // show join prompt
// }
// }
public isJoining(): boolean {
return this.state.joining;
}
// Any error that has occurred during joining
public getJoinError(): Optional<Error> {
return this.state.joinError;
}
// The mxEvent if one is currently being replied to/quoted
public getQuotingEvent(): MatrixEvent | null {
return this.state.replyingToEvent;
}
public shouldPeek(): boolean {
return this.state.shouldPeek;
}
public getWasContextSwitch(): boolean {
return this.state.wasContextSwitch;
}
public isViewingCall(): boolean {
return this.state.viewingCall;
}
public skipCallLobby(): boolean | undefined {
return this.state.skipLobby;
}
/**
* Gets the current state of the 'promptForAskToJoin' property.
*
* @returns {boolean} The value of the 'promptForAskToJoin' property.
*/
public promptAskToJoin(): boolean {
return this.state.promptAskToJoin;
}
/**
* Submits a request to join a room by sending a knock request.
*
* @param {SubmitAskToJoinPayload} payload - The payload containing information to submit the request.
* @returns {void}
*/
private submitAskToJoin(payload: SubmitAskToJoinPayload): void {
MatrixClientPeg.safeGet()
.knockRoom(payload.roomId, { viaServers: this.state.viaServers, ...payload.opts })
.catch((err: MatrixError) =>
Modal.createDialog(ErrorDialog, {
title: _t("room|error_join_title"),
description: err.httpStatus === 403 ? _t("room|error_join_403") : err.message,
}),
)
.finally(() => this.setState({ promptAskToJoin: false }));
}
/**
* Cancels a request to join a room by sending a leave request.
*
* @param {CancelAskToJoinPayload} payload - The payload containing information to cancel the request.
* @returns {void}
*/
private cancelAskToJoin(payload: CancelAskToJoinPayload): void {
MatrixClientPeg.safeGet()
.leave(payload.roomId)
.catch((err: MatrixError) =>
Modal.createDialog(ErrorDialog, {
title: _t("room|error_cancel_knock_title"),
description: err.message,
}),
);
}
/**
* Gets the current state of the 'viewRoomOpts' property.
*
* @returns {ViewRoomOpts} The value of the 'viewRoomOpts' property.
*/
public getViewRoomOpts(): ViewRoomOpts {
return this.state.viewRoomOpts;
}
/**
* Invokes the view room lifecycle to set the view room options.
*
* @returns {void}
*/
private setViewRoomOpts(): void {
const viewRoomOpts: ViewRoomOpts = { buttons: [] };
ModuleRunner.instance.invoke(RoomViewLifecycle.ViewRoom, viewRoomOpts, this.getRoomId());
this.setState({ viewRoomOpts });
}
}

View File

@@ -0,0 +1,307 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020-2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import EventEmitter from "events";
import {
KeyBackupInfo,
VerificationPhase,
VerificationRequest,
VerificationRequestEvent,
CryptoEvent,
} from "matrix-js-sdk/src/crypto-api";
import { logger } from "matrix-js-sdk/src/logger";
import { Device, SecretStorage } from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "../MatrixClientPeg";
import { AccessCancelledError, accessSecretStorage } from "../SecurityManager";
import Modal from "../Modal";
import InteractiveAuthDialog from "../components/views/dialogs/InteractiveAuthDialog";
import { _t } from "../languageHandler";
import { SdkContextClass } from "../contexts/SDKContext";
import { asyncSome } from "../utils/arrays";
import { initialiseDehydration } from "../utils/device/dehydration";
export enum Phase {
Loading = 0,
Intro = 1,
Busy = 2,
Done = 3, // final done stage, but still showing UX
ConfirmSkip = 4,
Finished = 5, // UX can be closed
ConfirmReset = 6,
}
export class SetupEncryptionStore extends EventEmitter {
private started?: boolean;
public phase?: Phase;
public verificationRequest: VerificationRequest | null = null;
public backupInfo: KeyBackupInfo | null = null;
// ID of the key that the secrets we want are encrypted with
public keyId: string | null = null;
// Descriptor of the key that the secrets we want are encrypted with
public keyInfo: SecretStorage.SecretStorageKeyDescription | null = null;
public hasDevicesToVerifyAgainst?: boolean;
public static sharedInstance(): SetupEncryptionStore {
if (!window.mxSetupEncryptionStore) window.mxSetupEncryptionStore = new SetupEncryptionStore();
return window.mxSetupEncryptionStore;
}
public start(): void {
if (this.started) {
return;
}
this.started = true;
this.phase = Phase.Loading;
const cli = MatrixClientPeg.safeGet();
cli.on(CryptoEvent.VerificationRequestReceived, this.onVerificationRequest);
cli.on(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
const requestsInProgress = cli.getCrypto()!.getVerificationRequestsToDeviceInProgress(cli.getUserId()!);
if (requestsInProgress.length) {
// If there are multiple, we take the most recent. Equally if the user sends another request from
// another device after this screen has been shown, we'll switch to the new one, so this
// generally doesn't support multiple requests.
this.setActiveVerificationRequest(requestsInProgress[requestsInProgress.length - 1]);
}
this.fetchKeyInfo();
}
public stop(): void {
if (!this.started) {
return;
}
this.started = false;
this.verificationRequest?.off(VerificationRequestEvent.Change, this.onVerificationRequestChange);
const cli = MatrixClientPeg.get();
if (!!cli) {
cli.removeListener(CryptoEvent.VerificationRequestReceived, this.onVerificationRequest);
cli.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
}
}
public async fetchKeyInfo(): Promise<void> {
if (!this.started) return; // bail if we were stopped
const cli = MatrixClientPeg.safeGet();
const keys = await cli.secretStorage.isStored("m.cross_signing.master");
if (keys === null || Object.keys(keys).length === 0) {
this.keyId = null;
this.keyInfo = null;
} else {
// If the secret is stored under more than one key, we just pick an arbitrary one
this.keyId = Object.keys(keys)[0];
this.keyInfo = keys[this.keyId];
}
// do we have any other verified devices which are E2EE which we can verify against?
const dehydratedDevice = await cli.getDehydratedDevice();
const ownUserId = cli.getUserId()!;
const crypto = cli.getCrypto()!;
const userDevices: Iterable<Device> =
(await crypto.getUserDeviceInfo([ownUserId])).get(ownUserId)?.values() ?? [];
this.hasDevicesToVerifyAgainst = await asyncSome(userDevices, async (device) => {
// Ignore dehydrated devices. `dehydratedDevice` is set by the
// implementation of MSC2697, whereas MSC3814 proposes that devices
// should set a `dehydrated` flag in the device key. We ignore
// both types of dehydrated devices.
if (dehydratedDevice && device.deviceId == dehydratedDevice?.device_id) return false;
if (device.dehydrated) return false;
// ignore devices without an identity key
if (!device.getIdentityKey()) return false;
const verificationStatus = await crypto.getDeviceVerificationStatus(ownUserId, device.deviceId);
return !!verificationStatus?.signedByOwner;
});
this.phase = Phase.Intro;
this.emit("update");
}
public async usePassPhrase(): Promise<void> {
logger.debug("SetupEncryptionStore.usePassphrase");
this.phase = Phase.Busy;
this.emit("update");
try {
const cli = MatrixClientPeg.safeGet();
const backupInfo = await cli.getKeyBackupVersion();
this.backupInfo = backupInfo;
this.emit("update");
await new Promise((resolve: (value?: unknown) => void, reject: (reason?: any) => void) => {
accessSecretStorage(async (): Promise<void> => {
// `accessSecretStorage` will call `boostrapCrossSigning` and `bootstrapSecretStorage`, so that
// should be enough to ensure that our device is correctly cross-signed.
//
// The remaining tasks (device dehydration and restoring key backup) may take some time due to
// processing many to-device messages in the case of device dehydration, or having many keys to
// restore in the case of key backups, so we allow the dialog to advance before this.
//
// However, we need to keep the 4S key cached, so we stay inside `accessSecretStorage`.
logger.debug(
"SetupEncryptionStore.usePassphrase: cross-signing and secret storage set up; checking " +
"dehydration and backup in the background",
);
resolve();
await initialiseDehydration();
if (backupInfo) {
await cli.restoreKeyBackupWithSecretStorage(backupInfo);
}
}).catch(reject);
});
if (await cli.getCrypto()?.getCrossSigningKeyId()) {
logger.debug("SetupEncryptionStore.usePassphrase: done");
this.phase = Phase.Done;
this.emit("update");
}
} catch (e) {
if (e instanceof AccessCancelledError) {
logger.debug("SetupEncryptionStore.usePassphrase: user cancelled access to secret storage");
} else {
logger.log("SetupEncryptionStore.usePassphrase: error", e);
}
this.phase = Phase.Intro;
this.emit("update");
}
}
private onUserTrustStatusChanged = async (userId: string): Promise<void> => {
if (userId !== MatrixClientPeg.safeGet().getSafeUserId()) return;
const publicKeysTrusted = await MatrixClientPeg.safeGet().getCrypto()?.getCrossSigningKeyId();
if (publicKeysTrusted) {
this.phase = Phase.Done;
this.emit("update");
}
};
public onVerificationRequest = (request: VerificationRequest): void => {
this.setActiveVerificationRequest(request);
};
public onVerificationRequestChange = async (): Promise<void> => {
if (this.verificationRequest?.phase === VerificationPhase.Cancelled) {
this.verificationRequest.off(VerificationRequestEvent.Change, this.onVerificationRequestChange);
this.verificationRequest = null;
this.emit("update");
} else if (this.verificationRequest?.phase === VerificationPhase.Done) {
this.verificationRequest.off(VerificationRequestEvent.Change, this.onVerificationRequestChange);
this.verificationRequest = null;
// At this point, the verification has finished, we just need to wait for
// cross signing to be ready to use, so wait for the user trust status to
// change (or change to DONE if it's already ready).
const publicKeysTrusted = await MatrixClientPeg.safeGet().getCrypto()?.getCrossSigningKeyId();
this.phase = publicKeysTrusted ? Phase.Done : Phase.Busy;
this.emit("update");
}
};
public skip(): void {
this.phase = Phase.ConfirmSkip;
this.emit("update");
}
public skipConfirm(): void {
this.phase = Phase.Finished;
this.emit("update");
}
public returnAfterSkip(): void {
this.phase = Phase.Intro;
this.emit("update");
}
public reset(): void {
this.phase = Phase.ConfirmReset;
this.emit("update");
}
public async resetConfirm(): Promise<void> {
try {
// If we've gotten here, the user presumably lost their
// secret storage key if they had one. Start by resetting
// secret storage and setting up a new recovery key, then
// create new cross-signing keys once that succeeds.
await accessSecretStorage(async (): Promise<void> => {
const cli = MatrixClientPeg.safeGet();
await cli.getCrypto()?.bootstrapCrossSigning({
authUploadDeviceSigningKeys: async (makeRequest): Promise<void> => {
const cachedPassword = SdkContextClass.instance.accountPasswordStore.getPassword();
if (cachedPassword) {
await makeRequest({
type: "m.login.password",
identifier: {
type: "m.id.user",
user: cli.getSafeUserId(),
},
user: cli.getSafeUserId(),
password: cachedPassword,
});
return;
}
const { finished } = Modal.createDialog(InteractiveAuthDialog, {
title: _t("encryption|bootstrap_title"),
matrixClient: cli,
makeRequest,
});
const [confirmed] = await finished;
if (!confirmed) {
throw new Error("Cross-signing key upload auth canceled");
}
},
setupNewCrossSigning: true,
});
await initialiseDehydration(true);
this.phase = Phase.Finished;
}, true);
} catch (e) {
logger.error("Error resetting cross-signing", e);
this.phase = Phase.Intro;
}
this.emit("update");
}
public returnAfterReset(): void {
this.phase = Phase.Intro;
this.emit("update");
}
public done(): void {
this.phase = Phase.Finished;
this.emit("update");
// async - ask other clients for keys, if necessary
MatrixClientPeg.safeGet().crypto?.cancelAndResendAllOutgoingKeyRequests();
}
private async setActiveVerificationRequest(request: VerificationRequest): Promise<void> {
if (!this.started) return; // bail if we were stopped
if (request.otherUserId !== MatrixClientPeg.safeGet().getUserId()) return;
if (this.verificationRequest) {
this.verificationRequest.off(VerificationRequestEvent.Change, this.onVerificationRequestChange);
}
this.verificationRequest = request;
await request.accept();
request.on(VerificationRequestEvent.Change, this.onVerificationRequestChange);
this.emit("update");
}
public lostKeys(): boolean {
return !this.hasDevicesToVerifyAgainst && !this.keyInfo;
}
}

View File

@@ -0,0 +1,126 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import EventEmitter from "events";
import { base32 } from "rfc4648";
import { RoomType } from "matrix-js-sdk/src/matrix";
// Dev note: the interface is split in two so we don't have to disable the
// linter across the whole project.
export interface IThreepidInviteWireFormat {
email: string;
signurl: string;
room_name: string; // eslint-disable-line camelcase
room_avatar_url: string; // eslint-disable-line camelcase
inviter_name: string; // eslint-disable-line camelcase
// TODO: Figure out if these are ever populated
guest_access_token?: string; // eslint-disable-line camelcase
guest_user_id?: string; // eslint-disable-line camelcase
}
interface IPersistedThreepidInvite extends IThreepidInviteWireFormat {
roomId: string;
}
export interface IThreepidInvite {
id: string; // generated by us
roomId: string;
toEmail: string;
signUrl: string;
roomName: string;
roomAvatarUrl: string;
inviterName: string;
}
// Any data about the room that would normally come from the homeserver
// but has been passed out-of-band, eg. the room name and avatar URL
// from an email invite (a workaround for the fact that we can't
// get this information from the HS using an email invite).
export interface IOOBData {
name?: string; // The room's name
avatarUrl?: string; // The mxc:// avatar URL for the room
inviterName?: string; // The display name of the person who invited us to the room
// eslint-disable-next-line camelcase
room_name?: string; // The name of the room, to be used until we are told better by the server
roomType?: RoomType | string; // The type of the room, to be used until we are told better by the server
}
const STORAGE_PREFIX = "mx_threepid_invite_";
export default class ThreepidInviteStore extends EventEmitter {
private static _instance: ThreepidInviteStore;
public static get instance(): ThreepidInviteStore {
if (!ThreepidInviteStore._instance) {
ThreepidInviteStore._instance = new ThreepidInviteStore();
}
return ThreepidInviteStore._instance;
}
public storeInvite(roomId: string, wireInvite: IThreepidInviteWireFormat): IThreepidInvite {
const invite = <IPersistedThreepidInvite>{ roomId, ...wireInvite };
const id = this.generateIdOf(invite);
localStorage.setItem(`${STORAGE_PREFIX}${id}`, JSON.stringify(invite));
return this.translateInvite(invite);
}
public getWireInvites(): IPersistedThreepidInvite[] {
const results: IPersistedThreepidInvite[] = [];
for (let i = 0; i < localStorage.length; i++) {
const keyName = localStorage.key(i);
if (!keyName?.startsWith(STORAGE_PREFIX)) continue;
try {
results.push(JSON.parse(localStorage.getItem(keyName)!) as IPersistedThreepidInvite);
} catch (e) {
console.warn("Failed to parse 3pid invite", e);
}
}
return results;
}
public getInvites(): IThreepidInvite[] {
return this.getWireInvites().map((i) => this.translateInvite(i));
}
// Currently Element can only handle one invite at a time, so handle that
public pickBestInvite(): IThreepidInvite {
return this.getInvites()[0];
}
public resolveInvite(invite: IThreepidInvite): void {
localStorage.removeItem(`${STORAGE_PREFIX}${invite.id}`);
}
private generateIdOf(persisted: IPersistedThreepidInvite): string {
// Use a consistent "hash" to form an ID.
return base32.stringify(Buffer.from(JSON.stringify(persisted)));
}
private translateInvite(persisted: IPersistedThreepidInvite): IThreepidInvite {
return {
id: this.generateIdOf(persisted),
roomId: persisted.roomId,
toEmail: persisted.email,
signUrl: persisted.signurl,
roomName: persisted.room_name,
roomAvatarUrl: persisted.room_avatar_url,
inviterName: persisted.inviter_name,
};
}
public translateToWireFormat(invite: IThreepidInvite): IThreepidInviteWireFormat {
return {
email: invite.toEmail,
signurl: invite.signUrl,
room_name: invite.roomName,
room_avatar_url: invite.roomAvatarUrl,
inviter_name: invite.inviterName,
};
}
}

89
src/stores/ToastStore.ts Normal file
View File

@@ -0,0 +1,89 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import EventEmitter from "events";
import React from "react";
import { ComponentClass } from "../@types/common";
export interface IToast<C extends ComponentClass> {
key: string;
// higher priority number will be shown on top of lower priority
priority: number;
title?: string;
icon?: string;
component: C;
className?: string;
bodyClassName?: string;
props?: Omit<React.ComponentProps<C>, "toastKey">; // toastKey is injected by ToastContainer
}
/**
* Holds the active toasts
*/
export default class ToastStore extends EventEmitter {
private toasts: IToast<any>[] = [];
// The count of toasts which have been seen & dealt with in this stack
// where the count resets when the stack of toasts clears.
private countSeen = 0;
public static sharedInstance(): ToastStore {
if (!window.mxToastStore) window.mxToastStore = new ToastStore();
return window.mxToastStore;
}
public reset(): void {
this.toasts = [];
this.countSeen = 0;
}
/**
* Add or replace a toast
* If a toast with the same toastKey already exists, the given toast will replace it
* Toasts are always added underneath any toasts of the same priority, so existing
* toasts stay at the top unless a higher priority one arrives (better to not change the
* toast unless necessary).
*
* @param {object} newToast The new toast
*/
public addOrReplaceToast<C extends ComponentClass>(newToast: IToast<C>): void {
const oldIndex = this.toasts.findIndex((t) => t.key === newToast.key);
if (oldIndex === -1) {
let newIndex = this.toasts.length;
while (newIndex > 0 && this.toasts[newIndex - 1].priority < newToast.priority) --newIndex;
this.toasts.splice(newIndex, 0, newToast);
} else {
this.toasts[oldIndex] = newToast;
}
this.emit("update");
}
public dismissToast(key: string): void {
if (this.toasts[0] && this.toasts[0].key === key) {
this.countSeen++;
}
const length = this.toasts.length;
this.toasts = this.toasts.filter((t) => t.key !== key);
if (length !== this.toasts.length) {
if (this.toasts.length === 0) {
this.countSeen = 0;
}
this.emit("update");
}
}
public getToasts(): IToast<any>[] {
return this.toasts;
}
public getCountSeen(): number {
return this.countSeen;
}
}

104
src/stores/TypingStore.ts Normal file
View File

@@ -0,0 +1,104 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { SdkContextClass } from "../contexts/SDKContext";
import SettingsStore from "../settings/SettingsStore";
import { isLocalRoom } from "../utils/localRoom/isLocalRoom";
import Timer from "../utils/Timer";
const TYPING_USER_TIMEOUT = 10000;
const TYPING_SERVER_TIMEOUT = 30000;
/**
* Tracks typing state for users.
*/
export default class TypingStore {
private typingStates: {
[roomId: string]: {
isTyping: boolean;
userTimer: Timer;
serverTimer: Timer;
};
} = {};
public constructor(private readonly context: SdkContextClass) {
this.reset();
}
/**
* Clears all cached typing states. Intended to be called when the
* MatrixClientPeg client changes.
*/
public reset(): void {
this.typingStates = {
// "roomId": {
// isTyping: bool, // Whether the user is typing or not
// userTimer: Timer, // Local timeout for "user has stopped typing"
// serverTimer: Timer, // Maximum timeout for the typing state
// },
};
}
/**
* Changes the typing status for the MatrixClientPeg user.
* @param {string} roomId The room ID to set the typing state in.
* @param {boolean} isTyping Whether the user is typing or not.
*/
public setSelfTyping(roomId: string, threadId: string | null, isTyping: boolean): void {
// No typing notifications for local rooms
if (isLocalRoom(roomId)) return;
if (!SettingsStore.getValue("sendTypingNotifications")) return;
if (SettingsStore.getValue("lowBandwidth")) return;
// Disable typing notification for threads for the initial launch
// before we figure out a better user experience for them
if (threadId) return;
let currentTyping = this.typingStates[roomId];
if ((!isTyping && !currentTyping) || currentTyping?.isTyping === isTyping) {
// No change in state, so don't do anything. We'll let the timer run its course.
return;
}
if (!currentTyping) {
currentTyping = this.typingStates[roomId] = {
isTyping: isTyping,
serverTimer: new Timer(TYPING_SERVER_TIMEOUT),
userTimer: new Timer(TYPING_USER_TIMEOUT),
};
}
currentTyping.isTyping = isTyping;
if (isTyping) {
if (!currentTyping.serverTimer.isRunning()) {
currentTyping.serverTimer
.restart()
.finished()
.then(() => {
const currentTyping = this.typingStates[roomId];
if (currentTyping) currentTyping.isTyping = false;
// The server will (should) time us out on typing, so we don't
// need to advertise a stop of typing.
});
} else currentTyping.serverTimer.restart();
if (!currentTyping.userTimer.isRunning()) {
currentTyping.userTimer
.restart()
.finished()
.then(() => {
this.setSelfTyping(roomId, threadId, false);
});
} else currentTyping.userTimer.restart();
}
this.context.client?.sendTyping(roomId, isTyping, TYPING_SERVER_TIMEOUT);
}
}

100
src/stores/UIStore.ts Normal file
View File

@@ -0,0 +1,100 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import EventEmitter from "events";
export enum UI_EVENTS {
Resize = "resize",
}
export default class UIStore extends EventEmitter {
private static _instance: UIStore | null = null;
private resizeObserver: ResizeObserver;
private uiElementDimensions = new Map<string, DOMRectReadOnly>();
private trackedUiElements = new Map<Element, string>();
public windowWidth: number;
public windowHeight: number;
public constructor() {
super();
// eslint-disable-next-line no-restricted-properties
this.windowWidth = window.innerWidth;
// eslint-disable-next-line no-restricted-properties
this.windowHeight = window.innerHeight;
this.resizeObserver = new ResizeObserver(this.resizeObserverCallback);
this.resizeObserver.observe(document.body);
}
public static get instance(): UIStore {
if (!UIStore._instance) {
UIStore._instance = new UIStore();
}
return UIStore._instance;
}
public static destroy(): void {
if (UIStore._instance) {
UIStore._instance.resizeObserver.disconnect();
UIStore._instance.removeAllListeners();
UIStore._instance = null;
}
}
public getElementDimensions(name: string): DOMRectReadOnly | undefined {
return this.uiElementDimensions.get(name);
}
public trackElementDimensions(name: string, element: Element): void {
this.trackedUiElements.set(element, name);
this.resizeObserver.observe(element);
}
public stopTrackingElementDimensions(name: string): void {
let trackedElement: Element | undefined;
this.trackedUiElements.forEach((trackedElementName, element) => {
if (trackedElementName === name) {
trackedElement = element;
}
});
if (trackedElement) {
this.resizeObserver.unobserve(trackedElement);
this.uiElementDimensions.delete(name);
this.trackedUiElements.delete(trackedElement);
}
}
public isTrackingElementDimensions(name: string): boolean {
return this.uiElementDimensions.has(name);
}
private resizeObserverCallback = (entries: ResizeObserverEntry[]): void => {
const windowEntry = entries.find((entry) => entry.target === document.body);
if (windowEntry) {
this.windowWidth = windowEntry.contentRect.width;
this.windowHeight = windowEntry.contentRect.height;
}
entries.forEach((entry) => {
const trackedElementName = this.trackedUiElements.get(entry.target);
if (trackedElementName) {
this.uiElementDimensions.set(trackedElementName, entry.contentRect);
this.emit(trackedElementName, UI_EVENTS.Resize, entry);
}
});
this.emit(UI_EVENTS.Resize, entries);
};
}
window.mxUIStore = UIStore.instance;

View File

@@ -0,0 +1,198 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { logger } from "matrix-js-sdk/src/logger";
import {
IMatrixProfile,
MatrixClient,
MatrixError,
MatrixEvent,
RoomMember,
RoomMemberEvent,
} from "matrix-js-sdk/src/matrix";
import { LruCache } from "../utils/LruCache";
const cacheSize = 500;
type StoreProfileValue = IMatrixProfile | undefined | null;
interface GetOptions {
/** Whether calling the function shouuld raise an Error. */
shouldThrow: boolean;
}
/**
* This store provides cached access to user profiles.
* Listens for membership events and invalidates the cache for a profile on update with different profile values.
*/
export class UserProfilesStore {
private profiles = new LruCache<string, IMatrixProfile | null>(cacheSize);
private profileLookupErrors = new LruCache<string, MatrixError>(cacheSize);
private knownProfiles = new LruCache<string, IMatrixProfile | null>(cacheSize);
public constructor(private client: MatrixClient) {
client.on(RoomMemberEvent.Membership, this.onRoomMembershipEvent);
}
/**
* Synchronously get a profile from the store cache.
*
* @param userId - User Id of the profile to fetch
* @returns The profile, if cached by the store.
* Null if the profile does not exist.
* Undefined if the profile is not cached by the store.
* In this case a profile can be fetched from the API via {@link fetchProfile}.
*/
public getProfile(userId: string): StoreProfileValue {
return this.profiles.get(userId);
}
/**
* Async shortcut function that returns the profile from cache or
* or fetches it on cache miss.
*
* @param userId - User Id of the profile to get or fetch
* @returns The profile, if cached by the store or fetched from the API.
* Null if the profile does not exist or an error occurred during fetch.
*/
public async getOrFetchProfile(userId: string, options?: GetOptions): Promise<IMatrixProfile | null> {
const cachedProfile = this.profiles.get(userId);
if (cachedProfile) return cachedProfile;
return this.fetchProfile(userId, options);
}
/**
* Get a profile lookup error.
*
* @param userId - User Id for which to get the lookup error
* @returns The lookup error or undefined if there was no error or the profile was not fetched.
*/
public getProfileLookupError(userId: string): MatrixError | undefined {
return this.profileLookupErrors.get(userId);
}
/**
* Synchronously get a profile from known users from the store cache.
* Known user means that at least one shared room with the user exists.
*
* @param userId - User Id of the profile to fetch
* @returns The profile, if cached by the store.
* Null if the profile does not exist.
* Undefined if the profile is not cached by the store.
* In this case a profile can be fetched from the API via {@link fetchOnlyKnownProfile}.
*/
public getOnlyKnownProfile(userId: string): StoreProfileValue {
return this.knownProfiles.get(userId);
}
/**
* Asynchronousely fetches a profile from the API.
* Stores the result in the cache, so that next time {@link getProfile} returns this value.
*
* @param userId - User Id for which the profile should be fetched for
* @returns The profile, if found.
* Null if the profile does not exist or there was an error fetching it.
*/
public async fetchProfile(userId: string, options?: GetOptions): Promise<IMatrixProfile | null> {
const profile = await this.fetchProfileFromApi(userId, options);
this.profiles.set(userId, profile);
return profile;
}
/**
* Asynchronousely fetches a profile from a known user from the API.
* Known user means that at least one shared room with the user exists.
* Stores the result in the cache, so that next time {@link getOnlyKnownProfile} returns this value.
*
* @param userId - User Id for which the profile should be fetched for
* @returns The profile, if found.
* Undefined if the user is unknown.
* Null if the profile does not exist or there was an error fetching it.
*/
public async fetchOnlyKnownProfile(userId: string): Promise<StoreProfileValue> {
// Do not look up unknown users. The test for existence in knownProfiles is a performance optimisation.
// If the user Id exists in knownProfiles we know them.
if (!this.knownProfiles.has(userId) && !this.isUserIdKnown(userId)) return undefined;
const profile = await this.fetchProfileFromApi(userId);
this.knownProfiles.set(userId, profile);
return profile;
}
public flush(): void {
this.profiles = new LruCache<string, IMatrixProfile | null>(cacheSize);
this.profileLookupErrors = new LruCache<string, MatrixError>(cacheSize);
this.knownProfiles = new LruCache<string, IMatrixProfile | null>(cacheSize);
}
/**
* Looks up a user profile via API.
*
* @param userId - User Id for which the profile should be fetched for
* @returns The profile information or null on errors
*/
private async fetchProfileFromApi(userId: string, options?: GetOptions): Promise<IMatrixProfile | null> {
// invalidate cached profile errors
this.profileLookupErrors.delete(userId);
try {
return (await this.client.getProfileInfo(userId)) ?? null;
} catch (e) {
logger.warn(`Error retrieving profile for userId ${userId}`, e);
if (e instanceof MatrixError) {
this.profileLookupErrors.set(userId, e);
}
if (options?.shouldThrow) {
throw e;
}
}
return null;
}
/**
* Whether at least one shared room with the userId exists.
*
* @param userId
* @returns true: at least one room shared with user identified by its Id, else false.
*/
private isUserIdKnown(userId: string): boolean {
return this.client.getRooms().some((room) => {
return !!room.getMember(userId);
});
}
/**
* Simple cache invalidation if a room membership event is received and
* at least one profile value differs from the cached one.
*/
private onRoomMembershipEvent = (event: MatrixEvent, member: RoomMember): void => {
const profile = this.profiles.get(member.userId);
if (
profile &&
(profile.displayname !== member.rawDisplayName || profile.avatar_url !== member.getMxcAvatarUrl())
) {
this.profiles.delete(member.userId);
}
const knownProfile = this.knownProfiles.get(member.userId);
if (
knownProfile &&
(knownProfile.displayname !== member.rawDisplayName || knownProfile.avatar_url !== member.getMxcAvatarUrl())
) {
this.knownProfiles.delete(member.userId);
}
};
}

View File

@@ -0,0 +1,99 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021, 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Optional } from "matrix-events-sdk";
import { Room, IEventRelation, RelationType } from "matrix-js-sdk/src/matrix";
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
import defaultDispatcher from "../dispatcher/dispatcher";
import { ActionPayload } from "../dispatcher/payloads";
import { createVoiceMessageRecording, VoiceMessageRecording } from "../audio/VoiceMessageRecording";
const SEPARATOR = "|";
interface IState {
[voiceRecordingId: string]: Optional<VoiceMessageRecording>;
}
export class VoiceRecordingStore extends AsyncStoreWithClient<IState> {
private static internalInstance: VoiceRecordingStore;
public constructor() {
super(defaultDispatcher, {});
}
public static get instance(): VoiceRecordingStore {
if (!this.internalInstance) {
this.internalInstance = new VoiceRecordingStore();
this.internalInstance.start();
}
return this.internalInstance;
}
protected async onAction(payload: ActionPayload): Promise<void> {
// Nothing to do, but we're required to override the function
return;
}
public static getVoiceRecordingId(room: Room, relation?: IEventRelation): string {
if (relation?.rel_type === "io.element.thread" || relation?.rel_type === RelationType.Thread) {
return room.roomId + SEPARATOR + relation.event_id;
} else {
return room.roomId;
}
}
/**
* Gets the active recording instance, if any.
* @param {string} voiceRecordingId The room ID (with optionally the thread ID if in one) to get the recording in.
* @returns {Optional<VoiceRecording>} The recording, if any.
*/
public getActiveRecording(voiceRecordingId: string): Optional<VoiceMessageRecording> {
return this.state[voiceRecordingId];
}
/**
* Starts a new recording if one isn't already in progress. Note that this simply
* creates a recording instance - whether or not recording is actively in progress
* can be seen via the VoiceRecording class.
* @param {string} voiceRecordingId The room ID (with optionally the thread ID if in one) to start recording in.
* @returns {VoiceRecording} The recording.
*/
public startRecording(voiceRecordingId?: string): VoiceMessageRecording {
if (!this.matrixClient) throw new Error("Cannot start a recording without a MatrixClient");
if (!voiceRecordingId) throw new Error("Recording must be associated with a room");
if (this.state[voiceRecordingId]) throw new Error("A recording is already in progress");
const recording = createVoiceMessageRecording(this.matrixClient);
// noinspection JSIgnoredPromiseFromCall - we can safely run this async
this.updateState({ ...this.state, [voiceRecordingId]: recording });
return recording;
}
/**
* Disposes of the current recording, no matter the state of it.
* @param {string} voiceRecordingId The room ID (with optionally the thread ID if in one) to dispose of the recording in.
* @returns {Promise<void>} Resolves when complete.
*/
public disposeRecording(voiceRecordingId: string): Promise<void> {
this.state[voiceRecordingId]?.destroy(); // stops internally
const {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
[voiceRecordingId]: _toDelete,
...newState
} = this.state;
// unexpectedly AsyncStore.updateState merges state
// AsyncStore.reset actually just *sets*
return this.reset(newState);
}
}
window.mxVoiceRecordingStore = VoiceRecordingStore.instance;

View File

@@ -0,0 +1,110 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2018-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import EventEmitter from "events";
import { IWidget } from "matrix-widget-api";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import { WidgetType } from "../widgets/WidgetType";
/**
* Acts as a place to get & set widget state, storing local echo state and
* proxying through state from the js-sdk.
*/
class WidgetEchoStore extends EventEmitter {
private roomWidgetEcho: {
[roomId: string]: {
[widgetId: string]: IWidget;
};
};
public constructor() {
super();
this.roomWidgetEcho = {
// Map as below. Object is the content of the widget state event,
// so for widgets that have been deleted locally, the object is empty.
// roomId: {
// widgetId: IWidget
// }
};
}
/**
* Gets the widgets for a room, subtracting those that are pending deletion.
* Widgets that are pending addition are not included, since widgets are
* represented as MatrixEvents, so to do this we'd have to create fake MatrixEvents,
* and we don't really need the actual widget events anyway since we just want to
* show a spinner / prevent widgets being added twice.
*
* @param {string} roomId The ID of the room to get widgets for
* @param {MatrixEvent[]} currentRoomWidgets Current widgets for the room
* @returns {MatrixEvent[]} List of widgets in the room, minus any pending removal
*/
public getEchoedRoomWidgets(roomId: string, currentRoomWidgets: MatrixEvent[]): MatrixEvent[] {
const echoedWidgets: MatrixEvent[] = [];
const roomEchoState = Object.assign({}, this.roomWidgetEcho[roomId]);
for (const w of currentRoomWidgets) {
const widgetId = w.getStateKey()!;
// If there's no echo, or the echo still has a widget present, show the *old* widget
// we don't include widgets that have changed for the same reason we don't include new ones,
// ie. we'd need to fake matrix events to do so and there's currently no need.
if (!roomEchoState[widgetId] || Object.keys(roomEchoState[widgetId]).length !== 0) {
echoedWidgets.push(w);
}
delete roomEchoState[widgetId];
}
return echoedWidgets;
}
public roomHasPendingWidgetsOfType(roomId: string, currentRoomWidgets: MatrixEvent[], type?: WidgetType): boolean {
const roomEchoState = Object.assign({}, this.roomWidgetEcho[roomId]);
// any widget IDs that are already in the room are not pending, so
// echoes for them don't count as pending.
for (const w of currentRoomWidgets) {
const widgetId = w.getStateKey()!;
delete roomEchoState[widgetId];
}
// if there's anything left then there are pending widgets.
if (type === undefined) {
return Object.keys(roomEchoState).length > 0;
} else {
return Object.values(roomEchoState).some((widget) => {
return type.matches(widget.type);
});
}
}
public roomHasPendingWidgets(roomId: string, currentRoomWidgets: MatrixEvent[]): boolean {
return this.roomHasPendingWidgetsOfType(roomId, currentRoomWidgets);
}
public setRoomWidgetEcho(roomId: string, widgetId: string, state: IWidget): void {
if (this.roomWidgetEcho[roomId] === undefined) this.roomWidgetEcho[roomId] = {};
this.roomWidgetEcho[roomId][widgetId] = state;
this.emit("update", roomId, widgetId);
}
public removeRoomWidgetEcho(roomId: string, widgetId: string): void {
delete this.roomWidgetEcho[roomId][widgetId];
if (Object.keys(this.roomWidgetEcho[roomId]).length === 0) delete this.roomWidgetEcho[roomId];
this.emit("update", roomId, widgetId);
}
}
let singletonWidgetEchoStore: WidgetEchoStore | null = null;
if (!singletonWidgetEchoStore) {
singletonWidgetEchoStore = new WidgetEchoStore();
}
export default singletonWidgetEchoStore!;

212
src/stores/WidgetStore.ts Normal file
View File

@@ -0,0 +1,212 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Room, RoomStateEvent, MatrixEvent, ClientEvent } from "matrix-js-sdk/src/matrix";
import { IWidget } from "matrix-widget-api";
import { logger } from "matrix-js-sdk/src/logger";
import { ActionPayload } from "../dispatcher/payloads";
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
import defaultDispatcher from "../dispatcher/dispatcher";
import WidgetEchoStore from "../stores/WidgetEchoStore";
import ActiveWidgetStore from "../stores/ActiveWidgetStore";
import WidgetUtils from "../utils/WidgetUtils";
import { UPDATE_EVENT } from "./AsyncStore";
interface IState {}
export interface IApp extends IWidget {
"roomId": string;
"eventId"?: string; // not present on virtual widgets
// eslint-disable-next-line camelcase
"avatar_url"?: string; // MSC2765 https://github.com/matrix-org/matrix-doc/pull/2765
// Whether the widget was created from `widget_build_url` and thus is a call widget of some kind
"io.element.managed_hybrid"?: boolean;
}
export function isAppWidget(widget: IWidget | IApp): widget is IApp {
return "roomId" in widget && typeof widget.roomId === "string";
}
export function isVirtualWidget(widget: IApp): boolean {
return widget.eventId === undefined;
}
interface IRoomWidgets {
widgets: IApp[];
}
// TODO consolidate WidgetEchoStore into this
// TODO consolidate ActiveWidgetStore into this
export default class WidgetStore extends AsyncStoreWithClient<IState> {
private static readonly internalInstance = (() => {
const instance = new WidgetStore();
instance.start();
return instance;
})();
private widgetMap = new Map<string, IApp>(); // Key is widget Unique ID (UID)
private roomMap = new Map<string, IRoomWidgets>(); // Key is room ID
private constructor() {
super(defaultDispatcher, {});
WidgetEchoStore.on("update", this.onWidgetEchoStoreUpdate);
}
public static get instance(): WidgetStore {
return WidgetStore.internalInstance;
}
private initRoom(roomId: string): void {
if (!this.roomMap.has(roomId)) {
this.roomMap.set(roomId, {
widgets: [],
});
}
}
protected async onReady(): Promise<any> {
if (!this.matrixClient) return;
this.matrixClient.on(ClientEvent.Room, this.onRoom);
this.matrixClient.on(RoomStateEvent.Events, this.onRoomStateEvents);
this.matrixClient.getRooms().forEach((room: Room) => {
this.loadRoomWidgets(room);
});
this.emit(UPDATE_EVENT, null); // emit for all rooms
}
protected async onNotReady(): Promise<any> {
if (this.matrixClient) {
this.matrixClient.off(ClientEvent.Room, this.onRoom);
this.matrixClient.off(RoomStateEvent.Events, this.onRoomStateEvents);
}
this.widgetMap = new Map();
this.roomMap = new Map();
await this.reset({});
}
// We don't need this, but our contract says we do.
protected async onAction(payload: ActionPayload): Promise<void> {
return;
}
private onWidgetEchoStoreUpdate = (roomId: string): void => {
this.initRoom(roomId);
this.loadRoomWidgets(this.matrixClient?.getRoom(roomId) ?? null);
this.emit(UPDATE_EVENT, roomId);
};
private generateApps(room: Room): IApp[] {
return WidgetEchoStore.getEchoedRoomWidgets(room.roomId, WidgetUtils.getRoomWidgets(room)).map((ev) => {
return WidgetUtils.makeAppConfig(
ev.getStateKey()!,
ev.getContent(),
ev.getSender()!,
ev.getRoomId(),
ev.getId(),
);
});
}
private loadRoomWidgets(room: Room | null): void {
if (!room) return;
const roomInfo = this.roomMap.get(room.roomId) || <IRoomWidgets>{};
roomInfo.widgets = [];
// first clean out old widgets from the map which originate from this room
// otherwise we are out of sync with the rest of the app with stale widget events during removal
Array.from(this.widgetMap.values()).forEach((app) => {
if (app.roomId !== room.roomId) return; // skip - wrong room
if (isVirtualWidget(app)) {
// virtual widget - keep it
roomInfo.widgets.push(app);
} else {
this.widgetMap.delete(WidgetUtils.getWidgetUid(app));
}
});
let edited = false;
this.generateApps(room).forEach((app) => {
// Sanity check for https://github.com/vector-im/element-web/issues/15705
const existingApp = this.widgetMap.get(WidgetUtils.getWidgetUid(app));
if (existingApp) {
logger.warn(
`Possible widget ID conflict for ${app.id} - wants to store in room ${app.roomId} ` +
`but is currently stored as ${existingApp.roomId} - letting the want win`,
);
}
this.widgetMap.set(WidgetUtils.getWidgetUid(app), app);
roomInfo.widgets.push(app);
edited = true;
});
if (edited && !this.roomMap.has(room.roomId)) {
this.roomMap.set(room.roomId, roomInfo);
}
// If a persistent widget is active, check to see if it's just been removed.
// If it has, it needs to destroyed otherwise unmounting the node won't kill it
const persistentWidgetId = ActiveWidgetStore.instance.getPersistentWidgetId();
if (
persistentWidgetId &&
ActiveWidgetStore.instance.getPersistentRoomId() === room.roomId &&
!roomInfo.widgets.some((w) => w.id === persistentWidgetId)
) {
logger.log(`Persistent widget ${persistentWidgetId} removed from room ${room.roomId}: destroying.`);
ActiveWidgetStore.instance.destroyPersistentWidget(persistentWidgetId, room.roomId);
}
this.emit(room.roomId);
}
private onRoom = (room: Room): void => {
this.initRoom(room.roomId);
this.loadRoomWidgets(room);
this.emit(UPDATE_EVENT, room.roomId);
};
private onRoomStateEvents = (ev: MatrixEvent): void => {
if (ev.getType() !== "im.vector.modular.widgets") return; // TODO: Support m.widget too
const roomId = ev.getRoomId()!;
this.initRoom(roomId);
this.loadRoomWidgets(this.matrixClient?.getRoom(roomId) ?? null);
this.emit(UPDATE_EVENT, roomId);
};
public get(widgetId: string, roomId: string | undefined): IApp | undefined {
return this.widgetMap.get(WidgetUtils.calcWidgetUid(widgetId, roomId));
}
public getRoom(roomId: string, initIfNeeded = false): IRoomWidgets {
if (initIfNeeded) this.initRoom(roomId); // internally handles "if needed"
return this.roomMap.get(roomId)!;
}
public getApps(roomId: string): IApp[] {
const roomInfo = this.getRoom(roomId);
return roomInfo?.widgets || [];
}
public addVirtualWidget(widget: IWidget, roomId: string): IApp {
this.initRoom(roomId);
const app = WidgetUtils.makeAppConfig(widget.id, widget, widget.creatorUserId, roomId, undefined);
this.widgetMap.set(WidgetUtils.getWidgetUid(app), app);
this.roomMap.get(roomId)!.widgets.push(app);
return app;
}
public removeVirtualWidget(widgetId: string, roomId: string): void {
this.widgetMap.delete(WidgetUtils.calcWidgetUid(widgetId, roomId));
const roomApps = this.roomMap.get(roomId);
if (roomApps) {
roomApps.widgets = roomApps.widgets.filter((app) => !(app.id === widgetId && app.roomId === roomId));
}
}
}
window.mxWidgetStore = WidgetStore.instance;

View File

@@ -0,0 +1,23 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Room } from "matrix-js-sdk/src/matrix";
import { RoomEchoChamber } from "./RoomEchoChamber";
import { EchoStore } from "./EchoStore";
/**
* Semantic access to local echo
*/
export class EchoChamber {
private constructor() {}
public static forRoom(room: Room): RoomEchoChamber {
return EchoStore.instance.getOrCreateChamberForRoom(room);
}
}

View File

@@ -0,0 +1,79 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { EchoTransaction, RunFn, TransactionStatus } from "./EchoTransaction";
import { arrayFastClone } from "../../utils/arrays";
import { IDestroyable } from "../../utils/IDestroyable";
import { Whenable } from "../../utils/Whenable";
export enum ContextTransactionState {
NotStarted,
PendingErrors,
AllSuccessful,
}
export abstract class EchoContext extends Whenable<ContextTransactionState> implements IDestroyable {
private _transactions: EchoTransaction[] = [];
private _state = ContextTransactionState.NotStarted;
public get transactions(): EchoTransaction[] {
return arrayFastClone(this._transactions);
}
public get state(): ContextTransactionState {
return this._state;
}
public get firstFailedTime(): Date | null {
const failedTxn = this.transactions.find((t) => t.didPreviouslyFail || t.status === TransactionStatus.Error);
if (failedTxn) return failedTxn.startTime;
return null;
}
public disownTransaction(txn: EchoTransaction): void {
const idx = this._transactions.indexOf(txn);
if (idx >= 0) this._transactions.splice(idx, 1);
txn.destroy();
this.checkTransactions();
}
public beginTransaction(auditName: string, runFn: RunFn): EchoTransaction {
const txn = new EchoTransaction(auditName, runFn);
this._transactions.push(txn);
txn.whenAnything(this.checkTransactions);
// We have no intent to call the transaction again if it succeeds (in fact, it'll
// be really angry at us if we do), so call that the end of the road for the events.
txn.when(TransactionStatus.Success, () => txn.destroy());
return txn;
}
private checkTransactions = (): void => {
let status = ContextTransactionState.AllSuccessful;
for (const txn of this.transactions) {
if (txn.status === TransactionStatus.Error || txn.didPreviouslyFail) {
status = ContextTransactionState.PendingErrors;
break;
} else if (txn.status === TransactionStatus.Pending) {
status = ContextTransactionState.NotStarted;
// no break as we might hit something which broke
}
}
this._state = status;
this.notifyCondition(status);
};
public destroy(): void {
for (const txn of this.transactions) {
txn.destroy();
}
this._transactions = [];
super.destroy();
}
}

View File

@@ -0,0 +1,95 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Room } from "matrix-js-sdk/src/matrix";
import { GenericEchoChamber } from "./GenericEchoChamber";
import { RoomEchoChamber } from "./RoomEchoChamber";
import { RoomEchoContext } from "./RoomEchoContext";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { ActionPayload } from "../../dispatcher/payloads";
import { ContextTransactionState, EchoContext } from "./EchoContext";
import NonUrgentToastStore, { ToastReference } from "../NonUrgentToastStore";
import NonUrgentEchoFailureToast from "../../components/views/toasts/NonUrgentEchoFailureToast";
interface IState {
toastRef: ToastReference;
}
type ContextKey = string;
const roomContextKey = (room: Room): ContextKey => `room-${room.roomId}`;
export class EchoStore extends AsyncStoreWithClient<IState> {
private static _instance: EchoStore;
private caches = new Map<ContextKey, GenericEchoChamber<any, any, any>>();
public constructor() {
super(defaultDispatcher);
}
public static get instance(): EchoStore {
if (!this._instance) {
this._instance = new EchoStore();
this._instance.start();
}
return this._instance;
}
public get contexts(): EchoContext[] {
return Array.from(this.caches.values()).map((e) => e.context);
}
public getOrCreateChamberForRoom(room: Room): RoomEchoChamber {
if (this.caches.has(roomContextKey(room))) {
return this.caches.get(roomContextKey(room)) as RoomEchoChamber;
}
const context = new RoomEchoContext(room);
context.whenAnything(() => this.checkContexts());
const echo = new RoomEchoChamber(context);
echo.setClient(this.matrixClient);
this.caches.set(roomContextKey(room), echo);
return echo;
}
private async checkContexts(): Promise<void> {
let hasOrHadError = false;
for (const echo of this.caches.values()) {
hasOrHadError = echo.context.state === ContextTransactionState.PendingErrors;
if (hasOrHadError) break;
}
if (hasOrHadError && !this.state.toastRef) {
const ref = NonUrgentToastStore.instance.addToast(NonUrgentEchoFailureToast);
await this.updateState({ toastRef: ref });
} else if (!hasOrHadError && this.state.toastRef) {
NonUrgentToastStore.instance.removeToast(this.state.toastRef);
await this.updateState({ toastRef: null });
}
}
protected async onReady(): Promise<any> {
if (!this.caches) return; // can only happen during initialization
for (const echo of this.caches.values()) {
echo.setClient(this.matrixClient);
}
}
protected async onNotReady(): Promise<any> {
for (const echo of this.caches.values()) {
echo.setClient(null);
}
}
protected async onAction(payload: ActionPayload): Promise<void> {}
}

View File

@@ -0,0 +1,64 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Whenable } from "../../utils/Whenable";
export type RunFn = () => Promise<void>;
export enum TransactionStatus {
Pending,
Success,
Error,
}
export class EchoTransaction extends Whenable<TransactionStatus> {
private _status = TransactionStatus.Pending;
private didFail = false;
public readonly startTime = new Date();
public constructor(
public readonly auditName: string,
public runFn: RunFn,
) {
super();
}
public get didPreviouslyFail(): boolean {
return this.didFail;
}
public get status(): TransactionStatus {
return this._status;
}
public run(): void {
if (this.status === TransactionStatus.Success) {
throw new Error("Cannot re-run a successful echo transaction");
}
this.setStatus(TransactionStatus.Pending);
this.runFn()
.then(() => this.setStatus(TransactionStatus.Success))
.catch(() => this.setStatus(TransactionStatus.Error));
}
public cancel(): void {
// Success basically means "done"
this.setStatus(TransactionStatus.Success);
}
private setStatus(status: TransactionStatus): void {
this._status = status;
if (status === TransactionStatus.Error) {
this.didFail = true;
} else if (status === TransactionStatus.Success) {
this.didFail = false;
}
this.notifyCondition(status);
}
}

View File

@@ -0,0 +1,89 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { EventEmitter } from "events";
import { EchoContext } from "./EchoContext";
import { EchoTransaction, RunFn, TransactionStatus } from "./EchoTransaction";
export async function implicitlyReverted(): Promise<void> {
// do nothing :D
}
export const PROPERTY_UPDATED = "property_updated";
export abstract class GenericEchoChamber<C extends EchoContext, K, V> extends EventEmitter {
private cache = new Map<K, { txn: EchoTransaction; val: V }>();
protected matrixClient: MatrixClient | null = null;
protected constructor(
public readonly context: C,
private lookupFn: (key: K) => V,
) {
super();
}
public setClient(client: MatrixClient | null): void {
const oldClient = this.matrixClient;
this.matrixClient = client;
this.onClientChanged(oldClient, client);
}
protected abstract onClientChanged(oldClient: MatrixClient | null, newClient: MatrixClient | null): void;
/**
* Gets a value. If the key is in flight, the cached value will be returned. If
* the key is not in flight then the lookupFn provided to this class will be
* called instead.
* @param key The key to look up.
* @returns The value for the key.
*/
public getValue(key: K): V {
return this.cache.has(key) ? this.cache.get(key)!.val : this.lookupFn(key);
}
private cacheVal(key: K, val: V, txn: EchoTransaction): void {
this.cache.set(key, { txn, val });
this.emit(PROPERTY_UPDATED, key);
}
private decacheKey(key: K): void {
if (this.cache.has(key)) {
this.context.disownTransaction(this.cache.get(key)!.txn);
this.cache.delete(key);
this.emit(PROPERTY_UPDATED, key);
}
}
protected markEchoReceived(key: K): void {
if (this.cache.has(key)) {
const txn = this.cache.get(key)!.txn;
this.context.disownTransaction(txn);
txn.cancel();
}
this.decacheKey(key);
}
public setValue(auditName: string, key: K, targetVal: V, runFn: RunFn, revertFn: RunFn): void {
// Cancel any pending transactions for the same key
if (this.cache.has(key)) {
this.cache.get(key)!.txn.cancel();
}
const ctxn = this.context.beginTransaction(auditName, runFn);
this.cacheVal(key, targetVal, ctxn); // set the cache now as it won't be updated by the .when() ladder below.
ctxn.when(TransactionStatus.Pending, () => this.cacheVal(key, targetVal, ctxn)).when(
TransactionStatus.Error,
() => revertFn(),
);
ctxn.run();
}
}

View File

@@ -0,0 +1,76 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MatrixEvent, ClientEvent, MatrixClient, EventType } from "matrix-js-sdk/src/matrix";
import { GenericEchoChamber, implicitlyReverted, PROPERTY_UPDATED } from "./GenericEchoChamber";
import { getRoomNotifsState, RoomNotifState, setRoomNotifsState } from "../../RoomNotifs";
import { RoomEchoContext } from "./RoomEchoContext";
import { _t } from "../../languageHandler";
export enum CachedRoomKey {
NotificationVolume,
}
export class RoomEchoChamber extends GenericEchoChamber<RoomEchoContext, CachedRoomKey, RoomNotifState | undefined> {
private properties = new Map<CachedRoomKey, RoomNotifState>();
public constructor(context: RoomEchoContext) {
super(context, (k) => this.properties.get(k));
}
protected onClientChanged(oldClient: MatrixClient | null, newClient: MatrixClient | null): void {
this.properties.clear();
oldClient?.removeListener(ClientEvent.AccountData, this.onAccountData);
if (newClient) {
// Register the listeners first
newClient.on(ClientEvent.AccountData, this.onAccountData);
// Then populate the properties map
this.updateNotificationVolume();
}
}
private onAccountData = (event: MatrixEvent): void => {
if (!this.matrixClient) return;
if (event.getType() === EventType.PushRules) {
const currentVolume = this.properties.get(CachedRoomKey.NotificationVolume);
const newVolume = getRoomNotifsState(this.matrixClient, this.context.room.roomId);
if (currentVolume !== newVolume) {
this.updateNotificationVolume();
}
}
};
private updateNotificationVolume(): void {
const state = this.matrixClient ? getRoomNotifsState(this.matrixClient, this.context.room.roomId) : null;
if (state) this.properties.set(CachedRoomKey.NotificationVolume, state);
else this.properties.delete(CachedRoomKey.NotificationVolume);
this.markEchoReceived(CachedRoomKey.NotificationVolume);
this.emit(PROPERTY_UPDATED, CachedRoomKey.NotificationVolume);
}
// ---- helpers below here ----
public get notificationVolume(): RoomNotifState | undefined {
return this.getValue(CachedRoomKey.NotificationVolume);
}
public set notificationVolume(v: RoomNotifState | undefined) {
if (v === undefined) return;
this.setValue(
_t("notifications|error_change_title"),
CachedRoomKey.NotificationVolume,
v,
async (): Promise<void> => {
return setRoomNotifsState(this.context.room.client, this.context.room.roomId, v);
},
implicitlyReverted,
);
}
}

View File

@@ -0,0 +1,17 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Room } from "matrix-js-sdk/src/matrix";
import { EchoContext } from "./EchoContext";
export class RoomEchoContext extends EchoContext {
public constructor(public readonly room: Room) {
super();
}
}

View File

@@ -0,0 +1,95 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020-2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Room } from "matrix-js-sdk/src/matrix";
import { NotificationLevel } from "./NotificationLevel";
import { arrayDiff } from "../../utils/arrays";
import { RoomNotificationState } from "./RoomNotificationState";
import { NotificationState, NotificationStateEvents } from "./NotificationState";
export type FetchRoomFn = (room: Room) => RoomNotificationState;
export class ListNotificationState extends NotificationState {
private rooms: Room[] = [];
private states: { [roomId: string]: RoomNotificationState } = {};
public constructor(
private byTileCount = false,
private getRoomFn: FetchRoomFn,
) {
super();
}
public get symbol(): string | null {
return this._level === NotificationLevel.Unsent ? "!" : null;
}
public setRooms(rooms: Room[]): void {
// If we're only concerned about the tile count, don't bother setting up listeners.
if (this.byTileCount) {
this.rooms = rooms;
this.calculateTotalState();
return;
}
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(NotificationStateEvents.Update, this.onRoomNotificationStateUpdate);
}
for (const newRoom of diff.added) {
const state = this.getRoomFn(newRoom);
state.on(NotificationStateEvents.Update, this.onRoomNotificationStateUpdate);
this.states[newRoom.roomId] = state;
}
this.calculateTotalState();
}
public getForRoom(room: Room): RoomNotificationState {
const state = this.states[room.roomId];
if (!state) throw new Error("Unknown room for notification state");
return state;
}
public destroy(): void {
super.destroy();
for (const state of Object.values(this.states)) {
state.off(NotificationStateEvents.Update, this.onRoomNotificationStateUpdate);
}
this.states = {};
}
private onRoomNotificationStateUpdate = (): void => {
this.calculateTotalState();
};
private calculateTotalState(): void {
const snapshot = this.snapshot();
if (this.byTileCount) {
this._level = NotificationLevel.Highlight;
this._count = this.rooms.length;
} else {
this._count = 0;
this._level = NotificationLevel.None;
for (const state of Object.values(this.states)) {
this._count += state.count;
this._level = Math.max(this.level, state.level);
}
}
// finally, publish an update if needed
this.emitIfUpdated(snapshot);
}
}

View File

@@ -0,0 +1,37 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { _t } from "../../languageHandler";
export enum NotificationLevel {
Muted,
// Inverted (None -> Red) because we do integer comparisons on this
None, // nothing special
// TODO: Remove bold with notifications: https://github.com/vector-im/element-web/issues/14227
Activity, // no badge, show as unread
Notification, // unread notified messages
Highlight, // unread pings
Unsent, // some messages failed to send
}
export function humanReadableNotificationLevel(level: NotificationLevel): string {
switch (level) {
case NotificationLevel.None:
return _t("notifications|level_none");
case NotificationLevel.Activity:
return _t("notifications|level_activity");
case NotificationLevel.Notification:
return _t("notifications|level_notification");
case NotificationLevel.Highlight:
return _t("notifications|level_highlight");
case NotificationLevel.Unsent:
return _t("notifications|level_unsent");
case NotificationLevel.Muted:
return _t("notifications|level_muted");
}
}

View File

@@ -0,0 +1,145 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { TypedEventEmitter } from "matrix-js-sdk/src/matrix";
import { NotificationLevel } from "./NotificationLevel";
import { IDestroyable } from "../../utils/IDestroyable";
import SettingsStore from "../../settings/SettingsStore";
export interface INotificationStateSnapshotParams {
symbol: string | null;
count: number;
level: NotificationLevel;
muted: boolean;
knocked: boolean;
}
export enum NotificationStateEvents {
Update = "update",
}
type EventHandlerMap = {
[NotificationStateEvents.Update]: () => void;
};
export abstract class NotificationState
extends TypedEventEmitter<NotificationStateEvents, EventHandlerMap>
implements INotificationStateSnapshotParams, IDestroyable
{
//
protected _symbol: string | null = null;
protected _count = 0;
protected _level: NotificationLevel = NotificationLevel.None;
protected _muted = false;
protected _knocked = false;
private watcherReferences: string[] = [];
public constructor() {
super();
this.watcherReferences.push(
SettingsStore.watchSetting("feature_hidebold", null, () => {
this.emit(NotificationStateEvents.Update);
}),
);
}
public get symbol(): string | null {
return this._symbol;
}
public get count(): number {
return this._count;
}
public get level(): NotificationLevel {
return this._level;
}
public get muted(): boolean {
return this._muted;
}
public get knocked(): boolean {
return this._knocked;
}
public get isIdle(): boolean {
return this.level <= NotificationLevel.None;
}
public get isUnread(): boolean {
if (this.level > NotificationLevel.Activity) {
return true;
} else {
const hideBold = SettingsStore.getValue("feature_hidebold");
return this.level === NotificationLevel.Activity && !hideBold;
}
}
public get hasUnreadCount(): boolean {
return this.level >= NotificationLevel.Notification && (!!this.count || !!this.symbol);
}
public get hasMentions(): boolean {
return this.level >= NotificationLevel.Highlight;
}
protected emitIfUpdated(snapshot: NotificationStateSnapshot): void {
if (snapshot.isDifferentFrom(this)) {
this.emit(NotificationStateEvents.Update);
}
}
protected snapshot(): NotificationStateSnapshot {
return new NotificationStateSnapshot(this);
}
public destroy(): void {
this.removeAllListeners(NotificationStateEvents.Update);
for (const watcherReference of this.watcherReferences) {
SettingsStore.unwatchSetting(watcherReference);
}
this.watcherReferences = [];
}
}
export class NotificationStateSnapshot {
private readonly symbol: string | null;
private readonly count: number;
private readonly level: NotificationLevel;
private readonly muted: boolean;
private readonly knocked: boolean;
public constructor(state: INotificationStateSnapshotParams) {
this.symbol = state.symbol;
this.count = state.count;
this.level = state.level;
this.muted = state.muted;
this.knocked = state.knocked;
}
public isDifferentFrom(other: INotificationStateSnapshotParams): boolean {
const before = {
count: this.count,
symbol: this.symbol,
level: this.level,
muted: this.muted,
knocked: this.knocked,
};
const after = {
count: other.count,
symbol: other.symbol,
level: other.level,
muted: other.muted,
knocked: other.knocked,
};
return JSON.stringify(before) !== JSON.stringify(after);
}
}

View File

@@ -0,0 +1,112 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020-2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MatrixEventEvent, RoomEvent, ClientEvent } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import type { Room, MatrixEvent } from "matrix-js-sdk/src/matrix";
import type { IDestroyable } from "../../utils/IDestroyable";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import { readReceiptChangeIsFor } from "../../utils/read-receipts";
import * as RoomNotifs from "../../RoomNotifs";
import { NotificationState } from "./NotificationState";
import SettingsStore from "../../settings/SettingsStore";
import { MARKED_UNREAD_TYPE_STABLE, MARKED_UNREAD_TYPE_UNSTABLE } from "../../utils/notifications";
export class RoomNotificationState extends NotificationState implements IDestroyable {
public constructor(
public readonly room: Room,
private includeThreads: boolean,
) {
super();
const cli = this.room.client;
this.room.on(RoomEvent.Receipt, this.handleReadReceipt);
this.room.on(RoomEvent.MyMembership, this.handleMembershipUpdate);
this.room.on(RoomEvent.LocalEchoUpdated, this.handleLocalEchoUpdated);
this.room.on(RoomEvent.Timeline, this.handleRoomEventUpdate);
this.room.on(RoomEvent.Redaction, this.handleRoomEventUpdate);
this.room.on(RoomEvent.AccountData, this.handleRoomAccountDataUpdate);
this.room.on(RoomEvent.UnreadNotifications, this.handleNotificationCountUpdate); // for server-sent counts
cli.on(MatrixEventEvent.Decrypted, this.onEventDecrypted);
cli.on(ClientEvent.AccountData, this.handleAccountDataUpdate);
this.updateNotificationState();
}
public destroy(): void {
super.destroy();
const cli = this.room.client;
this.room.removeListener(RoomEvent.Receipt, this.handleReadReceipt);
this.room.removeListener(RoomEvent.MyMembership, this.handleMembershipUpdate);
this.room.removeListener(RoomEvent.LocalEchoUpdated, this.handleLocalEchoUpdated);
this.room.removeListener(RoomEvent.Timeline, this.handleRoomEventUpdate);
this.room.removeListener(RoomEvent.Redaction, this.handleRoomEventUpdate);
this.room.removeListener(RoomEvent.AccountData, this.handleRoomAccountDataUpdate);
cli.removeListener(MatrixEventEvent.Decrypted, this.onEventDecrypted);
cli.removeListener(ClientEvent.AccountData, this.handleAccountDataUpdate);
}
private handleLocalEchoUpdated = (): void => {
this.updateNotificationState();
};
private handleReadReceipt = (event: MatrixEvent, room: Room): void => {
if (!readReceiptChangeIsFor(event, MatrixClientPeg.safeGet())) return; // not our own - ignore
if (room.roomId !== this.room.roomId) return; // not for us - ignore
this.updateNotificationState();
};
private handleMembershipUpdate = (): void => {
this.updateNotificationState();
};
private handleNotificationCountUpdate = (): void => {
this.updateNotificationState();
};
private onEventDecrypted = (event: MatrixEvent): void => {
if (event.getRoomId() !== this.room.roomId) return; // ignore - not for us or notifications timeline
this.updateNotificationState();
};
private handleRoomEventUpdate = (event: MatrixEvent): void => {
if (event?.getRoomId() !== this.room.roomId) return; // ignore - not for us or notifications timeline
this.updateNotificationState();
};
private handleAccountDataUpdate = (ev: MatrixEvent): void => {
if (ev.getType() === "m.push_rules") {
this.updateNotificationState();
}
};
private handleRoomAccountDataUpdate = (ev: MatrixEvent): void => {
if ([MARKED_UNREAD_TYPE_STABLE, MARKED_UNREAD_TYPE_UNSTABLE].includes(ev.getType())) {
this.updateNotificationState();
}
};
private updateNotificationState(): void {
const snapshot = this.snapshot();
const { level, symbol, count } = RoomNotifs.determineUnreadState(this.room, undefined, this.includeThreads);
const muted =
RoomNotifs.getRoomNotifsState(this.room.client, this.room.roomId) === RoomNotifs.RoomNotifState.Mute;
const knocked =
SettingsStore.getValue("feature_ask_to_join") && this.room.getMyMembership() === KnownMembership.Knock;
this._level = level;
this._symbol = symbol;
this._count = count;
this._muted = muted;
this._knocked = knocked;
// finally, publish an update if needed
this.emitIfUpdated(snapshot);
}
}

View File

@@ -0,0 +1,155 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020-2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Room, ClientEvent, SyncState } from "matrix-js-sdk/src/matrix";
import { ActionPayload } from "../../dispatcher/payloads";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import defaultDispatcher, { MatrixDispatcher } from "../../dispatcher/dispatcher";
import { DefaultTagID, TagID } from "../room-list/models";
import { FetchRoomFn, ListNotificationState } from "./ListNotificationState";
import { RoomNotificationState } from "./RoomNotificationState";
import { SummarizedNotificationState } from "./SummarizedNotificationState";
import { VisibilityProvider } from "../room-list/filters/VisibilityProvider";
import { PosthogAnalytics } from "../../PosthogAnalytics";
import SettingsStore from "../../settings/SettingsStore";
interface IState {}
export const UPDATE_STATUS_INDICATOR = Symbol("update-status-indicator");
export class RoomNotificationStateStore extends AsyncStoreWithClient<IState> {
private static readonly internalInstance = (() => {
const instance = new RoomNotificationStateStore();
instance.start();
return instance;
})();
private roomMap = new Map<Room, RoomNotificationState>();
private listMap = new Map<TagID, ListNotificationState>();
private _globalState = new SummarizedNotificationState();
private constructor(dispatcher = defaultDispatcher) {
super(dispatcher, {});
SettingsStore.watchSetting("feature_dynamic_room_predecessors", null, () => {
// We pass SyncState.Syncing here to "simulate" a sync happening.
// The code that receives these events actually doesn't care
// what state we pass, except that it behaves differently if we
// pass SyncState.Error.
this.emitUpdateIfStateChanged(SyncState.Syncing, false);
});
}
/**
* @internal Public for test only
*/
public static testInstance(dispatcher: MatrixDispatcher): RoomNotificationStateStore {
return new RoomNotificationStateStore();
}
/**
* Gets a snapshot of notification state for all visible rooms. The number of states recorded
* on the SummarizedNotificationState is equivalent to rooms.
*/
public get globalState(): SummarizedNotificationState {
return this._globalState;
}
/**
* Gets an instance of the list state class for the given tag.
* @param tagId The tag to get the notification state for.
* @returns The notification state for the tag.
*/
public getListState(tagId: TagID): ListNotificationState {
if (this.listMap.has(tagId)) {
return this.listMap.get(tagId)!;
}
// TODO: Update if/when invites move out of the room list.
const useTileCount = tagId === DefaultTagID.Invite;
const getRoomFn: FetchRoomFn = (room: Room) => {
return this.getRoomState(room);
};
const state = new ListNotificationState(useTileCount, getRoomFn);
this.listMap.set(tagId, state);
return state;
}
/**
* Gets a copy of the notification state for a room. The consumer should not
* attempt to destroy the returned state as it may be shared with other
* consumers.
* @param room The room to get the notification state for.
* @returns The room's notification state.
*/
public getRoomState(room: Room): RoomNotificationState {
if (!this.roomMap.has(room)) {
this.roomMap.set(room, new RoomNotificationState(room, false));
}
return this.roomMap.get(room)!;
}
public static get instance(): RoomNotificationStateStore {
return RoomNotificationStateStore.internalInstance;
}
private onSync = (state: SyncState, prevState: SyncState | null): void => {
this.emitUpdateIfStateChanged(state, state !== prevState);
};
/**
* If the SummarizedNotificationState of this room has changed, or forceEmit
* is true, emit an UPDATE_STATUS_INDICATOR event.
*
* @internal public for test
*/
public emitUpdateIfStateChanged = (state: SyncState, forceEmit: boolean): void => {
if (!this.matrixClient) return;
// Only count visible rooms to not torment the user with notification counts in rooms they can't see.
// This will include highlights from the previous version of the room internally
const msc3946ProcessDynamicPredecessor = SettingsStore.getValue("feature_dynamic_room_predecessors");
const globalState = new SummarizedNotificationState();
const visibleRooms = this.matrixClient.getVisibleRooms(msc3946ProcessDynamicPredecessor);
let numFavourites = 0;
for (const room of visibleRooms) {
if (VisibilityProvider.instance.isRoomVisible(room)) {
globalState.add(this.getRoomState(room));
if (room.tags[DefaultTagID.Favourite] && !room.getType()) numFavourites++;
}
}
PosthogAnalytics.instance.setProperty("numFavouriteRooms", numFavourites);
if (
this.globalState.symbol !== globalState.symbol ||
this.globalState.count !== globalState.count ||
this.globalState.level !== globalState.level ||
this.globalState.numUnreadStates !== globalState.numUnreadStates ||
forceEmit
) {
this._globalState = globalState;
this.emit(UPDATE_STATUS_INDICATOR, globalState, state);
}
};
protected async onReady(): Promise<void> {
this.matrixClient?.on(ClientEvent.Sync, this.onSync);
}
protected async onNotReady(): Promise<any> {
this.matrixClient?.off(ClientEvent.Sync, this.onSync);
for (const roomState of this.roomMap.values()) {
roomState.destroy();
}
}
// We don't need this, but our contract says we do.
protected async onAction(payload: ActionPayload): Promise<void> {}
}

View File

@@ -0,0 +1,85 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021, 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Room } from "matrix-js-sdk/src/matrix";
import { NotificationLevel } from "./NotificationLevel";
import { arrayDiff } from "../../utils/arrays";
import { RoomNotificationState } from "./RoomNotificationState";
import { NotificationState, NotificationStateEvents } from "./NotificationState";
import { FetchRoomFn } from "./ListNotificationState";
import { DefaultTagID } from "../room-list/models";
import RoomListStore from "../room-list/RoomListStore";
export class SpaceNotificationState extends NotificationState {
public rooms: Room[] = []; // exposed only for tests
private states: { [spaceId: string]: RoomNotificationState } = {};
public constructor(private getRoomFn: FetchRoomFn) {
super();
}
public get symbol(): string | null {
return this._level === NotificationLevel.Unsent ? "!" : null;
}
public setRooms(rooms: Room[]): void {
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(NotificationStateEvents.Update, this.onRoomNotificationStateUpdate);
}
for (const newRoom of diff.added) {
const state = this.getRoomFn(newRoom);
state.on(NotificationStateEvents.Update, this.onRoomNotificationStateUpdate);
this.states[newRoom.roomId] = state;
}
this.calculateTotalState();
}
public getFirstRoomWithNotifications(): string | undefined {
return Object.values(this.states).find((state) => state.level >= this.level)?.room.roomId;
}
public destroy(): void {
super.destroy();
for (const state of Object.values(this.states)) {
state.off(NotificationStateEvents.Update, this.onRoomNotificationStateUpdate);
}
this.states = {};
}
private onRoomNotificationStateUpdate = (): void => {
this.calculateTotalState();
};
private calculateTotalState(): void {
const snapshot = this.snapshot();
this._count = 0;
this._level = NotificationLevel.None;
for (const [roomId, state] of Object.entries(this.states)) {
const room = this.rooms.find((r) => r.roomId === roomId);
const roomTags = room ? RoomListStore.instance.getTagsForRoom(room) : [];
// We ignore unreads in LowPriority rooms, see https://github.com/vector-im/element-web/issues/16836
if (roomTags.includes(DefaultTagID.LowPriority) && state.level === NotificationLevel.Activity) continue;
this._count += state.count;
this._level = Math.max(this.level, state.level);
}
// finally, publish an update if needed
this.emitIfUpdated(snapshot);
}
}

View File

@@ -0,0 +1,29 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { NotificationLevel } from "./NotificationLevel";
import { NotificationState } from "./NotificationState";
export class StaticNotificationState extends NotificationState {
public static readonly RED_EXCLAMATION = StaticNotificationState.forSymbol("!", NotificationLevel.Highlight);
public constructor(symbol: string | null, count: number, level: NotificationLevel) {
super();
this._symbol = symbol;
this._count = count;
this._level = level;
}
public static forCount(count: number, level: NotificationLevel): StaticNotificationState {
return new StaticNotificationState(null, count, level);
}
public static forSymbol(symbol: string, level: NotificationLevel): StaticNotificationState {
return new StaticNotificationState(symbol, 0, level);
}
}

View File

@@ -0,0 +1,54 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { NotificationLevel } from "./NotificationLevel";
import { NotificationState } from "./NotificationState";
/**
* Summarizes a number of states into a unique snapshot. To populate, call
* the add() function with the notification states to be included.
*
* Useful for community notification counts, global notification counts, etc.
*/
export class SummarizedNotificationState extends NotificationState {
private totalStatesWithUnread = 0;
public constructor() {
super();
this._symbol = null;
this._count = 0;
this._level = NotificationLevel.None;
}
public get numUnreadStates(): number {
return this.totalStatesWithUnread;
}
/**
* Append a notification state to this snapshot, taking the loudest NotificationColor
* of the two. By default this will not adopt the symbol of the other notification
* state to prevent the count from being lost in typical usage.
* @param other The other notification state to append.
* @param includeSymbol If true, the notification state's symbol will be taken if one
* is present.
*/
public add(other: NotificationState, includeSymbol = false): void {
if (other.symbol && includeSymbol) {
this._symbol = other.symbol;
}
if (other.count) {
this._count += other.count;
}
if (other.level > this.level) {
this._level = other.level;
}
if (other.hasUnreadCount) {
this.totalStatesWithUnread++;
}
}
}

View File

@@ -0,0 +1,171 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MatrixClient, discoverAndValidateOIDCIssuerWellKnown } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { OidcClient } from "oidc-client-ts";
import {
getStoredOidcTokenIssuer,
getStoredOidcClientId,
getStoredOidcIdToken,
} from "../../utils/oidc/persistOidcSettings";
import PlatformPeg from "../../PlatformPeg";
/**
* @experimental
* Stores information about configured OIDC provider
*
* In OIDC Native mode the client is registered with OIDC directly and maintains an OIDC token.
*
* In OIDC Aware mode, the client is aware that the Server is using OIDC, but is using the standard Matrix APIs for most things.
* (Notable exceptions are account management, where a link to the account management endpoint will be provided instead.)
*
* Otherwise, the store is not operating. Auth is then in Legacy mode and everything uses normal Matrix APIs.
*/
export class OidcClientStore {
private oidcClient?: OidcClient;
private initialisingOidcClientPromise: Promise<void> | undefined;
private authenticatedIssuer?: string; // set only in OIDC-native mode
private _accountManagementEndpoint?: string;
/**
* Promise which resolves once this store is read to use, which may mean there is no OIDC client if we're in legacy mode,
* or we just have the account management endpoint if running in OIDC-aware mode.
*/
public readonly readyPromise: Promise<void>;
public constructor(private readonly matrixClient: MatrixClient) {
this.readyPromise = this.init();
}
private async init(): Promise<void> {
this.authenticatedIssuer = getStoredOidcTokenIssuer();
if (this.authenticatedIssuer) {
await this.getOidcClient();
} else {
// We are not in OIDC Native mode, as we have no locally stored issuer. Check if the server delegates auth to OIDC.
try {
const authIssuer = await this.matrixClient.getAuthIssuer();
const { accountManagementEndpoint, metadata } = await discoverAndValidateOIDCIssuerWellKnown(
authIssuer.issuer,
);
this.setAccountManagementEndpoint(accountManagementEndpoint, metadata.issuer);
} catch (e) {
console.log("Auth issuer not found", e);
}
}
}
/**
* True when the active user is authenticated via OIDC
*/
public get isUserAuthenticatedWithOidc(): boolean {
return !!this.authenticatedIssuer;
}
private setAccountManagementEndpoint(endpoint: string | undefined, issuer: string): void {
// if no account endpoint is configured default to the issuer
const url = new URL(endpoint ?? issuer);
const idToken = getStoredOidcIdToken();
if (idToken) {
url.searchParams.set("id_token_hint", idToken);
}
this._accountManagementEndpoint = url.toString();
}
public get accountManagementEndpoint(): string | undefined {
return this._accountManagementEndpoint;
}
/**
* Revokes provided access and refresh tokens with the configured OIDC provider
* @param accessToken
* @param refreshToken
* @returns Promise that resolves when tokens have been revoked
* @throws when OidcClient cannot be initialised, or revoking either token fails
*/
public async revokeTokens(accessToken?: string, refreshToken?: string): Promise<void> {
const client = await this.getOidcClient();
if (!client) {
throw new Error("No OIDC client");
}
const results = await Promise.all([
this.tryRevokeToken(client, accessToken, "access_token"),
this.tryRevokeToken(client, refreshToken, "refresh_token"),
]);
if (results.some((success) => !success)) {
throw new Error("Failed to revoke tokens");
}
}
/**
* Try to revoke a given token
* @param oidcClient
* @param token
* @param tokenType passed to revocation endpoint as token type hint
* @returns Promise that resolved with boolean whether the token revocation succeeded or not
*/
private async tryRevokeToken(
oidcClient: OidcClient,
token: string | undefined,
tokenType: "access_token" | "refresh_token",
): Promise<boolean> {
try {
if (!token) {
return false;
}
await oidcClient.revokeToken(token, tokenType);
return true;
} catch (error) {
logger.error(`Failed to revoke ${tokenType}`, error);
return false;
}
}
private async getOidcClient(): Promise<OidcClient | undefined> {
if (!this.oidcClient && !this.initialisingOidcClientPromise) {
this.initialisingOidcClientPromise = this.initOidcClient();
}
await this.initialisingOidcClientPromise;
this.initialisingOidcClientPromise = undefined;
return this.oidcClient;
}
/**
* Tries to initialise an OidcClient using stored clientId and OIDC discovery.
* Assigns this.oidcClient and accountManagement endpoint.
* Logs errors and does not throw when oidc client cannot be initialised.
* @returns promise that resolves when initialising OidcClient succeeds or fails
*/
private async initOidcClient(): Promise<void> {
if (!this.authenticatedIssuer) {
logger.error("Cannot initialise OIDC client without issuer.");
return;
}
try {
const clientId = getStoredOidcClientId();
const { accountManagementEndpoint, metadata, signingKeys } = await discoverAndValidateOIDCIssuerWellKnown(
this.authenticatedIssuer,
);
this.setAccountManagementEndpoint(accountManagementEndpoint, metadata.issuer);
this.oidcClient = new OidcClient({
...metadata,
authority: metadata.issuer,
signingKeys,
redirect_uri: PlatformPeg.get()!.getOidcCallbackUrl().href,
client_id: clientId,
});
} catch (error) {
logger.error("Failed to initialise OidcClientStore", error);
}
}
}

View File

@@ -0,0 +1,412 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { logger } from "matrix-js-sdk/src/logger";
import { CryptoEvent } from "matrix-js-sdk/src/crypto-api";
import { Optional } from "matrix-events-sdk";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { pendingVerificationRequestForUser } from "../../verification";
import SettingsStore from "../../settings/SettingsStore";
import { RightPanelPhases } from "./RightPanelStorePhases";
import { SettingLevel } from "../../settings/SettingLevel";
import { UPDATE_EVENT } from "../AsyncStore";
import { ReadyWatchingStore } from "../ReadyWatchingStore";
import {
convertToStatePanel,
convertToStorePanel,
IRightPanelCard,
IRightPanelCardState,
IRightPanelForRoom,
} from "./RightPanelStoreIPanelState";
import { ActionPayload } from "../../dispatcher/payloads";
import { Action } from "../../dispatcher/actions";
import { ActiveRoomChangedPayload } from "../../dispatcher/payloads/ActiveRoomChangedPayload";
import { SdkContextClass } from "../../contexts/SDKContext";
import { MatrixClientPeg } from "../../MatrixClientPeg";
/**
* A class for tracking the state of the right panel between layouts and
* sessions. This state includes a history for each room. Each history element
* contains the phase (e.g. RightPanelPhase.RoomMemberInfo) and the state (e.g.
* the member) associated with it.
*/
export default class RightPanelStore extends ReadyWatchingStore {
private static internalInstance: RightPanelStore;
private global?: IRightPanelForRoom;
private byRoom: { [roomId: string]: IRightPanelForRoom } = {};
private viewedRoomId: Optional<string>;
private constructor() {
super(defaultDispatcher);
this.reset();
}
/**
* Resets the store. Intended for test usage only.
*/
public reset(): void {
this.global = undefined;
this.byRoom = {};
this.viewedRoomId = null;
}
protected async onReady(): Promise<any> {
this.viewedRoomId = SdkContextClass.instance.roomViewStore.getRoomId();
this.matrixClient?.on(CryptoEvent.VerificationRequestReceived, this.onVerificationRequestUpdate);
this.loadCacheFromSettings();
this.emitAndUpdateSettings();
}
protected async onNotReady(): Promise<any> {
this.matrixClient?.off(CryptoEvent.VerificationRequestReceived, this.onVerificationRequestUpdate);
}
protected onDispatcherAction(payload: ActionPayload): void {
switch (payload.action) {
case Action.ActiveRoomChanged: {
const changePayload = <ActiveRoomChangedPayload>payload;
this.handleViewedRoomChange(changePayload.oldRoomId, changePayload.newRoomId);
break;
}
case Action.FocusMessageSearch: {
if (this.currentCard.phase !== RightPanelPhases.RoomSummary) {
this.setCard({ phase: RightPanelPhases.RoomSummary, state: { focusRoomSearch: true } });
}
}
}
}
// Getters
/**
* If you are calling this from a component that already knows about a
* specific room from props / state, then it's best to prefer
* `isOpenForRoom` below to ensure all your data is for a single room
* during room changes.
*/
public get isOpen(): boolean {
return this.byRoom[this.viewedRoomId ?? ""]?.isOpen ?? false;
}
public isOpenForRoom(roomId: string): boolean {
return this.byRoom[roomId]?.isOpen ?? false;
}
public get roomPhaseHistory(): Array<IRightPanelCard> {
return this.byRoom[this.viewedRoomId ?? ""]?.history ?? [];
}
/**
* If you are calling this from a component that already knows about a
* specific room from props / state, then it's best to prefer
* `currentCardForRoom` below to ensure all your data is for a single room
* during room changes.
*/
public get currentCard(): IRightPanelCard {
const hist = this.roomPhaseHistory;
if (hist.length >= 1) {
return hist[hist.length - 1];
}
return { state: {}, phase: null };
}
public currentCardForRoom(roomId: string): IRightPanelCard {
const hist = this.byRoom[roomId]?.history ?? [];
if (hist.length > 0) {
return hist[hist.length - 1];
}
return { state: {}, phase: null };
}
public get previousCard(): IRightPanelCard {
const hist = this.roomPhaseHistory;
if (hist?.length >= 2) {
return hist[hist.length - 2];
}
return { state: {}, phase: null };
}
// Setters
public setCard(card: IRightPanelCard, allowClose = true, roomId?: string): void {
const rId = roomId ?? this.viewedRoomId ?? "";
// This function behaves as following:
// Update state: if the same phase is send but with a state
// Set right panel and erase history: if a "different to the current" phase is send (with or without a state)
// If the right panel is set, this function also shows the right panel.
const redirect = this.getVerificationRedirect(card);
const targetPhase = redirect?.phase ?? card.phase;
const cardState = redirect?.state ?? (Object.keys(card.state ?? {}).length === 0 ? null : card.state);
// Checks for wrong SetRightPanelPhase requests
if (!this.isPhaseValid(targetPhase, Boolean(rId))) return;
if (targetPhase === this.currentCardForRoom(rId)?.phase && !!cardState) {
// Update state: set right panel with a new state but keep the phase (don't know it this is ever needed...)
const hist = this.byRoom[rId]?.history ?? [];
hist[hist.length - 1].state = cardState;
this.emitAndUpdateSettings();
} else if (targetPhase !== this.currentCardForRoom(rId)?.phase || !this.byRoom[rId]) {
// Set right panel and initialize/erase history
const history = [{ phase: targetPhase, state: cardState ?? {} }];
this.byRoom[rId] = { history, isOpen: true };
this.emitAndUpdateSettings();
} else {
this.show(rId);
this.emitAndUpdateSettings();
}
}
public setCards(cards: IRightPanelCard[], allowClose = true, roomId: string | null = null): void {
// This function sets the history of the right panel and shows the right panel if not already visible.
const rId = roomId ?? this.viewedRoomId ?? "";
const history = cards.map((c) => ({ phase: c.phase, state: c.state ?? {} }));
this.byRoom[rId] = { history, isOpen: true };
this.show(rId);
this.emitAndUpdateSettings();
}
// Appends a card to the history and shows the right panel if not already visible
public pushCard(card: IRightPanelCard, allowClose = true, roomId: string | null = null): void {
const rId = roomId ?? this.viewedRoomId ?? "";
const redirect = this.getVerificationRedirect(card);
const targetPhase = redirect?.phase ?? card.phase;
const pState = redirect?.state ?? card.state ?? {};
// Checks for wrong SetRightPanelPhase requests
if (!this.isPhaseValid(targetPhase, Boolean(rId))) return;
const roomCache = this.byRoom[rId];
if (!!roomCache) {
// append new phase
roomCache.history.push({ state: pState, phase: targetPhase });
roomCache.isOpen = allowClose ? roomCache.isOpen : true;
} else {
// setup room panel cache with the new card
this.byRoom[rId] = {
history: [{ phase: targetPhase, state: pState }],
// if there was no right panel store object the the panel was closed -> keep it closed, except if allowClose==false
isOpen: !allowClose,
};
}
this.show(rId);
this.emitAndUpdateSettings();
}
public popCard(roomId: string | null = null): IRightPanelCard | undefined {
const rId = roomId ?? this.viewedRoomId ?? "";
if (!this.byRoom[rId]) return;
const removedCard = this.byRoom[rId].history.pop();
this.emitAndUpdateSettings();
return removedCard;
}
public togglePanel(roomId: string | null): void {
const rId = roomId ?? this.viewedRoomId ?? "";
if (!this.byRoom[rId]) return;
this.byRoom[rId].isOpen = !this.byRoom[rId].isOpen;
this.emitAndUpdateSettings();
}
public show(roomId: string | null): void {
if (!this.isOpenForRoom(roomId ?? this.viewedRoomId ?? "")) {
this.togglePanel(roomId);
}
}
public hide(roomId: string | null): void {
if (this.isOpenForRoom(roomId ?? this.viewedRoomId ?? "")) {
this.togglePanel(roomId);
}
}
/**
* Helper to show a right panel phase.
* If the UI is already showing that phase, the right panel will be hidden.
*
* Calling the same phase twice with a different state will update the current
* phase and push the old state in the right panel history.
* @param phase The right panel phase.
* @param cardState The state within the phase.
*/
public showOrHidePhase(phase: RightPanelPhases, cardState?: Partial<IRightPanelCardState>): void {
if (this.currentCard.phase == phase && !cardState && this.isOpen) {
this.togglePanel(null);
} else {
this.setCard({ phase, state: cardState });
if (!this.isOpen) this.togglePanel(null);
}
}
private loadCacheFromSettings(): void {
if (this.viewedRoomId) {
const room = this.mxClient?.getRoom(this.viewedRoomId);
if (!!room) {
this.global =
this.global ?? convertToStatePanel(SettingsStore.getValue("RightPanel.phasesGlobal"), room);
this.byRoom[this.viewedRoomId] =
this.byRoom[this.viewedRoomId] ??
convertToStatePanel(SettingsStore.getValue("RightPanel.phases", this.viewedRoomId), room);
} else {
logger.warn(
"Could not restore the right panel after load because there was no associated room object.",
);
}
}
}
private emitAndUpdateSettings(): void {
this.filterValidCards(this.global);
const storePanelGlobal = convertToStorePanel(this.global);
SettingsStore.setValue("RightPanel.phasesGlobal", null, SettingLevel.DEVICE, storePanelGlobal);
if (!!this.viewedRoomId) {
const panelThisRoom = this.byRoom[this.viewedRoomId];
this.filterValidCards(panelThisRoom);
const storePanelThisRoom = convertToStorePanel(panelThisRoom);
SettingsStore.setValue(
"RightPanel.phases",
this.viewedRoomId,
SettingLevel.ROOM_DEVICE,
storePanelThisRoom,
);
}
this.emit(UPDATE_EVENT, null);
}
private filterValidCards(rightPanelForRoom?: IRightPanelForRoom): void {
if (!rightPanelForRoom?.history) return;
rightPanelForRoom.history = rightPanelForRoom.history.filter((card) => this.isCardStateValid(card));
if (!rightPanelForRoom.history.length) {
rightPanelForRoom.isOpen = false;
}
}
private isCardStateValid(card: IRightPanelCard): boolean {
// this function does a sanity check on the card. this is required because
// some phases require specific state properties that might not be available.
// This can be caused on if element is reloaded and the tries to reload right panel data from id's stored in the local storage.
// we store id's of users and matrix events. If are not yet fetched on reload the right panel cannot display them.
// or potentially other errors.
// (A nicer fix could be to indicate, that the right panel is loading if there is missing state data and re-emit if the data is available)
switch (card.phase) {
case RightPanelPhases.ThreadView:
if (!card.state?.threadHeadEvent) {
logger.warn("removed card from right panel because of missing threadHeadEvent in card state");
}
return !!card.state?.threadHeadEvent;
case RightPanelPhases.RoomMemberInfo:
case RightPanelPhases.SpaceMemberInfo:
case RightPanelPhases.EncryptionPanel:
if (!card.state?.member) {
logger.warn("removed card from right panel because of missing member in card state");
}
return !!card.state?.member;
case RightPanelPhases.Room3pidMemberInfo:
case RightPanelPhases.Space3pidMemberInfo:
if (!card.state?.memberInfoEvent) {
logger.warn("removed card from right panel because of missing memberInfoEvent in card state");
}
return !!card.state?.memberInfoEvent;
case RightPanelPhases.Widget:
if (!card.state?.widgetId) {
logger.warn("removed card from right panel because of missing widgetId in card state");
}
return !!card.state?.widgetId;
}
return true;
}
private getVerificationRedirect(card: IRightPanelCard): IRightPanelCard | null {
if (card.phase === RightPanelPhases.RoomMemberInfo && card.state) {
// RightPanelPhases.RoomMemberInfo -> needs to be changed to RightPanelPhases.EncryptionPanel if there is a pending verification request
const { member } = card.state;
const pendingRequest = member
? pendingVerificationRequestForUser(MatrixClientPeg.safeGet(), member)
: undefined;
if (pendingRequest) {
return {
phase: RightPanelPhases.EncryptionPanel,
state: {
verificationRequest: pendingRequest,
member,
},
};
}
}
return null;
}
private isPhaseValid(targetPhase: RightPanelPhases | null, isViewingRoom: boolean): boolean {
if (!targetPhase || !RightPanelPhases[targetPhase]) {
logger.warn(`Tried to switch right panel to unknown phase: ${targetPhase}`);
return false;
}
if (!isViewingRoom) {
logger.warn(
`Tried to switch right panel to a room phase: ${targetPhase}, ` +
`but we are currently not viewing a room`,
);
return false;
}
return true;
}
private onVerificationRequestUpdate = (): void => {
if (!this.currentCard?.state) return;
const { member } = this.currentCard.state;
if (!member) return;
const pendingRequest = pendingVerificationRequestForUser(MatrixClientPeg.safeGet(), member);
if (pendingRequest) {
this.currentCard.state.verificationRequest = pendingRequest;
this.emitAndUpdateSettings();
}
};
private handleViewedRoomChange(oldRoomId: Optional<string>, newRoomId: Optional<string>): void {
if (!this.mxClient) return; // not ready, onReady will handle the first room
this.viewedRoomId = newRoomId;
// load values from byRoomCache with the viewedRoomId.
this.loadCacheFromSettings();
// when we're switching to a room, clear out any stale MemberInfo cards
// in order to fix https://github.com/vector-im/element-web/issues/21487
if (this.currentCard?.phase !== RightPanelPhases.EncryptionPanel) {
const panel = this.byRoom[this.viewedRoomId ?? ""];
if (panel?.history) {
panel.history = panel.history.filter(
(card: IRightPanelCard) =>
card.phase != RightPanelPhases.RoomMemberInfo &&
card.phase != RightPanelPhases.Room3pidMemberInfo,
);
}
}
// when we're switching to a room, clear out thread permalinks to not get you stuck in the middle of the thread
// in order to fix https://github.com/matrix-org/matrix-react-sdk/pull/11011
if (this.currentCard?.phase === RightPanelPhases.ThreadView && this.currentCard.state) {
this.currentCard.state.initialEvent = undefined;
this.currentCard.state.isInitialEventHighlighted = undefined;
this.currentCard.state.initialEventScrollIntoView = undefined;
}
this.emitAndUpdateSettings();
}
public static get instance(): RightPanelStore {
if (!this.internalInstance) {
this.internalInstance = new RightPanelStore();
this.internalInstance.start();
}
return this.internalInstance;
}
}
window.mxRightPanelStore = RightPanelStore.instance;

View File

@@ -0,0 +1,111 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MatrixEvent, Room, RoomMember, User } from "matrix-js-sdk/src/matrix";
import { VerificationRequest } from "matrix-js-sdk/src/crypto-api";
import { RightPanelPhases } from "./RightPanelStorePhases";
export interface IRightPanelCardState {
member?: RoomMember | User;
verificationRequest?: VerificationRequest;
verificationRequestPromise?: Promise<VerificationRequest>;
widgetId?: string;
spaceId?: string;
// Room3pidMemberInfo, Space3pidMemberInfo,
memberInfoEvent?: MatrixEvent;
// threads
threadHeadEvent?: MatrixEvent;
initialEvent?: MatrixEvent;
isInitialEventHighlighted?: boolean;
initialEventScrollIntoView?: boolean;
// room summary
focusRoomSearch?: boolean;
}
export interface IRightPanelCardStateStored {
memberId?: string;
// we do not store the things associated with verification
widgetId?: string;
spaceId?: string;
// 3pidMemberInfo
memberInfoEventId?: string;
// threads
threadHeadEventId?: string;
initialEventId?: string;
isInitialEventHighlighted?: boolean;
initialEventScrollIntoView?: boolean;
}
export interface IRightPanelCard {
phase: RightPanelPhases | null;
state?: IRightPanelCardState;
}
export interface IRightPanelCardStored {
phase: RightPanelPhases | null;
state?: IRightPanelCardStateStored;
}
export interface IRightPanelForRoom {
isOpen: boolean;
history: Array<IRightPanelCard>;
}
interface IRightPanelForRoomStored {
isOpen: boolean;
history: Array<IRightPanelCardStored>;
}
export function convertToStorePanel(cacheRoom?: IRightPanelForRoom): IRightPanelForRoomStored | undefined {
if (!cacheRoom) return undefined;
const storeHistory = [...cacheRoom.history].map((panelState) => convertCardToStore(panelState));
return { isOpen: cacheRoom.isOpen, history: storeHistory };
}
export function convertToStatePanel(storeRoom: IRightPanelForRoomStored, room: Room): IRightPanelForRoom {
if (!storeRoom) return storeRoom;
const stateHistory = [...storeRoom.history].map((panelStateStore) => convertStoreToCard(panelStateStore, room));
return { history: stateHistory, isOpen: storeRoom.isOpen };
}
export function convertCardToStore(panelState: IRightPanelCard): IRightPanelCardStored {
const state = panelState.state ?? {};
const stateStored: IRightPanelCardStateStored = {
widgetId: state.widgetId,
spaceId: state.spaceId,
isInitialEventHighlighted: state.isInitialEventHighlighted,
initialEventScrollIntoView: state.initialEventScrollIntoView,
threadHeadEventId: !!state?.threadHeadEvent?.getId() ? state.threadHeadEvent.getId() : undefined,
memberInfoEventId: !!state?.memberInfoEvent?.getId() ? state.memberInfoEvent.getId() : undefined,
initialEventId: !!state?.initialEvent?.getId() ? state.initialEvent.getId() : undefined,
memberId: !!state?.member?.userId ? state.member.userId : undefined,
};
return { state: stateStored, phase: panelState.phase };
}
function convertStoreToCard(panelStateStore: IRightPanelCardStored, room: Room): IRightPanelCard {
const stateStored = panelStateStore.state ?? {};
const state: IRightPanelCardState = {
widgetId: stateStored.widgetId,
spaceId: stateStored.spaceId,
isInitialEventHighlighted: stateStored.isInitialEventHighlighted,
initialEventScrollIntoView: stateStored.initialEventScrollIntoView,
threadHeadEvent: !!stateStored?.threadHeadEventId
? room.findEventById(stateStored.threadHeadEventId)
: undefined,
memberInfoEvent: !!stateStored?.memberInfoEventId
? room.findEventById(stateStored.memberInfoEventId)
: undefined,
initialEvent: !!stateStored?.initialEventId ? room.findEventById(stateStored.initialEventId) : undefined,
member: (!!stateStored?.memberId && room.getMember(stateStored.memberId)) || undefined,
};
return { state: state, phase: panelStateStore.phase };
}

View File

@@ -0,0 +1,51 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { _t } from "../../languageHandler";
// These are in their own file because of circular imports being a problem.
export enum RightPanelPhases {
// Room stuff
RoomMemberList = "RoomMemberList",
FilePanel = "FilePanel",
NotificationPanel = "NotificationPanel",
RoomMemberInfo = "RoomMemberInfo",
EncryptionPanel = "EncryptionPanel",
RoomSummary = "RoomSummary",
Widget = "Widget",
PinnedMessages = "PinnedMessages",
Timeline = "Timeline",
Extensions = "Extensions",
Room3pidMemberInfo = "Room3pidMemberInfo",
// Space stuff
SpaceMemberList = "SpaceMemberList",
SpaceMemberInfo = "SpaceMemberInfo",
Space3pidMemberInfo = "Space3pidMemberInfo",
// Thread stuff
ThreadView = "ThreadView",
ThreadPanel = "ThreadPanel",
}
export function backLabelForPhase(phase: RightPanelPhases | null): string | null {
switch (phase) {
case RightPanelPhases.ThreadPanel:
return _t("common|threads");
case RightPanelPhases.Timeline:
return _t("chat_card_back_action_label");
case RightPanelPhases.RoomSummary:
return _t("room_summary_card_back_action_label");
case RightPanelPhases.RoomMemberList:
return _t("member_list_back_action_label");
case RightPanelPhases.ThreadView:
return _t("thread_view_back_action_label");
}
return null;
}

View File

@@ -0,0 +1,29 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { ActionPayload } from "../../../dispatcher/payloads";
import RightPanelStore from "../RightPanelStore";
import { RightPanelPhases } from "../RightPanelStorePhases";
/**
* Handle an Action.View3pidInvite action.
* Where payload has an event, open the right panel with 3pid room member info without clearing right panel history.
* Otherwise, 'close' the 3pid member info by displaying the room member list in the right panel.
* @param payload
* @param rightPanelStore store instance
*/
export const onView3pidInvite = (payload: ActionPayload, rightPanelStore: RightPanelStore): void => {
if (payload.event) {
rightPanelStore.pushCard({
phase: RightPanelPhases.Room3pidMemberInfo,
state: { memberInfoEvent: payload.event },
});
} else {
rightPanelStore.showOrHidePhase(RightPanelPhases.RoomMemberList);
}
};

View File

@@ -0,0 +1,9 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
export * from "./View3pidInvite";

View File

@@ -0,0 +1,111 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import type { Room } from "matrix-js-sdk/src/matrix";
import type { EventEmitter } from "events";
import { ITagMap, ListAlgorithm, SortAlgorithm } from "./algorithms/models";
import { RoomUpdateCause, TagID } from "./models";
import { IFilterCondition } from "./filters/IFilterCondition";
export enum RoomListStoreEvent {
// The event/channel which is called when the room lists have been changed.
ListsUpdate = "lists_update",
// The event which is called when the room list is loading.
// Called with the (tagId, bool) which is true when the list is loading, else false.
ListsLoading = "lists_loading",
}
export interface RoomListStore extends EventEmitter {
/**
* Gets an ordered set of rooms for the all known tags.
* @returns {ITagMap} The cached list of rooms, ordered,
* for each tag. May be empty, but never null/undefined.
*/
get orderedLists(): ITagMap;
/**
* Return the total number of rooms in this list. Prefer this method to
* RoomListStore.orderedLists[tagId].length because the client may not
* be aware of all the rooms in this list (e.g in Sliding Sync).
* @param tagId the tag to get the room count for.
* @returns the number of rooms in this list, or 0 if the list is unknown.
*/
getCount(tagId: TagID): number;
/**
* Set the sort algorithm for the specified tag.
* @param tagId the tag to set the algorithm for
* @param sort the sort algorithm to set to
*/
setTagSorting(tagId: TagID, sort: SortAlgorithm): void;
/**
* Get the sort algorithm for the specified tag.
* @param tagId tag to get the sort algorithm for
* @returns the sort algorithm
*/
getTagSorting(tagId: TagID): SortAlgorithm | null;
/**
* Set the list algorithm for the specified tag.
* @param tagId the tag to set the algorithm for
* @param order the list algorithm to set to
*/
setListOrder(tagId: TagID, order: ListAlgorithm): void;
/**
* Get the list algorithm for the specified tag.
* @param tagId tag to get the list algorithm for
* @returns the list algorithm
*/
getListOrder(tagId: TagID): ListAlgorithm | null;
/**
* Regenerates the room whole room list, discarding any previous results.
*
* Note: This is only exposed externally for the tests. Do not call this from within
* the app.
* @param params.trigger Set to false to prevent a list update from being sent. Should only
* be used if the calling code will manually trigger the update.
*/
regenerateAllLists(params: { trigger: boolean }): void;
/**
* Adds a filter condition to the room list store. Filters may be applied async,
* and thus might not cause an update to the store immediately.
* @param {IFilterCondition} filter The filter condition to add.
*/
addFilter(filter: IFilterCondition): Promise<void>;
/**
* Removes a filter condition from the room list store. If the filter was
* not previously added to the room list store, this will no-op. The effects
* of removing a filter may be applied async and therefore might not cause
* an update right away.
* @param {IFilterCondition} filter The filter condition to remove.
*/
removeFilter(filter: IFilterCondition): void;
/**
* Gets the tags for a room identified by the store. The returned set
* should never be empty, and will contain DefaultTagID.Untagged if
* the store is not aware of any tags.
* @param room The room to get the tags for.
* @returns The tags for the room.
*/
getTagsForRoom(room: Room): TagID[];
/**
* Manually update a room with a given cause. This should only be used if the
* room list store would otherwise be incapable of doing the update itself. Note
* that this may race with the room list's regular operation.
* @param {Room} room The room to update.
* @param {RoomUpdateCause} cause The cause to update for.
*/
manualRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<void>;
}

View File

@@ -0,0 +1,111 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { TagID } from "./models";
const TILE_HEIGHT_PX = 44;
interface ISerializedListLayout {
numTiles: number;
showPreviews: boolean;
collapsed: boolean;
}
export class ListLayout {
private _n = 0;
private _previews = false;
private _collapsed = false;
public constructor(public readonly tagId: TagID) {
const serialized = localStorage.getItem(this.key);
if (serialized) {
// We don't use the setters as they cause writes.
const parsed = <ISerializedListLayout>JSON.parse(serialized);
this._n = parsed.numTiles;
this._previews = parsed.showPreviews;
this._collapsed = parsed.collapsed;
}
}
public get isCollapsed(): boolean {
return this._collapsed;
}
public set isCollapsed(v: boolean) {
this._collapsed = v;
this.save();
}
public get showPreviews(): boolean {
return this._previews;
}
public set showPreviews(v: boolean) {
this._previews = v;
this.save();
}
public get tileHeight(): number {
return TILE_HEIGHT_PX;
}
private get key(): string {
return `mx_sublist_layout_${this.tagId}_boxed`;
}
public get visibleTiles(): number {
if (this._n === 0) return this.defaultVisibleTiles;
return Math.max(this._n, this.minVisibleTiles);
}
public set visibleTiles(v: number) {
this._n = v;
this.save();
}
public get minVisibleTiles(): number {
return 1;
}
public get defaultVisibleTiles(): number {
// This number is what "feels right", and mostly subject to design's opinion.
return 8;
}
public tilesWithPadding(n: number, paddingPx: number): number {
return this.pixelsToTiles(this.tilesToPixelsWithPadding(n, paddingPx));
}
public tilesToPixelsWithPadding(n: number, paddingPx: number): number {
return this.tilesToPixels(n) + paddingPx;
}
public tilesToPixels(n: number): number {
return n * this.tileHeight;
}
public pixelsToTiles(px: number): number {
return px / this.tileHeight;
}
public reset(): void {
localStorage.removeItem(this.key);
}
private save(): void {
localStorage.setItem(this.key, JSON.stringify(this.serialize()));
}
private serialize(): ISerializedListLayout {
return {
numTiles: this.visibleTiles,
showPreviews: this.showPreviews,
collapsed: this.isCollapsed,
};
}
}

View File

@@ -0,0 +1,289 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Room, RelationType, MatrixEvent, Thread, M_POLL_START, RoomEvent } from "matrix-js-sdk/src/matrix";
import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
import { ActionPayload } from "../../dispatcher/payloads";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { MessageEventPreview } from "./previews/MessageEventPreview";
import { PollStartEventPreview } from "./previews/PollStartEventPreview";
import { TagID } from "./models";
import { LegacyCallInviteEventPreview } from "./previews/LegacyCallInviteEventPreview";
import { LegacyCallAnswerEventPreview } from "./previews/LegacyCallAnswerEventPreview";
import { LegacyCallHangupEvent } from "./previews/LegacyCallHangupEvent";
import { StickerEventPreview } from "./previews/StickerEventPreview";
import { ReactionEventPreview } from "./previews/ReactionEventPreview";
import { UPDATE_EVENT } from "../AsyncStore";
import { IPreview } from "./previews/IPreview";
import { VoiceBroadcastInfoEventType } from "../../voice-broadcast";
import { VoiceBroadcastPreview } from "./previews/VoiceBroadcastPreview";
import shouldHideEvent from "../../shouldHideEvent";
// Emitted event for when a room's preview has changed. First argument will the room for which
// the change happened.
const ROOM_PREVIEW_CHANGED = "room_preview_changed";
const PREVIEWS: Record<
string,
{
isState: boolean;
previewer: IPreview;
}
> = {
"m.room.message": {
isState: false,
previewer: new MessageEventPreview(),
},
"m.call.invite": {
isState: false,
previewer: new LegacyCallInviteEventPreview(),
},
"m.call.answer": {
isState: false,
previewer: new LegacyCallAnswerEventPreview(),
},
"m.call.hangup": {
isState: false,
previewer: new LegacyCallHangupEvent(),
},
"m.sticker": {
isState: false,
previewer: new StickerEventPreview(),
},
"m.reaction": {
isState: false,
previewer: new ReactionEventPreview(),
},
[M_POLL_START.name]: {
isState: false,
previewer: new PollStartEventPreview(),
},
[M_POLL_START.altName]: {
isState: false,
previewer: new PollStartEventPreview(),
},
[VoiceBroadcastInfoEventType]: {
isState: true,
previewer: new VoiceBroadcastPreview(),
},
};
// The maximum number of events we're willing to look back on to get a preview.
const MAX_EVENTS_BACKWARDS = 50;
// type merging ftw
type TAG_ANY = "im.vector.any"; // eslint-disable-line @typescript-eslint/naming-convention
const TAG_ANY: TAG_ANY = "im.vector.any";
interface IState {
// Empty because we don't actually use the state
}
export interface MessagePreview {
event: MatrixEvent;
isThreadReply: boolean;
text: string;
}
const isThreadReply = (event: MatrixEvent): boolean => {
// a thread root event cannot be a thread reply
if (event.isThreadRoot) return false;
const thread = event.getThread();
// it cannot be a thread reply if there is no thread
if (!thread) return false;
const relation = event.getRelation();
if (
!!relation &&
relation.rel_type === RelationType.Annotation &&
relation.event_id === thread.rootEvent?.getId()
) {
// annotations on the thread root are not a thread reply
return false;
}
return true;
};
const mkMessagePreview = (text: string, event: MatrixEvent): MessagePreview => {
return {
event,
text,
isThreadReply: isThreadReply(event),
};
};
export class MessagePreviewStore extends AsyncStoreWithClient<IState> {
private static readonly internalInstance = (() => {
const instance = new MessagePreviewStore();
instance.start();
return instance;
})();
/**
* @internal Public for test only
*/
public static testInstance(): MessagePreviewStore {
return new MessagePreviewStore();
}
// null indicates the preview is empty / irrelevant
private previews = new Map<string, Map<TagID | TAG_ANY, MessagePreview | null>>();
private constructor() {
super(defaultDispatcher, {});
}
public static get instance(): MessagePreviewStore {
return MessagePreviewStore.internalInstance;
}
public static getPreviewChangedEventName(room: Room): string {
return `${ROOM_PREVIEW_CHANGED}:${room?.roomId}`;
}
/**
* Gets the pre-translated preview for a given room
* @param room The room to get the preview for.
* @param inTagId The tag ID in which the room resides
* @returns The preview, or null if none present.
*/
public async getPreviewForRoom(room: Room, inTagId: TagID): Promise<MessagePreview | null> {
if (!room) return null; // invalid room, just return nothing
if (!this.previews.has(room.roomId)) await this.generatePreview(room, inTagId);
const previews = this.previews.get(room.roomId);
if (!previews) return null;
if (previews.has(inTagId)) {
return previews.get(inTagId)!;
}
return previews.get(TAG_ANY) ?? null;
}
public generatePreviewForEvent(event: MatrixEvent): string {
const previewDef = PREVIEWS[event.getType()];
return previewDef?.previewer.getTextFor(event, undefined, true) ?? "";
}
private async generatePreview(room: Room, tagId?: TagID): Promise<void> {
const events = [...room.getLiveTimeline().getEvents(), ...room.getPendingEvents()];
// add last reply from each thread
room.getThreads().forEach((thread: Thread): void => {
const lastReply = thread.lastReply();
if (lastReply) events.push(lastReply);
});
// sort events from oldest to newest
events.sort((a: MatrixEvent, b: MatrixEvent) => {
return a.getTs() - b.getTs();
});
if (!events) return; // should only happen in tests
let map = this.previews.get(room.roomId);
if (!map) {
map = new Map<TagID | TAG_ANY, MessagePreview | null>();
this.previews.set(room.roomId, map);
}
// Set the tags so we know what to generate
if (!map.has(TAG_ANY)) map.set(TAG_ANY, null);
if (tagId && !map.has(tagId)) map.set(tagId, null);
let changed = false;
for (let i = events.length - 1; i >= 0; i--) {
if (i === events.length - MAX_EVENTS_BACKWARDS) {
// limit reached - clear the preview by breaking out of the loop
break;
}
const event = events[i];
await this.matrixClient?.decryptEventIfNeeded(event);
const shouldHide = shouldHideEvent(event);
if (shouldHide) continue;
const previewDef = PREVIEWS[event.getType()];
if (!previewDef) continue;
if (previewDef.isState && isNullOrUndefined(event.getStateKey())) continue;
const anyPreviewText = previewDef.previewer.getTextFor(event);
if (!anyPreviewText) continue; // not previewable for some reason
changed = changed || anyPreviewText !== map.get(TAG_ANY)?.text;
map.set(TAG_ANY, mkMessagePreview(anyPreviewText, event));
const tagsToGenerate = Array.from(map.keys()).filter((t) => t !== TAG_ANY); // we did the any tag above
for (const genTagId of tagsToGenerate) {
const realTagId = genTagId === TAG_ANY ? undefined : genTagId;
const preview = previewDef.previewer.getTextFor(event, realTagId);
if (preview === anyPreviewText) {
changed = changed || anyPreviewText !== map.get(genTagId)?.text;
map.delete(genTagId);
} else {
changed = changed || preview !== map.get(genTagId)?.text;
map.set(genTagId, preview ? mkMessagePreview(anyPreviewText, event) : null);
}
}
if (changed) {
// We've muted the underlying Map, so just emit that we've changed.
this.previews.set(room.roomId, map);
this.emit(UPDATE_EVENT, this);
this.emit(MessagePreviewStore.getPreviewChangedEventName(room), room);
}
return; // we're done
}
// At this point, we didn't generate a preview so clear it
this.previews.set(room.roomId, new Map<TagID | TAG_ANY, MessagePreview | null>());
this.emit(UPDATE_EVENT, this);
this.emit(MessagePreviewStore.getPreviewChangedEventName(room), room);
}
protected async onAction(payload: ActionPayload): Promise<void> {
if (!this.matrixClient) return;
if (payload.action === "MatrixActions.Room.timeline" || payload.action === "MatrixActions.Event.decrypted") {
const event = payload.event; // TODO: Type out the dispatcher
const roomId = event.getRoomId();
const isHistoricalEvent = payload.hasOwnProperty("isLiveEvent") && !payload.isLiveEvent;
if (!roomId || !this.previews.has(roomId) || isHistoricalEvent) return;
const room = this.matrixClient.getRoom(roomId);
if (!room) return;
await this.generatePreview(room, TAG_ANY);
}
}
protected async onReady(): Promise<void> {
if (!this.matrixClient) return;
this.matrixClient.on(RoomEvent.LocalEchoUpdated, this.onLocalEchoUpdated);
}
protected async onNotReady(): Promise<void> {
if (!this.matrixClient) return;
this.matrixClient.off(RoomEvent.LocalEchoUpdated, this.onLocalEchoUpdated);
}
protected onLocalEchoUpdated = async (ev: MatrixEvent, room: Room): Promise<void> => {
if (!this.previews.has(room.roomId)) return;
await this.generatePreview(room, TAG_ANY);
};
}

View File

@@ -0,0 +1,66 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { logger } from "matrix-js-sdk/src/logger";
import { TagID } from "./models";
import { ListLayout } from "./ListLayout";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { ActionPayload } from "../../dispatcher/payloads";
interface IState {}
export default class RoomListLayoutStore extends AsyncStoreWithClient<IState> {
private static internalInstance: RoomListLayoutStore;
private readonly layoutMap = new Map<TagID, ListLayout>();
public constructor() {
super(defaultDispatcher);
}
public static get instance(): RoomListLayoutStore {
if (!this.internalInstance) {
this.internalInstance = new RoomListLayoutStore();
this.internalInstance.start();
}
return RoomListLayoutStore.internalInstance;
}
public ensureLayoutExists(tagId: TagID): void {
if (!this.layoutMap.has(tagId)) {
this.layoutMap.set(tagId, new ListLayout(tagId));
}
}
public getLayoutFor(tagId: TagID): ListLayout {
if (!this.layoutMap.has(tagId)) {
this.layoutMap.set(tagId, new ListLayout(tagId));
}
return this.layoutMap.get(tagId)!;
}
// Note: this primarily exists for debugging, and isn't really intended to be used by anything.
public async resetLayouts(): Promise<void> {
logger.warn("Resetting layouts for room list");
for (const layout of this.layoutMap.values()) {
layout.reset();
}
}
protected async onNotReady(): Promise<any> {
// On logout, clear the map.
this.layoutMap.clear();
}
// We don't need this function, but our contract says we do
protected async onAction(payload: ActionPayload): Promise<void> {}
}
window.mxRoomListLayoutStore = RoomListLayoutStore.instance;

View File

@@ -0,0 +1,659 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2018-2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MatrixClient, Room, RoomState, EventType } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { logger } from "matrix-js-sdk/src/logger";
import SettingsStore from "../../settings/SettingsStore";
import { DefaultTagID, OrderedDefaultTagIDs, RoomUpdateCause, TagID } from "./models";
import { IListOrderingMap, ITagMap, ITagSortingMap, ListAlgorithm, SortAlgorithm } from "./algorithms/models";
import { ActionPayload } from "../../dispatcher/payloads";
import defaultDispatcher, { MatrixDispatcher } from "../../dispatcher/dispatcher";
import { readReceiptChangeIsFor } from "../../utils/read-receipts";
import { FILTER_CHANGED, IFilterCondition } from "./filters/IFilterCondition";
import { Algorithm, LIST_UPDATED_EVENT } from "./algorithms/Algorithm";
import { EffectiveMembership, getEffectiveMembership, getEffectiveMembershipTag } from "../../utils/membership";
import RoomListLayoutStore from "./RoomListLayoutStore";
import { MarkedExecution } from "../../utils/MarkedExecution";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import { RoomNotificationStateStore } from "../notifications/RoomNotificationStateStore";
import { VisibilityProvider } from "./filters/VisibilityProvider";
import { SpaceWatcher } from "./SpaceWatcher";
import { IRoomTimelineActionPayload } from "../../actions/MatrixActionCreators";
import { RoomListStore as Interface, RoomListStoreEvent } from "./Interface";
import { SlidingRoomListStoreClass } from "./SlidingRoomListStore";
import { UPDATE_EVENT } from "../AsyncStore";
import { SdkContextClass } from "../../contexts/SDKContext";
import { getChangedOverrideRoomMutePushRules } from "./utils/roomMute";
interface IState {
// state is tracked in underlying classes
}
export const LISTS_UPDATE_EVENT = RoomListStoreEvent.ListsUpdate;
export const LISTS_LOADING_EVENT = RoomListStoreEvent.ListsLoading; // unused; used by SlidingRoomListStore
export class RoomListStoreClass extends AsyncStoreWithClient<IState> implements Interface {
/**
* Set to true if you're running tests on the store. Should not be touched in
* any other environment.
*/
public static TEST_MODE = false;
private initialListsGenerated = false;
private msc3946ProcessDynamicPredecessor: boolean;
private msc3946SettingWatcherRef: string;
private algorithm = new Algorithm();
private prefilterConditions: IFilterCondition[] = [];
private updateFn = new MarkedExecution(() => {
for (const tagId of Object.keys(this.orderedLists)) {
RoomNotificationStateStore.instance.getListState(tagId).setRooms(this.orderedLists[tagId]);
}
this.emit(LISTS_UPDATE_EVENT);
});
public constructor(dis: MatrixDispatcher) {
super(dis);
this.setMaxListeners(20); // RoomList + LeftPanel + 8xRoomSubList + spares
this.algorithm.start();
this.msc3946ProcessDynamicPredecessor = SettingsStore.getValue("feature_dynamic_room_predecessors");
this.msc3946SettingWatcherRef = SettingsStore.watchSetting(
"feature_dynamic_room_predecessors",
null,
(_settingName, _roomId, _level, _newValAtLevel, newVal) => {
this.msc3946ProcessDynamicPredecessor = newVal;
this.regenerateAllLists({ trigger: true });
},
);
}
public componentWillUnmount(): void {
SettingsStore.unwatchSetting(this.msc3946SettingWatcherRef);
}
private setupWatchers(): void {
// TODO: Maybe destroy this if this class supports destruction
new SpaceWatcher(this);
}
public get orderedLists(): ITagMap {
if (!this.algorithm) return {}; // No tags yet.
return this.algorithm.getOrderedRooms();
}
// Intended for test usage
public async resetStore(): Promise<void> {
await this.reset();
this.prefilterConditions = [];
this.initialListsGenerated = false;
this.algorithm.off(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated);
this.algorithm.off(FILTER_CHANGED, this.onAlgorithmListUpdated);
this.algorithm.stop();
this.algorithm = new Algorithm();
this.algorithm.on(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated);
this.algorithm.on(FILTER_CHANGED, this.onAlgorithmListUpdated);
// Reset state without causing updates as the client will have been destroyed
// and downstream code will throw NPE errors.
await this.reset(null, true);
}
// Public for test usage. Do not call this.
public async makeReady(forcedClient?: MatrixClient): Promise<void> {
if (forcedClient) {
this.readyStore.useUnitTestClient(forcedClient);
}
SdkContextClass.instance.roomViewStore.addListener(UPDATE_EVENT, () => this.handleRVSUpdate({}));
this.algorithm.on(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated);
this.algorithm.on(FILTER_CHANGED, this.onAlgorithmFilterUpdated);
this.setupWatchers();
// Update any settings here, as some may have happened before we were logically ready.
logger.log("Regenerating room lists: Startup");
this.updateAlgorithmInstances();
this.regenerateAllLists({ trigger: false });
this.handleRVSUpdate({ trigger: false }); // fake an RVS update to adjust sticky room, if needed
this.updateFn.mark(); // we almost certainly want to trigger an update.
this.updateFn.trigger();
}
/**
* Handles suspected RoomViewStore changes.
* @param trigger Set to false to prevent a list update from being sent. Should only
* be used if the calling code will manually trigger the update.
*/
private handleRVSUpdate({ trigger = true }): void {
if (!this.matrixClient) return; // We assume there won't be RVS updates without a client
const activeRoomId = SdkContextClass.instance.roomViewStore.getRoomId();
if (!activeRoomId && this.algorithm.stickyRoom) {
this.algorithm.setStickyRoom(null);
} else if (activeRoomId) {
const activeRoom = this.matrixClient.getRoom(activeRoomId);
if (!activeRoom) {
logger.warn(`${activeRoomId} is current in RVS but missing from client - clearing sticky room`);
this.algorithm.setStickyRoom(null);
} else if (activeRoom !== this.algorithm.stickyRoom) {
this.algorithm.setStickyRoom(activeRoom);
}
}
if (trigger) this.updateFn.trigger();
}
protected async onReady(): Promise<any> {
await this.makeReady();
}
protected async onNotReady(): Promise<any> {
await this.resetStore();
}
protected async onAction(payload: ActionPayload): Promise<void> {
// If we're not remotely ready, don't even bother scheduling the dispatch handling.
// This is repeated in the handler just in case things change between a decision here and
// when the timer fires.
const logicallyReady = this.matrixClient && this.initialListsGenerated;
if (!logicallyReady) return;
// When we're running tests we can't reliably use setImmediate out of timing concerns.
// As such, we use a more synchronous model.
if (RoomListStoreClass.TEST_MODE) {
await this.onDispatchAsync(payload);
return;
}
// We do this to intentionally break out of the current event loop task, allowing
// us to instead wait for a more convenient time to run our updates.
setTimeout(() => this.onDispatchAsync(payload));
}
protected async onDispatchAsync(payload: ActionPayload): Promise<void> {
// Everything here requires a MatrixClient or some sort of logical readiness.
if (!this.matrixClient || !this.initialListsGenerated) return;
if (!this.algorithm) {
// This shouldn't happen because `initialListsGenerated` implies we have an algorithm.
throw new Error("Room list store has no algorithm to process dispatcher update with");
}
if (payload.action === "MatrixActions.Room.receipt") {
// First see if the receipt event is for our own user. If it was, trigger
// a room update (we probably read the room on a different device).
if (readReceiptChangeIsFor(payload.event, this.matrixClient)) {
const room = payload.room;
if (!room) {
logger.warn(`Own read receipt was in unknown room ${room.roomId}`);
return;
}
await this.handleRoomUpdate(room, RoomUpdateCause.ReadReceipt);
this.updateFn.trigger();
return;
}
} else if (payload.action === "MatrixActions.Room.tags") {
const roomPayload = <any>payload; // TODO: Type out the dispatcher types
await this.handleRoomUpdate(roomPayload.room, RoomUpdateCause.PossibleTagChange);
this.updateFn.trigger();
} else if (payload.action === "MatrixActions.Room.timeline") {
const eventPayload = <IRoomTimelineActionPayload>payload;
// Ignore non-live events (backfill) and notification timeline set events (without a room)
if (!eventPayload.isLiveEvent || !eventPayload.isLiveUnfilteredRoomTimelineEvent || !eventPayload.room) {
return;
}
const roomId = eventPayload.event.getRoomId();
const room = this.matrixClient.getRoom(roomId);
const tryUpdate = async (updatedRoom: Room): Promise<void> => {
if (
eventPayload.event.getType() === EventType.RoomTombstone &&
eventPayload.event.getStateKey() === ""
) {
const newRoom = this.matrixClient?.getRoom(eventPayload.event.getContent()["replacement_room"]);
if (newRoom) {
// If we have the new room, then the new room check will have seen the predecessor
// and did the required updates, so do nothing here.
return;
}
}
// If the join rule changes we need to update the tags for the room.
// A conference tag is determined by the room public join rule.
if (eventPayload.event.getType() === EventType.RoomJoinRules)
await this.handleRoomUpdate(updatedRoom, RoomUpdateCause.PossibleTagChange);
else await this.handleRoomUpdate(updatedRoom, RoomUpdateCause.Timeline);
this.updateFn.trigger();
};
if (!room) {
logger.warn(`Live timeline event ${eventPayload.event.getId()} received without associated room`);
logger.warn(`Queuing failed room update for retry as a result.`);
window.setTimeout(async (): Promise<void> => {
const updatedRoom = this.matrixClient?.getRoom(roomId);
if (updatedRoom) {
await tryUpdate(updatedRoom);
}
}, 100); // 100ms should be enough for the room to show up
return;
} else {
await tryUpdate(room);
}
} else if (payload.action === "MatrixActions.Event.decrypted") {
const eventPayload = <any>payload; // TODO: Type out the dispatcher types
const roomId = eventPayload.event.getRoomId();
if (!roomId) {
return;
}
const room = this.matrixClient.getRoom(roomId);
if (!room) {
logger.warn(`Event ${eventPayload.event.getId()} was decrypted in an unknown room ${roomId}`);
return;
}
await this.handleRoomUpdate(room, RoomUpdateCause.Timeline);
this.updateFn.trigger();
} else if (payload.action === "MatrixActions.accountData" && payload.event_type === EventType.Direct) {
const eventPayload = <any>payload; // TODO: Type out the dispatcher types
const dmMap = eventPayload.event.getContent();
for (const userId of Object.keys(dmMap)) {
const roomIds = dmMap[userId];
for (const roomId of roomIds) {
const room = this.matrixClient.getRoom(roomId);
if (!room) {
logger.warn(`${roomId} was found in DMs but the room is not in the store`);
continue;
}
// We expect this RoomUpdateCause to no-op if there's no change, and we don't expect
// the user to have hundreds of rooms to update in one event. As such, we just hammer
// away at updates until the problem is solved. If we were expecting more than a couple
// of rooms to be updated at once, we would consider batching the rooms up.
await this.handleRoomUpdate(room, RoomUpdateCause.PossibleTagChange);
}
}
this.updateFn.trigger();
} else if (payload.action === "MatrixActions.Room.myMembership") {
this.onDispatchMyMembership(<any>payload);
return;
}
const possibleMuteChangeRoomIds = getChangedOverrideRoomMutePushRules(payload);
if (possibleMuteChangeRoomIds) {
for (const roomId of possibleMuteChangeRoomIds) {
const room = roomId && this.matrixClient.getRoom(roomId);
if (room) {
await this.handleRoomUpdate(room, RoomUpdateCause.PossibleMuteChange);
}
}
this.updateFn.trigger();
}
}
/**
* Handle a MatrixActions.Room.myMembership event from the dispatcher.
*
* Public for test.
*/
public async onDispatchMyMembership(membershipPayload: any): Promise<void> {
// TODO: Type out the dispatcher types so membershipPayload is not any
const oldMembership = getEffectiveMembership(membershipPayload.oldMembership);
const newMembership = getEffectiveMembershipTag(membershipPayload.room, membershipPayload.membership);
if (oldMembership !== EffectiveMembership.Join && newMembership === EffectiveMembership.Join) {
// If we're joining an upgraded room, we'll want to make sure we don't proliferate
// the dead room in the list.
const roomState: RoomState = membershipPayload.room.currentState;
const predecessor = roomState.findPredecessor(this.msc3946ProcessDynamicPredecessor);
if (predecessor) {
const prevRoom = this.matrixClient?.getRoom(predecessor.roomId);
if (prevRoom) {
const isSticky = this.algorithm.stickyRoom === prevRoom;
if (isSticky) {
this.algorithm.setStickyRoom(null);
}
// Note: we hit the algorithm instead of our handleRoomUpdate() function to
// avoid redundant updates.
this.algorithm.handleRoomUpdate(prevRoom, RoomUpdateCause.RoomRemoved);
} else {
logger.warn(`Unable to find predecessor room with id ${predecessor.roomId}`);
}
}
await this.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.NewRoom);
this.updateFn.trigger();
return;
}
if (oldMembership !== EffectiveMembership.Invite && newMembership === EffectiveMembership.Invite) {
await this.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.NewRoom);
this.updateFn.trigger();
return;
}
// If it's not a join, it's transitioning into a different list (possibly historical)
if (oldMembership !== newMembership) {
await this.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.PossibleTagChange);
this.updateFn.trigger();
return;
}
}
private async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<any> {
if (cause === RoomUpdateCause.NewRoom && room.getMyMembership() === KnownMembership.Invite) {
// Let the visibility provider know that there is a new invited room. It would be nice
// if this could just be an event that things listen for but the point of this is that
// we delay doing anything about this room until the VoipUserMapper had had a chance
// to do the things it needs to do to decide if we should show this room or not, so
// an even wouldn't et us do that.
await VisibilityProvider.instance.onNewInvitedRoom(room);
}
if (!VisibilityProvider.instance.isRoomVisible(room)) {
return; // don't do anything on rooms that aren't visible
}
if (
(cause === RoomUpdateCause.NewRoom || cause === RoomUpdateCause.PossibleTagChange) &&
!this.prefilterConditions.every((c) => c.isVisible(room))
) {
return; // don't do anything on new/moved rooms which ought not to be shown
}
const shouldUpdate = this.algorithm.handleRoomUpdate(room, cause);
if (shouldUpdate) {
this.updateFn.mark();
}
}
private async recalculatePrefiltering(): Promise<void> {
if (!this.algorithm) return;
if (!this.algorithm.hasTagSortingMap) return; // we're still loading
// Inhibit updates because we're about to lie heavily to the algorithm
this.algorithm.updatesInhibited = true;
// Figure out which rooms are about to be valid, and the state of affairs
const rooms = this.getPlausibleRooms();
const currentSticky = this.algorithm.stickyRoom;
const stickyIsStillPresent = currentSticky && rooms.includes(currentSticky);
// Reset the sticky room before resetting the known rooms so the algorithm
// doesn't freak out.
this.algorithm.setStickyRoom(null);
this.algorithm.setKnownRooms(rooms);
// Set the sticky room back, if needed, now that we have updated the store.
// This will use relative stickyness to the new room set.
if (stickyIsStillPresent) {
this.algorithm.setStickyRoom(currentSticky);
}
// Finally, mark an update and resume updates from the algorithm
this.updateFn.mark();
this.algorithm.updatesInhibited = false;
}
public setTagSorting(tagId: TagID, sort: SortAlgorithm): void {
this.setAndPersistTagSorting(tagId, sort);
this.updateFn.trigger();
}
private setAndPersistTagSorting(tagId: TagID, sort: SortAlgorithm): void {
this.algorithm.setTagSorting(tagId, sort);
// TODO: Per-account? https://github.com/vector-im/element-web/issues/14114
localStorage.setItem(`mx_tagSort_${tagId}`, sort);
}
public getTagSorting(tagId: TagID): SortAlgorithm | null {
return this.algorithm.getTagSorting(tagId);
}
// noinspection JSMethodCanBeStatic
private getStoredTagSorting(tagId: TagID): SortAlgorithm {
// TODO: Per-account? https://github.com/vector-im/element-web/issues/14114
return <SortAlgorithm>localStorage.getItem(`mx_tagSort_${tagId}`);
}
// logic must match calculateListOrder
private calculateTagSorting(tagId: TagID): SortAlgorithm {
const definedSort = this.getTagSorting(tagId);
const storedSort = this.getStoredTagSorting(tagId);
// We use the following order to determine which of the 4 flags to use:
// Stored > Settings > Defined > Default
let tagSort = SortAlgorithm.Recent;
if (storedSort) {
tagSort = storedSort;
} else if (definedSort) {
tagSort = definedSort;
} // else default (already set)
return tagSort;
}
public setListOrder(tagId: TagID, order: ListAlgorithm): void {
this.setAndPersistListOrder(tagId, order);
this.updateFn.trigger();
}
private setAndPersistListOrder(tagId: TagID, order: ListAlgorithm): void {
this.algorithm.setListOrdering(tagId, order);
// TODO: Per-account? https://github.com/vector-im/element-web/issues/14114
localStorage.setItem(`mx_listOrder_${tagId}`, order);
}
public getListOrder(tagId: TagID): ListAlgorithm | null {
return this.algorithm.getListOrdering(tagId);
}
// noinspection JSMethodCanBeStatic
private getStoredListOrder(tagId: TagID): ListAlgorithm {
// TODO: Per-account? https://github.com/vector-im/element-web/issues/14114
return <ListAlgorithm>localStorage.getItem(`mx_listOrder_${tagId}`);
}
// logic must match calculateTagSorting
private calculateListOrder(tagId: TagID): ListAlgorithm {
const defaultOrder = ListAlgorithm.Natural;
const definedOrder = this.getListOrder(tagId);
const storedOrder = this.getStoredListOrder(tagId);
// We use the following order to determine which of the 4 flags to use:
// Stored > Settings > Defined > Default
let listOrder = defaultOrder;
if (storedOrder) {
listOrder = storedOrder;
} else if (definedOrder) {
listOrder = definedOrder;
} // else default (already set)
return listOrder;
}
private updateAlgorithmInstances(): void {
// We'll require an update, so mark for one. Marking now also prevents the calls
// to setTagSorting and setListOrder from causing triggers.
this.updateFn.mark();
for (const tag of Object.keys(this.orderedLists)) {
const definedSort = this.getTagSorting(tag);
const definedOrder = this.getListOrder(tag);
const tagSort = this.calculateTagSorting(tag);
const listOrder = this.calculateListOrder(tag);
if (tagSort !== definedSort) {
this.setAndPersistTagSorting(tag, tagSort);
}
if (listOrder !== definedOrder) {
this.setAndPersistListOrder(tag, listOrder);
}
}
}
private onAlgorithmListUpdated = (forceUpdate: boolean): void => {
this.updateFn.mark();
if (forceUpdate) this.updateFn.trigger();
};
private onAlgorithmFilterUpdated = (): void => {
// The filter can happen off-cycle, so trigger an update. The filter will have
// already caused a mark.
this.updateFn.trigger();
};
private onPrefilterUpdated = async (): Promise<void> => {
await this.recalculatePrefiltering();
this.updateFn.trigger();
};
private getPlausibleRooms(): Room[] {
if (!this.matrixClient) return [];
let rooms = this.matrixClient.getVisibleRooms(this.msc3946ProcessDynamicPredecessor);
rooms = rooms.filter((r) => VisibilityProvider.instance.isRoomVisible(r));
if (this.prefilterConditions.length > 0) {
rooms = rooms.filter((r) => {
for (const filter of this.prefilterConditions) {
if (!filter.isVisible(r)) {
return false;
}
}
return true;
});
}
return rooms;
}
/**
* Regenerates the room whole room list, discarding any previous results.
*
* Note: This is only exposed externally for the tests. Do not call this from within
* the app.
* @param trigger Set to false to prevent a list update from being sent. Should only
* be used if the calling code will manually trigger the update.
*/
public regenerateAllLists({ trigger = true }): void {
logger.warn("Regenerating all room lists");
const rooms = this.getPlausibleRooms();
const sorts: ITagSortingMap = {};
const orders: IListOrderingMap = {};
const allTags = [...OrderedDefaultTagIDs];
for (const tagId of allTags) {
sorts[tagId] = this.calculateTagSorting(tagId);
orders[tagId] = this.calculateListOrder(tagId);
RoomListLayoutStore.instance.ensureLayoutExists(tagId);
}
this.algorithm.populateTags(sorts, orders);
this.algorithm.setKnownRooms(rooms);
this.initialListsGenerated = true;
if (trigger) this.updateFn.trigger();
}
/**
* Adds a filter condition to the room list store. Filters may be applied async,
* and thus might not cause an update to the store immediately.
* @param {IFilterCondition} filter The filter condition to add.
*/
public async addFilter(filter: IFilterCondition): Promise<void> {
filter.on(FILTER_CHANGED, this.onPrefilterUpdated);
this.prefilterConditions.push(filter);
const promise = this.recalculatePrefiltering();
promise.then(() => this.updateFn.trigger());
}
/**
* Removes a filter condition from the room list store. If the filter was
* not previously added to the room list store, this will no-op. The effects
* of removing a filter may be applied async and therefore might not cause
* an update right away.
* @param {IFilterCondition} filter The filter condition to remove.
*/
public removeFilter(filter: IFilterCondition): void {
let promise = Promise.resolve();
let removed = false;
const idx = this.prefilterConditions.indexOf(filter);
if (idx >= 0) {
filter.off(FILTER_CHANGED, this.onPrefilterUpdated);
this.prefilterConditions.splice(idx, 1);
promise = this.recalculatePrefiltering();
removed = true;
}
if (removed) {
promise.then(() => this.updateFn.trigger());
}
}
/**
* Gets the tags for a room identified by the store. The returned set
* should never be empty, and will contain DefaultTagID.Untagged if
* the store is not aware of any tags.
* @param room The room to get the tags for.
* @returns The tags for the room.
*/
public getTagsForRoom(room: Room): TagID[] {
const algorithmTags = this.algorithm.getTagsForRoom(room);
if (!algorithmTags) return [DefaultTagID.Untagged];
return algorithmTags;
}
public getCount(tagId: TagID): number {
// The room list store knows about all the rooms, so just return the length.
return this.orderedLists[tagId].length || 0;
}
/**
* Manually update a room with a given cause. This should only be used if the
* room list store would otherwise be incapable of doing the update itself. Note
* that this may race with the room list's regular operation.
* @param {Room} room The room to update.
* @param {RoomUpdateCause} cause The cause to update for.
*/
public async manualRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<void> {
await this.handleRoomUpdate(room, cause);
this.updateFn.trigger();
}
}
export default class RoomListStore {
private static internalInstance: Interface;
public static get instance(): Interface {
if (!RoomListStore.internalInstance) {
if (SettingsStore.getValue("feature_sliding_sync")) {
logger.info("using SlidingRoomListStoreClass");
const instance = new SlidingRoomListStoreClass(defaultDispatcher, SdkContextClass.instance);
instance.start();
RoomListStore.internalInstance = instance;
} else {
const instance = new RoomListStoreClass(defaultDispatcher);
instance.start();
RoomListStore.internalInstance = instance;
}
}
return this.internalInstance;
}
}
window.mxRoomListStore = RoomListStore.instance;

View File

@@ -0,0 +1,395 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Room } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { MSC3575Filter, SlidingSyncEvent } from "matrix-js-sdk/src/sliding-sync";
import { Optional } from "matrix-events-sdk";
import { RoomUpdateCause, TagID, OrderedDefaultTagIDs, DefaultTagID } from "./models";
import { ITagMap, ListAlgorithm, SortAlgorithm } from "./algorithms/models";
import { ActionPayload } from "../../dispatcher/payloads";
import { MatrixDispatcher } from "../../dispatcher/dispatcher";
import { IFilterCondition } from "./filters/IFilterCondition";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import { RoomListStore as Interface, RoomListStoreEvent } from "./Interface";
import { MetaSpace, SpaceKey, UPDATE_SELECTED_SPACE } from "../spaces";
import { LISTS_LOADING_EVENT } from "./RoomListStore";
import { UPDATE_EVENT } from "../AsyncStore";
import { SdkContextClass } from "../../contexts/SDKContext";
interface IState {
// state is tracked in underlying classes
}
export const SlidingSyncSortToFilter: Record<SortAlgorithm, string[]> = {
[SortAlgorithm.Alphabetic]: ["by_name", "by_recency"],
[SortAlgorithm.Recent]: ["by_notification_level", "by_recency"],
[SortAlgorithm.Manual]: ["by_recency"],
};
const filterConditions: Record<TagID, MSC3575Filter> = {
[DefaultTagID.Invite]: {
is_invite: true,
},
[DefaultTagID.Favourite]: {
tags: ["m.favourite"],
},
[DefaultTagID.DM]: {
is_dm: true,
is_invite: false,
// If a DM has a Favourite & Low Prio tag then it'll be shown in those lists instead
not_tags: ["m.favourite", "m.lowpriority"],
},
[DefaultTagID.Untagged]: {
is_dm: false,
is_invite: false,
not_room_types: ["m.space"],
not_tags: ["m.favourite", "m.lowpriority"],
// spaces filter added dynamically
},
[DefaultTagID.LowPriority]: {
tags: ["m.lowpriority"],
// If a room has both Favourite & Low Prio tags then it'll be shown under Favourites
not_tags: ["m.favourite"],
},
// TODO https://github.com/vector-im/element-web/issues/23207
// DefaultTagID.ServerNotice,
// DefaultTagID.Suggested,
// DefaultTagID.Archived,
};
export const LISTS_UPDATE_EVENT = RoomListStoreEvent.ListsUpdate;
export class SlidingRoomListStoreClass extends AsyncStoreWithClient<IState> implements Interface {
private tagIdToSortAlgo: Record<TagID, SortAlgorithm> = {};
private tagMap: ITagMap = {};
private counts: Record<TagID, number> = {};
private stickyRoomId: Optional<string>;
public constructor(
dis: MatrixDispatcher,
private readonly context: SdkContextClass,
) {
super(dis);
this.setMaxListeners(20); // RoomList + LeftPanel + 8xRoomSubList + spares
}
public async setTagSorting(tagId: TagID, sort: SortAlgorithm): Promise<void> {
logger.info("SlidingRoomListStore.setTagSorting ", tagId, sort);
this.tagIdToSortAlgo[tagId] = sort;
switch (sort) {
case SortAlgorithm.Alphabetic:
await this.context.slidingSyncManager.ensureListRegistered(tagId, {
sort: SlidingSyncSortToFilter[SortAlgorithm.Alphabetic],
});
break;
case SortAlgorithm.Recent:
await this.context.slidingSyncManager.ensureListRegistered(tagId, {
sort: SlidingSyncSortToFilter[SortAlgorithm.Recent],
});
break;
case SortAlgorithm.Manual:
logger.error("cannot enable manual sort in sliding sync mode");
break;
default:
logger.error("unknown sort mode: ", sort);
}
}
public getTagSorting(tagId: TagID): SortAlgorithm {
let algo = this.tagIdToSortAlgo[tagId];
if (!algo) {
logger.warn("SlidingRoomListStore.getTagSorting: no sort algorithm for tag ", tagId);
algo = SortAlgorithm.Recent; // why not, we have to do something..
}
return algo;
}
public getCount(tagId: TagID): number {
return this.counts[tagId] || 0;
}
public setListOrder(tagId: TagID, order: ListAlgorithm): void {
// TODO: https://github.com/vector-im/element-web/issues/23207
}
public getListOrder(tagId: TagID): ListAlgorithm {
// TODO: handle unread msgs first? https://github.com/vector-im/element-web/issues/23207
return ListAlgorithm.Natural;
}
/**
* Adds a filter condition to the room list store. Filters may be applied async,
* and thus might not cause an update to the store immediately.
* @param {IFilterCondition} filter The filter condition to add.
*/
public async addFilter(filter: IFilterCondition): Promise<void> {
// Do nothing, the filters are only used by SpaceWatcher to see if a room should appear
// in the room list. We do not support arbitrary code for filters in sliding sync.
}
/**
* Removes a filter condition from the room list store. If the filter was
* not previously added to the room list store, this will no-op. The effects
* of removing a filter may be applied async and therefore might not cause
* an update right away.
* @param {IFilterCondition} filter The filter condition to remove.
*/
public removeFilter(filter: IFilterCondition): void {
// Do nothing, the filters are only used by SpaceWatcher to see if a room should appear
// in the room list. We do not support arbitrary code for filters in sliding sync.
}
/**
* Gets the tags for a room identified by the store. The returned set
* should never be empty, and will contain DefaultTagID.Untagged if
* the store is not aware of any tags.
* @param room The room to get the tags for.
* @returns The tags for the room.
*/
public getTagsForRoom(room: Room): TagID[] {
// check all lists for each tag we know about and see if the room is there
const tags: TagID[] = [];
for (const tagId in this.tagIdToSortAlgo) {
const listData = this.context.slidingSyncManager.slidingSync?.getListData(tagId);
if (!listData) {
continue;
}
for (const roomIndex in listData.roomIndexToRoomId) {
const roomId = listData.roomIndexToRoomId[roomIndex];
if (roomId === room.roomId) {
tags.push(tagId);
break;
}
}
}
return tags;
}
/**
* Manually update a room with a given cause. This should only be used if the
* room list store would otherwise be incapable of doing the update itself. Note
* that this may race with the room list's regular operation.
* @param {Room} room The room to update.
* @param {RoomUpdateCause} cause The cause to update for.
*/
public async manualRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<void> {
// TODO: this is only used when you forget a room, not that important for now.
}
public get orderedLists(): ITagMap {
return this.tagMap;
}
private refreshOrderedLists(tagId: string, roomIndexToRoomId: Record<number, string>): void {
const tagMap = this.tagMap;
// this room will not move due to it being viewed: it is sticky. This can be null to indicate
// no sticky room if you aren't viewing a room.
this.stickyRoomId = this.context.roomViewStore.getRoomId();
let stickyRoomNewIndex = -1;
const stickyRoomOldIndex = (tagMap[tagId] || []).findIndex((room): boolean => {
return room.roomId === this.stickyRoomId;
});
// order from low to high
const orderedRoomIndexes = Object.keys(roomIndexToRoomId)
.map((numStr) => {
return Number(numStr);
})
.sort((a, b) => {
return a - b;
});
const seenRoomIds = new Set<string>();
const orderedRoomIds = orderedRoomIndexes.map((i) => {
const rid = roomIndexToRoomId[i];
if (seenRoomIds.has(rid)) {
logger.error("room " + rid + " already has an index position: duplicate room!");
}
seenRoomIds.add(rid);
if (!rid) {
throw new Error("index " + i + " has no room ID: Map => " + JSON.stringify(roomIndexToRoomId));
}
if (rid === this.stickyRoomId) {
stickyRoomNewIndex = i;
}
return rid;
});
logger.debug(
`SlidingRoomListStore.refreshOrderedLists ${tagId} sticky: ${this.stickyRoomId}`,
`${stickyRoomOldIndex} -> ${stickyRoomNewIndex}`,
"rooms:",
orderedRoomIds.length < 30 ? orderedRoomIds : orderedRoomIds.length,
);
if (this.stickyRoomId && stickyRoomOldIndex >= 0 && stickyRoomNewIndex >= 0) {
// this update will move this sticky room from old to new, which we do not want.
// Instead, keep the sticky room ID index position as it is, swap it with
// whatever was in its place.
// Some scenarios with sticky room S and bump room B (other letters unimportant):
// A, S, C, B S, A, B
// B, A, S, C <---- without sticky rooms ---> B, S, A
// B, S, A, C <- with sticky rooms applied -> S, B, A
// In other words, we need to swap positions to keep it locked in place.
const inWayRoomId = orderedRoomIds[stickyRoomOldIndex];
orderedRoomIds[stickyRoomOldIndex] = this.stickyRoomId;
orderedRoomIds[stickyRoomNewIndex] = inWayRoomId;
}
// now set the rooms
const rooms: Room[] = [];
orderedRoomIds.forEach((roomId) => {
const room = this.matrixClient?.getRoom(roomId);
if (!room) {
return;
}
rooms.push(room);
});
tagMap[tagId] = rooms;
this.tagMap = tagMap;
}
private onSlidingSyncListUpdate(tagId: string, joinCount: number, roomIndexToRoomId: Record<number, string>): void {
this.counts[tagId] = joinCount;
this.refreshOrderedLists(tagId, roomIndexToRoomId);
// let the UI update
this.emit(LISTS_UPDATE_EVENT);
}
private onRoomViewStoreUpdated(): void {
// we only care about this to know when the user has clicked on a room to set the stickiness value
if (this.context.roomViewStore.getRoomId() === this.stickyRoomId) {
return;
}
let hasUpdatedAnyList = false;
// every list with the OLD sticky room ID needs to be resorted because it now needs to take
// its proper place as it is no longer sticky. The newly sticky room can remain the same though,
// as we only actually care about its sticky status when we get list updates.
const oldStickyRoom = this.stickyRoomId;
// it's not safe to check the data in slidingSync as it is tracking the server's view of the
// room list. There's an edge case whereby the sticky room has gone outside the window and so
// would not be present in the roomIndexToRoomId map anymore, and hence clicking away from it
// will make it disappear eventually. We need to check orderedLists as that is the actual
// sorted renderable list of rooms which sticky rooms apply to.
for (const tagId in this.orderedLists) {
const list = this.orderedLists[tagId];
const room = list.find((room) => {
return room.roomId === oldStickyRoom;
});
if (room) {
// resort it based on the slidingSync view of the list. This may cause this old sticky
// room to cease to exist.
const listData = this.context.slidingSyncManager.slidingSync?.getListData(tagId);
if (!listData) {
continue;
}
this.refreshOrderedLists(tagId, listData.roomIndexToRoomId);
hasUpdatedAnyList = true;
}
}
// in the event we didn't call refreshOrderedLists, it helps to still remember the sticky room ID.
this.stickyRoomId = this.context.roomViewStore.getRoomId();
if (hasUpdatedAnyList) {
this.emit(LISTS_UPDATE_EVENT);
}
}
protected async onReady(): Promise<any> {
logger.info("SlidingRoomListStore.onReady");
// permanent listeners: never get destroyed. Could be an issue if we want to test this in isolation.
this.context.slidingSyncManager.slidingSync!.on(SlidingSyncEvent.List, this.onSlidingSyncListUpdate.bind(this));
this.context.roomViewStore.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdated.bind(this));
this.context.spaceStore.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated.bind(this));
if (this.context.spaceStore.activeSpace) {
this.onSelectedSpaceUpdated(this.context.spaceStore.activeSpace, false);
}
// sliding sync has an initial response for spaces. Now request all the lists.
// We do the spaces list _first_ to avoid potential flickering on DefaultTagID.Untagged list
// which would be caused by initially having no `spaces` filter set, and then suddenly setting one.
OrderedDefaultTagIDs.forEach((tagId) => {
const filter = filterConditions[tagId];
if (!filter) {
logger.info("SlidingRoomListStore.onReady unsupported list ", tagId);
return; // we do not support this list yet.
}
const sort = SortAlgorithm.Recent; // default to recency sort, TODO: read from config
this.tagIdToSortAlgo[tagId] = sort;
this.emit(LISTS_LOADING_EVENT, tagId, true);
this.context.slidingSyncManager
.ensureListRegistered(tagId, {
filters: filter,
sort: SlidingSyncSortToFilter[sort],
})
.then(() => {
this.emit(LISTS_LOADING_EVENT, tagId, false);
});
});
}
private onSelectedSpaceUpdated = (activeSpace: SpaceKey, allRoomsInHome: boolean): void => {
logger.info("SlidingRoomListStore.onSelectedSpaceUpdated", activeSpace);
// update the untagged filter
const tagId = DefaultTagID.Untagged;
const filters = filterConditions[tagId];
const oldSpace = filters.spaces?.[0];
filters.spaces = activeSpace && activeSpace != MetaSpace.Home ? [activeSpace] : undefined;
if (oldSpace !== activeSpace) {
// include subspaces in this list
this.context.spaceStore.traverseSpace(
activeSpace,
(roomId: string) => {
if (roomId === activeSpace) {
return;
}
if (!filters.spaces) {
filters.spaces = [];
}
filters.spaces.push(roomId); // add subspace
},
false,
);
this.emit(LISTS_LOADING_EVENT, tagId, true);
this.context.slidingSyncManager
.ensureListRegistered(tagId, {
filters: filters,
})
.then(() => {
this.emit(LISTS_LOADING_EVENT, tagId, false);
});
}
};
// Intended for test usage
public async resetStore(): Promise<void> {
// Test function
}
/**
* Regenerates the room whole room list, discarding any previous results.
*
* Note: This is only exposed externally for the tests. Do not call this from within
* the app.
* @param trigger Set to false to prevent a list update from being sent. Should only
* be used if the calling code will manually trigger the update.
*/
public regenerateAllLists({ trigger = true }): void {
// Test function
}
protected async onNotReady(): Promise<any> {
await this.resetStore();
}
protected async onAction(payload: ActionPayload): Promise<void> {}
}

View File

@@ -0,0 +1,63 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { RoomListStore as Interface } from "./Interface";
import { SpaceFilterCondition } from "./filters/SpaceFilterCondition";
import SpaceStore from "../spaces/SpaceStore";
import { MetaSpace, SpaceKey, UPDATE_HOME_BEHAVIOUR, UPDATE_SELECTED_SPACE } from "../spaces";
/**
* Watches for changes in spaces to manage the filter on the provided RoomListStore
*/
export class SpaceWatcher {
private readonly filter = new SpaceFilterCondition();
// we track these separately to the SpaceStore as we need to observe transitions
private activeSpace: SpaceKey = SpaceStore.instance.activeSpace;
private allRoomsInHome: boolean = SpaceStore.instance.allRoomsInHome;
public constructor(private store: Interface) {
if (SpaceWatcher.needsFilter(this.activeSpace, this.allRoomsInHome)) {
this.updateFilter();
store.addFilter(this.filter);
}
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated);
SpaceStore.instance.on(UPDATE_HOME_BEHAVIOUR, this.onHomeBehaviourUpdated);
}
private static needsFilter(spaceKey: SpaceKey, allRoomsInHome: boolean): boolean {
return !(spaceKey === MetaSpace.Home && allRoomsInHome);
}
private onSelectedSpaceUpdated = (activeSpace: SpaceKey, allRoomsInHome = this.allRoomsInHome): void => {
if (activeSpace === this.activeSpace && allRoomsInHome === this.allRoomsInHome) return; // nop
const neededFilter = SpaceWatcher.needsFilter(this.activeSpace, this.allRoomsInHome);
const needsFilter = SpaceWatcher.needsFilter(activeSpace, allRoomsInHome);
this.activeSpace = activeSpace;
this.allRoomsInHome = allRoomsInHome;
if (needsFilter) {
this.updateFilter();
}
if (!neededFilter && needsFilter) {
this.store.addFilter(this.filter);
} else if (neededFilter && !needsFilter) {
this.store.removeFilter(this.filter);
}
};
private onHomeBehaviourUpdated = (allRoomsInHome: boolean): void => {
this.onSelectedSpaceUpdated(this.activeSpace, allRoomsInHome);
};
private updateFilter = (): void => {
this.filter.updateSpace(this.activeSpace);
};
}

View File

@@ -0,0 +1,770 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { JoinRule, Room } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
import { EventEmitter } from "events";
import { logger } from "matrix-js-sdk/src/logger";
import DMRoomMap from "../../../utils/DMRoomMap";
import { arrayDiff, arrayHasDiff } from "../../../utils/arrays";
import { DefaultTagID, RoomUpdateCause, TagID } from "../models";
import {
IListOrderingMap,
IOrderingAlgorithmMap,
ITagMap,
ITagSortingMap,
ListAlgorithm,
SortAlgorithm,
} from "./models";
import {
EffectiveMembership,
getEffectiveMembership,
getEffectiveMembershipTag,
splitRoomsByMembership,
} from "../../../utils/membership";
import { OrderingAlgorithm } from "./list-ordering/OrderingAlgorithm";
import { getListAlgorithmInstance } from "./list-ordering";
import { VisibilityProvider } from "../filters/VisibilityProvider";
import { CallStore, CallStoreEvent } from "../../CallStore";
/**
* Fired when the Algorithm has determined a list has been updated.
*/
export const LIST_UPDATED_EVENT = "list_updated_event";
// These are the causes which require a room to be known in order for us to handle them. If
// a cause in this list is raised and we don't know about the room, we don't handle the update.
//
// Note: these typically happen when a new room is coming in, such as the user creating or
// joining the room. For these cases, we need to know about the room prior to handling it otherwise
// we'll make bad assumptions.
const CAUSES_REQUIRING_ROOM = [RoomUpdateCause.Timeline, RoomUpdateCause.ReadReceipt];
interface IStickyRoom {
room: Room;
position: number;
tag: TagID;
}
/**
* Represents a list ordering algorithm. This class will take care of tag
* management (which rooms go in which tags) and ask the implementation to
* deal with ordering mechanics.
*/
export class Algorithm extends EventEmitter {
private _cachedRooms: ITagMap = {};
private _cachedStickyRooms: ITagMap | null = {}; // a clone of the _cachedRooms, with the sticky room
private _stickyRoom: IStickyRoom | null = null;
private _lastStickyRoom: IStickyRoom | null = null; // only not-null when changing the sticky room
private sortAlgorithms: ITagSortingMap | null = null;
private listAlgorithms: IListOrderingMap | null = null;
private algorithms: IOrderingAlgorithmMap | null = null;
private rooms: Room[] = [];
private roomIdsToTags: {
[roomId: string]: TagID[];
} = {};
/**
* Set to true to suspend emissions of algorithm updates.
*/
public updatesInhibited = false;
public start(): void {
CallStore.instance.on(CallStoreEvent.ConnectedCalls, this.onConnectedCalls);
}
public stop(): void {
CallStore.instance.off(CallStoreEvent.ConnectedCalls, this.onConnectedCalls);
}
public get stickyRoom(): Room | null {
return this._stickyRoom ? this._stickyRoom.room : null;
}
public get hasTagSortingMap(): boolean {
return !!this.sortAlgorithms;
}
protected set cachedRooms(val: ITagMap) {
this._cachedRooms = val;
this.recalculateStickyRoom();
this.recalculateActiveCallRooms();
}
protected get cachedRooms(): ITagMap {
// 🐉 Here be dragons.
// Note: this is used by the underlying algorithm classes, so don't make it return
// the sticky room cache. If it ends up returning the sticky room cache, we end up
// corrupting our caches and confusing them.
return this._cachedRooms;
}
/**
* Awaitable version of the sticky room setter.
* @param val The new room to sticky.
*/
public setStickyRoom(val: Room | null): void {
try {
this.updateStickyRoom(val);
} catch (e) {
logger.warn("Failed to update sticky room", e);
}
}
public getTagSorting(tagId: TagID): SortAlgorithm | null {
if (!this.sortAlgorithms) return null;
return this.sortAlgorithms[tagId];
}
public setTagSorting(tagId: TagID, sort: SortAlgorithm): void {
if (!tagId) throw new Error("Tag ID must be defined");
if (!sort) throw new Error("Algorithm must be defined");
if (!this.sortAlgorithms) throw new Error("this.sortAlgorithms must be defined before calling setTagSorting");
if (!this.algorithms) throw new Error("this.algorithms must be defined before calling setTagSorting");
this.sortAlgorithms[tagId] = sort;
const algorithm: OrderingAlgorithm = this.algorithms[tagId];
algorithm.setSortAlgorithm(sort);
this._cachedRooms[tagId] = algorithm.orderedRooms;
this.recalculateStickyRoom(tagId); // update sticky room to make sure it appears if needed
this.recalculateActiveCallRooms(tagId);
}
public getListOrdering(tagId: TagID): ListAlgorithm | null {
if (!this.listAlgorithms) return null;
return this.listAlgorithms[tagId];
}
public setListOrdering(tagId: TagID, order: ListAlgorithm): void {
if (!tagId) throw new Error("Tag ID must be defined");
if (!order) throw new Error("Algorithm must be defined");
if (!this.sortAlgorithms) throw new Error("this.sortAlgorithms must be defined before calling setListOrdering");
if (!this.listAlgorithms) throw new Error("this.listAlgorithms must be defined before calling setListOrdering");
if (!this.algorithms) throw new Error("this.algorithms must be defined before calling setListOrdering");
this.listAlgorithms[tagId] = order;
const algorithm = getListAlgorithmInstance(order, tagId, this.sortAlgorithms[tagId]);
this.algorithms[tagId] = algorithm;
algorithm.setRooms(this._cachedRooms[tagId]);
this._cachedRooms[tagId] = algorithm.orderedRooms;
this.recalculateStickyRoom(tagId); // update sticky room to make sure it appears if needed
this.recalculateActiveCallRooms(tagId);
}
private updateStickyRoom(val: Room | null): void {
this.doUpdateStickyRoom(val);
this._lastStickyRoom = null; // clear to indicate we're done changing
}
private doUpdateStickyRoom(val: Room | null): void {
if (val?.isSpaceRoom() && val.getMyMembership() !== KnownMembership.Invite) {
// no-op sticky rooms for spaces - they're effectively virtual rooms
val = null;
}
if (val && !VisibilityProvider.instance.isRoomVisible(val)) {
val = null; // the room isn't visible - lie to the rest of this function
}
// Set the last sticky room to indicate that we're in a change. The code throughout the
// class can safely handle a null room, so this should be safe to do as a backup.
this._lastStickyRoom = this._stickyRoom || <IStickyRoom>{};
// It's possible to have no selected room. In that case, clear the sticky room
if (!val) {
if (this._stickyRoom) {
const stickyRoom = this._stickyRoom.room;
this._stickyRoom = null; // clear before we go to update the algorithm
// Lie to the algorithm and re-add the room to the algorithm
this.handleRoomUpdate(stickyRoom, RoomUpdateCause.NewRoom);
return;
}
return;
}
// When we do have a room though, we expect to be able to find it
let tag = this.roomIdsToTags[val.roomId]?.[0];
if (!tag) throw new Error(`${val.roomId} does not belong to a tag and cannot be sticky`);
// We specifically do NOT use the ordered rooms set as it contains the sticky room, which
// means we'll be off by 1 when the user is switching rooms. This leads to visual jumping
// when the user is moving south in the list (not north, because of math).
const tagList = this.getOrderedRoomsWithoutSticky()[tag] || []; // can be null if filtering
let position = tagList.indexOf(val);
// We do want to see if a tag change happened though - if this did happen then we'll want
// to force the position to zero (top) to ensure we can properly handle it.
const wasSticky = this._lastStickyRoom.room ? this._lastStickyRoom.room.roomId === val.roomId : false;
if (this._lastStickyRoom.tag && tag !== this._lastStickyRoom.tag && wasSticky && position < 0) {
logger.warn(`Sticky room ${val.roomId} changed tags during sticky room handling`);
position = 0;
}
// Sanity check the position to make sure the room is qualified for being sticky
if (position < 0) throw new Error(`${val.roomId} does not appear to be known and cannot be sticky`);
// 🐉 Here be dragons.
// Before we can go through with lying to the underlying algorithm about a room
// we need to ensure that when we do we're ready for the inevitable sticky room
// update we'll receive. To prepare for that, we first remove the sticky room and
// recalculate the state ourselves so that when the underlying algorithm calls for
// the same thing it no-ops. After we're done calling the algorithm, we'll issue
// a new update for ourselves.
const lastStickyRoom = this._stickyRoom;
this._stickyRoom = null; // clear before we update the algorithm
this.recalculateStickyRoom();
// When we do have the room, re-add the old room (if needed) to the algorithm
// and remove the sticky room from the algorithm. This is so the underlying
// algorithm doesn't try and confuse itself with the sticky room concept.
// We don't add the new room if the sticky room isn't changing because that's
// an easy way to cause duplication. We have to do room ID checks instead of
// referential checks as the references can differ through the lifecycle.
if (lastStickyRoom && lastStickyRoom.room && lastStickyRoom.room.roomId !== val.roomId) {
// Lie to the algorithm and re-add the room to the algorithm
this.handleRoomUpdate(lastStickyRoom.room, RoomUpdateCause.NewRoom);
}
// Lie to the algorithm and remove the room from it's field of view
this.handleRoomUpdate(val, RoomUpdateCause.RoomRemoved);
// handleRoomUpdate may have modified this._stickyRoom. Convince the
// compiler of this fact.
this._stickyRoom = this.stickyRoomMightBeModified();
// Check for tag & position changes while we're here. We also check the room to ensure
// it is still the same room.
if (this._stickyRoom) {
if (this._stickyRoom.room !== val) {
// Check the room IDs just in case
if (this._stickyRoom.room.roomId === val.roomId) {
logger.warn("Sticky room changed references");
} else {
throw new Error("Sticky room changed while the sticky room was changing");
}
}
logger.warn(
`Sticky room changed tag & position from ${tag} / ${position} ` +
`to ${this._stickyRoom.tag} / ${this._stickyRoom.position}`,
);
tag = this._stickyRoom.tag;
position = this._stickyRoom.position;
}
// Now that we're done lying to the algorithm, we need to update our position
// marker only if the user is moving further down the same list. If they're switching
// lists, or moving upwards, the position marker will splice in just fine but if
// they went downwards in the same list we'll be off by 1 due to the shifting rooms.
if (lastStickyRoom && lastStickyRoom.tag === tag && lastStickyRoom.position <= position) {
position++;
}
this._stickyRoom = {
room: val,
position: position,
tag: tag,
};
// We update the filtered rooms just in case, as otherwise users will end up visiting
// a room while filtering and it'll disappear. We don't update the filter earlier in
// this function simply because we don't have to.
this.recalculateStickyRoom();
this.recalculateActiveCallRooms(tag);
if (lastStickyRoom && lastStickyRoom.tag !== tag) this.recalculateActiveCallRooms(lastStickyRoom.tag);
// Finally, trigger an update
if (this.updatesInhibited) return;
this.emit(LIST_UPDATED_EVENT);
}
/**
* Hack to prevent Typescript claiming this._stickyRoom is always null.
*/
private stickyRoomMightBeModified(): IStickyRoom | null {
return this._stickyRoom;
}
private onConnectedCalls = (): void => {
// In case we're unsticking a room, sort it back into natural order
this.recalculateStickyRoom();
// Update the stickiness of rooms with calls
this.recalculateActiveCallRooms();
if (this.updatesInhibited) return;
// This isn't in response to any particular RoomListStore update,
// so notify the store that it needs to force-update
this.emit(LIST_UPDATED_EVENT, true);
};
private initCachedStickyRooms(): void {
this._cachedStickyRooms = {};
for (const tagId of Object.keys(this.cachedRooms)) {
this._cachedStickyRooms[tagId] = [...this.cachedRooms[tagId]]; // shallow clone
}
}
/**
* Recalculate the sticky room position. If this is being called in relation to
* a specific tag being updated, it should be given to this function to optimize
* the call.
* @param updatedTag The tag that was updated, if possible.
*/
protected recalculateStickyRoom(updatedTag: TagID | null = null): void {
// 🐉 Here be dragons.
// This function does far too much for what it should, and is called by many places.
// Not only is this responsible for ensuring the sticky room is held in place at all
// times, it is also responsible for ensuring our clone of the cachedRooms is up to
// date. If either of these desyncs, we see weird behaviour like duplicated rooms,
// outdated lists, and other nonsensical issues that aren't necessarily obvious.
if (!this._stickyRoom) {
// If there's no sticky room, just do nothing useful.
if (!!this._cachedStickyRooms) {
// Clear the cache if we won't be needing it
this._cachedStickyRooms = null;
if (this.updatesInhibited) return;
this.emit(LIST_UPDATED_EVENT);
}
return;
}
if (!this._cachedStickyRooms || !updatedTag) {
this.initCachedStickyRooms();
}
if (updatedTag) {
// Update the tag indicated by the caller, if possible. This is mostly to ensure
// our cache is up to date.
if (this._cachedStickyRooms) {
this._cachedStickyRooms[updatedTag] = [...this.cachedRooms[updatedTag]]; // shallow clone
}
}
// Now try to insert the sticky room, if we need to.
// We need to if there's no updated tag (we regenned the whole cache) or if the tag
// we might have updated from the cache is also our sticky room.
const sticky = this._stickyRoom;
if (sticky && (!updatedTag || updatedTag === sticky.tag) && this._cachedStickyRooms) {
this._cachedStickyRooms[sticky.tag].splice(sticky.position, 0, sticky.room);
}
// Finally, trigger an update
if (this.updatesInhibited) return;
this.emit(LIST_UPDATED_EVENT);
}
/**
* Recalculate the position of any rooms with calls. If this is being called in
* relation to a specific tag being updated, it should be given to this function to
* optimize the call.
*
* This expects to be called *after* the sticky rooms are updated, and sticks the
* room with the currently active call to the top of its tag.
*
* @param updatedTag The tag that was updated, if possible.
*/
protected recalculateActiveCallRooms(updatedTag: TagID | null = null): void {
if (!updatedTag) {
// Assume all tags need updating
// We're not modifying the map here, so can safely rely on the cached values
// rather than the explicitly sticky map.
for (const tagId of Object.keys(this.cachedRooms)) {
if (!tagId) {
throw new Error("Unexpected recursion: falsy tag");
}
this.recalculateActiveCallRooms(tagId);
}
return;
}
if (CallStore.instance.connectedCalls.size) {
// We operate on the sticky rooms map
if (!this._cachedStickyRooms) this.initCachedStickyRooms();
const rooms = this._cachedStickyRooms![updatedTag];
const activeRoomIds = new Set([...CallStore.instance.connectedCalls].map((call) => call.roomId));
const activeRooms: Room[] = [];
const inactiveRooms: Room[] = [];
for (const room of rooms) {
(activeRoomIds.has(room.roomId) ? activeRooms : inactiveRooms).push(room);
}
// Stick rooms with active calls to the top
this._cachedStickyRooms![updatedTag] = [...activeRooms, ...inactiveRooms];
}
}
/**
* Asks the Algorithm to regenerate all lists, using the tags given
* as reference for which lists to generate and which way to generate
* them.
* @param {ITagSortingMap} tagSortingMap The tags to generate.
* @param {IListOrderingMap} listOrderingMap The ordering of those tags.
*/
public populateTags(tagSortingMap: ITagSortingMap, listOrderingMap: IListOrderingMap): void {
if (!tagSortingMap) throw new Error(`Sorting map cannot be null or empty`);
if (!listOrderingMap) throw new Error(`Ordering ma cannot be null or empty`);
if (arrayHasDiff(Object.keys(tagSortingMap), Object.keys(listOrderingMap))) {
throw new Error(`Both maps must contain the exact same tags`);
}
this.sortAlgorithms = tagSortingMap;
this.listAlgorithms = listOrderingMap;
this.algorithms = {};
for (const tag of Object.keys(tagSortingMap)) {
this.algorithms[tag] = getListAlgorithmInstance(this.listAlgorithms[tag], tag, this.sortAlgorithms[tag]);
}
return this.setKnownRooms(this.rooms);
}
/**
* Gets an ordered set of rooms for the all known tags.
* @returns {ITagMap} The cached list of rooms, ordered,
* for each tag. May be empty, but never null/undefined.
*/
public getOrderedRooms(): ITagMap {
return this._cachedStickyRooms || this.cachedRooms;
}
/**
* This returns the same as getOrderedRooms(), but without the sticky room
* map as it causes issues for sticky room handling (see sticky room handling
* for more information).
* @returns {ITagMap} The cached list of rooms, ordered,
* for each tag. May be empty, but never null/undefined.
*/
private getOrderedRoomsWithoutSticky(): ITagMap {
return this.cachedRooms;
}
/**
* Seeds the Algorithm with a set of rooms. The algorithm will discard all
* previously known information and instead use these rooms instead.
* @param {Room[]} rooms The rooms to force the algorithm to use.
*/
public setKnownRooms(rooms: Room[]): void {
if (isNullOrUndefined(rooms)) throw new Error(`Array of rooms cannot be null`);
if (!this.sortAlgorithms) throw new Error(`Cannot set known rooms without a tag sorting map`);
if (!this.updatesInhibited) {
// We only log this if we're expecting to be publishing updates, which means that
// this could be an unexpected invocation. If we're inhibited, then this is probably
// an intentional invocation.
logger.warn("Resetting known rooms, initiating regeneration");
}
// Before we go any further we need to clear (but remember) the sticky room to
// avoid accidentally duplicating it in the list.
const oldStickyRoom = this._stickyRoom;
if (oldStickyRoom) this.updateStickyRoom(null);
this.rooms = rooms;
const newTags: ITagMap = {};
for (const tagId in this.sortAlgorithms) {
// noinspection JSUnfilteredForInLoop
newTags[tagId] = [];
}
// If we can avoid doing work, do so.
if (!rooms.length) {
this.generateFreshTags(newTags); // just in case it wants to do something
this.cachedRooms = newTags;
return;
}
// Split out the easy rooms first (leave and invite)
const memberships = splitRoomsByMembership(rooms);
for (const room of memberships[EffectiveMembership.Invite]) {
newTags[DefaultTagID.Invite].push(room);
}
for (const room of memberships[EffectiveMembership.Leave]) {
// We may not have had an archived section previously, so make sure its there.
if (newTags[DefaultTagID.Archived] === undefined) newTags[DefaultTagID.Archived] = [];
newTags[DefaultTagID.Archived].push(room);
}
// Now process all the joined rooms. This is a bit more complicated
for (const room of memberships[EffectiveMembership.Join]) {
const tags = this.getTagsOfJoinedRoom(room);
let inTag = false;
if (tags.length > 0) {
for (const tag of tags) {
if (!isNullOrUndefined(newTags[tag])) {
newTags[tag].push(room);
inTag = true;
}
}
}
if (!inTag) {
if (DMRoomMap.shared().getUserIdForRoomId(room.roomId)) {
newTags[DefaultTagID.DM].push(room);
} else {
newTags[DefaultTagID.Untagged].push(room);
}
}
}
this.generateFreshTags(newTags);
this.cachedRooms = newTags; // this recalculates the filtered rooms for us
this.updateTagsFromCache();
// Now that we've finished generation, we need to update the sticky room to what
// it was. It's entirely possible that it changed lists though, so if it did then
// we also have to update the position of it.
if (oldStickyRoom && oldStickyRoom.room) {
this.updateStickyRoom(oldStickyRoom.room);
if (this._stickyRoom && this._stickyRoom.room) {
// just in case the update doesn't go according to plan
if (this._stickyRoom.tag !== oldStickyRoom.tag) {
// We put the sticky room at the top of the list to treat it as an obvious tag change.
this._stickyRoom.position = 0;
this.recalculateStickyRoom(this._stickyRoom.tag);
}
}
}
}
public getTagsForRoom(room: Room): TagID[] {
const tags: TagID[] = [];
if (!getEffectiveMembership(room.getMyMembership())) return []; // peeked room has no tags
const membership = getEffectiveMembershipTag(room);
if (membership === EffectiveMembership.Invite) {
tags.push(DefaultTagID.Invite);
} else if (membership === EffectiveMembership.Leave) {
tags.push(DefaultTagID.Archived);
} else {
tags.push(...this.getTagsOfJoinedRoom(room));
}
if (!tags.length) tags.push(DefaultTagID.Untagged);
return tags;
}
private getTagsOfJoinedRoom(room: Room): TagID[] {
let tags = Object.keys(room.tags || {});
if (tags.length === 0) {
// Check to see if it's a DM if it isn't anything else
if (DMRoomMap.shared().getUserIdForRoomId(room.roomId)) {
tags = [DefaultTagID.DM];
}
}
if (room.isCallRoom() && (room.getJoinRule() === JoinRule.Public || room.getJoinRule() === JoinRule.Knock)) {
tags.push(DefaultTagID.Conference);
}
return tags;
}
/**
* Updates the roomsToTags map
*/
private updateTagsFromCache(): void {
const newMap: Algorithm["roomIdsToTags"] = {};
const tags = Object.keys(this.cachedRooms);
for (const tagId of tags) {
const rooms = this.cachedRooms[tagId];
for (const room of rooms) {
if (!newMap[room.roomId]) newMap[room.roomId] = [];
newMap[room.roomId].push(tagId);
}
}
this.roomIdsToTags = newMap;
}
/**
* Called when the Algorithm believes a complete regeneration of the existing
* lists is needed.
* @param {ITagMap} updatedTagMap The tag map which needs populating. Each tag
* will already have the rooms which belong to it - they just need ordering. Must
* be mutated in place.
*/
private generateFreshTags(updatedTagMap: ITagMap): void {
if (!this.algorithms) throw new Error("Not ready: no algorithms to determine tags from");
for (const tag of Object.keys(updatedTagMap)) {
const algorithm: OrderingAlgorithm = this.algorithms[tag];
if (!algorithm) throw new Error(`No algorithm for ${tag}`);
algorithm.setRooms(updatedTagMap[tag]);
updatedTagMap[tag] = algorithm.orderedRooms;
}
}
/**
* Asks the Algorithm to update its knowledge of a room. For example, when
* a user tags a room, joins/creates a room, or leaves a room the Algorithm
* should be told that the room's info might have changed. The Algorithm
* may no-op this request if no changes are required.
* @param {Room} room The room which might have affected sorting.
* @param {RoomUpdateCause} cause The reason for the update being triggered.
* @returns {Promise<boolean>} A boolean of whether or not getOrderedRooms()
* should be called after processing.
*/
public handleRoomUpdate(room: Room, cause: RoomUpdateCause): boolean {
if (!this.algorithms) throw new Error("Not ready: no algorithms to determine tags from");
// Note: check the isSticky against the room ID just in case the reference is wrong
const isSticky = this._stickyRoom?.room?.roomId === room.roomId;
if (cause === RoomUpdateCause.NewRoom) {
const isForLastSticky = this._lastStickyRoom?.room === room;
const roomTags = this.roomIdsToTags[room.roomId];
const hasTags = roomTags && roomTags.length > 0;
// Don't change the cause if the last sticky room is being re-added. If we fail to
// pass the cause through as NewRoom, we'll fail to lie to the algorithm and thus
// lose the room.
if (hasTags && !isForLastSticky) {
logger.warn(`${room.roomId} is reportedly new but is already known - assuming TagChange instead`);
cause = RoomUpdateCause.PossibleTagChange;
}
// Check to see if the room is known first
let knownRoomRef = this.rooms.includes(room);
if (hasTags && !knownRoomRef) {
logger.warn(`${room.roomId} might be a reference change - attempting to update reference`);
this.rooms = this.rooms.map((r) => (r.roomId === room.roomId ? room : r));
knownRoomRef = this.rooms.includes(room);
if (!knownRoomRef) {
logger.warn(`${room.roomId} is still not referenced. It may be sticky.`);
}
}
// If we have tags for a room and don't have the room referenced, something went horribly
// wrong - the reference should have been updated above.
if (hasTags && !knownRoomRef && !isSticky) {
throw new Error(`${room.roomId} is missing from room array but is known - trying to find duplicate`);
}
// Like above, update the reference to the sticky room if we need to
if (hasTags && isSticky && this._stickyRoom) {
// Go directly in and set the sticky room's new reference, being careful not
// to trigger a sticky room update ourselves.
this._stickyRoom.room = room;
}
// If after all that we're still a NewRoom update, add the room if applicable.
// We don't do this for the sticky room (because it causes duplication issues)
// or if we know about the reference (as it should be replaced).
if (cause === RoomUpdateCause.NewRoom && !isSticky && !knownRoomRef) {
this.rooms.push(room);
}
}
let didTagChange = false;
if (cause === RoomUpdateCause.PossibleTagChange) {
const oldTags = this.roomIdsToTags[room.roomId] || [];
const newTags = this.getTagsForRoom(room);
const diff = arrayDiff(oldTags, newTags);
if (diff.removed.length > 0 || diff.added.length > 0) {
for (const rmTag of diff.removed) {
const algorithm: OrderingAlgorithm = this.algorithms[rmTag];
if (!algorithm) throw new Error(`No algorithm for ${rmTag}`);
algorithm.handleRoomUpdate(room, RoomUpdateCause.RoomRemoved);
this._cachedRooms[rmTag] = algorithm.orderedRooms;
this.recalculateStickyRoom(rmTag); // update sticky room to make sure it moves if needed
this.recalculateActiveCallRooms(rmTag);
}
for (const addTag of diff.added) {
const algorithm: OrderingAlgorithm = this.algorithms[addTag];
if (!algorithm) throw new Error(`No algorithm for ${addTag}`);
algorithm.handleRoomUpdate(room, RoomUpdateCause.NewRoom);
this._cachedRooms[addTag] = algorithm.orderedRooms;
}
// Update the tag map so we don't regen it in a moment
this.roomIdsToTags[room.roomId] = newTags;
cause = RoomUpdateCause.Timeline;
didTagChange = true;
} else {
// This is a tag change update and no tags were changed, nothing to do!
return false;
}
if (didTagChange && isSticky) {
// Manually update the tag for the sticky room without triggering a sticky room
// update. The update will be handled implicitly by the sticky room handling and
// requires no changes on our part, if we're in the middle of a sticky room change.
if (this._lastStickyRoom) {
this._stickyRoom = {
room,
tag: this.roomIdsToTags[room.roomId][0],
position: 0, // right at the top as it changed tags
};
} else {
// We have to clear the lock as the sticky room change will trigger updates.
this.setStickyRoom(room);
}
}
}
// If the update is for a room change which might be the sticky room, prevent it. We
// need to make sure that the causes (NewRoom and RoomRemoved) are still triggered though
// as the sticky room relies on this.
if (cause !== RoomUpdateCause.NewRoom && cause !== RoomUpdateCause.RoomRemoved) {
if (this.stickyRoom === room) {
return false;
}
}
if (!this.roomIdsToTags[room.roomId]) {
if (CAUSES_REQUIRING_ROOM.includes(cause)) {
return false;
}
// Get the tags for the room and populate the cache
const roomTags = this.getTagsForRoom(room).filter((t) => !isNullOrUndefined(this.cachedRooms[t]));
// "This should never happen" condition - we specify DefaultTagID.Untagged in getTagsForRoom(),
// which means we should *always* have a tag to go off of.
if (!roomTags.length) throw new Error(`Tags cannot be determined for ${room.roomId}`);
this.roomIdsToTags[room.roomId] = roomTags;
}
const tags = this.roomIdsToTags[room.roomId];
if (!tags) {
logger.warn(`No tags known for "${room.name}" (${room.roomId})`);
return false;
}
let changed = didTagChange;
for (const tag of tags) {
const algorithm: OrderingAlgorithm = this.algorithms[tag];
if (!algorithm) throw new Error(`No algorithm for ${tag}`);
algorithm.handleRoomUpdate(room, cause);
this._cachedRooms[tag] = algorithm.orderedRooms;
// Flag that we've done something
this.recalculateStickyRoom(tag); // update sticky room to make sure it appears if needed
this.recalculateActiveCallRooms(tag);
changed = true;
}
return changed;
}
}

View File

@@ -0,0 +1,311 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2018, 2019 New Vector Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Room } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { RoomUpdateCause, TagID } from "../../models";
import { SortAlgorithm } from "../models";
import { sortRoomsWithAlgorithm } from "../tag-sorting";
import { OrderingAlgorithm } from "./OrderingAlgorithm";
import { NotificationLevel } from "../../../notifications/NotificationLevel";
import { RoomNotificationStateStore } from "../../../notifications/RoomNotificationStateStore";
type CategorizedRoomMap = {
[category in NotificationLevel]: Room[];
};
type CategoryIndex = Partial<{
[category in NotificationLevel]: number; // integer
}>;
// Caution: changing this means you'll need to update a bunch of assumptions and
// comments! Check the usage of Category carefully to figure out what needs changing
// if you're going to change this array's order.
const CATEGORY_ORDER = [
NotificationLevel.Unsent,
NotificationLevel.Highlight,
NotificationLevel.Notification,
NotificationLevel.Activity,
NotificationLevel.None, // idle
NotificationLevel.Muted,
];
/**
* An implementation of the "importance" algorithm for room list sorting. Where
* the tag sorting algorithm does not interfere, rooms will be ordered into
* categories of varying importance to the user. Alphabetical sorting does not
* interfere with this algorithm, however manual ordering does.
*
* The importance of a room is defined by the kind of notifications, if any, are
* present on the room. These are classified internally as Unsent, Red, Grey,
* Bold, and Idle. 'Unsent' rooms have unsent messages, Red rooms have mentions,
* grey have unread messages, bold is a less noisy version of grey, and idle
* means all activity has been seen by the user.
*
* The algorithm works by monitoring all room changes, including new messages in
* tracked rooms, to determine if it needs a new category or different placement
* within the same category. For more information, see the comments contained
* within the class.
*/
export class ImportanceAlgorithm extends OrderingAlgorithm {
// This tracks the category for the tag it represents by tracking the index of
// each category within the list, where zero is the top of the list. This then
// tracks when rooms change categories and splices the orderedRooms array as
// needed, preventing many ordering operations.
private indices: CategoryIndex = {};
public constructor(tagId: TagID, initialSortingAlgorithm: SortAlgorithm) {
super(tagId, initialSortingAlgorithm);
}
// noinspection JSMethodCanBeStatic
private categorizeRooms(rooms: Room[]): CategorizedRoomMap {
const map: CategorizedRoomMap = {
[NotificationLevel.Unsent]: [],
[NotificationLevel.Highlight]: [],
[NotificationLevel.Notification]: [],
[NotificationLevel.Activity]: [],
[NotificationLevel.None]: [],
[NotificationLevel.Muted]: [],
};
for (const room of rooms) {
const category = this.getRoomCategory(room);
map[category]?.push(room);
}
return map;
}
// noinspection JSMethodCanBeStatic
private getRoomCategory(room: Room): NotificationLevel {
// It's fine for us to call this a lot because it's cached, and we shouldn't be
// wasting anything by doing so as the store holds single references
const state = RoomNotificationStateStore.instance.getRoomState(room);
return this.isMutedToBottom && state.muted ? NotificationLevel.Muted : state.level;
}
public setRooms(rooms: Room[]): void {
if (this.sortingAlgorithm === SortAlgorithm.Manual) {
this.cachedOrderedRooms = sortRoomsWithAlgorithm(rooms, this.tagId, this.sortingAlgorithm);
} else {
// Every other sorting type affects the categories, not the whole tag.
const categorized = this.categorizeRooms(rooms);
for (const category of Object.keys(categorized)) {
const notificationColor = category as unknown as NotificationLevel;
const roomsToOrder = categorized[notificationColor];
categorized[notificationColor] = sortRoomsWithAlgorithm(
roomsToOrder,
this.tagId,
this.sortingAlgorithm,
);
}
const newlyOrganized: Room[] = [];
const newIndices: CategoryIndex = {};
for (const category of CATEGORY_ORDER) {
newIndices[category] = newlyOrganized.length;
newlyOrganized.push(...categorized[category]);
}
this.indices = newIndices;
this.cachedOrderedRooms = newlyOrganized;
}
}
private getCategoryIndex(category: NotificationLevel): number {
const categoryIndex = this.indices[category];
if (categoryIndex === undefined) {
throw new Error(`Index of category ${category} not found`);
}
return categoryIndex;
}
private handleSplice(room: Room, cause: RoomUpdateCause): boolean {
if (cause === RoomUpdateCause.NewRoom) {
const category = this.getRoomCategory(room);
this.alterCategoryPositionBy(category, 1, this.indices);
this.cachedOrderedRooms.splice(this.getCategoryIndex(category), 0, room); // splice in the new room (pre-adjusted)
this.sortCategory(category);
} else if (cause === RoomUpdateCause.RoomRemoved) {
const roomIdx = this.getRoomIndex(room);
if (roomIdx === -1) {
logger.warn(`Tried to remove unknown room from ${this.tagId}: ${room.roomId}`);
return false; // no change
}
const oldCategory = this.getCategoryFromIndices(roomIdx, this.indices);
this.alterCategoryPositionBy(oldCategory, -1, this.indices);
this.cachedOrderedRooms.splice(roomIdx, 1); // remove the room
} else {
throw new Error(`Unhandled splice: ${cause}`);
}
// changes have been made if we made it here, so say so
return true;
}
public handleRoomUpdate(room: Room, cause: RoomUpdateCause): boolean {
if (cause === RoomUpdateCause.NewRoom || cause === RoomUpdateCause.RoomRemoved) {
return this.handleSplice(room, cause);
}
if (
cause !== RoomUpdateCause.Timeline &&
cause !== RoomUpdateCause.ReadReceipt &&
cause !== RoomUpdateCause.PossibleMuteChange
) {
throw new Error(`Unsupported update cause: ${cause}`);
}
// don't react to mute changes when we are not sorting by mute
if (cause === RoomUpdateCause.PossibleMuteChange && !this.isMutedToBottom) {
return false;
}
if (this.sortingAlgorithm === SortAlgorithm.Manual) {
return false; // Nothing to do here.
}
const category = this.getRoomCategory(room);
const roomIdx = this.getRoomIndex(room);
if (roomIdx === -1) {
throw new Error(`Room ${room.roomId} has no index in ${this.tagId}`);
}
// Try to avoid doing array operations if we don't have to: only move rooms within
// the categories if we're jumping categories
const oldCategory = this.getCategoryFromIndices(roomIdx, this.indices);
if (oldCategory !== category) {
// Move the room and update the indices
this.moveRoomIndexes(1, oldCategory, category, this.indices);
this.cachedOrderedRooms.splice(roomIdx, 1); // splice out the old index (fixed position)
this.cachedOrderedRooms.splice(this.getCategoryIndex(category), 0, room); // splice in the new room (pre-adjusted)
// Note: if moveRoomIndexes() is called after the splice then the insert operation
// will happen in the wrong place. Because we would have already adjusted the index
// for the category, we don't need to determine how the room is moving in the list.
// If we instead tried to insert before updating the indices, we'd have to determine
// whether the room was moving later (towards IDLE) or earlier (towards RED) from its
// current position, as it'll affect the category's start index after we remove the
// room from the array.
}
// Sort the category now that we've dumped the room in
this.sortCategory(category);
return true; // change made
}
private sortCategory(category: NotificationLevel): void {
// This should be relatively quick because the room is usually inserted at the top of the
// category, and most popular sorting algorithms will deal with trying to keep the active
// room at the top/start of the category. For the few algorithms that will have to move the
// thing quite far (alphabetic with a Z room for example), the list should already be sorted
// well enough that it can rip through the array and slot the changed room in quickly.
const nextCategoryStartIdx =
category === CATEGORY_ORDER[CATEGORY_ORDER.length - 1]
? Number.MAX_SAFE_INTEGER
: this.getCategoryIndex(CATEGORY_ORDER[CATEGORY_ORDER.indexOf(category) + 1]);
const startIdx = this.getCategoryIndex(category);
const numSort = nextCategoryStartIdx - startIdx; // splice() returns up to the max, so MAX_SAFE_INT is fine
const unsortedSlice = this.cachedOrderedRooms.splice(startIdx, numSort);
const sorted = sortRoomsWithAlgorithm(unsortedSlice, this.tagId, this.sortingAlgorithm);
this.cachedOrderedRooms.splice(startIdx, 0, ...sorted);
}
// noinspection JSMethodCanBeStatic
private getCategoryFromIndices(index: number, indices: CategoryIndex): NotificationLevel {
for (let i = 0; i < CATEGORY_ORDER.length; i++) {
const category = CATEGORY_ORDER[i];
const isLast = i === CATEGORY_ORDER.length - 1;
const startIdx = indices[category];
const endIdx = isLast ? Number.MAX_SAFE_INTEGER : indices[CATEGORY_ORDER[i + 1]];
if (startIdx === undefined || endIdx === undefined) continue;
if (index >= startIdx && index < endIdx) {
return category;
}
}
// "Should never happen" disclaimer goes here
throw new Error("Programming error: somehow you've ended up with an index that isn't in a category");
}
// noinspection JSMethodCanBeStatic
private moveRoomIndexes(
nRooms: number,
fromCategory: NotificationLevel,
toCategory: NotificationLevel,
indices: CategoryIndex,
): void {
// We have to update the index of the category *after* the from/toCategory variables
// in order to update the indices correctly. Because the room is moving from/to those
// categories, the next category's index will change - not the category we're modifying.
// We also need to update subsequent categories as they'll all shift by nRooms, so we
// loop over the order to achieve that.
this.alterCategoryPositionBy(fromCategory, -nRooms, indices);
this.alterCategoryPositionBy(toCategory, +nRooms, indices);
}
private alterCategoryPositionBy(category: NotificationLevel, n: number, indices: CategoryIndex): void {
// Note: when we alter a category's index, we actually have to modify the ones following
// the target and not the target itself.
// XXX: If this ever actually gets more than one room passed to it, it'll need more index
// handling. For instance, if 45 rooms are removed from the middle of a 50 room list, the
// index for the categories will be way off.
const nextOrderIndex = CATEGORY_ORDER.indexOf(category) + 1;
if (n > 0) {
for (let i = nextOrderIndex; i < CATEGORY_ORDER.length; i++) {
const nextCategory = CATEGORY_ORDER[i];
if (indices[nextCategory] === undefined) {
throw new Error(`Index of category ${category} not found`);
}
indices[nextCategory]! += Math.abs(n);
}
} else if (n < 0) {
for (let i = nextOrderIndex; i < CATEGORY_ORDER.length; i++) {
const nextCategory = CATEGORY_ORDER[i];
if (indices[nextCategory] === undefined) {
throw new Error(`Index of category ${category} not found`);
}
indices[nextCategory]! -= Math.abs(n);
}
}
// Do a quick check to see if we've completely broken the index
for (let i = 1; i < CATEGORY_ORDER.length; i++) {
const lastCat = CATEGORY_ORDER[i - 1];
const lastCatIndex = indices[lastCat];
const thisCat = CATEGORY_ORDER[i];
const thisCatIndex = indices[thisCat];
if (lastCatIndex === undefined || thisCatIndex === undefined || lastCatIndex > thisCatIndex) {
// "should never happen" disclaimer goes here
logger.warn(
`!! Room list index corruption: ${lastCat} (i:${indices[lastCat]}) is greater ` +
`than ${thisCat} (i:${indices[thisCat]}) - category indices are likely desynced from reality`,
);
// TODO: Regenerate index when this happens: https://github.com/vector-im/element-web/issues/14234
}
}
}
}

View File

@@ -0,0 +1,203 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Room } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { SortAlgorithm } from "../models";
import { sortRoomsWithAlgorithm } from "../tag-sorting";
import { OrderingAlgorithm } from "./OrderingAlgorithm";
import { RoomUpdateCause, TagID } from "../../models";
import { RoomNotificationStateStore } from "../../../notifications/RoomNotificationStateStore";
type NaturalCategorizedRoomMap = {
defaultRooms: Room[];
mutedRooms: Room[];
};
/**
* Uses the natural tag sorting algorithm order to determine tag ordering. No
* additional behavioural changes are present.
*/
export class NaturalAlgorithm extends OrderingAlgorithm {
private cachedCategorizedOrderedRooms: NaturalCategorizedRoomMap = {
defaultRooms: [],
mutedRooms: [],
};
public constructor(tagId: TagID, initialSortingAlgorithm: SortAlgorithm) {
super(tagId, initialSortingAlgorithm);
}
public setRooms(rooms: Room[]): void {
const { defaultRooms, mutedRooms } = this.categorizeRooms(rooms);
this.cachedCategorizedOrderedRooms = {
defaultRooms: sortRoomsWithAlgorithm(defaultRooms, this.tagId, this.sortingAlgorithm),
mutedRooms: sortRoomsWithAlgorithm(mutedRooms, this.tagId, this.sortingAlgorithm),
};
this.buildCachedOrderedRooms();
}
public handleRoomUpdate(room: Room, cause: RoomUpdateCause): boolean {
const isSplice = cause === RoomUpdateCause.NewRoom || cause === RoomUpdateCause.RoomRemoved;
const isInPlace =
cause === RoomUpdateCause.Timeline ||
cause === RoomUpdateCause.ReadReceipt ||
cause === RoomUpdateCause.PossibleMuteChange;
const isMuted = this.isMutedToBottom && this.getRoomIsMuted(room);
if (!isSplice && !isInPlace) {
throw new Error(`Unsupported update cause: ${cause}`);
}
if (cause === RoomUpdateCause.NewRoom) {
if (isMuted) {
this.cachedCategorizedOrderedRooms.mutedRooms = sortRoomsWithAlgorithm(
[...this.cachedCategorizedOrderedRooms.mutedRooms, room],
this.tagId,
this.sortingAlgorithm,
);
} else {
this.cachedCategorizedOrderedRooms.defaultRooms = sortRoomsWithAlgorithm(
[...this.cachedCategorizedOrderedRooms.defaultRooms, room],
this.tagId,
this.sortingAlgorithm,
);
}
this.buildCachedOrderedRooms();
return true;
} else if (cause === RoomUpdateCause.RoomRemoved) {
return this.removeRoom(room);
} else if (cause === RoomUpdateCause.PossibleMuteChange) {
if (this.isMutedToBottom) {
return this.onPossibleMuteChange(room);
} else {
return false;
}
}
// TODO: Optimize this to avoid useless operations: https://github.com/vector-im/element-web/issues/14457
// For example, we can skip updates to alphabetic (sometimes) and manually ordered tags
if (isMuted) {
this.cachedCategorizedOrderedRooms.mutedRooms = sortRoomsWithAlgorithm(
this.cachedCategorizedOrderedRooms.mutedRooms,
this.tagId,
this.sortingAlgorithm,
);
} else {
this.cachedCategorizedOrderedRooms.defaultRooms = sortRoomsWithAlgorithm(
this.cachedCategorizedOrderedRooms.defaultRooms,
this.tagId,
this.sortingAlgorithm,
);
}
this.buildCachedOrderedRooms();
return true;
}
/**
* Remove a room from the cached room list
* @param room Room to remove
* @returns {boolean} true when room list should update as result
*/
private removeRoom(room: Room): boolean {
const defaultIndex = this.cachedCategorizedOrderedRooms.defaultRooms.findIndex((r) => r.roomId === room.roomId);
if (defaultIndex > -1) {
this.cachedCategorizedOrderedRooms.defaultRooms.splice(defaultIndex, 1);
this.buildCachedOrderedRooms();
return true;
}
const mutedIndex = this.cachedCategorizedOrderedRooms.mutedRooms.findIndex((r) => r.roomId === room.roomId);
if (mutedIndex > -1) {
this.cachedCategorizedOrderedRooms.mutedRooms.splice(mutedIndex, 1);
this.buildCachedOrderedRooms();
return true;
}
logger.warn(`Tried to remove unknown room from ${this.tagId}: ${room.roomId}`);
// room was not in cached lists, no update
return false;
}
/**
* Sets cachedOrderedRooms from cachedCategorizedOrderedRooms
*/
private buildCachedOrderedRooms(): void {
this.cachedOrderedRooms = [
...this.cachedCategorizedOrderedRooms.defaultRooms,
...this.cachedCategorizedOrderedRooms.mutedRooms,
];
}
private getRoomIsMuted(room: Room): boolean {
// It's fine for us to call this a lot because it's cached, and we shouldn't be
// wasting anything by doing so as the store holds single references
const state = RoomNotificationStateStore.instance.getRoomState(room);
return state.muted;
}
private categorizeRooms(rooms: Room[]): NaturalCategorizedRoomMap {
if (!this.isMutedToBottom) {
return { defaultRooms: rooms, mutedRooms: [] };
}
return rooms.reduce<NaturalCategorizedRoomMap>(
(acc, room: Room) => {
if (this.getRoomIsMuted(room)) {
acc.mutedRooms.push(room);
} else {
acc.defaultRooms.push(room);
}
return acc;
},
{ defaultRooms: [], mutedRooms: [] } as NaturalCategorizedRoomMap,
);
}
private onPossibleMuteChange(room: Room): boolean {
const isMuted = this.getRoomIsMuted(room);
if (isMuted) {
const defaultIndex = this.cachedCategorizedOrderedRooms.defaultRooms.findIndex(
(r) => r.roomId === room.roomId,
);
// room has been muted
if (defaultIndex > -1) {
// remove from the default list
this.cachedCategorizedOrderedRooms.defaultRooms.splice(defaultIndex, 1);
// add to muted list and reorder
this.cachedCategorizedOrderedRooms.mutedRooms = sortRoomsWithAlgorithm(
[...this.cachedCategorizedOrderedRooms.mutedRooms, room],
this.tagId,
this.sortingAlgorithm,
);
// rebuild
this.buildCachedOrderedRooms();
return true;
}
} else {
const mutedIndex = this.cachedCategorizedOrderedRooms.mutedRooms.findIndex((r) => r.roomId === room.roomId);
// room has been unmuted
if (mutedIndex > -1) {
// remove from the muted list
this.cachedCategorizedOrderedRooms.mutedRooms.splice(mutedIndex, 1);
// add to default list and reorder
this.cachedCategorizedOrderedRooms.defaultRooms = sortRoomsWithAlgorithm(
[...this.cachedCategorizedOrderedRooms.defaultRooms, room],
this.tagId,
this.sortingAlgorithm,
);
// rebuild
this.buildCachedOrderedRooms();
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,83 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Room } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { RoomUpdateCause, TagID } from "../../models";
import { SortAlgorithm } from "../models";
/**
* Represents a list ordering algorithm. Subclasses should populate the
* `cachedOrderedRooms` field.
*/
export abstract class OrderingAlgorithm {
protected cachedOrderedRooms: Room[] = [];
// set by setSortAlgorithm() in ctor
protected sortingAlgorithm!: SortAlgorithm;
protected constructor(
protected tagId: TagID,
initialSortingAlgorithm: SortAlgorithm,
) {
// noinspection JSIgnoredPromiseFromCall
this.setSortAlgorithm(initialSortingAlgorithm); // we use the setter for validation
}
/**
* The rooms as ordered by the algorithm.
*/
public get orderedRooms(): Room[] {
return this.cachedOrderedRooms;
}
public get isMutedToBottom(): boolean {
return this.sortingAlgorithm === SortAlgorithm.Recent;
}
/**
* Sets the sorting algorithm to use within the list.
* @param newAlgorithm The new algorithm. Must be defined.
* @returns Resolves when complete.
*/
public setSortAlgorithm(newAlgorithm: SortAlgorithm): void {
if (!newAlgorithm) throw new Error("A sorting algorithm must be defined");
this.sortingAlgorithm = newAlgorithm;
// Force regeneration of the rooms
this.setRooms(this.orderedRooms);
}
/**
* Sets the rooms the algorithm should be handling, implying a reconstruction
* of the ordering.
* @param rooms The rooms to use going forward.
*/
public abstract setRooms(rooms: Room[]): void;
/**
* Handle a room update. The Algorithm will only call this for causes which
* the list ordering algorithm can handle within the same tag. For example,
* tag changes will not be sent here.
* @param room The room where the update happened.
* @param cause The cause of the update.
* @returns True if the update requires the Algorithm to update the presentation layers.
*/
public abstract handleRoomUpdate(room: Room, cause: RoomUpdateCause): boolean;
protected getRoomIndex(room: Room): number {
let roomIdx = this.cachedOrderedRooms.indexOf(room);
if (roomIdx === -1) {
// can only happen if the js-sdk's store goes sideways.
logger.warn(`Degrading performance to find missing room in "${this.tagId}": ${room.roomId}`);
roomIdx = this.cachedOrderedRooms.findIndex((r) => r.roomId === room.roomId);
}
return roomIdx;
}
}

View File

@@ -0,0 +1,41 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { ImportanceAlgorithm } from "./ImportanceAlgorithm";
import { ListAlgorithm, SortAlgorithm } from "../models";
import { NaturalAlgorithm } from "./NaturalAlgorithm";
import { TagID } from "../../models";
import { OrderingAlgorithm } from "./OrderingAlgorithm";
interface AlgorithmFactory {
(tagId: TagID, initialSortingAlgorithm: SortAlgorithm): OrderingAlgorithm;
}
const ALGORITHM_FACTORIES: { [algorithm in ListAlgorithm]: AlgorithmFactory } = {
[ListAlgorithm.Natural]: (tagId, initSort) => new NaturalAlgorithm(tagId, initSort),
[ListAlgorithm.Importance]: (tagId, initSort) => new ImportanceAlgorithm(tagId, initSort),
};
/**
* Gets an instance of the defined algorithm
* @param {ListAlgorithm} algorithm The algorithm to get an instance of.
* @param {TagID} tagId The tag the algorithm is for.
* @param {SortAlgorithm} initSort The initial sorting algorithm for the ordering algorithm.
* @returns {Algorithm} The algorithm instance.
*/
export function getListAlgorithmInstance(
algorithm: ListAlgorithm,
tagId: TagID,
initSort: SortAlgorithm,
): OrderingAlgorithm {
if (!ALGORITHM_FACTORIES[algorithm]) {
throw new Error(`${algorithm} is not a known algorithm`);
}
return ALGORITHM_FACTORIES[algorithm](tagId, initSort);
}

View File

@@ -0,0 +1,46 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Room } from "matrix-js-sdk/src/matrix";
import { TagID } from "../models";
import { OrderingAlgorithm } from "./list-ordering/OrderingAlgorithm";
export enum SortAlgorithm {
Manual = "MANUAL",
Alphabetic = "ALPHABETIC",
Recent = "RECENT",
}
export enum ListAlgorithm {
// Orders Red > Grey > Bold > Idle
Importance = "IMPORTANCE",
// Orders however the SortAlgorithm decides
Natural = "NATURAL",
}
export interface ITagSortingMap {
// @ts-ignore - TypeScript really wants this to be [tagId: string] but we know better.
[tagId: TagID]: SortAlgorithm;
}
export interface IListOrderingMap {
// @ts-ignore - TypeScript really wants this to be [tagId: string] but we know better.
[tagId: TagID]: ListAlgorithm;
}
export interface IOrderingAlgorithmMap {
// @ts-ignore - TypeScript really wants this to be [tagId: string] but we know better.
[tagId: TagID]: OrderingAlgorithm;
}
export interface ITagMap {
// @ts-ignore - TypeScript really wants this to be [tagId: string] but we know better.
[tagId: TagID]: Room[];
}

View File

@@ -0,0 +1,24 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Room } from "matrix-js-sdk/src/matrix";
import { TagID } from "../../models";
import { IAlgorithm } from "./IAlgorithm";
/**
* Sorts rooms according to the browser's determination of alphabetic.
*/
export class AlphabeticAlgorithm implements IAlgorithm {
public sortRooms(rooms: Room[], tagId: TagID): Room[] {
const collator = new Intl.Collator();
return rooms.sort((a, b) => {
return collator.compare(a.name, b.name);
});
}
}

View File

@@ -0,0 +1,24 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Room } from "matrix-js-sdk/src/matrix";
import { TagID } from "../../models";
/**
* Represents a tag sorting algorithm.
*/
export interface IAlgorithm {
/**
* Sorts the given rooms according to the sorting rules of the algorithm.
* @param {Room[]} rooms The rooms to sort.
* @param {TagID} tagId The tag ID in which the rooms are being sorted.
* @returns {Room[]} Returns the sorted rooms.
*/
sortRooms(rooms: Room[], tagId: TagID): Room[];
}

View File

@@ -0,0 +1,24 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Room } from "matrix-js-sdk/src/matrix";
import { TagID } from "../../models";
import { IAlgorithm } from "./IAlgorithm";
/**
* Sorts rooms according to the tag's `order` property on the room.
*/
export class ManualAlgorithm implements IAlgorithm {
public sortRooms(rooms: Room[], tagId: TagID): Room[] {
const getOrderProp = (r: Room): number => r.tags[tagId].order || 0;
return rooms.sort((a, b) => {
return getOrderProp(a) - getOrderProp(b);
});
}
}

View File

@@ -0,0 +1,122 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Room, MatrixEvent, EventType } from "matrix-js-sdk/src/matrix";
import { TagID } from "../../models";
import { IAlgorithm } from "./IAlgorithm";
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
import * as Unread from "../../../../Unread";
import { EffectiveMembership, getEffectiveMembership } from "../../../../utils/membership";
export function shouldCauseReorder(event: MatrixEvent): boolean {
const type = event.getType();
const content = event.getContent();
const prevContent = event.getPrevContent();
// Never ignore membership changes
if (type === EventType.RoomMember && prevContent.membership !== content.membership) return true;
// Ignore display name changes
if (type === EventType.RoomMember && prevContent.displayname !== content.displayname) return false;
// Ignore avatar changes
if (type === EventType.RoomMember && prevContent.avatar_url !== content.avatar_url) return false;
return true;
}
export const sortRooms = (rooms: Room[]): Room[] => {
// We cache the timestamp lookup to avoid iterating forever on the timeline
// of events. This cache only survives a single sort though.
// We wouldn't need this if `.sort()` didn't constantly try and compare all
// of the rooms to each other.
// TODO: We could probably improve the sorting algorithm here by finding changes.
// See https://github.com/vector-im/element-web/issues/14459
// For example, if we spent a little bit of time to determine which elements have
// actually changed (probably needs to be done higher up?) then we could do an
// insertion sort or similar on the limited set of changes.
// TODO: Don't assume we're using the same client as the peg
// See https://github.com/vector-im/element-web/issues/14458
let myUserId = "";
if (MatrixClientPeg.get()) {
myUserId = MatrixClientPeg.get()!.getSafeUserId();
}
const tsCache: { [roomId: string]: number } = {};
return rooms.sort((a, b) => {
const roomALastTs = tsCache[a.roomId] ?? getLastTs(a, myUserId);
const roomBLastTs = tsCache[b.roomId] ?? getLastTs(b, myUserId);
tsCache[a.roomId] = roomALastTs;
tsCache[b.roomId] = roomBLastTs;
return roomBLastTs - roomALastTs;
});
};
const getLastTs = (r: Room, userId: string): number => {
const mainTimelineLastTs = ((): number => {
// Apparently we can have rooms without timelines, at least under testing
// environments. Just return MAX_INT when this happens.
if (!r?.timeline) {
return Number.MAX_SAFE_INTEGER;
}
// If the room hasn't been joined yet, it probably won't have a timeline to
// parse. We'll still fall back to the timeline if this fails, but chances
// are we'll at least have our own membership event to go off of.
const effectiveMembership = getEffectiveMembership(r.getMyMembership());
if (effectiveMembership !== EffectiveMembership.Join) {
const membershipEvent = r.currentState.getStateEvents(EventType.RoomMember, userId);
if (membershipEvent && !Array.isArray(membershipEvent)) {
return membershipEvent.getTs();
}
}
for (let i = r.timeline.length - 1; i >= 0; --i) {
const ev = r.timeline[i];
if (!ev.getTs()) continue; // skip events that don't have timestamps (tests only?)
if (
(ev.getSender() === userId && shouldCauseReorder(ev)) ||
Unread.eventTriggersUnreadCount(r.client, ev)
) {
return ev.getTs();
}
}
// we might only have events that don't trigger the unread indicator,
// in which case use the oldest event even if normally it wouldn't count.
// This is better than just assuming the last event was forever ago.
return r.timeline[0]?.getTs() ?? Number.MAX_SAFE_INTEGER;
})();
const threadLastEventTimestamps = r.getThreads().map((thread) => {
const event = thread.replyToEvent ?? thread.rootEvent;
return event?.getTs() ?? 0;
});
return Math.max(mainTimelineLastTs, ...threadLastEventTimestamps);
};
/**
* Sorts rooms according to the last event's timestamp in each room that seems
* useful to the user.
*/
export class RecentAlgorithm implements IAlgorithm {
public sortRooms(rooms: Room[], tagId: TagID): Room[] {
return sortRooms(rooms);
}
public getLastTs(room: Room, userId: string): number {
return getLastTs(room, userId);
}
}

View File

@@ -0,0 +1,46 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Room } from "matrix-js-sdk/src/matrix";
import { SortAlgorithm } from "../models";
import { ManualAlgorithm } from "./ManualAlgorithm";
import { IAlgorithm } from "./IAlgorithm";
import { TagID } from "../../models";
import { RecentAlgorithm } from "./RecentAlgorithm";
import { AlphabeticAlgorithm } from "./AlphabeticAlgorithm";
const ALGORITHM_INSTANCES: { [algorithm in SortAlgorithm]: IAlgorithm } = {
[SortAlgorithm.Recent]: new RecentAlgorithm(),
[SortAlgorithm.Alphabetic]: new AlphabeticAlgorithm(),
[SortAlgorithm.Manual]: new ManualAlgorithm(),
};
/**
* Gets an instance of the defined algorithm
* @param {SortAlgorithm} algorithm The algorithm to get an instance of.
* @returns {IAlgorithm} The algorithm instance.
*/
export function getSortingAlgorithmInstance(algorithm: SortAlgorithm): IAlgorithm {
if (!ALGORITHM_INSTANCES[algorithm]) {
throw new Error(`${algorithm} is not a known algorithm`);
}
return ALGORITHM_INSTANCES[algorithm];
}
/**
* Sorts rooms in a given tag according to the algorithm given.
* @param {Room[]} rooms The rooms to sort.
* @param {TagID} tagId The tag in which the sorting is occurring.
* @param {SortAlgorithm} algorithm The algorithm to use for sorting.
* @returns {Room[]} Returns the sorted rooms.
*/
export function sortRoomsWithAlgorithm(rooms: Room[], tagId: TagID, algorithm: SortAlgorithm): Room[] {
return getSortingAlgorithmInstance(algorithm).sortRooms(rooms, tagId);
}

View File

@@ -0,0 +1,34 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Room } from "matrix-js-sdk/src/matrix";
import { EventEmitter } from "events";
export const FILTER_CHANGED = "filter_changed";
/**
* A filter condition for the room list, determining if a room
* should be shown or not.
*
* All filter conditions are expected to be stable executions,
* meaning that given the same input the same answer will be
* returned (thus allowing caching). As such, filter conditions
* can, but shouldn't, do heavier logic and not worry about being
* called constantly by the room list. When the condition changes
* such that different inputs lead to different answers (such
* as a change in the user's input), this emits FILTER_CHANGED.
*/
export interface IFilterCondition extends EventEmitter {
/**
* Determines if a given room should be visible under this
* condition.
* @param room The room to check.
* @returns True if the room should be visible.
*/
isVisible(room: Room): boolean;
}

View File

@@ -0,0 +1,72 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { EventEmitter } from "events";
import { Room } from "matrix-js-sdk/src/matrix";
import { FILTER_CHANGED, IFilterCondition } from "./IFilterCondition";
import { IDestroyable } from "../../../utils/IDestroyable";
import SpaceStore from "../../spaces/SpaceStore";
import { isMetaSpace, 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
* 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<string>();
private userIds = new Set<string>();
private showPeopleInSpace = true;
private space: SpaceKey = MetaSpace.Home;
public isVisible(room: Room): boolean {
return SpaceStore.instance.isRoomInSpace(this.space, room.roomId);
}
private onStoreUpdate = async (forceUpdate = false): Promise<void> => {
const beforeRoomIds = this.roomIds;
// clone the set as it may be mutated by the space store internally
this.roomIds = new Set(SpaceStore.instance.getSpaceFilteredRoomIds(this.space));
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 =
isMetaSpace(this.space[0]) || SettingsStore.getValue("Spaces.showPeopleInSpace", this.space);
if (
forceUpdate ||
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
setTimeout(() => {
this.emit(FILTER_CHANGED);
});
}
};
public updateSpace(space: SpaceKey): void {
SpaceStore.instance.off(this.space, this.onStoreUpdate);
SpaceStore.instance.on((this.space = space), this.onStoreUpdate);
this.onStoreUpdate(true); // initial update from the change to the space
}
public destroy(): void {
SpaceStore.instance.off(this.space, this.onStoreUpdate);
}
}

View File

@@ -0,0 +1,61 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
* Please see LICENSE files in the repository root for full details.
*/
import { Room } from "matrix-js-sdk/src/matrix";
import LegacyCallHandler from "../../../LegacyCallHandler";
import { RoomListCustomisations } from "../../../customisations/RoomList";
import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom";
import VoipUserMapper from "../../../VoipUserMapper";
export class VisibilityProvider {
private static internalInstance: VisibilityProvider;
private constructor() {}
public static get instance(): VisibilityProvider {
if (!VisibilityProvider.internalInstance) {
VisibilityProvider.internalInstance = new VisibilityProvider();
}
return VisibilityProvider.internalInstance;
}
public async onNewInvitedRoom(room: Room): Promise<void> {
await VoipUserMapper.sharedInstance().onNewInvitedRoom(room);
}
public isRoomVisible(room?: Room): boolean {
if (!room) {
return false;
}
if (
LegacyCallHandler.instance.getSupportsVirtualRooms() &&
VoipUserMapper.sharedInstance().isVirtualRoom(room)
) {
return false;
}
// hide space rooms as they'll be shown in the SpacePanel
if (room.isSpaceRoom()) {
return false;
}
if (isLocalRoom(room)) {
// local rooms shouldn't show up anywhere
return false;
}
const isVisibleFn = RoomListCustomisations.isRoomVisible;
if (isVisibleFn) {
return isVisibleFn(room);
}
return true; // default
}
}

View File

@@ -0,0 +1,42 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
export enum DefaultTagID {
Invite = "im.vector.fake.invite",
Untagged = "im.vector.fake.recent", // legacy: used to just be 'recent rooms' but now it's all untagged rooms
Archived = "im.vector.fake.archived",
LowPriority = "m.lowpriority",
Favourite = "m.favourite",
DM = "im.vector.fake.direct",
Conference = "im.vector.fake.conferences",
ServerNotice = "m.server_notice",
Suggested = "im.vector.fake.suggested",
}
export const OrderedDefaultTagIDs = [
DefaultTagID.Invite,
DefaultTagID.Favourite,
DefaultTagID.DM,
DefaultTagID.Conference,
DefaultTagID.Untagged,
DefaultTagID.LowPriority,
DefaultTagID.ServerNotice,
DefaultTagID.Suggested,
DefaultTagID.Archived,
];
export type TagID = string | DefaultTagID;
export enum RoomUpdateCause {
Timeline = "TIMELINE",
PossibleTagChange = "POSSIBLE_TAG_CHANGE",
PossibleMuteChange = "POSSIBLE_MUTE_CHANGE",
ReadReceipt = "READ_RECEIPT",
NewRoom = "NEW_ROOM",
RoomRemoved = "ROOM_REMOVED",
}

View File

@@ -0,0 +1,25 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import { TagID } from "../models";
/**
* Represents an event preview.
*/
export interface IPreview {
/**
* Gets the text which represents the event as a preview.
* @param event The event to preview.
* @param tagId Optional. The tag where the room the event was sent in resides.
* @param isThread Optional. Whether the preview being generated is for a thread summary.
* @returns The preview.
*/
getTextFor(event: MatrixEvent, tagId?: TagID, isThread?: boolean): string | null;
}

View File

@@ -0,0 +1,28 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import { IPreview } from "./IPreview";
import { TagID } from "../models";
import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
import { _t } from "../../../languageHandler";
export class LegacyCallAnswerEventPreview implements IPreview {
public getTextFor(event: MatrixEvent, tagId?: TagID): string {
if (shouldPrefixMessagesIn(event.getRoomId()!, tagId)) {
if (isSelf(event)) {
return _t("event_preview|m.call.answer|you");
} else {
return _t("event_preview|m.call.answer|user", { senderName: getSenderName(event) });
}
} else {
return _t("event_preview|m.call.answer|dm");
}
}
}

View File

@@ -0,0 +1,28 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import { IPreview } from "./IPreview";
import { TagID } from "../models";
import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
import { _t } from "../../../languageHandler";
export class LegacyCallHangupEvent implements IPreview {
public getTextFor(event: MatrixEvent, tagId?: TagID): string {
if (shouldPrefixMessagesIn(event.getRoomId()!, tagId)) {
if (isSelf(event)) {
return _t("event_preview|m.call.hangup|you");
} else {
return _t("event_preview|m.call.hangup|user", { senderName: getSenderName(event) });
}
} else {
return _t("timeline|m.call.hangup|dm");
}
}
}

View File

@@ -0,0 +1,32 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import { IPreview } from "./IPreview";
import { TagID } from "../models";
import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
import { _t } from "../../../languageHandler";
export class LegacyCallInviteEventPreview implements IPreview {
public getTextFor(event: MatrixEvent, tagId?: TagID): string {
if (shouldPrefixMessagesIn(event.getRoomId()!, tagId)) {
if (isSelf(event)) {
return _t("event_preview|m.call.invite|you");
} else {
return _t("event_preview|m.call.invite|user", { senderName: getSenderName(event) });
}
} else {
if (isSelf(event)) {
return _t("event_preview|m.call.invite|dm_send");
} else {
return _t("event_preview|m.call.invite|dm_receive", { senderName: getSenderName(event) });
}
}
}
}

View File

@@ -0,0 +1,74 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MatrixEvent, MsgType, RelationType } from "matrix-js-sdk/src/matrix";
import { IPreview } from "./IPreview";
import { TagID } from "../models";
import { _t, sanitizeForTranslation } from "../../../languageHandler";
import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
import { getHtmlText } from "../../../HtmlUtils";
import { stripHTMLReply, stripPlainReply } from "../../../utils/Reply";
import { VoiceBroadcastChunkEventType } from "../../../voice-broadcast/types";
export class MessageEventPreview implements IPreview {
public getTextFor(event: MatrixEvent, tagId?: TagID, isThread?: boolean): string | null {
let eventContent = event.getContent();
// no preview for broadcast chunks
if (eventContent[VoiceBroadcastChunkEventType]) return null;
if (event.isRelation(RelationType.Replace)) {
// It's an edit, generate the preview on the new text
eventContent = event.getContent()["m.new_content"];
}
if (!eventContent?.["body"]) return null; // invalid for our purposes
let body = eventContent["body"].trim();
if (!body) return null; // invalid event, no preview
// A msgtype is actually required in the spec but the app is a bit softer on this requirement
const msgtype = eventContent["msgtype"] ?? MsgType.Text;
const hasHtml = eventContent.format === "org.matrix.custom.html" && eventContent.formatted_body;
if (hasHtml) {
body = eventContent.formatted_body;
}
// XXX: Newer relations have a getRelation() function which is not compatible with replies.
if (event.getWireContent()["m.relates_to"]?.["m.in_reply_to"]) {
// If this is a reply, get the real reply and use that
if (hasHtml) {
body = (stripHTMLReply(body) || "").trim();
} else {
body = (stripPlainReply(body) || "").trim();
}
if (!body) return null; // invalid event, no preview
}
if (hasHtml) {
const sanitised = getHtmlText(body.replace(/<br\/?>/gi, "\n")); // replace line breaks before removing them
// run it through DOMParser to fixup encoded html entities
body = new DOMParser().parseFromString(sanitised, "text/html").documentElement.textContent;
}
body = sanitizeForTranslation(body);
if (msgtype === MsgType.Emote) {
return _t("event_preview|m.emote", { senderName: getSenderName(event), emote: body });
}
const roomId = event.getRoomId();
if (isThread || isSelf(event) || (roomId && !shouldPrefixMessagesIn(roomId, tagId))) {
return body;
} else {
return _t("event_preview|m.text", { senderName: getSenderName(event), message: body });
}
}
}

View File

@@ -0,0 +1,57 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MatrixEvent, PollStartEventContent } from "matrix-js-sdk/src/matrix";
import { InvalidEventError } from "matrix-js-sdk/src/extensible_events_v1/InvalidEventError";
import { PollStartEvent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent";
import { IPreview } from "./IPreview";
import { TagID } from "../models";
import { _t, sanitizeForTranslation } from "../../../languageHandler";
import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
export class PollStartEventPreview implements IPreview {
public static contextType = MatrixClientContext;
public declare context: React.ContextType<typeof MatrixClientContext>;
public getTextFor(event: MatrixEvent, tagId?: TagID, isThread?: boolean): string | null {
let eventContent = event.getContent();
if (event.isRelation("m.replace")) {
// It's an edit, generate the preview on the new text
eventContent = event.getContent()["m.new_content"];
}
// Check we have the information we need, and bail out if not
if (!eventContent) {
return null;
}
try {
const poll = new PollStartEvent({
type: event.getType(),
content: eventContent as PollStartEventContent,
});
let question = poll.question.text.trim();
question = sanitizeForTranslation(question);
if (isThread || isSelf(event) || !shouldPrefixMessagesIn(event.getRoomId()!, tagId)) {
return question;
} else {
return _t("event_preview|m.text", { senderName: getSenderName(event), message: question });
}
} catch (e) {
if (e instanceof InvalidEventError) {
return null;
}
throw e; // re-throw unknown errors
}
}
}

View File

@@ -0,0 +1,48 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import { IPreview } from "./IPreview";
import { TagID } from "../models";
import { getSenderName, isSelf } from "./utils";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { MessagePreviewStore } from "../MessagePreviewStore";
export class ReactionEventPreview implements IPreview {
public getTextFor(event: MatrixEvent, tagId?: TagID, isThread?: boolean): string | null {
const roomId = event.getRoomId();
if (!roomId) return null; // not a room event
const relation = event.getRelation();
if (!relation) return null; // invalid reaction (probably redacted)
const reaction = relation.key;
if (!reaction) return null; // invalid reaction (unknown format)
const cli = MatrixClientPeg.get();
const room = cli?.getRoom(roomId);
const relatedEvent = relation.event_id ? room?.findEventById(relation.event_id) : null;
if (!relatedEvent) return null;
const message = MessagePreviewStore.instance.generatePreviewForEvent(relatedEvent);
if (isSelf(event)) {
return _t("event_preview|m.reaction|you", {
reaction,
message,
});
}
return _t("event_preview|m.reaction|user", {
sender: getSenderName(event),
reaction,
message,
});
}
}

View File

@@ -0,0 +1,27 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import { IPreview } from "./IPreview";
import { TagID } from "../models";
import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
import { _t } from "../../../languageHandler";
export class StickerEventPreview implements IPreview {
public getTextFor(event: MatrixEvent, tagId?: TagID, isThread?: boolean): string | null {
const stickerName = event.getContent()["body"];
if (!stickerName) return null;
if (isThread || isSelf(event) || !shouldPrefixMessagesIn(event.getRoomId()!, tagId)) {
return stickerName;
} else {
return _t("event_preview|m.sticker", { senderName: getSenderName(event), stickerName });
}
}
}

View File

@@ -0,0 +1,23 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import { VoiceBroadcastInfoState } from "../../../voice-broadcast/types";
import { textForVoiceBroadcastStoppedEventWithoutLink } from "../../../voice-broadcast/utils/textForVoiceBroadcastStoppedEventWithoutLink";
import { IPreview } from "./IPreview";
export class VoiceBroadcastPreview implements IPreview {
public getTextFor(event: MatrixEvent, tagId?: string, isThread?: boolean): string | null {
if (!event.isRedacted() && event.getContent()?.state === VoiceBroadcastInfoState.Stopped) {
return textForVoiceBroadcastStoppedEventWithoutLink(event);
}
return null;
}
}

View File

@@ -0,0 +1,33 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { DefaultTagID, TagID } from "../models";
export function isSelf(event: MatrixEvent): boolean {
const selfUserId = MatrixClientPeg.safeGet().getSafeUserId();
if (event.getType() === "m.room.member") {
return event.getStateKey() === selfUserId;
}
return event.getSender() === selfUserId;
}
export function shouldPrefixMessagesIn(roomId: string, tagId?: TagID): boolean {
if (tagId !== DefaultTagID.DM) return true;
// We don't prefix anything in 1:1s
const room = MatrixClientPeg.safeGet().getRoom(roomId);
if (!room) return true;
return room.currentState.getJoinedMemberCount() !== 2;
}
export function getSenderName(event: MatrixEvent): string {
return event.sender?.name ?? event.getSender() ?? "";
}

View File

@@ -0,0 +1,46 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MatrixEvent, EventType, IPushRules } from "matrix-js-sdk/src/matrix";
import { ActionPayload } from "../../../dispatcher/payloads";
import { isRuleMaybeRoomMuteRule } from "../../../RoomNotifs";
import { arrayDiff } from "../../../utils/arrays";
/**
* Gets any changed push rules that are room specific overrides
* that mute notifications
* @param actionPayload
* @returns {string[]} ruleIds of added or removed rules
*/
export const getChangedOverrideRoomMutePushRules = (actionPayload: ActionPayload): string[] | undefined => {
if (
actionPayload.action !== "MatrixActions.accountData" ||
actionPayload.event?.getType() !== EventType.PushRules
) {
return undefined;
}
const event = actionPayload.event as MatrixEvent;
const prevEvent = actionPayload.previousEvent as MatrixEvent | undefined;
if (!event || !prevEvent) {
return undefined;
}
const roomPushRules = (event.getContent() as IPushRules)?.global?.override?.filter(isRuleMaybeRoomMuteRule);
const prevRoomPushRules = (prevEvent?.getContent() as IPushRules)?.global?.override?.filter(
isRuleMaybeRoomMuteRule,
);
const { added, removed } = arrayDiff(
prevRoomPushRules?.map((rule) => rule.rule_id) || [],
roomPushRules?.map((rule) => rule.rule_id) || [],
);
return [...added, ...removed];
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
const getSpaceCollapsedKey = (roomId: string, parents?: Set<string>): string => {
const separator = "/";
let path = "";
if (parents) {
for (const entry of parents.entries()) {
path += entry + separator;
}
}
return `mx_space_collapsed_${path + roomId}`;
};
export default class SpaceTreeLevelLayoutStore {
private static internalInstance: SpaceTreeLevelLayoutStore;
public static get instance(): SpaceTreeLevelLayoutStore {
if (!SpaceTreeLevelLayoutStore.internalInstance) {
SpaceTreeLevelLayoutStore.internalInstance = new SpaceTreeLevelLayoutStore();
}
return SpaceTreeLevelLayoutStore.internalInstance;
}
public setSpaceCollapsedState(roomId: string, parents: Set<string> | undefined, collapsed: boolean): void {
// XXX: localStorage doesn't allow booleans
localStorage.setItem(getSpaceCollapsedKey(roomId, parents), collapsed.toString());
}
public getSpaceCollapsedState(roomId: string, parents: Set<string> | undefined, fallback: boolean): boolean {
const collapsedLocalStorage = localStorage.getItem(getSpaceCollapsedKey(roomId, parents));
// XXX: localStorage doesn't allow booleans
return collapsedLocalStorage ? collapsedLocalStorage === "true" : fallback;
}
}

View File

@@ -0,0 +1,67 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { SpaceKey } from ".";
export type SpaceEntityMap = Map<SpaceKey, Set<string>>;
export type SpaceDescendantMap = Map<SpaceKey, Set<SpaceKey>>;
const traverseSpaceDescendants = (
spaceDescendantMap: SpaceDescendantMap,
spaceId: SpaceKey,
flatSpace = new Set<SpaceKey>(),
): Set<SpaceKey> => {
flatSpace.add(spaceId);
const descendentSpaces = spaceDescendantMap.get(spaceId);
descendentSpaces?.forEach((descendantSpaceId) => {
if (!flatSpace.has(descendantSpaceId)) {
traverseSpaceDescendants(spaceDescendantMap, descendantSpaceId, flatSpace);
}
});
return flatSpace;
};
/**
* Helper function to traverse space hierarchy and flatten
* @param spaceEntityMap ie map of rooms or dm userIds
* @param spaceDescendantMap map of spaces and their children
* @returns set of all rooms
*/
export const flattenSpaceHierarchy = (
spaceEntityMap: SpaceEntityMap,
spaceDescendantMap: SpaceDescendantMap,
spaceId: SpaceKey,
): Set<string> => {
const flattenedSpaceIds = traverseSpaceDescendants(spaceDescendantMap, spaceId);
const flattenedRooms = new Set<string>();
flattenedSpaceIds.forEach((id) => {
const roomIds = spaceEntityMap.get(id);
roomIds?.forEach(flattenedRooms.add, flattenedRooms);
});
return flattenedRooms;
};
export const flattenSpaceHierarchyWithCache =
(cache: SpaceEntityMap) =>
(
spaceEntityMap: SpaceEntityMap,
spaceDescendantMap: SpaceDescendantMap,
spaceId: SpaceKey,
useCache = true,
): Set<string> => {
if (useCache && cache.has(spaceId)) {
return cache.get(spaceId)!;
}
const result = flattenSpaceHierarchy(spaceEntityMap, spaceDescendantMap, spaceId);
cache.set(spaceId, result);
return result;
};

View File

@@ -0,0 +1,59 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Room, HierarchyRoom } from "matrix-js-sdk/src/matrix";
import { _t } from "../../languageHandler";
// The consts & types are moved out here to prevent cyclical imports
export const UPDATE_TOP_LEVEL_SPACES = Symbol("top-level-spaces");
export const UPDATE_INVITED_SPACES = Symbol("invited-spaces");
export const UPDATE_SELECTED_SPACE = Symbol("selected-space");
export const UPDATE_HOME_BEHAVIOUR = Symbol("home-behaviour");
export const UPDATE_SUGGESTED_ROOMS = Symbol("suggested-rooms");
// Space Key will be emitted when a Space's children change
export enum MetaSpace {
Home = "home-space",
Favourites = "favourites-space",
People = "people-space",
Orphans = "orphans-space",
VideoRooms = "video-rooms-space",
}
export const getMetaSpaceName = (spaceKey: MetaSpace, allRoomsInHome = false): string => {
switch (spaceKey) {
case MetaSpace.Home:
return allRoomsInHome ? _t("common|all_rooms") : _t("common|home");
case MetaSpace.Favourites:
return _t("common|favourites");
case MetaSpace.People:
return _t("common|people");
case MetaSpace.Orphans:
return _t("common|orphan_rooms");
case MetaSpace.VideoRooms:
return _t("voip|metaspace_video_rooms|conference_room_section");
}
};
export type SpaceKey = MetaSpace | Room["roomId"];
export interface ISuggestedRoom extends HierarchyRoom {
viaServers: string[];
}
export function isMetaSpace(spaceKey?: SpaceKey): boolean {
return (
spaceKey === MetaSpace.Home ||
spaceKey === MetaSpace.Favourites ||
spaceKey === MetaSpace.People ||
spaceKey === MetaSpace.Orphans ||
spaceKey === MetaSpace.VideoRooms
);
}

View File

@@ -0,0 +1,60 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 2020-2022 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
* Please see LICENSE files in the repository root for full details.
*/
import { IWidgetApiRequest } from "matrix-widget-api";
export enum ElementWidgetActions {
// All of these actions are currently specific to Jitsi and Element Call
JoinCall = "io.element.join",
HangupCall = "im.vector.hangup",
CallParticipants = "io.element.participants",
StartLiveStream = "im.vector.start_live_stream",
// Actions for switching layouts
TileLayout = "io.element.tile_layout",
SpotlightLayout = "io.element.spotlight_layout",
OpenIntegrationManager = "integration_manager_open",
/**
* @deprecated Use MSC2931 instead
*/
ViewRoom = "io.element.view_room",
// This action type is used as a `fromWidget` and a `toWidget` action.
// fromWidget: updates the client about the current device mute state
// toWidget: the client requests a specific device mute configuration
// The reply will always be the resulting configuration
// It is possible to sent an empty configuration to retrieve the current values or
// just one of the fields to update that particular value
// An undefined field means that EC will keep the mute state as is.
// -> this will allow the client to only get the current state
//
// The data of the widget action request and the response are:
// {
// audio_enabled?: boolean,
// video_enabled?: boolean
// }
// NOTE: this is currently unused. Its only here to make EW aware
// of this action so it does not throw errors.
DeviceMute = "io.element.device_mute",
}
export interface IHangupCallApiRequest extends IWidgetApiRequest {
data: {
errorMessage?: string;
};
}
/**
* @deprecated Use MSC2931 instead
*/
export interface IViewRoomApiRequest extends IWidgetApiRequest {
data: {
room_id: string; // eslint-disable-line camelcase
};
}

View File

@@ -0,0 +1,19 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
* Please see LICENSE files in the repository root for full details.
*/
export enum ElementWidgetCapabilities {
/**
* @deprecated Use MSC2931 instead.
*/
CanChangeViewedRoom = "io.element.view_room",
/**
* Ask Element to not give the option to move the widget into a separate tab.
* This replaces RequiresClient in MatrixCapabilities.
*/
RequiresClient = "io.element.requires_client",
}

View File

@@ -0,0 +1,551 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 2020-2022 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
* Please see LICENSE files in the repository root for full details.
*/
import { Room, MatrixEvent, MatrixEventEvent, MatrixClient, ClientEvent } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import {
ClientWidgetApi,
IModalWidgetOpenRequest,
IRoomEvent,
IStickerActionRequest,
IStickyActionRequest,
ITemplateParams,
IWidget,
IWidgetApiErrorResponseData,
IWidgetApiRequest,
IWidgetApiRequestEmptyData,
IWidgetData,
MatrixCapabilities,
runTemplate,
Widget,
WidgetApiFromWidgetAction,
WidgetKind,
} from "matrix-widget-api";
import { Optional } from "matrix-events-sdk";
import { EventEmitter } from "events";
import { logger } from "matrix-js-sdk/src/logger";
import { _t, getUserLanguage } from "../../languageHandler";
import { StopGapWidgetDriver } from "./StopGapWidgetDriver";
import { WidgetMessagingStore } from "./WidgetMessagingStore";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import { OwnProfileStore } from "../OwnProfileStore";
import WidgetUtils from "../../utils/WidgetUtils";
import { IntegrationManagers } from "../../integrations/IntegrationManagers";
import SettingsStore from "../../settings/SettingsStore";
import { WidgetType } from "../../widgets/WidgetType";
import ActiveWidgetStore from "../ActiveWidgetStore";
import { objectShallowClone } from "../../utils/objects";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { Action } from "../../dispatcher/actions";
import { ElementWidgetActions, IHangupCallApiRequest, IViewRoomApiRequest } from "./ElementWidgetActions";
import { ModalWidgetStore } from "../ModalWidgetStore";
import { IApp, isAppWidget } from "../WidgetStore";
import ThemeWatcher from "../../settings/watchers/ThemeWatcher";
import { getCustomTheme } from "../../theme";
import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities";
import { ELEMENT_CLIENT_ID } from "../../identifiers";
import { WidgetVariableCustomisations } from "../../customisations/WidgetVariables";
import { arrayFastClone } from "../../utils/arrays";
import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
import Modal from "../../Modal";
import ErrorDialog from "../../components/views/dialogs/ErrorDialog";
import { SdkContextClass } from "../../contexts/SDKContext";
// TODO: Destroy all of this code
interface IAppTileProps {
// Note: these are only the props we care about
app: IApp | IWidget;
room?: Room; // without a room it is a user widget
userId: string;
creatorUserId: string;
waitForIframeLoad: boolean;
whitelistCapabilities?: string[];
userWidget: boolean;
stickyPromise?: () => Promise<void>;
}
// TODO: Don't use this because it's wrong
export class ElementWidget extends Widget {
public constructor(private rawDefinition: IWidget) {
super(rawDefinition);
}
public get templateUrl(): string {
if (WidgetType.JITSI.matches(this.type)) {
return WidgetUtils.getLocalJitsiWrapperUrl({
forLocalRender: true,
auth: super.rawData?.auth as string, // this.rawData can call templateUrl, do this to prevent looping
});
}
return super.templateUrl;
}
public get popoutTemplateUrl(): string {
if (WidgetType.JITSI.matches(this.type)) {
return WidgetUtils.getLocalJitsiWrapperUrl({
forLocalRender: false, // The only important difference between this and templateUrl()
auth: super.rawData?.auth as string,
});
}
return this.templateUrl; // use this instead of super to ensure we get appropriate templating
}
public get rawData(): IWidgetData {
let conferenceId = super.rawData["conferenceId"];
if (conferenceId === undefined) {
// we'll need to parse the conference ID out of the URL for v1 Jitsi widgets
const parsedUrl = new URL(super.templateUrl); // use super to get the raw widget URL
conferenceId = parsedUrl.searchParams.get("confId");
}
let domain = super.rawData["domain"];
if (domain === undefined) {
// v1 widgets default to meet.element.io regardless of user settings
domain = "meet.element.io";
}
let theme = new ThemeWatcher().getEffectiveTheme();
if (theme.startsWith("custom-")) {
const customTheme = getCustomTheme(theme.slice(7));
// Jitsi only understands light/dark
theme = customTheme.is_dark ? "dark" : "light";
}
// only allow light/dark through, defaulting to dark as that was previously the only state
// accounts for legacy-light/legacy-dark themes too
if (theme.includes("light")) {
theme = "light";
} else {
theme = "dark";
}
return {
...super.rawData,
theme,
conferenceId,
domain,
};
}
public getCompleteUrl(params: ITemplateParams, asPopout = false): string {
return runTemplate(
asPopout ? this.popoutTemplateUrl : this.templateUrl,
{
...this.rawDefinition,
data: this.rawData,
},
params,
);
}
}
export class StopGapWidget extends EventEmitter {
private client: MatrixClient;
private messaging: ClientWidgetApi | null = null;
private mockWidget: ElementWidget;
private scalarToken?: string;
private roomId?: string;
private kind: WidgetKind;
private readonly virtual: boolean;
private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID
private stickyPromise?: () => Promise<void>; // This promise will be called and needs to resolve before the widget will actually become sticky.
public constructor(private appTileProps: IAppTileProps) {
super();
this.client = MatrixClientPeg.safeGet();
let app = appTileProps.app;
// Backwards compatibility: not all old widgets have a creatorUserId
if (!app.creatorUserId) {
app = objectShallowClone(app); // clone to prevent accidental mutation
app.creatorUserId = this.client.getUserId()!;
}
this.mockWidget = new ElementWidget(app);
this.roomId = appTileProps.room?.roomId;
this.kind = appTileProps.userWidget ? WidgetKind.Account : WidgetKind.Room; // probably
this.virtual = isAppWidget(app) && app.eventId === undefined;
this.stickyPromise = appTileProps.stickyPromise;
}
private get eventListenerRoomId(): Optional<string> {
// When widgets are listening to events, we need to make sure they're only
// receiving events for the right room. In particular, room widgets get locked
// to the room they were added in while account widgets listen to the currently
// active room.
if (this.roomId) return this.roomId;
return SdkContextClass.instance.roomViewStore.getRoomId();
}
public get widgetApi(): ClientWidgetApi | null {
return this.messaging;
}
/**
* The URL to use in the iframe
*/
public get embedUrl(): string {
return this.runUrlTemplate({ asPopout: false });
}
/**
* The URL to use in the popout
*/
public get popoutUrl(): string {
return this.runUrlTemplate({ asPopout: true });
}
private runUrlTemplate(opts = { asPopout: false }): string {
const fromCustomisation = WidgetVariableCustomisations?.provideVariables?.() ?? {};
const defaults: ITemplateParams = {
widgetRoomId: this.roomId,
currentUserId: this.client.getUserId()!,
userDisplayName: OwnProfileStore.instance.displayName ?? undefined,
userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl() ?? undefined,
clientId: ELEMENT_CLIENT_ID,
clientTheme: SettingsStore.getValue("theme"),
clientLanguage: getUserLanguage(),
deviceId: this.client.getDeviceId() ?? undefined,
baseUrl: this.client.baseUrl,
};
const templated = this.mockWidget.getCompleteUrl(Object.assign(defaults, fromCustomisation), opts?.asPopout);
const parsed = new URL(templated);
// Add in some legacy support sprinkles (for non-popout widgets)
// TODO: Replace these with proper widget params
// See https://github.com/matrix-org/matrix-doc/pull/1958/files#r405714833
if (!opts?.asPopout) {
parsed.searchParams.set("widgetId", this.mockWidget.id);
parsed.searchParams.set("parentUrl", window.location.href.split("#", 2)[0]);
// Give the widget a scalar token if we're supposed to (more legacy)
// TODO: Stop doing this
if (this.scalarToken) {
parsed.searchParams.set("scalar_token", this.scalarToken);
}
}
// Replace the encoded dollar signs back to dollar signs. They have no special meaning
// in HTTP, but URL parsers encode them anyways.
return parsed.toString().replace(/%24/g, "$");
}
public get started(): boolean {
return !!this.messaging;
}
private onOpenModal = async (ev: CustomEvent<IModalWidgetOpenRequest>): Promise<void> => {
ev.preventDefault();
if (ModalWidgetStore.instance.canOpenModalWidget()) {
ModalWidgetStore.instance.openModalWidget(ev.detail.data, this.mockWidget, this.roomId);
this.messaging?.transport.reply(ev.detail, {}); // ack
} else {
this.messaging?.transport.reply(ev.detail, {
error: {
message: "Unable to open modal at this time",
},
});
}
};
/**
* This starts the messaging for the widget if it is not in the state `started` yet.
* @param iframe the iframe the widget should use
*/
public startMessaging(iframe: HTMLIFrameElement): any {
if (this.started) return;
const allowedCapabilities = this.appTileProps.whitelistCapabilities || [];
const driver = new StopGapWidgetDriver(
allowedCapabilities,
this.mockWidget,
this.kind,
this.virtual,
this.roomId,
);
this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver);
this.messaging.on("preparing", () => this.emit("preparing"));
this.messaging.on("error:preparing", (err: unknown) => this.emit("error:preparing", err));
this.messaging.on("ready", () => {
WidgetMessagingStore.instance.storeMessaging(this.mockWidget, this.roomId, this.messaging!);
this.emit("ready");
});
this.messaging.on("capabilitiesNotified", () => this.emit("capabilitiesNotified"));
this.messaging.on(`action:${WidgetApiFromWidgetAction.OpenModalWidget}`, this.onOpenModal);
this.messaging.on(`action:${ElementWidgetActions.JoinCall}`, () => {
// pause voice broadcast recording when any widget sends a "join"
SdkContextClass.instance.voiceBroadcastRecordingsStore.getCurrent()?.pause();
});
// Always attach a handler for ViewRoom, but permission check it internally
this.messaging.on(`action:${ElementWidgetActions.ViewRoom}`, (ev: CustomEvent<IViewRoomApiRequest>) => {
ev.preventDefault(); // stop the widget API from auto-rejecting this
// Check up front if this is even a valid request
const targetRoomId = (ev.detail.data || {}).room_id;
if (!targetRoomId) {
return this.messaging?.transport.reply(ev.detail, <IWidgetApiErrorResponseData>{
error: { message: "Room ID not supplied." },
});
}
// Check the widget's permission
if (!this.messaging?.hasCapability(ElementWidgetCapabilities.CanChangeViewedRoom)) {
return this.messaging?.transport.reply(ev.detail, <IWidgetApiErrorResponseData>{
error: { message: "This widget does not have permission for this action (denied)." },
});
}
// at this point we can change rooms, so do that
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: targetRoomId,
metricsTrigger: "Widget",
});
// acknowledge so the widget doesn't freak out
this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{});
});
// Populate the map of "read up to" events for this widget with the current event in every room.
// This is a bit inefficient, but should be okay. We do this for all rooms in case the widget
// requests timeline capabilities in other rooms down the road. It's just easier to manage here.
for (const room of this.client.getRooms()) {
// Timelines are most recent last
const events = room.getLiveTimeline()?.getEvents() || [];
const roomEvent = events[events.length - 1];
if (!roomEvent) continue; // force later code to think the room is fresh
this.readUpToMap[room.roomId] = roomEvent.getId()!;
}
// Attach listeners for feeding events - the underlying widget classes handle permissions for us
this.client.on(ClientEvent.Event, this.onEvent);
this.client.on(MatrixEventEvent.Decrypted, this.onEventDecrypted);
this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
this.messaging.on(
`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`,
async (ev: CustomEvent<IStickyActionRequest>) => {
if (this.messaging?.hasCapability(MatrixCapabilities.AlwaysOnScreen)) {
ev.preventDefault();
if (ev.detail.data.value) {
// If the widget wants to become sticky we wait for the stickyPromise to resolve
if (this.stickyPromise) await this.stickyPromise();
}
// Stop being persistent can be done instantly
ActiveWidgetStore.instance.setWidgetPersistence(
this.mockWidget.id,
this.roomId ?? null,
ev.detail.data.value,
);
// Send the ack after the widget actually has become sticky.
this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{});
}
},
);
// TODO: Replace this event listener with appropriate driver functionality once the API
// establishes a sane way to send events back and forth.
this.messaging.on(
`action:${WidgetApiFromWidgetAction.SendSticker}`,
(ev: CustomEvent<IStickerActionRequest>) => {
if (this.messaging?.hasCapability(MatrixCapabilities.StickerSending)) {
// Acknowledge first
ev.preventDefault();
this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{});
// Send the sticker
defaultDispatcher.dispatch({
action: "m.sticker",
data: ev.detail.data,
widgetId: this.mockWidget.id,
});
}
},
);
if (WidgetType.STICKERPICKER.matches(this.mockWidget.type)) {
this.messaging.on(
`action:${ElementWidgetActions.OpenIntegrationManager}`,
(ev: CustomEvent<IWidgetApiRequest>) => {
// Acknowledge first
ev.preventDefault();
this.messaging?.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{});
// First close the stickerpicker
defaultDispatcher.dispatch({ action: "stickerpicker_close" });
// Now open the integration manager
// TODO: Spec this interaction.
const data = ev.detail.data;
const integType = data?.integType as string;
const integId = <string>data?.integId;
const roomId = SdkContextClass.instance.roomViewStore.getRoomId();
const room = roomId ? this.client.getRoom(roomId) : undefined;
if (!room) return;
// noinspection JSIgnoredPromiseFromCall
IntegrationManagers.sharedInstance()?.getPrimaryManager()?.open(room, `type_${integType}`, integId);
},
);
}
if (WidgetType.JITSI.matches(this.mockWidget.type)) {
this.messaging.on(`action:${ElementWidgetActions.HangupCall}`, (ev: CustomEvent<IHangupCallApiRequest>) => {
ev.preventDefault();
if (ev.detail.data?.errorMessage) {
Modal.createDialog(ErrorDialog, {
title: _t("widget|error_hangup_title"),
description: _t("widget|error_hangup_description", {
message: ev.detail.data.errorMessage,
}),
});
}
this.messaging?.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{});
});
}
}
public async prepare(): Promise<void> {
// Ensure the variables are ready for us to be rendered before continuing
await (WidgetVariableCustomisations?.isReady?.() ?? Promise.resolve());
if (this.scalarToken) return;
const existingMessaging = WidgetMessagingStore.instance.getMessaging(this.mockWidget, this.roomId);
if (existingMessaging) this.messaging = existingMessaging;
try {
if (WidgetUtils.isScalarUrl(this.mockWidget.templateUrl)) {
const managers = IntegrationManagers.sharedInstance();
if (managers.hasManager()) {
// TODO: Pick the right manager for the widget
const defaultManager = managers.getPrimaryManager();
if (defaultManager && WidgetUtils.isScalarUrl(defaultManager.apiUrl)) {
const scalar = defaultManager.getScalarClient();
this.scalarToken = await scalar.getScalarToken();
}
}
}
} catch (e) {
// All errors are non-fatal
logger.error("Error preparing widget communications: ", e);
}
}
/**
* Stops the widget messaging for if it is started. Skips stopping if it is an active
* widget.
* @param opts
*/
public stopMessaging(opts = { forceDestroy: false }): void {
if (
!opts?.forceDestroy &&
ActiveWidgetStore.instance.getWidgetPersistence(this.mockWidget.id, this.roomId ?? null)
) {
logger.log("Skipping destroy - persistent widget");
return;
}
if (!this.started) return;
WidgetMessagingStore.instance.stopMessaging(this.mockWidget, this.roomId);
this.messaging = null;
this.client.off(ClientEvent.Event, this.onEvent);
this.client.off(MatrixEventEvent.Decrypted, this.onEventDecrypted);
this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
}
private onEvent = (ev: MatrixEvent): void => {
this.client.decryptEventIfNeeded(ev);
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
this.feedEvent(ev);
};
private onEventDecrypted = (ev: MatrixEvent): void => {
if (ev.isDecryptionFailure()) return;
this.feedEvent(ev);
};
private onToDeviceEvent = async (ev: MatrixEvent): Promise<void> => {
await this.client.decryptEventIfNeeded(ev);
if (ev.isDecryptionFailure()) return;
await this.messaging?.feedToDevice(ev.getEffectiveEvent() as IRoomEvent, ev.isEncrypted());
};
private feedEvent(ev: MatrixEvent): void {
if (!this.messaging) return;
// Check to see if this event would be before or after our "read up to" marker. If it's
// before, or we can't decide, then we assume the widget will have already seen the event.
// If the event is after, or we don't have a marker for the room, then we'll send it through.
//
// This approach of "read up to" prevents widgets receiving decryption spam from startup or
// receiving out-of-order events from backfill and such.
//
// Skip marker timeline check for events with relations to unknown parent because these
// events are not added to the timeline here and will be ignored otherwise:
// https://github.com/matrix-org/matrix-js-sdk/blob/d3dfcd924201d71b434af3d77343b5229b6ed75e/src/models/room.ts#L2207-L2213
let isRelationToUnknown: boolean | undefined = undefined;
const upToEventId = this.readUpToMap[ev.getRoomId()!];
if (upToEventId) {
// Small optimization for exact match (prevent search)
if (upToEventId === ev.getId()) {
return;
}
// should be true to forward the event to the widget
let shouldForward = false;
const room = this.client.getRoom(ev.getRoomId()!);
if (!room) return;
// Timelines are most recent last, so reverse the order and limit ourselves to 100 events
// to avoid overusing the CPU.
const timeline = room.getLiveTimeline();
const events = arrayFastClone(timeline.getEvents()).reverse().slice(0, 100);
for (const timelineEvent of events) {
if (timelineEvent.getId() === upToEventId) {
break;
} else if (timelineEvent.getId() === ev.getId()) {
shouldForward = true;
break;
}
}
if (!shouldForward) {
// checks that the event has a relation to unknown event
isRelationToUnknown =
!ev.replyEventId && !!ev.relationEventId && !room.findEventById(ev.relationEventId);
if (!isRelationToUnknown) {
// Ignore the event: it is before our interest.
return;
}
}
}
// Skip marker assignment if membership is 'invite', otherwise 'm.room.member' from
// invitation room will assign it and new state events will be not forwarded to the widget
// because of empty timeline for invitation room and assigned marker.
const evRoomId = ev.getRoomId();
const evId = ev.getId();
if (evRoomId && evId) {
const room = this.client.getRoom(evRoomId);
if (room && room.getMyMembership() === KnownMembership.Join && !isRelationToUnknown) {
this.readUpToMap[evRoomId] = evId;
}
}
const raw = ev.getEffectiveEvent();
this.messaging.feedEvent(raw as IRoomEvent, this.eventListenerRoomId!).catch((e) => {
logger.error("Error sending event to widget: ", e);
});
}
}

View File

@@ -0,0 +1,689 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 2020-2023 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
* Please see LICENSE files in the repository root for full details.
*/
import {
Capability,
EventDirection,
IOpenIDCredentials,
IOpenIDUpdate,
ISendDelayedEventDetails,
ISendEventDetails,
ITurnServer,
IReadEventRelationsResult,
IRoomEvent,
MatrixCapabilities,
OpenIDRequestState,
SimpleObservable,
Symbols,
Widget,
WidgetDriver,
WidgetEventCapability,
WidgetKind,
ISearchUserDirectoryResult,
IGetMediaConfigResult,
UpdateDelayedEventAction,
} from "matrix-widget-api";
import {
ClientEvent,
ITurnServer as IClientTurnServer,
EventType,
IContent,
MatrixEvent,
Room,
Direction,
THREAD_RELATION_TYPE,
SendDelayedEventResponse,
StateEvents,
TimelineEvents,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import {
ApprovalOpts,
CapabilitiesOpts,
WidgetLifecycle,
} from "@matrix-org/react-sdk-module-api/lib/lifecycles/WidgetLifecycle";
import SdkConfig, { DEFAULTS } from "../../SdkConfig";
import { iterableDiff, iterableIntersection } from "../../utils/iterables";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import Modal from "../../Modal";
import WidgetOpenIDPermissionsDialog from "../../components/views/dialogs/WidgetOpenIDPermissionsDialog";
import WidgetCapabilitiesPromptDialog from "../../components/views/dialogs/WidgetCapabilitiesPromptDialog";
import { WidgetPermissionCustomisations } from "../../customisations/WidgetPermissions";
import { OIDCState } from "./WidgetPermissionStore";
import { WidgetType } from "../../widgets/WidgetType";
import { CHAT_EFFECTS } from "../../effects";
import { containsEmoji } from "../../effects/utils";
import dis from "../../dispatcher/dispatcher";
import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities";
import { navigateToPermalink } from "../../utils/permalinks/navigator";
import { SdkContextClass } from "../../contexts/SDKContext";
import { ModuleRunner } from "../../modules/ModuleRunner";
import SettingsStore from "../../settings/SettingsStore";
import { Media } from "../../customisations/Media";
// TODO: Purge this from the universe
function getRememberedCapabilitiesForWidget(widget: Widget): Capability[] {
return JSON.parse(localStorage.getItem(`widget_${widget.id}_approved_caps`) || "[]");
}
function setRememberedCapabilitiesForWidget(widget: Widget, caps: Capability[]): void {
localStorage.setItem(`widget_${widget.id}_approved_caps`, JSON.stringify(caps));
}
const normalizeTurnServer = ({ urls, username, credential }: IClientTurnServer): ITurnServer => ({
uris: urls,
username,
password: credential,
});
export class StopGapWidgetDriver extends WidgetDriver {
private allowedCapabilities: Set<Capability>;
// TODO: Refactor widgetKind into the Widget class
public constructor(
allowedCapabilities: Capability[],
private forWidget: Widget,
private forWidgetKind: WidgetKind,
virtual: boolean,
private inRoomId?: string,
) {
super();
// Always allow screenshots to be taken because it's a client-induced flow. The widget can't
// spew screenshots at us and can't request screenshots of us, so it's up to us to provide the
// button if the widget says it supports screenshots.
this.allowedCapabilities = new Set([
...allowedCapabilities,
MatrixCapabilities.Screenshots,
ElementWidgetCapabilities.RequiresClient,
]);
// Grant the permissions that are specific to given widget types
if (WidgetType.JITSI.matches(this.forWidget.type) && forWidgetKind === WidgetKind.Room) {
this.allowedCapabilities.add(MatrixCapabilities.AlwaysOnScreen);
} else if (WidgetType.STICKERPICKER.matches(this.forWidget.type) && forWidgetKind === WidgetKind.Account) {
const stickerSendingCap = WidgetEventCapability.forRoomEvent(EventDirection.Send, EventType.Sticker).raw;
this.allowedCapabilities.add(MatrixCapabilities.StickerSending); // legacy as far as MSC2762 is concerned
this.allowedCapabilities.add(stickerSendingCap);
// Auto-approve the legacy visibility capability. We send it regardless of capability.
// Widgets don't technically need to request this capability, but Scalar still does.
this.allowedCapabilities.add("visibility");
} else if (
virtual &&
new URL(SdkConfig.get("element_call").url ?? DEFAULTS.element_call.url!).origin === this.forWidget.origin
) {
// This is a trusted Element Call widget that we control
this.allowedCapabilities.add(MatrixCapabilities.AlwaysOnScreen);
this.allowedCapabilities.add(MatrixCapabilities.MSC3846TurnServers);
this.allowedCapabilities.add(`org.matrix.msc2762.timeline:${inRoomId}`);
this.allowedCapabilities.add(MatrixCapabilities.MSC4157SendDelayedEvent);
this.allowedCapabilities.add(MatrixCapabilities.MSC4157UpdateDelayedEvent);
this.allowedCapabilities.add(
WidgetEventCapability.forRoomEvent(EventDirection.Send, "org.matrix.rageshake_request").raw,
);
this.allowedCapabilities.add(
WidgetEventCapability.forRoomEvent(EventDirection.Receive, "org.matrix.rageshake_request").raw,
);
this.allowedCapabilities.add(
WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomMember).raw,
);
this.allowedCapabilities.add(
WidgetEventCapability.forStateEvent(EventDirection.Receive, "org.matrix.msc3401.call").raw,
);
this.allowedCapabilities.add(
WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomEncryption).raw,
);
const clientUserId = MatrixClientPeg.safeGet().getSafeUserId();
// For the legacy membership type
this.allowedCapabilities.add(
WidgetEventCapability.forStateEvent(EventDirection.Send, "org.matrix.msc3401.call.member", clientUserId)
.raw,
);
const clientDeviceId = MatrixClientPeg.safeGet().getDeviceId();
if (clientDeviceId !== null) {
// For the session membership type compliant with MSC4143
this.allowedCapabilities.add(
WidgetEventCapability.forStateEvent(
EventDirection.Send,
"org.matrix.msc3401.call.member",
`_${clientUserId}_${clientDeviceId}`,
).raw,
);
// Version with no leading underscore, for room versions whose auth rules allow it
this.allowedCapabilities.add(
WidgetEventCapability.forStateEvent(
EventDirection.Send,
"org.matrix.msc3401.call.member",
`${clientUserId}_${clientDeviceId}`,
).raw,
);
}
this.allowedCapabilities.add(
WidgetEventCapability.forStateEvent(EventDirection.Receive, "org.matrix.msc3401.call.member").raw,
);
// for determining auth rules specific to the room version
this.allowedCapabilities.add(
WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomCreate).raw,
);
const sendRecvRoomEvents = ["io.element.call.encryption_keys", EventType.Reaction, EventType.RoomRedaction];
for (const eventType of sendRecvRoomEvents) {
this.allowedCapabilities.add(WidgetEventCapability.forRoomEvent(EventDirection.Send, eventType).raw);
this.allowedCapabilities.add(WidgetEventCapability.forRoomEvent(EventDirection.Receive, eventType).raw);
}
const sendRecvToDevice = [
EventType.CallInvite,
EventType.CallCandidates,
EventType.CallAnswer,
EventType.CallHangup,
EventType.CallReject,
EventType.CallSelectAnswer,
EventType.CallNegotiate,
EventType.CallSDPStreamMetadataChanged,
EventType.CallSDPStreamMetadataChangedPrefix,
EventType.CallReplaces,
];
for (const eventType of sendRecvToDevice) {
this.allowedCapabilities.add(
WidgetEventCapability.forToDeviceEvent(EventDirection.Send, eventType).raw,
);
this.allowedCapabilities.add(
WidgetEventCapability.forToDeviceEvent(EventDirection.Receive, eventType).raw,
);
}
// To always allow OIDC requests for element call, the widgetPermissionStore is used:
SdkContextClass.instance.widgetPermissionStore.setOIDCState(
forWidget,
forWidgetKind,
inRoomId,
OIDCState.Allowed,
);
}
}
public async validateCapabilities(requested: Set<Capability>): Promise<Set<Capability>> {
// Check to see if any capabilities aren't automatically accepted (such as sticker pickers
// allowing stickers to be sent). If there are excess capabilities to be approved, the user
// will be prompted to accept them.
const diff = iterableDiff(requested, this.allowedCapabilities);
const missing = new Set(diff.removed); // "removed" is "in A (requested) but not in B (allowed)"
const allowedSoFar = new Set(this.allowedCapabilities);
getRememberedCapabilitiesForWidget(this.forWidget).forEach((cap) => {
allowedSoFar.add(cap);
missing.delete(cap);
});
let approved: Set<string> | undefined;
if (WidgetPermissionCustomisations.preapproveCapabilities) {
approved = await WidgetPermissionCustomisations.preapproveCapabilities(this.forWidget, requested);
} else {
const opts: CapabilitiesOpts = { approvedCapabilities: undefined };
ModuleRunner.instance.invoke(WidgetLifecycle.CapabilitiesRequest, opts, this.forWidget, requested);
approved = opts.approvedCapabilities;
}
if (approved) {
approved.forEach((cap) => {
allowedSoFar.add(cap);
missing.delete(cap);
});
}
// TODO: Do something when the widget requests new capabilities not yet asked for
let rememberApproved = false;
if (missing.size > 0) {
try {
const [result] = await Modal.createDialog(WidgetCapabilitiesPromptDialog, {
requestedCapabilities: missing,
widget: this.forWidget,
widgetKind: this.forWidgetKind,
}).finished;
result?.approved?.forEach((cap) => allowedSoFar.add(cap));
rememberApproved = !!result?.remember;
} catch (e) {
logger.error("Non-fatal error getting capabilities: ", e);
}
}
// discard all previously allowed capabilities if they are not requested
// TODO: this results in an unexpected behavior when this function is called during the capabilities renegotiation of MSC2974 that will be resolved later.
const allAllowed = new Set(iterableIntersection(allowedSoFar, requested));
if (rememberApproved) {
setRememberedCapabilitiesForWidget(this.forWidget, Array.from(allAllowed));
}
return allAllowed;
}
public async sendEvent<K extends keyof StateEvents>(
eventType: K,
content: StateEvents[K],
stateKey: string | null,
targetRoomId: string | null,
): Promise<ISendEventDetails>;
public async sendEvent<K extends keyof TimelineEvents>(
eventType: K,
content: TimelineEvents[K],
stateKey: null,
targetRoomId: string | null,
): Promise<ISendEventDetails>;
public async sendEvent(
eventType: string,
content: IContent,
stateKey: string | null = null,
targetRoomId: string | null = null,
): Promise<ISendEventDetails> {
const client = MatrixClientPeg.get();
const roomId = targetRoomId || SdkContextClass.instance.roomViewStore.getRoomId();
if (!client || !roomId) throw new Error("Not in a room or not attached to a client");
let r: { event_id: string } | null;
if (stateKey !== null) {
// state event
r = await client.sendStateEvent(
roomId,
eventType as keyof StateEvents,
content as StateEvents[keyof StateEvents],
stateKey,
);
} else if (eventType === EventType.RoomRedaction) {
// special case: extract the `redacts` property and call redact
r = await client.redactEvent(roomId, content["redacts"]);
} else {
// message event
r = await client.sendEvent(
roomId,
eventType as keyof TimelineEvents,
content as TimelineEvents[keyof TimelineEvents],
);
if (eventType === EventType.RoomMessage) {
CHAT_EFFECTS.forEach((effect) => {
if (containsEmoji(content, effect.emojis)) {
// For initial threads launch, chat effects are disabled
// see #19731
const isNotThread = content["m.relates_to"]?.rel_type !== THREAD_RELATION_TYPE.name;
if (isNotThread) {
dis.dispatch({ action: `effects.${effect.command}` });
}
}
});
}
}
return { roomId, eventId: r.event_id };
}
/**
* @experimental Part of MSC4140 & MSC4157
* @see {@link WidgetDriver#sendDelayedEvent}
*/
public async sendDelayedEvent<K extends keyof StateEvents>(
delay: number | null,
parentDelayId: string | null,
eventType: K,
content: StateEvents[K],
stateKey: string | null,
targetRoomId: string | null,
): Promise<ISendDelayedEventDetails>;
/**
* @experimental Part of MSC4140 & MSC4157
*/
public async sendDelayedEvent<K extends keyof TimelineEvents>(
delay: number | null,
parentDelayId: string | null,
eventType: K,
content: TimelineEvents[K],
stateKey: null,
targetRoomId: string | null,
): Promise<ISendDelayedEventDetails>;
public async sendDelayedEvent(
delay: number | null,
parentDelayId: string | null,
eventType: string,
content: IContent,
stateKey: string | null = null,
targetRoomId: string | null = null,
): Promise<ISendDelayedEventDetails> {
const client = MatrixClientPeg.get();
const roomId = targetRoomId || SdkContextClass.instance.roomViewStore.getRoomId();
if (!client || !roomId) throw new Error("Not in a room or not attached to a client");
let delayOpts;
if (delay !== null) {
delayOpts = {
delay,
...(parentDelayId !== null && { parent_delay_id: parentDelayId }),
};
} else if (parentDelayId !== null) {
delayOpts = {
parent_delay_id: parentDelayId,
};
} else {
throw new Error("Must provide at least one of delay or parentDelayId");
}
let r: SendDelayedEventResponse | null;
if (stateKey !== null) {
// state event
r = await client._unstable_sendDelayedStateEvent(
roomId,
delayOpts,
eventType as keyof StateEvents,
content as StateEvents[keyof StateEvents],
stateKey,
);
} else {
// message event
r = await client._unstable_sendDelayedEvent(
roomId,
delayOpts,
null,
eventType as keyof TimelineEvents,
content as TimelineEvents[keyof TimelineEvents],
);
}
return {
roomId,
delayId: r.delay_id,
};
}
/**
* @experimental Part of MSC4140 & MSC4157
*/
public async updateDelayedEvent(delayId: string, action: UpdateDelayedEventAction): Promise<void> {
const client = MatrixClientPeg.get();
if (!client) throw new Error("Not in a room or not attached to a client");
await client._unstable_updateDelayedEvent(delayId, action);
}
public async sendToDevice(
eventType: string,
encrypted: boolean,
contentMap: { [userId: string]: { [deviceId: string]: object } },
): Promise<void> {
const client = MatrixClientPeg.safeGet();
if (encrypted) {
const deviceInfoMap = await client.crypto!.deviceList.downloadKeys(Object.keys(contentMap), false);
await Promise.all(
Object.entries(contentMap).flatMap(([userId, userContentMap]) =>
Object.entries(userContentMap).map(async ([deviceId, content]): Promise<void> => {
const devices = deviceInfoMap.get(userId);
if (!devices) return;
if (deviceId === "*") {
// Send the message to all devices we have keys for
await client.encryptAndSendToDevices(
Array.from(devices.values()).map((deviceInfo) => ({
userId,
deviceInfo,
})),
content,
);
} else if (devices.has(deviceId)) {
// Send the message to a specific device
await client.encryptAndSendToDevices(
[{ userId, deviceInfo: devices.get(deviceId)! }],
content,
);
}
}),
),
);
} else {
await client.queueToDevice({
eventType,
batch: Object.entries(contentMap).flatMap(([userId, userContentMap]) =>
Object.entries(userContentMap).map(([deviceId, content]) => ({
userId,
deviceId,
payload: content,
})),
),
});
}
}
private pickRooms(roomIds?: (string | Symbols.AnyRoom)[]): Room[] {
const client = MatrixClientPeg.get();
if (!client) throw new Error("Not attached to a client");
const targetRooms = roomIds
? roomIds.includes(Symbols.AnyRoom)
? client.getVisibleRooms(SettingsStore.getValue("feature_dynamic_room_predecessors"))
: roomIds.map((r) => client.getRoom(r))
: [client.getRoom(SdkContextClass.instance.roomViewStore.getRoomId()!)];
return targetRooms.filter((r) => !!r) as Room[];
}
public async readRoomEvents(
eventType: string,
msgtype: string | undefined,
limitPerRoom: number,
roomIds?: (string | Symbols.AnyRoom)[],
): Promise<IRoomEvent[]> {
limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary
const rooms = this.pickRooms(roomIds);
const allResults: IRoomEvent[] = [];
for (const room of rooms) {
const results: MatrixEvent[] = [];
const events = room.getLiveTimeline().getEvents(); // timelines are most recent last
for (let i = events.length - 1; i > 0; i--) {
if (results.length >= limitPerRoom) break;
const ev = events[i];
if (ev.getType() !== eventType || ev.isState()) continue;
if (eventType === EventType.RoomMessage && msgtype && msgtype !== ev.getContent()["msgtype"]) continue;
results.push(ev);
}
results.forEach((e) => allResults.push(e.getEffectiveEvent() as IRoomEvent));
}
return allResults;
}
public async readStateEvents(
eventType: string,
stateKey: string | undefined,
limitPerRoom: number,
roomIds?: (string | Symbols.AnyRoom)[],
): Promise<IRoomEvent[]> {
limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary
const rooms = this.pickRooms(roomIds);
const allResults: IRoomEvent[] = [];
for (const room of rooms) {
const results: MatrixEvent[] = [];
const state = room.currentState.events.get(eventType);
if (state) {
if (stateKey === "" || !!stateKey) {
const forKey = state.get(stateKey);
if (forKey) results.push(forKey);
} else {
results.push(...Array.from(state.values()));
}
}
results.slice(0, limitPerRoom).forEach((e) => allResults.push(e.getEffectiveEvent() as IRoomEvent));
}
return allResults;
}
public async askOpenID(observer: SimpleObservable<IOpenIDUpdate>): Promise<void> {
const opts: ApprovalOpts = { approved: undefined };
ModuleRunner.instance.invoke(WidgetLifecycle.IdentityRequest, opts, this.forWidget);
if (opts.approved) {
return observer.update({
state: OpenIDRequestState.Allowed,
token: await MatrixClientPeg.safeGet().getOpenIdToken(),
});
}
const oidcState = SdkContextClass.instance.widgetPermissionStore.getOIDCState(
this.forWidget,
this.forWidgetKind,
this.inRoomId,
);
const getToken = (): Promise<IOpenIDCredentials> => {
return MatrixClientPeg.safeGet().getOpenIdToken();
};
if (oidcState === OIDCState.Denied) {
return observer.update({ state: OpenIDRequestState.Blocked });
}
if (oidcState === OIDCState.Allowed) {
return observer.update({ state: OpenIDRequestState.Allowed, token: await getToken() });
}
observer.update({ state: OpenIDRequestState.PendingUserConfirmation });
Modal.createDialog(WidgetOpenIDPermissionsDialog, {
widget: this.forWidget,
widgetKind: this.forWidgetKind,
inRoomId: this.inRoomId,
onFinished: async (confirm): Promise<void> => {
if (!confirm) {
return observer.update({ state: OpenIDRequestState.Blocked });
}
return observer.update({ state: OpenIDRequestState.Allowed, token: await getToken() });
},
});
}
public async navigate(uri: string): Promise<void> {
navigateToPermalink(uri);
}
public async *getTurnServers(): AsyncGenerator<ITurnServer> {
const client = MatrixClientPeg.safeGet();
if (!client.pollingTurnServers || !client.getTurnServers().length) return;
let setTurnServer: (server: ITurnServer) => void;
let setError: (error: Error) => void;
const onTurnServers = ([server]: IClientTurnServer[]): void => setTurnServer(normalizeTurnServer(server));
const onTurnServersError = (error: Error, fatal: boolean): void => {
if (fatal) setError(error);
};
client.on(ClientEvent.TurnServers, onTurnServers);
client.on(ClientEvent.TurnServersError, onTurnServersError);
try {
const initialTurnServer = client.getTurnServers()[0];
yield normalizeTurnServer(initialTurnServer);
// Repeatedly listen for new TURN servers until an error occurs or
// the caller stops this generator
while (true) {
yield await new Promise<ITurnServer>((resolve, reject) => {
setTurnServer = resolve;
setError = reject;
});
}
} finally {
// The loop was broken - clean up
client.off(ClientEvent.TurnServers, onTurnServers);
client.off(ClientEvent.TurnServersError, onTurnServersError);
}
}
public async readEventRelations(
eventId: string,
roomId?: string,
relationType?: string,
eventType?: string,
from?: string,
to?: string,
limit?: number,
direction?: "f" | "b",
): Promise<IReadEventRelationsResult> {
const client = MatrixClientPeg.safeGet();
const dir = direction as Direction;
roomId = roomId ?? SdkContextClass.instance.roomViewStore.getRoomId() ?? undefined;
if (typeof roomId !== "string") {
throw new Error("Error while reading the current room");
}
const { events, nextBatch, prevBatch } = await client.relations(
roomId,
eventId,
relationType ?? null,
eventType ?? null,
{ from, to, limit, dir },
);
return {
chunk: events.map((e) => e.getEffectiveEvent() as IRoomEvent),
nextBatch: nextBatch ?? undefined,
prevBatch: prevBatch ?? undefined,
};
}
public async searchUserDirectory(searchTerm: string, limit?: number): Promise<ISearchUserDirectoryResult> {
const client = MatrixClientPeg.safeGet();
const { limited, results } = await client.searchUserDirectory({ term: searchTerm, limit });
return {
limited,
results: results.map((r) => ({
userId: r.user_id,
displayName: r.display_name,
avatarUrl: r.avatar_url,
})),
};
}
public async getMediaConfig(): Promise<IGetMediaConfigResult> {
const client = MatrixClientPeg.safeGet();
return await client.getMediaConfig();
}
public async uploadFile(file: XMLHttpRequestBodyInit): Promise<{ contentUri: string }> {
const client = MatrixClientPeg.safeGet();
const uploadResult = await client.uploadContent(file);
return { contentUri: uploadResult.content_uri };
}
/**
* Download a file from the media repository on the homeserver.
*
* @param contentUri - the MXC URI of the file to download
* @returns an object with: file - response contents as Blob
*/
public async downloadFile(contentUri: string): Promise<{ file: XMLHttpRequestBodyInit }> {
const client = MatrixClientPeg.safeGet();
const media = new Media({ mxc: contentUri }, client);
const response = await media.downloadSource();
const blob = await response.blob();
return { file: blob };
}
}

View File

@@ -0,0 +1,518 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 2021, 2022 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
* Please see LICENSE files in the repository root for full details.
*/
import { Room, RoomStateEvent, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { Optional } from "matrix-events-sdk";
import { MapWithDefault, recursiveMapToObject } from "matrix-js-sdk/src/utils";
import { IWidget } from "matrix-widget-api";
import SettingsStore from "../../settings/SettingsStore";
import WidgetStore, { IApp } from "../WidgetStore";
import { WidgetType } from "../../widgets/WidgetType";
import { clamp, defaultNumber, sum } from "../../utils/numbers";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { ReadyWatchingStore } from "../ReadyWatchingStore";
import { SettingLevel } from "../../settings/SettingLevel";
import { arrayFastClone } from "../../utils/arrays";
import { UPDATE_EVENT } from "../AsyncStore";
import { Container, IStoredLayout, ILayoutStateEvent, WIDGET_LAYOUT_EVENT_TYPE, IWidgetLayouts } from "./types";
export type { IStoredLayout, ILayoutStateEvent };
export { Container, WIDGET_LAYOUT_EVENT_TYPE };
interface ILayoutSettings extends ILayoutStateEvent {
overrides?: string; // event ID for layout state event, if present
}
// Dev note: "Pinned" widgets are ones in the top container.
export const MAX_PINNED = 3;
// These two are whole percentages and don't really mean anything. Later values will decide
// minimum, but these help determine proportions during our calculations here. In fact, these
// values should be *smaller* than the actual minimums imposed by later components.
const MIN_WIDGET_WIDTH_PCT = 10; // 10%
const MIN_WIDGET_HEIGHT_PCT = 2; // 2%
interface ContainerValue {
ordered: IApp[];
height?: number;
distributions?: number[];
}
export class WidgetLayoutStore extends ReadyWatchingStore {
private static internalInstance: WidgetLayoutStore;
// Map: room Id → container → ContainerValue
private byRoom: MapWithDefault<string, Map<Container, ContainerValue>> = new MapWithDefault(() => new Map());
private pinnedRef: string | undefined;
private layoutRef: string | undefined;
private dynamicRef: string | undefined;
private constructor() {
super(defaultDispatcher);
}
public static get instance(): WidgetLayoutStore {
if (!this.internalInstance) {
this.internalInstance = new WidgetLayoutStore();
this.internalInstance.start();
}
return this.internalInstance;
}
public static emissionForRoom(room: Room): string {
return `update_${room.roomId}`;
}
private emitFor(room: Room): void {
this.emit(WidgetLayoutStore.emissionForRoom(room));
}
protected async onReady(): Promise<void> {
this.updateAllRooms();
this.matrixClient?.on(RoomStateEvent.Events, this.updateRoomFromState);
this.pinnedRef = SettingsStore.watchSetting("Widgets.pinned", null, this.updateFromSettings);
this.layoutRef = SettingsStore.watchSetting("Widgets.layout", null, this.updateFromSettings);
this.dynamicRef = SettingsStore.watchSetting(
"feature_dynamic_room_predecessors",
null,
this.updateFromSettings,
);
WidgetStore.instance.on(UPDATE_EVENT, this.updateFromWidgetStore);
}
protected async onNotReady(): Promise<void> {
this.byRoom = new MapWithDefault(() => new Map());
this.matrixClient?.off(RoomStateEvent.Events, this.updateRoomFromState);
if (this.pinnedRef) SettingsStore.unwatchSetting(this.pinnedRef);
if (this.layoutRef) SettingsStore.unwatchSetting(this.layoutRef);
if (this.dynamicRef) SettingsStore.unwatchSetting(this.dynamicRef);
WidgetStore.instance.off(UPDATE_EVENT, this.updateFromWidgetStore);
}
private updateAllRooms = (): void => {
const msc3946ProcessDynamicPredecessor = SettingsStore.getValue("feature_dynamic_room_predecessors");
if (!this.matrixClient) return;
this.byRoom = new MapWithDefault(() => new Map());
for (const room of this.matrixClient.getVisibleRooms(msc3946ProcessDynamicPredecessor)) {
this.recalculateRoom(room);
}
};
private updateFromWidgetStore = (roomId?: string): void => {
if (roomId) {
const room = this.matrixClient?.getRoom(roomId);
if (room) this.recalculateRoom(room);
} else {
this.updateAllRooms();
}
};
private updateRoomFromState = (ev: MatrixEvent): void => {
if (ev.getType() !== WIDGET_LAYOUT_EVENT_TYPE) return;
const room = this.matrixClient?.getRoom(ev.getRoomId());
if (room) this.recalculateRoom(room);
};
private updateFromSettings = (
_settingName: string,
roomId: string | null,
_atLevel: SettingLevel,
_newValAtLevel: any,
_newVal: any,
): void => {
if (roomId) {
const room = this.matrixClient?.getRoom(roomId);
if (room) this.recalculateRoom(room);
} else {
this.updateAllRooms();
}
};
public recalculateRoom(room: Room): void {
const widgets = WidgetStore.instance.getApps(room.roomId);
if (!widgets?.length) {
this.byRoom.set(room.roomId, new Map());
this.emitFor(room);
return;
}
const roomContainers = this.byRoom.getOrCreate(room.roomId);
const beforeChanges = JSON.stringify(recursiveMapToObject(roomContainers));
const layoutEv = room.currentState.getStateEvents(WIDGET_LAYOUT_EVENT_TYPE, "");
const legacyPinned = SettingsStore.getValue("Widgets.pinned", room.roomId);
let userLayout = SettingsStore.getValue<ILayoutSettings | null>("Widgets.layout", room.roomId);
if (layoutEv && userLayout && userLayout.overrides !== layoutEv.getId()) {
// For some other layout that we don't really care about. The user can reset this
// by updating their personal layout.
userLayout = null;
}
const roomLayout = layoutEv?.getContent<ILayoutStateEvent>() ?? null;
// We filter for the center container first.
// (An error is raised, if there are multiple widgets marked for the center container)
// For the right and top container multiple widgets are allowed.
const topWidgets: IApp[] = [];
const rightWidgets: IApp[] = [];
const centerWidgets: IApp[] = [];
for (const widget of widgets) {
const stateContainer = roomLayout?.widgets?.[widget.id]?.container;
const manualContainer = userLayout?.widgets?.[widget.id]?.container;
const isLegacyPinned = !!legacyPinned?.[widget.id];
const defaultContainer = WidgetType.JITSI.matches(widget.type) ? Container.Top : Container.Right;
if (manualContainer ? manualContainer === Container.Center : stateContainer === Container.Center) {
if (centerWidgets.length) {
console.error("Tried to push a second widget into the center container");
} else {
centerWidgets.push(widget);
}
// The widget won't need to be put in any other container.
continue;
}
let targetContainer: Container = defaultContainer;
if (!!manualContainer || !!stateContainer) {
targetContainer = manualContainer ?? stateContainer!;
} else if (isLegacyPinned && !stateContainer) {
// Special legacy case
targetContainer = Container.Top;
}
(targetContainer === Container.Top ? topWidgets : rightWidgets).push(widget);
}
// Trim to MAX_PINNED
const runoff = topWidgets.slice(MAX_PINNED);
rightWidgets.push(...runoff);
const collator = new Intl.Collator();
// Order the widgets in the top container, putting autopinned Jitsi widgets first
// unless they have a specific order in mind
topWidgets.sort((a, b) => {
const layoutA = roomLayout?.widgets?.[a.id];
const layoutB = roomLayout?.widgets?.[b.id];
const userLayoutA = userLayout?.widgets?.[a.id];
const userLayoutB = userLayout?.widgets?.[b.id];
// Jitsi widgets are defaulted to be the leftmost widget whereas other widgets
// default to the right side.
const defaultA = WidgetType.JITSI.matches(a.type) ? Number.MIN_SAFE_INTEGER : Number.MAX_SAFE_INTEGER;
const defaultB = WidgetType.JITSI.matches(b.type) ? Number.MIN_SAFE_INTEGER : Number.MAX_SAFE_INTEGER;
const orderA = defaultNumber(userLayoutA?.index, defaultNumber(layoutA?.index, defaultA));
const orderB = defaultNumber(userLayoutB?.index, defaultNumber(layoutB?.index, defaultB));
if (orderA === orderB) {
// We just need a tiebreak
return collator.compare(a.id, b.id);
}
return orderA - orderB;
});
// Determine width distribution and height of the top container now (the only relevant one)
const widths: number[] = [];
let maxHeight: number | null = null; // null == default
let doAutobalance = true;
for (let i = 0; i < topWidgets.length; i++) {
const widget = topWidgets[i];
const widgetLayout = roomLayout?.widgets?.[widget.id];
const userWidgetLayout = userLayout?.widgets?.[widget.id];
if (Number.isFinite(userWidgetLayout?.width) || Number.isFinite(widgetLayout?.width)) {
const val = (userWidgetLayout?.width || widgetLayout?.width)!;
const normalized = clamp(val, MIN_WIDGET_WIDTH_PCT, 100);
widths.push(normalized);
doAutobalance = false; // a manual width was specified
} else {
widths.push(100); // we'll figure this out later
}
if (widgetLayout?.height || userWidgetLayout?.height) {
const defRoomHeight = defaultNumber(widgetLayout?.height, MIN_WIDGET_HEIGHT_PCT);
const h = defaultNumber(userWidgetLayout?.height, defRoomHeight);
maxHeight = Math.max(maxHeight ?? 0, clamp(h, MIN_WIDGET_HEIGHT_PCT, 100));
}
}
if (doAutobalance) {
for (let i = 0; i < widths.length; i++) {
widths[i] = 100 / widths.length;
}
} else {
// If we're not autobalancing then it means that we're trying to make
// sure that widgets make up exactly 100% of space (not over, not under)
const difference = sum(...widths) - 100; // positive = over, negative = under
if (difference < 0) {
// For a deficit we just fill everything in equally
for (let i = 0; i < widths.length; i++) {
widths[i] += Math.abs(difference) / widths.length;
}
} else if (difference > 0) {
// When we're over, we try to scale all the widgets within range first.
// We clamp values to try and keep ourselves sane and within range.
for (let i = 0; i < widths.length; i++) {
widths[i] = clamp(widths[i] - difference / widths.length, MIN_WIDGET_WIDTH_PCT, 100);
}
// If we're still over, find the widgets which have more width than the minimum
// and balance them out until we're at 100%. This should keep us as close as possible
// to the intended distributions.
//
// Note: if we ever decide to set a minimum which is larger than 100%/MAX_WIDGETS then
// we probably have other issues - this code assumes we don't do that.
const toReclaim = sum(...widths) - 100;
if (toReclaim > 0) {
const largeIndices = widths
.map((v, i) => [i, v])
.filter((p) => p[1] > MIN_WIDGET_WIDTH_PCT)
.map((p) => p[0]);
for (const idx of largeIndices) {
widths[idx] -= toReclaim / largeIndices.length;
}
}
}
}
// Finally, fill in our cache and update
const newRoomContainers = new Map();
this.byRoom.set(room.roomId, newRoomContainers);
if (topWidgets.length) {
newRoomContainers.set(Container.Top, {
ordered: topWidgets,
distributions: widths,
height: maxHeight,
});
}
if (rightWidgets.length) {
newRoomContainers.set(Container.Right, {
ordered: rightWidgets,
});
}
if (centerWidgets.length) {
newRoomContainers.set(Container.Center, {
ordered: centerWidgets,
});
}
const afterChanges = JSON.stringify(recursiveMapToObject(newRoomContainers));
if (afterChanges !== beforeChanges) {
this.emitFor(room);
}
}
public getContainerWidgets(room: Optional<Room>, container: Container): IWidget[] {
return (room && this.byRoom.get(room.roomId)?.get(container)?.ordered) || [];
}
public isInContainer(room: Room, widget: IWidget, container: Container): boolean {
return this.getContainerWidgets(room, container).some((w) => w.id === widget.id);
}
public canAddToContainer(room: Room, container: Container): boolean {
switch (container) {
case Container.Top:
return this.getContainerWidgets(room, container).length < MAX_PINNED;
case Container.Right:
return this.getContainerWidgets(room, container).length < MAX_PINNED;
case Container.Center:
return this.getContainerWidgets(room, container).length < 1;
}
}
public getResizerDistributions(room: Room, container: Container): string[] {
// yes, string.
let distributions = this.byRoom.get(room.roomId)?.get(container)?.distributions;
if (!distributions || distributions.length < 2) return [];
// The distributor actually expects to be fed N-1 sizes and expands the middle section
// instead of the edges. Therefore, we need to return [0] when there's two widgets or
// [0, 2] when there's three (skipping [1] because it's irrelevant).
if (distributions.length === 2) distributions = [distributions[0]];
if (distributions.length === 3) distributions = [distributions[0], distributions[2]];
return distributions.map((d) => `${d.toFixed(1)}%`); // actual percents - these are decoded later
}
public setResizerDistributions(room: Room, container: Container, distributions: string[]): void {
if (container !== Container.Top) return; // ignore - not relevant
const numbers = distributions.map((d) => Number(Number(d.substring(0, d.length - 1)).toFixed(1)));
const widgets = this.getContainerWidgets(room, container);
// From getResizerDistributions, we need to fill in the middle size if applicable.
const remaining = 100 - sum(...numbers);
if (numbers.length === 2) numbers.splice(1, 0, remaining);
if (numbers.length === 1) numbers.push(remaining);
const localLayout: Record<string, IStoredLayout> = {};
widgets.forEach((w, i) => {
localLayout[w.id] = {
container: container,
width: numbers[i],
index: i,
height: this.byRoom.get(room.roomId)?.get(container)?.height || MIN_WIDGET_HEIGHT_PCT,
};
});
this.updateUserLayout(room, localLayout);
}
public getContainerHeight(room: Room, container: Container): number | null {
return this.byRoom.get(room.roomId)?.get(container)?.height ?? null; // let the default get returned if needed
}
public setContainerHeight(room: Room, container: Container, height?: number | null): void {
const widgets = this.getContainerWidgets(room, container);
const widths = this.byRoom.get(room.roomId)?.get(container)?.distributions;
const localLayout: Record<string, IStoredLayout> = {};
widgets.forEach((w, i) => {
localLayout[w.id] = {
container: container,
width: widths?.[i],
index: i,
height: height,
};
});
this.updateUserLayout(room, localLayout);
}
public moveWithinContainer(room: Room, container: Container, widget: IWidget, delta: number): void {
const widgets = arrayFastClone(this.getContainerWidgets(room, container));
const currentIdx = widgets.findIndex((w) => w.id === widget.id);
if (currentIdx < 0) return; // no change needed
widgets.splice(currentIdx, 1); // remove existing widget
const newIdx = clamp(currentIdx + delta, 0, widgets.length);
widgets.splice(newIdx, 0, widget);
const widths = this.byRoom.get(room.roomId)?.get(container)?.distributions;
const height = this.byRoom.get(room.roomId)?.get(container)?.height;
const localLayout: Record<string, IStoredLayout> = {};
widgets.forEach((w, i) => {
localLayout[w.id] = {
container: container,
width: widths?.[i],
index: i,
height,
};
});
this.updateUserLayout(room, localLayout);
}
public moveToContainer(room: Room, widget: IWidget, toContainer: Container): void {
const allWidgets = this.getAllWidgets(room);
if (!allWidgets.some(([w]) => w.id === widget.id)) return; // invalid
// Prepare other containers (potentially move widgets to obey the following rules)
const newLayout: Record<string, IStoredLayout> = {};
switch (toContainer) {
case Container.Right:
// new "right" widget
break;
case Container.Center:
// new "center" widget => all other widgets go into "right"
for (const w of this.getContainerWidgets(room, Container.Top)) {
newLayout[w.id] = { container: Container.Right };
}
for (const w of this.getContainerWidgets(room, Container.Center)) {
newLayout[w.id] = { container: Container.Right };
}
break;
case Container.Top:
// new "top" widget => the center widget moves into "right"
if (this.hasMaximisedWidget(room)) {
const centerWidget = this.getContainerWidgets(room, Container.Center)[0];
newLayout[centerWidget.id] = { container: Container.Right };
}
break;
}
newLayout[widget.id] = { container: toContainer };
// move widgets into requested containers.
this.updateUserLayout(room, newLayout);
}
public hasMaximisedWidget(room: Room): boolean {
return this.getContainerWidgets(room, Container.Center).length > 0;
}
public hasPinnedWidgets(room: Room): boolean {
return this.getContainerWidgets(room, Container.Top).length > 0;
}
public canCopyLayoutToRoom(room: Room): boolean {
if (!this.matrixClient) return false; // not ready yet
return room.currentState.maySendStateEvent(WIDGET_LAYOUT_EVENT_TYPE, this.matrixClient.getUserId()!);
}
public copyLayoutToRoom(room: Room): void {
const allWidgets = this.getAllWidgets(room);
const evContent: ILayoutStateEvent = { widgets: {} };
for (const [widget, container] of allWidgets) {
evContent.widgets[widget.id] = { container };
if (container === Container.Top) {
const containerWidgets = this.getContainerWidgets(room, container);
const idx = containerWidgets.findIndex((w) => w.id === widget.id);
const widths = this.byRoom.get(room.roomId)?.get(container)?.distributions;
const height = this.byRoom.get(room.roomId)?.get(container)?.height;
evContent.widgets[widget.id] = {
...evContent.widgets[widget.id],
height: height ? Math.round(height) : undefined,
width: widths?.[idx] ? Math.round(widths[idx]) : undefined,
index: idx,
};
}
}
this.matrixClient?.sendStateEvent(room.roomId, WIDGET_LAYOUT_EVENT_TYPE, evContent, "");
}
private getAllWidgets(room: Room): [IApp, Container][] {
const containers = this.byRoom.get(room.roomId);
if (!containers) return [];
const ret: [IApp, Container][] = [];
for (const [container, containerValue] of containers) {
const widgets = containerValue.ordered;
for (const widget of widgets) {
ret.push([widget, container as Container]);
}
}
return ret;
}
private updateUserLayout(room: Room, newLayout: IWidgetLayouts): void {
// Polyfill any missing widgets
const allWidgets = this.getAllWidgets(room);
for (const [widget, container] of allWidgets) {
const containerWidgets = this.getContainerWidgets(room, container);
const idx = containerWidgets.findIndex((w) => w.id === widget.id);
const widths = this.byRoom.get(room.roomId)?.get(container)?.distributions;
if (!newLayout[widget.id]) {
newLayout[widget.id] = {
container: container,
index: idx,
height: this.byRoom.get(room.roomId)?.get(container)?.height,
width: widths?.[idx],
};
}
}
const layoutEv = room.currentState.getStateEvents(WIDGET_LAYOUT_EVENT_TYPE, "");
SettingsStore.setValue("Widgets.layout", room.roomId, SettingLevel.ROOM_ACCOUNT, {
overrides: layoutEv?.getId(),
widgets: newLayout,
}).catch(() => this.recalculateRoom(room));
this.recalculateRoom(room); // call to try local echo on changes (the catch above undoes any errors)
}
}
window.mxWidgetLayoutStore = WidgetLayoutStore.instance;

View File

@@ -0,0 +1,86 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
* Please see LICENSE files in the repository root for full details.
*/
import { ClientWidgetApi, Widget } from "matrix-widget-api";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { ActionPayload } from "../../dispatcher/payloads";
import { EnhancedMap } from "../../utils/maps";
import WidgetUtils from "../../utils/WidgetUtils";
export enum WidgetMessagingStoreEvent {
StoreMessaging = "store_messaging",
StopMessaging = "stop_messaging",
}
/**
* Temporary holding store for widget messaging instances. This is eventually
* going to be merged with a more complete WidgetStore, but for now it's
* easiest to split this into a single place.
*/
export class WidgetMessagingStore extends AsyncStoreWithClient<{}> {
private static readonly internalInstance = (() => {
const instance = new WidgetMessagingStore();
instance.start();
return instance;
})();
private widgetMap = new EnhancedMap<string, ClientWidgetApi>(); // <widget UID, ClientWidgetAPi>
public constructor() {
super(defaultDispatcher);
}
public static get instance(): WidgetMessagingStore {
return WidgetMessagingStore.internalInstance;
}
protected async onAction(payload: ActionPayload): Promise<void> {
// nothing to do
}
protected async onReady(): Promise<any> {
// just in case
this.widgetMap.clear();
}
public storeMessaging(widget: Widget, roomId: string | undefined, widgetApi: ClientWidgetApi): void {
this.stopMessaging(widget, roomId);
const uid = WidgetUtils.calcWidgetUid(widget.id, roomId);
this.widgetMap.set(uid, widgetApi);
this.emit(WidgetMessagingStoreEvent.StoreMessaging, uid, widgetApi);
}
public stopMessaging(widget: Widget, roomId: string | undefined): void {
this.stopMessagingByUid(WidgetUtils.calcWidgetUid(widget.id, roomId));
}
public getMessaging(widget: Widget, roomId: string | undefined): ClientWidgetApi | undefined {
return this.widgetMap.get(WidgetUtils.calcWidgetUid(widget.id, roomId));
}
/**
* Stops the widget messaging instance for a given widget UID.
* @param {string} widgetUid The widget UID.
*/
public stopMessagingByUid(widgetUid: string): void {
this.widgetMap.remove(widgetUid)?.stop();
this.emit(WidgetMessagingStoreEvent.StopMessaging, widgetUid);
}
/**
* Gets the widget messaging class for a given widget UID.
* @param {string} widgetUid The widget UID.
* @returns {ClientWidgetApi} The widget API, or a falsy value if not found.
*/
public getMessagingForUid(widgetUid: string): ClientWidgetApi | undefined {
return this.widgetMap.get(widgetUid);
}
}

View File

@@ -0,0 +1,77 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
* Please see LICENSE files in the repository root for full details.
*/
import { Widget, WidgetKind } from "matrix-widget-api";
import SettingsStore from "../../settings/SettingsStore";
import { SettingLevel } from "../../settings/SettingLevel";
import { SdkContextClass } from "../../contexts/SDKContext";
export enum OIDCState {
Allowed, // user has set the remembered value as allowed
Denied, // user has set the remembered value as disallowed
Unknown, // user has not set a remembered value
}
export class WidgetPermissionStore {
public constructor(private readonly context: SdkContextClass) {}
// TODO (all functions here): Merge widgetKind with the widget definition
private packSettingKey(widget: Widget, kind: WidgetKind, roomId?: string): string {
let location: string | null | undefined = roomId;
if (kind !== WidgetKind.Room) {
location = this.context.client?.getUserId();
}
if (kind === WidgetKind.Modal) {
location = "*MODAL*-" + location; // to guarantee differentiation from whatever spawned it
}
if (!location) {
throw new Error("Failed to determine a location to check the widget's OIDC state with");
}
return encodeURIComponent(`${location}::${widget.templateUrl}`);
}
public getOIDCState(widget: Widget, kind: WidgetKind, roomId?: string): OIDCState {
const settingsKey = this.packSettingKey(widget, kind, roomId);
const settings = SettingsStore.getValue("widgetOpenIDPermissions");
if (settings?.deny?.includes(settingsKey)) {
return OIDCState.Denied;
}
if (settings?.allow?.includes(settingsKey)) {
return OIDCState.Allowed;
}
return OIDCState.Unknown;
}
public setOIDCState(widget: Widget, kind: WidgetKind, roomId: string | undefined, newState: OIDCState): void {
const settingsKey = this.packSettingKey(widget, kind, roomId);
let currentValues = SettingsStore.getValue<{
allow?: string[];
deny?: string[];
}>("widgetOpenIDPermissions");
if (!currentValues) {
currentValues = {};
}
if (!currentValues.allow) currentValues.allow = [];
if (!currentValues.deny) currentValues.deny = [];
if (newState === OIDCState.Allowed) {
currentValues.allow.push(settingsKey);
} else if (newState === OIDCState.Denied) {
currentValues.deny.push(settingsKey);
} else {
currentValues.allow = currentValues.allow.filter((c) => c !== settingsKey);
currentValues.deny = currentValues.deny.filter((c) => c !== settingsKey);
}
SettingsStore.setValue("widgetOpenIDPermissions", null, SettingLevel.DEVICE, currentValues);
}
}

View File

@@ -0,0 +1,56 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
export interface IStoredLayout {
// Where to store the widget. Required.
container: Container;
// The index (order) to position the widgets in. Only applies for
// ordered containers (like the top container). Smaller numbers first,
// and conflicts resolved by comparing widget IDs.
index?: number;
// Percentage (integer) for relative width of the container to consume.
// Clamped to 0-100 and may have minimums imposed upon it. Only applies
// to containers which support inner resizing (currently only the top
// container).
width?: number;
// Percentage (integer) for relative height of the container. Note that
// this only applies to the top container currently, and that container
// will take the highest value among widgets in the container. Clamped
// to 0-100 and may have minimums imposed on it.
height?: number | null;
// TODO: [Deferred] Maximizing (fullscreen) widgets by default.
}
export interface IWidgetLayouts {
[widgetId: string]: IStoredLayout;
}
export interface ILayoutStateEvent {
// TODO: [Deferred] Forced layout (fixed with no changes)
// The widget layouts.
widgets: IWidgetLayouts;
}
export const WIDGET_LAYOUT_EVENT_TYPE = "io.element.widgets.layout";
export enum Container {
// "Top" is the app drawer, and currently the only sensible value.
Top = "top",
// "Right" is the right panel, and the default for widgets. Setting
// this as a container on a widget is essentially like saying "no
// changes needed", though this may change in the future.
Right = "right",
Center = "center",
}