2024-02-14 21:18:09 +00:00
|
|
|
import {showNewsPopup, showTipPopup} from 'app/client/components/modals';
|
2024-02-13 17:49:00 +00:00
|
|
|
import {logTelemetryEvent} from 'app/client/lib/telemetry';
|
2022-12-20 02:06:39 +00:00
|
|
|
import {AppModel} from 'app/client/models/AppModel';
|
2023-01-13 07:39:33 +00:00
|
|
|
import {getUserPrefObs} from 'app/client/models/UserPrefs';
|
2022-12-20 02:06:39 +00:00
|
|
|
import {GristBehavioralPrompts} from 'app/client/ui/GristTooltips';
|
|
|
|
import {isNarrowScreen} from 'app/client/ui2018/cssVars';
|
2023-01-13 07:39:33 +00:00
|
|
|
import {BehavioralPrompt, BehavioralPromptPrefs} from 'app/common/Prefs';
|
2022-12-20 16:37:11 +00:00
|
|
|
import {getGristConfig} from 'app/common/urlUtils';
|
2023-01-13 07:39:33 +00:00
|
|
|
import {Computed, Disposable, dom, Observable} from 'grainjs';
|
2024-02-14 21:18:09 +00:00
|
|
|
import {IPopupOptions, PopupControl} from 'popweasel';
|
2022-12-20 02:06:39 +00:00
|
|
|
|
2023-09-27 00:40:34 +00:00
|
|
|
/**
|
2024-02-14 21:18:09 +00:00
|
|
|
* Options for showing a popup.
|
2023-09-27 00:40:34 +00:00
|
|
|
*/
|
2024-02-14 21:18:09 +00:00
|
|
|
export interface ShowPopupOptions {
|
|
|
|
/** Defaults to `false`. Only applies to "tip" popups. */
|
2022-12-20 02:06:39 +00:00
|
|
|
hideArrow?: boolean;
|
|
|
|
popupOptions?: IPopupOptions;
|
|
|
|
onDispose?(): void;
|
2023-09-27 00:40:34 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2024-02-14 21:18:09 +00:00
|
|
|
* Options for attaching a popup to a DOM element.
|
2023-09-27 00:40:34 +00:00
|
|
|
*/
|
2024-02-14 21:18:09 +00:00
|
|
|
export interface AttachPopupOptions extends ShowPopupOptions {
|
2023-09-27 00:40:34 +00:00
|
|
|
/**
|
2024-02-14 21:18:09 +00:00
|
|
|
* Optional callback that should return true if the popup should be disabled.
|
2023-09-27 00:40:34 +00:00
|
|
|
*
|
2024-02-14 21:18:09 +00:00
|
|
|
* If omitted, the popup is enabled.
|
2023-09-27 00:40:34 +00:00
|
|
|
*/
|
|
|
|
isDisabled?(): boolean;
|
2022-12-20 02:06:39 +00:00
|
|
|
}
|
|
|
|
|
2024-02-14 21:18:09 +00:00
|
|
|
interface QueuedPopup {
|
2022-12-20 02:06:39 +00:00
|
|
|
prompt: BehavioralPrompt;
|
|
|
|
refElement: Element;
|
2024-02-14 21:18:09 +00:00
|
|
|
options: ShowPopupOptions;
|
2022-12-20 02:06:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2024-02-14 21:18:09 +00:00
|
|
|
* Manages popups for product announcements and tips.
|
2022-12-20 02:06:39 +00:00
|
|
|
*
|
2024-02-14 21:18:09 +00:00
|
|
|
* 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.
|
2022-12-20 02:06:39 +00:00
|
|
|
*/
|
2023-01-13 07:39:33 +00:00
|
|
|
export class BehavioralPromptsManager extends Disposable {
|
2023-03-29 19:52:04 +00:00
|
|
|
private _isDisabled: boolean = false;
|
|
|
|
|
2023-01-13 07:39:33 +00:00
|
|
|
private readonly _prefs = getUserPrefObs(this._appModel.userPrefsObs, 'behavioralPrompts',
|
|
|
|
{ defaultValue: { dontShowTips: false, dismissedTips: [] } }) as Observable<BehavioralPromptPrefs>;
|
|
|
|
|
2024-02-14 21:18:09 +00:00
|
|
|
private _dismissedPopups: Computed<Set<BehavioralPrompt>> = Computed.create(this, use => {
|
2022-12-20 02:06:39 +00:00
|
|
|
const {dismissedTips} = use(this._prefs);
|
|
|
|
return new Set(dismissedTips.filter(BehavioralPrompt.guard));
|
|
|
|
});
|
2023-01-13 07:39:33 +00:00
|
|
|
|
2024-02-14 21:18:09 +00:00
|
|
|
private _queuedPopups: QueuedPopup[] = [];
|
|
|
|
|
|
|
|
private _activePopupCtl: PopupControl<IPopupOptions>;
|
2022-12-20 02:06:39 +00:00
|
|
|
|
|
|
|
constructor(private _appModel: AppModel) {
|
|
|
|
super();
|
|
|
|
}
|
|
|
|
|
2024-02-14 21:18:09 +00:00
|
|
|
public showPopup(refElement: Element, prompt: BehavioralPrompt, options: ShowPopupOptions = {}) {
|
|
|
|
this._queuePopup(refElement, prompt, options);
|
2022-12-20 02:06:39 +00:00
|
|
|
}
|
|
|
|
|
2024-02-14 21:18:09 +00:00
|
|
|
public attachPopup(prompt: BehavioralPrompt, options: AttachPopupOptions = {}) {
|
2022-12-20 02:06:39 +00:00
|
|
|
return (element: Element) => {
|
2023-09-27 00:40:34 +00:00
|
|
|
if (options.isDisabled?.()) { return; }
|
|
|
|
|
2024-02-14 21:18:09 +00:00
|
|
|
this._queuePopup(element, prompt, options);
|
2022-12-20 02:06:39 +00:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2024-02-14 21:18:09 +00:00
|
|
|
public hasSeenPopup(prompt: BehavioralPrompt) {
|
|
|
|
return this._dismissedPopups.get().has(prompt);
|
2022-12-20 02:06:39 +00:00
|
|
|
}
|
|
|
|
|
2024-02-14 21:18:09 +00:00
|
|
|
public shouldShowPopup(prompt: BehavioralPrompt): boolean {
|
2023-09-27 00:40:34 +00:00
|
|
|
if (this._isDisabled) { return false; }
|
|
|
|
|
2024-02-14 21:18:09 +00:00
|
|
|
// For non-SaaS flavors of Grist, don't show popups if the Help Center is explicitly
|
2023-10-30 03:21:28 +00:00
|
|
|
// 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;
|
|
|
|
}
|
|
|
|
|
2023-09-27 00:40:34 +00:00
|
|
|
const {
|
2024-02-14 21:18:09 +00:00
|
|
|
popupType,
|
|
|
|
audience = 'everyone',
|
|
|
|
deviceType = 'desktop',
|
|
|
|
deploymentTypes,
|
2023-09-27 00:40:34 +00:00
|
|
|
forceShow = false,
|
|
|
|
} = GristBehavioralPrompts[prompt];
|
|
|
|
|
|
|
|
if (
|
2024-02-14 21:18:09 +00:00
|
|
|
(audience === 'anonymous-users' && this._appModel.currentValidUser) ||
|
|
|
|
(audience === 'signed-in-users' && !this._appModel.currentValidUser)
|
|
|
|
) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (
|
|
|
|
deploymentTypes !== 'all' &&
|
|
|
|
(!deploymentType || !deploymentTypes.includes(deploymentType))
|
2023-09-27 00:40:34 +00:00
|
|
|
) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2024-02-14 21:18:09 +00:00
|
|
|
const currentDeviceType = isNarrowScreen() ? 'mobile' : 'desktop';
|
|
|
|
if (deviceType !== 'all' && deviceType !== currentDeviceType) { return false; }
|
2023-09-27 00:40:34 +00:00
|
|
|
|
2024-02-14 21:18:09 +00:00
|
|
|
return (
|
|
|
|
forceShow ||
|
|
|
|
(popupType === 'news' && !this.hasSeenPopup(prompt)) ||
|
|
|
|
(!this._prefs.get().dontShowTips && !this.hasSeenPopup(prompt))
|
|
|
|
);
|
2023-09-27 00:40:34 +00:00
|
|
|
}
|
|
|
|
|
2023-03-29 19:52:04 +00:00
|
|
|
public enable() {
|
|
|
|
this._isDisabled = false;
|
|
|
|
}
|
|
|
|
|
|
|
|
public disable() {
|
|
|
|
this._isDisabled = true;
|
2024-02-14 21:18:09 +00:00
|
|
|
this._removeQueuedPopups();
|
|
|
|
this._removeActivePopup();
|
|
|
|
}
|
|
|
|
|
|
|
|
public isDisabled() {
|
|
|
|
return this._isDisabled;
|
2023-03-29 19:52:04 +00:00
|
|
|
}
|
|
|
|
|
2023-04-14 10:09:50 +00:00
|
|
|
public reset() {
|
|
|
|
this._prefs.set({...this._prefs.get(), dismissedTips: [], dontShowTips: false});
|
|
|
|
this.enable();
|
|
|
|
}
|
|
|
|
|
2024-02-14 21:18:09 +00:00
|
|
|
private _queuePopup(refElement: Element, prompt: BehavioralPrompt, options: ShowPopupOptions) {
|
|
|
|
if (!this.shouldShowPopup(prompt)) { return; }
|
2022-12-20 02:06:39 +00:00
|
|
|
|
2024-02-14 21:18:09 +00:00
|
|
|
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
|
2022-12-20 02:06:39 +00:00
|
|
|
// cause the next one in the queue to be shown.
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2024-02-14 21:18:09 +00:00
|
|
|
this._showPopup(refElement, prompt, options);
|
2022-12-20 02:06:39 +00:00
|
|
|
}
|
|
|
|
|
2024-02-14 21:18:09 +00:00
|
|
|
private _showPopup(refElement: Element, prompt: BehavioralPrompt, options: ShowPopupOptions) {
|
2023-09-27 00:40:34 +00:00
|
|
|
const {hideArrow, onDispose, popupOptions} = options;
|
2024-02-14 21:18:09 +00:00
|
|
|
const {popupType, title, content, hideDontShowTips = false, markAsSeen = true} = GristBehavioralPrompts[prompt];
|
|
|
|
let ctl: PopupControl<IPopupOptions>;
|
|
|
|
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}`);
|
|
|
|
}
|
2022-12-20 02:06:39 +00:00
|
|
|
|
2024-02-14 21:18:09 +00:00
|
|
|
this._activePopupCtl = ctl;
|
2022-12-20 02:06:39 +00:00
|
|
|
ctl.onDispose(() => {
|
|
|
|
onDispose?.();
|
2024-02-14 21:18:09 +00:00
|
|
|
this._showNextQueuedPopup();
|
2022-12-20 02:06:39 +00:00
|
|
|
});
|
2024-02-14 21:18:09 +00:00
|
|
|
const close = () => {
|
|
|
|
if (!ctl.isDisposed()) {
|
|
|
|
ctl.close();
|
|
|
|
}
|
|
|
|
};
|
2022-12-20 02:06:39 +00:00
|
|
|
dom.onElem(refElement, 'click', () => close());
|
|
|
|
dom.onDisposeElem(refElement, () => close());
|
2024-02-13 17:49:00 +00:00
|
|
|
|
|
|
|
logTelemetryEvent('viewedTip', {full: {tipName: prompt}});
|
2022-12-20 02:06:39 +00:00
|
|
|
}
|
|
|
|
|
2024-02-14 21:18:09 +00:00
|
|
|
private _showNextQueuedPopup() {
|
|
|
|
this._queuedPopups.shift();
|
|
|
|
if (this._queuedPopups.length !== 0) {
|
|
|
|
const [nextPopup] = this._queuedPopups;
|
|
|
|
const {refElement, prompt, options} = nextPopup;
|
|
|
|
this._showPopup(refElement, prompt, options);
|
2022-12-20 02:06:39 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private _markAsSeen(prompt: BehavioralPrompt) {
|
2024-02-14 21:18:09 +00:00
|
|
|
if (this._isDisabled) { return; }
|
|
|
|
|
2022-12-20 02:06:39 +00:00
|
|
|
const {dismissedTips} = this._prefs.get();
|
|
|
|
const newDismissedTips = new Set(dismissedTips);
|
|
|
|
newDismissedTips.add(prompt);
|
|
|
|
this._prefs.set({...this._prefs.get(), dismissedTips: [...newDismissedTips]});
|
|
|
|
}
|
|
|
|
|
|
|
|
private _dontShowTips() {
|
2024-02-14 21:18:09 +00:00
|
|
|
if (this._isDisabled) { return; }
|
|
|
|
|
2022-12-20 02:06:39 +00:00
|
|
|
this._prefs.set({...this._prefs.get(), dontShowTips: true});
|
2024-02-14 21:18:09 +00:00
|
|
|
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 = [];
|
2022-12-20 02:06:39 +00:00
|
|
|
}
|
|
|
|
}
|