Compare commits
1
Commits
1ff28820f3
...
bd1e61e8cd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd1e61e8cd |
@@ -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