From 35237a58359e4bd705d07814c4eb0497d95a5ac8 Mon Sep 17 00:00:00 2001 From: George Gevoian Date: Tue, 4 Jul 2023 17:21:34 -0400 Subject: [PATCH] (core) Add Support Grist page and nudge Summary: Adds a new Support Grist page (accessible only in grist-core), containing options to opt in to telemetry and sponsor Grist Labs on GitHub. A nudge is also shown in the doc menu, which can be collapsed or permanently dismissed. Test Plan: Browser and server tests. Reviewers: paulfitz, dsagal Reviewed By: paulfitz Subscribers: jarek, dsagal Differential Revision: https://phab.getgrist.com/D3926 --- app/client/accountMain.ts | 5 - app/client/activationMain.ts | 5 - .../components/BehavioralPromptsManager.ts | 38 +- app/client/lib/imports.d.ts | 6 + app/client/lib/imports.js | 3 + app/client/models/AppModel.ts | 33 +- app/client/models/TelemetryModel.ts | 32 ++ app/client/models/gristUrlState.ts | 10 +- app/client/ui/AccountWidget.ts | 29 +- app/client/ui/AppUI.ts | 8 +- app/client/ui/DocMenu.ts | 9 +- app/client/ui/DocTutorial.ts | 23 +- app/client/ui/GristTooltips.ts | 15 +- app/client/ui/SupportGristNudge.ts | 326 ++++++++++++++++++ app/client/ui/SupportGristPage.ts | 289 ++++++++++++++++ app/client/ui/TopBar.ts | 2 +- app/client/ui2018/IconList.ts | 2 + app/common/Install.ts | 10 + app/common/InstallAPI.ts | 51 +++ app/common/Prefs.ts | 1 + app/common/Telemetry.ts | 34 -- app/common/gristUrls.ts | 14 +- app/gen-server/entity/Activation.ts | 42 +++ app/gen-server/entity/Document.ts | 31 +- app/gen-server/lib/Activations.ts | 1 + app/gen-server/lib/HomeDBManager.ts | 12 +- .../1682636695021-ActivationPrefs.ts | 16 + app/server/companion.ts | 8 +- app/server/lib/ActiveDoc.ts | 9 +- app/server/lib/AppEndpoint.ts | 5 +- app/server/lib/DocApi.ts | 5 +- app/server/lib/FlexServer.ts | 55 ++- app/server/lib/GristServer.ts | 7 +- app/server/lib/Telemetry.ts | 222 +++++++++--- app/{common => server/lib}/hashingUtils.ts | 6 +- app/server/lib/requestUtils.ts | 8 +- app/server/lib/sendAppPage.ts | 9 +- app/server/mergedServerMain.ts | 5 +- buildtools/webpack.config.js | 2 - static/account.html | 16 - static/activation.html | 17 - static/icons/icons.css | 1 + static/ui-icons/UI/Heart.svg | 10 + test/common/Telemetry.ts | 82 +---- test/nbrowser/SupportGrist.ts | 308 +++++++++++++++++ test/server/lib/Authorizer.ts | 2 +- test/server/lib/Telemetry.ts | 264 ++++++++++++-- 47 files changed, 1733 insertions(+), 355 deletions(-) delete mode 100644 app/client/accountMain.ts delete mode 100644 app/client/activationMain.ts create mode 100644 app/client/models/TelemetryModel.ts create mode 100644 app/client/ui/SupportGristNudge.ts create mode 100644 app/client/ui/SupportGristPage.ts create mode 100644 app/common/Install.ts create mode 100644 app/common/InstallAPI.ts create mode 100644 app/gen-server/migration/1682636695021-ActivationPrefs.ts rename app/{common => server/lib}/hashingUtils.ts (70%) delete mode 100644 static/account.html delete mode 100644 static/activation.html create mode 100644 static/ui-icons/UI/Heart.svg create mode 100644 test/nbrowser/SupportGrist.ts diff --git a/app/client/accountMain.ts b/app/client/accountMain.ts deleted file mode 100644 index ed4b2da1..00000000 --- a/app/client/accountMain.ts +++ /dev/null @@ -1,5 +0,0 @@ -import {AccountPage} from 'app/client/ui/AccountPage'; -import {setupPage} from 'app/client/ui/setupPage'; -import {dom} from 'grainjs'; - -setupPage((appModel) => dom.create(AccountPage, appModel)); diff --git a/app/client/activationMain.ts b/app/client/activationMain.ts deleted file mode 100644 index 33a56d32..00000000 --- a/app/client/activationMain.ts +++ /dev/null @@ -1,5 +0,0 @@ -import {ActivationPage} from 'app/client/ui/ActivationPage'; -import {setupPage} from 'app/client/ui/setupPage'; -import {dom} from 'grainjs'; - -setupPage((appModel) => dom.create(ActivationPage, appModel)); diff --git a/app/client/components/BehavioralPromptsManager.ts b/app/client/components/BehavioralPromptsManager.ts index c086f7ac..3cfa95b7 100644 --- a/app/client/components/BehavioralPromptsManager.ts +++ b/app/client/components/BehavioralPromptsManager.ts @@ -83,21 +83,7 @@ export class BehavioralPromptsManager extends Disposable { } private _queueTip(refElement: Element, prompt: BehavioralPrompt, options: AttachOptions) { - if ( - this._isDisabled || - // Don't show tips if surveying is disabled. - // TODO: Move this into a dedicated variable - this is only a short-term fix for hiding - // tips in grist-core. - (!getGristConfig().survey && prompt !== 'rickRow') || - // Or if this tip shouldn't be shown on mobile. - (isNarrowScreen() && !options.showOnMobile) || - // Or if "Don't show tips" was checked in the past. - (this._prefs.get().dontShowTips && !options.forceShow) || - // Or if this tip has been shown and dismissed in the past. - this.hasSeenTip(prompt) - ) { - return; - } + if (!this._shouldQueueTip(prompt, options)) { return; } this._queuedTips.push({prompt, refElement, options}); if (this._queuedTips.length > 1) { @@ -156,4 +142,26 @@ export class BehavioralPromptsManager extends Disposable { this._prefs.set({...this._prefs.get(), dontShowTips: true}); this._queuedTips = []; } + + private _shouldQueueTip(prompt: BehavioralPrompt, options: AttachOptions) { + if ( + this._isDisabled || + (isNarrowScreen() && !options.showOnMobile) || + (this._prefs.get().dontShowTips && !options.forceShow) || + this.hasSeenTip(prompt) + ) { + return false; + } + + const {deploymentType} = getGristConfig(); + const {deploymentTypes} = GristBehavioralPrompts[prompt]; + if ( + deploymentTypes !== '*' && + (!deploymentType || !deploymentTypes.includes(deploymentType)) + ) { + return false; + } + + return true; + } } diff --git a/app/client/lib/imports.d.ts b/app/client/lib/imports.d.ts index 39e3438a..4691bef6 100644 --- a/app/client/lib/imports.d.ts +++ b/app/client/lib/imports.d.ts @@ -1,4 +1,7 @@ +import * as AccountPageModule from 'app/client/ui/AccountPage'; +import * as ActivationPageModule from 'app/client/ui/ActivationPage'; import * as BillingPageModule from 'app/client/ui/BillingPage'; +import * as SupportGristPageModule from 'app/client/ui/SupportGristPage'; import * as GristDocModule from 'app/client/components/GristDoc'; import * as ViewPane from 'app/client/components/ViewPane'; import * as UserManagerModule from 'app/client/ui/UserManager'; @@ -9,7 +12,10 @@ import * as plotly from 'plotly.js'; export type PlotlyType = typeof plotly; export type MomentTimezone = typeof momentTimezone; +export function loadAccountPage(): Promise; +export function loadActivationPage(): Promise; export function loadBillingPage(): Promise; +export function loadSupportGristPage(): Promise; export function loadGristDoc(): Promise; export function loadMomentTimezone(): Promise; export function loadPlotly(): Promise; diff --git a/app/client/lib/imports.js b/app/client/lib/imports.js index a42a4662..b5d6f428 100644 --- a/app/client/lib/imports.js +++ b/app/client/lib/imports.js @@ -6,7 +6,10 @@ * */ +exports.loadAccountPage = () => import('app/client/ui/AccountPage' /* webpackChunkName: "AccountPage" */); +exports.loadActivationPage = () => import('app/client/ui/ActivationPage' /* webpackChunkName: "ActivationPage" */); exports.loadBillingPage = () => import('app/client/ui/BillingPage' /* webpackChunkName: "BillingModule" */); +exports.loadSupportGristPage = () => import('app/client/ui/SupportGristPage' /* webpackChunkName: "SupportGristPage" */); exports.loadGristDoc = () => import('app/client/components/GristDoc' /* webpackChunkName: "GristDoc" */); // When importing this way, the module is under the "default" member, not sure why (maybe // esbuild-loader's doing). diff --git a/app/client/models/AppModel.ts b/app/client/models/AppModel.ts index 0b5d24d2..f2fc1692 100644 --- a/app/client/models/AppModel.ts +++ b/app/client/models/AppModel.ts @@ -9,6 +9,7 @@ import {urlState} from 'app/client/models/gristUrlState'; import {Notifier} from 'app/client/models/NotifyModel'; import {getFlavor, ProductFlavor} from 'app/client/ui/CustomThemes'; import {buildNewSiteModal, buildUpgradeModal} from 'app/client/ui/ProductUpgrades'; +import {SupportGristNudge} from 'app/client/ui/SupportGristNudge'; import {attachCssThemeVars, prefersDarkModeObs} from 'app/client/ui2018/cssVars'; import {OrgUsageSummary} from 'app/common/DocUsage'; import {Features, isLegacyPlan, Product} from 'app/common/Features'; @@ -31,7 +32,15 @@ const t = makeT('AppModel'); // Reexported for convenience. export {reportError} from 'app/client/models/errors'; -export type PageType = "doc" | "home" | "billing" | "welcome"; +export type PageType = + | "doc" + | "home" + | "billing" + | "welcome" + | "account" + | "support-grist" + | "activation"; + const G = getBrowserGlobals('document', 'window'); // TopAppModel is the part of the app model that persists across org and user switches. @@ -107,6 +116,8 @@ export interface AppModel { behavioralPromptsManager: BehavioralPromptsManager; + supportGristNudge: SupportGristNudge; + refreshOrgUsage(): Promise; showUpgradeModal(): void; showNewSiteModal(): void; @@ -253,7 +264,23 @@ export class AppModelImpl extends Disposable implements AppModel { // Get the current PageType from the URL. public readonly pageType: Observable = Computed.create(this, urlState().state, - (use, state) => (state.doc ? "doc" : (state.billing ? "billing" : (state.welcome ? "welcome" : "home")))); + (_use, state) => { + if (state.doc) { + return 'doc'; + } else if (state.billing) { + return 'billing'; + } else if (state.welcome) { + return 'welcome'; + } else if (state.account) { + return 'account'; + } else if (state.supportGrist) { + return 'support-grist'; + } else if (state.activation) { + return 'activation'; + } else { + return 'home'; + } + }); public readonly needsOrg: Observable = Computed.create( this, urlState().state, (use, state) => { @@ -265,6 +292,8 @@ export class AppModelImpl extends Disposable implements AppModel { public readonly behavioralPromptsManager: BehavioralPromptsManager = BehavioralPromptsManager.create(this, this); + public readonly supportGristNudge: SupportGristNudge = SupportGristNudge.create(this, this); + constructor( public readonly topAppModel: TopAppModel, public readonly currentUser: FullUser|null, diff --git a/app/client/models/TelemetryModel.ts b/app/client/models/TelemetryModel.ts new file mode 100644 index 00000000..5d7af638 --- /dev/null +++ b/app/client/models/TelemetryModel.ts @@ -0,0 +1,32 @@ +import {AppModel, getHomeUrl} from 'app/client/models/AppModel'; +import {TelemetryPrefs} from 'app/common/Install'; +import {InstallAPI, InstallAPIImpl, TelemetryPrefsWithSources} from 'app/common/InstallAPI'; +import {bundleChanges, Disposable, Observable} from 'grainjs'; + +export interface TelemetryModel { + /** Telemetry preferences (e.g. the current telemetry level). */ + readonly prefs: Observable; + fetchTelemetryPrefs(): Promise; + updateTelemetryPrefs(prefs: Partial): Promise; +} + +export class TelemetryModelImpl extends Disposable implements TelemetryModel { + public readonly prefs: Observable = Observable.create(this, null); + private readonly _installAPI: InstallAPI = new InstallAPIImpl(getHomeUrl()); + + constructor(_appModel: AppModel) { + super(); + } + + public async fetchTelemetryPrefs(): Promise { + const prefs = await this._installAPI.getInstallPrefs(); + bundleChanges(() => { + this.prefs.set(prefs.telemetry); + }); + } + + public async updateTelemetryPrefs(prefs: Partial): Promise { + await this._installAPI.updateInstallPrefs({telemetry: prefs}); + await this.fetchTelemetryPrefs(); + } +} diff --git a/app/client/models/gristUrlState.ts b/app/client/models/gristUrlState.ts index 0295b5f8..4e1523fe 100644 --- a/app/client/models/gristUrlState.ts +++ b/app/client/models/gristUrlState.ts @@ -156,7 +156,8 @@ export class UrlStateImpl { */ public updateState(prevState: IGristUrlState, newState: IGristUrlState): IGristUrlState { const keepState = (newState.org || newState.ws || newState.homePage || newState.doc || isEmpty(newState) || - newState.account || newState.billing || newState.activation || newState.welcome) ? + newState.account || newState.billing || newState.activation || newState.welcome || + newState.supportGrist) ? (prevState.org ? {org: prevState.org} : {}) : prevState; return {...keepState, ...newState}; @@ -186,8 +187,11 @@ export class UrlStateImpl { // Reload when moving to/from the Grist sign-up page. const signupReload = [prevState.login, newState.login].includes('signup') && prevState.login !== newState.login; - return Boolean(orgReload || accountReload || billingReload || activationReload - || gristConfig.errPage || docReload || welcomeReload || linkKeysReload || signupReload); + // Reload when moving to/from the support Grist page. + const supportGristReload = Boolean(prevState.supportGrist) !== Boolean(newState.supportGrist); + return Boolean(orgReload || accountReload || billingReload || activationReload || + gristConfig.errPage || docReload || welcomeReload || linkKeysReload || signupReload || + supportGristReload); } /** diff --git a/app/client/ui/AccountWidget.ts b/app/client/ui/AccountWidget.ts index ef45f649..70889a8e 100644 --- a/app/client/ui/AccountWidget.ts +++ b/app/client/ui/AccountWidget.ts @@ -65,7 +65,7 @@ export class AccountWidget extends Disposable { t("Toggle Mobile Mode"), cssCheckmark('Tick', dom.show(viewport.viewportEnabled)), testId('usermenu-toggle-mobile'), - ); + ); if (!user) { return [ @@ -100,6 +100,7 @@ export class AccountWidget extends Disposable { this._maybeBuildBillingPageMenuItem(), this._maybeBuildActivationPageMenuItem(), + this._maybeBuildSupportGristPageMenuItem(), mobileModeToggle, @@ -155,10 +156,10 @@ export class AccountWidget extends Disposable { // For links, disabling with just a class is hard; easier to just not make it a link. // TODO weasel menus should support disabling menuItemLink. (isBillingManager ? - menuItemLink(urlState().setLinkUrl({billing: 'billing'}), 'Billing Account') : - menuItem(() => null, 'Billing Account', dom.cls('disabled', true)) + menuItemLink(urlState().setLinkUrl({billing: 'billing'}), t('Billing Account')) : + menuItem(() => null, t('Billing Account'), dom.cls('disabled', true)) ) : - menuItem(() => this._appModel.showUpgradeModal(), 'Upgrade Plan'); + menuItem(() => this._appModel.showUpgradeModal(), t('Upgrade Plan')); } private _maybeBuildActivationPageMenuItem() { @@ -167,7 +168,21 @@ export class AccountWidget extends Disposable { return null; } - return menuItemLink('Activation', urlState().setLinkUrl({activation: 'activation'})); + return menuItemLink(t('Activation'), urlState().setLinkUrl({activation: 'activation'})); + } + + private _maybeBuildSupportGristPageMenuItem() { + const {deploymentType} = getGristConfig(); + if (deploymentType !== 'core') { + return null; + } + + return menuItemLink( + t('Support Grist'), + cssHeartIcon('💛'), + urlState().setLinkUrl({supportGrist: 'support-grist'}), + testId('usermenu-support-grist'), + ); } } @@ -183,6 +198,10 @@ export const cssUserIcon = styled('div', ` cursor: pointer; `); +const cssHeartIcon = styled('span', ` + margin-left: 8px; +`); + const cssUserInfo = styled('div', ` padding: 12px 24px 12px 16px; min-width: 200px; diff --git a/app/client/ui/AppUI.ts b/app/client/ui/AppUI.ts index 71485ca4..a6f30e0c 100644 --- a/app/client/ui/AppUI.ts +++ b/app/client/ui/AppUI.ts @@ -1,7 +1,7 @@ import {buildDocumentBanners, buildHomeBanners} from 'app/client/components/Banners'; import {ViewAsBanner} from 'app/client/components/ViewAsBanner'; import {domAsync} from 'app/client/lib/domAsync'; -import {loadBillingPage} from 'app/client/lib/imports'; +import {loadAccountPage, loadActivationPage, loadBillingPage, loadSupportGristPage} from 'app/client/lib/imports'; import {createSessionObs, isBoolean, isNumber} from 'app/client/lib/sessionObs'; import {AppModel, TopAppModel} from 'app/client/models/AppModel'; import {DocPageModelImpl} from 'app/client/models/DocPageModel'; @@ -75,6 +75,12 @@ function createMainPage(appModel: AppModel, appObj: App) { return domAsync(loadBillingPage().then(bp => dom.create(bp.BillingPage, appModel))); } else if (pageType === 'welcome') { return dom.create(WelcomePage, appModel); + } else if (pageType === 'account') { + return domAsync(loadAccountPage().then(ap => dom.create(ap.AccountPage, appModel))); + } else if (pageType === 'support-grist') { + return domAsync(loadSupportGristPage().then(sgp => dom.create(sgp.SupportGristPage, appModel))); + } else if (pageType === 'activation') { + return domAsync(loadActivationPage().then(ap => dom.create(ap.ActivationPage, appModel))); } else { return dom.create(pagePanelsDoc, appModel, appObj); } diff --git a/app/client/ui/DocMenu.ts b/app/client/ui/DocMenu.ts index c4a3eabc..6a716765 100644 --- a/app/client/ui/DocMenu.ts +++ b/app/client/ui/DocMenu.ts @@ -174,7 +174,14 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) { testId('doclist') ), dom.maybe(use => !use(isNarrowScreenObs()) && ['all', 'workspace'].includes(use(home.currentPage)), - () => upgradeButton.showUpgradeCard(css.upgradeCard.cls(''))), + () => { + // TODO: These don't currently clash (grist-core stubs the upgradeButton), but a way to + // manage card popups will be needed if more are added later. + return [ + upgradeButton.showUpgradeCard(css.upgradeCard.cls('')), + home.app.supportGristNudge.showCard(), + ]; + }), )); } diff --git a/app/client/ui/DocTutorial.ts b/app/client/ui/DocTutorial.ts index 97f68a4f..67905417 100644 --- a/app/client/ui/DocTutorial.ts +++ b/app/client/ui/DocTutorial.ts @@ -1,4 +1,5 @@ import {GristDoc} from 'app/client/components/GristDoc'; +import {logTelemetryEvent} from 'app/client/lib/telemetry'; import {getWelcomeHomeUrl, urlState} from 'app/client/models/gristUrlState'; import {renderer} from 'app/client/ui/DocTutorialRenderer'; import {cssPopupBody, FloatingPopup} from 'app/client/ui/FloatingPopup'; @@ -30,12 +31,12 @@ const TOOLTIP_KEY = 'docTutorialTooltip'; export class DocTutorial extends FloatingPopup { private _appModel = this._gristDoc.docPageModel.appModel; private _currentDoc = this._gristDoc.docPageModel.currentDoc.get(); + private _currentFork = this._currentDoc?.forks?.[0]; private _docComm = this._gristDoc.docComm; private _docData = this._gristDoc.docData; private _docId = this._gristDoc.docId(); private _slides: Observable = Observable.create(this, null); - private _currentSlideIndex = Observable.create(this, - this._currentDoc?.forks?.[0]?.options?.tutorial?.lastSlideIndex ?? 0); + private _currentSlideIndex = Observable.create(this, this._currentFork?.options?.tutorial?.lastSlideIndex ?? 0); private _saveCurrentSlidePositionDebounced = debounce(this._saveCurrentSlidePosition, 1000, { @@ -231,14 +232,30 @@ export class DocTutorial extends FloatingPopup { private async _saveCurrentSlidePosition() { const currentOptions = this._currentDoc?.options ?? {}; + const currentSlideIndex = this._currentSlideIndex.get(); + const numSlides = this._slides.get()?.length; await this._appModel.api.updateDoc(this._docId, { options: { ...currentOptions, tutorial: { - lastSlideIndex: this._currentSlideIndex.get(), + lastSlideIndex: currentSlideIndex, } } }); + + let percentComplete: number | undefined = undefined; + if (numSlides !== undefined && numSlides > 0) { + percentComplete = Math.floor(((currentSlideIndex + 1) / numSlides) * 100); + } + logTelemetryEvent('tutorialProgressChanged', { + full: { + tutorialForkIdDigest: this._currentFork?.id, + tutorialTrunkIdDigest: this._currentFork?.trunkId, + lastSlideIndex: currentSlideIndex, + numSlides, + percentComplete, + }, + }); } private async _changeSlide(slideIndex: number) { diff --git a/app/client/ui/GristTooltips.ts b/app/client/ui/GristTooltips.ts index 62d172c0..04264845 100644 --- a/app/client/ui/GristTooltips.ts +++ b/app/client/ui/GristTooltips.ts @@ -3,7 +3,7 @@ import {makeT} from 'app/client/lib/localization'; import {ShortcutKey, ShortcutKeyContent} from 'app/client/ui/ShortcutKey'; import {icon} from 'app/client/ui2018/icons'; import {cssLink} from 'app/client/ui2018/links'; -import {commonUrls} from 'app/common/gristUrls'; +import {commonUrls, GristDeploymentType} from 'app/common/gristUrls'; import {BehavioralPrompt} from 'app/common/Prefs'; import {dom, DomContents, DomElementArg, styled} from 'grainjs'; @@ -104,6 +104,7 @@ export const GristTooltips: Record = { export interface BehavioralPromptContent { title: () => string; content: (...domArgs: DomElementArg[]) => DomContents; + deploymentTypes: GristDeploymentType[] | '*'; } export const GristBehavioralPrompts: Record = { @@ -119,6 +120,7 @@ export const GristBehavioralPrompts: Record t('Reference Columns'), @@ -133,6 +135,7 @@ export const GristBehavioralPrompts: Record t('Raw Data page'), @@ -142,6 +145,7 @@ export const GristBehavioralPrompts: Record t('Access Rules'), @@ -151,6 +155,7 @@ export const GristBehavioralPrompts: Record t('Pinning Filters'), @@ -160,6 +165,7 @@ export const GristBehavioralPrompts: Record t('Nested Filtering'), @@ -168,6 +174,7 @@ export const GristBehavioralPrompts: Record t('Selecting Data'), @@ -176,6 +183,7 @@ export const GristBehavioralPrompts: Record t('Linking Widgets'), @@ -185,6 +193,7 @@ export const GristBehavioralPrompts: Record t('Editing Card Layout'), @@ -195,6 +204,7 @@ export const GristBehavioralPrompts: Record t('Add New'), @@ -203,6 +213,7 @@ export const GristBehavioralPrompts: Record t('Anchor Links'), @@ -217,6 +228,7 @@ export const GristBehavioralPrompts: Record t('Custom Widgets'), @@ -230,5 +242,6 @@ export const GristBehavioralPrompts: Record