diff --git a/src/app/features/toast/LotusToastContainer.tsx b/src/app/features/toast/LotusToastContainer.tsx index 070d0953e..23634ed91 100644 --- a/src/app/features/toast/LotusToastContainer.tsx +++ b/src/app/features/toast/LotusToastContainer.tsx @@ -63,6 +63,7 @@ function ToastCard({ toast }: ToastCardProps) { const handleDismiss = (e: React.MouseEvent) => { e.stopPropagation(); + toast.onDismiss?.(); dismiss(toast.id); }; diff --git a/src/app/hooks/usePwaInstallPrompt.ts b/src/app/hooks/usePwaInstallPrompt.ts new file mode 100644 index 000000000..d818fefc0 --- /dev/null +++ b/src/app/hooks/usePwaInstallPrompt.ts @@ -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; + 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(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]); +} diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index e28a885aa..85c604f16 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -22,6 +22,7 @@ import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { setStripTrackingOnRender } from '../../plugins/react-custom-html-parser'; import { useSettingsSync } from '../../hooks/useSettingsSync'; +import { usePwaInstallPrompt } from '../../hooks/usePwaInstallPrompt'; import { allInvitesAtom } from '../../state/room-list/inviteList'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useHydrateMsgDrafts } from '../../hooks/useHydrateMsgDrafts'; @@ -105,6 +106,12 @@ function SettingsSyncFeature() { return null; } +// [Gitea #116] Offer to install the PWA (Chromium prompt / iOS instructions). +function PwaInstallFeature() { + usePwaInstallPrompt(); + return null; +} + function PageZoomFeature() { const [pageZoom] = useSetting(settingsAtom, 'pageZoom'); @@ -906,6 +913,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + diff --git a/src/app/state/toast.ts b/src/app/state/toast.ts index 61da74abc..4a228d03c 100644 --- a/src/app/state/toast.ts +++ b/src/app/state/toast.ts @@ -12,6 +12,7 @@ export type ToastNotif = { hashPath?: string; // overrides window.location.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 + 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 }; diff --git a/src/app/utils/pwaInstall.test.ts b/src/app/utils/pwaInstall.test.ts new file mode 100644 index 000000000..3f0d06c37 --- /dev/null +++ b/src/app/utils/pwaInstall.test.ts @@ -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'); + }); +}); diff --git a/src/app/utils/pwaInstall.ts b/src/app/utils/pwaInstall.ts new file mode 100644 index 000000000..0cf6fec63 --- /dev/null +++ b/src/app/utils/pwaInstall.ts @@ -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'; +};