Merge pull request #5065 from matrix-org/travis/echo/audit

Add local echo for notifications in the new room list
This commit is contained in:
J. Ryan Stinnett 2020-07-31 12:20:20 +01:00 committed by GitHub
commit af49639bd8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 1105 additions and 30 deletions

39
docs/local-echo-dev.md Normal file
View file

@ -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.

View file

@ -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";

View file

@ -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;
}
}

View file

@ -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;
}
}
}
}
}
}

View file

@ -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;
}
}

View file

@ -0,0 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.53033 0.46967C1.23744 0.176777 0.762563 0.176777 0.46967 0.46967C0.176777 0.762563 0.176777 1.23744 0.46967 1.53033L4.3982 5.45886C3.81109 6.13809 3.38896 7.01315 3.21555 7.99387C1.96379 8.20624 1 9.465 1 10.981C1 12.6455 2.16209 14 3.59014 14H12.9393L16.4697 17.5303C16.7626 17.8232 17.2374 17.8232 17.5303 17.5303C17.8232 17.2374 17.8232 16.7626 17.5303 16.4697L1.53033 0.46967ZM17 10.9817C16.998 11.8303 16.6946 12.5985 16.2081 13.1475L7.07635 4.01569C7.18805 4.00529 7.30083 4 7.41451 4C8.75982 4 9.99711 4.71787 10.8072 5.94503C11.0993 5.85476 11.4011 5.80939 11.7058 5.80939C13.0303 5.80939 14.2138 6.65743 14.8199 8.00337C16.0519 8.23522 17 9.48685 17 10.9817Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 839 B

View file

@ -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<T, U> = {[P in Exclude<keyof T, keyof U>] ? : never};
export type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;
export type Writeable<T> = { -readonly [P in keyof T]: T[P] };
export type ComponentClass = keyof JSX.IntrinsicElements | JSXElementConstructor<any>;

View file

@ -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<IProps, IState> {
</DragDropContext>
</div>
<CallContainer />
<NonUrgentToastContainer />
</MatrixClientContext.Provider>
);
}

View file

@ -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<IProps, IState> {
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 (
<div className="mx_NonUrgentToastContainer_toast" key={`toast-${i}`}>
{React.createElement(t, {})}
</div>
);
});
return (
<div className="mx_NonUrgentToastContainer" role="alert">
{toasts}
</div>
);
}
}

View file

@ -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<IProps> {
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 = (
<div className="mx_ServerOfflineDialog_content_context_timeline_header">
<RoomAvatar width={24} height={24} room={c.room} />
<span>{c.room.name}</span>
</div>
);
const entries = c.transactions
.filter(t => t.status === TransactionStatus.DoneError || t.didPreviouslyFail)
.map((t, j) => {
let button = <Spinner w={19} h={19} />;
if (t.status === TransactionStatus.DoneError) {
button = (
<AccessibleButton kind="link" onClick={() => t.run()}>{_t("Resend")}</AccessibleButton>
);
}
return (
<div className="mx_ServerOfflineDialog_content_context_txn" key={`txn-${j}`}>
<span className="mx_ServerOfflineDialog_content_context_txn_desc">
{t.auditName}
</span>
{button}
</div>
);
});
return (
<div className="mx_ServerOfflineDialog_content_context" key={`context-${i}`}>
<div className="mx_ServerOfflineDialog_content_context_timestamp">
{formatTime(c.firstFailedTime, SettingsStore.getValue("showTwelveHourTimestamps"))}
</div>
<div className="mx_ServerOfflineDialog_content_context_timeline">
{header}
{entries}
</div>
</div>
)
});
}
public render() {
let timeline = this.renderTimeline().filter(c => !!c); // remove nulls for next check
if (timeline.length === 0) {
timeline = [<div key={1}>{_t("You're all caught up.")}</div>];
}
const serverName = MatrixClientPeg.getHomeserverName();
return <BaseDialog title={_t("Server isn't responding")}
className='mx_ServerOfflineDialog'
contentId='mx_Dialog_content'
onFinished={this.props.onFinished}
hasCancel={true}
>
<div className="mx_ServerOfflineDialog_content">
<p>{_t(
"Your server isn't responding to some of your requests. " +
"Below are some of the most likely reasons.",
)}</p>
<ul>
<li>{_t("The server (%(serverName)s) took too long to respond.", {serverName})}</li>
<li>{_t("Your firewall or anti-virus is blocking the request.")}</li>
<li>{_t("A browser extension is preventing the request.")}</li>
<li>{_t("The server is offline.")}</li>
<li>{_t("The server has denied your request.")}</li>
<li>{_t("Your area is experiencing difficulties connecting to the internet.")}</li>
<li>{_t("A connection error occurred while trying to contact the server.")}</li>
<li>{_t("The server is not configured to indicate what the problem is (CORS).")}</li>
</ul>
<hr />
<h2>{_t("Recent changes that have not yet been received")}</h2>
{timeline}
</div>
</BaseDialog>;
}
}

View file

@ -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<IProps, IState> {
private dispatcherRef: string;
private roomTileRef = createRef<HTMLDivElement>();
private notificationState: NotificationState;
private roomProps: RoomEchoChamber;
constructor(props: IProps) {
super(props);
@ -130,12 +127,19 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
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<IProps, IState> {
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<IProps, IState> {
return null;
}
const state = getRoomNotifsState(this.props.room.roomId);
const state = this.roomProps.notificationVolume;
let contextMenu = null;
if (this.state.notificationsMenuPosition) {

View file

@ -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 (
<div className="mx_NonUrgentEchoFailureToast">
<span className="mx_NonUrgentEchoFailureToast_icon" />
{_t("Your server isn't responding to some <a>requests</a>.", {}, {
'a': (sub) => (
<AccessibleButton kind="link" onClick={this.openDialog}>{sub}</AccessibleButton>
),
})}
</div>
)
}
}

View file

@ -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 <a>requests</a>.": "Your server isn't responding to some <a>requests</a>.",
"From %(deviceName)s (%(deviceId)s)": "From %(deviceName)s (%(deviceId)s)",
"Decline (%(counter)s)": "Decline (%(counter)s)",
"Accept <policyLink /> to continue:": "Accept <policyLink /> 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 <a>report a bug</a>.": "This usually only affects how the room is processed on the server. If you're having problems with your %(brand)s, please <a>report a bug</a>.",
"You'll upgrade this room from <oldVersion /> to <newVersion />.": "You'll upgrade this room from <oldVersion /> to <newVersion />.",
"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",

View file

@ -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<T extends Object> extends AsyncStore<T> {
protected matrixClient: MatrixClient;
protected abstract async onAction(payload: ActionPayload);
protected constructor(dispatcher: Dispatcher<ActionPayload>, initialState: T = <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<T extends Object> 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();

View file

@ -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<ToastReference, ComponentClass>();
public static get instance(): NonUrgentToastStore {
if (!NonUrgentToastStore._instance) {
NonUrgentToastStore._instance = new NonUrgentToastStore();
}
return NonUrgentToastStore._instance;
}
public get components(): ComponentClass[] {
return Array.from(this.toasts.values());
}
public addToast(c: ComponentClass): ToastReference {
const ref: ToastReference = Symbol();
this.toasts.set(ref, c);
this.emit(UPDATE_EVENT);
return ref;
}
public removeToast(ref: ToastReference) {
this.toasts.delete(ref);
this.emit(UPDATE_EVENT);
}
}

View file

@ -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<C extends keyof JSX.IntrinsicElements | JSXElementConstructor<any>> {
export interface IToast<C extends ComponentClass> {
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<C extends keyof JSX.IntrinsicElements | JSXElementConstructor<any>>(newToast: IToast<C>) {
addOrReplaceToast<C extends ComponentClass>(newToast: IToast<C>) {
const oldIndex = this.toasts.findIndex(t => t.key === newToast.key);
if (oldIndex === -1) {
let newIndex = this.toasts.length;

View file

@ -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);
}
}

View file

@ -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<ContextTransactionState> implements IDestroyable {
private _transactions: EchoTransaction[] = [];
private _state = ContextTransactionState.NotStarted;
public get transactions(): EchoTransaction[] {
return arrayFastClone(this._transactions);
}
public get state(): ContextTransactionState {
return this._state;
}
public get firstFailedTime(): Date {
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();
}
}

View file

@ -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<IState> {
private static _instance: EchoStore;
private caches = new Map<ContextKey, GenericEchoChamber<any, any, any>>();
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<any> {
if (!this.caches) return; // can only happen during initialization
for (const echo of this.caches.values()) {
echo.setClient(this.matrixClient);
}
}
protected async onNotReady(): Promise<any> {
for (const echo of this.caches.values()) {
echo.setClient(null);
}
}
protected async onAction(payload: ActionPayload): Promise<any> {
// We have nothing to actually listen for
return Promise.resolve();
}
}

View file

@ -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<void>;
export enum TransactionStatus {
Pending,
DoneSuccess,
DoneError,
}
export class EchoTransaction extends Whenable<TransactionStatus> {
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);
}
}

View file

@ -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<C extends EchoContext, K, V> extends EventEmitter {
private cache = new Map<K, {txn: EchoTransaction, val: V}>();
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();
}
}

View file

@ -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<RoomEchoContext, CachedRoomKey, CachedRoomValues> {
private properties = new Map<CachedRoomKey, CachedRoomValues>();
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);
}
}

View file

@ -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();
}
}

86
src/utils/Whenable.ts Normal file
View file

@ -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<T> = (w: Whenable<T>) => 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<T> implements IDestroyable {
private listeners: {condition: T | null, fn: WhenFn<T>}[] = [];
/**
* 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<T>): Whenable<T> {
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<T>): Whenable<T> {
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<T>): Whenable<T> {
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 = [];
}
}