diff --git a/src/store/user/initialState.ts b/src/store/user/initialState.ts index ba25edd30882..e8142707a6d1 100644 --- a/src/store/user/initialState.ts +++ b/src/store/user/initialState.ts @@ -1,12 +1,13 @@ +import { UserSyncState, initialSyncState } from '@/store/user/slices/sync/initialState'; + import { UserAuthState, initialAuthState } from './slices/auth/initialState'; -import { UserCommonState, initialCommonState } from './slices/common/initialState'; import { UserPreferenceState, initialPreferenceState } from './slices/preference/initialState'; import { UserSettingsState, initialSettingsState } from './slices/settings/initialState'; -export type UserState = UserCommonState & UserSettingsState & UserPreferenceState & UserAuthState; +export type UserState = UserSyncState & UserSettingsState & UserPreferenceState & UserAuthState; export const initialState: UserState = { - ...initialCommonState, + ...initialSyncState, ...initialSettingsState, ...initialPreferenceState, ...initialAuthState, diff --git a/src/store/user/slices/common/action.test.ts b/src/store/user/slices/common/action.test.ts index d85fe9e1a11b..deb5ee3335d3 100644 --- a/src/store/user/slices/common/action.test.ts +++ b/src/store/user/slices/common/action.test.ts @@ -154,89 +154,6 @@ describe('createCommonSlice', () => { }); }); - describe('refreshConnection', () => { - it('should not call triggerEnableSync when userId is empty', async () => { - const { result } = renderHook(() => useUserStore()); - const onEvent = vi.fn(); - - vi.spyOn(userProfileSelectors, 'userId').mockReturnValueOnce(undefined); - const triggerEnableSyncSpy = vi.spyOn(result.current, 'triggerEnableSync'); - - await act(async () => { - await result.current.refreshConnection(onEvent); - }); - - expect(triggerEnableSyncSpy).not.toHaveBeenCalled(); - }); - - it('should call triggerEnableSync when userId exists', async () => { - const { result } = renderHook(() => useUserStore()); - const onEvent = vi.fn(); - const userId = 'user-id'; - - vi.spyOn(userProfileSelectors, 'userId').mockReturnValueOnce(userId); - const triggerEnableSyncSpy = vi.spyOn(result.current, 'triggerEnableSync'); - - await act(async () => { - await result.current.refreshConnection(onEvent); - }); - - expect(triggerEnableSyncSpy).toHaveBeenCalledWith(userId, onEvent); - }); - }); - - describe('triggerEnableSync', () => { - it('should return false when sync.channelName is empty', async () => { - const { result } = renderHook(() => useUserStore()); - const userId = 'user-id'; - const onEvent = vi.fn(); - - vi.spyOn(syncSettingsSelectors, 'webrtcConfig').mockReturnValueOnce({ - channelName: '', - enabled: true, - }); - - const data = await act(async () => { - return result.current.triggerEnableSync(userId, onEvent); - }); - - expect(data).toBe(false); - }); - - it('should call globalService.enabledSync when sync.channelName exists', async () => { - const userId = 'user-id'; - const onEvent = vi.fn(); - const channelName = 'channel-name'; - const channelPassword = 'channel-password'; - const deviceName = 'device-name'; - const signaling = 'signaling'; - - vi.spyOn(syncSettingsSelectors, 'webrtcConfig').mockReturnValueOnce({ - channelName, - channelPassword, - signaling, - enabled: true, - }); - vi.spyOn(syncSettingsSelectors, 'deviceName').mockReturnValueOnce(deviceName); - const enabledSyncSpy = vi.spyOn(globalService, 'enabledSync').mockResolvedValueOnce(true); - const { result } = renderHook(() => useUserStore()); - - const data = await act(async () => { - return result.current.triggerEnableSync(userId, onEvent); - }); - - expect(enabledSyncSpy).toHaveBeenCalledWith({ - channel: { name: channelName, password: channelPassword }, - onAwarenessChange: expect.any(Function), - onSyncEvent: onEvent, - onSyncStatusChange: expect.any(Function), - signaling, - user: expect.objectContaining({ id: userId, name: deviceName }), - }); - expect(data).toBe(true); - }); - }); - describe('useCheckTrace', () => { it('should return false when shouldFetch is false', async () => { const { result } = renderHook(() => useUserStore().useCheckTrace(false), { @@ -270,47 +187,4 @@ describe('createCommonSlice', () => { expect(messageCountToCheckTraceSpy).toHaveBeenCalled(); }); }); - - describe('useEnabledSync', () => { - it('should return false when userId is empty', async () => { - const { result } = renderHook(() => useUserStore().useEnabledSync(true, undefined, vi.fn()), { - wrapper: withSWR, - }); - - await waitFor(() => expect(result.current.data).toBe(false)); - }); - - it('should call globalService.disableSync when userEnableSync is false', async () => { - const disableSyncSpy = vi.spyOn(globalService, 'disableSync').mockResolvedValueOnce(false); - - const { result } = renderHook( - () => useUserStore().useEnabledSync(false, 'user-id', vi.fn()), - { wrapper: withSWR }, - ); - - await waitFor(() => expect(result.current.data).toBeUndefined()); - expect(disableSyncSpy).toHaveBeenCalled(); - }); - - it('should call triggerEnableSync when userEnableSync and userId exist', async () => { - const userId = 'user-id'; - const onEvent = vi.fn(); - const triggerEnableSyncSpy = vi.fn().mockResolvedValueOnce(true); - - const { result } = renderHook(() => useUserStore()); - - // replace triggerEnableSync as a mock - result.current.triggerEnableSync = triggerEnableSyncSpy; - - const { result: swrResult } = renderHook( - () => result.current.useEnabledSync(true, userId, onEvent), - { - wrapper: withSWR, - }, - ); - - await waitFor(() => expect(swrResult.current.data).toBe(true)); - expect(triggerEnableSyncSpy).toHaveBeenCalledWith(userId, onEvent); - }); - }); }); diff --git a/src/store/user/slices/common/action.ts b/src/store/user/slices/common/action.ts index 7b9a32ef4a10..30d4adbed3a1 100644 --- a/src/store/user/slices/common/action.ts +++ b/src/store/user/slices/common/action.ts @@ -8,15 +8,12 @@ import { UserConfig, userService } from '@/services/user'; import type { UserStore } from '@/store/user'; import type { GlobalServerConfig } from '@/types/serverConfig'; import type { GlobalSettings } from '@/types/settings'; -import { OnSyncEvent, PeerSyncStatus } from '@/types/sync'; import { switchLang } from '@/utils/client/switchLang'; import { merge } from '@/utils/merge'; -import { browserInfo } from '@/utils/platform'; import { setNamespace } from '@/utils/storeDebug'; import { preferenceSelectors } from '../preference/selectors'; -import { settingsSelectors, syncSettingsSelectors } from '../settings/selectors'; -import { userProfileSelectors } from './selectors'; +import { settingsSelectors } from '../settings/selectors'; const n = setNamespace('common'); @@ -24,16 +21,9 @@ const n = setNamespace('common'); * 设置操作 */ export interface CommonAction { - refreshConnection: (onEvent: OnSyncEvent) => Promise; refreshUserConfig: () => Promise; - triggerEnableSync: (userId: string, onEvent: OnSyncEvent) => Promise; updateAvatar: (avatar: string) => Promise; useCheckTrace: (shouldFetch: boolean) => SWRResponse; - useEnabledSync: ( - userEnableSync: boolean, - userId: string | undefined, - onEvent: OnSyncEvent, - ) => SWRResponse; useFetchServerConfig: () => SWRResponse; useFetchUserConfig: (initServer: boolean) => SWRResponse; } @@ -46,14 +36,6 @@ export const createCommonSlice: StateCreator< [], CommonAction > = (set, get) => ({ - refreshConnection: async (onEvent) => { - const userId = userProfileSelectors.userId(get()); - - if (!userId) return; - - await get().triggerEnableSync(userId, onEvent); - }, - refreshUserConfig: async () => { await mutate([USER_CONFIG_FETCH_KEY, true]); @@ -61,38 +43,6 @@ export const createCommonSlice: StateCreator< get().refreshModelProviderList(); }, - triggerEnableSync: async (userId: string, onEvent: OnSyncEvent) => { - // double-check the sync ability - // if there is no channelName, don't start sync - const sync = syncSettingsSelectors.webrtcConfig(get()); - if (!sync.channelName) return false; - - const name = syncSettingsSelectors.deviceName(get()); - - const defaultUserName = `My ${browserInfo.browser} (${browserInfo.os})`; - - set({ syncStatus: PeerSyncStatus.Connecting }); - return globalService.enabledSync({ - channel: { - name: sync.channelName, - password: sync.channelPassword, - }, - onAwarenessChange(state) { - set({ syncAwareness: state }); - }, - onSyncEvent: onEvent, - onSyncStatusChange: (status) => { - set({ syncStatus: status }); - }, - signaling: sync.signaling, - user: { - id: userId, - // if user don't set the name, use default name - name: name || defaultUserName, - ...browserInfo, - }, - }); - }, updateAvatar: async (avatar) => { await userService.updateAvatar(avatar); await get().refreshUserConfig(); @@ -115,25 +65,6 @@ export const createCommonSlice: StateCreator< }, ), - useEnabledSync: (userEnableSync, userId, onEvent) => - useSWR( - ['enableSync', userEnableSync, userId], - async () => { - // if user don't enable sync or no userId ,don't start sync - if (!userId) return false; - - // if user don't enable sync, stop sync - if (!userEnableSync) return globalService.disableSync(); - - return get().triggerEnableSync(userId, onEvent); - }, - { - onSuccess: (syncEnabled) => { - set({ syncEnabled }, false, n('useEnabledSync')); - }, - revalidateOnFocus: false, - }, - ), useFetchServerConfig: () => useSWR('fetchGlobalConfig', globalService.getGlobalConfig, { onSuccess: (data) => { diff --git a/src/store/user/slices/sync/action.test.ts b/src/store/user/slices/sync/action.test.ts new file mode 100644 index 000000000000..2ee13918e356 --- /dev/null +++ b/src/store/user/slices/sync/action.test.ts @@ -0,0 +1,150 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { withSWR } from '~test-utils'; + +import { globalService } from '@/services/global'; +import { useUserStore } from '@/store/user'; +import { userProfileSelectors } from '@/store/user/slices/auth/selectors'; +import { syncSettingsSelectors } from '@/store/user/slices/settings/selectors'; + +vi.mock('zustand/traditional'); + +vi.mock('swr', async (importOriginal) => { + const modules = await importOriginal(); + return { + ...(modules as any), + mutate: vi.fn(), + }; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('createSyncSlice', () => { + describe('refreshConnection', () => { + it('should not call triggerEnableSync when userId is empty', async () => { + const { result } = renderHook(() => useUserStore()); + const onEvent = vi.fn(); + + vi.spyOn(userProfileSelectors, 'userId').mockReturnValueOnce(undefined as any); + const triggerEnableSyncSpy = vi.spyOn(result.current, 'triggerEnableSync'); + + await act(async () => { + await result.current.refreshConnection(onEvent); + }); + + expect(triggerEnableSyncSpy).not.toHaveBeenCalled(); + }); + + it('should call triggerEnableSync when userId exists', async () => { + const { result } = renderHook(() => useUserStore()); + const onEvent = vi.fn(); + const userId = 'user-id'; + + vi.spyOn(userProfileSelectors, 'userId').mockReturnValueOnce(userId); + const triggerEnableSyncSpy = vi.spyOn(result.current, 'triggerEnableSync'); + + await act(async () => { + await result.current.refreshConnection(onEvent); + }); + + expect(triggerEnableSyncSpy).toHaveBeenCalledWith(userId, onEvent); + }); + }); + + describe('triggerEnableSync', () => { + it('should return false when sync.channelName is empty', async () => { + const { result } = renderHook(() => useUserStore()); + const userId = 'user-id'; + const onEvent = vi.fn(); + + vi.spyOn(syncSettingsSelectors, 'webrtcConfig').mockReturnValueOnce({ + channelName: '', + enabled: true, + }); + + const data = await act(async () => { + return result.current.triggerEnableSync(userId, onEvent); + }); + + expect(data).toBe(false); + }); + + it('should call globalService.enabledSync when sync.channelName exists', async () => { + const userId = 'user-id'; + const onEvent = vi.fn(); + const channelName = 'channel-name'; + const channelPassword = 'channel-password'; + const deviceName = 'device-name'; + const signaling = 'signaling'; + + vi.spyOn(syncSettingsSelectors, 'webrtcConfig').mockReturnValueOnce({ + channelName, + channelPassword, + signaling, + enabled: true, + }); + vi.spyOn(syncSettingsSelectors, 'deviceName').mockReturnValueOnce(deviceName); + const enabledSyncSpy = vi.spyOn(globalService, 'enabledSync').mockResolvedValueOnce(true); + const { result } = renderHook(() => useUserStore()); + + const data = await act(async () => { + return result.current.triggerEnableSync(userId, onEvent); + }); + + expect(enabledSyncSpy).toHaveBeenCalledWith({ + channel: { name: channelName, password: channelPassword }, + onAwarenessChange: expect.any(Function), + onSyncEvent: onEvent, + onSyncStatusChange: expect.any(Function), + signaling, + user: expect.objectContaining({ id: userId, name: deviceName }), + }); + expect(data).toBe(true); + }); + }); + + describe('useEnabledSync', () => { + it('should return false when userId is empty', async () => { + const { result } = renderHook(() => useUserStore().useEnabledSync(true, undefined, vi.fn()), { + wrapper: withSWR, + }); + + await waitFor(() => expect(result.current.data).toBe(false)); + }); + + it('should call globalService.disableSync when userEnableSync is false', async () => { + const disableSyncSpy = vi.spyOn(globalService, 'disableSync').mockResolvedValueOnce(false); + + const { result } = renderHook( + () => useUserStore().useEnabledSync(false, 'user-id', vi.fn()), + { wrapper: withSWR }, + ); + + await waitFor(() => expect(result.current.data).toBeUndefined()); + expect(disableSyncSpy).toHaveBeenCalled(); + }); + + it('should call triggerEnableSync when userEnableSync and userId exist', async () => { + const userId = 'user-id'; + const onEvent = vi.fn(); + const triggerEnableSyncSpy = vi.fn().mockResolvedValueOnce(true); + + const { result } = renderHook(() => useUserStore()); + + // replace triggerEnableSync as a mock + result.current.triggerEnableSync = triggerEnableSyncSpy; + + const { result: swrResult } = renderHook( + () => result.current.useEnabledSync(true, userId, onEvent), + { + wrapper: withSWR, + }, + ); + + await waitFor(() => expect(swrResult.current.data).toBe(true)); + expect(triggerEnableSyncSpy).toHaveBeenCalledWith(userId, onEvent); + }); + }); +}); diff --git a/src/store/user/slices/sync/action.ts b/src/store/user/slices/sync/action.ts new file mode 100644 index 000000000000..b549e475c908 --- /dev/null +++ b/src/store/user/slices/sync/action.ts @@ -0,0 +1,103 @@ +import useSWR, { SWRResponse, mutate } from 'swr'; +import type { StateCreator } from 'zustand/vanilla'; + +import { globalService } from '@/services/global'; +import type { UserStore } from '@/store/user'; +import { OnSyncEvent, PeerSyncStatus } from '@/types/sync'; +import { browserInfo } from '@/utils/platform'; +import { setNamespace } from '@/utils/storeDebug'; + +import { userProfileSelectors } from '../auth/selectors'; +import { syncSettingsSelectors } from '../settings/selectors'; + +const n = setNamespace('sync'); + +/** + * 设置操作 + */ +export interface SyncAction { + refreshConnection: (onEvent: OnSyncEvent) => Promise; + triggerEnableSync: (userId: string, onEvent: OnSyncEvent) => Promise; + useEnabledSync: ( + userEnableSync: boolean, + userId: string | undefined, + onEvent: OnSyncEvent, + ) => SWRResponse; +} + +const USER_CONFIG_FETCH_KEY = 'fetchUserConfig'; + +export const createSyncSlice: StateCreator< + UserStore, + [['zustand/devtools', never]], + [], + SyncAction +> = (set, get) => ({ + refreshConnection: async (onEvent) => { + const userId = userProfileSelectors.userId(get()); + + if (!userId) return; + + await get().triggerEnableSync(userId, onEvent); + }, + + refreshUserConfig: async () => { + await mutate([USER_CONFIG_FETCH_KEY, true]); + + // when get the user config ,refresh the model provider list to the latest + get().refreshModelProviderList(); + }, + + triggerEnableSync: async (userId: string, onEvent: OnSyncEvent) => { + // double-check the sync ability + // if there is no channelName, don't start sync + const sync = syncSettingsSelectors.webrtcConfig(get()); + if (!sync.channelName) return false; + + const name = syncSettingsSelectors.deviceName(get()); + + const defaultUserName = `My ${browserInfo.browser} (${browserInfo.os})`; + + set({ syncStatus: PeerSyncStatus.Connecting }); + return globalService.enabledSync({ + channel: { + name: sync.channelName, + password: sync.channelPassword, + }, + onAwarenessChange(state) { + set({ syncAwareness: state }); + }, + onSyncEvent: onEvent, + onSyncStatusChange: (status) => { + set({ syncStatus: status }); + }, + signaling: sync.signaling, + user: { + id: userId, + // if user don't set the name, use default name + name: name || defaultUserName, + ...browserInfo, + }, + }); + }, + + useEnabledSync: (userEnableSync, userId, onEvent) => + useSWR( + ['enableSync', userEnableSync, userId], + async () => { + // if user don't enable sync or no userId ,don't start sync + if (!userId) return false; + + // if user don't enable sync, stop sync + if (!userEnableSync) return globalService.disableSync(); + + return get().triggerEnableSync(userId, onEvent); + }, + { + onSuccess: (syncEnabled) => { + set({ syncEnabled }, false, n('useEnabledSync')); + }, + revalidateOnFocus: false, + }, + ), +}); diff --git a/src/store/user/slices/common/initialState.ts b/src/store/user/slices/sync/initialState.ts similarity index 61% rename from src/store/user/slices/common/initialState.ts rename to src/store/user/slices/sync/initialState.ts index a88f6c7649ec..9345992102a6 100644 --- a/src/store/user/slices/common/initialState.ts +++ b/src/store/user/slices/sync/initialState.ts @@ -1,17 +1,12 @@ import { PeerSyncStatus, SyncAwarenessState } from '@/types/sync'; -export interface Guide { - // Topic 引导 - topic?: boolean; -} - -export interface UserCommonState { +export interface UserSyncState { syncAwareness: SyncAwarenessState[]; syncEnabled: boolean; syncStatus: PeerSyncStatus; } -export const initialCommonState: UserCommonState = { +export const initialSyncState: UserSyncState = { syncAwareness: [], syncEnabled: false, syncStatus: PeerSyncStatus.Disabled, diff --git a/src/store/user/store.ts b/src/store/user/store.ts index 34e1e361ad10..f1a1e5f895b5 100644 --- a/src/store/user/store.ts +++ b/src/store/user/store.ts @@ -10,21 +10,24 @@ import { type UserAuthAction, createAuthSlice } from './slices/auth/action'; import { type CommonAction, createCommonSlice } from './slices/common/action'; import { type PreferenceAction, createPreferenceSlice } from './slices/preference/action'; import { type SettingsAction, createSettingsSlice } from './slices/settings/actions'; +import { type SyncAction, createSyncSlice } from './slices/sync/action'; // =============== 聚合 createStoreFn ============ // -export type UserStore = CommonAction & +export type UserStore = SyncAction & UserState & SettingsAction & PreferenceAction & - UserAuthAction; + UserAuthAction & + CommonAction; const createStore: StateCreator = (...parameters) => ({ ...initialState, - ...createCommonSlice(...parameters), + ...createSyncSlice(...parameters), ...createSettingsSlice(...parameters), ...createPreferenceSlice(...parameters), ...createAuthSlice(...parameters), + ...createCommonSlice(...parameters), }); // =============== 实装 useStore ============ //