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'); import defaults = require('lodash/defaults'); import {isNarrowScreenObs, testId} from 'app/client/ui2018/cssVars'; // When rendering app errors, we'll only show the last few. const maxAppErrors = 5; interface INotifier { // 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. createUserError(message: string, options?: INotifyOptions): INotification; createUserMessage(message: string, options?: INotifyOptions): INotification; createAppError(error: Error): void; createProgressIndicator(name: string, size: string, expireOnComplete: boolean): IProgress; createNotification(options: INotifyOptions): INotification; setConnectState(isConnected: boolean): void; slowNotification(promise: Promise, optTimeout?: number): Promise; getFullAppErrors(): IAppError[]; } interface INotification extends Expirable { expire(): Promise; } export interface IProgress extends Expirable { setProgress(percent: number): void; } // Identifies supported actions. These are implemented in NotifyUI. export type NotifyAction = 'upgrade' | 'renew' | 'personal' | 'report-problem' | 'ask-for-help'; export interface INotifyOptions { message: string | (() => DomElementArg); // A string, or a function that builds dom. timestamp?: number; title?: string; canUserClose?: boolean; inToast?: boolean; inDropdown?: boolean; expireSec?: number; badgeCounter?: boolean; level: 'message' | 'info' | 'success' | 'warning' | 'error'; memos?: string[]; // A list of relevant notes. // 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(this, 'active'); constructor() { super(); } /** * Sets status to 'expiring', then calls dispose after a short delay. */ public async expire(): Promise { this.status.set('expiring'); await delay(Expirable.fadeDelay); if (!this.isDisposed()) { this.dispose(); } } } export class Notification extends Expirable implements INotification { public options: Required = { title: '', message: '', timestamp: Date.now(), inDropdown: false, badgeCounter: false, inToast: true, expireSec: 0, canUserClose: false, actions: [], memos: [], key: null, level: 'message' }; constructor(_opts: INotifyOptions) { super(); this.options = defaults({}, _opts, this.options); 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(); public autoDispose(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()); private _dropdownItems = this.autoDispose(obsArray()); private _progressItems = this.autoDispose(obsArray([])); private _keyedItems = new Map(); 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()); // 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(this); private _appErrorToast = Holder.create(this); private _slowNotificationToast = Holder.create(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, level : 'message' })) : 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, }; } /** * Creates a basic toast user error. By default, expires in 10 seconds. * Takes an options objects to configure `expireSec` and `canUserClose`. * Set `expireSec` to 0 to prevent expiration. * * 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 createUserError(message: string, options: Partial = {}): INotification { return this.createUserMessage(message, { level: 'error', ...options }); } /** * 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. */ public createUserMessage(message: string, options: Partial = {}): INotification { 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, level: 'message', inDropdown: true, ...options, key: options.key && ("dropdown:" + options.key), }); } return this.createNotification({ timestamp, message, inToast: true, expireSec: 10, canUserClose: true, inDropdown: false, level: 'message', ...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); } /** * Show a notification when promise takes longer than optTimeout to resolve. Returns the passed in * promise. */ public async slowNotification(promise: Promise, optTimeout: number = 1000): Promise { if (await isLongerThan(promise, optTimeout)) { if (this._slowNotificationToast.isEmpty()) { this._slowNotificationToast.autoDispose(this.createNotification({ message: "Still working...", canUserClose: false, inToast: true, level: 'message', })); } await this._slowNotificationInactivityTimer.disableUntilFinish(promise); } return promise; } private async _addNotification(n: Notification): Promise { 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. 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') ); }), title: 'Unexpected error', canUserClose: true, inToast: where === 'toast', expireSec: where === 'toast' ? 10 : 0, inDropdown: where === 'dropdown', actions: ['report-problem'], level: 'error', }); } } function arrayRemove(arr: MutableObsArray, 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.'}; } }