@lobehub/chat 0.161.10 → 0.161.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/package.json +2 -1
- package/src/app/(loading)/Redirect.tsx +29 -17
- package/src/app/(main)/_layout/Desktop.tsx +2 -2
- package/src/app/layout.tsx +2 -0
- package/src/const/layoutTokens.ts +1 -0
- package/src/features/PWAInstall/index.tsx +22 -0
- package/src/hooks/usePWAInstall.test.ts +78 -0
- package/src/hooks/usePWAInstall.ts +23 -2
- package/src/hooks/usePlatform.test.ts +82 -0
- package/src/hooks/usePlatform.ts +19 -2
- package/src/layout/AuthProvider/NoAuth/index.tsx +16 -0
- package/src/layout/AuthProvider/index.tsx +2 -1
- package/src/middleware.ts +1 -10
- package/src/services/user/client.test.ts +2 -0
- package/src/services/user/client.ts +4 -0
- package/src/services/user/type.ts +0 -1
- package/src/store/user/slices/auth/selectors.ts +1 -0
- package/src/store/user/slices/common/action.test.ts +2 -1
- package/src/store/user/slices/common/action.ts +8 -1
- package/src/store/user/slices/common/initialState.ts +6 -0
- package/src/types/user/index.ts +2 -0
- package/src/utils/platform.test.ts +83 -0
- package/src/utils/platform.ts +33 -2
- package/src/hooks/useIsPWA.ts +0 -13
- package/src/utils/matchMedia.ts +0 -10
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,56 @@
|
|
|
2
2
|
|
|
3
3
|
# Changelog
|
|
4
4
|
|
|
5
|
+
### [Version 0.161.12](https://github.com/lobehub/lobe-chat/compare/v0.161.11...v0.161.12)
|
|
6
|
+
|
|
7
|
+
<sup>Released on **2024-05-23**</sup>
|
|
8
|
+
|
|
9
|
+
#### ♻ Code Refactoring
|
|
10
|
+
|
|
11
|
+
- **misc**: Refactor the home redirect implement.
|
|
12
|
+
|
|
13
|
+
<br/>
|
|
14
|
+
|
|
15
|
+
<details>
|
|
16
|
+
<summary><kbd>Improvements and Fixes</kbd></summary>
|
|
17
|
+
|
|
18
|
+
#### Code refactoring
|
|
19
|
+
|
|
20
|
+
- **misc**: Refactor the home redirect implement, closes [#2626](https://github.com/lobehub/lobe-chat/issues/2626) ([ab4216e](https://github.com/lobehub/lobe-chat/commit/ab4216e))
|
|
21
|
+
|
|
22
|
+
</details>
|
|
23
|
+
|
|
24
|
+
<div align="right">
|
|
25
|
+
|
|
26
|
+
[](#readme-top)
|
|
27
|
+
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
### [Version 0.161.11](https://github.com/lobehub/lobe-chat/compare/v0.161.10...v0.161.11)
|
|
31
|
+
|
|
32
|
+
<sup>Released on **2024-05-23**</sup>
|
|
33
|
+
|
|
34
|
+
#### 💄 Styles
|
|
35
|
+
|
|
36
|
+
- **misc**: Improve PWA install guide.
|
|
37
|
+
|
|
38
|
+
<br/>
|
|
39
|
+
|
|
40
|
+
<details>
|
|
41
|
+
<summary><kbd>Improvements and Fixes</kbd></summary>
|
|
42
|
+
|
|
43
|
+
#### Styles
|
|
44
|
+
|
|
45
|
+
- **misc**: Improve PWA install guide, closes [#2617](https://github.com/lobehub/lobe-chat/issues/2617) ([7fee545](https://github.com/lobehub/lobe-chat/commit/7fee545))
|
|
46
|
+
|
|
47
|
+
</details>
|
|
48
|
+
|
|
49
|
+
<div align="right">
|
|
50
|
+
|
|
51
|
+
[](#readme-top)
|
|
52
|
+
|
|
53
|
+
</div>
|
|
54
|
+
|
|
5
55
|
### [Version 0.161.10](https://github.com/lobehub/lobe-chat/compare/v0.161.9...v0.161.10)
|
|
6
56
|
|
|
7
57
|
<sup>Released on **2024-05-23**</sup>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lobehub/chat",
|
|
3
|
-
"version": "0.161.
|
|
3
|
+
"version": "0.161.12",
|
|
4
4
|
"description": "Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"framework",
|
|
@@ -94,6 +94,7 @@
|
|
|
94
94
|
"@clerk/themes": "^2.1.6",
|
|
95
95
|
"@google/generative-ai": "^0.11.3",
|
|
96
96
|
"@icons-pack/react-simple-icons": "^9.5.0",
|
|
97
|
+
"@khmyznikov/pwa-install": "^0.3.9",
|
|
97
98
|
"@lobehub/chat-plugin-sdk": "latest",
|
|
98
99
|
"@lobehub/chat-plugins-gateway": "latest",
|
|
99
100
|
"@lobehub/icons": "^1.22.0",
|
|
@@ -3,35 +3,47 @@
|
|
|
3
3
|
import { useRouter } from 'next/navigation';
|
|
4
4
|
import { memo, useEffect } from 'react';
|
|
5
5
|
|
|
6
|
-
import { messageService } from '@/services/message';
|
|
7
|
-
import { sessionService } from '@/services/session';
|
|
8
6
|
import { useUserStore } from '@/store/user';
|
|
9
7
|
import { authSelectors } from '@/store/user/selectors';
|
|
10
8
|
|
|
11
|
-
const checkHasConversation = async () => {
|
|
12
|
-
const hasMessages = await messageService.hasMessages();
|
|
13
|
-
const hasAgents = await sessionService.hasSessions();
|
|
14
|
-
return hasMessages || hasAgents;
|
|
15
|
-
};
|
|
16
|
-
|
|
17
9
|
const Redirect = memo(() => {
|
|
18
10
|
const router = useRouter();
|
|
19
|
-
const isLogin = useUserStore(
|
|
11
|
+
const [isLogin, isLoaded, isUserStateInit, isUserHasConversation, isOnboard] = useUserStore(
|
|
12
|
+
(s) => [
|
|
13
|
+
authSelectors.isLogin(s),
|
|
14
|
+
authSelectors.isLoaded(s),
|
|
15
|
+
s.isUserStateInit,
|
|
16
|
+
s.isUserHasConversation,
|
|
17
|
+
s.isOnboard,
|
|
18
|
+
],
|
|
19
|
+
);
|
|
20
20
|
|
|
21
21
|
useEffect(() => {
|
|
22
|
+
// if user auth state is not ready, wait for loading
|
|
23
|
+
if (!isLoaded) return;
|
|
24
|
+
|
|
25
|
+
// this mean user is definitely not login
|
|
22
26
|
if (!isLogin) {
|
|
23
27
|
router.replace('/welcome');
|
|
24
28
|
return;
|
|
25
29
|
}
|
|
26
30
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
// if user state not init, wait for loading
|
|
32
|
+
if (!isUserStateInit) return;
|
|
33
|
+
|
|
34
|
+
// user need to onboard
|
|
35
|
+
if (!isOnboard) {
|
|
36
|
+
router.replace('/onboard');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// finally check the conversation status
|
|
41
|
+
if (isUserHasConversation) {
|
|
42
|
+
router.replace('/chat');
|
|
43
|
+
} else {
|
|
44
|
+
router.replace('/welcome');
|
|
45
|
+
}
|
|
46
|
+
}, [isUserStateInit, isLoaded, isUserHasConversation, isOnboard, isLogin]);
|
|
35
47
|
|
|
36
48
|
return null;
|
|
37
49
|
});
|
|
@@ -4,12 +4,12 @@ import { useTheme } from 'antd-style';
|
|
|
4
4
|
import { memo } from 'react';
|
|
5
5
|
import { Flexbox } from 'react-layout-kit';
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { usePlatform } from '@/hooks/usePlatform';
|
|
8
8
|
|
|
9
9
|
import { LayoutProps } from './type';
|
|
10
10
|
|
|
11
11
|
const Layout = memo<LayoutProps>(({ children, nav }) => {
|
|
12
|
-
const isPWA =
|
|
12
|
+
const { isPWA } = usePlatform();
|
|
13
13
|
const theme = useTheme();
|
|
14
14
|
|
|
15
15
|
return (
|
package/src/app/layout.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { isRtlLang } from 'rtl-detect';
|
|
|
6
6
|
|
|
7
7
|
import Analytics from '@/components/Analytics';
|
|
8
8
|
import { DEFAULT_LANG, LOBE_LOCALE_COOKIE } from '@/const/locale';
|
|
9
|
+
import PWAInstall from '@/features/PWAInstall';
|
|
9
10
|
import AuthProvider from '@/layout/AuthProvider';
|
|
10
11
|
import GlobalProvider from '@/layout/GlobalProvider';
|
|
11
12
|
import { isMobileDevice } from '@/utils/responsive';
|
|
@@ -31,6 +32,7 @@ const RootLayout = async ({ children, modal }: RootLayoutProps) => {
|
|
|
31
32
|
{children}
|
|
32
33
|
{modal}
|
|
33
34
|
</AuthProvider>
|
|
35
|
+
<PWAInstall />
|
|
34
36
|
</GlobalProvider>
|
|
35
37
|
<Analytics />
|
|
36
38
|
{inVercel && <SpeedInsights />}
|
|
@@ -18,3 +18,4 @@ export const MOBILE_HEADER_ICON_SIZE = { blockSize: 36, fontSize: 22 };
|
|
|
18
18
|
export const DESKTOP_HEADER_ICON_SIZE = { fontSize: 24 };
|
|
19
19
|
export const HEADER_ICON_SIZE = (mobile?: boolean) =>
|
|
20
20
|
mobile ? MOBILE_HEADER_ICON_SIZE : DESKTOP_HEADER_ICON_SIZE;
|
|
21
|
+
export const PWA_INSTALL_ID = 'pwa-install';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import dynamic from 'next/dynamic';
|
|
4
|
+
import { memo } from 'react';
|
|
5
|
+
import { useTranslation } from 'react-i18next';
|
|
6
|
+
|
|
7
|
+
import { PWA_INSTALL_ID } from '@/const/layoutTokens';
|
|
8
|
+
import { usePlatform } from '@/hooks/usePlatform';
|
|
9
|
+
|
|
10
|
+
// @ts-ignore
|
|
11
|
+
const PWA: any = dynamic(() => import('@khmyznikov/pwa-install/dist/pwa-install.react.js'), {
|
|
12
|
+
ssr: false,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const PWAInstall = memo(() => {
|
|
16
|
+
const { t } = useTranslation('metadata');
|
|
17
|
+
const { isPWA } = usePlatform();
|
|
18
|
+
if (isPWA) return null;
|
|
19
|
+
return <PWA description={t('chat.description')} id={PWA_INSTALL_ID} />;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export default PWAInstall;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react';
|
|
2
|
+
import { pwaInstallHandler } from 'pwa-install-handler';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { PWA_INSTALL_ID } from '@/const/layoutTokens';
|
|
6
|
+
|
|
7
|
+
import { usePWAInstall } from './usePWAInstall';
|
|
8
|
+
import { usePlatform } from './usePlatform';
|
|
9
|
+
|
|
10
|
+
// Mocks
|
|
11
|
+
vi.mock('./usePlatform', () => ({
|
|
12
|
+
usePlatform: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock('@/utils/env', () => ({
|
|
16
|
+
isOnServerSide: false,
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
vi.mock('pwa-install-handler', () => ({
|
|
20
|
+
pwaInstallHandler: {
|
|
21
|
+
addListener: vi.fn(),
|
|
22
|
+
removeListener: vi.fn(),
|
|
23
|
+
getEvent: vi.fn(),
|
|
24
|
+
},
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
describe('usePWAInstall', () => {
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
vi.clearAllMocks();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should return canInstall as false when in PWA', () => {
|
|
33
|
+
vi.mocked(usePlatform).mockReturnValue({ isSupportInstallPWA: true, isPWA: true } as any);
|
|
34
|
+
|
|
35
|
+
const { result } = renderHook(() => usePWAInstall());
|
|
36
|
+
|
|
37
|
+
expect(result.current.canInstall).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should return canInstall based on canInstall state when support PWA', () => {
|
|
41
|
+
vi.mocked(usePlatform).mockReturnValue({ isSupportInstallPWA: true, isPWA: false } as any);
|
|
42
|
+
|
|
43
|
+
const { result, rerender } = renderHook(() => usePWAInstall());
|
|
44
|
+
|
|
45
|
+
expect(result.current.canInstall).toBe(false);
|
|
46
|
+
|
|
47
|
+
act(() => {
|
|
48
|
+
vi.mocked(pwaInstallHandler.addListener).mock.calls[0][0](true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
rerender();
|
|
52
|
+
|
|
53
|
+
expect(result.current.canInstall).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should return canInstall as true when not support PWA', () => {
|
|
57
|
+
vi.mocked(usePlatform).mockReturnValue({ isSupportInstallPWA: false, isPWA: false } as any);
|
|
58
|
+
|
|
59
|
+
const { result } = renderHook(() => usePWAInstall());
|
|
60
|
+
|
|
61
|
+
expect(result.current.canInstall).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should call pwa.showDialog when install is called', () => {
|
|
65
|
+
const mockShowDialog = vi.fn();
|
|
66
|
+
document.body.innerHTML = `<div id="${PWA_INSTALL_ID}"></div>`;
|
|
67
|
+
const pwaElement: any = document.querySelector(`#${PWA_INSTALL_ID}`);
|
|
68
|
+
pwaElement.showDialog = mockShowDialog;
|
|
69
|
+
|
|
70
|
+
const { result } = renderHook(() => usePWAInstall());
|
|
71
|
+
|
|
72
|
+
act(() => {
|
|
73
|
+
result.current.install();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(mockShowDialog).toHaveBeenCalledWith(true);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -1,18 +1,39 @@
|
|
|
1
1
|
import { pwaInstallHandler } from 'pwa-install-handler';
|
|
2
2
|
import { useEffect, useState } from 'react';
|
|
3
3
|
|
|
4
|
+
import { PWA_INSTALL_ID } from '@/const/layoutTokens';
|
|
5
|
+
import { isOnServerSide } from '@/utils/env';
|
|
6
|
+
|
|
7
|
+
import { usePlatform } from './usePlatform';
|
|
8
|
+
|
|
4
9
|
export const usePWAInstall = () => {
|
|
5
10
|
const [canInstall, setCanInstall] = useState(false);
|
|
11
|
+
const { isSupportInstallPWA, isPWA } = usePlatform();
|
|
6
12
|
|
|
7
13
|
useEffect(() => {
|
|
14
|
+
if (isOnServerSide) return;
|
|
8
15
|
pwaInstallHandler.addListener(setCanInstall);
|
|
9
16
|
return () => {
|
|
10
17
|
pwaInstallHandler.removeListener(setCanInstall);
|
|
11
18
|
};
|
|
12
19
|
}, []);
|
|
13
20
|
|
|
21
|
+
const installCheck = () => {
|
|
22
|
+
// 当在 PWA 中时,不显示安装按钮
|
|
23
|
+
if (isPWA) return false;
|
|
24
|
+
// 其他情况下,根据是否可以安装来显示安装按钮 (如已经安装则不显示)
|
|
25
|
+
if (isSupportInstallPWA) return canInstall;
|
|
26
|
+
// 当在不支持 PWA 的环境中时,安装按钮 (此时为安装教程)
|
|
27
|
+
return true;
|
|
28
|
+
};
|
|
29
|
+
|
|
14
30
|
return {
|
|
15
|
-
canInstall,
|
|
16
|
-
install:
|
|
31
|
+
canInstall: installCheck(),
|
|
32
|
+
install: () => {
|
|
33
|
+
const pwa: any = document.querySelector(`#${PWA_INSTALL_ID}`);
|
|
34
|
+
if (!pwa) return;
|
|
35
|
+
pwa.externalPromptEvent = pwaInstallHandler.getEvent();
|
|
36
|
+
pwa?.showDialog(true);
|
|
37
|
+
},
|
|
17
38
|
};
|
|
18
39
|
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { renderHook } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import * as platformUtils from '@/utils/platform';
|
|
5
|
+
|
|
6
|
+
import { usePlatform } from './usePlatform';
|
|
7
|
+
|
|
8
|
+
// Mocks
|
|
9
|
+
vi.mock('@/utils/platform', () => ({
|
|
10
|
+
getBrowser: vi.fn(),
|
|
11
|
+
getPlatform: vi.fn(),
|
|
12
|
+
isInStandaloneMode: vi.fn(),
|
|
13
|
+
isSonomaOrLaterSafari: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
describe('usePlatform', () => {
|
|
17
|
+
it('should return correct platform info for Mac OS and Chrome', () => {
|
|
18
|
+
vi.mocked(platformUtils.getPlatform).mockReturnValue('Mac OS');
|
|
19
|
+
vi.mocked(platformUtils.getBrowser).mockReturnValue('Chrome');
|
|
20
|
+
vi.mocked(platformUtils.isInStandaloneMode).mockReturnValue(false);
|
|
21
|
+
vi.mocked(platformUtils.isSonomaOrLaterSafari).mockReturnValue(false);
|
|
22
|
+
|
|
23
|
+
const { result } = renderHook(() => usePlatform());
|
|
24
|
+
|
|
25
|
+
expect(result.current).toEqual({
|
|
26
|
+
isApple: true,
|
|
27
|
+
isChrome: true,
|
|
28
|
+
isChromium: true,
|
|
29
|
+
isEdge: false,
|
|
30
|
+
isIOS: false,
|
|
31
|
+
isMacOS: true,
|
|
32
|
+
isPWA: false,
|
|
33
|
+
isSafari: false,
|
|
34
|
+
isSonomaOrLaterSafari: false,
|
|
35
|
+
isSupportInstallPWA: true,
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should return correct platform info for iOS and Safari', () => {
|
|
40
|
+
vi.mocked(platformUtils.getPlatform).mockReturnValue('iOS');
|
|
41
|
+
vi.mocked(platformUtils.getBrowser).mockReturnValue('Safari');
|
|
42
|
+
vi.mocked(platformUtils.isInStandaloneMode).mockReturnValue(true);
|
|
43
|
+
vi.mocked(platformUtils.isSonomaOrLaterSafari).mockReturnValue(true);
|
|
44
|
+
|
|
45
|
+
const { result } = renderHook(() => usePlatform());
|
|
46
|
+
|
|
47
|
+
expect(result.current).toEqual({
|
|
48
|
+
isApple: true,
|
|
49
|
+
isChrome: false,
|
|
50
|
+
isChromium: false,
|
|
51
|
+
isEdge: false,
|
|
52
|
+
isIOS: true,
|
|
53
|
+
isMacOS: false,
|
|
54
|
+
isPWA: true,
|
|
55
|
+
isSafari: true,
|
|
56
|
+
isSonomaOrLaterSafari: true,
|
|
57
|
+
isSupportInstallPWA: false,
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should return correct platform info for Windows and Edge', () => {
|
|
62
|
+
vi.mocked(platformUtils.getPlatform).mockReturnValue('Windows');
|
|
63
|
+
vi.mocked(platformUtils.getBrowser).mockReturnValue('Edge');
|
|
64
|
+
vi.mocked(platformUtils.isInStandaloneMode).mockReturnValue(false);
|
|
65
|
+
vi.mocked(platformUtils.isSonomaOrLaterSafari).mockReturnValue(false);
|
|
66
|
+
|
|
67
|
+
const { result } = renderHook(() => usePlatform());
|
|
68
|
+
|
|
69
|
+
expect(result.current).toEqual({
|
|
70
|
+
isApple: false,
|
|
71
|
+
isChrome: false,
|
|
72
|
+
isChromium: true,
|
|
73
|
+
isEdge: true,
|
|
74
|
+
isIOS: false,
|
|
75
|
+
isMacOS: false,
|
|
76
|
+
isPWA: false,
|
|
77
|
+
isSafari: false,
|
|
78
|
+
isSonomaOrLaterSafari: false,
|
|
79
|
+
isSupportInstallPWA: true,
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
package/src/hooks/usePlatform.ts
CHANGED
|
@@ -1,15 +1,32 @@
|
|
|
1
1
|
import { useRef } from 'react';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
getBrowser,
|
|
5
|
+
getPlatform,
|
|
6
|
+
isInStandaloneMode,
|
|
7
|
+
isSonomaOrLaterSafari,
|
|
8
|
+
} from '@/utils/platform';
|
|
4
9
|
|
|
5
10
|
export const usePlatform = () => {
|
|
6
11
|
const platform = useRef(getPlatform());
|
|
7
12
|
const browser = useRef(getBrowser());
|
|
8
|
-
|
|
13
|
+
|
|
14
|
+
const platformInfo = {
|
|
9
15
|
isApple: platform.current && ['Mac OS', 'iOS'].includes(platform.current),
|
|
10
16
|
isChrome: browser.current === 'Chrome',
|
|
17
|
+
isChromium: browser.current && ['Chrome', 'Edge', 'Opera', 'Brave'].includes(browser.current),
|
|
18
|
+
isEdge: browser.current === 'Edge',
|
|
11
19
|
isIOS: platform.current === 'iOS',
|
|
12
20
|
isMacOS: platform.current === 'Mac OS',
|
|
21
|
+
isPWA: isInStandaloneMode(),
|
|
13
22
|
isSafari: browser.current === 'Safari',
|
|
23
|
+
isSonomaOrLaterSafari: isSonomaOrLaterSafari(),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
...platformInfo,
|
|
28
|
+
isSupportInstallPWA:
|
|
29
|
+
(platformInfo.isChromium && !platformInfo.isIOS) ||
|
|
30
|
+
(platformInfo.isMacOS && platformInfo.isSonomaOrLaterSafari),
|
|
14
31
|
};
|
|
15
32
|
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { PropsWithChildren, memo } from 'react';
|
|
4
|
+
import { createStoreUpdater } from 'zustand-utils';
|
|
5
|
+
|
|
6
|
+
import { useUserStore } from '@/store/user';
|
|
7
|
+
|
|
8
|
+
const NoAuthProvider = memo<PropsWithChildren>(({ children }) => {
|
|
9
|
+
const useStoreUpdater = createStoreUpdater(useUserStore);
|
|
10
|
+
|
|
11
|
+
useStoreUpdater('isLoaded', true);
|
|
12
|
+
|
|
13
|
+
return children;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export default NoAuthProvider;
|
|
@@ -4,13 +4,14 @@ import { authEnv } from '@/config/auth';
|
|
|
4
4
|
|
|
5
5
|
import Clerk from './Clerk';
|
|
6
6
|
import NextAuth from './NextAuth';
|
|
7
|
+
import NoAuth from './NoAuth';
|
|
7
8
|
|
|
8
9
|
const AuthProvider = ({ children }: PropsWithChildren) => {
|
|
9
10
|
if (authEnv.NEXT_PUBLIC_ENABLE_CLERK_AUTH) return <Clerk>{children}</Clerk>;
|
|
10
11
|
|
|
11
12
|
if (authEnv.NEXT_PUBLIC_ENABLE_NEXT_AUTH) return <NextAuth>{children}</NextAuth>;
|
|
12
13
|
|
|
13
|
-
return children
|
|
14
|
+
return <NoAuth>{children}</NoAuth>;
|
|
14
15
|
};
|
|
15
16
|
|
|
16
17
|
export default AuthProvider;
|
package/src/middleware.ts
CHANGED
|
@@ -41,16 +41,7 @@ const nextAuthMiddleware = auth((req) => {
|
|
|
41
41
|
});
|
|
42
42
|
|
|
43
43
|
export default authEnv.NEXT_PUBLIC_ENABLE_CLERK_AUTH
|
|
44
|
-
?
|
|
45
|
-
clerkMiddleware((auth, request) => {
|
|
46
|
-
// if user is logged in and on the home page, redirect to chat
|
|
47
|
-
if (auth().userId && request.nextUrl.pathname === '/') {
|
|
48
|
-
request.nextUrl.pathname = '/chat';
|
|
49
|
-
return NextResponse.redirect(request.nextUrl);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
return NextResponse.next();
|
|
53
|
-
})
|
|
44
|
+
? clerkMiddleware()
|
|
54
45
|
: authEnv.NEXT_PUBLIC_ENABLE_NEXT_AUTH
|
|
55
46
|
? nextAuthMiddleware
|
|
56
47
|
: defaultMiddleware;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { DeepPartial } from 'utility-types';
|
|
2
2
|
|
|
3
3
|
import { MessageModel } from '@/database/client/models/message';
|
|
4
|
+
import { SessionModel } from '@/database/client/models/session';
|
|
4
5
|
import { UserModel } from '@/database/client/models/user';
|
|
5
6
|
import { GlobalSettings } from '@/types/settings';
|
|
6
7
|
import { UserInitializationState, UserPreference } from '@/types/user';
|
|
@@ -18,10 +19,13 @@ export class ClientService implements IUserService {
|
|
|
18
19
|
async getUserState(): Promise<UserInitializationState> {
|
|
19
20
|
const user = await UserModel.getUser();
|
|
20
21
|
const messageCount = await MessageModel.count();
|
|
22
|
+
const sessionCount = await SessionModel.count();
|
|
21
23
|
|
|
22
24
|
return {
|
|
23
25
|
avatar: user.avatar,
|
|
26
|
+
canEnablePWAGuide: messageCount >= 2,
|
|
24
27
|
canEnableTrace: messageCount >= 4,
|
|
28
|
+
hasConversation: messageCount > 0 || sessionCount > 0,
|
|
25
29
|
isOnboard: true,
|
|
26
30
|
preference: await this.preferenceStorage.getFromLocalStorage(),
|
|
27
31
|
settings: user.settings as GlobalSettings,
|
|
@@ -6,7 +6,6 @@ import { UserInitializationState, UserPreference } from '@/types/user';
|
|
|
6
6
|
export interface IUserService {
|
|
7
7
|
getUserState: () => Promise<UserInitializationState>;
|
|
8
8
|
resetUserSettings: () => Promise<any>;
|
|
9
|
-
updateAvatar: (avatar: string) => Promise<any>;
|
|
10
9
|
updatePreference: (preference: UserPreference) => Promise<any>;
|
|
11
10
|
updateUserSettings: (patch: DeepPartial<GlobalSettings>) => Promise<any>;
|
|
12
11
|
}
|
|
@@ -41,6 +41,7 @@ const isLogin = (s: UserStore) => {
|
|
|
41
41
|
};
|
|
42
42
|
|
|
43
43
|
export const authSelectors = {
|
|
44
|
+
isLoaded: (s: UserStore) => s.isLoaded,
|
|
44
45
|
isLogin,
|
|
45
46
|
isLoginWithAuth: (s: UserStore) => s.isSignedIn,
|
|
46
47
|
isLoginWithClerk: (s: UserStore): boolean => (s.isSignedIn && enableClerk) || false,
|
|
@@ -5,6 +5,7 @@ import { withSWR } from '~test-utils';
|
|
|
5
5
|
|
|
6
6
|
import { DEFAULT_PREFERENCE } from '@/const/user';
|
|
7
7
|
import { userService } from '@/services/user';
|
|
8
|
+
import { ClientService } from '@/services/user/client';
|
|
8
9
|
import { useUserStore } from '@/store/user';
|
|
9
10
|
import { preferenceSelectors } from '@/store/user/selectors';
|
|
10
11
|
import { GlobalServerConfig } from '@/types/serverConfig';
|
|
@@ -36,7 +37,7 @@ describe('createCommonSlice', () => {
|
|
|
36
37
|
const avatar = 'new-avatar';
|
|
37
38
|
|
|
38
39
|
const spyOn = vi.spyOn(result.current, 'refreshUserState');
|
|
39
|
-
const updateAvatarSpy = vi.spyOn(
|
|
40
|
+
const updateAvatarSpy = vi.spyOn(ClientService.prototype, 'updateAvatar');
|
|
40
41
|
|
|
41
42
|
await act(async () => {
|
|
42
43
|
await result.current.updateAvatar(avatar);
|
|
@@ -4,6 +4,7 @@ import type { StateCreator } from 'zustand/vanilla';
|
|
|
4
4
|
|
|
5
5
|
import { DEFAULT_PREFERENCE } from '@/const/user';
|
|
6
6
|
import { userService } from '@/services/user';
|
|
7
|
+
import { ClientService } from '@/services/user/client';
|
|
7
8
|
import type { UserStore } from '@/store/user';
|
|
8
9
|
import type { GlobalServerConfig } from '@/types/serverConfig';
|
|
9
10
|
import type { GlobalSettings } from '@/types/settings';
|
|
@@ -45,7 +46,9 @@ export const createCommonSlice: StateCreator<
|
|
|
45
46
|
await mutate(GET_USER_STATE_KEY);
|
|
46
47
|
},
|
|
47
48
|
updateAvatar: async (avatar) => {
|
|
48
|
-
|
|
49
|
+
const clientService = new ClientService();
|
|
50
|
+
|
|
51
|
+
await clientService.updateAvatar(avatar);
|
|
49
52
|
await get().refreshUserState();
|
|
50
53
|
},
|
|
51
54
|
|
|
@@ -89,8 +92,12 @@ export const createCommonSlice: StateCreator<
|
|
|
89
92
|
{
|
|
90
93
|
defaultSettings,
|
|
91
94
|
enabledNextAuth: serverConfig.enabledOAuthSSO,
|
|
95
|
+
isOnboard: data.isOnboard,
|
|
96
|
+
isShowPWAGuide: data.canEnablePWAGuide,
|
|
92
97
|
isUserCanEnableTrace: data.canEnableTrace,
|
|
98
|
+
isUserHasConversation: data.hasConversation,
|
|
93
99
|
isUserStateInit: true,
|
|
100
|
+
|
|
94
101
|
preference,
|
|
95
102
|
serverLanguageModel: serverConfig.languageModel,
|
|
96
103
|
settings: data.settings || {},
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
export interface CommonState {
|
|
2
|
+
isOnboard: boolean;
|
|
3
|
+
isShowPWAGuide: boolean;
|
|
2
4
|
isUserCanEnableTrace: boolean;
|
|
5
|
+
isUserHasConversation: boolean;
|
|
3
6
|
isUserStateInit: boolean;
|
|
4
7
|
}
|
|
5
8
|
|
|
6
9
|
export const initialCommonState: CommonState = {
|
|
10
|
+
isOnboard: false,
|
|
11
|
+
isShowPWAGuide: false,
|
|
7
12
|
isUserCanEnableTrace: false,
|
|
13
|
+
isUserHasConversation: false,
|
|
8
14
|
isUserStateInit: false,
|
|
9
15
|
};
|
package/src/types/user/index.ts
CHANGED
|
@@ -34,7 +34,9 @@ export interface UserPreference {
|
|
|
34
34
|
|
|
35
35
|
export interface UserInitializationState {
|
|
36
36
|
avatar?: string;
|
|
37
|
+
canEnablePWAGuide?: boolean;
|
|
37
38
|
canEnableTrace?: boolean;
|
|
39
|
+
hasConversation?: boolean;
|
|
38
40
|
isOnboard?: boolean;
|
|
39
41
|
preference: UserPreference;
|
|
40
42
|
settings: DeepPartial<GlobalSettings>;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { isSonomaOrLaterSafari } from './platform';
|
|
4
|
+
|
|
5
|
+
describe('isSonomaOrLaterSafari', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
// 重置 navigator 对象
|
|
8
|
+
vi.stubGlobal('navigator', {
|
|
9
|
+
userAgent:
|
|
10
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Safari/605.1.15',
|
|
11
|
+
maxTouchPoints: 0,
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('should return false when userAgent is not Macintosh', () => {
|
|
16
|
+
vi.stubGlobal('navigator', { userAgent: 'Windows NT 10.0; Win64; x64' });
|
|
17
|
+
expect(isSonomaOrLaterSafari()).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should return false when navigator.maxTouchPoints > 0', () => {
|
|
21
|
+
Object.defineProperty(navigator, 'maxTouchPoints', { value: 1 });
|
|
22
|
+
expect(isSonomaOrLaterSafari()).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should return false when Safari version < 17', () => {
|
|
26
|
+
vi.stubGlobal('navigator', {
|
|
27
|
+
userAgent:
|
|
28
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Safari/605.1.15',
|
|
29
|
+
});
|
|
30
|
+
expect(isSonomaOrLaterSafari()).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should return false when audio codec check fails', () => {
|
|
34
|
+
vi.stubGlobal('navigator', {
|
|
35
|
+
userAgent:
|
|
36
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15',
|
|
37
|
+
});
|
|
38
|
+
vi.spyOn(document, 'createElement').mockReturnValueOnce({
|
|
39
|
+
canPlayType: vi.fn().mockReturnValue(''),
|
|
40
|
+
} as any);
|
|
41
|
+
vi.stubGlobal(
|
|
42
|
+
'OffscreenCanvas',
|
|
43
|
+
class {
|
|
44
|
+
getContext = vi.fn().mockReturnValueOnce(null);
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
expect(isSonomaOrLaterSafari()).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should return false when WebGL check fails', () => {
|
|
51
|
+
vi.stubGlobal('navigator', {
|
|
52
|
+
userAgent:
|
|
53
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15',
|
|
54
|
+
});
|
|
55
|
+
vi.spyOn(document, 'createElement').mockReturnValueOnce({
|
|
56
|
+
canPlayType: vi.fn().mockReturnValue('maybe'),
|
|
57
|
+
} as any);
|
|
58
|
+
vi.stubGlobal(
|
|
59
|
+
'OffscreenCanvas',
|
|
60
|
+
class {
|
|
61
|
+
getContext = vi.fn().mockReturnValueOnce(null);
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
expect(isSonomaOrLaterSafari()).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should return true when all checks pass', () => {
|
|
68
|
+
vi.stubGlobal('navigator', {
|
|
69
|
+
userAgent:
|
|
70
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15',
|
|
71
|
+
});
|
|
72
|
+
vi.spyOn(document, 'createElement').mockReturnValueOnce({
|
|
73
|
+
canPlayType: vi.fn().mockReturnValue('maybe'),
|
|
74
|
+
} as any);
|
|
75
|
+
vi.stubGlobal(
|
|
76
|
+
'OffscreenCanvas',
|
|
77
|
+
class {
|
|
78
|
+
getContext = vi.fn().mockReturnValueOnce({});
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
expect(isSonomaOrLaterSafari()).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
});
|
package/src/utils/platform.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import UAParser from 'ua-parser-js';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
import { isOnServerSide } from '@/utils/env';
|
|
4
|
+
|
|
5
|
+
export const getParser = () => {
|
|
6
|
+
if (isOnServerSide) return new UAParser('Node');
|
|
5
7
|
|
|
6
8
|
let ua = navigator.userAgent;
|
|
7
9
|
return new UAParser(ua);
|
|
@@ -22,3 +24,32 @@ export const browserInfo = {
|
|
|
22
24
|
};
|
|
23
25
|
|
|
24
26
|
export const isMacOS = () => getPlatform() === 'Mac OS';
|
|
27
|
+
|
|
28
|
+
export const isInStandaloneMode = () => {
|
|
29
|
+
if (isOnServerSide) return false;
|
|
30
|
+
return (
|
|
31
|
+
window.matchMedia('(display-mode: standalone)').matches ||
|
|
32
|
+
('standalone' in navigator && (navigator as any).standalone === true)
|
|
33
|
+
);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const isSonomaOrLaterSafari = () => {
|
|
37
|
+
if (isOnServerSide) return false;
|
|
38
|
+
|
|
39
|
+
// refs: https://github.com/khmyznikov/pwa-install/blob/0904788b9d0e34399846f6cb7dbb5efeabb62c20/src/utils.ts#L24
|
|
40
|
+
const userAgent = navigator.userAgent.toLowerCase();
|
|
41
|
+
if (navigator.maxTouchPoints || !/macintosh/.test(userAgent)) return false;
|
|
42
|
+
|
|
43
|
+
// check safari version >= 17
|
|
44
|
+
const version = /version\/(\d{2})\./.exec(userAgent);
|
|
45
|
+
if (!version || !version[1] || !(parseInt(version[1]) >= 17)) return false;
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
// hacky way to detect Sonoma
|
|
49
|
+
const audioCheck = document.createElement('audio').canPlayType('audio/wav; codecs="1"');
|
|
50
|
+
const webGLCheck = new OffscreenCanvas(1, 1).getContext('webgl');
|
|
51
|
+
return Boolean(audioCheck) && Boolean(webGLCheck);
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
};
|
package/src/hooks/useIsPWA.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import { useEffect, useState } from 'react';
|
|
2
|
-
|
|
3
|
-
import { isInStandaloneMode } from '@/utils/matchMedia';
|
|
4
|
-
|
|
5
|
-
export const useIsPWA = () => {
|
|
6
|
-
const [isPWA, setIsPWA] = useState(false);
|
|
7
|
-
|
|
8
|
-
useEffect(() => {
|
|
9
|
-
setIsPWA(isInStandaloneMode());
|
|
10
|
-
}, []);
|
|
11
|
-
|
|
12
|
-
return isPWA;
|
|
13
|
-
};
|
package/src/utils/matchMedia.ts
DELETED