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. + diff --git a/res/css/_components.scss b/res/css/_components.scss index 23e4af780a..fcc87e2061 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"; @@ -75,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"; @@ -215,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/structures/_NonUrgentToastContainer.scss b/res/css/structures/_NonUrgentToastContainer.scss new file mode 100644 index 0000000000..826a812406 --- /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/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/@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 760ea52855..48669a3721 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"; import { ToggleRightPanelPayload } from "../../dispatcher/payloads/ToggleRightPanelPayload"; // We need to fetch each pinned message individually (if we don't already have it) @@ -688,6 +689,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/components/views/dialogs/ServerOfflineDialog.tsx b/src/components/views/dialogs/ServerOfflineDialog.tsx new file mode 100644 index 0000000000..6d2aa5ac27 --- /dev/null +++ b/src/components/views/dialogs/ServerOfflineDialog.tsx @@ -0,0 +1,124 @@ +/* +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"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; + +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.")}
]; + } + + const serverName = MatrixClientPeg.getHomeserverName(); + return +
+

{_t( + "Your server isn't responding to some of your requests. " + + "Below are some of the most likely reasons.", + )}

+
    +
  • {_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.")}
  • +
  • {_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/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index fc7d3a528b..02aa915fa5 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, RoomEchoChamber } from "../../../stores/local-echo/RoomEchoChamber"; +import { PROPERTY_UPDATED } from "../../../stores/local-echo/GenericEchoChamber"; 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: RoomEchoChamber; 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/components/views/toasts/NonUrgentEchoFailureToast.tsx b/src/components/views/toasts/NonUrgentEchoFailureToast.tsx new file mode 100644 index 0000000000..76d0328e8b --- /dev/null +++ b/src/components/views/toasts/NonUrgentEchoFailureToast.tsx @@ -0,0 +1,40 @@ +/* +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"; +import AccessibleButton from "../elements/AccessibleButton"; +import Modal from "../../../Modal"; +import ServerOfflineDialog from "../dialogs/ServerOfflineDialog"; + +export default class NonUrgentEchoFailureToast extends React.PureComponent { + private openDialog = () => { + Modal.createTrackedDialog('Local Echo Server Error', '', ServerOfflineDialog, {}); + }; + + public 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 644cd03daf..7684fcf5aa 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -443,6 +443,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", @@ -613,6 +614,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:", @@ -1745,6 +1747,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. 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.", + "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 +1867,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/AsyncStoreWithClient.ts b/src/stores/AsyncStoreWithClient.ts index f305bcb913..1ed7c6a547 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. } @@ -42,8 +55,14 @@ export abstract class AsyncStoreWithClient extends AsyncStore< if (!(payload.prevState === 'PREPARED' && payload.state !== 'PREPARED')) { 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(); 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; diff --git a/src/stores/local-echo/EchoChamber.ts b/src/stores/local-echo/EchoChamber.ts new file mode 100644 index 0000000000..f61e521728 --- /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 { RoomEchoChamber } from "./RoomEchoChamber"; +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): RoomEchoChamber { + return EchoStore.instance.getOrCreateChamberForRoom(room); + } +} diff --git a/src/stores/local-echo/EchoContext.ts b/src/stores/local-echo/EchoContext.ts new file mode 100644 index 0000000000..9ed5cf387f --- /dev/null +++ b/src/stores/local-echo/EchoContext.ts @@ -0,0 +1,87 @@ +/* +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[] = []; + private _state = ContextTransactionState.NotStarted; + + public get transactions(): EchoTransaction[] { + return arrayFastClone(this._transactions); + } + + public get state(): ContextTransactionState { + 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); + 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 || 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() { + 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 new file mode 100644 index 0000000000..76e90be45e --- /dev/null +++ b/src/stores/local-echo/EchoStore.ts @@ -0,0 +1,104 @@ +/* +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 { GenericEchoChamber } from "./GenericEchoChamber"; +import { Room } from "matrix-js-sdk/src/models/room"; +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 { + 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 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() { + 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); + } + } + + 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..543c3b4ccd --- /dev/null +++ b/src/stores/local-echo/EchoTransaction.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 { 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 readonly startTime = new Date(); + + 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)); + } + + public cancel() { + // Success basically means "done" + this.setStatus(TransactionStatus.DoneSuccess); + } + + 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/GenericEchoChamber.ts b/src/stores/local-echo/GenericEchoChamber.ts new file mode 100644 index 0000000000..7bedc9bde8 --- /dev/null +++ b/src/stores/local-echo/GenericEchoChamber.ts @@ -0,0 +1,91 @@ +/* +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 { EchoTransaction, 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 GenericEchoChamber extends EventEmitter { + private cache = new Map(); + protected matrixClient: MatrixClient; + + protected constructor(public readonly 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).val : this.lookupFn(key); + } + + private cacheVal(key: K, val: V, txn: EchoTransaction) { + this.cache.set(key, {txn, val}); + this.emit(PROPERTY_UPDATED, key); + } + + private decacheKey(key: K) { + 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) { + 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) { + // 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, () => revertFn()); + + txn.run(); + } +} diff --git a/src/stores/local-echo/RoomEchoChamber.ts b/src/stores/local-echo/RoomEchoChamber.ts new file mode 100644 index 0000000000..e113f68c32 --- /dev/null +++ b/src/stores/local-echo/RoomEchoChamber.ts @@ -0,0 +1,78 @@ +/* +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 { GenericEchoChamber, implicitlyReverted, PROPERTY_UPDATED } from "./GenericEchoChamber"; +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 RoomEchoChamber extends GenericEchoChamber { + 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.markEchoReceived(CachedRoomKey.NotificationVolume); + 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 () => { + return 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 = []; + } +}