import {showNewsPopup, showTipPopup} from 'app/client/components/modals'; import {logTelemetryEvent} from 'app/client/lib/telemetry'; import {AppModel} from 'app/client/models/AppModel'; import {getUserPrefObs} from 'app/client/models/UserPrefs'; import {GristBehavioralPrompts} from 'app/client/ui/GristTooltips'; import {isNarrowScreen} from 'app/client/ui2018/cssVars'; import {BehavioralPrompt, BehavioralPromptPrefs} from 'app/common/Prefs'; import {getGristConfig} from 'app/common/urlUtils'; import {Computed, Disposable, dom, Observable} from 'grainjs'; import {IPopupOptions, PopupControl} from 'popweasel'; /** * Options for showing a popup. */ export interface ShowPopupOptions { /** Defaults to `false`. Only applies to "tip" popups. */ hideArrow?: boolean; popupOptions?: IPopupOptions; onDispose?(): void; } /** * Options for attaching a popup to a DOM element. */ export interface AttachPopupOptions extends ShowPopupOptions { /** * Optional callback that should return true if the popup should be disabled. * * If omitted, the popup is enabled. */ isDisabled?(): boolean; } interface QueuedPopup { prompt: BehavioralPrompt; refElement: Element; options: ShowPopupOptions; } /** * Manages popups for product announcements and tips. * * Popups are shown in the order that they are attached, with at most one popup * visible at any point in time. Popups that aren't visible are queued until all * preceding popups have been dismissed. */ export class BehavioralPromptsManager extends Disposable { private _isDisabled: boolean = false; private readonly _prefs = getUserPrefObs(this._appModel.userPrefsObs, 'behavioralPrompts', { defaultValue: { dontShowTips: false, dismissedTips: [] } }) as Observable; private _dismissedPopups: Computed> = Computed.create(this, use => { const {dismissedTips} = use(this._prefs); return new Set(dismissedTips.filter(BehavioralPrompt.guard)); }); private _queuedPopups: QueuedPopup[] = []; private _activePopupCtl: PopupControl; constructor(private _appModel: AppModel) { super(); } public showPopup(refElement: Element, prompt: BehavioralPrompt, options: ShowPopupOptions = {}) { this._queuePopup(refElement, prompt, options); } public attachPopup(prompt: BehavioralPrompt, options: AttachPopupOptions = {}) { return (element: Element) => { if (options.isDisabled?.()) { return; } this._queuePopup(element, prompt, options); }; } public hasSeenPopup(prompt: BehavioralPrompt) { return this._dismissedPopups.get().has(prompt); } public shouldShowPopup(prompt: BehavioralPrompt): boolean { if (this._isDisabled) { return false; } // For non-SaaS flavors of Grist, don't show popups if the Help Center is explicitly // disabled. A separate opt-out feature could be added down the road for more granularity, // but will require communication in advance to avoid disrupting users. const {deploymentType, features} = getGristConfig(); if ( !features?.includes('helpCenter') && // This one is an easter egg, so we make an exception. prompt !== 'rickRow' ) { return false; } const { popupType, audience = 'everyone', deviceType = 'desktop', deploymentTypes, forceShow = false, } = GristBehavioralPrompts[prompt]; if ( (audience === 'anonymous-users' && this._appModel.currentValidUser) || (audience === 'signed-in-users' && !this._appModel.currentValidUser) ) { return false; } if ( deploymentTypes !== 'all' && (!deploymentType || !deploymentTypes.includes(deploymentType)) ) { return false; } const currentDeviceType = isNarrowScreen() ? 'mobile' : 'desktop'; if (deviceType !== 'all' && deviceType !== currentDeviceType) { return false; } return ( forceShow || (popupType === 'news' && !this.hasSeenPopup(prompt)) || (!this._prefs.get().dontShowTips && !this.hasSeenPopup(prompt)) ); } public enable() { this._isDisabled = false; } public disable() { this._isDisabled = true; this._removeQueuedPopups(); this._removeActivePopup(); } public isDisabled() { return this._isDisabled; } public reset() { this._prefs.set({...this._prefs.get(), dismissedTips: [], dontShowTips: false}); this.enable(); } private _queuePopup(refElement: Element, prompt: BehavioralPrompt, options: ShowPopupOptions) { if (!this.shouldShowPopup(prompt)) { return; } this._queuedPopups.push({prompt, refElement, options}); if (this._queuedPopups.length > 1) { // If we're already showing a popup, wait for that one to be dismissed, which will // cause the next one in the queue to be shown. return; } this._showPopup(refElement, prompt, options); } private _showPopup(refElement: Element, prompt: BehavioralPrompt, options: ShowPopupOptions) { const {hideArrow, onDispose, popupOptions} = options; const {popupType, title, content, hideDontShowTips = false, markAsSeen = true} = GristBehavioralPrompts[prompt]; let ctl: PopupControl; if (popupType === 'news') { ctl = showNewsPopup(refElement, title(), content(), { popupOptions, }); ctl.onDispose(() => { if (markAsSeen) { this._markAsSeen(prompt); } }); } else if (popupType === 'tip') { ctl = showTipPopup(refElement, title(), content(), { onClose: (dontShowTips) => { if (dontShowTips) { this._dontShowTips(); } if (markAsSeen) { this._markAsSeen(prompt); } }, hideArrow, popupOptions, hideDontShowTips, }); } else { throw new Error(`BehavioralPromptsManager received unknown popup type: ${popupType}`); } this._activePopupCtl = ctl; ctl.onDispose(() => { onDispose?.(); this._showNextQueuedPopup(); }); const close = () => { if (!ctl.isDisposed()) { ctl.close(); } }; dom.onElem(refElement, 'click', () => close()); dom.onDisposeElem(refElement, () => close()); logTelemetryEvent('viewedTip', {full: {tipName: prompt}}); } private _showNextQueuedPopup() { this._queuedPopups.shift(); if (this._queuedPopups.length !== 0) { const [nextPopup] = this._queuedPopups; const {refElement, prompt, options} = nextPopup; this._showPopup(refElement, prompt, options); } } private _markAsSeen(prompt: BehavioralPrompt) { if (this._isDisabled) { return; } const {dismissedTips} = this._prefs.get(); const newDismissedTips = new Set(dismissedTips); newDismissedTips.add(prompt); this._prefs.set({...this._prefs.get(), dismissedTips: [...newDismissedTips]}); } private _dontShowTips() { if (this._isDisabled) { return; } this._prefs.set({...this._prefs.get(), dontShowTips: true}); this._queuedPopups = this._queuedPopups.filter(({prompt}) => { return GristBehavioralPrompts[prompt].popupType !== 'tip'; }); } private _removeActivePopup() { if (this._activePopupCtl && !this._activePopupCtl.isDisposed()) { this._activePopupCtl.close(); } } private _removeQueuedPopups() { this._queuedPopups = []; } }