feat(pwa): offer to install — Chromium prompt, iOS "Add to Home Screen" hint (#116)
CI / Build & Quality Checks (push) Successful in 1m59s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 1m59s
CI / Build & Quality Checks (push) Successful in 1m59s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 1m59s
The client never handled beforeinstallprompt and showed no install hint, so phone users only got the PWA if they knew to dig through Share → Add to Home Screen (iOS never prompts; Chromium's mini-infobar is easy to miss). - utils/pwaInstall.ts (pure, 4 tests): show from the second visit, never in Tauri or an installed PWA (display-mode standalone / navigator.standalone), 30-day snooze after a dismissal; kind = real prompt when the browser handed us a deferred beforeinstallprompt, Share-sheet instructions on iOS Safari, nothing elsewhere (Firefox desktop has no install path). - hooks/usePwaInstallPrompt.ts: captures beforeinstallprompt/appinstalled, counts one visit per browser session, waits 6s for the prompt event before deciding, then enqueues a sticky toast; tap → prompt(), X → snooze. - ToastNotif gains onDismiss (fired by the X button only) so the snooze is recorded however the toast is closed. Mounted from ClientNonUIFeatures for signed-in users only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -63,6 +63,7 @@ function ToastCard({ toast }: ToastCardProps) {
|
|||||||
|
|
||||||
const handleDismiss = (e: React.MouseEvent) => {
|
const handleDismiss = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
toast.onDismiss?.();
|
||||||
dismiss(toast.id);
|
dismiss(toast.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { Icons } from 'folds';
|
||||||
|
import { isTauri } from './useTauri';
|
||||||
|
import { ua } from '../utils/user-agent';
|
||||||
|
import { dismissToastAtom, toastQueueAtom } from '../state/toast';
|
||||||
|
import { installHintKind, shouldShowInstallHint } from '../utils/pwaInstall';
|
||||||
|
|
||||||
|
const VISITS_KEY = 'pwa-install-visits';
|
||||||
|
const DISMISSED_KEY = 'pwa-install-dismissed-at';
|
||||||
|
const SESSION_COUNTED_KEY = 'pwa-install-visit-counted';
|
||||||
|
const TOAST_ID = 'pwa-install-hint';
|
||||||
|
// Give the browser a moment to fire beforeinstallprompt (Chromium does so
|
||||||
|
// shortly after load once the manifest checks pass) before deciding which hint.
|
||||||
|
const DECIDE_DELAY_MS = 6000;
|
||||||
|
|
||||||
|
type BeforeInstallPromptEvent = Event & {
|
||||||
|
prompt: () => Promise<void>;
|
||||||
|
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readNumber = (key: string): number | null => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
if (raw === null) return null;
|
||||||
|
const n = Number(raw);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const write = (key: string, value: string): void => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
} catch {
|
||||||
|
/* blocked storage — the hint just won't be remembered */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One visit per browser session (tab lifetime), not per React mount. */
|
||||||
|
const countVisit = (): number => {
|
||||||
|
try {
|
||||||
|
if (sessionStorage.getItem(SESSION_COUNTED_KEY)) return readNumber(VISITS_KEY) ?? 1;
|
||||||
|
sessionStorage.setItem(SESSION_COUNTED_KEY, '1');
|
||||||
|
} catch {
|
||||||
|
return readNumber(VISITS_KEY) ?? 1;
|
||||||
|
}
|
||||||
|
const next = (readNumber(VISITS_KEY) ?? 0) + 1;
|
||||||
|
write(VISITS_KEY, String(next));
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isStandalone = (): boolean =>
|
||||||
|
window.matchMedia?.('(display-mode: standalone)').matches === true ||
|
||||||
|
(navigator as Navigator & { standalone?: boolean }).standalone === true;
|
||||||
|
|
||||||
|
const isIosSafari = (): boolean => {
|
||||||
|
const { os, browser } = ua();
|
||||||
|
return os.name === 'iOS' && (browser.name ?? '').includes('Safari');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #116] Offer to install the PWA: a real prompt on Chromium (deferred
|
||||||
|
* `beforeinstallprompt`), Share-sheet instructions on iOS Safari, nothing
|
||||||
|
* elsewhere. Shown as a sticky toast from the second visit, snoozed 30 days on
|
||||||
|
* dismiss, never in Tauri or an installed PWA. Mount once for signed-in users.
|
||||||
|
*/
|
||||||
|
export function usePwaInstallPrompt(): void {
|
||||||
|
const enqueueToast = useSetAtom(toastQueueAtom);
|
||||||
|
const dismissToast = useSetAtom(dismissToastAtom);
|
||||||
|
const deferredRef = useRef<BeforeInstallPromptEvent | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isTauri() || isStandalone()) return undefined;
|
||||||
|
|
||||||
|
const onBeforeInstall = (evt: Event) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
deferredRef.current = evt as BeforeInstallPromptEvent;
|
||||||
|
};
|
||||||
|
const onInstalled = () => {
|
||||||
|
deferredRef.current = null;
|
||||||
|
dismissToast(TOAST_ID);
|
||||||
|
write(DISMISSED_KEY, String(Date.now()));
|
||||||
|
};
|
||||||
|
window.addEventListener('beforeinstallprompt', onBeforeInstall);
|
||||||
|
window.addEventListener('appinstalled', onInstalled);
|
||||||
|
|
||||||
|
const visits = countVisit();
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const show = shouldShowInstallHint({
|
||||||
|
isTauri: false,
|
||||||
|
isStandalone: isStandalone(),
|
||||||
|
visits,
|
||||||
|
dismissedAt: readNumber(DISMISSED_KEY),
|
||||||
|
now: Date.now(),
|
||||||
|
});
|
||||||
|
if (!show) return;
|
||||||
|
const kind = installHintKind(deferredRef.current !== null, isIosSafari());
|
||||||
|
if (kind === 'none') return;
|
||||||
|
|
||||||
|
const dismiss = () => {
|
||||||
|
write(DISMISSED_KEY, String(Date.now()));
|
||||||
|
dismissToast(TOAST_ID);
|
||||||
|
};
|
||||||
|
|
||||||
|
enqueueToast({
|
||||||
|
id: TOAST_ID,
|
||||||
|
iconSrc: Icons.Download,
|
||||||
|
displayName: 'Install Lotus Chat',
|
||||||
|
body:
|
||||||
|
kind === 'prompt'
|
||||||
|
? 'Add it to your home screen or desktop for faster access and its own window. Tap to install.'
|
||||||
|
: 'On iPhone/iPad: tap Share, then "Add to Home Screen". Tap to dismiss.',
|
||||||
|
roomName: '',
|
||||||
|
roomId: '',
|
||||||
|
sticky: true,
|
||||||
|
onDismiss: () => write(DISMISSED_KEY, String(Date.now())),
|
||||||
|
onClick: () => {
|
||||||
|
const deferred = deferredRef.current;
|
||||||
|
if (kind === 'prompt' && deferred) {
|
||||||
|
deferred
|
||||||
|
.prompt()
|
||||||
|
.then(() => deferred.userChoice)
|
||||||
|
.then(({ outcome }) => {
|
||||||
|
if (outcome === 'accepted') deferredRef.current = null;
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(dismiss);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dismiss();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, DECIDE_DELAY_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
window.removeEventListener('beforeinstallprompt', onBeforeInstall);
|
||||||
|
window.removeEventListener('appinstalled', onInstalled);
|
||||||
|
};
|
||||||
|
}, [enqueueToast, dismissToast]);
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import { useSetting } from '../../state/hooks/settings';
|
|||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { setStripTrackingOnRender } from '../../plugins/react-custom-html-parser';
|
import { setStripTrackingOnRender } from '../../plugins/react-custom-html-parser';
|
||||||
import { useSettingsSync } from '../../hooks/useSettingsSync';
|
import { useSettingsSync } from '../../hooks/useSettingsSync';
|
||||||
|
import { usePwaInstallPrompt } from '../../hooks/usePwaInstallPrompt';
|
||||||
import { allInvitesAtom } from '../../state/room-list/inviteList';
|
import { allInvitesAtom } from '../../state/room-list/inviteList';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useHydrateMsgDrafts } from '../../hooks/useHydrateMsgDrafts';
|
import { useHydrateMsgDrafts } from '../../hooks/useHydrateMsgDrafts';
|
||||||
@@ -105,6 +106,12 @@ function SettingsSyncFeature() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [Gitea #116] Offer to install the PWA (Chromium prompt / iOS instructions).
|
||||||
|
function PwaInstallFeature() {
|
||||||
|
usePwaInstallPrompt();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function PageZoomFeature() {
|
function PageZoomFeature() {
|
||||||
const [pageZoom] = useSetting(settingsAtom, 'pageZoom');
|
const [pageZoom] = useSetting(settingsAtom, 'pageZoom');
|
||||||
|
|
||||||
@@ -906,6 +913,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
|||||||
<PageZoomFeature />
|
<PageZoomFeature />
|
||||||
<TrackingParamsFeature />
|
<TrackingParamsFeature />
|
||||||
<SettingsSyncFeature />
|
<SettingsSyncFeature />
|
||||||
|
<PwaInstallFeature />
|
||||||
<FaviconUpdater />
|
<FaviconUpdater />
|
||||||
<PresenceUpdater />
|
<PresenceUpdater />
|
||||||
<MuteTimerRestore />
|
<MuteTimerRestore />
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type ToastNotif = {
|
|||||||
hashPath?: string; // overrides window.location.hash navigation when set
|
hashPath?: string; // overrides window.location.hash navigation when set
|
||||||
onClick?: () => void; // custom click handler; skips hash navigation when set
|
onClick?: () => void; // custom click handler; skips hash navigation when set
|
||||||
sticky?: boolean; // when true, does not auto-dismiss — use for action toasts that require a click
|
sticky?: boolean; // when true, does not auto-dismiss — use for action toasts that require a click
|
||||||
|
onDismiss?: () => void; // called when the user closes the toast with its X (not on click/eviction)
|
||||||
createdAt?: number; // set by toastQueueAtom when enqueued; used for #80 eviction ordering
|
createdAt?: number; // set by toastQueueAtom when enqueued; used for #80 eviction ordering
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { INSTALL_HINT_SNOOZE_MS, installHintKind, shouldShowInstallHint } from './pwaInstall';
|
||||||
|
|
||||||
|
const base = { isTauri: false, isStandalone: false, visits: 2, dismissedAt: null, now: 1_000_000 };
|
||||||
|
|
||||||
|
describe('shouldShowInstallHint', () => {
|
||||||
|
it('shows from the second visit in a plain browser tab', () => {
|
||||||
|
assert.equal(shouldShowInstallHint(base), true);
|
||||||
|
assert.equal(shouldShowInstallHint({ ...base, visits: 1 }), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never shows in the desktop app or an installed PWA', () => {
|
||||||
|
assert.equal(shouldShowInstallHint({ ...base, isTauri: true }), false);
|
||||||
|
assert.equal(shouldShowInstallHint({ ...base, isStandalone: true }), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('snoozes for 30 days after a dismissal', () => {
|
||||||
|
const dismissedAt = base.now - INSTALL_HINT_SNOOZE_MS + 1;
|
||||||
|
assert.equal(shouldShowInstallHint({ ...base, dismissedAt }), false);
|
||||||
|
assert.equal(
|
||||||
|
shouldShowInstallHint({ ...base, dismissedAt: base.now - INSTALL_HINT_SNOOZE_MS }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('installHintKind', () => {
|
||||||
|
it('prefers a real prompt, falls back to iOS instructions, else nothing', () => {
|
||||||
|
assert.equal(installHintKind(true, true), 'prompt');
|
||||||
|
assert.equal(installHintKind(false, true), 'ios-share');
|
||||||
|
assert.equal(installHintKind(false, false), 'none');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* [Gitea #116] PWA install hint — pure decision logic.
|
||||||
|
*
|
||||||
|
* Chromium fires `beforeinstallprompt`; iOS Safari never prompts and only
|
||||||
|
* installs via Share → "Add to Home Screen", so people on phones simply never
|
||||||
|
* find it. The hint is shown once the user has come back at least once (second
|
||||||
|
* visit), never inside the desktop app or an already-installed PWA, and snoozes
|
||||||
|
* for 30 days after a dismissal.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const INSTALL_HINT_MIN_VISITS = 2;
|
||||||
|
export const INSTALL_HINT_SNOOZE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export type InstallHintInput = {
|
||||||
|
/** Running inside the Tauri desktop app. */
|
||||||
|
isTauri: boolean;
|
||||||
|
/** `display-mode: standalone` or iOS `navigator.standalone` — already installed. */
|
||||||
|
isStandalone: boolean;
|
||||||
|
/** Distinct app visits recorded so far, including this one. */
|
||||||
|
visits: number;
|
||||||
|
/** Epoch ms of the last dismissal, or null. */
|
||||||
|
dismissedAt: number | null;
|
||||||
|
now: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const shouldShowInstallHint = ({
|
||||||
|
isTauri,
|
||||||
|
isStandalone,
|
||||||
|
visits,
|
||||||
|
dismissedAt,
|
||||||
|
now,
|
||||||
|
}: InstallHintInput): boolean => {
|
||||||
|
if (isTauri || isStandalone) return false;
|
||||||
|
if (visits < INSTALL_HINT_MIN_VISITS) return false;
|
||||||
|
if (dismissedAt !== null && now - dismissedAt < INSTALL_HINT_SNOOZE_MS) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InstallHintKind = 'prompt' | 'ios-share' | 'none';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which hint to show: a real install prompt when the browser handed us a
|
||||||
|
* deferred `beforeinstallprompt`, the manual Share-sheet instructions on iOS
|
||||||
|
* Safari, or nothing (Firefox desktop and friends have no install path — a
|
||||||
|
* hint there would just be noise).
|
||||||
|
*/
|
||||||
|
export const installHintKind = (
|
||||||
|
hasDeferredPrompt: boolean,
|
||||||
|
isIosSafari: boolean,
|
||||||
|
): InstallHintKind => {
|
||||||
|
if (hasDeferredPrompt) return 'prompt';
|
||||||
|
if (isIosSafari) return 'ios-share';
|
||||||
|
return 'none';
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user