diff --git a/res/css/_components.scss b/res/css/_components.scss index 28e1332d08..fcc87e2061 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -76,6 +76,7 @@ @import "./views/dialogs/_RoomSettingsDialogBridges.scss"; @import "./views/dialogs/_RoomUpgradeDialog.scss"; @import "./views/dialogs/_RoomUpgradeWarningDialog.scss"; +@import "./views/dialogs/_ServerOfflineDialog.scss"; @import "./views/dialogs/_SetEmailDialog.scss"; @import "./views/dialogs/_SetMxIdDialog.scss"; @import "./views/dialogs/_SetPasswordDialog.scss"; @@ -216,6 +217,7 @@ @import "./views/settings/tabs/user/_SecurityUserSettingsTab.scss"; @import "./views/settings/tabs/user/_VoiceUserSettingsTab.scss"; @import "./views/terms/_InlineTermsAgreement.scss"; +@import "./views/toasts/_NonUrgentEchoFailureToast.scss"; @import "./views/verification/_VerificationShowSas.scss"; @import "./views/voip/_CallContainer.scss"; @import "./views/voip/_CallView.scss"; diff --git a/res/css/views/dialogs/_ServerOfflineDialog.scss b/res/css/views/dialogs/_ServerOfflineDialog.scss new file mode 100644 index 0000000000..ae4b70beb3 --- /dev/null +++ b/res/css/views/dialogs/_ServerOfflineDialog.scss @@ -0,0 +1,72 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_ServerOfflineDialog { + .mx_ServerOfflineDialog_content { + padding-right: 85px; + color: $primary-fg-color; + + hr { + border-color: $primary-fg-color; + opacity: 0.1; + border-bottom: none; + } + + ul { + padding: 16px; + + li:nth-child(n + 2) { + margin-top: 16px; + } + } + + .mx_ServerOfflineDialog_content_context { + .mx_ServerOfflineDialog_content_context_timestamp { + display: inline-block; + width: 115px; + color: $muted-fg-color; + line-height: 24px; // same as avatar + vertical-align: top; + } + + .mx_ServerOfflineDialog_content_context_timeline { + display: inline-block; + width: calc(100% - 155px); // 115px timestamp width + 40px right margin + + .mx_ServerOfflineDialog_content_context_timeline_header { + span { + margin-left: 8px; + vertical-align: middle; + } + } + + .mx_ServerOfflineDialog_content_context_txn { + position: relative; + margin-top: 8px; + + .mx_ServerOfflineDialog_content_context_txn_desc { + width: calc(100% - 100px); // 100px is an arbitrary margin for the button + } + + .mx_AccessibleButton { + float: right; + padding: 0; + } + } + } + } + } +} diff --git a/res/css/views/toasts/_NonUrgentEchoFailureToast.scss b/res/css/views/toasts/_NonUrgentEchoFailureToast.scss new file mode 100644 index 0000000000..9a8229b38e --- /dev/null +++ b/res/css/views/toasts/_NonUrgentEchoFailureToast.scss @@ -0,0 +1,37 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_NonUrgentEchoFailureToast { + .mx_NonUrgentEchoFailureToast_icon { + display: inline-block; + width: $font-18px; + height: $font-18px; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + background-color: #fff; // we know that non-urgent toasts are always styled the same + mask-image: url('$(res)/img/element-icons/cloud-off.svg'); + margin-right: 8px; + } + + span { // includes the i18n block + vertical-align: middle; + } + + .mx_AccessibleButton { + padding: 0; + } +} diff --git a/res/img/element-icons/cloud-off.svg b/res/img/element-icons/cloud-off.svg new file mode 100644 index 0000000000..7faea7d3b5 --- /dev/null +++ b/res/img/element-icons/cloud-off.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/views/dialogs/ServerOfflineDialog.tsx b/src/components/views/dialogs/ServerOfflineDialog.tsx new file mode 100644 index 0000000000..074ea19d4d --- /dev/null +++ b/src/components/views/dialogs/ServerOfflineDialog.tsx @@ -0,0 +1,122 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import * as React from 'react'; +import BaseDialog from './BaseDialog'; +import { _t } from '../../../languageHandler'; +import { EchoStore } from "../../../stores/local-echo/EchoStore"; +import { formatTime } from "../../../DateUtils"; +import SettingsStore from "../../../settings/SettingsStore"; +import { RoomEchoContext } from "../../../stores/local-echo/RoomEchoContext"; +import RoomAvatar from "../avatars/RoomAvatar"; +import { TransactionStatus } from "../../../stores/local-echo/EchoTransaction"; +import Spinner from "../elements/Spinner"; +import AccessibleButton from "../elements/AccessibleButton"; +import { UPDATE_EVENT } from "../../../stores/AsyncStore"; + +interface IProps { + onFinished: (bool) => void; +} + +export default class ServerOfflineDialog extends React.PureComponent { + public componentDidMount() { + EchoStore.instance.on(UPDATE_EVENT, this.onEchosUpdated); + } + + public componentWillUnmount() { + EchoStore.instance.off(UPDATE_EVENT, this.onEchosUpdated); + } + + private onEchosUpdated = () => { + this.forceUpdate(); // no state to worry about + }; + + private renderTimeline(): React.ReactElement[] { + return EchoStore.instance.contexts.map((c, i) => { + if (!c.firstFailedTime) return null; // not useful + if (!(c instanceof RoomEchoContext)) throw new Error("Cannot render unknown context: " + c); + const header = ( +
+ + {c.room.name} +
+ ); + const entries = c.transactions + .filter(t => t.status === TransactionStatus.DoneError || t.didPreviouslyFail) + .map((t, j) => { + let button = ; + if (t.status === TransactionStatus.DoneError) { + button = ( + t.run()}>{_t("Resend")} + ); + } + return ( +
+ + {t.auditName} + + {button} +
+ ); + }); + return ( +
+
+ {formatTime(c.firstFailedTime, SettingsStore.getValue("showTwelveHourTimestamps"))} +
+
+ {header} + {entries} +
+
+ ) + }); + } + + public render() { + let timeline = this.renderTimeline().filter(c => !!c); // remove nulls for next check + if (timeline.length === 0) { + timeline = [
{_t("You're all caught up.")}
]; + } + + return +
+

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

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

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

+ {timeline} +
+
; + } +} diff --git a/src/components/views/toasts/NonUrgentEchoFailureToast.tsx b/src/components/views/toasts/NonUrgentEchoFailureToast.tsx index c9a5037045..76d0328e8b 100644 --- a/src/components/views/toasts/NonUrgentEchoFailureToast.tsx +++ b/src/components/views/toasts/NonUrgentEchoFailureToast.tsx @@ -16,13 +16,23 @@ limitations under the License. import React from "react"; import { _t } from "../../../languageHandler"; +import AccessibleButton from "../elements/AccessibleButton"; +import Modal from "../../../Modal"; +import ServerOfflineDialog from "../dialogs/ServerOfflineDialog"; export default class NonUrgentEchoFailureToast extends React.PureComponent { - render() { + private openDialog = () => { + Modal.createTrackedDialog('Local Echo Server Error', '', ServerOfflineDialog, {}); + }; + + public render() { return (
- {_t("Your server isn't responding to some requests", {}, { - 'a': (sub) => {sub} + + {_t("Your server isn't responding to some requests.", {}, { + 'a': (sub) => ( + {sub} + ), })}
) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index a281834628..d1e940b3be 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -612,7 +612,7 @@ "Headphones": "Headphones", "Folder": "Folder", "Pin": "Pin", - "Your server isn't responding to some requests": "Your server isn't responding to some requests", + "Your server isn't responding to some requests.": "Your server isn't responding to some requests.", "From %(deviceName)s (%(deviceId)s)": "From %(deviceName)s (%(deviceId)s)", "Decline (%(counter)s)": "Decline (%(counter)s)", "Accept to continue:": "Accept to continue:", @@ -1745,6 +1745,19 @@ "Upgrading a room is an advanced action and is usually recommended when a room is unstable due to bugs, missing features or security vulnerabilities.": "Upgrading a room is an advanced action and is usually recommended when a room is unstable due to bugs, missing features or security vulnerabilities.", "This usually only affects how the room is processed on the server. If you're having problems with your %(brand)s, please report a bug.": "This usually only affects how the room is processed on the server. If you're having problems with your %(brand)s, please report a bug.", "You'll upgrade this room from to .": "You'll upgrade this room from to .", + "Resend": "Resend", + "You're all caught up.": "You're all caught up.", + "Server isn't responding": "Server isn't responding", + "Your server isn't responding to some of your requests for some reason. Below are some possible reasons why this happened.": "Your server isn't responding to some of your requests for some reason. Below are some possible reasons why this happened.", + "The server took too long to respond.": "The server took too long to respond.", + "Your firewall or anti-virus is blocking the request.": "Your firewall or anti-virus is blocking the request.", + "A browser extension is preventing the request.": "A browser extension is preventing the request.", + "The server is offline.": "The server is offline.", + "The server has denied your request.": "The server has denied your request.", + "Your area is experiencing difficulties connecting to the internet.": "Your area is experiencing difficulties connecting to the internet.", + "A connection error occurred while trying to contact the server.": "A connection error occurred while trying to contact the server.", + "The server is not configured to indicate what the problem is (CORS).": "The server is not configured to indicate what the problem is (CORS).", + "Recent changes that have not yet been received": "Recent changes that have not yet been received", "Sign out and remove encryption keys?": "Sign out and remove encryption keys?", "Clear Storage and Sign Out": "Clear Storage and Sign Out", "Send Logs": "Send Logs", @@ -1852,7 +1865,6 @@ "Reject invitation": "Reject invitation", "Are you sure you want to reject the invitation?": "Are you sure you want to reject the invitation?", "Unable to reject invite": "Unable to reject invite", - "Resend": "Resend", "Resend edit": "Resend edit", "Resend %(unsentCount)s reaction(s)": "Resend %(unsentCount)s reaction(s)", "Resend removal": "Resend removal", diff --git a/src/stores/local-echo/CachedEcho.ts b/src/stores/local-echo/CachedEcho.ts index 2d1f3d8848..ce89e639c9 100644 --- a/src/stores/local-echo/CachedEcho.ts +++ b/src/stores/local-echo/CachedEcho.ts @@ -59,13 +59,18 @@ export abstract class CachedEcho extends EventEmitt private decacheKey(key: K) { if (this.cache.has(key)) { - this.cache.get(key).txn.cancel(); // should be safe to call + this.context.disownTransaction(this.cache.get(key).txn); this.cache.delete(key); this.emit(PROPERTY_UPDATED, key); } } protected markEchoReceived(key: K) { + if (this.cache.has(key)) { + const txn = this.cache.get(key).txn; + this.context.disownTransaction(txn); + txn.cancel(); + } this.decacheKey(key); } @@ -79,7 +84,6 @@ export abstract class CachedEcho extends EventEmitt this.cacheVal(key, targetVal, txn); // set the cache now as it won't be updated by the .when() ladder below. txn.when(TransactionStatus.Pending, () => this.cacheVal(key, targetVal, txn)) - .when(TransactionStatus.DoneError, () => this.decacheKey(key)) .when(TransactionStatus.DoneError, () => revertFn()); txn.run(); diff --git a/src/stores/local-echo/EchoContext.ts b/src/stores/local-echo/EchoContext.ts index ffad76b4a6..9ed5cf387f 100644 --- a/src/stores/local-echo/EchoContext.ts +++ b/src/stores/local-echo/EchoContext.ts @@ -28,7 +28,6 @@ export enum ContextTransactionState { export abstract class EchoContext extends Whenable implements IDestroyable { private _transactions: EchoTransaction[] = []; private _state = ContextTransactionState.NotStarted; - public readonly startTime: Date = new Date(); public get transactions(): EchoTransaction[] { return arrayFastClone(this._transactions); @@ -38,6 +37,19 @@ export abstract class EchoContext extends Whenable impl return this._state; } + public get firstFailedTime(): Date { + const failedTxn = this.transactions.find(t => t.didPreviouslyFail || t.status === TransactionStatus.DoneError); + if (failedTxn) return failedTxn.startTime; + return null; + } + + public disownTransaction(txn: EchoTransaction) { + const idx = this._transactions.indexOf(txn); + if (idx >= 0) this._transactions.splice(idx, 1); + txn.destroy(); + this.checkTransactions(); + } + public beginTransaction(auditName: string, runFn: RunFn): EchoTransaction { const txn = new EchoTransaction(auditName, runFn); this._transactions.push(txn); diff --git a/src/stores/local-echo/EchoStore.ts b/src/stores/local-echo/EchoStore.ts index 8514bff731..ccb65fd5d7 100644 --- a/src/stores/local-echo/EchoStore.ts +++ b/src/stores/local-echo/EchoStore.ts @@ -22,7 +22,7 @@ import { RoomEchoContext } from "./RoomEchoContext"; import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; import defaultDispatcher from "../../dispatcher/dispatcher"; import { ActionPayload } from "../../dispatcher/payloads"; -import { ContextTransactionState } from "./EchoContext"; +import { ContextTransactionState, EchoContext } from "./EchoContext"; import NonUrgentToastStore, { ToastReference } from "../NonUrgentToastStore"; import NonUrgentEchoFailureToast from "../../components/views/toasts/NonUrgentEchoFailureToast"; @@ -50,6 +50,10 @@ export class EchoStore extends AsyncStoreWithClient { return EchoStore._instance; } + public get contexts(): EchoContext[] { + return Array.from(this.caches.values()).map(e => e.context); + } + public getOrCreateEchoForRoom(room: Room): RoomCachedEcho { if (this.caches.has(roomContextKey(room))) { return this.caches.get(roomContextKey(room)) as RoomCachedEcho; diff --git a/src/stores/local-echo/EchoTransaction.ts b/src/stores/local-echo/EchoTransaction.ts index 7993a7838b..543c3b4ccd 100644 --- a/src/stores/local-echo/EchoTransaction.ts +++ b/src/stores/local-echo/EchoTransaction.ts @@ -28,6 +28,8 @@ export class EchoTransaction extends Whenable { private _status = TransactionStatus.Pending; private didFail = false; + public readonly startTime = new Date(); + public constructor( public readonly auditName, public runFn: RunFn,