From 14757cacd54258c4a648bb9c8e4d86103b605f56 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 29 Jul 2020 12:43:35 -0600 Subject: [PATCH 01/10] Introduce a concept of "non-urgent" toasts This is somewhat expected to be temporary. --- res/css/_components.scss | 1 + .../structures/_NonUrgentToastContainer.scss | 35 +++++++++++ src/@types/common.ts | 4 ++ src/components/structures/LoggedInView.tsx | 2 + .../structures/NonUrgentToastContainer.tsx | 63 +++++++++++++++++++ src/stores/NonUrgentToastStore.ts | 50 +++++++++++++++ src/stores/ToastStore.ts | 7 ++- 7 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 res/css/structures/_NonUrgentToastContainer.scss create mode 100644 src/components/structures/NonUrgentToastContainer.tsx create mode 100644 src/stores/NonUrgentToastStore.ts diff --git a/res/css/_components.scss b/res/css/_components.scss index 23e4af780a..28e1332d08 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -15,6 +15,7 @@ @import "./structures/_MainSplit.scss"; @import "./structures/_MatrixChat.scss"; @import "./structures/_MyGroups.scss"; +@import "./structures/_NonUrgentToastContainer.scss"; @import "./structures/_NotificationPanel.scss"; @import "./structures/_RightPanel.scss"; @import "./structures/_RoomDirectory.scss"; diff --git a/res/css/structures/_NonUrgentToastContainer.scss b/res/css/structures/_NonUrgentToastContainer.scss new file mode 100644 index 0000000000..26715c6048 --- /dev/null +++ b/res/css/structures/_NonUrgentToastContainer.scss @@ -0,0 +1,35 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_NonUrgentToastContainer { + position: absolute; + bottom: 30px; + left: 28px; + z-index: 101; // same level as other toasts + + .mx_NonUrgentToastContainer_toast { + padding: 10px 12px; + border-radius: 8px; + width: 320px; + font-size: $font-13px; + margin-top: 8px; + + // We don't use variables on the colours because we want it to be the same + // in all themes. + background-color: #17191C; + color: #fff; + } +} diff --git a/src/@types/common.ts b/src/@types/common.ts index a24d47ac9e..b887bd4090 100644 --- a/src/@types/common.ts +++ b/src/@types/common.ts @@ -14,7 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { JSXElementConstructor } from "react"; + // Based on https://stackoverflow.com/a/53229857/3532235 export type Without = {[P in Exclude] ? : never}; export type XOR = (T | U) extends object ? (Without & U) | (Without & T) : T | U; export type Writeable = { -readonly [P in keyof T]: T[P] }; + +export type ComponentClass = keyof JSX.IntrinsicElements | JSXElementConstructor; diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 1f561e68ef..431b7307d8 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -54,6 +54,7 @@ import LeftPanel from "./LeftPanel"; import CallContainer from '../views/voip/CallContainer'; import { ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPayload"; import RoomListStore from "../../stores/room-list/RoomListStore"; +import NonUrgentToastContainer from "./NonUrgentToastContainer"; // We need to fetch each pinned message individually (if we don't already have it) // so each pinned message may trigger a request. Limit the number per room for sanity. @@ -687,6 +688,7 @@ class LoggedInView extends React.Component { + ); } diff --git a/src/components/structures/NonUrgentToastContainer.tsx b/src/components/structures/NonUrgentToastContainer.tsx new file mode 100644 index 0000000000..8d415df4dd --- /dev/null +++ b/src/components/structures/NonUrgentToastContainer.tsx @@ -0,0 +1,63 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import * as React from "react"; +import { ComponentClass } from "../../@types/common"; +import NonUrgentToastStore from "../../stores/NonUrgentToastStore"; +import { UPDATE_EVENT } from "../../stores/AsyncStore"; + +interface IProps { +} + +interface IState { + toasts: ComponentClass[], +} + +export default class NonUrgentToastContainer extends React.PureComponent { + public constructor(props, context) { + super(props, context); + + this.state = { + toasts: NonUrgentToastStore.instance.components, + }; + + NonUrgentToastStore.instance.on(UPDATE_EVENT, this.onUpdateToasts); + } + + public componentWillUnmount() { + NonUrgentToastStore.instance.off(UPDATE_EVENT, this.onUpdateToasts); + } + + private onUpdateToasts = () => { + this.setState({toasts: NonUrgentToastStore.instance.components}); + }; + + public render() { + const toasts = this.state.toasts.map((t, i) => { + return ( +
+ {React.createElement(t, {})} +
+ ); + }); + + return ( +
+ {toasts} +
+ ); + } +} diff --git a/src/stores/NonUrgentToastStore.ts b/src/stores/NonUrgentToastStore.ts new file mode 100644 index 0000000000..72f896749c --- /dev/null +++ b/src/stores/NonUrgentToastStore.ts @@ -0,0 +1,50 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import EventEmitter from "events"; +import { 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(); + + 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) { + this.toasts.delete(ref); + this.emit(UPDATE_EVENT); + } +} diff --git a/src/stores/ToastStore.ts b/src/stores/ToastStore.ts index afb9fe1f8c..038aebc7c9 100644 --- a/src/stores/ToastStore.ts +++ b/src/stores/ToastStore.ts @@ -15,9 +15,10 @@ limitations under the License. */ import EventEmitter from "events"; -import React, {JSXElementConstructor} from "react"; +import React from "react"; +import { ComponentClass } from "../@types/common"; -export interface IToast> { +export interface IToast { key: string; // higher priority number will be shown on top of lower priority priority: number; @@ -55,7 +56,7 @@ export default class ToastStore extends EventEmitter { * * @param {object} newToast The new toast */ - addOrReplaceToast>(newToast: IToast) { + addOrReplaceToast(newToast: IToast) { const oldIndex = this.toasts.findIndex(t => t.key === newToast.key); if (oldIndex === -1) { let newIndex = this.toasts.length; From 75f53e411841703a8b2d1dbb6fac51752ea3aa59 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 29 Jul 2020 16:52:20 -0600 Subject: [PATCH 02/10] Ensure AsyncStoreWithClient can start mid-lifecycle In some cases we're likely to miss the PREPARED sync, so just handle ourselves as ready if we have a client set. --- src/stores/AsyncStoreWithClient.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/stores/AsyncStoreWithClient.ts b/src/stores/AsyncStoreWithClient.ts index 5b9f95f991..4accef6f77 100644 --- a/src/stores/AsyncStoreWithClient.ts +++ b/src/stores/AsyncStoreWithClient.ts @@ -17,12 +17,25 @@ limitations under the License. import { MatrixClient } from "matrix-js-sdk/src/client"; import { AsyncStore } from "./AsyncStore"; import { ActionPayload } from "../dispatcher/payloads"; +import { Dispatcher } from "flux"; +import { MatrixClientPeg } from "../MatrixClientPeg"; export abstract class AsyncStoreWithClient extends AsyncStore { protected matrixClient: MatrixClient; protected abstract async onAction(payload: ActionPayload); + protected constructor(dispatcher: Dispatcher, initialState: T = {}) { + super(dispatcher, initialState); + + if (MatrixClientPeg.get()) { + this.matrixClient = MatrixClientPeg.get(); + + // noinspection JSIgnoredPromiseFromCall + this.onReady(); + } + } + protected async onReady() { // Default implementation is to do nothing. } @@ -40,8 +53,13 @@ export abstract class AsyncStoreWithClient extends AsyncStore< return; } - this.matrixClient = payload.matrixClient; - await this.onReady(); + if (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 === 'on_logged_out') { if (this.matrixClient) { await this.onNotReady(); From 8f1af4be14028e91fdf807d7f01f1b57170d0116 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 29 Jul 2020 16:53:26 -0600 Subject: [PATCH 03/10] Add local echo capabilities for rooms The structure here might need some documentation and work, but overall the idea is that all calls pass through a CachedEcho instance, which are self-updating. --- src/stores/local-echo/CachedEcho.ts | 72 ++++++++++++++++++++ src/stores/local-echo/EchoChamber.ts | 31 +++++++++ src/stores/local-echo/EchoContext.ts | 68 +++++++++++++++++++ src/stores/local-echo/EchoStore.ts | 72 ++++++++++++++++++++ src/stores/local-echo/EchoTransaction.ts | 65 ++++++++++++++++++ src/stores/local-echo/RoomCachedEcho.ts | 77 +++++++++++++++++++++ src/stores/local-echo/RoomEchoContext.ts | 24 +++++++ src/utils/Whenable.ts | 86 ++++++++++++++++++++++++ 8 files changed, 495 insertions(+) create mode 100644 src/stores/local-echo/CachedEcho.ts create mode 100644 src/stores/local-echo/EchoChamber.ts create mode 100644 src/stores/local-echo/EchoContext.ts create mode 100644 src/stores/local-echo/EchoStore.ts create mode 100644 src/stores/local-echo/EchoTransaction.ts create mode 100644 src/stores/local-echo/RoomCachedEcho.ts create mode 100644 src/stores/local-echo/RoomEchoContext.ts create mode 100644 src/utils/Whenable.ts diff --git a/src/stores/local-echo/CachedEcho.ts b/src/stores/local-echo/CachedEcho.ts new file mode 100644 index 0000000000..caa7ad1d48 --- /dev/null +++ b/src/stores/local-echo/CachedEcho.ts @@ -0,0 +1,72 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { EchoContext } from "./EchoContext"; +import { RunFn, TransactionStatus } from "./EchoTransaction"; +import { MatrixClient } from "matrix-js-sdk/src/client"; +import { EventEmitter } from "events"; + +export async function implicitlyReverted() { + // do nothing :D +} + +export const PROPERTY_UPDATED = "property_updated"; + +export abstract class CachedEcho extends EventEmitter { + private cache = new Map(); + protected matrixClient: MatrixClient; + + protected constructor(protected context: C, private lookupFn: (key: K) => V) { + super(); + } + + public setClient(client: MatrixClient) { + const oldClient = this.matrixClient; + this.matrixClient = client; + this.onClientChanged(oldClient, client); + } + + protected abstract onClientChanged(oldClient: MatrixClient, newClient: MatrixClient); + + /** + * 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) : this.lookupFn(key); + } + + private cacheVal(key: K, val: V) { + this.cache.set(key, val); + this.emit(PROPERTY_UPDATED, key); + } + + private decacheKey(key: K) { + this.cache.delete(key); + this.emit(PROPERTY_UPDATED, key); + } + + public setValue(auditName: string, key: K, targetVal: V, runFn: RunFn, revertFn: RunFn) { + this.cacheVal(key, targetVal); // set the cache now as it won't be updated by the .when() ladder below. + this.context.beginTransaction(auditName, runFn) + .when(TransactionStatus.Pending, () => this.cacheVal(key, targetVal)) + .whenAnyOf([TransactionStatus.DoneError, TransactionStatus.DoneSuccess], () => this.decacheKey(key)) + .when(TransactionStatus.DoneError, () => revertFn()); + } +} diff --git a/src/stores/local-echo/EchoChamber.ts b/src/stores/local-echo/EchoChamber.ts new file mode 100644 index 0000000000..4c5109da2d --- /dev/null +++ b/src/stores/local-echo/EchoChamber.ts @@ -0,0 +1,31 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { RoomCachedEcho } from "./RoomCachedEcho"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { EchoStore } from "./EchoStore"; + +/** + * Semantic access to local echo + */ +export class EchoChamber { + private constructor() { + } + + public static forRoom(room: Room): RoomCachedEcho { + return EchoStore.instance.getOrCreateEchoForRoom(room); + } +} diff --git a/src/stores/local-echo/EchoContext.ts b/src/stores/local-echo/EchoContext.ts new file mode 100644 index 0000000000..0d5eb961c3 --- /dev/null +++ b/src/stores/local-echo/EchoContext.ts @@ -0,0 +1,68 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { 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 implements IDestroyable { + private _transactions: EchoTransaction[] = []; + public readonly startTime: Date = new Date(); + + public get transactions(): EchoTransaction[] { + return arrayFastClone(this._transactions); + } + + 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.DoneSuccess, () => txn.destroy()); + + return txn; + } + + private checkTransactions = () => { + let status = ContextTransactionState.AllSuccessful; + for (const txn of this.transactions) { + if (txn.status === TransactionStatus.DoneError) { + status = ContextTransactionState.PendingErrors; + break; + } else if (txn.status === TransactionStatus.Pending) { + status = ContextTransactionState.NotStarted; + // no break as we might hit something which broke + } + } + this.notifyCondition(status); + }; + + public destroy() { + for (const txn of this.transactions) { + txn.destroy(); + } + super.destroy(); + } +} diff --git a/src/stores/local-echo/EchoStore.ts b/src/stores/local-echo/EchoStore.ts new file mode 100644 index 0000000000..80c669e5c6 --- /dev/null +++ b/src/stores/local-echo/EchoStore.ts @@ -0,0 +1,72 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { EventEmitter } from "events"; +import { CachedEcho } from "./CachedEcho"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { RoomCachedEcho } from "./RoomCachedEcho"; +import { RoomEchoContext } from "./RoomEchoContext"; +import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; +import defaultDispatcher from "../../dispatcher/dispatcher"; +import { ActionPayload } from "../../dispatcher/payloads"; + +type ContextKey = string; + +const roomContextKey = (room: Room): ContextKey => `room-${room.roomId}`; + +export class EchoStore extends AsyncStoreWithClient { + private static _instance: EchoStore; + + private caches = new Map>(); + + constructor() { + super(defaultDispatcher); + } + + public static get instance(): EchoStore { + if (!EchoStore._instance) { + EchoStore._instance = new EchoStore(); + } + return EchoStore._instance; + } + + public getOrCreateEchoForRoom(room: Room): RoomCachedEcho { + if (this.caches.has(roomContextKey(room))) { + return this.caches.get(roomContextKey(room)) as RoomCachedEcho; + } + const echo = new RoomCachedEcho(new RoomEchoContext(room)); + echo.setClient(this.matrixClient); + this.caches.set(roomContextKey(room), echo); + return echo; + } + + protected async onReady(): Promise { + for (const echo of this.caches.values()) { + echo.setClient(this.matrixClient); + } + } + + protected async onNotReady(): Promise { + for (const echo of this.caches.values()) { + echo.setClient(null); + } + } + + protected async onAction(payload: ActionPayload): Promise { + // We have nothing to actually listen for + return Promise.resolve(); + } +} diff --git a/src/stores/local-echo/EchoTransaction.ts b/src/stores/local-echo/EchoTransaction.ts new file mode 100644 index 0000000000..b2125aac08 --- /dev/null +++ b/src/stores/local-echo/EchoTransaction.ts @@ -0,0 +1,65 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { Whenable } from "../../utils/Whenable"; + +export type RunFn = () => Promise; + +export enum TransactionStatus { + Pending, + DoneSuccess, + DoneError, +} + +export class EchoTransaction extends Whenable { + private _status = TransactionStatus.Pending; + private didFail = false; + + public constructor( + public readonly auditName, + public runFn: RunFn, + ) { + super(); + } + + public get didPreviouslyFail(): boolean { + return this.didFail; + } + + public get status(): TransactionStatus { + return this._status; + } + + public run() { + if (this.status === TransactionStatus.DoneSuccess) { + throw new Error("Cannot re-run a successful echo transaction"); + } + this.setStatus(TransactionStatus.Pending); + this.runFn() + .then(() => this.setStatus(TransactionStatus.DoneSuccess)) + .catch(() => this.setStatus(TransactionStatus.DoneError)); + } + + private setStatus(status: TransactionStatus) { + this._status = status; + if (status === TransactionStatus.DoneError) { + this.didFail = true; + } else if (status === TransactionStatus.DoneSuccess) { + this.didFail = false; + } + this.notifyCondition(status); + } +} diff --git a/src/stores/local-echo/RoomCachedEcho.ts b/src/stores/local-echo/RoomCachedEcho.ts new file mode 100644 index 0000000000..0aec4a4e1c --- /dev/null +++ b/src/stores/local-echo/RoomCachedEcho.ts @@ -0,0 +1,77 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { CachedEcho, implicitlyReverted, PROPERTY_UPDATED } from "./CachedEcho"; +import { getRoomNotifsState, setRoomNotifsState } from "../../RoomNotifs"; +import { RoomEchoContext } from "./RoomEchoContext"; +import { _t } from "../../languageHandler"; +import { Volume } from "../../RoomNotifsTypes"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; + +export type CachedRoomValues = Volume; + +export enum CachedRoomKey { + NotificationVolume, +} + +export class RoomCachedEcho extends CachedEcho { + private properties = new Map(); + + public constructor(context: RoomEchoContext) { + super(context, (k) => this.properties.get(k)); + } + + protected onClientChanged(oldClient, newClient) { + this.properties.clear(); + if (oldClient) { + oldClient.removeListener("accountData", this.onAccountData); + } + if (newClient) { + // Register the listeners first + newClient.on("accountData", this.onAccountData); + + // Then populate the properties map + this.updateNotificationVolume(); + } + } + + private onAccountData = (event: MatrixEvent) => { + if (event.getType() === "m.push_rules") { + const currentVolume = this.properties.get(CachedRoomKey.NotificationVolume) as Volume; + const newVolume = getRoomNotifsState(this.context.room.roomId) as Volume; + if (currentVolume !== newVolume) { + this.updateNotificationVolume(); + } + } + }; + + private updateNotificationVolume() { + this.properties.set(CachedRoomKey.NotificationVolume, getRoomNotifsState(this.context.room.roomId)); + this.emit(PROPERTY_UPDATED, CachedRoomKey.NotificationVolume); + } + + // ---- helpers below here ---- + + public get notificationVolume(): Volume { + return this.getValue(CachedRoomKey.NotificationVolume); + } + + public set notificationVolume(v: Volume) { + this.setValue(_t("Change notification settings"), CachedRoomKey.NotificationVolume, v, async () => { + setRoomNotifsState(this.context.room.roomId, v); + }, implicitlyReverted); + } +} diff --git a/src/stores/local-echo/RoomEchoContext.ts b/src/stores/local-echo/RoomEchoContext.ts new file mode 100644 index 0000000000..4105f728c3 --- /dev/null +++ b/src/stores/local-echo/RoomEchoContext.ts @@ -0,0 +1,24 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { EchoContext } from "./EchoContext"; +import { Room } from "matrix-js-sdk/src/models/room"; + +export class RoomEchoContext extends EchoContext { + constructor(public readonly room: Room) { + super(); + } +} diff --git a/src/utils/Whenable.ts b/src/utils/Whenable.ts new file mode 100644 index 0000000000..afa220fe82 --- /dev/null +++ b/src/utils/Whenable.ts @@ -0,0 +1,86 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + + +import { IDestroyable } from "./IDestroyable"; +import { arrayFastClone } from "./arrays"; + +export type WhenFn = (w: Whenable) => void; + +/** + * Whenables are a cheap way to have Observable patterns mixed with typical + * usage of Promises, without having to tear down listeners or calls. Whenables + * are intended to be used when a condition will be met multiple times and + * the consumer needs to know *when* that happens. + */ +export abstract class Whenable implements IDestroyable { + private listeners: {condition: T | null, fn: WhenFn}[] = []; + + /** + * Sets up a call to `fn` *when* the `condition` is met. + * @param condition The condition to match. + * @param fn The function to call. + * @returns This. + */ + public when(condition: T, fn: WhenFn): Whenable { + this.listeners.push({condition, fn}); + return this; + } + + /** + * Sets up a fall to `fn` *when* any of the `conditions` are met. + * @param conditions The conditions to match. + * @param fn The function to call. + * @returns This. + */ + public whenAnyOf(conditions: T[], fn: WhenFn): Whenable { + for (const condition of conditions) { + this.when(condition, fn); + } + return this; + } + + /** + * Sets up a call to `fn` *when* any condition is met. + * @param fn The function to call. + * @returns This. + */ + public whenAnything(fn: WhenFn): Whenable { + this.listeners.push({condition: null, fn}); + return this; + } + + /** + * Notifies all the whenables of a given condition. + * @param condition The new condition that has been met. + */ + protected notifyCondition(condition: T) { + const listeners = arrayFastClone(this.listeners); // clone just in case the handler modifies us + for (const listener of listeners) { + if (listener.condition === null || listener.condition === condition) { + try { + listener.fn(this); + } catch (e) { + console.error(`Error calling whenable listener for ${condition}:`, e); + } + } + } + } + + public destroy() { + this.listeners = []; + } +} From 0f1b9937a95b6f7142e99c96b64d513ce8a66fb5 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 29 Jul 2020 16:54:04 -0600 Subject: [PATCH 04/10] Connect RoomTile's notification volume to local echo This presumes success as we don't yet have a UI for failures. --- src/components/views/rooms/RoomTile.tsx | 44 +++++++++++-------------- src/i18n/strings/en_EN.json | 1 + 2 files changed, 21 insertions(+), 24 deletions(-) diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index fc7d3a528b..4b051cf795 100644 --- a/src/components/views/rooms/RoomTile.tsx +++ b/src/components/views/rooms/RoomTile.tsx @@ -17,12 +17,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {createRef} from "react"; +import React, { createRef } from "react"; import { Room } from "matrix-js-sdk/src/models/room"; import classNames from "classnames"; import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton"; import dis from '../../../dispatcher/dispatcher'; +import defaultDispatcher from '../../../dispatcher/dispatcher'; import { Key } from "../../../Keyboard"; import ActiveRoomObserver from "../../../ActiveRoomObserver"; import { _t } from "../../../languageHandler"; @@ -30,31 +31,26 @@ import { ChevronFace, ContextMenu, ContextMenuTooltipButton, - MenuItemRadio, - MenuItemCheckbox, MenuItem, + MenuItemCheckbox, + MenuItemRadio, } from "../../structures/ContextMenu"; import { DefaultTagID, TagID } from "../../../stores/room-list/models"; import { MessagePreviewStore, ROOM_PREVIEW_CHANGED } from "../../../stores/room-list/MessagePreviewStore"; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; -import { - getRoomNotifsState, - setRoomNotifsState, - ALL_MESSAGES, - ALL_MESSAGES_LOUD, - MENTIONS_ONLY, - MUTE, -} from "../../../RoomNotifs"; +import { ALL_MESSAGES, ALL_MESSAGES_LOUD, MENTIONS_ONLY, MUTE, } from "../../../RoomNotifs"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import NotificationBadge from "./NotificationBadge"; import { Volume } from "../../../RoomNotifsTypes"; import RoomListStore from "../../../stores/room-list/RoomListStore"; import RoomListActions from "../../../actions/RoomListActions"; -import defaultDispatcher from "../../../dispatcher/dispatcher"; -import {ActionPayload} from "../../../dispatcher/payloads"; +import { ActionPayload } from "../../../dispatcher/payloads"; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; import { NOTIFICATION_STATE_UPDATE, NotificationState } from "../../../stores/notifications/NotificationState"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; +import { EchoChamber } from "../../../stores/local-echo/EchoChamber"; +import { CachedRoomKey, RoomCachedEcho } from "../../../stores/local-echo/RoomCachedEcho"; +import { PROPERTY_UPDATED } from "../../../stores/local-echo/CachedEcho"; interface IProps { room: Room; @@ -112,6 +108,7 @@ export default class RoomTile extends React.PureComponent { private dispatcherRef: string; private roomTileRef = createRef(); private notificationState: NotificationState; + private roomProps: RoomCachedEcho; constructor(props: IProps) { super(props); @@ -130,12 +127,19 @@ export default class RoomTile extends React.PureComponent { MessagePreviewStore.instance.on(ROOM_PREVIEW_CHANGED, this.onRoomPreviewChanged); this.notificationState = RoomNotificationStateStore.instance.getRoomState(this.props.room); this.notificationState.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate); + this.roomProps = EchoChamber.forRoom(this.props.room); + this.roomProps.on(PROPERTY_UPDATED, this.onRoomPropertyUpdate); } private onNotificationUpdate = () => { this.forceUpdate(); // notification state changed - update }; + private onRoomPropertyUpdate = (property: CachedRoomKey) => { + if (property === CachedRoomKey.NotificationVolume) this.onNotificationUpdate(); + // else ignore - not important for this tile + }; + private get showContextMenu(): boolean { return !this.props.isMinimized && this.props.tag !== DefaultTagID.Invite; } @@ -307,17 +311,9 @@ export default class RoomTile extends React.PureComponent { ev.stopPropagation(); if (MatrixClientPeg.get().isGuest()) return; - // get key before we go async and React discards the nativeEvent - const key = (ev as React.KeyboardEvent).key; - try { - // TODO add local echo - https://github.com/vector-im/riot-web/issues/14280 - await setRoomNotifsState(this.props.room.roomId, newState); - } catch (error) { - // TODO: some form of error notification to the user to inform them that their state change failed. - // See https://github.com/vector-im/riot-web/issues/14281 - console.error(error); - } + this.roomProps.notificationVolume = newState; + const key = (ev as React.KeyboardEvent).key; if (key === Key.ENTER) { // Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12 this.setState({notificationsMenuPosition: null}); // hide the menu @@ -335,7 +331,7 @@ export default class RoomTile extends React.PureComponent { return null; } - const state = getRoomNotifsState(this.props.room.roomId); + const state = this.roomProps.notificationVolume; let contextMenu = null; if (this.state.notificationsMenuPosition) { diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 18e2da9a31..6433285d20 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -441,6 +441,7 @@ "%(senderName)s: %(message)s": "%(senderName)s: %(message)s", "%(senderName)s: %(reaction)s": "%(senderName)s: %(reaction)s", "%(senderName)s: %(stickerName)s": "%(senderName)s: %(stickerName)s", + "Change notification settings": "Change notification settings", "New spinner design": "New spinner design", "Message Pinning": "Message Pinning", "Custom user status messages": "Custom user status messages", From 14b0def14332f723348d66051b9792bc3adf617f Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 29 Jul 2020 19:11:24 -0600 Subject: [PATCH 05/10] Fix echo handling and show a barebones toast on error The EchoTransaction was wrongly assuming that it knew better than the caller for when the success condition was met, so the echo marking has been left an exercise for the caller. In this case, we mark when we finally receive the sync with the updated rules. We also have to cancel previous transactions otherwise if the user mashes buttons we could forever show the toast, and that would be bad. --- .../toasts/NonUrgentEchoFailureToast.tsx | 30 ++++++++++++++ src/i18n/strings/en_EN.json | 1 + src/stores/local-echo/CachedEcho.ts | 39 +++++++++++++------ src/stores/local-echo/EchoContext.ts | 9 ++++- src/stores/local-echo/EchoStore.ts | 33 +++++++++++++++- src/stores/local-echo/EchoTransaction.ts | 5 +++ src/stores/local-echo/RoomCachedEcho.ts | 3 +- 7 files changed, 104 insertions(+), 16 deletions(-) create mode 100644 src/components/views/toasts/NonUrgentEchoFailureToast.tsx diff --git a/src/components/views/toasts/NonUrgentEchoFailureToast.tsx b/src/components/views/toasts/NonUrgentEchoFailureToast.tsx new file mode 100644 index 0000000000..c9a5037045 --- /dev/null +++ b/src/components/views/toasts/NonUrgentEchoFailureToast.tsx @@ -0,0 +1,30 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import { _t } from "../../../languageHandler"; + +export default class NonUrgentEchoFailureToast extends React.PureComponent { + render() { + return ( +
+ {_t("Your server isn't responding to some requests", {}, { + 'a': (sub) => {sub} + })} +
+ ) + } +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 6433285d20..a281834628 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -612,6 +612,7 @@ "Headphones": "Headphones", "Folder": "Folder", "Pin": "Pin", + "Your server isn't responding to some requests": "Your server isn't responding to some requests", "From %(deviceName)s (%(deviceId)s)": "From %(deviceName)s (%(deviceId)s)", "Decline (%(counter)s)": "Decline (%(counter)s)", "Accept to continue:": "Accept to continue:", diff --git a/src/stores/local-echo/CachedEcho.ts b/src/stores/local-echo/CachedEcho.ts index caa7ad1d48..2d1f3d8848 100644 --- a/src/stores/local-echo/CachedEcho.ts +++ b/src/stores/local-echo/CachedEcho.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { EchoContext } from "./EchoContext"; -import { RunFn, TransactionStatus } from "./EchoTransaction"; +import { EchoTransaction, RunFn, TransactionStatus } from "./EchoTransaction"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { EventEmitter } from "events"; @@ -26,10 +26,10 @@ export async function implicitlyReverted() { export const PROPERTY_UPDATED = "property_updated"; export abstract class CachedEcho extends EventEmitter { - private cache = new Map(); + private cache = new Map(); protected matrixClient: MatrixClient; - protected constructor(protected context: C, private lookupFn: (key: K) => V) { + protected constructor(public readonly context: C, private lookupFn: (key: K) => V) { super(); } @@ -49,24 +49,39 @@ export abstract class CachedEcho extends EventEmitt * @returns The value for the key. */ public getValue(key: K): V { - return this.cache.has(key) ? this.cache.get(key) : this.lookupFn(key); + return this.cache.has(key) ? this.cache.get(key).val : this.lookupFn(key); } - private cacheVal(key: K, val: V) { - this.cache.set(key, val); + private cacheVal(key: K, val: V, txn: EchoTransaction) { + this.cache.set(key, {txn, val}); this.emit(PROPERTY_UPDATED, key); } private decacheKey(key: K) { - this.cache.delete(key); - this.emit(PROPERTY_UPDATED, key); + if (this.cache.has(key)) { + this.cache.get(key).txn.cancel(); // should be safe to call + this.cache.delete(key); + this.emit(PROPERTY_UPDATED, key); + } + } + + protected markEchoReceived(key: K) { + this.decacheKey(key); } public setValue(auditName: string, key: K, targetVal: V, runFn: RunFn, revertFn: RunFn) { - this.cacheVal(key, targetVal); // set the cache now as it won't be updated by the .when() ladder below. - this.context.beginTransaction(auditName, runFn) - .when(TransactionStatus.Pending, () => this.cacheVal(key, targetVal)) - .whenAnyOf([TransactionStatus.DoneError, TransactionStatus.DoneSuccess], () => this.decacheKey(key)) + // Cancel any pending transactions for the same key + if (this.cache.has(key)) { + this.cache.get(key).txn.cancel(); + } + + const txn = this.context.beginTransaction(auditName, runFn); + this.cacheVal(key, targetVal, txn); // set the cache now as it won't be updated by the .when() ladder below. + + txn.when(TransactionStatus.Pending, () => this.cacheVal(key, targetVal, txn)) + .when(TransactionStatus.DoneError, () => this.decacheKey(key)) .when(TransactionStatus.DoneError, () => revertFn()); + + txn.run(); } } diff --git a/src/stores/local-echo/EchoContext.ts b/src/stores/local-echo/EchoContext.ts index 0d5eb961c3..ffad76b4a6 100644 --- a/src/stores/local-echo/EchoContext.ts +++ b/src/stores/local-echo/EchoContext.ts @@ -27,12 +27,17 @@ export enum ContextTransactionState { export abstract class EchoContext extends Whenable implements IDestroyable { private _transactions: EchoTransaction[] = []; + private _state = ContextTransactionState.NotStarted; public readonly startTime: Date = new Date(); public get transactions(): EchoTransaction[] { return arrayFastClone(this._transactions); } + public get state(): ContextTransactionState { + return this._state; + } + public beginTransaction(auditName: string, runFn: RunFn): EchoTransaction { const txn = new EchoTransaction(auditName, runFn); this._transactions.push(txn); @@ -48,7 +53,7 @@ export abstract class EchoContext extends Whenable impl private checkTransactions = () => { let status = ContextTransactionState.AllSuccessful; for (const txn of this.transactions) { - if (txn.status === TransactionStatus.DoneError) { + if (txn.status === TransactionStatus.DoneError || txn.didPreviouslyFail) { status = ContextTransactionState.PendingErrors; break; } else if (txn.status === TransactionStatus.Pending) { @@ -56,6 +61,7 @@ export abstract class EchoContext extends Whenable impl // no break as we might hit something which broke } } + this._state = status; this.notifyCondition(status); }; @@ -63,6 +69,7 @@ export abstract class EchoContext extends Whenable impl for (const txn of this.transactions) { txn.destroy(); } + this._transactions = []; super.destroy(); } } diff --git a/src/stores/local-echo/EchoStore.ts b/src/stores/local-echo/EchoStore.ts index 80c669e5c6..8514bff731 100644 --- a/src/stores/local-echo/EchoStore.ts +++ b/src/stores/local-echo/EchoStore.ts @@ -22,12 +22,19 @@ import { RoomEchoContext } from "./RoomEchoContext"; import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; import defaultDispatcher from "../../dispatcher/dispatcher"; import { ActionPayload } from "../../dispatcher/payloads"; +import { ContextTransactionState } 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 { +export class EchoStore extends AsyncStoreWithClient { private static _instance: EchoStore; private caches = new Map>(); @@ -47,13 +54,35 @@ export class EchoStore extends AsyncStoreWithClient { if (this.caches.has(roomContextKey(room))) { return this.caches.get(roomContextKey(room)) as RoomCachedEcho; } - const echo = new RoomCachedEcho(new RoomEchoContext(room)); + + const context = new RoomEchoContext(room); + context.whenAnything(() => this.checkContexts()); + + const echo = new RoomCachedEcho(context); echo.setClient(this.matrixClient); this.caches.set(roomContextKey(room), echo); + return echo; } + private async checkContexts() { + 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 { + if (!this.caches) return; // can only happen during initialization for (const echo of this.caches.values()) { echo.setClient(this.matrixClient); } diff --git a/src/stores/local-echo/EchoTransaction.ts b/src/stores/local-echo/EchoTransaction.ts index b2125aac08..7993a7838b 100644 --- a/src/stores/local-echo/EchoTransaction.ts +++ b/src/stores/local-echo/EchoTransaction.ts @@ -53,6 +53,11 @@ export class EchoTransaction extends Whenable { .catch(() => this.setStatus(TransactionStatus.DoneError)); } + public cancel() { + // Success basically means "done" + this.setStatus(TransactionStatus.DoneSuccess); + } + private setStatus(status: TransactionStatus) { this._status = status; if (status === TransactionStatus.DoneError) { diff --git a/src/stores/local-echo/RoomCachedEcho.ts b/src/stores/local-echo/RoomCachedEcho.ts index 0aec4a4e1c..3ac01d3873 100644 --- a/src/stores/local-echo/RoomCachedEcho.ts +++ b/src/stores/local-echo/RoomCachedEcho.ts @@ -60,6 +60,7 @@ export class RoomCachedEcho extends CachedEcho { - setRoomNotifsState(this.context.room.roomId, v); + return setRoomNotifsState(this.context.room.roomId, v); }, implicitlyReverted); } } From c5574219bba4fbbed4344e0b4d58e9a28139d520 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 29 Jul 2020 20:36:04 -0600 Subject: [PATCH 06/10] Implement dialog for resending local echo transactions --- res/css/_components.scss | 2 + .../views/dialogs/_ServerOfflineDialog.scss | 72 +++++++++++ .../toasts/_NonUrgentEchoFailureToast.scss | 37 ++++++ res/img/element-icons/cloud-off.svg | 3 + .../views/dialogs/ServerOfflineDialog.tsx | 122 ++++++++++++++++++ .../toasts/NonUrgentEchoFailureToast.tsx | 16 ++- src/i18n/strings/en_EN.json | 16 ++- src/stores/local-echo/CachedEcho.ts | 8 +- src/stores/local-echo/EchoContext.ts | 14 +- src/stores/local-echo/EchoStore.ts | 6 +- src/stores/local-echo/EchoTransaction.ts | 2 + 11 files changed, 289 insertions(+), 9 deletions(-) create mode 100644 res/css/views/dialogs/_ServerOfflineDialog.scss create mode 100644 res/css/views/toasts/_NonUrgentEchoFailureToast.scss create mode 100644 res/img/element-icons/cloud-off.svg create mode 100644 src/components/views/dialogs/ServerOfflineDialog.tsx diff --git a/res/css/_components.scss b/res/css/_components.scss index 28e1332d08..fcc87e2061 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -76,6 +76,7 @@ @import "./views/dialogs/_RoomSettingsDialogBridges.scss"; @import "./views/dialogs/_RoomUpgradeDialog.scss"; @import "./views/dialogs/_RoomUpgradeWarningDialog.scss"; +@import "./views/dialogs/_ServerOfflineDialog.scss"; @import "./views/dialogs/_SetEmailDialog.scss"; @import "./views/dialogs/_SetMxIdDialog.scss"; @import "./views/dialogs/_SetPasswordDialog.scss"; @@ -216,6 +217,7 @@ @import "./views/settings/tabs/user/_SecurityUserSettingsTab.scss"; @import "./views/settings/tabs/user/_VoiceUserSettingsTab.scss"; @import "./views/terms/_InlineTermsAgreement.scss"; +@import "./views/toasts/_NonUrgentEchoFailureToast.scss"; @import "./views/verification/_VerificationShowSas.scss"; @import "./views/voip/_CallContainer.scss"; @import "./views/voip/_CallView.scss"; diff --git a/res/css/views/dialogs/_ServerOfflineDialog.scss b/res/css/views/dialogs/_ServerOfflineDialog.scss new file mode 100644 index 0000000000..ae4b70beb3 --- /dev/null +++ b/res/css/views/dialogs/_ServerOfflineDialog.scss @@ -0,0 +1,72 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_ServerOfflineDialog { + .mx_ServerOfflineDialog_content { + padding-right: 85px; + color: $primary-fg-color; + + hr { + border-color: $primary-fg-color; + opacity: 0.1; + border-bottom: none; + } + + ul { + padding: 16px; + + li:nth-child(n + 2) { + margin-top: 16px; + } + } + + .mx_ServerOfflineDialog_content_context { + .mx_ServerOfflineDialog_content_context_timestamp { + display: inline-block; + width: 115px; + color: $muted-fg-color; + line-height: 24px; // same as avatar + vertical-align: top; + } + + .mx_ServerOfflineDialog_content_context_timeline { + display: inline-block; + width: calc(100% - 155px); // 115px timestamp width + 40px right margin + + .mx_ServerOfflineDialog_content_context_timeline_header { + span { + margin-left: 8px; + vertical-align: middle; + } + } + + .mx_ServerOfflineDialog_content_context_txn { + position: relative; + margin-top: 8px; + + .mx_ServerOfflineDialog_content_context_txn_desc { + width: calc(100% - 100px); // 100px is an arbitrary margin for the button + } + + .mx_AccessibleButton { + float: right; + padding: 0; + } + } + } + } + } +} diff --git a/res/css/views/toasts/_NonUrgentEchoFailureToast.scss b/res/css/views/toasts/_NonUrgentEchoFailureToast.scss new file mode 100644 index 0000000000..9a8229b38e --- /dev/null +++ b/res/css/views/toasts/_NonUrgentEchoFailureToast.scss @@ -0,0 +1,37 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_NonUrgentEchoFailureToast { + .mx_NonUrgentEchoFailureToast_icon { + display: inline-block; + width: $font-18px; + height: $font-18px; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + background-color: #fff; // we know that non-urgent toasts are always styled the same + mask-image: url('$(res)/img/element-icons/cloud-off.svg'); + margin-right: 8px; + } + + span { // includes the i18n block + vertical-align: middle; + } + + .mx_AccessibleButton { + padding: 0; + } +} diff --git a/res/img/element-icons/cloud-off.svg b/res/img/element-icons/cloud-off.svg new file mode 100644 index 0000000000..7faea7d3b5 --- /dev/null +++ b/res/img/element-icons/cloud-off.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/views/dialogs/ServerOfflineDialog.tsx b/src/components/views/dialogs/ServerOfflineDialog.tsx new file mode 100644 index 0000000000..074ea19d4d --- /dev/null +++ b/src/components/views/dialogs/ServerOfflineDialog.tsx @@ -0,0 +1,122 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import * as React from 'react'; +import BaseDialog from './BaseDialog'; +import { _t } from '../../../languageHandler'; +import { EchoStore } from "../../../stores/local-echo/EchoStore"; +import { formatTime } from "../../../DateUtils"; +import SettingsStore from "../../../settings/SettingsStore"; +import { RoomEchoContext } from "../../../stores/local-echo/RoomEchoContext"; +import RoomAvatar from "../avatars/RoomAvatar"; +import { TransactionStatus } from "../../../stores/local-echo/EchoTransaction"; +import Spinner from "../elements/Spinner"; +import AccessibleButton from "../elements/AccessibleButton"; +import { UPDATE_EVENT } from "../../../stores/AsyncStore"; + +interface IProps { + onFinished: (bool) => void; +} + +export default class ServerOfflineDialog extends React.PureComponent { + public componentDidMount() { + EchoStore.instance.on(UPDATE_EVENT, this.onEchosUpdated); + } + + public componentWillUnmount() { + EchoStore.instance.off(UPDATE_EVENT, this.onEchosUpdated); + } + + private onEchosUpdated = () => { + this.forceUpdate(); // no state to worry about + }; + + private renderTimeline(): React.ReactElement[] { + return EchoStore.instance.contexts.map((c, i) => { + if (!c.firstFailedTime) return null; // not useful + if (!(c instanceof RoomEchoContext)) throw new Error("Cannot render unknown context: " + c); + const header = ( +
+ + {c.room.name} +
+ ); + const entries = c.transactions + .filter(t => t.status === TransactionStatus.DoneError || t.didPreviouslyFail) + .map((t, j) => { + let button = ; + if (t.status === TransactionStatus.DoneError) { + button = ( + t.run()}>{_t("Resend")} + ); + } + return ( +
+ + {t.auditName} + + {button} +
+ ); + }); + return ( +
+
+ {formatTime(c.firstFailedTime, SettingsStore.getValue("showTwelveHourTimestamps"))} +
+
+ {header} + {entries} +
+
+ ) + }); + } + + public render() { + let timeline = this.renderTimeline().filter(c => !!c); // remove nulls for next check + if (timeline.length === 0) { + timeline = [
{_t("You're all caught up.")}
]; + } + + return +
+

{_t( + "Your server isn't responding to some of your requests for some reason. " + + "Below are some possible reasons why this happened.", + )}

+
    +
  • {_t("The server took too long to respond.")}
  • +
  • {_t("Your firewall or anti-virus is blocking the request.")}
  • +
  • {_t("A browser extension is preventing the request.")}
  • +
  • {_t("The server is offline.")}
  • +
  • {_t("The server has denied your request.")}
  • +
  • {_t("Your area is experiencing difficulties connecting to the internet.")}
  • +
  • {_t("A connection error occurred while trying to contact the server.")}
  • +
  • {_t("The server is not configured to indicate what the problem is (CORS).")}
  • +
+
+

{_t("Recent changes that have not yet been received")}

+ {timeline} +
+
; + } +} diff --git a/src/components/views/toasts/NonUrgentEchoFailureToast.tsx b/src/components/views/toasts/NonUrgentEchoFailureToast.tsx index c9a5037045..76d0328e8b 100644 --- a/src/components/views/toasts/NonUrgentEchoFailureToast.tsx +++ b/src/components/views/toasts/NonUrgentEchoFailureToast.tsx @@ -16,13 +16,23 @@ limitations under the License. import React from "react"; import { _t } from "../../../languageHandler"; +import AccessibleButton from "../elements/AccessibleButton"; +import Modal from "../../../Modal"; +import ServerOfflineDialog from "../dialogs/ServerOfflineDialog"; export default class NonUrgentEchoFailureToast extends React.PureComponent { - render() { + private openDialog = () => { + Modal.createTrackedDialog('Local Echo Server Error', '', ServerOfflineDialog, {}); + }; + + public render() { return (
- {_t("Your server isn't responding to some requests", {}, { - 'a': (sub) => {sub} + + {_t("Your server isn't responding to some requests.", {}, { + 'a': (sub) => ( + {sub} + ), })}
) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index a281834628..d1e940b3be 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -612,7 +612,7 @@ "Headphones": "Headphones", "Folder": "Folder", "Pin": "Pin", - "Your server isn't responding to some requests": "Your server isn't responding to some requests", + "Your server isn't responding to some requests.": "Your server isn't responding to some requests.", "From %(deviceName)s (%(deviceId)s)": "From %(deviceName)s (%(deviceId)s)", "Decline (%(counter)s)": "Decline (%(counter)s)", "Accept to continue:": "Accept to continue:", @@ -1745,6 +1745,19 @@ "Upgrading a room is an advanced action and is usually recommended when a room is unstable due to bugs, missing features or security vulnerabilities.": "Upgrading a room is an advanced action and is usually recommended when a room is unstable due to bugs, missing features or security vulnerabilities.", "This usually only affects how the room is processed on the server. If you're having problems with your %(brand)s, please report a bug.": "This usually only affects how the room is processed on the server. If you're having problems with your %(brand)s, please report a bug.", "You'll upgrade this room from to .": "You'll upgrade this room from to .", + "Resend": "Resend", + "You're all caught up.": "You're all caught up.", + "Server isn't responding": "Server isn't responding", + "Your server isn't responding to some of your requests for some reason. Below are some possible reasons why this happened.": "Your server isn't responding to some of your requests for some reason. Below are some possible reasons why this happened.", + "The server took too long to respond.": "The server took too long to respond.", + "Your firewall or anti-virus is blocking the request.": "Your firewall or anti-virus is blocking the request.", + "A browser extension is preventing the request.": "A browser extension is preventing the request.", + "The server is offline.": "The server is offline.", + "The server has denied your request.": "The server has denied your request.", + "Your area is experiencing difficulties connecting to the internet.": "Your area is experiencing difficulties connecting to the internet.", + "A connection error occurred while trying to contact the server.": "A connection error occurred while trying to contact the server.", + "The server is not configured to indicate what the problem is (CORS).": "The server is not configured to indicate what the problem is (CORS).", + "Recent changes that have not yet been received": "Recent changes that have not yet been received", "Sign out and remove encryption keys?": "Sign out and remove encryption keys?", "Clear Storage and Sign Out": "Clear Storage and Sign Out", "Send Logs": "Send Logs", @@ -1852,7 +1865,6 @@ "Reject invitation": "Reject invitation", "Are you sure you want to reject the invitation?": "Are you sure you want to reject the invitation?", "Unable to reject invite": "Unable to reject invite", - "Resend": "Resend", "Resend edit": "Resend edit", "Resend %(unsentCount)s reaction(s)": "Resend %(unsentCount)s reaction(s)", "Resend removal": "Resend removal", diff --git a/src/stores/local-echo/CachedEcho.ts b/src/stores/local-echo/CachedEcho.ts index 2d1f3d8848..ce89e639c9 100644 --- a/src/stores/local-echo/CachedEcho.ts +++ b/src/stores/local-echo/CachedEcho.ts @@ -59,13 +59,18 @@ export abstract class CachedEcho extends EventEmitt private decacheKey(key: K) { if (this.cache.has(key)) { - this.cache.get(key).txn.cancel(); // should be safe to call + this.context.disownTransaction(this.cache.get(key).txn); this.cache.delete(key); this.emit(PROPERTY_UPDATED, key); } } protected markEchoReceived(key: K) { + if (this.cache.has(key)) { + const txn = this.cache.get(key).txn; + this.context.disownTransaction(txn); + txn.cancel(); + } this.decacheKey(key); } @@ -79,7 +84,6 @@ export abstract class CachedEcho extends EventEmitt this.cacheVal(key, targetVal, txn); // set the cache now as it won't be updated by the .when() ladder below. txn.when(TransactionStatus.Pending, () => this.cacheVal(key, targetVal, txn)) - .when(TransactionStatus.DoneError, () => this.decacheKey(key)) .when(TransactionStatus.DoneError, () => revertFn()); txn.run(); diff --git a/src/stores/local-echo/EchoContext.ts b/src/stores/local-echo/EchoContext.ts index ffad76b4a6..9ed5cf387f 100644 --- a/src/stores/local-echo/EchoContext.ts +++ b/src/stores/local-echo/EchoContext.ts @@ -28,7 +28,6 @@ export enum ContextTransactionState { export abstract class EchoContext extends Whenable implements IDestroyable { private _transactions: EchoTransaction[] = []; private _state = ContextTransactionState.NotStarted; - public readonly startTime: Date = new Date(); public get transactions(): EchoTransaction[] { return arrayFastClone(this._transactions); @@ -38,6 +37,19 @@ export abstract class EchoContext extends Whenable impl return this._state; } + public get firstFailedTime(): Date { + const failedTxn = this.transactions.find(t => t.didPreviouslyFail || t.status === TransactionStatus.DoneError); + if (failedTxn) return failedTxn.startTime; + return null; + } + + public disownTransaction(txn: EchoTransaction) { + 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); diff --git a/src/stores/local-echo/EchoStore.ts b/src/stores/local-echo/EchoStore.ts index 8514bff731..ccb65fd5d7 100644 --- a/src/stores/local-echo/EchoStore.ts +++ b/src/stores/local-echo/EchoStore.ts @@ -22,7 +22,7 @@ import { RoomEchoContext } from "./RoomEchoContext"; import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; import defaultDispatcher from "../../dispatcher/dispatcher"; import { ActionPayload } from "../../dispatcher/payloads"; -import { ContextTransactionState } from "./EchoContext"; +import { ContextTransactionState, EchoContext } from "./EchoContext"; import NonUrgentToastStore, { ToastReference } from "../NonUrgentToastStore"; import NonUrgentEchoFailureToast from "../../components/views/toasts/NonUrgentEchoFailureToast"; @@ -50,6 +50,10 @@ export class EchoStore extends AsyncStoreWithClient { return EchoStore._instance; } + public get contexts(): EchoContext[] { + return Array.from(this.caches.values()).map(e => e.context); + } + public getOrCreateEchoForRoom(room: Room): RoomCachedEcho { if (this.caches.has(roomContextKey(room))) { return this.caches.get(roomContextKey(room)) as RoomCachedEcho; diff --git a/src/stores/local-echo/EchoTransaction.ts b/src/stores/local-echo/EchoTransaction.ts index 7993a7838b..543c3b4ccd 100644 --- a/src/stores/local-echo/EchoTransaction.ts +++ b/src/stores/local-echo/EchoTransaction.ts @@ -28,6 +28,8 @@ export class EchoTransaction extends Whenable { private _status = TransactionStatus.Pending; private didFail = false; + public readonly startTime = new Date(); + public constructor( public readonly auditName, public runFn: RunFn, From e3765ea8c5ea1d6fb01ec0a8715f8acf46f71a3e Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 29 Jul 2020 20:47:32 -0600 Subject: [PATCH 07/10] Appease the linters --- res/css/structures/_NonUrgentToastContainer.scss | 2 +- src/stores/local-echo/EchoStore.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/res/css/structures/_NonUrgentToastContainer.scss b/res/css/structures/_NonUrgentToastContainer.scss index 26715c6048..826a812406 100644 --- a/res/css/structures/_NonUrgentToastContainer.scss +++ b/res/css/structures/_NonUrgentToastContainer.scss @@ -29,7 +29,7 @@ limitations under the License. // We don't use variables on the colours because we want it to be the same // in all themes. - background-color: #17191C; + background-color: #17191c; color: #fff; } } diff --git a/src/stores/local-echo/EchoStore.ts b/src/stores/local-echo/EchoStore.ts index ccb65fd5d7..ef2ee2d275 100644 --- a/src/stores/local-echo/EchoStore.ts +++ b/src/stores/local-echo/EchoStore.ts @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventEmitter } from "events"; import { CachedEcho } from "./CachedEcho"; import { Room } from "matrix-js-sdk/src/models/room"; import { RoomCachedEcho } from "./RoomCachedEcho"; From fca6def58898c1e8eaf067fc609a5a912bdcd96e Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 30 Jul 2020 09:15:19 -0600 Subject: [PATCH 08/10] Rename and document what local echo is --- src/components/views/rooms/RoomTile.tsx | 6 +++--- src/stores/local-echo/EchoChamber.ts | 6 +++--- src/stores/local-echo/EchoStore.ts | 12 ++++++------ .../{CachedEcho.ts => GenericEchoChamber.ts} | 2 +- .../{RoomCachedEcho.ts => RoomEchoChamber.ts} | 4 ++-- 5 files changed, 15 insertions(+), 15 deletions(-) rename src/stores/local-echo/{CachedEcho.ts => GenericEchoChamber.ts} (97%) rename src/stores/local-echo/{RoomCachedEcho.ts => RoomEchoChamber.ts} (92%) diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index 4b051cf795..02aa915fa5 100644 --- a/src/components/views/rooms/RoomTile.tsx +++ b/src/components/views/rooms/RoomTile.tsx @@ -49,8 +49,8 @@ import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNo import { NOTIFICATION_STATE_UPDATE, NotificationState } from "../../../stores/notifications/NotificationState"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import { EchoChamber } from "../../../stores/local-echo/EchoChamber"; -import { CachedRoomKey, RoomCachedEcho } from "../../../stores/local-echo/RoomCachedEcho"; -import { PROPERTY_UPDATED } from "../../../stores/local-echo/CachedEcho"; +import { CachedRoomKey, RoomEchoChamber } from "../../../stores/local-echo/RoomEchoChamber"; +import { PROPERTY_UPDATED } from "../../../stores/local-echo/GenericEchoChamber"; interface IProps { room: Room; @@ -108,7 +108,7 @@ export default class RoomTile extends React.PureComponent { private dispatcherRef: string; private roomTileRef = createRef(); private notificationState: NotificationState; - private roomProps: RoomCachedEcho; + private roomProps: RoomEchoChamber; constructor(props: IProps) { super(props); diff --git a/src/stores/local-echo/EchoChamber.ts b/src/stores/local-echo/EchoChamber.ts index 4c5109da2d..f61e521728 100644 --- a/src/stores/local-echo/EchoChamber.ts +++ b/src/stores/local-echo/EchoChamber.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { RoomCachedEcho } from "./RoomCachedEcho"; +import { RoomEchoChamber } from "./RoomEchoChamber"; import { Room } from "matrix-js-sdk/src/models/room"; import { EchoStore } from "./EchoStore"; @@ -25,7 +25,7 @@ export class EchoChamber { private constructor() { } - public static forRoom(room: Room): RoomCachedEcho { - return EchoStore.instance.getOrCreateEchoForRoom(room); + public static forRoom(room: Room): RoomEchoChamber { + return EchoStore.instance.getOrCreateChamberForRoom(room); } } diff --git a/src/stores/local-echo/EchoStore.ts b/src/stores/local-echo/EchoStore.ts index ef2ee2d275..76e90be45e 100644 --- a/src/stores/local-echo/EchoStore.ts +++ b/src/stores/local-echo/EchoStore.ts @@ -14,9 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { CachedEcho } from "./CachedEcho"; +import { GenericEchoChamber } from "./GenericEchoChamber"; import { Room } from "matrix-js-sdk/src/models/room"; -import { RoomCachedEcho } from "./RoomCachedEcho"; +import { RoomEchoChamber } from "./RoomEchoChamber"; import { RoomEchoContext } from "./RoomEchoContext"; import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; import defaultDispatcher from "../../dispatcher/dispatcher"; @@ -36,7 +36,7 @@ const roomContextKey = (room: Room): ContextKey => `room-${room.roomId}`; export class EchoStore extends AsyncStoreWithClient { private static _instance: EchoStore; - private caches = new Map>(); + private caches = new Map>(); constructor() { super(defaultDispatcher); @@ -53,15 +53,15 @@ export class EchoStore extends AsyncStoreWithClient { return Array.from(this.caches.values()).map(e => e.context); } - public getOrCreateEchoForRoom(room: Room): RoomCachedEcho { + public getOrCreateChamberForRoom(room: Room): RoomEchoChamber { if (this.caches.has(roomContextKey(room))) { - return this.caches.get(roomContextKey(room)) as RoomCachedEcho; + return this.caches.get(roomContextKey(room)) as RoomEchoChamber; } const context = new RoomEchoContext(room); context.whenAnything(() => this.checkContexts()); - const echo = new RoomCachedEcho(context); + const echo = new RoomEchoChamber(context); echo.setClient(this.matrixClient); this.caches.set(roomContextKey(room), echo); diff --git a/src/stores/local-echo/CachedEcho.ts b/src/stores/local-echo/GenericEchoChamber.ts similarity index 97% rename from src/stores/local-echo/CachedEcho.ts rename to src/stores/local-echo/GenericEchoChamber.ts index ce89e639c9..7bedc9bde8 100644 --- a/src/stores/local-echo/CachedEcho.ts +++ b/src/stores/local-echo/GenericEchoChamber.ts @@ -25,7 +25,7 @@ export async function implicitlyReverted() { export const PROPERTY_UPDATED = "property_updated"; -export abstract class CachedEcho extends EventEmitter { +export abstract class GenericEchoChamber extends EventEmitter { private cache = new Map(); protected matrixClient: MatrixClient; diff --git a/src/stores/local-echo/RoomCachedEcho.ts b/src/stores/local-echo/RoomEchoChamber.ts similarity index 92% rename from src/stores/local-echo/RoomCachedEcho.ts rename to src/stores/local-echo/RoomEchoChamber.ts index 3ac01d3873..e113f68c32 100644 --- a/src/stores/local-echo/RoomCachedEcho.ts +++ b/src/stores/local-echo/RoomEchoChamber.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { CachedEcho, implicitlyReverted, PROPERTY_UPDATED } from "./CachedEcho"; +import { GenericEchoChamber, implicitlyReverted, PROPERTY_UPDATED } from "./GenericEchoChamber"; import { getRoomNotifsState, setRoomNotifsState } from "../../RoomNotifs"; import { RoomEchoContext } from "./RoomEchoContext"; import { _t } from "../../languageHandler"; @@ -27,7 +27,7 @@ export enum CachedRoomKey { NotificationVolume, } -export class RoomCachedEcho extends CachedEcho { +export class RoomEchoChamber extends GenericEchoChamber { private properties = new Map(); public constructor(context: RoomEchoContext) { From 3b346161a58932409960761ac2bd59a91d168ac5 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 30 Jul 2020 09:24:38 -0600 Subject: [PATCH 09/10] Actually include the docs for local echo --- docs/local-echo-dev.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 docs/local-echo-dev.md diff --git a/docs/local-echo-dev.md b/docs/local-echo-dev.md new file mode 100644 index 0000000000..e4725a9b07 --- /dev/null +++ b/docs/local-echo-dev.md @@ -0,0 +1,39 @@ +# Local echo (developer docs) + +The React SDK provides some local echo functionality to allow for components to do something +quickly and fall back when it fails. This is all available in the `local-echo` directory within +`stores`. + +Echo is handled in EchoChambers, with `GenericEchoChamber` being the base implementation for all +chambers. The `EchoChamber` class is provided as semantic access to a `GenericEchoChamber` +implementation, such as the `RoomEchoChamber` (which handles echoable details of a room). + +Anything that can be locally echoed will be provided by the `GenericEchoChamber` implementation. +The echo chamber will also need to deal with external changes, and has full control over whether +or not something has successfully been echoed. + +An `EchoContext` is provided to echo chambers (usually with a matching type: `RoomEchoContext` +gets provided to a `RoomEchoChamber` for example) with details about their intended area of +effect, as well as manage `EchoTransaction`s. An `EchoTransaction` is simply a unit of work that +needs to be locally echoed. + +The `EchoStore` manages echo chamber instances, builds contexts, and is generally less semantically +accessible than the `EchoChamber` class. For separation of concerns, and to try and keep things +tidy, this is an intentional design decision. + +**Note**: The local echo stack uses a "whenable" pattern, which is similar to thenables and +`EventEmitter`. Whenables are ways of actioning a changing condition without having to deal +with listeners being torn down. Once the reference count of the Whenable causes garbage collection, +the Whenable's listeners will also be torn down. This is accelerated by the `IDestroyable` interface +usage. + +## Audit functionality + +The UI supports a "Server isn't responding" dialog which includes a partial audit log-like +structure to it. This is partially the reason for added complexity of `EchoTransaction`s +and `EchoContext`s - this information feeds the UI states which then provide direct retry +mechanisms. + +The `EchoStore` is responsible for ensuring that the appropriate non-urgent toast (lower left) +is set up, where the dialog then drives through the contexts and transactions. + From 7522380763605154ace517420000a3f0a2089333 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 30 Jul 2020 10:17:47 -0600 Subject: [PATCH 10/10] Update copy --- src/components/views/dialogs/ServerOfflineDialog.tsx | 8 +++++--- src/i18n/strings/en_EN.json | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/components/views/dialogs/ServerOfflineDialog.tsx b/src/components/views/dialogs/ServerOfflineDialog.tsx index 074ea19d4d..6d2aa5ac27 100644 --- a/src/components/views/dialogs/ServerOfflineDialog.tsx +++ b/src/components/views/dialogs/ServerOfflineDialog.tsx @@ -26,6 +26,7 @@ import { TransactionStatus } from "../../../stores/local-echo/EchoTransaction"; import Spinner from "../elements/Spinner"; import AccessibleButton from "../elements/AccessibleButton"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; interface IProps { onFinished: (bool) => void; @@ -92,6 +93,7 @@ export default class ServerOfflineDialog extends React.PureComponent { timeline = [
{_t("You're all caught up.")}
]; } + const serverName = MatrixClientPeg.getHomeserverName(); return { >

{_t( - "Your server isn't responding to some of your requests for some reason. " + - "Below are some possible reasons why this happened.", + "Your server isn't responding to some of your requests. " + + "Below are some of the most likely reasons.", )}

    -
  • {_t("The server took too long to respond.")}
  • +
  • {_t("The server (%(serverName)s) took too long to respond.", {serverName})}
  • {_t("Your firewall or anti-virus is blocking the request.")}
  • {_t("A browser extension is preventing the request.")}
  • {_t("The server is offline.")}
  • diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index d1e940b3be..c3d4a1c8cc 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1748,8 +1748,8 @@ "Resend": "Resend", "You're all caught up.": "You're all caught up.", "Server isn't responding": "Server isn't responding", - "Your server isn't responding to some of your requests for some reason. Below are some possible reasons why this happened.": "Your server isn't responding to some of your requests for some reason. Below are some possible reasons why this happened.", - "The server took too long to respond.": "The server took too long to respond.", + "Your server isn't responding to some of your requests. Below are some of the most likely reasons.": "Your server isn't responding to some of your requests. Below are some of the most likely reasons.", + "The server (%(serverName)s) took too long to respond.": "The server (%(serverName)s) took too long to respond.", "Your firewall or anti-virus is blocking the request.": "Your firewall or anti-virus is blocking the request.", "A browser extension is preventing the request.": "A browser extension is preventing the request.", "The server is offline.": "The server is offline.",