feat(pwa): pre-install naming, install UX, and manifest shortcuts (#554)
* feat(web-pwa): add dynamic manifest endpoint with blob fallback * feat(ui-pwa): add install prompt and manifest sync hooks * feat(settings): add web-only preinstall app name preference * fix(web-pwa): scope recent shortcuts to active project sessions --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
575cfa2604
commit
ca18b8be0f
@@ -16,6 +16,8 @@ import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup';
|
||||
import { useQueuedMessageAutoSend } from '@/hooks/useQueuedMessageAutoSend';
|
||||
import { useRouter } from '@/hooks/useRouter';
|
||||
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
||||
import { usePwaManifestSync } from '@/hooks/usePwaManifestSync';
|
||||
import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt';
|
||||
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
||||
import { GitPollingProvider } from '@/hooks/useGitPolling';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
@@ -319,6 +321,8 @@ function App({ apis }: AppProps) {
|
||||
useServerSessionStatus({ enabled: embeddedBackgroundWorkEnabled });
|
||||
|
||||
usePushVisibilityBeacon({ enabled: embeddedBackgroundWorkEnabled });
|
||||
usePwaManifestSync();
|
||||
usePwaInstallPrompt();
|
||||
|
||||
useWindowTitle();
|
||||
|
||||
|
||||
@@ -103,8 +103,9 @@ const ShortcutsSectionContent: React.FC = () => {
|
||||
// Visual section: Theme Mode, Font Size, Spacing, Corner Radius, Input Bar Offset (mobile), Nav Rail
|
||||
const VisualSectionContent: React.FC = () => {
|
||||
const isVSCode = isVSCodeRuntime();
|
||||
const visibleSettings: Array<'theme' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'cornerRadius' | 'inputBarOffset' | 'terminalQuickKeys' | 'navRail' | 'mermaidRendering' | 'userMessageRendering' | 'stickyUserHeader'> = [
|
||||
const visibleSettings: Array<'theme' | 'pwaInstallName' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'cornerRadius' | 'inputBarOffset' | 'terminalQuickKeys' | 'navRail' | 'mermaidRendering' | 'userMessageRendering' | 'stickyUserHeader'> = [
|
||||
'theme',
|
||||
'pwaInstallName',
|
||||
'fontSize',
|
||||
'terminalFontSize',
|
||||
'spacing',
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { NumberInput } from '@/components/ui/number-input';
|
||||
import { Radio } from '@/components/ui/radio';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -18,8 +19,9 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { usePwaDetection } from '@/hooks/usePwaDetection';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import {
|
||||
setDirectoryShowHidden,
|
||||
@@ -97,6 +99,13 @@ const MERMAID_RENDERING_OPTIONS: Option<'svg' | 'ascii'>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_PWA_INSTALL_NAME = 'OpenChamber - AI Coding Assistant';
|
||||
|
||||
type PwaInstallNameWindow = Window & {
|
||||
__OPENCHAMBER_SET_PWA_INSTALL_NAME__?: (value: string) => string;
|
||||
__OPENCHAMBER_UPDATE_PWA_MANIFEST__?: () => void;
|
||||
};
|
||||
|
||||
const USER_MESSAGE_RENDERING_OPTIONS: Option<'markdown' | 'plain'>[] = [
|
||||
{
|
||||
id: 'markdown',
|
||||
@@ -114,7 +123,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
|
||||
return mode === 'markdown' ? 'markdown' : 'plain';
|
||||
};
|
||||
|
||||
export type VisibleSetting = 'theme' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'cornerRadius' | 'inputBarOffset' | 'navRail' | 'toolOutput' | 'mermaidRendering' | 'userMessageRendering' | 'stickyUserHeader' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'queueMode' | 'textJustificationActivity' | 'terminalQuickKeys' | 'persistDraft';
|
||||
export type VisibleSetting = 'theme' | 'pwaInstallName' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'cornerRadius' | 'inputBarOffset' | 'navRail' | 'toolOutput' | 'mermaidRendering' | 'userMessageRendering' | 'stickyUserHeader' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'queueMode' | 'textJustificationActivity' | 'terminalQuickKeys' | 'persistDraft';
|
||||
|
||||
interface OpenChamberVisualSettingsProps {
|
||||
/** Which settings to show. If undefined, shows all. */
|
||||
@@ -123,6 +132,7 @@ interface OpenChamberVisualSettingsProps {
|
||||
|
||||
export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps> = ({ visibleSettings }) => {
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const { browserTab } = usePwaDetection();
|
||||
const directoryShowHidden = useDirectoryShowHidden();
|
||||
const showReasoningTraces = useUIStore(state => state.showReasoningTraces);
|
||||
const setShowReasoningTraces = useUIStore(state => state.setShowReasoningTraces);
|
||||
@@ -218,7 +228,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
};
|
||||
|
||||
const isVSCode = isVSCodeRuntime();
|
||||
const hasAppearanceSettings = shouldShow('theme') && !isVSCode;
|
||||
const hasAppearanceSettings = (shouldShow('theme') || shouldShow('pwaInstallName')) && !isVSCode;
|
||||
const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('spacing') || shouldShow('cornerRadius') || shouldShow('inputBarOffset');
|
||||
const hasNavigationSettings = (!isMobile && shouldShow('navRail')) || (shouldShow('terminalQuickKeys') && !isMobile);
|
||||
const hasBehaviorSettings = shouldShow('toolOutput')
|
||||
@@ -232,6 +242,74 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
|| shouldShow('queueMode')
|
||||
|| shouldShow('textJustificationActivity')
|
||||
|| shouldShow('persistDraft');
|
||||
|
||||
const showPwaInstallNameSetting = shouldShow('pwaInstallName') && isWebRuntime() && browserTab;
|
||||
const [pwaInstallName, setPwaInstallName] = React.useState('');
|
||||
|
||||
const applyPwaInstallName = React.useCallback(async (value: string) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const win = window as PwaInstallNameWindow;
|
||||
const normalized = value.trim().replace(/\s+/g, ' ').slice(0, 64);
|
||||
const persistedValue = normalized;
|
||||
|
||||
await updateDesktopSettings({ pwaAppName: persistedValue });
|
||||
|
||||
if (typeof win.__OPENCHAMBER_SET_PWA_INSTALL_NAME__ === 'function') {
|
||||
const resolved = win.__OPENCHAMBER_SET_PWA_INSTALL_NAME__(persistedValue);
|
||||
setPwaInstallName(resolved);
|
||||
return;
|
||||
}
|
||||
|
||||
setPwaInstallName(persistedValue || DEFAULT_PWA_INSTALL_NAME);
|
||||
win.__OPENCHAMBER_UPDATE_PWA_MANIFEST__?.();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined' || !showPwaInstallNameSetting) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const loadPwaInstallName = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/config/settings', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (!cancelled) {
|
||||
setPwaInstallName(DEFAULT_PWA_INSTALL_NAME);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await response.json().catch(() => ({}));
|
||||
const raw = typeof settings?.pwaAppName === 'string' ? settings.pwaAppName : '';
|
||||
const normalized = raw.trim().replace(/\s+/g, ' ').slice(0, 64);
|
||||
|
||||
if (!cancelled) {
|
||||
setPwaInstallName(normalized || DEFAULT_PWA_INSTALL_NAME);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setPwaInstallName(DEFAULT_PWA_INSTALL_NAME);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadPwaInstallName();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [showPwaInstallNameSetting]);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
|
||||
@@ -333,6 +411,48 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{showPwaInstallNameSetting && (
|
||||
<div className={cn('py-1.5', isMobile ? 'space-y-2' : 'flex items-center gap-8')}>
|
||||
<div className={cn('flex min-w-0 flex-col', isMobile ? 'w-full' : 'w-56 shrink-0')}>
|
||||
<span className="typography-ui-label text-foreground">Install App Name</span>
|
||||
<span className="typography-meta text-muted-foreground">Used by Chrome install prompt before install.</span>
|
||||
</div>
|
||||
<div className={cn('flex items-center gap-2', isMobile ? 'w-full' : 'w-fit min-w-[22rem]')}>
|
||||
<Input
|
||||
value={pwaInstallName}
|
||||
onChange={(event) => {
|
||||
setPwaInstallName(event.target.value);
|
||||
}}
|
||||
onBlur={() => {
|
||||
void applyPwaInstallName(pwaInstallName);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void applyPwaInstallName(pwaInstallName);
|
||||
}
|
||||
}}
|
||||
className="h-7"
|
||||
maxLength={64}
|
||||
aria-label="PWA install app name"
|
||||
/>
|
||||
<ButtonSmall
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setPwaInstallName(DEFAULT_PWA_INSTALL_NAME);
|
||||
void applyPwaInstallName('');
|
||||
}}
|
||||
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Reset install app name"
|
||||
title="Reset"
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
import { getPWADisplayMode, type PWADisplayMode } from '@/lib/pwa';
|
||||
|
||||
type PwaDetectionState = {
|
||||
displayMode: PWADisplayMode;
|
||||
installed: boolean;
|
||||
browserTab: boolean;
|
||||
};
|
||||
|
||||
const getState = (): PwaDetectionState => {
|
||||
const displayMode = getPWADisplayMode();
|
||||
return {
|
||||
displayMode,
|
||||
installed: displayMode !== 'browser',
|
||||
browserTab: displayMode === 'browser',
|
||||
};
|
||||
};
|
||||
|
||||
export const usePwaDetection = (): PwaDetectionState => {
|
||||
const [state, setState] = React.useState<PwaDetectionState>(() => getState());
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
const queries = [
|
||||
window.matchMedia('(display-mode: standalone)'),
|
||||
window.matchMedia('(display-mode: minimal-ui)'),
|
||||
window.matchMedia('(display-mode: fullscreen)'),
|
||||
window.matchMedia('(display-mode: window-controls-overlay)'),
|
||||
];
|
||||
|
||||
const onChange = () => {
|
||||
setState(getState());
|
||||
};
|
||||
|
||||
onChange();
|
||||
|
||||
for (const query of queries) {
|
||||
query.addEventListener('change', onChange);
|
||||
}
|
||||
|
||||
window.addEventListener('appinstalled', onChange);
|
||||
window.addEventListener('focus', onChange);
|
||||
|
||||
return () => {
|
||||
for (const query of queries) {
|
||||
query.removeEventListener('change', onChange);
|
||||
}
|
||||
window.removeEventListener('appinstalled', onChange);
|
||||
window.removeEventListener('focus', onChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
import { toast } from '@/components/ui';
|
||||
import { isWebRuntime } from '@/lib/desktop';
|
||||
import { usePwaDetection } from '@/hooks/usePwaDetection';
|
||||
|
||||
type InstallPromptOutcome = 'accepted' | 'dismissed';
|
||||
|
||||
type BeforeInstallPromptEvent = Event & {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: InstallPromptOutcome }>;
|
||||
};
|
||||
|
||||
export const usePwaInstallPrompt = () => {
|
||||
const { browserTab } = usePwaDetection();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined' || !isWebRuntime() || !browserTab) {
|
||||
return;
|
||||
}
|
||||
|
||||
let deferredPrompt: BeforeInstallPromptEvent | null = null;
|
||||
let installToastId: string | number | null = null;
|
||||
|
||||
const dismissInstallToast = () => {
|
||||
if (installToastId === null) {
|
||||
return;
|
||||
}
|
||||
toast.dismiss(installToastId);
|
||||
installToastId = null;
|
||||
};
|
||||
|
||||
const triggerInstall = async () => {
|
||||
if (!deferredPrompt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const promptEvent = deferredPrompt;
|
||||
deferredPrompt = null;
|
||||
dismissInstallToast();
|
||||
|
||||
await promptEvent.prompt();
|
||||
const { outcome } = await promptEvent.userChoice;
|
||||
if (outcome === 'accepted') {
|
||||
toast.success('Install started');
|
||||
}
|
||||
};
|
||||
|
||||
const onBeforeInstallPrompt = (event: Event) => {
|
||||
const installEvent = event as BeforeInstallPromptEvent;
|
||||
if (typeof installEvent.prompt !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
installEvent.preventDefault();
|
||||
deferredPrompt = installEvent;
|
||||
|
||||
if (installToastId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
installToastId = toast.info('Install OpenChamber for quicker access', {
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: 'Install',
|
||||
onClick: () => {
|
||||
void triggerInstall();
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onAppInstalled = () => {
|
||||
deferredPrompt = null;
|
||||
dismissInstallToast();
|
||||
toast.success('OpenChamber installed');
|
||||
};
|
||||
|
||||
window.addEventListener('beforeinstallprompt', onBeforeInstallPrompt as EventListener);
|
||||
window.addEventListener('appinstalled', onAppInstalled);
|
||||
|
||||
return () => {
|
||||
dismissInstallToast();
|
||||
window.removeEventListener('beforeinstallprompt', onBeforeInstallPrompt as EventListener);
|
||||
window.removeEventListener('appinstalled', onAppInstalled);
|
||||
};
|
||||
}, [browserTab]);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { isWebRuntime } from '@/lib/desktop';
|
||||
import { PWA_RECENT_SESSIONS_STORAGE_KEY } from '@/lib/pwa';
|
||||
|
||||
type RecentSessionShortcut = {
|
||||
sessionId: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type ManifestSyncWindow = Window & {
|
||||
__OPENCHAMBER_UPDATE_PWA_MANIFEST__?: () => void;
|
||||
};
|
||||
|
||||
const MAX_RECENT_SHORTCUTS = 3;
|
||||
|
||||
const normalizeRecentTitle = (value: string | undefined, fallback: string): string => {
|
||||
if (typeof value !== 'string') {
|
||||
return fallback;
|
||||
}
|
||||
const normalized = value.trim().replace(/\s+/g, ' ');
|
||||
if (!normalized) {
|
||||
return fallback;
|
||||
}
|
||||
return normalized.slice(0, 48);
|
||||
};
|
||||
|
||||
const buildRecentShortcuts = (
|
||||
sessions: Array<{ id: string; title?: string }>,
|
||||
currentSessionId: string | null,
|
||||
): RecentSessionShortcut[] => {
|
||||
const ordered = currentSessionId
|
||||
? [
|
||||
...sessions.filter((session) => session.id === currentSessionId),
|
||||
...sessions.filter((session) => session.id !== currentSessionId),
|
||||
]
|
||||
: sessions;
|
||||
|
||||
const shortcuts: RecentSessionShortcut[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const session of ordered) {
|
||||
const sessionId = typeof session.id === 'string' ? session.id.trim() : '';
|
||||
if (!sessionId || seen.has(sessionId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(sessionId);
|
||||
shortcuts.push({
|
||||
sessionId,
|
||||
title: normalizeRecentTitle(session.title, `Session ${shortcuts.length + 1}`),
|
||||
});
|
||||
|
||||
if (shortcuts.length >= MAX_RECENT_SHORTCUTS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return shortcuts;
|
||||
};
|
||||
|
||||
export const usePwaManifestSync = () => {
|
||||
const sessions = useSessionStore((state) => state.sessions);
|
||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
||||
|
||||
const recentShortcuts = React.useMemo(() => {
|
||||
return buildRecentShortcuts(sessions, currentSessionId);
|
||||
}, [currentSessionId, sessions]);
|
||||
|
||||
const signature = React.useMemo(() => JSON.stringify(recentShortcuts), [recentShortcuts]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined' || !isWebRuntime()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (recentShortcuts.length === 0) {
|
||||
localStorage.removeItem(PWA_RECENT_SESSIONS_STORAGE_KEY);
|
||||
} else {
|
||||
localStorage.setItem(PWA_RECENT_SESSIONS_STORAGE_KEY, signature);
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const win = window as ManifestSyncWindow;
|
||||
win.__OPENCHAMBER_UPDATE_PWA_MANIFEST__?.();
|
||||
}, [recentShortcuts, signature]);
|
||||
};
|
||||
@@ -537,6 +537,7 @@ export interface SettingsPayload {
|
||||
openInAppId?: string;
|
||||
gitProviderId?: string;
|
||||
gitModelId?: string;
|
||||
pwaAppName?: string;
|
||||
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@ export type DesktopSettings = {
|
||||
zenModel?: string;
|
||||
gitProviderId?: string;
|
||||
gitModelId?: string;
|
||||
pwaAppName?: string;
|
||||
toolCallExpansion?: 'collapsed' | 'activity' | 'detailed';
|
||||
userMessageRenderingMode?: 'markdown' | 'plain';
|
||||
stickyUserHeader?: boolean;
|
||||
|
||||
@@ -74,6 +74,14 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
|
||||
if (typeof settings.openInAppId === 'string' && settings.openInAppId.length > 0) {
|
||||
localStorage.setItem('openInAppId', settings.openInAppId);
|
||||
}
|
||||
if (typeof settings.pwaAppName === 'string') {
|
||||
const normalized = settings.pwaAppName.trim().replace(/\s+/g, ' ').slice(0, 64);
|
||||
if (normalized.length > 0) {
|
||||
localStorage.setItem('openchamber.pwaName', normalized);
|
||||
} else {
|
||||
localStorage.removeItem('openchamber.pwaName');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
type PersistApi = {
|
||||
@@ -784,6 +792,10 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
||||
if (typeof candidate.openInAppId === 'string' && candidate.openInAppId.length > 0) {
|
||||
result.openInAppId = candidate.openInAppId;
|
||||
}
|
||||
if (typeof candidate.pwaAppName === 'string') {
|
||||
const normalized = candidate.pwaAppName.trim().replace(/\s+/g, ' ').slice(0, 64);
|
||||
result.pwaAppName = normalized.length > 0 ? normalized : '';
|
||||
}
|
||||
|
||||
if (typeof candidate.messageLimit === 'number' && Number.isFinite(candidate.messageLimit)) {
|
||||
result.messageLimit = candidate.messageLimit;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
export type PWADisplayMode =
|
||||
| 'browser'
|
||||
| 'standalone'
|
||||
| 'minimal-ui'
|
||||
| 'fullscreen'
|
||||
| 'window-controls-overlay'
|
||||
| 'twa';
|
||||
|
||||
const DISPLAY_MODES: Array<Exclude<PWADisplayMode, 'browser' | 'twa'>> = ['standalone', 'minimal-ui', 'fullscreen', 'window-controls-overlay'];
|
||||
|
||||
export const PWA_INSTALL_NAME_STORAGE_KEY = 'openchamber.pwaName';
|
||||
export const PWA_RECENT_SESSIONS_STORAGE_KEY = 'openchamber.pwaRecentSessions';
|
||||
|
||||
const matchesDisplayMode = (mode: Exclude<PWADisplayMode, 'browser' | 'twa'>): boolean => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
return false;
|
||||
}
|
||||
return window.matchMedia(`(display-mode: ${mode})`).matches;
|
||||
};
|
||||
|
||||
export const getPWADisplayMode = (): PWADisplayMode => {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'browser';
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined' && document.referrer.startsWith('android-app://')) {
|
||||
return 'twa';
|
||||
}
|
||||
|
||||
const navigatorStandalone = Boolean((window.navigator as Navigator & { standalone?: boolean }).standalone);
|
||||
if (navigatorStandalone) {
|
||||
return 'standalone';
|
||||
}
|
||||
|
||||
const matched = DISPLAY_MODES.find((mode) => matchesDisplayMode(mode));
|
||||
return matched ?? 'browser';
|
||||
};
|
||||
|
||||
export const isInstalledPWARuntime = (): boolean => {
|
||||
return getPWADisplayMode() !== 'browser';
|
||||
};
|
||||
@@ -143,7 +143,7 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [
|
||||
title: 'Appearance',
|
||||
group: 'appearance',
|
||||
kind: 'single',
|
||||
keywords: ['theme', 'font', 'spacing', 'padding', 'corner radius', 'radius', 'input bar', 'terminal'],
|
||||
keywords: ['theme', 'font', 'spacing', 'padding', 'corner radius', 'radius', 'input bar', 'terminal', 'pwa', 'install name', 'app shortcuts'],
|
||||
},
|
||||
{
|
||||
slug: 'chat',
|
||||
|
||||
Reference in New Issue
Block a user