2020-10-02 15:10:00 +00:00
|
|
|
import * as log from 'app/client/lib/log';
|
|
|
|
import {ConnectState, ConnectStateManager} from 'app/client/models/ConnectState';
|
|
|
|
import {delay} from 'app/common/delay';
|
|
|
|
import {isLongerThan} from 'app/common/gutil';
|
|
|
|
import {InactivityTimer} from 'app/common/InactivityTimer';
|
|
|
|
import {timeFormat} from 'app/common/timeFormat';
|
|
|
|
import {bundleChanges, Disposable, Holder, IDisposable, IDisposableOwner } from 'grainjs';
|
|
|
|
import {Computed, dom, DomElementArg, MutableObsArray, obsArray, Observable} from 'grainjs';
|
|
|
|
import clamp = require('lodash/clamp');
|
2021-02-15 21:36:33 +00:00
|
|
|
import defaults = require('lodash/defaults');
|
2021-08-31 18:04:45 +00:00
|
|
|
import {isNarrowScreenObs, testId} from 'app/client/ui2018/cssVars';
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
// When rendering app errors, we'll only show the last few.
|
|
|
|
const maxAppErrors = 5;
|
|
|
|
|
|
|
|
interface INotifier {
|
2021-10-06 15:50:29 +00:00
|
|
|
createUserMessage(message: string, options?: INotifyOptions): INotification;
|
2020-10-02 15:10:00 +00:00
|
|
|
// If you are looking to report errors, please do that via reportError rather
|
|
|
|
// than these methods so that we have a chance to send the error to our logs.
|
|
|
|
createAppError(error: Error): void;
|
|
|
|
|
|
|
|
createProgressIndicator(name: string, size: string, expireOnComplete: boolean): IProgress;
|
|
|
|
createNotification(options: INotifyOptions): INotification;
|
|
|
|
setConnectState(isConnected: boolean): void;
|
|
|
|
slowNotification<T>(promise: Promise<T>, optTimeout?: number): Promise<T>;
|
|
|
|
getFullAppErrors(): IAppError[];
|
|
|
|
}
|
|
|
|
|
2022-10-21 10:55:01 +00:00
|
|
|
export interface INotification extends Expirable {
|
2020-10-02 15:10:00 +00:00
|
|
|
expire(): Promise<void>;
|
|
|
|
}
|
|
|
|
|
|
|
|
export interface IProgress extends Expirable {
|
|
|
|
setProgress(percent: number): void;
|
|
|
|
}
|
|
|
|
|
2022-10-21 10:55:01 +00:00
|
|
|
/**
|
|
|
|
* Custom action to be shown as a notification with a handler.
|
|
|
|
*/
|
|
|
|
export interface CustomAction { label: string, action: () => void }
|
|
|
|
/**
|
|
|
|
* A string, or a function that builds dom.
|
|
|
|
*/
|
|
|
|
export type MessageType = string | (() => DomElementArg);
|
2020-10-02 15:10:00 +00:00
|
|
|
// Identifies supported actions. These are implemented in NotifyUI.
|
2022-10-21 10:55:01 +00:00
|
|
|
export type NotifyAction = 'upgrade' | 'renew' | 'personal' | 'report-problem' | 'ask-for-help' | CustomAction;
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
export interface INotifyOptions {
|
2022-10-21 10:55:01 +00:00
|
|
|
message: MessageType; // A string, or a function that builds dom.
|
2020-10-02 15:10:00 +00:00
|
|
|
timestamp?: number;
|
|
|
|
title?: string;
|
|
|
|
canUserClose?: boolean;
|
|
|
|
inToast?: boolean;
|
|
|
|
inDropdown?: boolean;
|
|
|
|
expireSec?: number;
|
|
|
|
badgeCounter?: boolean;
|
2021-10-01 19:38:58 +00:00
|
|
|
level: 'message' | 'info' | 'success' | 'warning' | 'error';
|
2020-10-02 15:10:00 +00:00
|
|
|
|
2021-02-15 21:36:33 +00:00
|
|
|
memos?: string[]; // A list of relevant notes.
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
// cssToastAction class from NotifyUI will be applied automatically to action elements.
|
|
|
|
actions?: NotifyAction[];
|
|
|
|
|
|
|
|
// When set, the notification will replace any previous notification with the same key.
|
|
|
|
// This way, we can avoid accumulating many of substantially identical notifications.
|
|
|
|
key?: string|null;
|
|
|
|
}
|
|
|
|
|
|
|
|
type Status = 'active' | 'expiring';
|
|
|
|
|
|
|
|
export class Expirable extends Disposable {
|
|
|
|
public static readonly fadeDelay = 250;
|
|
|
|
public readonly status = Observable.create<Status>(this, 'active');
|
|
|
|
|
|
|
|
constructor() {
|
|
|
|
super();
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Sets status to 'expiring', then calls dispose after a short delay.
|
|
|
|
*/
|
|
|
|
public async expire(): Promise<void> {
|
|
|
|
this.status.set('expiring');
|
|
|
|
await delay(Expirable.fadeDelay);
|
|
|
|
if (!this.isDisposed()) {
|
|
|
|
this.dispose();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export class Notification extends Expirable implements INotification {
|
|
|
|
|
|
|
|
public options: Required<INotifyOptions> = {
|
|
|
|
title: '',
|
|
|
|
message: '',
|
|
|
|
timestamp: Date.now(),
|
|
|
|
inDropdown: false,
|
|
|
|
badgeCounter: false,
|
|
|
|
inToast: true,
|
|
|
|
expireSec: 0,
|
|
|
|
canUserClose: false,
|
|
|
|
actions: [],
|
2021-02-15 21:36:33 +00:00
|
|
|
memos: [],
|
2020-10-02 15:10:00 +00:00
|
|
|
key: null,
|
2021-10-01 19:38:58 +00:00
|
|
|
level: 'message'
|
2020-10-02 15:10:00 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
constructor(_opts: INotifyOptions) {
|
|
|
|
super();
|
2021-03-25 23:15:34 +00:00
|
|
|
this.options = defaults({}, _opts, this.options);
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
if (this.options.expireSec > 0) {
|
|
|
|
const expireTimer = setTimeout(() => this.expire(), 1000 * this.options.expireSec);
|
|
|
|
this.onDispose(() => clearTimeout(expireTimer));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
interface IProgressOptions {
|
|
|
|
name: string;
|
|
|
|
size: string;
|
|
|
|
expireOnComplete?: boolean;
|
|
|
|
}
|
|
|
|
|
|
|
|
export class Progress extends Expirable implements IProgress {
|
|
|
|
|
|
|
|
public readonly progress = Observable.create(this, 0);
|
|
|
|
|
|
|
|
constructor(public options: IProgressOptions) {
|
|
|
|
super();
|
|
|
|
|
|
|
|
if (options.expireOnComplete) {
|
|
|
|
this.autoDispose(this.progress.addListener(async progress => {
|
|
|
|
if (progress >= 100) {
|
|
|
|
await this.expire();
|
|
|
|
}
|
|
|
|
}));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* progress should be between 0 and 100.
|
|
|
|
*/
|
|
|
|
public setProgress(progress: number) {
|
|
|
|
this.progress.set(clamp(progress, 0, 100));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Similar to grainjs MultiHolder, but knows when items are disposed externally and releases them
|
|
|
|
* (avoiding the "already disposed" warnings in that case). This is probably how grainjs's
|
|
|
|
* MultiHolder should actually work, and maybe how `Disposable.autoDispose` should generally work.
|
|
|
|
*/
|
|
|
|
export class BetterMultiHolder implements IDisposableOwner {
|
|
|
|
private _items = new Set<IDisposable>();
|
|
|
|
|
|
|
|
public autoDispose<T extends IDisposable>(obj: T): T {
|
|
|
|
this._items.add(obj);
|
|
|
|
if (obj instanceof Disposable) {
|
|
|
|
obj.onDispose(() => this._items.delete(obj));
|
|
|
|
}
|
|
|
|
return obj;
|
|
|
|
}
|
|
|
|
|
|
|
|
public dispose() {
|
|
|
|
for (const item of this._items) {
|
|
|
|
item.dispose();
|
|
|
|
}
|
|
|
|
this._items.clear();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export interface IAppError {
|
|
|
|
error: Error;
|
|
|
|
timestamp: number;
|
|
|
|
seen?: boolean; // If seen, this will be hidden from the "app errors" toast
|
|
|
|
}
|
|
|
|
|
|
|
|
export class Notifier extends Disposable implements INotifier {
|
|
|
|
private _itemsHolder = this.autoDispose(new BetterMultiHolder());
|
|
|
|
|
|
|
|
private _toasts = this.autoDispose(obsArray<Notification>());
|
|
|
|
private _dropdownItems = this.autoDispose(obsArray<Notification>());
|
|
|
|
private _progressItems = this.autoDispose(obsArray<Progress>([]));
|
|
|
|
private _keyedItems = new Map<string, Notification>();
|
|
|
|
|
|
|
|
private _connectStateManager = ConnectStateManager.create(this);
|
|
|
|
private _connectState = this._connectStateManager.connectState;
|
|
|
|
private _disconnectMsg = Computed.create(this, (use) => getDisconnectMessage(use(this._connectState)));
|
|
|
|
|
|
|
|
// Holds recent application errors, which the user may report to us.
|
|
|
|
private _appErrorList = this.autoDispose(obsArray<IAppError>());
|
|
|
|
|
|
|
|
// The dropdown will show all recent errors; the toast only the "new" ones, i.e. those since the
|
|
|
|
// last toast was closed.
|
|
|
|
private _appErrorDropdownItem = Holder.create<INotification>(this);
|
|
|
|
private _appErrorToast = Holder.create<INotification>(this);
|
|
|
|
private _slowNotificationToast = Holder.create<INotification>(this);
|
|
|
|
private _slowNotificationInactivityTimer = new InactivityTimer(() => this._slowNotificationToast.clear(), 0);
|
|
|
|
|
|
|
|
constructor() {
|
|
|
|
super();
|
|
|
|
Computed.create(this, this._disconnectMsg, (use, msg) =>
|
|
|
|
msg ? use.owner.autoDispose(this.createNotification({
|
|
|
|
message: msg.message,
|
|
|
|
title: msg.title,
|
|
|
|
canUserClose: true,
|
|
|
|
inToast: true,
|
2021-10-01 19:38:58 +00:00
|
|
|
level : 'message'
|
2020-10-02 15:10:00 +00:00
|
|
|
})) : null);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Exposes all the state needed for building UI. This is simply to clarify the intended usage:
|
|
|
|
* these members aren't intended to be exposed, except to the UI-building code.
|
|
|
|
*/
|
|
|
|
public getStateForUI() {
|
|
|
|
return {
|
|
|
|
toasts: this._toasts,
|
|
|
|
dropdownItems: this._dropdownItems,
|
|
|
|
progressItems: this._progressItems,
|
|
|
|
connectState: this._connectState,
|
|
|
|
disconnectMsg: this._disconnectMsg,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2021-10-01 19:38:58 +00:00
|
|
|
/**
|
|
|
|
* Creates a basic toast notification. By default, expires in 10 seconds.
|
|
|
|
* Takes an options objects to configure `expireSec` and `canUserClose`.
|
|
|
|
* Set `expireSec` to 0 to prevent expiration.
|
|
|
|
*
|
|
|
|
* Additional option level, can be used to style the notification to like a success, warning,
|
|
|
|
* info or error message.
|
|
|
|
*/
|
2022-10-21 10:55:01 +00:00
|
|
|
public createUserMessage(message: MessageType, options: Partial<INotifyOptions> = {}): INotification {
|
2020-10-02 15:10:00 +00:00
|
|
|
const timestamp = Date.now();
|
|
|
|
if (options.actions && options.actions.includes('ask-for-help')) {
|
|
|
|
// If user should be able to ask for help, add this error to the notifier dropdown too for a
|
|
|
|
// good while, so the user can find it after the toast disappears.
|
|
|
|
this.createNotification({
|
|
|
|
timestamp,
|
|
|
|
message,
|
|
|
|
inToast: false,
|
|
|
|
expireSec: 300,
|
|
|
|
canUserClose: true,
|
2021-10-01 19:38:58 +00:00
|
|
|
level: 'message',
|
2020-10-02 15:10:00 +00:00
|
|
|
inDropdown: true,
|
|
|
|
...options,
|
|
|
|
key: options.key && ("dropdown:" + options.key),
|
|
|
|
});
|
|
|
|
}
|
|
|
|
return this.createNotification({
|
|
|
|
timestamp,
|
|
|
|
message,
|
|
|
|
inToast: true,
|
|
|
|
expireSec: 10,
|
|
|
|
canUserClose: true,
|
|
|
|
inDropdown: false,
|
2021-10-01 19:38:58 +00:00
|
|
|
level: 'message',
|
2020-10-02 15:10:00 +00:00
|
|
|
...options,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* If you are looking to report errors, please do that via reportError so
|
|
|
|
* that we have a chance to send the error to our logs.
|
|
|
|
*/
|
|
|
|
public createAppError(error: Error): void {
|
|
|
|
bundleChanges(() => {
|
|
|
|
// Remove old messages, to keep a max of maxAppErrors.
|
|
|
|
if (this._appErrorList.get().length >= maxAppErrors) {
|
|
|
|
this._appErrorList.splice(0, this._appErrorList.get().length - maxAppErrors + 1);
|
|
|
|
}
|
|
|
|
this._appErrorList.push({error, timestamp: Date.now()});
|
|
|
|
});
|
|
|
|
|
|
|
|
// Create a dropdown item for errors if we don't have one yet.
|
|
|
|
if (this._appErrorDropdownItem.isEmpty()) {
|
|
|
|
this._appErrorDropdownItem.autoDispose(this._createAppErrorItem('dropdown'));
|
|
|
|
}
|
|
|
|
|
|
|
|
// Create a toast for errors if we don't have one yet. When it's closed, mark the items as
|
|
|
|
// "seen" (i.e. not to be shown when the toast pops up again).
|
|
|
|
if (this._appErrorToast.isEmpty()) {
|
|
|
|
const n = this._appErrorToast.autoDispose(this._createAppErrorItem('toast'));
|
|
|
|
n.onDispose(() => this._appErrorList.get().forEach((appErr) => { appErr.seen = true; }));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
public createNotification(opts: INotifyOptions): INotification {
|
|
|
|
const n = Notification.create(this._itemsHolder, opts);
|
|
|
|
this._addNotification(n).catch((e) => { log.warn('_addNotification failed', e); });
|
|
|
|
return n;
|
|
|
|
}
|
|
|
|
|
|
|
|
public createProgressIndicator(name: string, size: string, expireOnComplete = false): IProgress {
|
|
|
|
// Progress objects normally dispose themselves; constructor disposes any leftover items.
|
|
|
|
const p = Progress.create(this._itemsHolder, {name, size, expireOnComplete});
|
|
|
|
this._progressItems.push(p);
|
|
|
|
p.onDispose(() => this.isDisposed() || arrayRemove(this._progressItems, p));
|
|
|
|
return p;
|
|
|
|
}
|
|
|
|
|
|
|
|
public setConnectState(isConnected: boolean): void {
|
|
|
|
this._connectStateManager.setConnected(isConnected);
|
|
|
|
}
|
|
|
|
|
|
|
|
public getFullAppErrors() {
|
|
|
|
return this._appErrorList.get();
|
|
|
|
}
|
|
|
|
|
|
|
|
// This is exposed primarily for tests.
|
|
|
|
public clearAppErrors() {
|
|
|
|
this._appErrorList.splice(0);
|
2021-11-09 10:34:26 +00:00
|
|
|
this._appErrorToast.clear();
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Show a notification when promise takes longer than optTimeout to resolve. Returns the passed in
|
|
|
|
* promise.
|
|
|
|
*/
|
|
|
|
public async slowNotification<T>(promise: Promise<T>, optTimeout: number = 1000): Promise<T> {
|
|
|
|
if (await isLongerThan(promise, optTimeout)) {
|
|
|
|
if (this._slowNotificationToast.isEmpty()) {
|
|
|
|
this._slowNotificationToast.autoDispose(this.createNotification({
|
|
|
|
message: "Still working...",
|
|
|
|
canUserClose: false,
|
|
|
|
inToast: true,
|
2021-10-01 19:38:58 +00:00
|
|
|
level: 'message',
|
2020-10-02 15:10:00 +00:00
|
|
|
}));
|
|
|
|
}
|
|
|
|
await this._slowNotificationInactivityTimer.disableUntilFinish(promise);
|
|
|
|
}
|
|
|
|
return promise;
|
|
|
|
}
|
|
|
|
|
|
|
|
private async _addNotification(n: Notification): Promise<void> {
|
|
|
|
const key = n.options.key;
|
|
|
|
if (key) {
|
|
|
|
const prev = this._keyedItems.get(key);
|
|
|
|
if (prev) {
|
|
|
|
await prev.expire();
|
|
|
|
}
|
|
|
|
this._keyedItems.set(key, n);
|
|
|
|
n.onDispose(() => this.isDisposed() || this._keyedItems.delete(key));
|
|
|
|
}
|
|
|
|
if (n.options.inToast) {
|
|
|
|
this._toasts.push(n);
|
|
|
|
n.onDispose(() => this.isDisposed() || arrayRemove(this._toasts, n));
|
|
|
|
}
|
|
|
|
if (n.options.inDropdown) {
|
|
|
|
this._dropdownItems.push(n);
|
|
|
|
n.onDispose(() => this.isDisposed() || arrayRemove(this._dropdownItems, n));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private _createAppErrorItem(where: 'toast' | 'dropdown') {
|
|
|
|
return this.createNotification({
|
|
|
|
// Building DOM here in NotifyModel seems wrong, but I haven't come up with a better way.
|
2021-08-31 18:04:45 +00:00
|
|
|
message: () => dom.domComputed((use) => {
|
|
|
|
let appErrors = use(this._appErrorList);
|
|
|
|
|
|
|
|
// On narrow screens, only show the most recent error in toasts to conserve space.
|
|
|
|
if (where === 'toast' && use(isNarrowScreenObs())) {
|
|
|
|
appErrors = appErrors.length > 0 ? [appErrors[appErrors.length - 1]] : [];
|
|
|
|
}
|
|
|
|
|
|
|
|
return dom('div',
|
|
|
|
dom.forEach(appErrors, (appErr: IAppError) =>
|
|
|
|
(where === 'toast' && appErr.seen ? null :
|
|
|
|
dom('div', timeFormat('T', new Date(appErr.timestamp)), ' ',
|
|
|
|
appErr.error.message, testId('notification-app-error'))
|
|
|
|
)
|
|
|
|
),
|
|
|
|
testId('notification-app-errors')
|
|
|
|
);
|
|
|
|
}),
|
2020-10-02 15:10:00 +00:00
|
|
|
title: 'Unexpected error',
|
|
|
|
canUserClose: true,
|
|
|
|
inToast: where === 'toast',
|
|
|
|
expireSec: where === 'toast' ? 10 : 0,
|
|
|
|
inDropdown: where === 'dropdown',
|
|
|
|
actions: ['report-problem'],
|
2021-10-01 19:38:58 +00:00
|
|
|
level: 'error',
|
2020-10-02 15:10:00 +00:00
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function arrayRemove<T>(arr: MutableObsArray<T>, elem: T) {
|
|
|
|
const removeIdx = arr.get().findIndex(e => e === elem);
|
|
|
|
if (removeIdx !== -1) {
|
|
|
|
arr.splice(removeIdx, 1);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function getDisconnectMessage(state: ConnectState): {title: string, message: string}|undefined {
|
|
|
|
switch (state) {
|
|
|
|
case ConnectState.RecentlyDisconnected:
|
|
|
|
return {title: 'Connection is lost', message: 'Attempting to reconnect...'};
|
|
|
|
case ConnectState.ReallyDisconnected:
|
|
|
|
return {title: 'Not connected', message: 'The document is in read-only mode until you are back online.'};
|
|
|
|
}
|
|
|
|
}
|