@lobehub/chat 0.152.7 → 0.152.8
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 +25 -0
- package/package.json +1 -1
- package/src/app/(main)/chat/(mobile)/features/SessionHeader.tsx +2 -2
- package/src/app/(main)/chat/features/ShareButton/ShareModal.tsx +2 -2
- package/src/features/AvatarWithUpload/index.tsx +2 -2
- package/src/layout/GlobalProvider/index.tsx +7 -1
- package/src/services/chat.ts +2 -2
- package/src/store/chat/slices/message/selectors.ts +2 -2
- package/src/store/serverConfig/Provider.tsx +3 -2
- package/src/store/serverConfig/selectors.ts +1 -0
- package/src/store/serverConfig/store.ts +1 -0
- package/src/store/user/initialState.ts +5 -3
- package/src/store/user/selectors.ts +1 -1
- package/src/store/user/slices/auth/action.test.ts +118 -0
- package/src/store/user/slices/auth/action.ts +81 -0
- package/src/store/user/slices/auth/initialState.ts +20 -0
- package/src/store/user/slices/auth/selectors.ts +6 -0
- package/src/store/user/slices/common/action.test.ts +0 -223
- package/src/store/user/slices/common/action.ts +3 -112
- package/src/store/user/slices/settings/initialState.ts +0 -2
- package/src/store/user/slices/sync/action.test.ts +150 -0
- package/src/store/user/slices/sync/action.ts +94 -0
- package/src/store/user/slices/{common → sync}/initialState.ts +2 -7
- package/src/store/user/store.ts +11 -2
- package/src/store/user/slices/common/selectors.ts +0 -6
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
# Changelog
|
|
4
4
|
|
|
5
|
+
### [Version 0.152.8](https://github.com/lobehub/lobe-chat/compare/v0.152.7...v0.152.8)
|
|
6
|
+
|
|
7
|
+
<sup>Released on **2024-05-03**</sup>
|
|
8
|
+
|
|
9
|
+
#### ♻ Code Refactoring
|
|
10
|
+
|
|
11
|
+
- **misc**: User store add an auth slice.
|
|
12
|
+
|
|
13
|
+
<br/>
|
|
14
|
+
|
|
15
|
+
<details>
|
|
16
|
+
<summary><kbd>Improvements and Fixes</kbd></summary>
|
|
17
|
+
|
|
18
|
+
#### Code refactoring
|
|
19
|
+
|
|
20
|
+
- **misc**: User store add an auth slice, closes [#2214](https://github.com/lobehub/lobe-chat/issues/2214) ([948b257](https://github.com/lobehub/lobe-chat/commit/948b257))
|
|
21
|
+
|
|
22
|
+
</details>
|
|
23
|
+
|
|
24
|
+
<div align="right">
|
|
25
|
+
|
|
26
|
+
[](#readme-top)
|
|
27
|
+
|
|
28
|
+
</div>
|
|
29
|
+
|
|
5
30
|
### [Version 0.152.7](https://github.com/lobehub/lobe-chat/compare/v0.152.6...v0.152.7)
|
|
6
31
|
|
|
7
32
|
<sup>Released on **2024-05-02**</sup>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lobehub/chat",
|
|
3
|
-
"version": "0.152.
|
|
3
|
+
"version": "0.152.8",
|
|
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",
|
|
@@ -10,7 +10,7 @@ import SyncStatusInspector from '@/features/SyncStatusInspector';
|
|
|
10
10
|
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
|
|
11
11
|
import { useSessionStore } from '@/store/session';
|
|
12
12
|
import { useUserStore } from '@/store/user';
|
|
13
|
-
import {
|
|
13
|
+
import { userProfileSelectors } from '@/store/user/selectors';
|
|
14
14
|
import { mobileHeaderSticky } from '@/styles/mobileHeader';
|
|
15
15
|
|
|
16
16
|
export const useStyles = createStyles(({ css, token }) => ({
|
|
@@ -26,7 +26,7 @@ export const useStyles = createStyles(({ css, token }) => ({
|
|
|
26
26
|
const Header = memo(() => {
|
|
27
27
|
const [createSession] = useSessionStore((s) => [s.createSession]);
|
|
28
28
|
const router = useRouter();
|
|
29
|
-
const avatar = useUserStore(
|
|
29
|
+
const avatar = useUserStore(userProfileSelectors.userAvatar);
|
|
30
30
|
const { showCreateSession } = useServerConfigStore(featureFlagsSelectors);
|
|
31
31
|
|
|
32
32
|
return (
|
|
@@ -7,7 +7,7 @@ import { Flexbox } from 'react-layout-kit';
|
|
|
7
7
|
import { FORM_STYLE } from '@/const/layoutTokens';
|
|
8
8
|
import { useChatStore } from '@/store/chat';
|
|
9
9
|
import { useUserStore } from '@/store/user';
|
|
10
|
-
import {
|
|
10
|
+
import { userProfileSelectors } from '@/store/user/selectors';
|
|
11
11
|
|
|
12
12
|
import Preview from './Preview';
|
|
13
13
|
import { FieldType, ImageType } from './type';
|
|
@@ -49,7 +49,7 @@ const ShareModal = memo<ModalProps>(({ onCancel, open }) => {
|
|
|
49
49
|
const [fieldValue, setFieldValue] = useState<FieldType>(DEFAULT_FIELD_VALUE);
|
|
50
50
|
const [tab, setTab] = useState<Tab>(Tab.Screenshot);
|
|
51
51
|
const { t } = useTranslation('chat');
|
|
52
|
-
const avatar = useUserStore(
|
|
52
|
+
const avatar = useUserStore(userProfileSelectors.userAvatar);
|
|
53
53
|
const [shareLoading, shareToShareGPT] = useChatStore((s) => [s.shareLoading, s.shareToShareGPT]);
|
|
54
54
|
const { loading, onDownload, title } = useScreenshot(fieldValue.imageType);
|
|
55
55
|
|
|
@@ -7,7 +7,7 @@ import { CSSProperties, memo, useCallback } from 'react';
|
|
|
7
7
|
|
|
8
8
|
import { DEFAULT_USER_AVATAR_URL } from '@/const/meta';
|
|
9
9
|
import { useUserStore } from '@/store/user';
|
|
10
|
-
import {
|
|
10
|
+
import { userProfileSelectors } from '@/store/user/selectors';
|
|
11
11
|
import { imageToBase64 } from '@/utils/imageToBase64';
|
|
12
12
|
import { createUploadImageHandler } from '@/utils/uploadFIle';
|
|
13
13
|
|
|
@@ -41,7 +41,7 @@ const AvatarWithUpload = memo<AvatarWithUploadProps>(
|
|
|
41
41
|
({ size = 40, compressSize = 256, style, id }) => {
|
|
42
42
|
const { styles } = useStyle();
|
|
43
43
|
const [avatar, updateAvatar] = useUserStore((s) => [
|
|
44
|
-
|
|
44
|
+
userProfileSelectors.userAvatar(s),
|
|
45
45
|
s.updateAvatar,
|
|
46
46
|
]);
|
|
47
47
|
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { getServerGlobalConfig } from '@/server/globalConfig';
|
|
14
14
|
import { ServerConfigStoreProvider } from '@/store/serverConfig';
|
|
15
15
|
import { getAntdLocale } from '@/utils/locale';
|
|
16
|
+
import { isMobileDevice } from '@/utils/responsive';
|
|
16
17
|
|
|
17
18
|
import AppTheme from './AppTheme';
|
|
18
19
|
import Locale from './Locale';
|
|
@@ -48,6 +49,7 @@ const GlobalLayout = async ({ children }: GlobalLayoutProps) => {
|
|
|
48
49
|
// get default feature flags to use with ssr
|
|
49
50
|
const serverFeatureFlags = getServerFeatureFlagsValue();
|
|
50
51
|
const serverConfig = getServerGlobalConfig();
|
|
52
|
+
const isMobile = isMobileDevice();
|
|
51
53
|
return (
|
|
52
54
|
<StyleRegistry>
|
|
53
55
|
<Locale antdLocale={antdLocale} defaultLang={defaultLang?.value}>
|
|
@@ -57,7 +59,11 @@ const GlobalLayout = async ({ children }: GlobalLayoutProps) => {
|
|
|
57
59
|
defaultPrimaryColor={primaryColor?.value as any}
|
|
58
60
|
>
|
|
59
61
|
<StoreInitialization />
|
|
60
|
-
<ServerConfigStoreProvider
|
|
62
|
+
<ServerConfigStoreProvider
|
|
63
|
+
featureFlags={serverFeatureFlags}
|
|
64
|
+
isMobile={isMobile}
|
|
65
|
+
serverConfig={serverConfig}
|
|
66
|
+
>
|
|
61
67
|
{children}
|
|
62
68
|
</ServerConfigStoreProvider>
|
|
63
69
|
<DebugUI />
|
package/src/services/chat.ts
CHANGED
|
@@ -15,10 +15,10 @@ import { useToolStore } from '@/store/tool';
|
|
|
15
15
|
import { pluginSelectors, toolSelectors } from '@/store/tool/selectors';
|
|
16
16
|
import { useUserStore } from '@/store/user';
|
|
17
17
|
import {
|
|
18
|
-
commonSelectors,
|
|
19
18
|
modelConfigSelectors,
|
|
20
19
|
modelProviderSelectors,
|
|
21
20
|
preferenceSelectors,
|
|
21
|
+
userProfileSelectors,
|
|
22
22
|
} from '@/store/user/selectors';
|
|
23
23
|
import { ChatErrorType } from '@/types/fetch';
|
|
24
24
|
import { ChatMessage } from '@/types/message';
|
|
@@ -482,7 +482,7 @@ class ChatService {
|
|
|
482
482
|
...trace,
|
|
483
483
|
enabled: true,
|
|
484
484
|
tags: [tag, ...(trace?.tags || []), ...tags].filter(Boolean) as string[],
|
|
485
|
-
userId:
|
|
485
|
+
userId: userProfileSelectors.userId(useUserStore.getState()),
|
|
486
486
|
};
|
|
487
487
|
}
|
|
488
488
|
|
|
@@ -8,7 +8,7 @@ import { agentSelectors } from '@/store/agent/selectors';
|
|
|
8
8
|
import { useSessionStore } from '@/store/session';
|
|
9
9
|
import { sessionMetaSelectors } from '@/store/session/selectors';
|
|
10
10
|
import { useUserStore } from '@/store/user';
|
|
11
|
-
import {
|
|
11
|
+
import { userProfileSelectors } from '@/store/user/selectors';
|
|
12
12
|
import { ChatMessage } from '@/types/message';
|
|
13
13
|
import { MetaData } from '@/types/meta';
|
|
14
14
|
import { merge } from '@/utils/merge';
|
|
@@ -20,7 +20,7 @@ const getMeta = (message: ChatMessage) => {
|
|
|
20
20
|
switch (message.role) {
|
|
21
21
|
case 'user': {
|
|
22
22
|
return {
|
|
23
|
-
avatar:
|
|
23
|
+
avatar: userProfileSelectors.userAvatar(useUserStore.getState()) || DEFAULT_USER_AVATAR,
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
26
|
|
|
@@ -10,12 +10,13 @@ import { Provider, createServerConfigStore } from './store';
|
|
|
10
10
|
interface GlobalStoreProviderProps {
|
|
11
11
|
children: ReactNode;
|
|
12
12
|
featureFlags?: Partial<IFeatureFlags>;
|
|
13
|
+
isMobile?: boolean;
|
|
13
14
|
serverConfig?: GlobalServerConfig;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export const ServerConfigStoreProvider = memo<GlobalStoreProviderProps>(
|
|
17
|
-
({ children, featureFlags, serverConfig }) => (
|
|
18
|
-
<Provider createStore={() => createServerConfigStore({ featureFlags, serverConfig })}>
|
|
18
|
+
({ children, featureFlags, serverConfig, isMobile }) => (
|
|
19
|
+
<Provider createStore={() => createServerConfigStore({ featureFlags, isMobile, serverConfig })}>
|
|
19
20
|
{children}
|
|
20
21
|
</Provider>
|
|
21
22
|
),
|
|
@@ -8,4 +8,5 @@ export const featureFlagsSelectors = (s: ServerConfigStore) =>
|
|
|
8
8
|
export const serverConfigSelectors = {
|
|
9
9
|
enabledOAuthSSO: (s: ServerConfigStore) => s.serverConfig.enabledOAuthSSO,
|
|
10
10
|
enabledTelemetryChat: (s: ServerConfigStore) => s.serverConfig.telemetry.langfuse || false,
|
|
11
|
+
isMobile: (s: ServerConfigStore) => s.isMobile || false,
|
|
11
12
|
};
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { UserAuthState, initialAuthState } from './slices/auth/initialState';
|
|
2
2
|
import { UserPreferenceState, initialPreferenceState } from './slices/preference/initialState';
|
|
3
3
|
import { UserSettingsState, initialSettingsState } from './slices/settings/initialState';
|
|
4
|
+
import { UserSyncState, initialSyncState } from './slices/sync/initialState';
|
|
4
5
|
|
|
5
|
-
export type UserState =
|
|
6
|
+
export type UserState = UserSyncState & UserSettingsState & UserPreferenceState & UserAuthState;
|
|
6
7
|
|
|
7
8
|
export const initialState: UserState = {
|
|
8
|
-
...
|
|
9
|
+
...initialSyncState,
|
|
9
10
|
...initialSettingsState,
|
|
10
11
|
...initialPreferenceState,
|
|
12
|
+
...initialAuthState,
|
|
11
13
|
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
2
|
+
import { mutate } from 'swr';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { withSWR } from '~test-utils';
|
|
5
|
+
|
|
6
|
+
import { userService } from '@/services/user';
|
|
7
|
+
import { useUserStore } from '@/store/user';
|
|
8
|
+
import { switchLang } from '@/utils/client/switchLang';
|
|
9
|
+
|
|
10
|
+
vi.mock('zustand/traditional');
|
|
11
|
+
|
|
12
|
+
vi.mock('@/utils/client/switchLang', () => ({
|
|
13
|
+
switchLang: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock('swr', async (importOriginal) => {
|
|
17
|
+
const modules = await importOriginal();
|
|
18
|
+
return {
|
|
19
|
+
...(modules as any),
|
|
20
|
+
mutate: vi.fn(),
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
vi.restoreAllMocks();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('createAuthSlice', () => {
|
|
29
|
+
describe('refreshUserConfig', () => {
|
|
30
|
+
it('should refresh user config', async () => {
|
|
31
|
+
const { result } = renderHook(() => useUserStore());
|
|
32
|
+
|
|
33
|
+
await act(async () => {
|
|
34
|
+
await result.current.refreshUserConfig();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
expect(mutate).toHaveBeenCalledWith(['fetchUserConfig', true]);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('useFetchUserConfig', () => {
|
|
42
|
+
it('should not fetch user config if initServer is false', async () => {
|
|
43
|
+
const mockUserConfig: any = undefined; // 模拟未初始化服务器的情况
|
|
44
|
+
vi.spyOn(userService, 'getUserConfig').mockResolvedValueOnce(mockUserConfig);
|
|
45
|
+
|
|
46
|
+
const { result } = renderHook(() => useUserStore().useFetchUserConfig(false), {
|
|
47
|
+
wrapper: withSWR,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// 因为 initServer 为 false,所以不会触发 getUserConfig 的调用
|
|
51
|
+
expect(userService.getUserConfig).not.toHaveBeenCalled();
|
|
52
|
+
// 确保状态未改变
|
|
53
|
+
expect(result.current.data).toBeUndefined();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should fetch user config correctly when initServer is true', async () => {
|
|
57
|
+
const mockUserConfig: any = {
|
|
58
|
+
avatar: 'new-avatar-url',
|
|
59
|
+
settings: {
|
|
60
|
+
language: 'en',
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
vi.spyOn(userService, 'getUserConfig').mockResolvedValueOnce(mockUserConfig);
|
|
64
|
+
|
|
65
|
+
const { result } = renderHook(() => useUserStore().useFetchUserConfig(true), {
|
|
66
|
+
wrapper: withSWR,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// 等待 SWR 完成数据获取
|
|
70
|
+
await waitFor(() => expect(result.current.data).toEqual(mockUserConfig));
|
|
71
|
+
|
|
72
|
+
// 验证状态是否正确更新
|
|
73
|
+
expect(useUserStore.getState().avatar).toBe(mockUserConfig.avatar);
|
|
74
|
+
expect(useUserStore.getState().settings).toEqual(mockUserConfig.settings);
|
|
75
|
+
|
|
76
|
+
// 验证是否正确处理了语言设置
|
|
77
|
+
expect(switchLang).not.toHaveBeenCalledWith('auto');
|
|
78
|
+
});
|
|
79
|
+
it('should call switch language when language is auto', async () => {
|
|
80
|
+
const mockUserConfig: any = {
|
|
81
|
+
avatar: 'new-avatar-url',
|
|
82
|
+
settings: {
|
|
83
|
+
language: 'auto',
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
vi.spyOn(userService, 'getUserConfig').mockResolvedValueOnce(mockUserConfig);
|
|
87
|
+
|
|
88
|
+
const { result } = renderHook(() => useUserStore().useFetchUserConfig(true), {
|
|
89
|
+
wrapper: withSWR,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// 等待 SWR 完成数据获取
|
|
93
|
+
await waitFor(() => expect(result.current.data).toEqual(mockUserConfig));
|
|
94
|
+
|
|
95
|
+
// 验证状态是否正确更新
|
|
96
|
+
expect(useUserStore.getState().avatar).toBe(mockUserConfig.avatar);
|
|
97
|
+
expect(useUserStore.getState().settings).toEqual(mockUserConfig.settings);
|
|
98
|
+
|
|
99
|
+
// 验证是否正确处理了语言设置
|
|
100
|
+
expect(switchLang).toHaveBeenCalledWith('auto');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should handle the case when user config is null', async () => {
|
|
104
|
+
vi.spyOn(userService, 'getUserConfig').mockResolvedValueOnce(null as any);
|
|
105
|
+
|
|
106
|
+
const { result } = renderHook(() => useUserStore().useFetchUserConfig(true), {
|
|
107
|
+
wrapper: withSWR,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// 等待 SWR 完成数据获取
|
|
111
|
+
await waitFor(() => expect(result.current.data).toBeNull());
|
|
112
|
+
|
|
113
|
+
// 验证状态未被错误更新
|
|
114
|
+
expect(useUserStore.getState().avatar).toBeUndefined();
|
|
115
|
+
expect(useUserStore.getState().settings).toEqual({});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import useSWR, { SWRResponse, mutate } from 'swr';
|
|
2
|
+
import { StateCreator } from 'zustand/vanilla';
|
|
3
|
+
|
|
4
|
+
import { UserConfig, userService } from '@/services/user';
|
|
5
|
+
import { switchLang } from '@/utils/client/switchLang';
|
|
6
|
+
import { setNamespace } from '@/utils/storeDebug';
|
|
7
|
+
|
|
8
|
+
import { UserStore } from '../../store';
|
|
9
|
+
import { settingsSelectors } from '../settings/selectors';
|
|
10
|
+
|
|
11
|
+
const n = setNamespace('auth');
|
|
12
|
+
const USER_CONFIG_FETCH_KEY = 'fetchUserConfig';
|
|
13
|
+
|
|
14
|
+
export interface UserAuthAction {
|
|
15
|
+
getUserConfig: () => void;
|
|
16
|
+
/**
|
|
17
|
+
* universal login method
|
|
18
|
+
*/
|
|
19
|
+
login: () => Promise<void>;
|
|
20
|
+
/**
|
|
21
|
+
* universal logout method
|
|
22
|
+
*/
|
|
23
|
+
logout: () => Promise<void>;
|
|
24
|
+
refreshUserConfig: () => Promise<void>;
|
|
25
|
+
|
|
26
|
+
useFetchUserConfig: (initServer: boolean) => SWRResponse<UserConfig | undefined>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const createAuthSlice: StateCreator<
|
|
30
|
+
UserStore,
|
|
31
|
+
[['zustand/devtools', never]],
|
|
32
|
+
[],
|
|
33
|
+
UserAuthAction
|
|
34
|
+
> = (set, get) => ({
|
|
35
|
+
getUserConfig: () => {
|
|
36
|
+
console.log(n('userconfig'));
|
|
37
|
+
},
|
|
38
|
+
login: async () => {
|
|
39
|
+
// TODO: 针对开启 next-auth 的场景,需要在这里调用登录方法
|
|
40
|
+
console.log(n('login'));
|
|
41
|
+
},
|
|
42
|
+
logout: async () => {
|
|
43
|
+
// TODO: 针对开启 next-auth 的场景,需要在这里调用登录方法
|
|
44
|
+
console.log(n('logout'));
|
|
45
|
+
},
|
|
46
|
+
refreshUserConfig: async () => {
|
|
47
|
+
await mutate([USER_CONFIG_FETCH_KEY, true]);
|
|
48
|
+
|
|
49
|
+
// when get the user config ,refresh the model provider list to the latest
|
|
50
|
+
get().refreshModelProviderList();
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
useFetchUserConfig: (initServer) =>
|
|
54
|
+
useSWR<UserConfig | undefined>(
|
|
55
|
+
[USER_CONFIG_FETCH_KEY, initServer],
|
|
56
|
+
async () => {
|
|
57
|
+
if (!initServer) return;
|
|
58
|
+
return userService.getUserConfig();
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
onSuccess: (data) => {
|
|
62
|
+
if (!data) return;
|
|
63
|
+
|
|
64
|
+
set(
|
|
65
|
+
{ avatar: data.avatar, settings: data.settings, userId: data.uuid },
|
|
66
|
+
false,
|
|
67
|
+
n('fetchUserConfig', data),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// when get the user config ,refresh the model provider list to the latest
|
|
71
|
+
get().refreshDefaultModelProviderList({ trigger: 'fetchUserConfig' });
|
|
72
|
+
|
|
73
|
+
const { language } = settingsSelectors.currentSettings(get());
|
|
74
|
+
if (language === 'auto') {
|
|
75
|
+
switchLang('auto');
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
revalidateOnFocus: false,
|
|
79
|
+
},
|
|
80
|
+
),
|
|
81
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface LobeUser {
|
|
2
|
+
avatar?: string;
|
|
3
|
+
firstName?: string | null;
|
|
4
|
+
fullName?: string | null;
|
|
5
|
+
id: string;
|
|
6
|
+
latestName?: string | null;
|
|
7
|
+
username?: string | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface UserAuthState {
|
|
11
|
+
/**
|
|
12
|
+
* @deprecated
|
|
13
|
+
*/
|
|
14
|
+
avatar?: string;
|
|
15
|
+
isSignedIn?: boolean;
|
|
16
|
+
user?: LobeUser;
|
|
17
|
+
userId?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const initialAuthState: UserAuthState = {};
|
|
@@ -8,17 +8,10 @@ import { messageService } from '@/services/message';
|
|
|
8
8
|
import { userService } from '@/services/user';
|
|
9
9
|
import { useUserStore } from '@/store/user';
|
|
10
10
|
import { preferenceSelectors } from '@/store/user/selectors';
|
|
11
|
-
import { commonSelectors } from '@/store/user/slices/common/selectors';
|
|
12
|
-
import { syncSettingsSelectors } from '@/store/user/slices/settings/selectors';
|
|
13
11
|
import { GlobalServerConfig } from '@/types/serverConfig';
|
|
14
|
-
import { switchLang } from '@/utils/client/switchLang';
|
|
15
12
|
|
|
16
13
|
vi.mock('zustand/traditional');
|
|
17
14
|
|
|
18
|
-
vi.mock('@/utils/client/switchLang', () => ({
|
|
19
|
-
switchLang: vi.fn(),
|
|
20
|
-
}));
|
|
21
|
-
|
|
22
15
|
vi.mock('swr', async (importOriginal) => {
|
|
23
16
|
const modules = await importOriginal();
|
|
24
17
|
return {
|
|
@@ -32,18 +25,6 @@ afterEach(() => {
|
|
|
32
25
|
});
|
|
33
26
|
|
|
34
27
|
describe('createCommonSlice', () => {
|
|
35
|
-
describe('refreshUserConfig', () => {
|
|
36
|
-
it('should refresh user config', async () => {
|
|
37
|
-
const { result } = renderHook(() => useUserStore());
|
|
38
|
-
|
|
39
|
-
await act(async () => {
|
|
40
|
-
await result.current.refreshUserConfig();
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
expect(mutate).toHaveBeenCalledWith(['fetchUserConfig', true]);
|
|
44
|
-
});
|
|
45
|
-
});
|
|
46
|
-
|
|
47
28
|
describe('updateAvatar', () => {
|
|
48
29
|
it('should update avatar', async () => {
|
|
49
30
|
const { result } = renderHook(() => useUserStore());
|
|
@@ -76,167 +57,6 @@ describe('createCommonSlice', () => {
|
|
|
76
57
|
});
|
|
77
58
|
});
|
|
78
59
|
|
|
79
|
-
describe('useFetchUserConfig', () => {
|
|
80
|
-
it('should not fetch user config if initServer is false', async () => {
|
|
81
|
-
const mockUserConfig: any = undefined; // 模拟未初始化服务器的情况
|
|
82
|
-
vi.spyOn(userService, 'getUserConfig').mockResolvedValueOnce(mockUserConfig);
|
|
83
|
-
|
|
84
|
-
const { result } = renderHook(() => useUserStore().useFetchUserConfig(false), {
|
|
85
|
-
wrapper: withSWR,
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
// 因为 initServer 为 false,所以不会触发 getUserConfig 的调用
|
|
89
|
-
expect(userService.getUserConfig).not.toHaveBeenCalled();
|
|
90
|
-
// 确保状态未改变
|
|
91
|
-
expect(result.current.data).toBeUndefined();
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it('should fetch user config correctly when initServer is true', async () => {
|
|
95
|
-
const mockUserConfig: any = {
|
|
96
|
-
avatar: 'new-avatar-url',
|
|
97
|
-
settings: {
|
|
98
|
-
language: 'en',
|
|
99
|
-
},
|
|
100
|
-
};
|
|
101
|
-
vi.spyOn(userService, 'getUserConfig').mockResolvedValueOnce(mockUserConfig);
|
|
102
|
-
|
|
103
|
-
const { result } = renderHook(() => useUserStore().useFetchUserConfig(true), {
|
|
104
|
-
wrapper: withSWR,
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
// 等待 SWR 完成数据获取
|
|
108
|
-
await waitFor(() => expect(result.current.data).toEqual(mockUserConfig));
|
|
109
|
-
|
|
110
|
-
// 验证状态是否正确更新
|
|
111
|
-
expect(useUserStore.getState().avatar).toBe(mockUserConfig.avatar);
|
|
112
|
-
expect(useUserStore.getState().settings).toEqual(mockUserConfig.settings);
|
|
113
|
-
|
|
114
|
-
// 验证是否正确处理了语言设置
|
|
115
|
-
expect(switchLang).not.toHaveBeenCalledWith('auto');
|
|
116
|
-
});
|
|
117
|
-
it('should call switch language when language is auto', async () => {
|
|
118
|
-
const mockUserConfig: any = {
|
|
119
|
-
avatar: 'new-avatar-url',
|
|
120
|
-
settings: {
|
|
121
|
-
language: 'auto',
|
|
122
|
-
},
|
|
123
|
-
};
|
|
124
|
-
vi.spyOn(userService, 'getUserConfig').mockResolvedValueOnce(mockUserConfig);
|
|
125
|
-
|
|
126
|
-
const { result } = renderHook(() => useUserStore().useFetchUserConfig(true), {
|
|
127
|
-
wrapper: withSWR,
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
// 等待 SWR 完成数据获取
|
|
131
|
-
await waitFor(() => expect(result.current.data).toEqual(mockUserConfig));
|
|
132
|
-
|
|
133
|
-
// 验证状态是否正确更新
|
|
134
|
-
expect(useUserStore.getState().avatar).toBe(mockUserConfig.avatar);
|
|
135
|
-
expect(useUserStore.getState().settings).toEqual(mockUserConfig.settings);
|
|
136
|
-
|
|
137
|
-
// 验证是否正确处理了语言设置
|
|
138
|
-
expect(switchLang).toHaveBeenCalledWith('auto');
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it('should handle the case when user config is null', async () => {
|
|
142
|
-
vi.spyOn(userService, 'getUserConfig').mockResolvedValueOnce(null as any);
|
|
143
|
-
|
|
144
|
-
const { result } = renderHook(() => useUserStore().useFetchUserConfig(true), {
|
|
145
|
-
wrapper: withSWR,
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
// 等待 SWR 完成数据获取
|
|
149
|
-
await waitFor(() => expect(result.current.data).toBeNull());
|
|
150
|
-
|
|
151
|
-
// 验证状态未被错误更新
|
|
152
|
-
expect(useUserStore.getState().avatar).toBeUndefined();
|
|
153
|
-
expect(useUserStore.getState().settings).toEqual({});
|
|
154
|
-
});
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
describe('refreshConnection', () => {
|
|
158
|
-
it('should not call triggerEnableSync when userId is empty', async () => {
|
|
159
|
-
const { result } = renderHook(() => useUserStore());
|
|
160
|
-
const onEvent = vi.fn();
|
|
161
|
-
|
|
162
|
-
vi.spyOn(commonSelectors, 'userId').mockReturnValueOnce(undefined);
|
|
163
|
-
const triggerEnableSyncSpy = vi.spyOn(result.current, 'triggerEnableSync');
|
|
164
|
-
|
|
165
|
-
await act(async () => {
|
|
166
|
-
await result.current.refreshConnection(onEvent);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
expect(triggerEnableSyncSpy).not.toHaveBeenCalled();
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it('should call triggerEnableSync when userId exists', async () => {
|
|
173
|
-
const { result } = renderHook(() => useUserStore());
|
|
174
|
-
const onEvent = vi.fn();
|
|
175
|
-
const userId = 'user-id';
|
|
176
|
-
|
|
177
|
-
vi.spyOn(commonSelectors, 'userId').mockReturnValueOnce(userId);
|
|
178
|
-
const triggerEnableSyncSpy = vi.spyOn(result.current, 'triggerEnableSync');
|
|
179
|
-
|
|
180
|
-
await act(async () => {
|
|
181
|
-
await result.current.refreshConnection(onEvent);
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
expect(triggerEnableSyncSpy).toHaveBeenCalledWith(userId, onEvent);
|
|
185
|
-
});
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
describe('triggerEnableSync', () => {
|
|
189
|
-
it('should return false when sync.channelName is empty', async () => {
|
|
190
|
-
const { result } = renderHook(() => useUserStore());
|
|
191
|
-
const userId = 'user-id';
|
|
192
|
-
const onEvent = vi.fn();
|
|
193
|
-
|
|
194
|
-
vi.spyOn(syncSettingsSelectors, 'webrtcConfig').mockReturnValueOnce({
|
|
195
|
-
channelName: '',
|
|
196
|
-
enabled: true,
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
const data = await act(async () => {
|
|
200
|
-
return result.current.triggerEnableSync(userId, onEvent);
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
expect(data).toBe(false);
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
it('should call globalService.enabledSync when sync.channelName exists', async () => {
|
|
207
|
-
const userId = 'user-id';
|
|
208
|
-
const onEvent = vi.fn();
|
|
209
|
-
const channelName = 'channel-name';
|
|
210
|
-
const channelPassword = 'channel-password';
|
|
211
|
-
const deviceName = 'device-name';
|
|
212
|
-
const signaling = 'signaling';
|
|
213
|
-
|
|
214
|
-
vi.spyOn(syncSettingsSelectors, 'webrtcConfig').mockReturnValueOnce({
|
|
215
|
-
channelName,
|
|
216
|
-
channelPassword,
|
|
217
|
-
signaling,
|
|
218
|
-
enabled: true,
|
|
219
|
-
});
|
|
220
|
-
vi.spyOn(syncSettingsSelectors, 'deviceName').mockReturnValueOnce(deviceName);
|
|
221
|
-
const enabledSyncSpy = vi.spyOn(globalService, 'enabledSync').mockResolvedValueOnce(true);
|
|
222
|
-
const { result } = renderHook(() => useUserStore());
|
|
223
|
-
|
|
224
|
-
const data = await act(async () => {
|
|
225
|
-
return result.current.triggerEnableSync(userId, onEvent);
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
expect(enabledSyncSpy).toHaveBeenCalledWith({
|
|
229
|
-
channel: { name: channelName, password: channelPassword },
|
|
230
|
-
onAwarenessChange: expect.any(Function),
|
|
231
|
-
onSyncEvent: onEvent,
|
|
232
|
-
onSyncStatusChange: expect.any(Function),
|
|
233
|
-
signaling,
|
|
234
|
-
user: expect.objectContaining({ id: userId, name: deviceName }),
|
|
235
|
-
});
|
|
236
|
-
expect(data).toBe(true);
|
|
237
|
-
});
|
|
238
|
-
});
|
|
239
|
-
|
|
240
60
|
describe('useCheckTrace', () => {
|
|
241
61
|
it('should return false when shouldFetch is false', async () => {
|
|
242
62
|
const { result } = renderHook(() => useUserStore().useCheckTrace(false), {
|
|
@@ -270,47 +90,4 @@ describe('createCommonSlice', () => {
|
|
|
270
90
|
expect(messageCountToCheckTraceSpy).toHaveBeenCalled();
|
|
271
91
|
});
|
|
272
92
|
});
|
|
273
|
-
|
|
274
|
-
describe('useEnabledSync', () => {
|
|
275
|
-
it('should return false when userId is empty', async () => {
|
|
276
|
-
const { result } = renderHook(() => useUserStore().useEnabledSync(true, undefined, vi.fn()), {
|
|
277
|
-
wrapper: withSWR,
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
await waitFor(() => expect(result.current.data).toBe(false));
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
it('should call globalService.disableSync when userEnableSync is false', async () => {
|
|
284
|
-
const disableSyncSpy = vi.spyOn(globalService, 'disableSync').mockResolvedValueOnce(false);
|
|
285
|
-
|
|
286
|
-
const { result } = renderHook(
|
|
287
|
-
() => useUserStore().useEnabledSync(false, 'user-id', vi.fn()),
|
|
288
|
-
{ wrapper: withSWR },
|
|
289
|
-
);
|
|
290
|
-
|
|
291
|
-
await waitFor(() => expect(result.current.data).toBeUndefined());
|
|
292
|
-
expect(disableSyncSpy).toHaveBeenCalled();
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
it('should call triggerEnableSync when userEnableSync and userId exist', async () => {
|
|
296
|
-
const userId = 'user-id';
|
|
297
|
-
const onEvent = vi.fn();
|
|
298
|
-
const triggerEnableSyncSpy = vi.fn().mockResolvedValueOnce(true);
|
|
299
|
-
|
|
300
|
-
const { result } = renderHook(() => useUserStore());
|
|
301
|
-
|
|
302
|
-
// replace triggerEnableSync as a mock
|
|
303
|
-
result.current.triggerEnableSync = triggerEnableSyncSpy;
|
|
304
|
-
|
|
305
|
-
const { result: swrResult } = renderHook(
|
|
306
|
-
() => result.current.useEnabledSync(true, userId, onEvent),
|
|
307
|
-
{
|
|
308
|
-
wrapper: withSWR,
|
|
309
|
-
},
|
|
310
|
-
);
|
|
311
|
-
|
|
312
|
-
await waitFor(() => expect(swrResult.current.data).toBe(true));
|
|
313
|
-
expect(triggerEnableSyncSpy).toHaveBeenCalledWith(userId, onEvent);
|
|
314
|
-
});
|
|
315
|
-
});
|
|
316
93
|
});
|
|
@@ -1,22 +1,17 @@
|
|
|
1
|
-
import useSWR, { SWRResponse
|
|
1
|
+
import useSWR, { SWRResponse } from 'swr';
|
|
2
2
|
import { DeepPartial } from 'utility-types';
|
|
3
3
|
import type { StateCreator } from 'zustand/vanilla';
|
|
4
4
|
|
|
5
5
|
import { globalService } from '@/services/global';
|
|
6
6
|
import { messageService } from '@/services/message';
|
|
7
|
-
import {
|
|
7
|
+
import { userService } from '@/services/user';
|
|
8
8
|
import type { UserStore } from '@/store/user';
|
|
9
9
|
import type { GlobalServerConfig } from '@/types/serverConfig';
|
|
10
10
|
import type { GlobalSettings } from '@/types/settings';
|
|
11
|
-
import { OnSyncEvent, PeerSyncStatus } from '@/types/sync';
|
|
12
|
-
import { switchLang } from '@/utils/client/switchLang';
|
|
13
11
|
import { merge } from '@/utils/merge';
|
|
14
|
-
import { browserInfo } from '@/utils/platform';
|
|
15
12
|
import { setNamespace } from '@/utils/storeDebug';
|
|
16
13
|
|
|
17
14
|
import { preferenceSelectors } from '../preference/selectors';
|
|
18
|
-
import { settingsSelectors, syncSettingsSelectors } from '../settings/selectors';
|
|
19
|
-
import { commonSelectors } from './selectors';
|
|
20
15
|
|
|
21
16
|
const n = setNamespace('common');
|
|
22
17
|
|
|
@@ -24,79 +19,22 @@ const n = setNamespace('common');
|
|
|
24
19
|
* 设置操作
|
|
25
20
|
*/
|
|
26
21
|
export interface CommonAction {
|
|
27
|
-
refreshConnection: (onEvent: OnSyncEvent) => Promise<void>;
|
|
28
|
-
refreshUserConfig: () => Promise<void>;
|
|
29
|
-
triggerEnableSync: (userId: string, onEvent: OnSyncEvent) => Promise<boolean>;
|
|
30
22
|
updateAvatar: (avatar: string) => Promise<void>;
|
|
31
23
|
useCheckTrace: (shouldFetch: boolean) => SWRResponse;
|
|
32
|
-
useEnabledSync: (
|
|
33
|
-
userEnableSync: boolean,
|
|
34
|
-
userId: string | undefined,
|
|
35
|
-
onEvent: OnSyncEvent,
|
|
36
|
-
) => SWRResponse;
|
|
37
24
|
useFetchServerConfig: () => SWRResponse;
|
|
38
|
-
useFetchUserConfig: (initServer: boolean) => SWRResponse<UserConfig | undefined>;
|
|
39
25
|
}
|
|
40
26
|
|
|
41
|
-
const USER_CONFIG_FETCH_KEY = 'fetchUserConfig';
|
|
42
|
-
|
|
43
27
|
export const createCommonSlice: StateCreator<
|
|
44
28
|
UserStore,
|
|
45
29
|
[['zustand/devtools', never]],
|
|
46
30
|
[],
|
|
47
31
|
CommonAction
|
|
48
32
|
> = (set, get) => ({
|
|
49
|
-
refreshConnection: async (onEvent) => {
|
|
50
|
-
const userId = commonSelectors.userId(get());
|
|
51
|
-
|
|
52
|
-
if (!userId) return;
|
|
53
|
-
|
|
54
|
-
await get().triggerEnableSync(userId, onEvent);
|
|
55
|
-
},
|
|
56
|
-
|
|
57
|
-
refreshUserConfig: async () => {
|
|
58
|
-
await mutate([USER_CONFIG_FETCH_KEY, true]);
|
|
59
|
-
|
|
60
|
-
// when get the user config ,refresh the model provider list to the latest
|
|
61
|
-
get().refreshModelProviderList();
|
|
62
|
-
},
|
|
63
|
-
|
|
64
|
-
triggerEnableSync: async (userId: string, onEvent: OnSyncEvent) => {
|
|
65
|
-
// double-check the sync ability
|
|
66
|
-
// if there is no channelName, don't start sync
|
|
67
|
-
const sync = syncSettingsSelectors.webrtcConfig(get());
|
|
68
|
-
if (!sync.channelName) return false;
|
|
69
|
-
|
|
70
|
-
const name = syncSettingsSelectors.deviceName(get());
|
|
71
|
-
|
|
72
|
-
const defaultUserName = `My ${browserInfo.browser} (${browserInfo.os})`;
|
|
73
|
-
|
|
74
|
-
set({ syncStatus: PeerSyncStatus.Connecting });
|
|
75
|
-
return globalService.enabledSync({
|
|
76
|
-
channel: {
|
|
77
|
-
name: sync.channelName,
|
|
78
|
-
password: sync.channelPassword,
|
|
79
|
-
},
|
|
80
|
-
onAwarenessChange(state) {
|
|
81
|
-
set({ syncAwareness: state });
|
|
82
|
-
},
|
|
83
|
-
onSyncEvent: onEvent,
|
|
84
|
-
onSyncStatusChange: (status) => {
|
|
85
|
-
set({ syncStatus: status });
|
|
86
|
-
},
|
|
87
|
-
signaling: sync.signaling,
|
|
88
|
-
user: {
|
|
89
|
-
id: userId,
|
|
90
|
-
// if user don't set the name, use default name
|
|
91
|
-
name: name || defaultUserName,
|
|
92
|
-
...browserInfo,
|
|
93
|
-
},
|
|
94
|
-
});
|
|
95
|
-
},
|
|
96
33
|
updateAvatar: async (avatar) => {
|
|
97
34
|
await userService.updateAvatar(avatar);
|
|
98
35
|
await get().refreshUserConfig();
|
|
99
36
|
},
|
|
37
|
+
|
|
100
38
|
useCheckTrace: (shouldFetch) =>
|
|
101
39
|
useSWR<boolean>(
|
|
102
40
|
['checkTrace', shouldFetch],
|
|
@@ -115,25 +53,6 @@ export const createCommonSlice: StateCreator<
|
|
|
115
53
|
},
|
|
116
54
|
),
|
|
117
55
|
|
|
118
|
-
useEnabledSync: (userEnableSync, userId, onEvent) =>
|
|
119
|
-
useSWR<boolean>(
|
|
120
|
-
['enableSync', userEnableSync, userId],
|
|
121
|
-
async () => {
|
|
122
|
-
// if user don't enable sync or no userId ,don't start sync
|
|
123
|
-
if (!userId) return false;
|
|
124
|
-
|
|
125
|
-
// if user don't enable sync, stop sync
|
|
126
|
-
if (!userEnableSync) return globalService.disableSync();
|
|
127
|
-
|
|
128
|
-
return get().triggerEnableSync(userId, onEvent);
|
|
129
|
-
},
|
|
130
|
-
{
|
|
131
|
-
onSuccess: (syncEnabled) => {
|
|
132
|
-
set({ syncEnabled }, false, n('useEnabledSync'));
|
|
133
|
-
},
|
|
134
|
-
revalidateOnFocus: false,
|
|
135
|
-
},
|
|
136
|
-
),
|
|
137
56
|
useFetchServerConfig: () =>
|
|
138
57
|
useSWR<GlobalServerConfig>('fetchGlobalConfig', globalService.getGlobalConfig, {
|
|
139
58
|
onSuccess: (data) => {
|
|
@@ -152,32 +71,4 @@ export const createCommonSlice: StateCreator<
|
|
|
152
71
|
},
|
|
153
72
|
revalidateOnFocus: false,
|
|
154
73
|
}),
|
|
155
|
-
useFetchUserConfig: (initServer) =>
|
|
156
|
-
useSWR<UserConfig | undefined>(
|
|
157
|
-
[USER_CONFIG_FETCH_KEY, initServer],
|
|
158
|
-
async () => {
|
|
159
|
-
if (!initServer) return;
|
|
160
|
-
return userService.getUserConfig();
|
|
161
|
-
},
|
|
162
|
-
{
|
|
163
|
-
onSuccess: (data) => {
|
|
164
|
-
if (!data) return;
|
|
165
|
-
|
|
166
|
-
set(
|
|
167
|
-
{ avatar: data.avatar, settings: data.settings, userId: data.uuid },
|
|
168
|
-
false,
|
|
169
|
-
n('fetchUserConfig', data),
|
|
170
|
-
);
|
|
171
|
-
|
|
172
|
-
// when get the user config ,refresh the model provider list to the latest
|
|
173
|
-
get().refreshDefaultModelProviderList({ trigger: 'fetchUserConfig' });
|
|
174
|
-
|
|
175
|
-
const { language } = settingsSelectors.currentSettings(get());
|
|
176
|
-
if (language === 'auto') {
|
|
177
|
-
switchLang('auto');
|
|
178
|
-
}
|
|
179
|
-
},
|
|
180
|
-
revalidateOnFocus: false,
|
|
181
|
-
},
|
|
182
|
-
),
|
|
183
74
|
});
|
|
@@ -7,14 +7,12 @@ import { GlobalServerConfig } from '@/types/serverConfig';
|
|
|
7
7
|
import { GlobalSettings } from '@/types/settings';
|
|
8
8
|
|
|
9
9
|
export interface UserSettingsState {
|
|
10
|
-
avatar?: string;
|
|
11
10
|
defaultModelProviderList: ModelProviderCard[];
|
|
12
11
|
defaultSettings: GlobalSettings;
|
|
13
12
|
editingCustomCardModel?: { id: string; provider: string } | undefined;
|
|
14
13
|
modelProviderList: ModelProviderCard[];
|
|
15
14
|
serverConfig: GlobalServerConfig;
|
|
16
15
|
settings: DeepPartial<GlobalSettings>;
|
|
17
|
-
userId?: string;
|
|
18
16
|
}
|
|
19
17
|
|
|
20
18
|
export const initialSettingsState: UserSettingsState = {
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { withSWR } from '~test-utils';
|
|
4
|
+
|
|
5
|
+
import { globalService } from '@/services/global';
|
|
6
|
+
import { useUserStore } from '@/store/user';
|
|
7
|
+
import { userProfileSelectors } from '@/store/user/slices/auth/selectors';
|
|
8
|
+
import { syncSettingsSelectors } from '@/store/user/slices/settings/selectors';
|
|
9
|
+
|
|
10
|
+
vi.mock('zustand/traditional');
|
|
11
|
+
|
|
12
|
+
vi.mock('swr', async (importOriginal) => {
|
|
13
|
+
const modules = await importOriginal();
|
|
14
|
+
return {
|
|
15
|
+
...(modules as any),
|
|
16
|
+
mutate: vi.fn(),
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
vi.restoreAllMocks();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('createSyncSlice', () => {
|
|
25
|
+
describe('refreshConnection', () => {
|
|
26
|
+
it('should not call triggerEnableSync when userId is empty', async () => {
|
|
27
|
+
const { result } = renderHook(() => useUserStore());
|
|
28
|
+
const onEvent = vi.fn();
|
|
29
|
+
|
|
30
|
+
vi.spyOn(userProfileSelectors, 'userId').mockReturnValueOnce(undefined as any);
|
|
31
|
+
const triggerEnableSyncSpy = vi.spyOn(result.current, 'triggerEnableSync');
|
|
32
|
+
|
|
33
|
+
await act(async () => {
|
|
34
|
+
await result.current.refreshConnection(onEvent);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
expect(triggerEnableSyncSpy).not.toHaveBeenCalled();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should call triggerEnableSync when userId exists', async () => {
|
|
41
|
+
const { result } = renderHook(() => useUserStore());
|
|
42
|
+
const onEvent = vi.fn();
|
|
43
|
+
const userId = 'user-id';
|
|
44
|
+
|
|
45
|
+
vi.spyOn(userProfileSelectors, 'userId').mockReturnValueOnce(userId);
|
|
46
|
+
const triggerEnableSyncSpy = vi.spyOn(result.current, 'triggerEnableSync');
|
|
47
|
+
|
|
48
|
+
await act(async () => {
|
|
49
|
+
await result.current.refreshConnection(onEvent);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(triggerEnableSyncSpy).toHaveBeenCalledWith(userId, onEvent);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('triggerEnableSync', () => {
|
|
57
|
+
it('should return false when sync.channelName is empty', async () => {
|
|
58
|
+
const { result } = renderHook(() => useUserStore());
|
|
59
|
+
const userId = 'user-id';
|
|
60
|
+
const onEvent = vi.fn();
|
|
61
|
+
|
|
62
|
+
vi.spyOn(syncSettingsSelectors, 'webrtcConfig').mockReturnValueOnce({
|
|
63
|
+
channelName: '',
|
|
64
|
+
enabled: true,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const data = await act(async () => {
|
|
68
|
+
return result.current.triggerEnableSync(userId, onEvent);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(data).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should call globalService.enabledSync when sync.channelName exists', async () => {
|
|
75
|
+
const userId = 'user-id';
|
|
76
|
+
const onEvent = vi.fn();
|
|
77
|
+
const channelName = 'channel-name';
|
|
78
|
+
const channelPassword = 'channel-password';
|
|
79
|
+
const deviceName = 'device-name';
|
|
80
|
+
const signaling = 'signaling';
|
|
81
|
+
|
|
82
|
+
vi.spyOn(syncSettingsSelectors, 'webrtcConfig').mockReturnValueOnce({
|
|
83
|
+
channelName,
|
|
84
|
+
channelPassword,
|
|
85
|
+
signaling,
|
|
86
|
+
enabled: true,
|
|
87
|
+
});
|
|
88
|
+
vi.spyOn(syncSettingsSelectors, 'deviceName').mockReturnValueOnce(deviceName);
|
|
89
|
+
const enabledSyncSpy = vi.spyOn(globalService, 'enabledSync').mockResolvedValueOnce(true);
|
|
90
|
+
const { result } = renderHook(() => useUserStore());
|
|
91
|
+
|
|
92
|
+
const data = await act(async () => {
|
|
93
|
+
return result.current.triggerEnableSync(userId, onEvent);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(enabledSyncSpy).toHaveBeenCalledWith({
|
|
97
|
+
channel: { name: channelName, password: channelPassword },
|
|
98
|
+
onAwarenessChange: expect.any(Function),
|
|
99
|
+
onSyncEvent: onEvent,
|
|
100
|
+
onSyncStatusChange: expect.any(Function),
|
|
101
|
+
signaling,
|
|
102
|
+
user: expect.objectContaining({ id: userId, name: deviceName }),
|
|
103
|
+
});
|
|
104
|
+
expect(data).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('useEnabledSync', () => {
|
|
109
|
+
it('should return false when userId is empty', async () => {
|
|
110
|
+
const { result } = renderHook(() => useUserStore().useEnabledSync(true, undefined, vi.fn()), {
|
|
111
|
+
wrapper: withSWR,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await waitFor(() => expect(result.current.data).toBe(false));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should call globalService.disableSync when userEnableSync is false', async () => {
|
|
118
|
+
const disableSyncSpy = vi.spyOn(globalService, 'disableSync').mockResolvedValueOnce(false);
|
|
119
|
+
|
|
120
|
+
const { result } = renderHook(
|
|
121
|
+
() => useUserStore().useEnabledSync(false, 'user-id', vi.fn()),
|
|
122
|
+
{ wrapper: withSWR },
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
await waitFor(() => expect(result.current.data).toBeUndefined());
|
|
126
|
+
expect(disableSyncSpy).toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should call triggerEnableSync when userEnableSync and userId exist', async () => {
|
|
130
|
+
const userId = 'user-id';
|
|
131
|
+
const onEvent = vi.fn();
|
|
132
|
+
const triggerEnableSyncSpy = vi.fn().mockResolvedValueOnce(true);
|
|
133
|
+
|
|
134
|
+
const { result } = renderHook(() => useUserStore());
|
|
135
|
+
|
|
136
|
+
// replace triggerEnableSync as a mock
|
|
137
|
+
result.current.triggerEnableSync = triggerEnableSyncSpy;
|
|
138
|
+
|
|
139
|
+
const { result: swrResult } = renderHook(
|
|
140
|
+
() => result.current.useEnabledSync(true, userId, onEvent),
|
|
141
|
+
{
|
|
142
|
+
wrapper: withSWR,
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
await waitFor(() => expect(swrResult.current.data).toBe(true));
|
|
147
|
+
expect(triggerEnableSyncSpy).toHaveBeenCalledWith(userId, onEvent);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import useSWR, { SWRResponse } from 'swr';
|
|
2
|
+
import type { StateCreator } from 'zustand/vanilla';
|
|
3
|
+
|
|
4
|
+
import { globalService } from '@/services/global';
|
|
5
|
+
import type { UserStore } from '@/store/user';
|
|
6
|
+
import { OnSyncEvent, PeerSyncStatus } from '@/types/sync';
|
|
7
|
+
import { browserInfo } from '@/utils/platform';
|
|
8
|
+
import { setNamespace } from '@/utils/storeDebug';
|
|
9
|
+
|
|
10
|
+
import { userProfileSelectors } from '../auth/selectors';
|
|
11
|
+
import { syncSettingsSelectors } from '../settings/selectors';
|
|
12
|
+
|
|
13
|
+
const n = setNamespace('sync');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 设置操作
|
|
17
|
+
*/
|
|
18
|
+
export interface SyncAction {
|
|
19
|
+
refreshConnection: (onEvent: OnSyncEvent) => Promise<void>;
|
|
20
|
+
triggerEnableSync: (userId: string, onEvent: OnSyncEvent) => Promise<boolean>;
|
|
21
|
+
useEnabledSync: (
|
|
22
|
+
userEnableSync: boolean,
|
|
23
|
+
userId: string | undefined,
|
|
24
|
+
onEvent: OnSyncEvent,
|
|
25
|
+
) => SWRResponse;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const createSyncSlice: StateCreator<
|
|
29
|
+
UserStore,
|
|
30
|
+
[['zustand/devtools', never]],
|
|
31
|
+
[],
|
|
32
|
+
SyncAction
|
|
33
|
+
> = (set, get) => ({
|
|
34
|
+
refreshConnection: async (onEvent) => {
|
|
35
|
+
const userId = userProfileSelectors.userId(get());
|
|
36
|
+
|
|
37
|
+
if (!userId) return;
|
|
38
|
+
|
|
39
|
+
await get().triggerEnableSync(userId, onEvent);
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
triggerEnableSync: async (userId: string, onEvent: OnSyncEvent) => {
|
|
43
|
+
// double-check the sync ability
|
|
44
|
+
// if there is no channelName, don't start sync
|
|
45
|
+
const sync = syncSettingsSelectors.webrtcConfig(get());
|
|
46
|
+
if (!sync.channelName) return false;
|
|
47
|
+
|
|
48
|
+
const name = syncSettingsSelectors.deviceName(get());
|
|
49
|
+
|
|
50
|
+
const defaultUserName = `My ${browserInfo.browser} (${browserInfo.os})`;
|
|
51
|
+
|
|
52
|
+
set({ syncStatus: PeerSyncStatus.Connecting });
|
|
53
|
+
return globalService.enabledSync({
|
|
54
|
+
channel: {
|
|
55
|
+
name: sync.channelName,
|
|
56
|
+
password: sync.channelPassword,
|
|
57
|
+
},
|
|
58
|
+
onAwarenessChange(state) {
|
|
59
|
+
set({ syncAwareness: state });
|
|
60
|
+
},
|
|
61
|
+
onSyncEvent: onEvent,
|
|
62
|
+
onSyncStatusChange: (status) => {
|
|
63
|
+
set({ syncStatus: status });
|
|
64
|
+
},
|
|
65
|
+
signaling: sync.signaling,
|
|
66
|
+
user: {
|
|
67
|
+
id: userId,
|
|
68
|
+
// if user don't set the name, use default name
|
|
69
|
+
name: name || defaultUserName,
|
|
70
|
+
...browserInfo,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
useEnabledSync: (userEnableSync, userId, onEvent) =>
|
|
76
|
+
useSWR<boolean>(
|
|
77
|
+
['enableSync', userEnableSync, userId],
|
|
78
|
+
async () => {
|
|
79
|
+
// if user don't enable sync or no userId ,don't start sync
|
|
80
|
+
if (!userId) return false;
|
|
81
|
+
|
|
82
|
+
// if user don't enable sync, stop sync
|
|
83
|
+
if (!userEnableSync) return globalService.disableSync();
|
|
84
|
+
|
|
85
|
+
return get().triggerEnableSync(userId, onEvent);
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
onSuccess: (syncEnabled) => {
|
|
89
|
+
set({ syncEnabled }, false, n('useEnabledSync'));
|
|
90
|
+
},
|
|
91
|
+
revalidateOnFocus: false,
|
|
92
|
+
},
|
|
93
|
+
),
|
|
94
|
+
});
|
|
@@ -1,17 +1,12 @@
|
|
|
1
1
|
import { PeerSyncStatus, SyncAwarenessState } from '@/types/sync';
|
|
2
2
|
|
|
3
|
-
export interface
|
|
4
|
-
// Topic 引导
|
|
5
|
-
topic?: boolean;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export interface UserCommonState {
|
|
3
|
+
export interface UserSyncState {
|
|
9
4
|
syncAwareness: SyncAwarenessState[];
|
|
10
5
|
syncEnabled: boolean;
|
|
11
6
|
syncStatus: PeerSyncStatus;
|
|
12
7
|
}
|
|
13
8
|
|
|
14
|
-
export const
|
|
9
|
+
export const initialSyncState: UserSyncState = {
|
|
15
10
|
syncAwareness: [],
|
|
16
11
|
syncEnabled: false,
|
|
17
12
|
syncStatus: PeerSyncStatus.Disabled,
|
package/src/store/user/store.ts
CHANGED
|
@@ -6,19 +6,28 @@ import { StateCreator } from 'zustand/vanilla';
|
|
|
6
6
|
import { isDev } from '@/utils/env';
|
|
7
7
|
|
|
8
8
|
import { type UserState, initialState } from './initialState';
|
|
9
|
+
import { type UserAuthAction, createAuthSlice } from './slices/auth/action';
|
|
9
10
|
import { type CommonAction, createCommonSlice } from './slices/common/action';
|
|
10
11
|
import { type PreferenceAction, createPreferenceSlice } from './slices/preference/action';
|
|
11
12
|
import { type SettingsAction, createSettingsSlice } from './slices/settings/actions';
|
|
13
|
+
import { type SyncAction, createSyncSlice } from './slices/sync/action';
|
|
12
14
|
|
|
13
15
|
// =============== 聚合 createStoreFn ============ //
|
|
14
16
|
|
|
15
|
-
export type UserStore =
|
|
17
|
+
export type UserStore = SyncAction &
|
|
18
|
+
UserState &
|
|
19
|
+
SettingsAction &
|
|
20
|
+
PreferenceAction &
|
|
21
|
+
UserAuthAction &
|
|
22
|
+
CommonAction;
|
|
16
23
|
|
|
17
24
|
const createStore: StateCreator<UserStore, [['zustand/devtools', never]]> = (...parameters) => ({
|
|
18
25
|
...initialState,
|
|
19
|
-
...
|
|
26
|
+
...createSyncSlice(...parameters),
|
|
20
27
|
...createSettingsSlice(...parameters),
|
|
21
28
|
...createPreferenceSlice(...parameters),
|
|
29
|
+
...createAuthSlice(...parameters),
|
|
30
|
+
...createCommonSlice(...parameters),
|
|
22
31
|
});
|
|
23
32
|
|
|
24
33
|
// =============== 实装 useStore ============ //
|