diff --git a/client/web/package.json b/client/web/package.json index aa51669c..c8fd4a36 100644 --- a/client/web/package.json +++ b/client/web/package.json @@ -33,6 +33,7 @@ "clsx": "^1.2.1", "compressorjs": "^1.1.1", "copy-to-clipboard": "^3.3.3", + "detect-browser": "^5.3.0", "emoji-mart": "^3.0.1", "immer": "^9.0.16", "is-electron": "^2.2.1", diff --git a/client/web/src/hooks/usePwa.ts b/client/web/src/hooks/usePwa.ts new file mode 100644 index 00000000..7864f0d8 --- /dev/null +++ b/client/web/src/hooks/usePwa.ts @@ -0,0 +1,217 @@ +// Fork from https://github.com/piro0919/use-pwa/blob/master/src/hooks/usePwa/index.ts + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { detect } from 'detect-browser'; + +type PromiseType> = T extends Promise + ? P + : never; + +type BeforeInstallPromptEvent = Event & { + readonly platforms: Array; + readonly userChoice: Promise<{ + outcome: 'accepted' | 'dismissed'; + platform: string; + }>; + prompt(): Promise; +}; + +export type PwaData = { + appinstalled: boolean; + canInstallprompt: boolean; + enabledA2hs: boolean; + enabledPwa: boolean; + enabledUpdate: boolean; + isLoading: boolean; + isPwa: boolean; + showInstallPrompt: () => void; + unregister: () => Promise; + userChoice?: PromiseType; +}; + +export function usePwa(): PwaData { + const beforeinstallprompt = useRef(); + const [appinstalled, setAppinstalled] = useState(false); + const [canInstallprompt, setCanInstallprompt] = useState(false); + const [enabledA2hs, setEnabledA2hs] = useState(false); + const [enabledPwa, setEnabledPwa] = useState(false); + const [isPwa, setIsPwa] = useState(false); + const [enabledUpdate, setEnabledUpdate] = useState(false); + const [userChoice, setUserChoice] = useState(); + const showInstallPrompt = useCallback(async () => { + if (!beforeinstallprompt.current) { + return; + } + + await beforeinstallprompt.current.prompt(); + + if (!beforeinstallprompt.current) { + return; + } + + const userChoice = await beforeinstallprompt.current.userChoice; + + setUserChoice(userChoice); + }, []); + const unregister = useCallback(async () => { + if (!('serviceWorker' in window.navigator)) { + return; + } + + const registration = await window.navigator.serviceWorker.getRegistration(); + + if (!registration) { + return; + } + + const result = await registration.unregister(); + + return result; + }, []); + const handleBeforeInstallPrompt = useCallback( + (event: BeforeInstallPromptEvent) => { + beforeinstallprompt.current = event; + + setCanInstallprompt(true); + }, + [] + ); + const handleAppinstalled = useCallback(() => { + setAppinstalled(true); + }, []); + const [completed, setCompleted] = useState({ + appinstalled: false, + beforeinstallprompt: false, + enabledA2hs: false, + enabledPwa: false, + enabledUpdate: false, + isPwa: false, + }); + const isLoading = useMemo( + () => !Object.values(completed).filter((value) => value).length, + [completed] + ); + + useEffect(() => { + window.addEventListener( + 'beforeinstallprompt', + handleBeforeInstallPrompt as any + ); + + setCompleted((prevCompleted) => ({ + ...prevCompleted, + beforeinstallprompt: true, + })); + + return () => { + window.removeEventListener( + 'beforeinstallprompt', + handleBeforeInstallPrompt as any + ); + }; + }, [handleBeforeInstallPrompt]); + + useEffect(() => { + window.addEventListener('appinstalled', handleAppinstalled); + + setCompleted((prevCompleted) => ({ + ...prevCompleted, + appinstalled: true, + })); + + return () => { + window.removeEventListener('appinstalled', handleAppinstalled); + }; + }, [handleAppinstalled]); + + useEffect(() => { + setEnabledPwa( + 'serviceWorker' in window.navigator && + 'BeforeInstallPromptEvent' in window + ); + + setCompleted((prevCompleted) => ({ + ...prevCompleted, + enabledPwa: true, + })); + }, []); + + useEffect(() => { + setIsPwa( + 'standalone' in window.navigator || + window.matchMedia('(display-mode: standalone)').matches + ); + + setCompleted((prevCompleted) => ({ + ...prevCompleted, + isPwa: true, + })); + }, []); + + useEffect(() => { + try { + const browser = detect(); + + if (!browser) { + return; + } + + const userAgent = window.navigator.userAgent.toLowerCase(); + const isIos = + userAgent.indexOf('iphone') >= 0 || + userAgent.indexOf('ipad') >= 0 || + (userAgent.indexOf('macintosh') >= 0 && 'ontouchend' in document); + const { name } = browser; + + setEnabledA2hs(isIos && name === 'ios'); + } finally { + setCompleted((prevCompleted) => ({ + ...prevCompleted, + enabledA2hs: true, + })); + } + }, []); + + useEffect(() => { + const callback = async () => { + try { + if (!('serviceWorker' in window.navigator)) { + return; + } + + const registration = + await window.navigator.serviceWorker.getRegistration(); + + if (!registration) { + return; + } + + registration.onupdatefound = async () => { + await registration.update(); + + setEnabledUpdate(true); + }; + } finally { + setCompleted((prevCompleted) => ({ + ...prevCompleted, + enabledUpdate: true, + })); + } + }; + + callback(); + }, []); + + return { + appinstalled, + canInstallprompt, + enabledA2hs, + enabledUpdate, + enabledPwa, + isLoading, + isPwa, + showInstallPrompt, + unregister, + userChoice, + }; +} diff --git a/client/web/src/routes/Main/Navbar/InstallBtn.tsx b/client/web/src/routes/Main/Navbar/InstallBtn.tsx new file mode 100644 index 00000000..b58964e4 --- /dev/null +++ b/client/web/src/routes/Main/Navbar/InstallBtn.tsx @@ -0,0 +1,23 @@ +import { usePwa } from '@/hooks/usePwa'; +import React from 'react'; +import { Icon } from 'tailchat-design'; + +/** + * 安装按钮 + */ +export const InstallBtn: React.FC = React.memo(() => { + const { canInstallprompt, showInstallPrompt } = usePwa(); + + if (!canInstallprompt) { + return null; + } + + return ( + + ); +}); +InstallBtn.displayName = 'InstallBtn'; diff --git a/client/web/src/routes/Main/Navbar/index.tsx b/client/web/src/routes/Main/Navbar/index.tsx index c6516bf9..1b03956a 100644 --- a/client/web/src/routes/Main/Navbar/index.tsx +++ b/client/web/src/routes/Main/Navbar/index.tsx @@ -6,6 +6,7 @@ import { Divider } from 'antd'; import { PersonalNav } from './PersonalNav'; import { DevContainer } from '@/components/DevContainer'; import { InboxNav } from './InboxNav'; +import { InstallBtn } from './InstallBtn'; /** * 导航栏组件 @@ -35,7 +36,14 @@ export const Navbar: React.FC = React.memo(() => { -
+
+ {/* 应用(PWA)安装按钮 */} + + + {/* 设置按钮 */}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eef65a1c..9659a311 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -361,6 +361,7 @@ importers: copy-webpack-plugin: ^11.0.0 cross-env: ^7.0.3 css-loader: ^5.2.7 + detect-browser: ^5.3.0 dotenv: ^10.0.0 dts-generator: ^3.0.0 emoji-mart: ^3.0.1 @@ -443,6 +444,7 @@ importers: clsx: 1.2.1 compressorjs: 1.1.1 copy-to-clipboard: 3.3.3 + detect-browser: 5.3.0 emoji-mart: 3.0.1_react@18.2.0 immer: 9.0.16 is-electron: 2.2.1 @@ -16620,6 +16622,10 @@ packages: dependencies: repeat-string: 1.6.1 + /detect-browser/5.3.0: + resolution: {integrity: sha512-53rsFbGdwMwlF7qvCt0ypLM5V5/Mbl0szB7GPN8y9NCcbknYOeVVXdrXEq+90IwAfrrzt6Hd+u2E2ntakICU8w==} + dev: false + /detect-file/1.0.0: resolution: {integrity: sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==} engines: {node: '>=0.10.0'}