Compare commits
1
Commits
1ff28820f3
...
bd1e61e8cd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd1e61e8cd |
@@ -63,6 +63,7 @@ function ToastCard({ toast }: ToastCardProps) {
|
||||
|
||||
const handleDismiss = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
toast.onDismiss?.();
|
||||
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 { 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) {
|
||||
<PageZoomFeature />
|
||||
<TrackingParamsFeature />
|
||||
<SettingsSyncFeature />
|
||||
<PwaInstallFeature />
|
||||
<FaviconUpdater />
|
||||
<PresenceUpdater />
|
||||
<MuteTimerRestore />
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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