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:
committed by
GitHub
parent
2b99496025
commit
c05c429803
35
src/stores/AccountPasswordStore.ts
Normal file
35
src/stores/AccountPasswordStore.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
126
src/stores/ActiveWidgetStore.ts
Normal file
126
src/stores/ActiveWidgetStore.ts
Normal 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
105
src/stores/AsyncStore.ts
Normal 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;
|
||||
}
|
||||
60
src/stores/AsyncStoreWithClient.ts
Normal file
60
src/stores/AsyncStoreWithClient.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
202
src/stores/AutoRageshakeStore.ts
Normal file
202
src/stores/AutoRageshakeStore.ts
Normal 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;
|
||||
190
src/stores/BreadcrumbsStore.ts
Normal file
190
src/stores/BreadcrumbsStore.ts
Normal 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
193
src/stores/CallStore.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
126
src/stores/LifecycleStore.ts
Normal file
126
src/stores/LifecycleStore.ts
Normal 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!;
|
||||
252
src/stores/MemberListStore.ts
Normal file
252
src/stores/MemberListStore.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
100
src/stores/ModalWidgetStore.ts
Normal file
100
src/stores/ModalWidgetStore.ts
Normal 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;
|
||||
43
src/stores/NonUrgentToastStore.ts
Normal file
43
src/stores/NonUrgentToastStore.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
627
src/stores/OwnBeaconStore.ts
Normal file
627
src/stores/OwnBeaconStore.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
177
src/stores/OwnProfileStore.ts
Normal file
177
src/stores/OwnProfileStore.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
}
|
||||
87
src/stores/ReadyWatchingStore.ts
Normal file
87
src/stores/ReadyWatchingStore.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
168
src/stores/ReleaseAnnouncementStore.ts
Normal file
168
src/stores/ReleaseAnnouncementStore.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
48
src/stores/RoomScrollStateStore.ts
Normal file
48
src/stores/RoomScrollStateStore.ts
Normal 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!;
|
||||
846
src/stores/RoomViewStore.tsx
Normal file
846
src/stores/RoomViewStore.tsx
Normal 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 });
|
||||
}
|
||||
}
|
||||
307
src/stores/SetupEncryptionStore.ts
Normal file
307
src/stores/SetupEncryptionStore.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
126
src/stores/ThreepidInviteStore.ts
Normal file
126
src/stores/ThreepidInviteStore.ts
Normal 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
89
src/stores/ToastStore.ts
Normal 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
104
src/stores/TypingStore.ts
Normal 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
100
src/stores/UIStore.ts
Normal 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;
|
||||
198
src/stores/UserProfilesStore.ts
Normal file
198
src/stores/UserProfilesStore.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
99
src/stores/VoiceRecordingStore.ts
Normal file
99
src/stores/VoiceRecordingStore.ts
Normal 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;
|
||||
110
src/stores/WidgetEchoStore.ts
Normal file
110
src/stores/WidgetEchoStore.ts
Normal 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
212
src/stores/WidgetStore.ts
Normal 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;
|
||||
23
src/stores/local-echo/EchoChamber.ts
Normal file
23
src/stores/local-echo/EchoChamber.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
79
src/stores/local-echo/EchoContext.ts
Normal file
79
src/stores/local-echo/EchoContext.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
95
src/stores/local-echo/EchoStore.ts
Normal file
95
src/stores/local-echo/EchoStore.ts
Normal 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> {}
|
||||
}
|
||||
64
src/stores/local-echo/EchoTransaction.ts
Normal file
64
src/stores/local-echo/EchoTransaction.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
89
src/stores/local-echo/GenericEchoChamber.ts
Normal file
89
src/stores/local-echo/GenericEchoChamber.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
76
src/stores/local-echo/RoomEchoChamber.ts
Normal file
76
src/stores/local-echo/RoomEchoChamber.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
17
src/stores/local-echo/RoomEchoContext.ts
Normal file
17
src/stores/local-echo/RoomEchoContext.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
95
src/stores/notifications/ListNotificationState.ts
Normal file
95
src/stores/notifications/ListNotificationState.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
37
src/stores/notifications/NotificationLevel.ts
Normal file
37
src/stores/notifications/NotificationLevel.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
145
src/stores/notifications/NotificationState.ts
Normal file
145
src/stores/notifications/NotificationState.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
112
src/stores/notifications/RoomNotificationState.ts
Normal file
112
src/stores/notifications/RoomNotificationState.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
155
src/stores/notifications/RoomNotificationStateStore.ts
Normal file
155
src/stores/notifications/RoomNotificationStateStore.ts
Normal 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> {}
|
||||
}
|
||||
85
src/stores/notifications/SpaceNotificationState.ts
Normal file
85
src/stores/notifications/SpaceNotificationState.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
29
src/stores/notifications/StaticNotificationState.ts
Normal file
29
src/stores/notifications/StaticNotificationState.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
54
src/stores/notifications/SummarizedNotificationState.ts
Normal file
54
src/stores/notifications/SummarizedNotificationState.ts
Normal 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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
171
src/stores/oidc/OidcClientStore.ts
Normal file
171
src/stores/oidc/OidcClientStore.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
412
src/stores/right-panel/RightPanelStore.ts
Normal file
412
src/stores/right-panel/RightPanelStore.ts
Normal 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;
|
||||
111
src/stores/right-panel/RightPanelStoreIPanelState.ts
Normal file
111
src/stores/right-panel/RightPanelStoreIPanelState.ts
Normal 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 };
|
||||
}
|
||||
51
src/stores/right-panel/RightPanelStorePhases.ts
Normal file
51
src/stores/right-panel/RightPanelStorePhases.ts
Normal 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;
|
||||
}
|
||||
29
src/stores/right-panel/action-handlers/View3pidInvite.ts
Normal file
29
src/stores/right-panel/action-handlers/View3pidInvite.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
9
src/stores/right-panel/action-handlers/index.ts
Normal file
9
src/stores/right-panel/action-handlers/index.ts
Normal 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";
|
||||
111
src/stores/room-list/Interface.ts
Normal file
111
src/stores/room-list/Interface.ts
Normal 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>;
|
||||
}
|
||||
111
src/stores/room-list/ListLayout.ts
Normal file
111
src/stores/room-list/ListLayout.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
289
src/stores/room-list/MessagePreviewStore.ts
Normal file
289
src/stores/room-list/MessagePreviewStore.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
66
src/stores/room-list/RoomListLayoutStore.ts
Normal file
66
src/stores/room-list/RoomListLayoutStore.ts
Normal 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;
|
||||
659
src/stores/room-list/RoomListStore.ts
Normal file
659
src/stores/room-list/RoomListStore.ts
Normal 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;
|
||||
395
src/stores/room-list/SlidingRoomListStore.ts
Normal file
395
src/stores/room-list/SlidingRoomListStore.ts
Normal 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> {}
|
||||
}
|
||||
63
src/stores/room-list/SpaceWatcher.ts
Normal file
63
src/stores/room-list/SpaceWatcher.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
770
src/stores/room-list/algorithms/Algorithm.ts
Normal file
770
src/stores/room-list/algorithms/Algorithm.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
41
src/stores/room-list/algorithms/list-ordering/index.ts
Normal file
41
src/stores/room-list/algorithms/list-ordering/index.ts
Normal 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);
|
||||
}
|
||||
46
src/stores/room-list/algorithms/models.ts
Normal file
46
src/stores/room-list/algorithms/models.ts
Normal 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[];
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
24
src/stores/room-list/algorithms/tag-sorting/IAlgorithm.ts
Normal file
24
src/stores/room-list/algorithms/tag-sorting/IAlgorithm.ts
Normal 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[];
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
122
src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts
Normal file
122
src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
46
src/stores/room-list/algorithms/tag-sorting/index.ts
Normal file
46
src/stores/room-list/algorithms/tag-sorting/index.ts
Normal 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);
|
||||
}
|
||||
34
src/stores/room-list/filters/IFilterCondition.ts
Normal file
34
src/stores/room-list/filters/IFilterCondition.ts
Normal 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;
|
||||
}
|
||||
72
src/stores/room-list/filters/SpaceFilterCondition.ts
Normal file
72
src/stores/room-list/filters/SpaceFilterCondition.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
61
src/stores/room-list/filters/VisibilityProvider.ts
Normal file
61
src/stores/room-list/filters/VisibilityProvider.ts
Normal 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
|
||||
}
|
||||
}
|
||||
42
src/stores/room-list/models.ts
Normal file
42
src/stores/room-list/models.ts
Normal 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",
|
||||
}
|
||||
25
src/stores/room-list/previews/IPreview.ts
Normal file
25
src/stores/room-list/previews/IPreview.ts
Normal 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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
28
src/stores/room-list/previews/LegacyCallHangupEvent.ts
Normal file
28
src/stores/room-list/previews/LegacyCallHangupEvent.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
74
src/stores/room-list/previews/MessageEventPreview.ts
Normal file
74
src/stores/room-list/previews/MessageEventPreview.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
57
src/stores/room-list/previews/PollStartEventPreview.ts
Normal file
57
src/stores/room-list/previews/PollStartEventPreview.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
48
src/stores/room-list/previews/ReactionEventPreview.ts
Normal file
48
src/stores/room-list/previews/ReactionEventPreview.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
27
src/stores/room-list/previews/StickerEventPreview.ts
Normal file
27
src/stores/room-list/previews/StickerEventPreview.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
23
src/stores/room-list/previews/VoiceBroadcastPreview.ts
Normal file
23
src/stores/room-list/previews/VoiceBroadcastPreview.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
33
src/stores/room-list/previews/utils.ts
Normal file
33
src/stores/room-list/previews/utils.ts
Normal 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() ?? "";
|
||||
}
|
||||
46
src/stores/room-list/utils/roomMute.ts
Normal file
46
src/stores/room-list/utils/roomMute.ts
Normal 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];
|
||||
};
|
||||
1418
src/stores/spaces/SpaceStore.ts
Normal file
1418
src/stores/spaces/SpaceStore.ts
Normal file
File diff suppressed because it is too large
Load Diff
40
src/stores/spaces/SpaceTreeLevelLayoutStore.ts
Normal file
40
src/stores/spaces/SpaceTreeLevelLayoutStore.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
67
src/stores/spaces/flattenSpaceHierarchy.ts
Normal file
67
src/stores/spaces/flattenSpaceHierarchy.ts
Normal 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;
|
||||
};
|
||||
59
src/stores/spaces/index.ts
Normal file
59
src/stores/spaces/index.ts
Normal 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
|
||||
);
|
||||
}
|
||||
60
src/stores/widgets/ElementWidgetActions.ts
Normal file
60
src/stores/widgets/ElementWidgetActions.ts
Normal 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
|
||||
};
|
||||
}
|
||||
19
src/stores/widgets/ElementWidgetCapabilities.ts
Normal file
19
src/stores/widgets/ElementWidgetCapabilities.ts
Normal 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",
|
||||
}
|
||||
551
src/stores/widgets/StopGapWidget.ts
Normal file
551
src/stores/widgets/StopGapWidget.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
689
src/stores/widgets/StopGapWidgetDriver.ts
Normal file
689
src/stores/widgets/StopGapWidgetDriver.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
518
src/stores/widgets/WidgetLayoutStore.ts
Normal file
518
src/stores/widgets/WidgetLayoutStore.ts
Normal 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;
|
||||
86
src/stores/widgets/WidgetMessagingStore.ts
Normal file
86
src/stores/widgets/WidgetMessagingStore.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
77
src/stores/widgets/WidgetPermissionStore.ts
Normal file
77
src/stores/widgets/WidgetPermissionStore.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
56
src/stores/widgets/types.ts
Normal file
56
src/stores/widgets/types.ts
Normal 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",
|
||||
}
|
||||
Reference in New Issue
Block a user