gristlabs_grist-core/app/client/ui/SupportGristNudge.ts
Dmitry S e380fcfa90 (core) Admin Panel and InstallAdmin class to identify installation admins.
Summary:
- Add InstallAdmin class to identify users who can manage Grist installation.

  This is overridable by different Grist flavors (e.g. different in SaaS).
  It generalizes previous logic used to decide who can control Activation
  settings (e.g. enable telemetry).

- Implement a basic Admin Panel at /admin, and move items previously in the
  "Support Grist" page into the "Support Grist" section of the Admin Panel.

- Replace "Support Grist" menu items with "Admin Panel" and show only to admins.

- Add "Support Grist" links to Github sponsorship to user-account menu.

- Add "Support Grist" button to top-bar, which
  - for admins, replaces the previous "Contribute" button and reopens the "Support Grist / opt-in to telemetry" nudge (unchanged)
  - for everyone else, links to Github sponsorship
  - in either case, user can dismiss it.

Test Plan: Shuffled some test cases between Support Grist and the new Admin Panel, and added some new cases.

Reviewers: jarek, paulfitz

Reviewed By: jarek, paulfitz

Differential Revision: https://phab.getgrist.com/D4194
2024-03-25 12:18:38 -04:00

306 lines
8.7 KiB
TypeScript

import {makeT} from 'app/client/lib/localization';
import {localStorageObs} from 'app/client/lib/localStorageObs';
import {getStorage} from 'app/client/lib/storage';
import {tokenFieldStyles} from 'app/client/lib/TokenField';
import {AppModel} from 'app/client/models/AppModel';
import {urlState} from 'app/client/models/gristUrlState';
import {TelemetryModel, TelemetryModelImpl} from 'app/client/models/TelemetryModel';
import {basicButton, basicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons';
import {colors, isNarrowScreenObs, testId, theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links';
import {commonUrls, isFeatureEnabled} from 'app/common/gristUrls';
import {getGristConfig} from 'app/common/urlUtils';
import {Computed, Disposable, dom, DomContents, Observable, styled, UseCB} from 'grainjs';
const t = makeT('SupportGristNudge');
type ButtonState =
| 'collapsed'
| 'expanded';
/**
* Nudges users to support Grist by opting in to telemetry or sponsoring on Github.
*
* For installation admins, this includes a card with a nudge which collapses into a "Support
* Grist" button in the top bar. When that's not applicable, it is only a "Support Grist" button
* that links to the Github sponsorship page.
*
* Users can dismiss these nudges.
*/
export class SupportGristNudge extends Disposable {
private readonly _telemetryModel: TelemetryModel = TelemetryModelImpl.create(this, this._appModel);
private readonly _buttonStateKey = `u=${this._appModel.currentValidUser?.id ?? 0};supportGristNudge`;
private _buttonState = localStorageObs(this._buttonStateKey, 'expanded') as Observable<ButtonState>;
// Whether the nudge just got accepted, and we should temporarily show the "Thanks" version.
private _justAccepted = Observable.create(this, false);
private _showButton: Computed<null|'link'|'expand'>;
private _showNudge: Computed<null|'normal'|'accepted'>;
constructor(private _appModel: AppModel) {
super();
const {deploymentType, telemetry} = getGristConfig();
const isEnabled = (deploymentType === 'core') && isFeatureEnabled("supportGrist");
const isAdmin = _appModel.isInstallAdmin();
const isTelemetryOn = (telemetry && telemetry.telemetryLevel !== 'off');
const isAdminNudgeApplicable = isAdmin && !isTelemetryOn;
const generallyHide = (use: UseCB) => (
!isEnabled ||
use(_appModel.dismissedPopups).includes('supportGrist') ||
use(isNarrowScreenObs())
);
this._showButton = Computed.create(this, use => {
if (generallyHide(use)) { return null; }
if (!isAdminNudgeApplicable) { return 'link'; }
if (use(this._buttonState) !== 'expanded') { return 'expand'; }
return null;
});
this._showNudge = Computed.create(this, use => {
if (use(this._justAccepted)) { return 'accepted'; }
if (generallyHide(use)) { return null; }
if (isAdminNudgeApplicable && use(this._buttonState) === 'expanded') { return 'normal'; }
return null;
});
}
public buildTopBarButton(): DomContents {
return dom.domComputed(this._showButton, (which) => {
if (!which) { return null; }
const elemType = (which === 'link') ? basicButtonLink : basicButton;
return cssContributeButton(
elemType(cssHeartIcon('💛 '), t('Support Grist'),
(which === 'link' ?
{href: commonUrls.githubSponsorGristLabs, target: '_blank'} :
dom.on('click', () => this._buttonState.set('expanded'))
),
cssContributeButtonCloseButton(
icon('CrossSmall'),
dom.on('click', (ev) => {
ev.stopPropagation();
ev.preventDefault();
this._dismissAndClose();
}),
testId('support-grist-button-dismiss'),
),
testId('support-grist-button'),
)
);
});
}
public buildNudgeCard() {
return dom.domComputed(this._showNudge, nudge => {
if (!nudge) { return null; }
return cssCard(
(nudge === 'normal' ?
this._buildSupportGristCardContent() :
this._buildOptedInCardContent()
),
testId('support-nudge'),
);
});
}
private _buildSupportGristCardContent() {
return [
cssCloseButton(
icon('CrossBig'),
dom.on('click', () => this._buttonState.set('collapsed')),
testId('support-nudge-close'),
),
cssLeftAlignedHeader(t('Support Grist')),
cssParagraph(t(
'Opt in to telemetry to help us understand how the product ' +
'is used, so that we can prioritize future improvements.'
)),
cssParagraph(
t(
'We only collect usage statistics, as detailed in our {{helpCenterLink}}, never ' +
'document contents. Opt out any time from the {{supportGristLink}} in the user menu.',
{
helpCenterLink: helpCenterLink(),
supportGristLink: adminPanelLink(),
},
),
),
cssFullWidthButton(
t('Opt in to Telemetry'),
dom.on('click', () => this._optInToTelemetry()),
testId('support-nudge-opt-in'),
),
];
}
private _buildOptedInCardContent() {
return [
cssCloseButton(
icon('CrossBig'),
dom.on('click', () => this._justAccepted.set(false)),
testId('support-nudge-close'),
),
cssCenteredFlex(cssSparks()),
cssCenterAlignedHeader(t('Opted In')),
cssParagraph(
t(
'Thank you! Your trust and support is greatly appreciated. ' +
'Opt out any time from the {{link}} in the user menu.',
{link: adminPanelLink()},
),
),
cssCenteredFlex(
cssPrimaryButton(
t('Close'),
dom.on('click', () => this._justAccepted.set(false)),
testId('support-nudge-close-button'),
),
),
];
}
private _markDismissed() {
this._appModel.dismissPopup('supportGrist', true);
// Also cleanup the no-longer-needed button state from localStorage.
getStorage().removeItem(this._buttonStateKey);
}
private _dismissAndClose() {
this._markDismissed();
this._justAccepted.set(false);
}
private async _optInToTelemetry() {
await this._telemetryModel.updateTelemetryPrefs({telemetryLevel: 'limited'});
this._markDismissed();
this._justAccepted.set(true);
}
}
function helpCenterLink() {
return cssLink(
t('Help Center'),
{href: commonUrls.helpTelemetryLimited, target: '_blank'},
);
}
function adminPanelLink() {
return cssLink(
t('Admin Panel'),
{href: urlState().makeUrl({adminPanel: 'admin'}), target: '_blank'},
);
}
const cssCenteredFlex = styled('div', `
display: flex;
justify-content: center;
align-items: center;
`);
const cssContributeButton = styled('div', `
margin-left: 8px;
margin-right: 8px;
`);
const cssContributeButtonCloseButton = styled(tokenFieldStyles.cssDeleteButton, `
margin-left: 4px;
vertical-align: bottom;
line-height: 1;
position: absolute;
top: -4px;
right: -8px;
border-radius: 16px;
background-color: ${colors.dark};
width: 18px;
height: 18px;
cursor: pointer;
z-index: 1;
display: none;
align-items: center;
justify-content: center;
--icon-color: ${colors.light};
.${cssContributeButton.className}:hover & {
display: flex;
}
&:hover {
--icon-color: ${colors.lightGreen};
}
`);
const cssCard = styled('div', `
width: 297px;
padding: 24px;
color: ${theme.announcementPopupFg};
background: ${theme.announcementPopupBg};
border-radius: 4px;
align-self: flex-start;
position: sticky;
flex-shrink: 0;
top: 0px;
`);
const cssHeader = styled('div', `
font-size: ${vars.xxxlargeFontSize};
font-weight: 600;
margin-bottom: 16px;
`);
const cssLeftAlignedHeader = styled(cssHeader, `
text-align: left;
`);
const cssCenterAlignedHeader = styled(cssHeader, `
text-align: center;
`);
const cssParagraph = styled('div', `
font-size: 13px;
line-height: 18px;
margin-bottom: 12px;
`);
const cssPrimaryButton = styled(bigPrimaryButton, `
display: flex;
justify-content: center;
align-items: center;
margin-top: 32px;
text-align: center;
`);
const cssFullWidthButton = styled(cssPrimaryButton, `
width: 100%;
`);
const cssCloseButton = styled('div', `
position: absolute;
top: 8px;
right: 8px;
padding: 4px;
border-radius: 4px;
cursor: pointer;
--icon-color: ${theme.popupCloseButtonFg};
&:hover {
background-color: ${theme.hover};
}
`);
const cssSparks = styled('div', `
height: 48px;
width: 48px;
background-image: var(--icon-Sparks);
display: inline-block;
background-repeat: no-repeat;
`);
// This is just to avoid the emoji pushing the button to be taller.
const cssHeartIcon = styled('span', `
line-height: 1;
`);