diff --git a/src/App.vue b/src/App.vue index 685b85bd..5ae0ebe3 100644 --- a/src/App.vue +++ b/src/App.vue @@ -26,6 +26,7 @@ import { TooltipProvider } from './components/ui/tooltip'; import { createGlobalStores } from './stores'; import { initNoty } from './plugin/noty'; + import { getGameLogTable } from './coordinators/gameLogCoordinator'; import { runCheckVRChatDebugLoggingFlow } from './coordinators/gameCoordinator'; import AlertDialogModal from './components/ui/alert-dialog/AlertDialogModal.vue'; @@ -57,7 +58,7 @@ }); onMounted(async () => { - store.gameLog.getGameLogTable(); + getGameLogTable(); await store.auth.migrateStoredUsers(); store.auth.autoLoginAfterMounted(); store.vrcx.checkAutoBackupRestoreVrcRegistry(); diff --git a/src/api/__tests__/entityQuerySync.test.js b/src/api/__tests__/entityQuerySync.test.js index 9c8db2b8..178b0736 100644 --- a/src/api/__tests__/entityQuerySync.test.js +++ b/src/api/__tests__/entityQuerySync.test.js @@ -29,6 +29,12 @@ vi.mock('../../queries', () => ({ user: (userId) => ['user', userId], avatar: (avatarId) => ['avatar', avatarId], world: (worldId) => ['world', worldId] + }, + entityQueryPolicies: { + user: {}, + avatar: {}, + world: {}, + worldCollection: {} } })); diff --git a/src/api/__tests__/friendQuerySync.test.js b/src/api/__tests__/friendQuerySync.test.js index bc656b5c..02d43b09 100644 --- a/src/api/__tests__/friendQuerySync.test.js +++ b/src/api/__tests__/friendQuerySync.test.js @@ -17,6 +17,12 @@ vi.mock('../../stores/user', () => ({ vi.mock('../../queries', () => ({ queryClient: { invalidateQueries: (...args) => mockInvalidateQueries(...args) + }, + entityQueryPolicies: { + user: {}, + avatar: {}, + world: {}, + worldCollection: {} } })); diff --git a/src/api/__tests__/groupQuerySync.test.js b/src/api/__tests__/groupQuerySync.test.js index 8967cfff..b14d5956 100644 --- a/src/api/__tests__/groupQuerySync.test.js +++ b/src/api/__tests__/groupQuerySync.test.js @@ -20,6 +20,12 @@ vi.mock('../../stores', () => ({ vi.mock('../../queries', () => ({ queryClient: { invalidateQueries: (...args) => mockInvalidateQueries(...args) + }, + entityQueryPolicies: { + user: {}, + avatar: {}, + world: {}, + worldCollection: {} } })); diff --git a/src/components/__tests__/AvatarInfo.test.js b/src/components/__tests__/AvatarInfo.test.js index cbfa94a1..d00807e2 100644 --- a/src/components/__tests__/AvatarInfo.test.js +++ b/src/components/__tests__/AvatarInfo.test.js @@ -81,6 +81,12 @@ vi.mock('../../service/watchState', () => ({ watchState: { isLoggedIn: false } })); +import * as avatarCoordinatorModule from '../../coordinators/avatarCoordinator'; +vi.mock('../../coordinators/avatarCoordinator', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, showAvatarAuthorDialog: vi.fn() }; +}); + const i18n = createI18n({ locale: 'en', fallbackLocale: 'en', @@ -210,9 +216,7 @@ describe('AvatarInfo.vue', () => { test('does not call showAvatarAuthorDialog when no imageurl', async () => { const wrapper = mountAvatarInfo({}); await wrapper.trigger('click'); - const { useAvatarStore } = await import('../../stores'); - const avatarStore = useAvatarStore(); - expect(avatarStore.showAvatarAuthorDialog).not.toHaveBeenCalled(); + expect(avatarCoordinatorModule.showAvatarAuthorDialog).not.toHaveBeenCalled(); }); }); }); diff --git a/src/components/dialogs/UserDialog/UserDialog.vue b/src/components/dialogs/UserDialog/UserDialog.vue index c1e15513..f5af404c 100644 --- a/src/components/dialogs/UserDialog/UserDialog.vue +++ b/src/components/dialogs/UserDialog/UserDialog.vue @@ -142,15 +142,15 @@ const { userDialog, languageDialog, currentUser, isLocalUserVrcPlusSupporter } = storeToRefs(useUserStore()); const { cachedUsers, showSendBoopDialog } = useUserStore(); const { showFavoriteDialog } = useFavoriteStore(); - import { showAvatarDialog, showAvatarAuthorDialog } from '../../../coordinators/avatarCoordinator'; +import { showAvatarDialog, showAvatarAuthorDialog } from '../../../coordinators/avatarCoordinator'; import { showUserDialog, refreshUserDialogAvatars } from '../../../coordinators/userCoordinator'; +import { getFriendRequest, handleFriendDelete } from '../../../coordinators/friendRelationshipCoordinator'; const { showModerateGroupDialog } = useGroupStore(); const { inviteGroupDialog } = storeToRefs(useGroupStore()); const { lastLocation, lastLocationDestination } = storeToRefs(useLocationStore()); const { refreshInviteMessageTableData } = useInviteStore(); const { friendLogTable } = storeToRefs(useFriendStore()); - const { getFriendRequest, handleFriendDelete } = useFriendStore(); const { clearInviteImageUpload, showGalleryPage } = useGalleryStore(); const { applyPlayerModeration, handlePlayerModerationDelete } = useModerationStore(); diff --git a/src/components/dialogs/UserDialog/__tests__/UserDialogMutualFriendsTab.test.js b/src/components/dialogs/UserDialog/__tests__/UserDialogMutualFriendsTab.test.js index 72652ec5..60ec5ce1 100644 --- a/src/components/dialogs/UserDialog/__tests__/UserDialogMutualFriendsTab.test.js +++ b/src/components/dialogs/UserDialog/__tests__/UserDialogMutualFriendsTab.test.js @@ -84,6 +84,12 @@ vi.mock('../../../../service/request', () => ({ failedGetRequests: new Map() })); +import * as userCoordinatorModule from '../../../../coordinators/userCoordinator'; +vi.mock('../../../../coordinators/userCoordinator', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, showUserDialog: vi.fn() }; +}); + import UserDialogMutualFriendsTab from '../UserDialogMutualFriendsTab.vue'; import { useUserStore } from '../../../../stores'; import { userDialogMutualFriendSortingOptions } from '../../../../shared/constants'; @@ -219,7 +225,7 @@ describe('UserDialogMutualFriendsTab.vue', () => { }; userStore.currentUser = { id: 'usr_me' }; const showUserDialogSpy = vi - .spyOn(userStore, 'showUserDialog') + .spyOn(userCoordinatorModule, 'showUserDialog') .mockImplementation(() => {}); const wrapper = mount(UserDialogMutualFriendsTab, { diff --git a/src/components/dialogs/UserDialog/__tests__/UserDialogWorldsTab.test.js b/src/components/dialogs/UserDialog/__tests__/UserDialogWorldsTab.test.js index bdf7ced9..590d41ba 100644 --- a/src/components/dialogs/UserDialog/__tests__/UserDialogWorldsTab.test.js +++ b/src/components/dialogs/UserDialog/__tests__/UserDialogWorldsTab.test.js @@ -88,6 +88,12 @@ vi.mock('../../../../service/request', () => ({ failedGetRequests: new Map() })); +import * as worldCoordinatorModule from '../../../../coordinators/worldCoordinator'; +vi.mock('../../../../coordinators/worldCoordinator', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, showWorldDialog: vi.fn() }; +}); + import UserDialogWorldsTab from '../UserDialogWorldsTab.vue'; import { useUserStore } from '../../../../stores'; import { @@ -235,10 +241,8 @@ describe('UserDialogWorldsTab.vue', () => { test('calls showWorldDialog when a world is clicked', async () => { const pinia = createTestingPinia({ stubActions: false }); const userStore = useUserStore(pinia); - const { useWorldStore } = await import('../../../../stores'); - const worldStore = useWorldStore(pinia); const showWorldDialogSpy = vi - .spyOn(worldStore, 'showWorldDialog') + .spyOn(worldCoordinatorModule, 'showWorldDialog') .mockImplementation(() => {}); userStore.userDialog = { diff --git a/src/coordinators/__tests__/authAutoLoginCoordinator.test.js b/src/coordinators/__tests__/authAutoLoginCoordinator.test.js deleted file mode 100644 index 5b1f1e7e..00000000 --- a/src/coordinators/__tests__/authAutoLoginCoordinator.test.js +++ /dev/null @@ -1,139 +0,0 @@ -import { beforeEach, describe, expect, test, vi } from 'vitest'; - -import { createAuthAutoLoginCoordinator } from '../authAutoLoginCoordinator'; - -/** - * - * @returns {Promise} Promise flush helper. - */ -async function flushPromises() { - await Promise.resolve(); - await Promise.resolve(); -} - -/** - * - * @param {object} overrides Dependency overrides for a specific test case. - * @returns {object} Mock dependencies for auth auto-login coordinator. - */ -function makeDeps(overrides = {}) { - let attemptingAutoLogin = false; - - const deps = { - getIsAttemptingAutoLogin: vi.fn(() => attemptingAutoLogin), - setAttemptingAutoLogin: vi.fn((value) => { - attemptingAutoLogin = value; - }), - getLastUserLoggedIn: vi.fn(() => 'usr_1'), - getSavedCredentials: vi.fn().mockResolvedValue({ - user: { id: 'usr_1' }, - loginParams: {} - }), - isPrimaryPasswordEnabled: vi.fn(() => false), - handleLogoutEvent: vi.fn().mockResolvedValue(undefined), - autoLoginAttempts: new Set(), - relogin: vi.fn().mockResolvedValue(undefined), - notifyAutoLoginSuccess: vi.fn(), - notifyAutoLoginFailed: vi.fn(), - notifyOffline: vi.fn(), - flashWindow: vi.fn(), - isOnline: vi.fn(() => true), - now: vi.fn(() => 1000) - }; - - return { - ...deps, - ...overrides - }; -} - -describe('createAuthAutoLoginCoordinator', () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.spyOn(console, 'error').mockImplementation(() => {}); - vi.spyOn(console, 'log').mockImplementation(() => {}); - }); - - test('returns early when auto-login is already in progress', async () => { - const deps = makeDeps({ - getIsAttemptingAutoLogin: vi.fn(() => true) - }); - const coordinator = createAuthAutoLoginCoordinator(deps); - - await coordinator.runHandleAutoLoginFlow(); - - expect(deps.setAttemptingAutoLogin).not.toHaveBeenCalled(); - expect(deps.getSavedCredentials).not.toHaveBeenCalled(); - }); - - test('stops flow when no saved credentials are found', async () => { - const deps = makeDeps({ - getSavedCredentials: vi.fn().mockResolvedValue(null) - }); - const coordinator = createAuthAutoLoginCoordinator(deps); - - await coordinator.runHandleAutoLoginFlow(); - - expect(deps.setAttemptingAutoLogin).toHaveBeenNthCalledWith(1, true); - expect(deps.setAttemptingAutoLogin).toHaveBeenNthCalledWith(2, false); - expect(deps.relogin).not.toHaveBeenCalled(); - }); - - test('logs out when primary password is enabled', async () => { - const deps = makeDeps({ - isPrimaryPasswordEnabled: vi.fn(() => true) - }); - const coordinator = createAuthAutoLoginCoordinator(deps); - - await coordinator.runHandleAutoLoginFlow(); - - expect(deps.setAttemptingAutoLogin).toHaveBeenNthCalledWith(1, true); - expect(deps.setAttemptingAutoLogin).toHaveBeenNthCalledWith(2, false); - expect(deps.handleLogoutEvent).toHaveBeenCalledTimes(1); - expect(deps.relogin).not.toHaveBeenCalled(); - }); - - test('logs out and flashes window when attempts exceed limit', async () => { - const deps = makeDeps({ - autoLoginAttempts: new Set([100, 200, 300]), - now: vi.fn(() => 500) - }); - const coordinator = createAuthAutoLoginCoordinator(deps); - - await coordinator.runHandleAutoLoginFlow(); - - expect(deps.handleLogoutEvent).toHaveBeenCalledTimes(1); - expect(deps.flashWindow).toHaveBeenCalledTimes(1); - expect(deps.relogin).not.toHaveBeenCalled(); - }); - - test('runs relogin and success notify on successful auto-login', async () => { - const deps = makeDeps(); - const coordinator = createAuthAutoLoginCoordinator(deps); - - await coordinator.runHandleAutoLoginFlow(); - await flushPromises(); - - expect(deps.autoLoginAttempts.has(1000)).toBe(true); - expect(deps.relogin).toHaveBeenCalledTimes(1); - expect(deps.notifyAutoLoginSuccess).toHaveBeenCalledTimes(1); - expect(deps.notifyAutoLoginFailed).not.toHaveBeenCalled(); - expect(deps.setAttemptingAutoLogin).toHaveBeenLastCalledWith(false); - }); - - test('runs failure and offline notifications when relogin fails while offline', async () => { - const deps = makeDeps({ - relogin: vi.fn().mockRejectedValue(new Error('failed')), - isOnline: vi.fn(() => false) - }); - const coordinator = createAuthAutoLoginCoordinator(deps); - - await coordinator.runHandleAutoLoginFlow(); - await flushPromises(); - - expect(deps.notifyAutoLoginSuccess).not.toHaveBeenCalled(); - expect(deps.notifyAutoLoginFailed).toHaveBeenCalledTimes(1); - expect(deps.notifyOffline).toHaveBeenCalledTimes(1); - expect(deps.setAttemptingAutoLogin).toHaveBeenLastCalledWith(false); - }); -}); diff --git a/src/coordinators/__tests__/authCoordinator.test.js b/src/coordinators/__tests__/authCoordinator.test.js deleted file mode 100644 index 83a7a8bf..00000000 --- a/src/coordinators/__tests__/authCoordinator.test.js +++ /dev/null @@ -1,96 +0,0 @@ -import { beforeEach, describe, expect, test, vi } from 'vitest'; - -import { createAuthCoordinator } from '../authCoordinator'; - -/** - * - * @returns {object} Mock dependencies for auth coordinator. - */ -function makeDeps() { - return { - userStore: { - currentUser: { id: 'usr_1' }, - setUserDialogVisible: vi.fn(), - applyCurrentUser: vi.fn() - }, - notificationStore: { - setNotificationInitStatus: vi.fn() - }, - updateLoopStore: { - setNextCurrentUserRefresh: vi.fn() - }, - initWebsocket: vi.fn(), - updateStoredUser: vi.fn().mockResolvedValue(undefined), - webApiService: { - clearCookies: vi.fn().mockResolvedValue(undefined) - }, - loginForm: { - value: { - lastUserLoggedIn: 'usr_1' - } - }, - configRepository: { - remove: vi.fn().mockResolvedValue(undefined) - }, - setAttemptingAutoLogin: vi.fn(), - autoLoginAttempts: { - clear: vi.fn() - }, - closeWebSocket: vi.fn(), - queryClient: { - clear: vi.fn() - }, - watchState: { - isLoggedIn: true, - isFriendsLoaded: true, - isFavoritesLoaded: true - } - }; -} - -describe('createAuthCoordinator', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - test('runLogoutFlow applies all logout side effects', async () => { - const deps = makeDeps(); - const coordinator = createAuthCoordinator(deps); - - await coordinator.runLogoutFlow(); - - expect(deps.userStore.setUserDialogVisible).toHaveBeenCalledWith(false); - expect(deps.watchState.isLoggedIn).toBe(false); - expect(deps.watchState.isFriendsLoaded).toBe(false); - expect(deps.watchState.isFavoritesLoaded).toBe(false); - expect( - deps.notificationStore.setNotificationInitStatus - ).toHaveBeenCalledWith(false); - expect(deps.updateStoredUser).toHaveBeenCalledWith( - deps.userStore.currentUser - ); - expect(deps.webApiService.clearCookies).toHaveBeenCalledTimes(1); - expect(deps.loginForm.value.lastUserLoggedIn).toBe(''); - expect(deps.configRepository.remove).toHaveBeenCalledWith( - 'lastUserLoggedIn' - ); - expect(deps.setAttemptingAutoLogin).toHaveBeenCalledWith(false); - expect(deps.autoLoginAttempts.clear).toHaveBeenCalledTimes(1); - expect(deps.closeWebSocket).toHaveBeenCalledTimes(1); - expect(deps.queryClient.clear).toHaveBeenCalledTimes(1); - }); - - test('runLoginSuccessFlow applies login success side effects', () => { - const deps = makeDeps(); - const coordinator = createAuthCoordinator(deps); - const json = { id: 'usr_2' }; - - coordinator.runLoginSuccessFlow(json); - - expect( - deps.updateLoopStore.setNextCurrentUserRefresh - ).toHaveBeenCalledWith(420); - expect(deps.userStore.applyCurrentUser).toHaveBeenCalledWith(json); - expect(deps.initWebsocket).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/coordinators/__tests__/friendPresenceCoordinator.test.js b/src/coordinators/__tests__/friendPresenceCoordinator.test.js deleted file mode 100644 index 17a89d0e..00000000 --- a/src/coordinators/__tests__/friendPresenceCoordinator.test.js +++ /dev/null @@ -1,159 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; - -import { createFriendPresenceCoordinator } from '../friendPresenceCoordinator'; - -/** - * @returns {object} Mock dependencies and mutable state for friend presence tests. - */ -function makeDeps() { - const friends = new Map(); - const cachedUsers = new Map(); - const pendingOfflineMap = new Map(); - const ref = { - id: 'usr_1', - state: 'online', - displayName: 'User 1', - location: 'wrld_1:1', - $location_at: 100, - $lastFetch: 0, - $online_for: 1, - $offline_for: '', - $active_for: '' - }; - const ctx = { - id: 'usr_1', - state: 'online', - ref, - name: 'User 1', - isVIP: false, - pendingOffline: false - }; - friends.set('usr_1', ctx); - cachedUsers.set('usr_1', ref); - - return { - deps: { - friends, - localFavoriteFriends: new Set(), - pendingOfflineMap, - pendingOfflineDelay: 100, - watchState: { isFriendsLoaded: true }, - appDebug: { debugFriendState: false }, - getCachedUsers: vi.fn(() => cachedUsers), - isRealInstance: vi.fn(() => false), - requestUser: vi.fn(), - getWorldName: vi.fn().mockResolvedValue('World 1'), - getGroupName: vi.fn().mockResolvedValue('Group 1'), - feedStore: { - addFeed: vi.fn() - }, - database: { - addOnlineOfflineToDatabase: vi.fn() - }, - updateOnlineFriendCounter: vi.fn(), - now: vi.fn(() => 1000), - nowIso: vi.fn(() => '2025-01-01T00:00:00.000Z') - }, - ctx, - ref, - pendingOfflineMap - }; -} - -describe('createFriendPresenceCoordinator', () => { - test('queues pending offline transition when friend moves from online to offline', async () => { - const { deps, ctx, pendingOfflineMap } = makeDeps(); - const coordinator = createFriendPresenceCoordinator(deps); - - await coordinator.runUpdateFriendFlow('usr_1', 'offline'); - - expect(ctx.pendingOffline).toBe(true); - expect(pendingOfflineMap.has('usr_1')).toBe(true); - expect(deps.updateOnlineFriendCounter).not.toHaveBeenCalled(); - }); - - test('processes pending offline queue and applies delayed transition', async () => { - const { deps, ctx, pendingOfflineMap, ref } = makeDeps(); - pendingOfflineMap.set('usr_1', { - startTime: 0, - newState: 'offline', - previousLocation: 'wrld_1:1', - previousLocationAt: 500 - }); - const coordinator = createFriendPresenceCoordinator(deps); - - await coordinator.runPendingOfflineTickFlow(); - - expect(ctx.state).toBe('offline'); - expect(ctx.pendingOffline).toBe(false); - expect(pendingOfflineMap.has('usr_1')).toBe(false); - expect(ref.$offline_for).toBe(1000); - expect(deps.feedStore.addFeed).toHaveBeenCalledTimes(1); - expect(deps.feedStore.addFeed).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'Offline', - userId: 'usr_1', - location: 'wrld_1:1', - worldName: 'World 1', - groupName: 'Group 1', - time: 500 - }) - ); - expect(deps.database.addOnlineOfflineToDatabase).toHaveBeenCalledTimes( - 1 - ); - expect(deps.updateOnlineFriendCounter).toHaveBeenCalledTimes(1); - }); - - test('cancels pending offline transition when online state arrives again', async () => { - const { deps, ctx, pendingOfflineMap } = makeDeps(); - pendingOfflineMap.set('usr_1', { - startTime: 900, - newState: 'offline', - previousLocation: 'wrld_1:1', - previousLocationAt: 800 - }); - const coordinator = createFriendPresenceCoordinator(deps); - - await coordinator.runUpdateFriendFlow('usr_1', 'online'); - - expect(ctx.pendingOffline).toBe(false); - expect(pendingOfflineMap.has('usr_1')).toBe(false); - }); - - test('applies offline to online transition contract immediately', async () => { - const { deps, ctx, ref } = makeDeps(); - ctx.state = 'offline'; - const coordinator = createFriendPresenceCoordinator(deps); - - await coordinator.runUpdateFriendFlow('usr_1', 'online'); - - expect(ctx.state).toBe('online'); - expect(ref.$online_for).toBe(1000); - expect(ref.$offline_for).toBe(''); - expect(deps.feedStore.addFeed).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'Online', - userId: 'usr_1', - location: 'wrld_1:1', - worldName: 'World 1', - groupName: 'Group 1' - }) - ); - expect(deps.database.addOnlineOfflineToDatabase).toHaveBeenCalledTimes( - 1 - ); - expect(deps.updateOnlineFriendCounter).toHaveBeenCalledTimes(1); - }); - - test('returns safely when friend context does not exist', async () => { - const { deps } = makeDeps(); - deps.friends.clear(); - const coordinator = createFriendPresenceCoordinator(deps); - - await coordinator.runUpdateFriendFlow('usr_404', 'online'); - - expect(deps.feedStore.addFeed).not.toHaveBeenCalled(); - expect(deps.updateOnlineFriendCounter).not.toHaveBeenCalled(); - }); -}); diff --git a/src/coordinators/__tests__/friendRelationshipCoordinator.test.js b/src/coordinators/__tests__/friendRelationshipCoordinator.test.js deleted file mode 100644 index d7e49b0d..00000000 --- a/src/coordinators/__tests__/friendRelationshipCoordinator.test.js +++ /dev/null @@ -1,158 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; - -import { createFriendRelationshipCoordinator } from '../friendRelationshipCoordinator'; - -/** - * @returns {Promise} Promise flush helper. - */ -async function flushPromises() { - await Promise.resolve(); - await Promise.resolve(); -} - -/** - * @returns {object} Mock dependencies for friend relationship tests. - */ -function makeDeps() { - return { - friendLog: new Map(), - friendLogTable: { - value: { - data: [] - } - }, - getCurrentUserId: vi.fn(() => 'usr_me'), - requestFriendStatus: vi.fn().mockResolvedValue({ - params: { - currentUserId: 'usr_me' - }, - json: { - isFriend: false - } - }), - handleFriendStatus: vi.fn(), - addFriendship: vi.fn(), - deleteFriend: vi.fn(), - database: { - addFriendLogHistory: vi.fn(), - deleteFriendLogCurrent: vi.fn() - }, - notificationStore: { - queueFriendLogNoty: vi.fn() - }, - sharedFeedStore: { - addEntry: vi.fn() - }, - favoriteStore: { - handleFavoriteDelete: vi.fn() - }, - uiStore: { - notifyMenu: vi.fn() - }, - shouldNotifyUnfriend: vi.fn(() => true), - nowIso: vi.fn(() => '2026-03-08T00:00:00.000Z') - }; -} - -describe('createFriendRelationshipCoordinator', () => { - test('runDeleteFriendshipFlow applies unfriend side effects after status check', async () => { - const deps = makeDeps(); - deps.friendLog.set('usr_1', { - displayName: 'User 1' - }); - const coordinator = createFriendRelationshipCoordinator(deps); - - coordinator.runDeleteFriendshipFlow('usr_1'); - await flushPromises(); - - expect(deps.requestFriendStatus).toHaveBeenCalledWith({ - userId: 'usr_1', - currentUserId: 'usr_me' - }); - expect(deps.handleFriendStatus).toHaveBeenCalledTimes(1); - expect(deps.friendLog.has('usr_1')).toBe(false); - expect(deps.database.addFriendLogHistory).toHaveBeenCalledTimes(1); - expect(deps.database.deleteFriendLogCurrent).toHaveBeenCalledWith( - 'usr_1' - ); - expect(deps.notificationStore.queueFriendLogNoty).toHaveBeenCalledTimes( - 1 - ); - expect(deps.sharedFeedStore.addEntry).toHaveBeenCalledTimes(1); - expect(deps.favoriteStore.handleFavoriteDelete).toHaveBeenCalledWith( - 'usr_1' - ); - expect(deps.uiStore.notifyMenu).toHaveBeenCalledWith('friend-log'); - expect(deps.deleteFriend).toHaveBeenCalledWith('usr_1'); - }); - - test('runUpdateFriendshipsFlow syncs additions and stale removals', async () => { - const deps = makeDeps(); - deps.friendLog.set('usr_me', { - displayName: 'Me' - }); - deps.friendLog.set('usr_keep', { - displayName: 'Keep' - }); - deps.friendLog.set('usr_drop', { - displayName: 'Drop' - }); - const coordinator = createFriendRelationshipCoordinator(deps); - - coordinator.runUpdateFriendshipsFlow({ - friends: ['usr_keep', 'usr_new'] - }); - await flushPromises(); - - expect(deps.addFriendship).toHaveBeenNthCalledWith(1, 'usr_keep'); - expect(deps.addFriendship).toHaveBeenNthCalledWith(2, 'usr_new'); - expect(deps.database.deleteFriendLogCurrent).toHaveBeenCalledWith( - 'usr_me' - ); - expect(deps.requestFriendStatus).toHaveBeenCalledWith({ - userId: 'usr_drop', - currentUserId: 'usr_me' - }); - }); - - test('ignores delayed status responses from stale current user', async () => { - const deps = makeDeps(); - deps.friendLog.set('usr_1', { - displayName: 'User 1' - }); - deps.requestFriendStatus.mockResolvedValue({ - params: { - currentUserId: 'usr_old' - }, - json: { - isFriend: false - } - }); - const coordinator = createFriendRelationshipCoordinator(deps); - - coordinator.runDeleteFriendshipFlow('usr_1'); - await flushPromises(); - - expect(deps.handleFriendStatus).not.toHaveBeenCalled(); - expect(deps.friendLog.has('usr_1')).toBe(true); - expect(deps.database.addFriendLogHistory).not.toHaveBeenCalled(); - expect(deps.deleteFriend).not.toHaveBeenCalled(); - }); - - test('respects unfriend notify switch', async () => { - const deps = makeDeps(); - deps.shouldNotifyUnfriend.mockReturnValue(false); - deps.friendLog.set('usr_1', { - displayName: 'User 1' - }); - const coordinator = createFriendRelationshipCoordinator(deps); - - coordinator.runDeleteFriendshipFlow('usr_1'); - await flushPromises(); - - expect(deps.uiStore.notifyMenu).not.toHaveBeenCalled(); - expect(deps.favoriteStore.handleFavoriteDelete).toHaveBeenCalledWith( - 'usr_1' - ); - }); -}); diff --git a/src/coordinators/__tests__/friendSyncCoordinator.test.js b/src/coordinators/__tests__/friendSyncCoordinator.test.js deleted file mode 100644 index 2aa1fc39..00000000 --- a/src/coordinators/__tests__/friendSyncCoordinator.test.js +++ /dev/null @@ -1,133 +0,0 @@ -import { beforeEach, describe, expect, test, vi } from 'vitest'; - -import { createFriendSyncCoordinator } from '../friendSyncCoordinator'; - -/** - * - * @returns {object} Mock dependencies for friend sync coordinator. - */ -function makeDeps() { - return { - getNextCurrentUserRefresh: vi.fn(() => 999), - getCurrentUser: vi.fn().mockResolvedValue(undefined), - refreshFriends: vi.fn().mockResolvedValue(undefined), - reconnectWebSocket: vi.fn(), - getCurrentUserId: vi.fn(() => 'usr_1'), - getCurrentUserRef: vi.fn(() => ({ id: 'usr_1' })), - setRefreshFriendsLoading: vi.fn(), - setFriendsLoaded: vi.fn(), - resetFriendLog: vi.fn(), - isFriendLogInitialized: vi.fn().mockResolvedValue(true), - getFriendLog: vi.fn().mockResolvedValue(undefined), - initFriendLog: vi.fn().mockResolvedValue(undefined), - isDontLogMeOut: vi.fn(() => false), - showLoadFailedToast: vi.fn(), - handleLogoutEvent: vi.fn(), - tryApplyFriendOrder: vi.fn(), - getAllUserStats: vi.fn(), - hasLegacyFriendLogData: vi.fn().mockResolvedValue(false), - removeLegacyFeedTable: vi.fn(), - migrateMemos: vi.fn(), - migrateFriendLog: vi.fn() - }; -} - -describe('createFriendSyncCoordinator', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - test('runRefreshFriendsListFlow refreshes current user before friends when refresh window is small', async () => { - const deps = makeDeps(); - deps.getNextCurrentUserRefresh.mockReturnValue(100); - const coordinator = createFriendSyncCoordinator(deps); - - await coordinator.runRefreshFriendsListFlow(); - - expect(deps.getCurrentUser).toHaveBeenCalledTimes(1); - expect(deps.refreshFriends).toHaveBeenCalledTimes(1); - expect(deps.reconnectWebSocket).toHaveBeenCalledTimes(1); - }); - - test('runRefreshFriendsListFlow skips current user refresh when window is large', async () => { - const deps = makeDeps(); - deps.getNextCurrentUserRefresh.mockReturnValue(300); - const coordinator = createFriendSyncCoordinator(deps); - - await coordinator.runRefreshFriendsListFlow(); - - expect(deps.getCurrentUser).not.toHaveBeenCalled(); - expect(deps.refreshFriends).toHaveBeenCalledTimes(1); - expect(deps.reconnectWebSocket).toHaveBeenCalledTimes(1); - }); - - test('runInitFriendsListFlow loads existing friend log when initialized', async () => { - const deps = makeDeps(); - deps.isFriendLogInitialized.mockResolvedValue(true); - deps.hasLegacyFriendLogData.mockResolvedValue(false); - const coordinator = createFriendSyncCoordinator(deps); - - await coordinator.runInitFriendsListFlow(); - - expect(deps.setRefreshFriendsLoading).toHaveBeenCalledWith(true); - expect(deps.setFriendsLoaded).toHaveBeenCalledWith(false); - expect(deps.resetFriendLog).toHaveBeenCalledTimes(1); - expect(deps.isFriendLogInitialized).toHaveBeenCalledWith('usr_1'); - expect(deps.getFriendLog).toHaveBeenCalledWith({ id: 'usr_1' }); - expect(deps.initFriendLog).not.toHaveBeenCalled(); - expect(deps.tryApplyFriendOrder).toHaveBeenCalledTimes(1); - expect(deps.getAllUserStats).toHaveBeenCalledTimes(1); - }); - - test('runInitFriendsListFlow initializes new friend log when not initialized', async () => { - const deps = makeDeps(); - deps.isFriendLogInitialized.mockResolvedValue(false); - const coordinator = createFriendSyncCoordinator(deps); - - await coordinator.runInitFriendsListFlow(); - - expect(deps.getFriendLog).not.toHaveBeenCalled(); - expect(deps.initFriendLog).toHaveBeenCalledWith({ id: 'usr_1' }); - }); - - test('runInitFriendsListFlow performs legacy migration when old data exists', async () => { - const deps = makeDeps(); - deps.hasLegacyFriendLogData.mockResolvedValue(true); - const coordinator = createFriendSyncCoordinator(deps); - - await coordinator.runInitFriendsListFlow(); - - expect(deps.removeLegacyFeedTable).toHaveBeenCalledWith('usr_1'); - expect(deps.migrateMemos).toHaveBeenCalledTimes(1); - expect(deps.migrateFriendLog).toHaveBeenCalledWith('usr_1'); - }); - - test('runInitFriendsListFlow logs out and rethrows when load fails and dontLogMeOut is false', async () => { - const deps = makeDeps(); - const err = new Error('load failed'); - deps.getFriendLog.mockRejectedValue(err); - deps.isDontLogMeOut.mockReturnValue(false); - const coordinator = createFriendSyncCoordinator(deps); - - await expect(coordinator.runInitFriendsListFlow()).rejects.toThrow( - 'load failed' - ); - expect(deps.showLoadFailedToast).toHaveBeenCalledTimes(1); - expect(deps.handleLogoutEvent).toHaveBeenCalledTimes(1); - expect(deps.tryApplyFriendOrder).not.toHaveBeenCalled(); - }); - - test('runInitFriendsListFlow continues when load fails and dontLogMeOut is true', async () => { - const deps = makeDeps(); - deps.getFriendLog.mockRejectedValue(new Error('load failed')); - deps.isDontLogMeOut.mockReturnValue(true); - const coordinator = createFriendSyncCoordinator(deps); - - await coordinator.runInitFriendsListFlow(); - - expect(deps.showLoadFailedToast).not.toHaveBeenCalled(); - expect(deps.handleLogoutEvent).not.toHaveBeenCalled(); - expect(deps.tryApplyFriendOrder).toHaveBeenCalledTimes(1); - expect(deps.getAllUserStats).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/coordinators/__tests__/gameCoordinator.test.js b/src/coordinators/__tests__/gameCoordinator.test.js deleted file mode 100644 index 88e9cd15..00000000 --- a/src/coordinators/__tests__/gameCoordinator.test.js +++ /dev/null @@ -1,125 +0,0 @@ -import { beforeEach, describe, expect, test, vi } from 'vitest'; - -import { createGameCoordinator } from '../gameCoordinator'; - -/** - * - * @returns {object} Mock dependencies for game coordinator. - */ -function makeDeps() { - return { - userStore: { - currentUser: { - currentAvatar: 'avtr_1' - }, - markCurrentUserGameStarted: vi.fn(), - markCurrentUserGameStopped: vi.fn() - }, - instanceStore: { - removeAllQueuedInstances: vi.fn() - }, - updateLoopStore: { - setIpcTimeout: vi.fn(), - setNextDiscordUpdate: vi.fn() - }, - locationStore: { - lastLocationReset: vi.fn() - }, - gameLogStore: { - clearNowPlaying: vi.fn() - }, - vrStore: { - updateVRLastLocation: vi.fn() - }, - avatarStore: { - addAvatarWearTime: vi.fn() - }, - configRepository: { - setBool: vi.fn().mockResolvedValue(undefined) - }, - workerTimers: { - setTimeout: vi.fn() - }, - checkVRChatDebugLogging: vi.fn(), - autoVRChatCacheManagement: vi.fn(), - checkIfGameCrashed: vi.fn(), - getIsGameNoVR: vi.fn(() => true) - }; -} - -describe('createGameCoordinator', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - test('runGameRunningChangedFlow(true) runs start + shared side effects', async () => { - const deps = makeDeps(); - const coordinator = createGameCoordinator(deps); - - await coordinator.runGameRunningChangedFlow(true); - - expect(deps.userStore.markCurrentUserGameStarted).toHaveBeenCalledTimes( - 1 - ); - expect(deps.configRepository.setBool).not.toHaveBeenCalled(); - expect( - deps.userStore.markCurrentUserGameStopped - ).not.toHaveBeenCalled(); - expect( - deps.instanceStore.removeAllQueuedInstances - ).not.toHaveBeenCalled(); - expect(deps.autoVRChatCacheManagement).not.toHaveBeenCalled(); - expect(deps.checkIfGameCrashed).not.toHaveBeenCalled(); - expect(deps.updateLoopStore.setIpcTimeout).not.toHaveBeenCalled(); - expect(deps.avatarStore.addAvatarWearTime).not.toHaveBeenCalled(); - - expect(deps.locationStore.lastLocationReset).toHaveBeenCalledTimes(1); - expect(deps.gameLogStore.clearNowPlaying).toHaveBeenCalledTimes(1); - expect(deps.vrStore.updateVRLastLocation).toHaveBeenCalledTimes(1); - expect(deps.updateLoopStore.setNextDiscordUpdate).toHaveBeenCalledWith( - 0 - ); - - expect(deps.workerTimers.setTimeout).toHaveBeenCalledTimes(1); - expect(deps.workerTimers.setTimeout.mock.calls[0][1]).toBe(60000); - const timeoutCb = deps.workerTimers.setTimeout.mock.calls[0][0]; - timeoutCb(); - expect(deps.checkVRChatDebugLogging).toHaveBeenCalledTimes(1); - }); - - test('runGameRunningChangedFlow(false) runs stop + shared side effects', async () => { - const deps = makeDeps(); - deps.getIsGameNoVR.mockReturnValue(false); - const coordinator = createGameCoordinator(deps); - - await coordinator.runGameRunningChangedFlow(false); - - expect(deps.getIsGameNoVR).toHaveBeenCalledTimes(1); - expect(deps.configRepository.setBool).toHaveBeenCalledWith( - 'isGameNoVR', - false - ); - expect(deps.userStore.markCurrentUserGameStopped).toHaveBeenCalledTimes( - 1 - ); - expect( - deps.instanceStore.removeAllQueuedInstances - ).toHaveBeenCalledTimes(1); - expect(deps.autoVRChatCacheManagement).toHaveBeenCalledTimes(1); - expect(deps.checkIfGameCrashed).toHaveBeenCalledTimes(1); - expect(deps.updateLoopStore.setIpcTimeout).toHaveBeenCalledWith(0); - expect(deps.avatarStore.addAvatarWearTime).toHaveBeenCalledWith( - 'avtr_1' - ); - - expect(deps.locationStore.lastLocationReset).toHaveBeenCalledTimes(1); - expect(deps.gameLogStore.clearNowPlaying).toHaveBeenCalledTimes(1); - expect(deps.vrStore.updateVRLastLocation).toHaveBeenCalledTimes(1); - expect(deps.updateLoopStore.setNextDiscordUpdate).toHaveBeenCalledWith( - 0 - ); - - expect(deps.workerTimers.setTimeout).toHaveBeenCalledTimes(1); - expect(deps.workerTimers.setTimeout.mock.calls[0][1]).toBe(60000); - }); -}); diff --git a/src/coordinators/__tests__/userEventCoordinator.test.js b/src/coordinators/__tests__/userEventCoordinator.test.js deleted file mode 100644 index b412e43b..00000000 --- a/src/coordinators/__tests__/userEventCoordinator.test.js +++ /dev/null @@ -1,251 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; - -import { createUserEventCoordinator } from '../userEventCoordinator'; - -/** - * @returns {object} Mock dependencies for user event tests. - */ -function makeDeps() { - const friendRef = { - id: 'usr_1' - }; - - return { - friendStore: { - friends: new Map([['usr_1', friendRef]]) - }, - state: { - instancePlayerCount: new Map() - }, - parseLocation: vi.fn((location) => { - if (location === 'loc_old') { - return { - tag: 'loc_old', - worldId: 'world_old', - groupId: 'group_old' - }; - } - if (location === 'loc_new') { - return { - tag: 'loc_new', - worldId: 'world_new', - groupId: 'group_new' - }; - } - return { - tag: location, - worldId: '', - groupId: '' - }; - }), - userDialog: { - value: { - $location: { - tag: 'loc_new' - } - } - }, - applyUserDialogLocation: vi.fn(), - worldStore: { - worldDialog: { - id: 'world_old' - } - }, - groupStore: { - groupDialog: { - id: 'group_new' - } - }, - instanceStore: { - applyWorldDialogInstances: vi.fn(), - applyGroupDialogInstances: vi.fn() - }, - appDebug: { - debugFriendState: false - }, - getWorldName: vi.fn().mockResolvedValue('World'), - getGroupName: vi.fn().mockResolvedValue('Group'), - feedStore: { - addFeed: vi.fn() - }, - database: { - addGPSToDatabase: vi.fn(), - addAvatarToDatabase: vi.fn(), - addStatusToDatabase: vi.fn(), - addBioToDatabase: vi.fn() - }, - avatarStore: { - getAvatarName: vi.fn().mockResolvedValue({ - ownerId: 'usr_owner', - avatarName: 'Avatar' - }) - }, - generalSettingsStore: { - logEmptyAvatars: false - }, - checkNote: vi.fn(), - now: vi.fn(() => 1000), - nowIso: vi.fn(() => '2025-01-01T00:00:00.000Z') - }; -} - -describe('createUserEventCoordinator', () => { - test('returns early when target user is not in friend map', async () => { - const deps = makeDeps(); - deps.friendStore.friends.clear(); - const coordinator = createUserEventCoordinator(deps); - - await coordinator.runHandleUserUpdateFlow( - { - id: 'usr_404', - displayName: 'Unknown' - }, - { - status: ['online', 'offline'] - } - ); - - expect(deps.feedStore.addFeed).not.toHaveBeenCalled(); - expect(deps.database.addStatusToDatabase).not.toHaveBeenCalled(); - }); - - test('updates location counters and dialog instance hooks on location change', async () => { - const deps = makeDeps(); - deps.state.instancePlayerCount.set('loc_old', 2); - const coordinator = createUserEventCoordinator(deps); - const ref = { - id: 'usr_1', - displayName: 'User 1' - }; - - await coordinator.runHandleUserUpdateFlow(ref, { - location: ['loc_new', 'loc_old', 50], - state: ['online', 'online'] - }); - - expect(deps.state.instancePlayerCount.get('loc_old')).toBe(1); - expect(deps.state.instancePlayerCount.get('loc_new')).toBe(1); - expect(deps.applyUserDialogLocation).toHaveBeenCalledWith(true); - expect(deps.instanceStore.applyWorldDialogInstances).toHaveBeenCalled(); - expect(deps.instanceStore.applyGroupDialogInstances).toHaveBeenCalled(); - }); - - test('writes GPS feed with adjusted traveling time contract', async () => { - const deps = makeDeps(); - const coordinator = createUserEventCoordinator(deps); - const ref = { - id: 'usr_1', - displayName: 'User 1', - $previousLocation: 'loc_old', - $travelingToTime: 900, - $location_at: 700 - }; - - await coordinator.runHandleUserUpdateFlow(ref, { - location: ['loc_new', 'traveling', 300] - }); - - expect(deps.feedStore.addFeed).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'GPS', - userId: 'usr_1', - previousLocation: 'loc_old', - location: 'loc_new', - worldName: 'World', - groupName: 'Group', - time: 200 - }) - ); - expect(deps.database.addGPSToDatabase).toHaveBeenCalledTimes(1); - expect(ref.$previousLocation).toBe(''); - expect(ref.$travelingToTime).toBe(1000); - }); - - test('stores previous location while user becomes traveling', async () => { - const deps = makeDeps(); - const coordinator = createUserEventCoordinator(deps); - const ref = { - id: 'usr_1', - displayName: 'User 1', - $previousLocation: '', - $travelingToTime: 0 - }; - - await coordinator.runHandleUserUpdateFlow(ref, { - location: ['traveling', 'loc_old'] - }); - - expect(ref.$previousLocation).toBe('loc_old'); - expect(ref.$travelingToTime).toBe(1000); - }); - - test('writes status and bio feeds and triggers note check', async () => { - const deps = makeDeps(); - const coordinator = createUserEventCoordinator(deps); - const ref = { - id: 'usr_1', - displayName: 'User 1', - status: 'busy', - statusDescription: 'old', - currentAvatarImageUrl: '', - currentAvatarThumbnailImageUrl: '', - currentAvatarTags: [], - profilePicOverride: '' - }; - - await coordinator.runHandleUserUpdateFlow(ref, { - status: ['join me', 'busy'], - statusDescription: ['new desc', 'old desc'], - bio: ['new bio', 'old bio'], - note: ['new note', 'old note'] - }); - - expect(deps.feedStore.addFeed).toHaveBeenCalledTimes(2); - expect(deps.database.addStatusToDatabase).toHaveBeenCalledTimes(1); - expect(deps.database.addBioToDatabase).toHaveBeenCalledTimes(1); - expect(deps.checkNote).toHaveBeenCalledWith('usr_1', 'new note'); - }); - - test('writes avatar change feed contract', async () => { - const deps = makeDeps(); - deps.generalSettingsStore.logEmptyAvatars = true; - deps.avatarStore.getAvatarName - .mockResolvedValueOnce({ - ownerId: 'usr_owner_new', - avatarName: 'Avatar New' - }) - .mockResolvedValueOnce({ - ownerId: 'usr_owner_old', - avatarName: 'Avatar Old' - }); - const coordinator = createUserEventCoordinator(deps); - const ref = { - id: 'usr_1', - displayName: 'User 1', - currentAvatarImageUrl: 'img_old', - currentAvatarThumbnailImageUrl: 'thumb_old', - currentAvatarTags: ['tag_old'], - profilePicOverride: '' - }; - - await coordinator.runHandleUserUpdateFlow(ref, { - currentAvatarImageUrl: ['img_new', 'img_old'], - currentAvatarThumbnailImageUrl: ['thumb_new', 'thumb_old'], - currentAvatarTags: [['tag_new'], ['tag_old']] - }); - - expect(deps.database.addAvatarToDatabase).toHaveBeenCalledTimes(1); - expect(deps.feedStore.addFeed).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'Avatar', - userId: 'usr_1', - ownerId: 'usr_owner_new', - previousOwnerId: 'usr_owner_old', - avatarName: 'Avatar New', - previousAvatarName: 'Avatar Old', - currentAvatarImageUrl: 'img_new', - previousCurrentAvatarImageUrl: 'img_old' - }) - ); - }); -}); diff --git a/src/coordinators/__tests__/userSessionCoordinator.test.js b/src/coordinators/__tests__/userSessionCoordinator.test.js deleted file mode 100644 index c864483c..00000000 --- a/src/coordinators/__tests__/userSessionCoordinator.test.js +++ /dev/null @@ -1,162 +0,0 @@ -import { beforeEach, describe, expect, test, vi } from 'vitest'; - -import { createUserSessionCoordinator } from '../userSessionCoordinator'; - -/** - * - * @returns {Promise} Promise flush helper. - */ -async function flushPromises() { - await Promise.resolve(); - await Promise.resolve(); -} - -/** - * - * @returns {object} Mock dependencies for user session coordinator. - */ -function makeDeps() { - return { - avatarStore: { - addAvatarToHistory: vi.fn(), - addAvatarWearTime: vi.fn() - }, - gameStore: { - isGameRunning: false - }, - groupStore: { - applyPresenceGroups: vi.fn() - }, - instanceStore: { - applyQueuedInstance: vi.fn() - }, - friendStore: { - updateUserCurrentStatus: vi.fn(), - updateFriendships: vi.fn() - }, - authStore: { - loginComplete: vi.fn() - }, - cachedUsers: { - clear: vi.fn() - }, - currentUser: { - value: { - homeLocation: 'wrld_current' - } - }, - userDialog: { - value: { - visible: false, - id: '', - $homeLocationName: '' - } - }, - getWorldName: vi.fn().mockResolvedValue('World Name'), - parseLocation: vi.fn((tag) => ({ tag })), - now: vi.fn(() => 111) - }; -} - -describe('createUserSessionCoordinator', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - test('runAvatarSwapFlow does nothing when user is not logged in', () => { - const deps = makeDeps(); - const coordinator = createUserSessionCoordinator(deps); - const ref = { - currentAvatar: 'avtr_old', - $previousAvatarSwapTime: null - }; - - coordinator.runAvatarSwapFlow({ - json: { currentAvatar: 'avtr_new' }, - ref, - isLoggedIn: false - }); - - expect(deps.avatarStore.addAvatarToHistory).not.toHaveBeenCalled(); - expect(deps.avatarStore.addAvatarWearTime).not.toHaveBeenCalled(); - }); - - test('runAvatarSwapFlow applies avatar swap side effects when game is running', () => { - const deps = makeDeps(); - deps.gameStore.isGameRunning = true; - const coordinator = createUserSessionCoordinator(deps); - const ref = { - currentAvatar: 'avtr_old', - $previousAvatarSwapTime: null - }; - - coordinator.runAvatarSwapFlow({ - json: { currentAvatar: 'avtr_new' }, - ref, - isLoggedIn: true - }); - - expect(deps.avatarStore.addAvatarToHistory).toHaveBeenCalledWith( - 'avtr_new' - ); - expect(deps.avatarStore.addAvatarWearTime).toHaveBeenCalledWith( - 'avtr_old' - ); - expect(ref.$previousAvatarSwapTime).toBe(111); - }); - - test('runFirstLoginFlow clears cache, updates currentUser and completes login', () => { - const deps = makeDeps(); - deps.gameStore.isGameRunning = true; - const coordinator = createUserSessionCoordinator(deps); - const ref = { - id: 'usr_1', - $previousAvatarSwapTime: null - }; - - coordinator.runFirstLoginFlow(ref); - - expect(deps.cachedUsers.clear).toHaveBeenCalledTimes(1); - expect(deps.currentUser.value).toBe(ref); - expect(deps.authStore.loginComplete).toHaveBeenCalledTimes(1); - expect(ref.$previousAvatarSwapTime).toBe(111); - }); - - test('runPostApplySyncFlow applies cross-store synchronization', () => { - const deps = makeDeps(); - const coordinator = createUserSessionCoordinator(deps); - const ref = { queuedInstance: 'wrld_1:123' }; - - coordinator.runPostApplySyncFlow(ref); - - expect(deps.groupStore.applyPresenceGroups).toHaveBeenCalledWith(ref); - expect(deps.instanceStore.applyQueuedInstance).toHaveBeenCalledWith( - 'wrld_1:123' - ); - expect(deps.friendStore.updateUserCurrentStatus).toHaveBeenCalledWith( - ref - ); - expect(deps.friendStore.updateFriendships).toHaveBeenCalledWith(ref); - }); - - test('runHomeLocationSyncFlow updates home location and visible dialog name', async () => { - const deps = makeDeps(); - deps.currentUser.value.homeLocation = 'wrld_home'; - deps.userDialog.value.visible = true; - deps.userDialog.value.id = 'usr_1'; - const coordinator = createUserSessionCoordinator(deps); - const ref = { - id: 'usr_1', - homeLocation: 'wrld_home', - $homeLocation: { tag: 'wrld_other' } - }; - - coordinator.runHomeLocationSyncFlow(ref); - await flushPromises(); - - expect(deps.parseLocation).toHaveBeenCalledWith('wrld_home'); - expect(ref.$homeLocation).toEqual({ tag: 'wrld_home' }); - expect(deps.getWorldName).toHaveBeenCalledWith('wrld_home'); - expect(deps.userDialog.value.$homeLocationName).toBe('World Name'); - }); -}); diff --git a/src/coordinators/authAutoLoginCoordinator.js b/src/coordinators/authAutoLoginCoordinator.js index af866948..99bf0247 100644 --- a/src/coordinators/authAutoLoginCoordinator.js +++ b/src/coordinators/authAutoLoginCoordinator.js @@ -52,31 +52,28 @@ export async function runHandleAutoLoginFlow({ } autoLoginAttempts.add(currentTimestamp); console.log('Attempting automatic login...'); - authStore - .relogin(user) - .then(() => { - if (AppDebug.errorNoty) { - toast.dismiss(AppDebug.errorNoty); - } - AppDebug.errorNoty = toast.success( - t('message.auth.auto_login_success') - ); - console.log('Automatically logged in.'); - }) - .catch((err) => { - if (AppDebug.errorNoty) { - toast.dismiss(AppDebug.errorNoty); - } - AppDebug.errorNoty = toast.error( - t('message.auth.auto_login_failed') - ); - console.error('Failed to login automatically.', err); - }) - .finally(() => { - authStore.setAttemptingAutoLogin(false); - if (!isOnline()) { - AppDebug.errorNoty = toast.error(t('message.auth.offline')); - console.error(`You're offline.`); - } - }); + try { + await authStore.relogin(user); + if (AppDebug.errorNoty) { + toast.dismiss(AppDebug.errorNoty); + } + AppDebug.errorNoty = toast.success( + t('message.auth.auto_login_success') + ); + console.log('Automatically logged in.'); + } catch (err) { + if (AppDebug.errorNoty) { + toast.dismiss(AppDebug.errorNoty); + } + AppDebug.errorNoty = toast.error( + t('message.auth.auto_login_failed') + ); + console.error('Failed to login automatically.', err); + } finally { + authStore.setAttemptingAutoLogin(false); + if (!isOnline()) { + AppDebug.errorNoty = toast.error(t('message.auth.offline')); + console.error(`You're offline.`); + } + } } diff --git a/src/coordinators/friendRelationshipCoordinator.js b/src/coordinators/friendRelationshipCoordinator.js index afc98a05..4929fceb 100644 --- a/src/coordinators/friendRelationshipCoordinator.js +++ b/src/coordinators/friendRelationshipCoordinator.js @@ -1,13 +1,331 @@ +import { i18n } from '../plugin/i18n'; + import { database } from '../service/database'; -import { friendRequest } from '../api'; +import { friendRequest, userRequest } from '../api'; +import { getNameColour } from '../shared/utils'; import { handleFavoriteDelete } from './favoriteCoordinator'; import { useAppearanceSettingsStore } from '../stores/settings/appearance'; import { useFavoriteStore } from '../stores/favorite'; import { useFriendStore } from '../stores/friend'; +import { useModalStore } from '../stores/modal'; import { useNotificationStore } from '../stores/notification'; import { useSharedFeedStore } from '../stores/sharedFeed'; import { useUiStore } from '../stores/ui'; import { useUserStore } from '../stores/user'; +import { watchState } from '../service/watchState'; + +import configRepository from '../service/config'; + +/** + * @param {object} args + */ +export function handleFriendStatus(args) { + const userStore = useUserStore(); + const D = userStore.userDialog; + if (D.visible === false || D.id !== args.params.userId) { + return; + } + const { json } = args; + D.isFriend = json.isFriend; + D.incomingRequest = json.incomingRequest; + D.outgoingRequest = json.outgoingRequest; +} + +/** + * @param {object} args + */ +export function handleFriendDelete(args) { + const userStore = useUserStore(); + const friendStore = useFriendStore(); + const D = userStore.userDialog; + if (D.visible === false || D.id !== args.params.userId) { + return; + } + D.isFriend = false; + runDeleteFriendshipFlow(args.params.userId); + friendStore.deleteFriend(args.params.userId); +} + +/** + * @param {object} args + */ +export function handleFriendAdd(args) { + const friendStore = useFriendStore(); + addFriendship(args.params.userId); + friendStore.addFriend(args.params.userId); +} + +/** + * @param {string} userId + * @returns {string} + */ +export function getFriendRequest(userId) { + const notificationStore = useNotificationStore(); + const array = notificationStore.notificationTable.data; + for (let i = array.length - 1; i >= 0; i--) { + if ( + array[i].type === 'friendRequest' && + array[i].senderUserId === userId + ) { + return array[i].id; + } + } + return ''; +} + +/** + * @param {string} userId + */ +function deleteFriendRequest(userId) { + const notificationStore = useNotificationStore(); + const array = notificationStore.notificationTable.data; + for (let i = array.length - 1; i >= 0; i--) { + if ( + array[i].type === 'friendRequest' && + array[i].senderUserId === userId + ) { + array.splice(i, 1); + return; + } + } +} + +/** + * @param {string} id + */ +export function addFriendship(id) { + const friendStore = useFriendStore(); + const userStore = useUserStore(); + const notificationStore = useNotificationStore(); + const sharedFeedStore = useSharedFeedStore(); + const uiStore = useUiStore(); + + const { friendLog, friendLogTable, state } = friendStore; + + if ( + !watchState.isFriendsLoaded || + friendLog.has(id) || + id === userStore.currentUser.id + ) { + return; + } + const ref = userStore.cachedUsers.get(id); + if (typeof ref === 'undefined') { + // deleted account on friends list + return; + } + friendRequest + .getFriendStatus({ + userId: id, + currentUserId: userStore.currentUser.id + }) + .then((args) => { + if (args.params.currentUserId !== userStore.currentUser.id) { + // safety check for delayed response + return; + } + handleFriendStatus(args); + if (args.json.isFriend && !friendLog.has(id)) { + if (state.friendNumber === 0) { + state.friendNumber = friendStore.friends.size; + } + ref.$friendNumber = ++state.friendNumber; + configRepository.setInt( + `VRCX_friendNumber_${userStore.currentUser.id}`, + state.friendNumber + ); + friendStore.addFriend(id, ref.state); + const friendLogHistory = { + created_at: new Date().toJSON(), + type: 'Friend', + userId: id, + displayName: ref.displayName, + friendNumber: ref.$friendNumber + }; + friendLogTable.value.data.push(friendLogHistory); + database.addFriendLogHistory(friendLogHistory); + notificationStore.queueFriendLogNoty(friendLogHistory); + sharedFeedStore.addEntry(friendLogHistory); + const friendLogCurrent = { + userId: id, + displayName: ref.displayName, + trustLevel: ref.$trustLevel, + friendNumber: ref.$friendNumber + }; + friendLog.set(id, friendLogCurrent); + database.setFriendLogCurrent(friendLogCurrent); + uiStore.notifyMenu('friend-log'); + deleteFriendRequest(id); + userRequest + .getUser({ + userId: id + }) + .then(() => { + if ( + userStore.userDialog.visible && + id === userStore.userDialog.id + ) { + userStore.applyUserDialogLocation(true); + } + }); + } + }); +} + +/** + * @param {object} ref + */ +export function updateFriendship(ref) { + const friendStore = useFriendStore(); + const notificationStore = useNotificationStore(); + const sharedFeedStore = useSharedFeedStore(); + const uiStore = useUiStore(); + + const { friendLog, friendLogTable } = friendStore; + + const ctx = friendLog.get(ref.id); + if (!watchState.isFriendsLoaded || typeof ctx === 'undefined') { + return; + } + if (ctx.friendNumber) { + ref.$friendNumber = ctx.friendNumber; + } + if (!ref.$friendNumber) { + ref.$friendNumber = 0; // no null + } + if (ctx.displayName !== ref.displayName) { + if (ctx.displayName) { + const friendLogHistoryDisplayName = { + created_at: new Date().toJSON(), + type: 'DisplayName', + userId: ref.id, + displayName: ref.displayName, + previousDisplayName: ctx.displayName, + friendNumber: ref.$friendNumber + }; + friendLogTable.value.data.push(friendLogHistoryDisplayName); + database.addFriendLogHistory(friendLogHistoryDisplayName); + notificationStore.queueFriendLogNoty( + friendLogHistoryDisplayName + ); + sharedFeedStore.addEntry(friendLogHistoryDisplayName); + const friendLogCurrent = { + userId: ref.id, + displayName: ref.displayName, + trustLevel: ref.$trustLevel, + friendNumber: ref.$friendNumber + }; + friendLog.set(ref.id, friendLogCurrent); + database.setFriendLogCurrent(friendLogCurrent); + ctx.displayName = ref.displayName; + uiStore.notifyMenu('friend-log'); + } + } + if ( + ref.$trustLevel && + ctx.trustLevel && + ctx.trustLevel !== ref.$trustLevel + ) { + if ( + (ctx.trustLevel === 'Trusted User' && + ref.$trustLevel === 'Veteran User') || + (ctx.trustLevel === 'Veteran User' && + ref.$trustLevel === 'Trusted User') + ) { + const friendLogCurrent3 = { + userId: ref.id, + displayName: ref.displayName, + trustLevel: ref.$trustLevel, + friendNumber: ref.$friendNumber + }; + friendLog.set(ref.id, friendLogCurrent3); + database.setFriendLogCurrent(friendLogCurrent3); + return; + } + const friendLogHistoryTrustLevel = { + created_at: new Date().toJSON(), + type: 'TrustLevel', + userId: ref.id, + displayName: ref.displayName, + trustLevel: ref.$trustLevel, + previousTrustLevel: ctx.trustLevel, + friendNumber: ref.$friendNumber + }; + friendLogTable.value.data.push(friendLogHistoryTrustLevel); + database.addFriendLogHistory(friendLogHistoryTrustLevel); + notificationStore.queueFriendLogNoty(friendLogHistoryTrustLevel); + sharedFeedStore.addEntry(friendLogHistoryTrustLevel); + const friendLogCurrent2 = { + userId: ref.id, + displayName: ref.displayName, + trustLevel: ref.$trustLevel, + friendNumber: ref.$friendNumber + }; + friendLog.set(ref.id, friendLogCurrent2); + database.setFriendLogCurrent(friendLogCurrent2); + uiStore.notifyMenu('friend-log'); + } + ctx.trustLevel = ref.$trustLevel; +} + +/** + * @param {string} id + */ +export function confirmDeleteFriend(id) { + const t = i18n.global.t; + const modalStore = useModalStore(); + modalStore + .confirm({ + description: t('confirm.unfriend'), + title: t('confirm.title') + }) + .then(async ({ ok }) => { + if (!ok) return; + const args = await friendRequest.deleteFriend({ + userId: id + }); + handleFriendDelete(args); + }) + .catch(() => {}); +} + +/** + * @param {object} ref + */ +export function userOnFriend(ref) { + const friendStore = useFriendStore(); + const userStore = useUserStore(); + + updateFriendship(ref); + if ( + watchState.isFriendsLoaded && + ref.isFriend && + !friendStore.friendLog.has(ref.id) && + ref.id !== userStore.currentUser.id + ) { + addFriendship(ref.id); + } +} + +/** + * @param {object} ref + */ +export function updateUserCurrentStatus(ref) { + const friendStore = useFriendStore(); + const userStore = useUserStore(); + const appearanceSettingsStore = useAppearanceSettingsStore(); + + if (watchState.isFriendsLoaded) { + friendStore.refreshFriendsStatus(ref); + } + friendStore.updateOnlineFriendCounter(); + + if (appearanceSettingsStore.randomUserColours) { + getNameColour(userStore.currentUser.id).then((colour) => { + userStore.setCurrentUserColour(colour); + }); + } +} /** * Validates and applies unfriend transition side effects. @@ -43,7 +361,7 @@ export function runDeleteFriendshipFlow( // safety check for delayed response return; } - friendStore.handleFriendStatus(args); + handleFriendStatus(args); if (!args.json.isFriend && friendLog.has(id)) { const friendLogHistory = { created_at: nowIso(), @@ -84,7 +402,7 @@ export function runUpdateFriendshipsFlow( const set = new Set(); for (id of ref.friends) { set.add(id); - friendStore.addFriendship(id); + addFriendship(id); } for (id of friendLog.keys()) { if (id === userStore.currentUser.id) { diff --git a/src/coordinators/gameLogCoordinator.js b/src/coordinators/gameLogCoordinator.js new file mode 100644 index 00000000..ef2e078b --- /dev/null +++ b/src/coordinators/gameLogCoordinator.js @@ -0,0 +1,582 @@ +import dayjs from 'dayjs'; + +import { + createJoinLeaveEntry, + createLocationEntry, + createPortalSpawnEntry, + createResourceLoadEntry, + findUserByDisplayName, + parseLocation, + parseInventoryFromUrl, + parsePrintFromUrl, + replaceBioSymbols +} from '../shared/utils'; +import { i18n } from '../plugin/i18n'; +import { AppDebug } from '../service/appConfig'; +import { database } from '../service/database'; +import { runLastLocationResetFlow, runUpdateCurrentUserLocationFlow } from './locationCoordinator'; +import { getGroupName } from '../shared/utils'; +import { userRequest } from '../api'; +import { watchState } from '../service/watchState'; +import { toast } from 'vue-sonner'; + +import { useAdvancedSettingsStore } from '../stores/settings/advanced'; +import { useFriendStore } from '../stores/friend'; +import { useGalleryStore } from '../stores/gallery'; +import { useGameStore } from '../stores/game'; +import { useGameLogStore } from '../stores/gameLog'; +import { useGeneralSettingsStore } from '../stores/settings/general'; +import { useInstanceStore } from '../stores/instance'; +import { useLocationStore } from '../stores/location'; +import { useModalStore } from '../stores/modal'; +import { useNotificationStore } from '../stores/notification'; +import { usePhotonStore } from '../stores/photon'; +import { useSharedFeedStore } from '../stores/sharedFeed'; +import { useUserStore } from '../stores/user'; +import { useVrStore } from '../stores/vr'; +import { useVrcxStore } from '../stores/vrcx'; + +import gameLogService from '../service/gameLog.js'; + +import * as workerTimers from 'worker-timers'; + +/** + * Loads the player list from game log history and syncs it to + * locationStore, instanceStore, vrStore, and userStore. + */ +export async function tryLoadPlayerList() { + const gameStore = useGameStore(); + const locationStore = useLocationStore(); + const userStore = useUserStore(); + const friendStore = useFriendStore(); + const instanceStore = useInstanceStore(); + const vrStore = useVrStore(); + + if (!gameStore.isGameRunning) { + return; + } + console.log('Loading player list from game log...'); + let ctx; + let i; + const data = await database.getGamelogDatabase(); + if (data.length === 0) { + return; + } + let length = 0; + for (i = data.length - 1; i > -1; i--) { + ctx = data[i]; + if (ctx.type === 'Location') { + locationStore.setLastLocation({ + date: Date.parse(ctx.created_at), + location: ctx.location, + name: ctx.worldName, + playerList: new Map(), + friendList: new Map() + }); + length = i; + break; + } + } + if (length > 0) { + for (i = length + 1; i < data.length; i++) { + ctx = data[i]; + if (ctx.type === 'OnPlayerJoined') { + if (!ctx.userId) { + ctx.userId = + findUserByDisplayName( + userStore.cachedUsers, + ctx.displayName + )?.id ?? ''; + } + const userMap = { + displayName: ctx.displayName, + userId: ctx.userId, + joinTime: Date.parse(ctx.created_at), + lastAvatar: '' + }; + locationStore.lastLocation.playerList.set( + ctx.userId, + userMap + ); + if (friendStore.friends.has(ctx.userId)) { + locationStore.lastLocation.friendList.set( + ctx.userId, + userMap + ); + } + } + if (ctx.type === 'OnPlayerLeft') { + locationStore.lastLocation.playerList.delete(ctx.userId); + locationStore.lastLocation.friendList.delete(ctx.userId); + } + } + locationStore.lastLocation.playerList.forEach((ref1) => { + if ( + ref1.userId && + typeof ref1.userId === 'string' && + !userStore.cachedUsers.has(ref1.userId) + ) { + userRequest.getUser({ userId: ref1.userId }); + } + }); + + runUpdateCurrentUserLocationFlow(); + instanceStore.updateCurrentInstanceWorld(); + vrStore.updateVRLastLocation(); + instanceStore.getCurrentInstanceUserList(); + userStore.applyUserDialogLocation(); + instanceStore.applyWorldDialogInstances(); + instanceStore.applyGroupDialogInstances(); + } +} + +/** + * Core game log entry processor. Dispatches game log events to the + * appropriate stores based on type. + * + * @param {object} gameLog + * @param {string} location + */ +export function addGameLogEntry(gameLog, location) { + const gameLogStore = useGameLogStore(); + const locationStore = useLocationStore(); + const instanceStore = useInstanceStore(); + const userStore = useUserStore(); + const friendStore = useFriendStore(); + const vrStore = useVrStore(); + const gameStore = useGameStore(); + const vrcxStore = useVrcxStore(); + const advancedSettingsStore = useAdvancedSettingsStore(); + const generalSettingsStore = useGeneralSettingsStore(); + const galleryStore = useGalleryStore(); + const photonStore = usePhotonStore(); + const sharedFeedStore = useSharedFeedStore(); + const notificationStore = useNotificationStore(); + + let entry = undefined; + if (advancedSettingsStore.gameLogDisabled) { + return; + } + let userId = String(gameLog.userId || ''); + if (!userId && gameLog.displayName) { + userId = + findUserByDisplayName( + userStore.cachedUsers, + gameLog.displayName + )?.id ?? ''; + } + switch (gameLog.type) { + case 'location-destination': + if (gameStore.isGameRunning) { + gameLogStore.addGameLog({ + created_at: gameLog.dt, + type: 'LocationDestination', + location: gameLog.location + }); + runLastLocationResetFlow(gameLog.dt); + locationStore.setLastLocationLocation('traveling'); + locationStore.setLastLocationDestination(gameLog.location); + locationStore.setLastLocationDestinationTime( + Date.parse(gameLog.dt) + ); + gameLogStore.state.lastLocationAvatarList.clear(); + instanceStore.removeQueuedInstance(gameLog.location); + runUpdateCurrentUserLocationFlow(); + gameLogStore.clearNowPlaying(); + instanceStore.updateCurrentInstanceWorld(); + userStore.applyUserDialogLocation(); + instanceStore.applyWorldDialogInstances(); + instanceStore.applyGroupDialogInstances(); + } + break; + case 'location': + instanceStore.addInstanceJoinHistory( + locationStore.lastLocation.location, + gameLog.dt + ); + const worldName = replaceBioSymbols(gameLog.worldName); + if (gameStore.isGameRunning) { + runLastLocationResetFlow(gameLog.dt); + gameLogStore.clearNowPlaying(); + locationStore.setLastLocation({ + date: Date.parse(gameLog.dt), + location: gameLog.location, + name: worldName, + playerList: new Map(), + friendList: new Map() + }); + instanceStore.removeQueuedInstance(gameLog.location); + runUpdateCurrentUserLocationFlow(); + vrStore.updateVRLastLocation(); + instanceStore.updateCurrentInstanceWorld(); + userStore.applyUserDialogLocation(); + instanceStore.applyWorldDialogInstances(); + instanceStore.applyGroupDialogInstances(); + } + instanceStore.addInstanceJoinHistory( + gameLog.location, + gameLog.dt + ); + const L = parseLocation(gameLog.location); + entry = createLocationEntry( + gameLog.dt, + gameLog.location, + L.worldId, + worldName + ); + getGroupName(gameLog.location).then((groupName) => { + entry.groupName = groupName; + }); + gameLogStore.addGamelogLocationToDatabase(entry); + break; + case 'player-joined': + const joinTime = Date.parse(gameLog.dt); + const userMap = { + displayName: gameLog.displayName, + userId, + joinTime, + lastAvatar: '' + }; + locationStore.lastLocation.playerList.set(userId, userMap); + const ref = userStore.cachedUsers.get(userId); + if (!userId) { + console.error('Missing userId:', gameLog.displayName); + } else if (userId === userStore.currentUser.id) { + // skip + } else if ( + friendStore.friends.has(userId) && + typeof ref !== 'undefined' + ) { + locationStore.lastLocation.friendList.set(userId, userMap); + if ( + ref.location !== locationStore.lastLocation.location && + ref.travelingToLocation !== + locationStore.lastLocation.location + ) { + ref.$location_at = joinTime; + } + } else if (typeof ref !== 'undefined') { + ref.$location_at = joinTime; + } else { + if (AppDebug.debugGameLog || AppDebug.debugWebRequests) { + console.log('Fetching user from gameLog:', userId); + } + userRequest.getUser({ userId }); + } + vrStore.updateVRLastLocation(); + instanceStore.getCurrentInstanceUserList(); + entry = createJoinLeaveEntry( + 'OnPlayerJoined', + gameLog.dt, + gameLog.displayName, + location, + userId + ); + database.addGamelogJoinLeaveToDatabase(entry); + break; + case 'player-left': + const ref1 = locationStore.lastLocation.playerList.get(userId); + if (typeof ref1 === 'undefined') { + break; + } + const time = dayjs(gameLog.dt) - ref1.joinTime; + locationStore.lastLocation.playerList.delete(userId); + locationStore.lastLocation.friendList.delete(userId); + gameLogStore.state.lastLocationAvatarList.delete(gameLog.displayName); + photonStore.photonLobbyAvatars.delete(userId); + vrStore.updateVRLastLocation(); + instanceStore.getCurrentInstanceUserList(); + entry = createJoinLeaveEntry( + 'OnPlayerLeft', + gameLog.dt, + gameLog.displayName, + location, + userId, + time + ); + database.addGamelogJoinLeaveToDatabase(entry); + break; + case 'portal-spawn': + if (vrcxStore.ipcEnabled && gameStore.isGameRunning) { + break; + } + entry = createPortalSpawnEntry(gameLog.dt, location); + database.addGamelogPortalSpawnToDatabase(entry); + break; + case 'video-play': + gameLog.videoUrl = decodeURI(gameLog.videoUrl); + if (gameLogStore.lastVideoUrl === gameLog.videoUrl) { + break; + } + gameLogStore.lastVideoUrl = gameLog.videoUrl; + gameLogStore.addGameLogVideo(gameLog, location, userId); + break; + case 'video-sync': + const timestamp = gameLog.timestamp.replace(/,/g, ''); + if (gameLogStore.nowPlaying.playing) { + gameLogStore.nowPlaying.offset = parseInt(timestamp, 10); + } + break; + case 'resource-load-string': + case 'resource-load-image': + if ( + !generalSettingsStore.logResourceLoad || + gameLogStore.lastResourceloadUrl === gameLog.resourceUrl + ) { + break; + } + gameLogStore.lastResourceloadUrl = gameLog.resourceUrl; + entry = createResourceLoadEntry( + gameLog.type, + gameLog.dt, + gameLog.resourceUrl, + location + ); + database.addGamelogResourceLoadToDatabase(entry); + break; + case 'screenshot': + vrcxStore.processScreenshot(gameLog.screenshotPath); + break; + case 'api-request': + if (AppDebug.debugWebRequests) { + console.log('API Request:', gameLog.url); + } + if (advancedSettingsStore.saveInstanceEmoji) { + const inv = parseInventoryFromUrl(gameLog.url); + if (inv) { + galleryStore.queueCheckInstanceInventory( + inv.inventoryId, + inv.userId + ); + } + } + if (advancedSettingsStore.saveInstancePrints) { + const printId = parsePrintFromUrl(gameLog.url); + if (printId) { + galleryStore.queueSavePrintToFile(printId); + } + } + break; + case 'avatar-change': + if (!gameStore.isGameRunning) { + break; + } + let avatarName = gameLogStore.state.lastLocationAvatarList.get( + gameLog.displayName + ); + if ( + photonStore.photonLoggingEnabled || + avatarName === gameLog.avatarName + ) { + break; + } + if (!avatarName) { + avatarName = gameLog.avatarName; + gameLogStore.state.lastLocationAvatarList.set( + gameLog.displayName, + avatarName + ); + break; + } + avatarName = gameLog.avatarName; + gameLogStore.state.lastLocationAvatarList.set( + gameLog.displayName, + avatarName + ); + entry = { + created_at: gameLog.dt, + type: 'AvatarChange', + userId, + name: avatarName, + displayName: gameLog.displayName + }; + break; + case 'vrcx': + const type = gameLog.data.substr(0, gameLog.data.indexOf(' ')); + if (type === 'VideoPlay(PyPyDance)') { + gameLogStore.addGameLogPyPyDance(gameLog, location); + } else if (type === 'VideoPlay(VRDancing)') { + gameLogStore.addGameLogVRDancing(gameLog, location); + } else if (type === 'VideoPlay(ZuwaZuwaDance)') { + gameLogStore.addGameLogZuwaZuwaDance(gameLog, location); + } else if (type === 'LSMedia') { + gameLogStore.addGameLogLSMedia(gameLog, location); + } else if (type === 'VideoPlay(PopcornPalace)') { + gameLogStore.addGameLogPopcornPalace(gameLog, location); + } + break; + case 'photon-id': + if (!gameStore.isGameRunning || !watchState.isFriendsLoaded) { + break; + } + const photonId = parseInt(gameLog.photonId, 10); + const ref2 = photonStore.photonLobby.get(photonId); + if (typeof ref2 === 'undefined') { + const foundUser = findUserByDisplayName( + userStore.cachedUsers, + gameLog.displayName + ); + if (foundUser) { + photonStore.photonLobby.set(photonId, foundUser); + photonStore.photonLobbyCurrent.set(photonId, foundUser); + } + const ctx1 = { + displayName: gameLog.displayName + }; + photonStore.photonLobby.set(photonId, ctx1); + photonStore.photonLobbyCurrent.set(photonId, ctx1); + instanceStore.getCurrentInstanceUserList(); + } + break; + case 'notification': + break; + case 'event': + entry = { + created_at: gameLog.dt, + type: 'Event', + data: gameLog.event + }; + database.addGamelogEventToDatabase(entry); + break; + case 'vrc-quit': + if (!gameStore.isGameRunning) { + break; + } + if (advancedSettingsStore.vrcQuitFix) { + const bias = Date.parse(gameLog.dt) + 3000; + if (bias < Date.now()) { + console.log('QuitFix: Bias too low, not killing VRC'); + break; + } + AppApi.QuitGame().then((processCount) => { + if (processCount > 1) { + console.log( + 'QuitFix: More than 1 process running, not killing VRC' + ); + } else if (processCount === 1) { + console.log('QuitFix: Killed VRC'); + } else { + console.log( + 'QuitFix: Nothing to kill, no VRC process running' + ); + } + }); + } + break; + case 'openvr-init': + gameStore.setIsGameNoVR(false); + configRepository.setBool('isGameNoVR', gameStore.isGameNoVR); + vrStore.updateOpenVR(); + break; + case 'desktop-mode': + gameStore.setIsGameNoVR(true); + configRepository.setBool('isGameNoVR', gameStore.isGameNoVR); + vrStore.updateOpenVR(); + break; + case 'udon-exception': + if (generalSettingsStore.udonExceptionLogging) { + console.log('UdonException', gameLog.data); + } + break; + case 'sticker-spawn': + if (!advancedSettingsStore.saveInstanceStickers) { + break; + } + galleryStore.trySaveStickerToFile( + gameLog.displayName, + gameLog.userId, + gameLog.inventoryId + ); + break; + } + if (typeof entry !== 'undefined') { + sharedFeedStore.addEntry(entry); + notificationStore.queueGameLogNoty(entry); + gameLogStore.addGameLog(entry); + } +} + +/** + * Parses raw game log JSON and delegates to addGameLogEntry. + * Called from C# / updateLoop. + * + * @param {string} json + */ +export function addGameLogEvent(json) { + const locationStore = useLocationStore(); + + const rawLogs = JSON.parse(json); + const gameLog = gameLogService.parseRawGameLog( + rawLogs[1], + rawLogs[2], + rawLogs.slice(3) + ); + if ( + AppDebug.debugGameLog && + gameLog.type !== 'photon-id' && + gameLog.type !== 'api-request' && + gameLog.type !== 'udon-exception' + ) { + console.log('gameLog:', gameLog); + } + addGameLogEntry(gameLog, locationStore.lastLocation.location); +} + +/** + * Starts game log processing from the database tail. + */ +export async function getGameLogTable() { + await database.initTables(); + const dateTill = await database.getLastDateGameLogDatabase(); + await updateGameLog(dateTill); +} + +/** + * Fetches all game log entries since dateTill and processes them. + * + * @param {string} dateTill + */ +async function updateGameLog(dateTill) { + await gameLogService.setDateTill(dateTill); + await new Promise((resolve) => { + workerTimers.setTimeout(resolve, 10000); + }); + let location = ''; + for (const gameLog of await gameLogService.getAll()) { + if (gameLog.type === 'location') { + location = gameLog.location; + } + addGameLogEntry(gameLog, location); + } +} + +/** + * Shows confirmation dialog before toggling the game log disabled setting. + */ +export async function disableGameLogDialog() { + const gameStore = useGameStore(); + const advancedSettingsStore = useAdvancedSettingsStore(); + const modalStore = useModalStore(); + const t = i18n.global.t; + + if (gameStore.isGameRunning) { + toast.error(t('message.gamelog.vrchat_must_be_closed')); + return; + } + if (!advancedSettingsStore.gameLogDisabled) { + modalStore + .confirm({ + description: t('confirm.disable_gamelog'), + title: t('confirm.title') + }) + .then(({ ok }) => { + if (!ok) return; + advancedSettingsStore.setGameLogDisabled(); + }) + .catch(() => {}); + } else { + advancedSettingsStore.setGameLogDisabled(); + } +} + +import configRepository from '../service/config'; diff --git a/src/coordinators/userCoordinator.js b/src/coordinators/userCoordinator.js index 3abf82ef..0a5f3103 100644 --- a/src/coordinators/userCoordinator.js +++ b/src/coordinators/userCoordinator.js @@ -38,6 +38,7 @@ import { import { runHandleUserUpdateFlow } from './userEventCoordinator'; import { runUpdateCurrentUserLocationFlow } from './locationCoordinator'; import { runUpdateFriendFlow } from './friendPresenceCoordinator'; +import { userOnFriend } from './friendRelationshipCoordinator'; import { handleGroupRepresented } from './groupCoordinator'; import { useAppearanceSettingsStore } from '../stores/settings/appearance'; import { useAuthStore } from '../stores/auth'; @@ -56,7 +57,8 @@ import { useSharedFeedStore } from '../stores/sharedFeed'; import { useUiStore } from '../stores/ui'; import { useUserStore } from '../stores/user'; -const robotUrl = `${AppDebug.endpointDomain}/file/file_0e8c4e32-7444-44ea-ade4-313c010d4bae/1/file`; +const getRobotUrl = () => + `${AppDebug.endpointDomain}/file/file_0e8c4e32-7444-44ea-ade4-313c010d4bae/1/file`; /** * @param {import('../types/api/user').GetUserResponse} json @@ -76,7 +78,7 @@ export function applyUser(json) { let ref = cachedUsers.get(json.id); let hasPropChanged = false; let changedProps = {}; - sanitizeUserJson(json, robotUrl); + sanitizeUserJson(json, getRobotUrl()); if (typeof ref === 'undefined') { ref = reactive(createDefaultUserRef(json)); if (locationStore.lastLocation.playerList.has(json.id)) { @@ -219,7 +221,7 @@ export function applyUser(json) { runUpdateFriendFlow(ref.id, ref.state); } applyFavorite('friend', ref.id); - friendStore.userOnFriend(ref); + userOnFriend(ref); const D = userDialog; if (D.visible && D.id === ref.id) { D.ref = ref; diff --git a/src/coordinators/userSessionCoordinator.js b/src/coordinators/userSessionCoordinator.js index 56aa30cb..30308d9d 100644 --- a/src/coordinators/userSessionCoordinator.js +++ b/src/coordinators/userSessionCoordinator.js @@ -1,5 +1,8 @@ import { getWorldName, parseLocation } from '../shared/utils'; -import { runUpdateFriendshipsFlow } from './friendRelationshipCoordinator'; +import { + runUpdateFriendshipsFlow, + updateUserCurrentStatus +} from './friendRelationshipCoordinator'; import { useAuthStore } from '../stores/auth'; import { useAvatarStore } from '../stores/avatar'; import { addAvatarToHistory, addAvatarWearTime } from './avatarCoordinator'; @@ -68,7 +71,7 @@ export function runPostApplySyncFlow(ref) { applyPresenceGroups(ref); instanceStore.applyQueuedInstance(ref.queuedInstance); - friendStore.updateUserCurrentStatus(ref); + updateUserCurrentStatus(ref); if (typeof ref.friends !== 'undefined') { runUpdateFriendshipsFlow(ref); } diff --git a/src/coordinators/vrcxCoordinator.js b/src/coordinators/vrcxCoordinator.js new file mode 100644 index 00000000..32d050bf --- /dev/null +++ b/src/coordinators/vrcxCoordinator.js @@ -0,0 +1,78 @@ +import { useAvatarStore } from '../stores/avatar'; +import { useFavoriteStore } from '../stores/favorite'; +import { useFriendStore } from '../stores/friend'; +import { useGalleryStore } from '../stores/gallery'; +import { useGroupStore } from '../stores/group'; +import { useInstanceStore } from '../stores/instance'; +import { useLocationStore } from '../stores/location'; +import { useUserStore } from '../stores/user'; +import { useWorldStore } from '../stores/world'; +import { failedGetRequests } from '../service/request'; + +/** + * Clears caches across multiple stores while preserving data that is + * still needed (friends, current user, favorites, active instances). + */ +export function clearVRCXCache() { + const userStore = useUserStore(); + const worldStore = useWorldStore(); + const avatarStore = useAvatarStore(); + const groupStore = useGroupStore(); + const instanceStore = useInstanceStore(); + const friendStore = useFriendStore(); + const favoriteStore = useFavoriteStore(); + const locationStore = useLocationStore(); + const galleryStore = useGalleryStore(); + + console.log('Clearing VRCX cache...'); + failedGetRequests.clear(); + userStore.cachedUsers.forEach((ref, id) => { + if ( + !friendStore.friends.has(id) && + !locationStore.lastLocation.playerList.has(ref.id) && + id !== userStore.currentUser.id + ) { + userStore.cachedUsers.delete(id); + } + }); + worldStore.cachedWorlds.forEach((ref, id) => { + if ( + !favoriteStore.getCachedFavoritesByObjectId(id) && + ref.authorId !== userStore.currentUser.id && + !favoriteStore.localWorldFavoritesList.includes(id) + ) { + worldStore.cachedWorlds.delete(id); + } + }); + avatarStore.cachedAvatars.forEach((ref, id) => { + if ( + !favoriteStore.getCachedFavoritesByObjectId(id) && + ref.authorId !== userStore.currentUser.id && + !favoriteStore.localAvatarFavoritesList.includes(id) && + !avatarStore.avatarHistory.includes(id) + ) { + avatarStore.cachedAvatars.delete(id); + } + }); + groupStore.cachedGroups.forEach((ref, id) => { + if (!groupStore.currentUserGroups.has(id)) { + groupStore.cachedGroups.delete(id); + } + }); + instanceStore.cachedInstances.forEach((ref, id) => { + if ( + [...friendStore.friends.values()].some( + (f) => f.$location?.tag === id + ) + ) { + return; + } + // delete instances over an hour old + if (Date.parse(ref.$fetchedAt) < Date.now() - 3600000) { + instanceStore.cachedInstances.delete(id); + } + }); + avatarStore.cachedAvatarNames.clear(); + userStore.customUserTags.clear(); + galleryStore.cachedEmoji.clear(); +} diff --git a/src/service/websocket.js b/src/service/websocket.js index d4130511..1e5c5c7c 100644 --- a/src/service/websocket.js +++ b/src/service/websocket.js @@ -19,6 +19,10 @@ import { getGroupDialogGroup, handleGroupMember } from '../coordinators/groupCoordinator'; +import { + handleFriendAdd, + handleFriendDelete +} from '../coordinators/friendRelationshipCoordinator'; import { escapeTag, parseLocation } from '../shared/utils'; import { AppDebug } from './appConfig'; import { groupRequest } from '../api'; @@ -268,7 +272,7 @@ function handlePipeline(args) { case 'friend-add': applyUser(content.user); - friendStore.handleFriendAdd({ + handleFriendAdd({ params: { userId: content.userId } @@ -276,7 +280,7 @@ function handlePipeline(args) { break; case 'friend-delete': - friendStore.handleFriendDelete({ + handleFriendDelete({ params: { userId: content.userId } diff --git a/src/stores/__tests__/search.test.js b/src/stores/__tests__/search.test.js index 57f9ae25..236d5f92 100644 --- a/src/stores/__tests__/search.test.js +++ b/src/stores/__tests__/search.test.js @@ -90,29 +90,21 @@ const mockGroupStrictsearch = vi.fn(); vi.mock('../user', () => ({ useUserStore: () => ({ - showUserDialog: mockShowUserDialog, cachedUsers: new Map(), showUserDialogHistory: new Set(), - currentUser: ref({ id: 'usr_me', homeLocation: '' }), - lookupUser: vi.fn(), - applyUser: vi.fn() + currentUser: ref({ id: 'usr_me', homeLocation: '' }) }) })); vi.mock('../avatar', () => ({ - useAvatarStore: () => ({ - showAvatarDialog: mockShowAvatarDialog - }) + useAvatarStore: () => ({}) })); vi.mock('../group', () => ({ - useGroupStore: () => ({ - showGroupDialog: mockShowGroupDialog - }) + useGroupStore: () => ({}) })); vi.mock('../world', () => ({ - useWorldStore: () => ({ - showWorldDialog: mockShowWorldDialog - }) + useWorldStore: () => ({}) })); + vi.mock('../friend', () => ({ useFriendStore: () => ({ friends: new Map() @@ -141,12 +133,29 @@ function makeApiMock() { groupRequest: { groupStrictsearch: (...args) => mockGroupStrictsearch(...args) }, + queryRequest: {}, miscRequest: {} }; } vi.mock('../../api', () => makeApiMock()); vi.mock('../../api/', () => makeApiMock()); +vi.mock('../../coordinators/userCoordinator', () => ({ + showUserDialog: (...args) => mockShowUserDialog(...args), + lookupUser: vi.fn(), + applyUser: vi.fn() +})); +vi.mock('../../coordinators/avatarCoordinator', () => ({ + showAvatarDialog: (...args) => mockShowAvatarDialog(...args), + getAvatarName: vi.fn() +})); +vi.mock('../../coordinators/groupCoordinator', () => ({ + showGroupDialog: (...args) => mockShowGroupDialog(...args) +})); +vi.mock('../../coordinators/worldCoordinator', () => ({ + showWorldDialog: (...args) => mockShowWorldDialog(...args) +})); + vi.mock('vue-sonner', () => ({ toast: { success: vi.fn(), diff --git a/src/stores/auth.js b/src/stores/auth.js index a70d5b31..a423fa01 100644 --- a/src/stores/auth.js +++ b/src/stores/auth.js @@ -39,9 +39,7 @@ export const useAuthStore = defineStore('Auth', () => { const { t } = useI18n(); const state = reactive({ - autoLoginAttempts: new Set(), - enableCustomEndpoint: false, - cachedConfig: {} + autoLoginAttempts: new Set() }); const loginForm = ref({ @@ -106,12 +104,12 @@ export const useAuthStore = defineStore('Auth', () => { * */ async function init() { - const [lastUserLoggedIn, enableCustomEndpoint] = await Promise.all([ + const [lastUserLoggedIn, savedEnableCustomEndpoint] = await Promise.all([ configRepository.getString('lastUserLoggedIn', ''), configRepository.getBool('VRCX_enableCustomEndpoint', false) ]); loginForm.value.lastUserLoggedIn = lastUserLoggedIn; - state.enableCustomEndpoint = enableCustomEndpoint; + enableCustomEndpoint.value = savedEnableCustomEndpoint; } init(); @@ -191,22 +189,17 @@ export const useAuthStore = defineStore('Auth', () => { await applyAutoLoginDelay(); // login at startup loginForm.value.loading = true; - authRequest - .getConfig() - .catch((err) => { - loginForm.value.loading = false; - throw err; - }) - .then(() => { - getCurrentUser() - .finally(() => { - loginForm.value.loading = false; - }) - .catch((err) => { - updateLoopStore.setNextCurrentUserRefresh(60); // 1min - console.error(err); - }); - }); + try { + await authRequest.getConfig(); + try { + await getCurrentUser(); + } catch (err) { + updateLoopStore.setNextCurrentUserRefresh(60); // 1min + console.error(err); + } + } finally { + loginForm.value.loading = false; + } } } @@ -435,7 +428,7 @@ export const useAuthStore = defineStore('Auth', () => { async function toggleCustomEndpoint() { await configRepository.setBool( 'VRCX_enableCustomEndpoint', - state.enableCustomEndpoint + enableCustomEndpoint.value ); loginForm.value.endpoint = ''; loginForm.value.websocket = ''; @@ -552,87 +545,64 @@ export const useAuthStore = defineStore('Auth', () => { AppDebug.endpointDomain = AppDebug.endpointDomainVrchat; AppDebug.websocketDomain = AppDebug.websocketDomainVrchat; } - authRequest - .getConfig() - .catch((err) => { - loginForm.value.loading = false; - throw err; - }) - .then((args) => { - if ( - loginForm.value.saveCredentials && - advancedSettingsStore.enablePrimaryPassword - ) { - modalStore - .prompt({ - title: t('prompt.primary_password.header'), - description: t( - 'prompt.primary_password.description' - ), - inputType: 'password', - pattern: /[\s\S]{1,32}/ - }) - .then(async ({ ok, value }) => { - if (!ok) return; - const savedCredentials = JSON.parse( - await configRepository.getString( - 'savedCredentials' - ) - ); - const saveCredential = - savedCredentials[ - Object.keys(savedCredentials)[0] - ]; - security - .decrypt( - saveCredential.loginParams.password, - value - ) - .then(() => { - security - .encrypt( - loginForm.value.password, - value - ) - .then((pwd) => { - authLogin({ - username: - loginForm.value - .username, - password: - loginForm.value - .password, - endpoint: - loginForm.value - .endpoint, - websocket: - loginForm.value - .websocket, - saveCredentials: - loginForm.value - .saveCredentials, - cipher: pwd - }); - }); - }); - }) - .finally(() => { - loginForm.value.loading = false; - }) - .catch(() => {}); - return args; + try { + await authRequest.getConfig(); + if ( + loginForm.value.saveCredentials && + advancedSettingsStore.enablePrimaryPassword + ) { + try { + const { ok, value } = await modalStore.prompt({ + title: t('prompt.primary_password.header'), + description: t( + 'prompt.primary_password.description' + ), + inputType: 'password', + pattern: /[\s\S]{1,32}/ + }); + if (ok) { + const savedCredentials = JSON.parse( + await configRepository.getString( + 'savedCredentials' + ) + ); + const saveCredential = + savedCredentials[ + Object.keys(savedCredentials)[0] + ]; + await security.decrypt( + saveCredential.loginParams.password, + value + ); + const pwd = await security.encrypt( + loginForm.value.password, + value + ); + await authLogin({ + username: loginForm.value.username, + password: loginForm.value.password, + endpoint: loginForm.value.endpoint, + websocket: loginForm.value.websocket, + saveCredentials: + loginForm.value.saveCredentials, + cipher: pwd + }); + } + } catch { + // prompt cancelled or crypto failed } - authLogin({ + } else { + await authLogin({ username: loginForm.value.username, password: loginForm.value.password, endpoint: loginForm.value.endpoint, websocket: loginForm.value.websocket, saveCredentials: loginForm.value.saveCredentials - }).finally(() => { - loginForm.value.loading = false; }); - return args; - }); + } + } finally { + loginForm.value.loading = false; + } } } @@ -876,7 +846,6 @@ export const useAuthStore = defineStore('Auth', () => { */ function setCachedConfig(value) { cachedConfig.value = value; - state.cachedConfig = value; } /** @@ -915,6 +884,7 @@ export const useAuthStore = defineStore('Auth', () => { handleCurrentUserUpdate, loginComplete, getAllSavedCredentials, + getSavedCredentials, setCachedConfig, setAttemptingAutoLogin }; diff --git a/src/stores/friend.js b/src/stores/friend.js index bff6d477..2a73ee5c 100644 --- a/src/stores/friend.js +++ b/src/stores/friend.js @@ -1,22 +1,17 @@ import { computed, reactive, ref, watch } from 'vue'; import { defineStore } from 'pinia'; -import { useI18n } from 'vue-i18n'; import { useRouter } from 'vue-router'; +import { i18n } from '../plugin/i18n'; import { compareByCreatedAtAscending, createRateLimiter, executeWithBackoff, getFriendsSortFunction, - getNameColour, getUserMemo, isRealInstance } from '../shared/utils'; import { friendRequest, userRequest } from '../api'; -import { - runDeleteFriendshipFlow, - runUpdateFriendshipsFlow -} from '../coordinators/friendRelationshipCoordinator'; import { runInitFriendsListFlow, runRefreshFriendsListFlow @@ -25,6 +20,10 @@ import { runPendingOfflineTickFlow, runUpdateFriendFlow } from '../coordinators/friendPresenceCoordinator'; +import { + updateFriendship, + runUpdateFriendshipsFlow +} from '../coordinators/friendRelationshipCoordinator'; import { applyUser } from '../coordinators/userCoordinator'; import { AppDebug } from '../service/appConfig'; import { database } from '../service/database'; @@ -33,10 +32,6 @@ import { useFavoriteStore } from './favorite'; import { useGeneralSettingsStore } from './settings/general'; import { useGroupStore } from './group'; import { useLocationStore } from './location'; -import { useModalStore } from './modal'; -import { useNotificationStore } from './notification'; -import { useSharedFeedStore } from './sharedFeed'; -import { useUiStore } from './ui'; import { useUserStore } from './user'; import { watchState } from '../service/watchState'; @@ -48,16 +43,12 @@ export const useFriendStore = defineStore('Friend', () => { const appearanceSettingsStore = useAppearanceSettingsStore(); const generalSettingsStore = useGeneralSettingsStore(); const userStore = useUserStore(); - const notificationStore = useNotificationStore(); - const uiStore = useUiStore(); const groupStore = useGroupStore(); - const sharedFeedStore = useSharedFeedStore(); const locationStore = useLocationStore(); const favoriteStore = useFavoriteStore(); - const modalStore = useModalStore(); - const { t } = useI18n(); const router = useRouter(); + const t = i18n.global.t; const state = reactive({ friendNumber: 0 @@ -236,7 +227,7 @@ export const useFriendStore = defineStore('Friend', () => { onlineFriendCount.value = 0; pendingOfflineMap.clear(); if (isLoggedIn) { - runInitFriendsListFlow(t); + runInitFriendsListFlow(i18n.global.t); pendingOfflineWorkerFunction(); } else { if (pendingOfflineWorker !== null) { @@ -270,94 +261,7 @@ export const useFriendStore = defineStore('Friend', () => { init(); - /** - * - * @param ref - */ - function updateUserCurrentStatus(ref) { - if (watchState.isFriendsLoaded) { - refreshFriendsStatus(ref); - } - updateOnlineFriendCounter(); - if (appearanceSettingsStore.randomUserColours) { - getNameColour(userStore.currentUser.id).then((colour) => { - userStore.setCurrentUserColour(colour); - }); - } - } - - /** - * - * @param args - */ - function handleFriendStatus(args) { - const D = userStore.userDialog; - if (D.visible === false || D.id !== args.params.userId) { - return; - } - const { json } = args; - D.isFriend = json.isFriend; - D.incomingRequest = json.incomingRequest; - D.outgoingRequest = json.outgoingRequest; - } - - /** - * - * @param args - */ - function handleFriendDelete(args) { - const D = userStore.userDialog; - if (D.visible === false || D.id !== args.params.userId) { - return; - } - D.isFriend = false; - runDeleteFriendshipFlow(args.params.userId); - deleteFriend(args.params.userId); - } - - /** - * - * @param args - */ - function handleFriendAdd(args) { - addFriendship(args.params.userId); - addFriend(args.params.userId); - } - - /** - * - * @param ref - */ - function userOnFriend(ref) { - updateFriendship(ref); - if ( - watchState.isFriendsLoaded && - ref.isFriend && - !friendLog.has(ref.id) && - ref.id !== userStore.currentUser.id - ) { - addFriendship(ref.id); - } - } - - /** - * - * @param {string} userId - * @returns {*|string} - */ - function getFriendRequest(userId) { - const array = notificationStore.notificationTable.data; - for (let i = array.length - 1; i >= 0; i--) { - if ( - array[i].type === 'friendRequest' && - array[i].senderUserId === userId - ) { - return array[i].id; - } - } - return ''; - } /** * @@ -794,183 +698,13 @@ export const useFriendStore = defineStore('Friend', () => { * * @param {string} id */ - function addFriendship(id) { - if ( - !watchState.isFriendsLoaded || - friendLog.has(id) || - id === userStore.currentUser.id - ) { - return; - } - const ref = userStore.cachedUsers.get(id); - if (typeof ref === 'undefined') { - // deleted account on friends list - return; - } - friendRequest - .getFriendStatus({ - userId: id, - currentUserId: userStore.currentUser.id - }) - .then((args) => { - if (args.params.currentUserId !== userStore.currentUser.id) { - // safety check for delayed response - return; - } - handleFriendStatus(args); - if (args.json.isFriend && !friendLog.has(id)) { - if (state.friendNumber === 0) { - state.friendNumber = friends.size; - } - ref.$friendNumber = ++state.friendNumber; - configRepository.setInt( - `VRCX_friendNumber_${userStore.currentUser.id}`, - state.friendNumber - ); - addFriend(id, ref.state); - const friendLogHistory = { - created_at: new Date().toJSON(), - type: 'Friend', - userId: id, - displayName: ref.displayName, - friendNumber: ref.$friendNumber - }; - friendLogTable.value.data.push(friendLogHistory); - database.addFriendLogHistory(friendLogHistory); - notificationStore.queueFriendLogNoty(friendLogHistory); - sharedFeedStore.addEntry(friendLogHistory); - const friendLogCurrent = { - userId: id, - displayName: ref.displayName, - trustLevel: ref.$trustLevel, - friendNumber: ref.$friendNumber - }; - friendLog.set(id, friendLogCurrent); - database.setFriendLogCurrent(friendLogCurrent); - uiStore.notifyMenu('friend-log'); - deleteFriendRequest(id); - userRequest - .getUser({ - userId: id - }) - .then(() => { - if ( - userStore.userDialog.visible && - id === userStore.userDialog.id - ) { - userStore.applyUserDialogLocation(true); - } - }); - } - }); - } - /** - * - * @param {string} userId - */ - function deleteFriendRequest(userId) { - const array = notificationStore.notificationTable.data; - for (let i = array.length - 1; i >= 0; i--) { - if ( - array[i].type === 'friendRequest' && - array[i].senderUserId === userId - ) { - array.splice(i, 1); - return; - } - } - } /** * * @param {object} ref */ - function updateFriendship(ref) { - const ctx = friendLog.get(ref.id); - if (!watchState.isFriendsLoaded || typeof ctx === 'undefined') { - return; - } - if (ctx.friendNumber) { - ref.$friendNumber = ctx.friendNumber; - } - if (!ref.$friendNumber) { - ref.$friendNumber = 0; // no null - } - if (ctx.displayName !== ref.displayName) { - if (ctx.displayName) { - const friendLogHistoryDisplayName = { - created_at: new Date().toJSON(), - type: 'DisplayName', - userId: ref.id, - displayName: ref.displayName, - previousDisplayName: ctx.displayName, - friendNumber: ref.$friendNumber - }; - friendLogTable.value.data.push(friendLogHistoryDisplayName); - database.addFriendLogHistory(friendLogHistoryDisplayName); - notificationStore.queueFriendLogNoty( - friendLogHistoryDisplayName - ); - sharedFeedStore.addEntry(friendLogHistoryDisplayName); - const friendLogCurrent = { - userId: ref.id, - displayName: ref.displayName, - trustLevel: ref.$trustLevel, - friendNumber: ref.$friendNumber - }; - friendLog.set(ref.id, friendLogCurrent); - database.setFriendLogCurrent(friendLogCurrent); - ctx.displayName = ref.displayName; - uiStore.notifyMenu('friend-log'); - } - } - if ( - ref.$trustLevel && - ctx.trustLevel && - ctx.trustLevel !== ref.$trustLevel - ) { - if ( - (ctx.trustLevel === 'Trusted User' && - ref.$trustLevel === 'Veteran User') || - (ctx.trustLevel === 'Veteran User' && - ref.$trustLevel === 'Trusted User') - ) { - const friendLogCurrent3 = { - userId: ref.id, - displayName: ref.displayName, - trustLevel: ref.$trustLevel, - friendNumber: ref.$friendNumber - }; - friendLog.set(ref.id, friendLogCurrent3); - database.setFriendLogCurrent(friendLogCurrent3); - return; - } - const friendLogHistoryTrustLevel = { - created_at: new Date().toJSON(), - type: 'TrustLevel', - userId: ref.id, - displayName: ref.displayName, - trustLevel: ref.$trustLevel, - previousTrustLevel: ctx.trustLevel, - friendNumber: ref.$friendNumber - }; - friendLogTable.value.data.push(friendLogHistoryTrustLevel); - database.addFriendLogHistory(friendLogHistoryTrustLevel); - notificationStore.queueFriendLogNoty(friendLogHistoryTrustLevel); - sharedFeedStore.addEntry(friendLogHistoryTrustLevel); - const friendLogCurrent2 = { - userId: ref.id, - displayName: ref.displayName, - trustLevel: ref.$trustLevel, - friendNumber: ref.$friendNumber - }; - friendLog.set(ref.id, friendLogCurrent2); - database.setFriendLogCurrent(friendLogCurrent2); - uiStore.notifyMenu('friend-log'); - } - ctx.trustLevel = ref.$trustLevel; - } + /** * @@ -1394,21 +1128,7 @@ export const useFriendStore = defineStore('Friend', () => { * * @param id */ - function confirmDeleteFriend(id) { - modalStore - .confirm({ - description: t('confirm.unfriend'), - title: t('confirm.title') - }) - .then(async ({ ok }) => { - if (!ok) return; - const args = await friendRequest.deleteFriend({ - userId: id - }); - handleFriendDelete(args); - }) - .catch(() => {}); - } + /** * Clears all entries in friendLog. @@ -1452,14 +1172,6 @@ export const useFriendStore = defineStore('Friend', () => { initFriendLog, migrateFriendLog, getFriendLog, - getFriendRequest, - userOnFriend, - confirmDeleteFriend, - updateUserCurrentStatus, - handleFriendAdd, - handleFriendDelete, - handleFriendStatus, - addFriendship, tryApplyFriendOrder, resetFriendLog, initFriendLogHistoryTable diff --git a/src/stores/gameLog/index.js b/src/stores/gameLog/index.js index 8e574d2b..a6612805 100644 --- a/src/stores/gameLog/index.js +++ b/src/stores/gameLog/index.js @@ -1,50 +1,34 @@ import { reactive, ref, shallowRef, watch } from 'vue'; import { defineStore } from 'pinia'; -import { toast } from 'vue-sonner'; -import { useI18n } from 'vue-i18n'; import { useRouter } from 'vue-router'; import dayjs from 'dayjs'; import { compareGameLogRows, - createJoinLeaveEntry, - createLocationEntry, - createPortalSpawnEntry, - createResourceLoadEntry, findUserByDisplayName, formatSeconds, gameLogSearchFilter, - getGroupName, - parseInventoryFromUrl, - parseLocation, - parsePrintFromUrl, - replaceBioSymbols + getGroupName } from '../../shared/utils'; -import { AppDebug } from '../../service/appConfig'; import { createMediaParsers } from './mediaParsers'; import { database } from '../../service/database'; import { useAdvancedSettingsStore } from '../settings/advanced'; import { useFriendStore } from '../friend'; -import { useGalleryStore } from '../gallery'; import { useGameStore } from '../game'; -import { useGeneralSettingsStore } from '../settings/general'; import { useInstanceStore } from '../instance'; import { useLocationStore } from '../location'; -import { runLastLocationResetFlow, runUpdateCurrentUserLocationFlow } from '../../coordinators/locationCoordinator'; -import { useModalStore } from '../modal'; import { useNotificationStore } from '../notification'; -import { usePhotonStore } from '../photon'; import { useSharedFeedStore } from '../sharedFeed'; import { useUiStore } from '../ui'; import { useUserStore } from '../user'; import { useVrStore } from '../vr'; import { useVrcxStore } from '../vrcx'; -import { userRequest } from '../../api'; import { watchState } from '../../service/watchState'; +import { tryLoadPlayerList, addGameLogEvent } from '../../coordinators/gameLogCoordinator'; + import configRepository from '../../service/config'; -import gameLogService from '../../service/gameLog.js'; import * as workerTimers from 'worker-timers'; @@ -59,14 +43,9 @@ export const useGameLogStore = defineStore('GameLog', () => { const vrcxStore = useVrcxStore(); const advancedSettingsStore = useAdvancedSettingsStore(); const gameStore = useGameStore(); - const generalSettingsStore = useGeneralSettingsStore(); - const galleryStore = useGalleryStore(); - const photonStore = usePhotonStore(); const sharedFeedStore = useSharedFeedStore(); - const modalStore = useModalStore(); const router = useRouter(); - const { t } = useI18n(); const state = reactive({ lastLocationAvatarList: new Map() @@ -304,89 +283,6 @@ export const useGameLogStore = defineStore('GameLog', () => { workerTimers.setTimeout(() => updateNowPlaying(), 1000); } - /** - * - */ - async function tryLoadPlayerList() { - // TODO: make this work again - if (!gameStore.isGameRunning) { - return; - } - console.log('Loading player list from game log...'); - let ctx; - let i; - const data = await database.getGamelogDatabase(); - if (data.length === 0) { - return; - } - let length = 0; - for (i = data.length - 1; i > -1; i--) { - ctx = data[i]; - if (ctx.type === 'Location') { - locationStore.setLastLocation({ - date: Date.parse(ctx.created_at), - location: ctx.location, - name: ctx.worldName, - playerList: new Map(), - friendList: new Map() - }); - length = i; - break; - } - } - if (length > 0) { - for (i = length + 1; i < data.length; i++) { - ctx = data[i]; - if (ctx.type === 'OnPlayerJoined') { - if (!ctx.userId) { - ctx.userId = - findUserByDisplayName( - userStore.cachedUsers, - ctx.displayName - )?.id ?? ''; - } - const userMap = { - displayName: ctx.displayName, - userId: ctx.userId, - joinTime: Date.parse(ctx.created_at), - lastAvatar: '' - }; - locationStore.lastLocation.playerList.set( - ctx.userId, - userMap - ); - if (friendStore.friends.has(ctx.userId)) { - locationStore.lastLocation.friendList.set( - ctx.userId, - userMap - ); - } - } - if (ctx.type === 'OnPlayerLeft') { - locationStore.lastLocation.playerList.delete(ctx.userId); - locationStore.lastLocation.friendList.delete(ctx.userId); - } - } - locationStore.lastLocation.playerList.forEach((ref1) => { - if ( - ref1.userId && - typeof ref1.userId === 'string' && - !userStore.cachedUsers.has(ref1.userId) - ) { - userRequest.getUser({ userId: ref1.userId }); - } - }); - - runUpdateCurrentUserLocationFlow(); - instanceStore.updateCurrentInstanceWorld(); - vrStore.updateVRLastLocation(); - instanceStore.getCurrentInstanceUserList(); - userStore.applyUserDialogLocation(); - instanceStore.applyWorldDialogInstances(); - instanceStore.applyGroupDialogInstances(); - } - } - /** * * @param row @@ -534,472 +430,6 @@ export const useGameLogStore = defineStore('GameLog', () => { } } - /** - * - * @param gameLog - * @param location - */ - function addGameLogEntry(gameLog, location) { - let entry = undefined; - if (advancedSettingsStore.gameLogDisabled) { - return; - } - let userId = String(gameLog.userId || ''); - if (!userId && gameLog.displayName) { - userId = - findUserByDisplayName( - userStore.cachedUsers, - gameLog.displayName - )?.id ?? ''; - } - switch (gameLog.type) { - case 'location-destination': - if (gameStore.isGameRunning) { - // needs to be added before OnPlayerLeft entries from LocationReset - addGameLog({ - created_at: gameLog.dt, - type: 'LocationDestination', - location: gameLog.location - }); - runLastLocationResetFlow(gameLog.dt); - locationStore.setLastLocationLocation('traveling'); - locationStore.setLastLocationDestination(gameLog.location); - locationStore.setLastLocationDestinationTime( - Date.parse(gameLog.dt) - ); - state.lastLocationAvatarList.clear(); - instanceStore.removeQueuedInstance(gameLog.location); - runUpdateCurrentUserLocationFlow(); - clearNowPlaying(); - instanceStore.updateCurrentInstanceWorld(); - userStore.applyUserDialogLocation(); - instanceStore.applyWorldDialogInstances(); - instanceStore.applyGroupDialogInstances(); - } - break; - case 'location': - instanceStore.addInstanceJoinHistory( - locationStore.lastLocation.location, - gameLog.dt - ); - const worldName = replaceBioSymbols(gameLog.worldName); - if (gameStore.isGameRunning) { - runLastLocationResetFlow(gameLog.dt); - clearNowPlaying(); - locationStore.setLastLocation({ - date: Date.parse(gameLog.dt), - location: gameLog.location, - name: worldName, - playerList: new Map(), - friendList: new Map() - }); - instanceStore.removeQueuedInstance(gameLog.location); - runUpdateCurrentUserLocationFlow(); - vrStore.updateVRLastLocation(); - instanceStore.updateCurrentInstanceWorld(); - userStore.applyUserDialogLocation(); - instanceStore.applyWorldDialogInstances(); - instanceStore.applyGroupDialogInstances(); - } - instanceStore.addInstanceJoinHistory( - gameLog.location, - gameLog.dt - ); - const L = parseLocation(gameLog.location); - entry = createLocationEntry( - gameLog.dt, - gameLog.location, - L.worldId, - worldName - ); - getGroupName(gameLog.location).then((groupName) => { - entry.groupName = groupName; - }); - addGamelogLocationToDatabase(entry); - break; - case 'player-joined': - const joinTime = Date.parse(gameLog.dt); - const userMap = { - displayName: gameLog.displayName, - userId, - joinTime, - lastAvatar: '' - }; - locationStore.lastLocation.playerList.set(userId, userMap); - const ref = userStore.cachedUsers.get(userId); - if (!userId) { - console.error('Missing userId:', gameLog.displayName); - } else if (userId === userStore.currentUser.id) { - // skip - } else if ( - friendStore.friends.has(userId) && - typeof ref !== 'undefined' - ) { - locationStore.lastLocation.friendList.set(userId, userMap); - if ( - ref.location !== locationStore.lastLocation.location && - ref.travelingToLocation !== - locationStore.lastLocation.location - ) { - // fix $location_at with private - ref.$location_at = joinTime; - } - } else if (typeof ref !== 'undefined') { - // set $location_at to join time if user isn't a friend - ref.$location_at = joinTime; - } else { - if (AppDebug.debugGameLog || AppDebug.debugWebRequests) { - console.log('Fetching user from gameLog:', userId); - } - userRequest.getUser({ userId }); - } - vrStore.updateVRLastLocation(); - instanceStore.getCurrentInstanceUserList(); - entry = createJoinLeaveEntry( - 'OnPlayerJoined', - gameLog.dt, - gameLog.displayName, - location, - userId - ); - database.addGamelogJoinLeaveToDatabase(entry); - break; - case 'player-left': - const ref1 = locationStore.lastLocation.playerList.get(userId); - if (typeof ref1 === 'undefined') { - break; - } - const time = dayjs(gameLog.dt) - ref1.joinTime; - locationStore.lastLocation.playerList.delete(userId); - locationStore.lastLocation.friendList.delete(userId); - state.lastLocationAvatarList.delete(gameLog.displayName); - photonStore.photonLobbyAvatars.delete(userId); - vrStore.updateVRLastLocation(); - instanceStore.getCurrentInstanceUserList(); - entry = createJoinLeaveEntry( - 'OnPlayerLeft', - gameLog.dt, - gameLog.displayName, - location, - userId, - time - ); - database.addGamelogJoinLeaveToDatabase(entry); - break; - case 'portal-spawn': - if (vrcxStore.ipcEnabled && gameStore.isGameRunning) { - break; - } - entry = createPortalSpawnEntry(gameLog.dt, location); - database.addGamelogPortalSpawnToDatabase(entry); - break; - case 'video-play': - gameLog.videoUrl = decodeURI(gameLog.videoUrl); - if (lastVideoUrl.value === gameLog.videoUrl) { - break; - } - lastVideoUrl.value = gameLog.videoUrl; - addGameLogVideo(gameLog, location, userId); - break; - case 'video-sync': - const timestamp = gameLog.timestamp.replace(/,/g, ''); - if (nowPlaying.value.playing) { - nowPlaying.value.offset = parseInt(timestamp, 10); - } - break; - case 'resource-load-string': - case 'resource-load-image': - if ( - !generalSettingsStore.logResourceLoad || - lastResourceloadUrl.value === gameLog.resourceUrl - ) { - break; - } - lastResourceloadUrl.value = gameLog.resourceUrl; - entry = createResourceLoadEntry( - gameLog.type, - gameLog.dt, - gameLog.resourceUrl, - location - ); - database.addGamelogResourceLoadToDatabase(entry); - break; - case 'screenshot': - // entry = { - // created_at: gameLog.dt, - // type: 'Event', - // data: `Screenshot Processed: ${gameLog.screenshotPath.replace( - // /^.*[\\/]/, - // '' - // )}` - // }; - // database.addGamelogEventToDatabase(entry); - - vrcxStore.processScreenshot(gameLog.screenshotPath); - break; - case 'api-request': - if (AppDebug.debugWebRequests) { - console.log('API Request:', gameLog.url); - } - // const userId = ''; - // try { - // const url = new URL(gameLog.url); - // const urlParams = new URLSearchParams(gameLog.url); - // if (url.pathname.substring(0, 13) === '/api/1/users/') { - // const pathArray = url.pathname.split('/'); - // userId = pathArray[4]; - // } else if (urlParams.has('userId')) { - // userId = urlParams.get('userId'); - // } - // } catch (err) { - // console.error(err); - // } - // if (!userId) { - // break; - // } - - if (advancedSettingsStore.saveInstanceEmoji) { - const inv = parseInventoryFromUrl(gameLog.url); - if (inv) { - galleryStore.queueCheckInstanceInventory( - inv.inventoryId, - inv.userId - ); - } - } - if (advancedSettingsStore.saveInstancePrints) { - const printId = parsePrintFromUrl(gameLog.url); - if (printId) { - galleryStore.queueSavePrintToFile(printId); - } - } - break; - case 'avatar-change': - if (!gameStore.isGameRunning) { - break; - } - let avatarName = state.lastLocationAvatarList.get( - gameLog.displayName - ); - if ( - photonStore.photonLoggingEnabled || - avatarName === gameLog.avatarName - ) { - break; - } - if (!avatarName) { - avatarName = gameLog.avatarName; - state.lastLocationAvatarList.set( - gameLog.displayName, - avatarName - ); - break; - } - avatarName = gameLog.avatarName; - state.lastLocationAvatarList.set( - gameLog.displayName, - avatarName - ); - entry = { - created_at: gameLog.dt, - type: 'AvatarChange', - userId, - name: avatarName, - displayName: gameLog.displayName - }; - break; - case 'vrcx': - // VideoPlay(PyPyDance) "https://jd.pypy.moe/api/v1/videos/jr1NX4Jo8GE.mp4",0.1001,239.606,"0905 : [J-POP] 【まなこ】金曜日のおはよう 踊ってみた (vernities)" - const type = gameLog.data.substr(0, gameLog.data.indexOf(' ')); - if (type === 'VideoPlay(PyPyDance)') { - addGameLogPyPyDance(gameLog, location); - } else if (type === 'VideoPlay(VRDancing)') { - addGameLogVRDancing(gameLog, location); - } else if (type === 'VideoPlay(ZuwaZuwaDance)') { - addGameLogZuwaZuwaDance(gameLog, location); - } else if (type === 'LSMedia') { - addGameLogLSMedia(gameLog, location); - } else if (type === 'VideoPlay(PopcornPalace)') { - addGameLogPopcornPalace(gameLog, location); - } - break; - case 'photon-id': - if (!gameStore.isGameRunning || !watchState.isFriendsLoaded) { - break; - } - const photonId = parseInt(gameLog.photonId, 10); - const ref2 = photonStore.photonLobby.get(photonId); - if (typeof ref2 === 'undefined') { - const foundUser = findUserByDisplayName( - userStore.cachedUsers, - gameLog.displayName - ); - if (foundUser) { - photonStore.photonLobby.set(photonId, foundUser); - photonStore.photonLobbyCurrent.set(photonId, foundUser); - } - const ctx1 = { - displayName: gameLog.displayName - }; - photonStore.photonLobby.set(photonId, ctx1); - photonStore.photonLobbyCurrent.set(photonId, ctx1); - instanceStore.getCurrentInstanceUserList(); - } - break; - case 'notification': - // entry = { - // created_at: gameLog.dt, - // type: 'Notification', - // data: gameLog.json - // }; - break; - case 'event': - entry = { - created_at: gameLog.dt, - type: 'Event', - data: gameLog.event - }; - database.addGamelogEventToDatabase(entry); - break; - case 'vrc-quit': - if (!gameStore.isGameRunning) { - break; - } - if (advancedSettingsStore.vrcQuitFix) { - const bias = Date.parse(gameLog.dt) + 3000; - if (bias < Date.now()) { - console.log('QuitFix: Bias too low, not killing VRC'); - break; - } - AppApi.QuitGame().then((processCount) => { - if (processCount > 1) { - console.log( - 'QuitFix: More than 1 process running, not killing VRC' - ); - } else if (processCount === 1) { - console.log('QuitFix: Killed VRC'); - } else { - console.log( - 'QuitFix: Nothing to kill, no VRC process running' - ); - } - }); - } - break; - case 'openvr-init': - gameStore.setIsGameNoVR(false); - configRepository.setBool('isGameNoVR', gameStore.isGameNoVR); - vrStore.updateOpenVR(); - break; - case 'desktop-mode': - gameStore.setIsGameNoVR(true); - configRepository.setBool('isGameNoVR', gameStore.isGameNoVR); - vrStore.updateOpenVR(); - break; - case 'udon-exception': - if (generalSettingsStore.udonExceptionLogging) { - console.log('UdonException', gameLog.data); - } - // entry = { - // created_at: gameLog.dt, - // type: 'Event', - // data: gameLog.data - // }; - // database.addGamelogEventToDatabase(entry); - break; - case 'sticker-spawn': - if (!advancedSettingsStore.saveInstanceStickers) { - break; - } - - galleryStore.trySaveStickerToFile( - gameLog.displayName, - gameLog.userId, - gameLog.inventoryId - ); - break; - } - if (typeof entry !== 'undefined') { - sharedFeedStore.addEntry(entry); - notificationStore.queueGameLogNoty(entry); - addGameLog(entry); - } - } - - /** - * - */ - async function getGameLogTable() { - await database.initTables(); - const dateTill = await database.getLastDateGameLogDatabase(); - updateGameLog(dateTill); - } - - /** - * - * @param dateTill - */ - async function updateGameLog(dateTill) { - await gameLogService.setDateTill(dateTill); - await new Promise((resolve) => { - workerTimers.setTimeout(resolve, 10000); - }); - let location = ''; - for (const gameLog of await gameLogService.getAll()) { - if (gameLog.type === 'location') { - location = gameLog.location; - } - addGameLogEntry(gameLog, location); - } - } - - // use in C# - /** - * - * @param json - */ - function addGameLogEvent(json) { - const rawLogs = JSON.parse(json); - const gameLog = gameLogService.parseRawGameLog( - rawLogs[1], - rawLogs[2], - rawLogs.slice(3) - ); - if ( - AppDebug.debugGameLog && - gameLog.type !== 'photon-id' && - gameLog.type !== 'api-request' && - gameLog.type !== 'udon-exception' - ) { - console.log('gameLog:', gameLog); - } - addGameLogEntry(gameLog, locationStore.lastLocation.location); - } - - /** - * - */ - async function disableGameLogDialog() { - if (gameStore.isGameRunning) { - toast.error(t('message.gamelog.vrchat_must_be_closed')); - return; - } - if (!advancedSettingsStore.gameLogDisabled) { - modalStore - .confirm({ - description: t('confirm.disable_gamelog'), - title: t('confirm.title') - }) - .then(({ ok }) => { - if (!ok) return; - advancedSettingsStore.setGameLogDisabled(); - }) - .catch(() => {}); - } else { - advancedSettingsStore.setGameLogDisabled(); - } - } - /** * */ @@ -1028,14 +458,21 @@ export const useGameLogStore = defineStore('GameLog', () => { clearNowPlaying, resetLastMediaUrls, - tryLoadPlayerList, gameLogIsFriend, gameLogIsFavorite, gameLogTableLookup, addGameLog, addGamelogLocationToDatabase, - getGameLogTable, - addGameLogEvent, - disableGameLogDialog + + // Media parsers (used by coordinator) + addGameLogVideo, + addGameLogPyPyDance, + addGameLogVRDancing, + addGameLogZuwaZuwaDance, + addGameLogLSMedia, + addGameLogPopcornPalace, + + // Re-exported from coordinator (called by C# via window.$pinia) + addGameLogEvent }; }); diff --git a/src/stores/notification/index.js b/src/stores/notification/index.js index 528f8800..4cff0936 100644 --- a/src/stores/notification/index.js +++ b/src/stores/notification/index.js @@ -41,6 +41,7 @@ import { useAdvancedSettingsStore } from '../settings/advanced'; import { useAppearanceSettingsStore } from '../settings/appearance'; import { useFavoriteStore } from '../favorite'; import { useFriendStore } from '../friend'; +import { handleFriendAdd } from '../../coordinators/friendRelationshipCoordinator'; import { useGameStore } from '../game'; import { useGeneralSettingsStore } from '../settings/general'; import { useGroupStore } from '../group'; @@ -533,7 +534,7 @@ export const useNotificationStore = defineStore('Notification', () => { notificationId: ref.id } }); - friendStore.handleFriendAdd({ + handleFriendAdd({ params: { userId: ref.senderUserId } diff --git a/src/stores/updateLoop.js b/src/stores/updateLoop.js index fcc477a3..1f08b8e9 100644 --- a/src/stores/updateLoop.js +++ b/src/stores/updateLoop.js @@ -5,11 +5,12 @@ import { database } from '../service/database'; import { groupRequest } from '../api'; import { runRefreshFriendsListFlow } from '../coordinators/friendSyncCoordinator'; import { runUpdateIsGameRunningFlow } from '../coordinators/gameCoordinator'; +import { addGameLogEvent } from '../coordinators/gameLogCoordinator'; import { runRefreshPlayerModerationsFlow } from '../coordinators/moderationCoordinator'; +import { clearVRCXCache } from '../coordinators/vrcxCoordinator'; import { useAuthStore } from './auth'; import { useDiscordPresenceSettingsStore } from './settings/discordPresence'; import { useFriendStore } from './friend'; -import { useGameLogStore } from './gameLog'; import { useGameStore } from './game'; import { useGroupStore } from './group'; import { handleGroupUserInstances } from '../coordinators/groupCoordinator'; @@ -31,7 +32,6 @@ export const useUpdateLoopStore = defineStore('UpdateLoop', () => { const moderationStore = useModerationStore(); const vrcxStore = useVrcxStore(); const discordPresenceSettingsStore = useDiscordPresenceSettingsStore(); - const gameLogStore = useGameLogStore(); const vrcxUpdaterStore = useVRCXUpdaterStore(); const groupStore = useGroupStore(); const vrStore = useVrStore(); @@ -114,7 +114,7 @@ export const useUpdateLoopStore = defineStore('UpdateLoop', () => { ) { state.nextClearVRCXCacheCheck = vrcxStore.clearVRCXCacheFrequency / 2; - vrcxStore.clearVRCXCache(); + clearVRCXCache(); } if (--state.nextDiscordUpdate <= 0) { state.nextDiscordUpdate = 3; @@ -131,7 +131,7 @@ export const useUpdateLoopStore = defineStore('UpdateLoop', () => { const logLines = await LogWatcher.GetLogLines(); if (logLines) { logLines.forEach((logLine) => { - gameLogStore.addGameLogEvent(logLine); + addGameLogEvent(logLine); }); } } diff --git a/src/stores/vrcx.js b/src/stores/vrcx.js index 24233c85..ab28db8a 100644 --- a/src/stores/vrcx.js +++ b/src/stores/vrcx.js @@ -46,6 +46,7 @@ import { useUpdateLoopStore } from './updateLoop'; import { useUserStore } from './user'; import { useVrcStatusStore } from './vrcStatus'; import { useWorldStore } from './world'; +import { clearVRCXCache } from '../coordinators/vrcxCoordinator'; import { watchState } from '../service/watchState'; import configRepository from '../service/config'; @@ -277,59 +278,7 @@ export const useVrcxStore = defineStore('Vrcx', () => { /** * */ - function clearVRCXCache() { - console.log('Clearing VRCX cache...'); - failedGetRequests.clear(); - userStore.cachedUsers.forEach((ref, id) => { - if ( - !friendStore.friends.has(id) && - !locationStore.lastLocation.playerList.has(ref.id) && - id !== userStore.currentUser.id - ) { - userStore.cachedUsers.delete(id); - } - }); - worldStore.cachedWorlds.forEach((ref, id) => { - if ( - !favoriteStore.getCachedFavoritesByObjectId(id) && - ref.authorId !== userStore.currentUser.id && - !favoriteStore.localWorldFavoritesList.includes(id) - ) { - worldStore.cachedWorlds.delete(id); - } - }); - avatarStore.cachedAvatars.forEach((ref, id) => { - if ( - !favoriteStore.getCachedFavoritesByObjectId(id) && - ref.authorId !== userStore.currentUser.id && - !favoriteStore.localAvatarFavoritesList.includes(id) && - !avatarStore.avatarHistory.includes(id) - ) { - avatarStore.cachedAvatars.delete(id); - } - }); - groupStore.cachedGroups.forEach((ref, id) => { - if (!groupStore.currentUserGroups.has(id)) { - groupStore.cachedGroups.delete(id); - } - }); - instanceStore.cachedInstances.forEach((ref, id) => { - if ( - [...friendStore.friends.values()].some( - (f) => f.$location?.tag === id - ) - ) { - return; - } - // delete instances over an hour old - if (Date.parse(ref.$fetchedAt) < Date.now() - 3600000) { - instanceStore.cachedInstances.delete(id); - } - }); - avatarStore.cachedAvatarNames.clear(); - userStore.customUserTags.clear(); - galleryStore.cachedEmoji.clear(); - } + /** * diff --git a/src/views/FriendList/FriendList.vue b/src/views/FriendList/FriendList.vue index c4dd2006..aedf7d3f 100644 --- a/src/views/FriendList/FriendList.vue +++ b/src/views/FriendList/FriendList.vue @@ -149,6 +149,7 @@ import { useDataTableScrollHeight } from '../../composables/useDataTableScrollHeight'; import { useVrcxVueTable } from '../../lib/table/useVrcxVueTable'; import { showUserDialog } from '../../coordinators/userCoordinator'; +import { confirmDeleteFriend, handleFriendDelete } from '../../coordinators/friendRelationshipCoordinator'; const { t } = useI18n(); @@ -156,7 +157,7 @@ import { showUserDialog } from '../../coordinators/userCoordinator'; const { friends, allFavoriteFriendIds } = storeToRefs(useFriendStore()); const modalStore = useModalStore(); - const { getAllUserStats, getAllUserMutualCount, confirmDeleteFriend, handleFriendDelete } = useFriendStore(); + const { getAllUserStats, getAllUserMutualCount } = useFriendStore(); const appearanceSettingsStore = useAppearanceSettingsStore(); const { randomUserColours } = storeToRefs(appearanceSettingsStore); diff --git a/src/views/FriendList/__tests__/FriendList.test.js b/src/views/FriendList/__tests__/FriendList.test.js index ca7efd93..1f392edb 100644 --- a/src/views/FriendList/__tests__/FriendList.test.js +++ b/src/views/FriendList/__tests__/FriendList.test.js @@ -40,9 +40,13 @@ mocks.pagination = mocks.makeRef({ }); mocks.sorting = mocks.makeRef([]); -vi.mock('pinia', () => ({ - storeToRefs: (store) => store -})); +vi.mock('pinia', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + storeToRefs: (store) => store + }; +}); vi.mock('vue-i18n', () => ({ useI18n: () => ({ @@ -66,9 +70,7 @@ vi.mock('../../../stores', () => ({ friends: mocks.friends, allFavoriteFriendIds: mocks.allFavoriteFriendIds, getAllUserStats: mocks.getAllUserStats, - getAllUserMutualCount: mocks.getAllUserMutualCount, - confirmDeleteFriend: mocks.confirmDeleteFriend, - handleFriendDelete: mocks.handleFriendDelete + getAllUserMutualCount: mocks.getAllUserMutualCount }), useModalStore: () => ({ confirm: (...args) => mocks.modalConfirm(...args), @@ -78,9 +80,7 @@ vi.mock('../../../stores', () => ({ stringComparer: mocks.stringComparer, friendsListSearch: mocks.friendsListSearch }), - useUserStore: () => ({ - showUserDialog: (...args) => mocks.showUserDialog(...args) - }), + useUserStore: () => ({}), useAppearanceSettingsStore: () => ({ tablePageSizes: [10, 25, 50], tablePageSize: 25, @@ -91,6 +91,15 @@ vi.mock('../../../stores', () => ({ }) })); +vi.mock('../../../coordinators/userCoordinator', () => ({ + showUserDialog: (...args) => mocks.showUserDialog(...args) +})); + +vi.mock('../../../coordinators/friendRelationshipCoordinator', () => ({ + confirmDeleteFriend: (...args) => mocks.confirmDeleteFriend(...args), + handleFriendDelete: (...args) => mocks.handleFriendDelete(...args) +})); + vi.mock('../../../plugin/router', () => ({ router: { push: (...args) => mocks.routerPush(...args) diff --git a/src/views/Moderation/__tests__/columns.test.js b/src/views/Moderation/__tests__/columns.test.js index 685b3fd2..04dbb42e 100644 --- a/src/views/Moderation/__tests__/columns.test.js +++ b/src/views/Moderation/__tests__/columns.test.js @@ -8,9 +8,13 @@ const mocks = vi.hoisted(() => ({ te: vi.fn((key) => key === 'view.moderation.filters.block') })); -vi.mock('pinia', () => ({ - storeToRefs: (store) => store -})); +vi.mock('pinia', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + storeToRefs: (store) => store + }; +}); vi.mock('../../../plugin', () => ({ i18n: { @@ -26,11 +30,14 @@ vi.mock('../../../stores', () => ({ shiftHeld: mocks.shiftHeld }), useUserStore: () => ({ - currentUser: mocks.currentUser, - showUserDialog: (...args) => mocks.showUserDialog(...args) + currentUser: mocks.currentUser }) })); +vi.mock('../../../coordinators/userCoordinator', () => ({ + showUserDialog: (...args) => mocks.showUserDialog(...args) +})); + vi.mock('../../../shared/utils', () => ({ formatDateFilter: (value, format) => `${format}:${value}` })); diff --git a/src/views/PlayerList/__tests__/PlayerList.test.js b/src/views/PlayerList/__tests__/PlayerList.test.js index 0b6c67de..40cee1a4 100644 --- a/src/views/PlayerList/__tests__/PlayerList.test.js +++ b/src/views/PlayerList/__tests__/PlayerList.test.js @@ -21,9 +21,13 @@ const mocks = vi.hoisted(() => ({ photonColumnToggleVisibility: vi.fn() })); -vi.mock('pinia', () => ({ - storeToRefs: (store) => store -})); +vi.mock('pinia', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + storeToRefs: (store) => store + }; +}); vi.mock('vue-i18n', () => ({ useI18n: () => ({ @@ -43,9 +47,7 @@ vi.mock('../../../stores', () => ({ saveChatboxUserBlacklist: (...args) => mocks.saveChatboxUserBlacklist(...args) }), useUserStore: () => ({ - currentUser: mocks.currentUser, - showUserDialog: (...args) => mocks.showUserDialog(...args), - lookupUser: (...args) => mocks.lookupUser(...args) + currentUser: mocks.currentUser }), useWorldStore: () => ({ showWorldDialog: (...args) => mocks.showWorldDialog(...args) @@ -64,6 +66,11 @@ vi.mock('../../../stores', () => ({ }) })); +vi.mock('../../../coordinators/userCoordinator', () => ({ + showUserDialog: (...args) => mocks.showUserDialog(...args), + lookupUser: (...args) => mocks.lookupUser(...args) +})); + vi.mock('../../../lib/table/useVrcxVueTable', () => ({ useVrcxVueTable: () => ({ table: { diff --git a/src/views/Settings/components/Tabs/AdvancedTab.vue b/src/views/Settings/components/Tabs/AdvancedTab.vue index 4babbe6a..3d214500 100644 --- a/src/views/Settings/components/Tabs/AdvancedTab.vue +++ b/src/views/Settings/components/Tabs/AdvancedTab.vue @@ -390,7 +390,6 @@ useAuthStore, useAvatarProviderStore, useAvatarStore, - useGameLogStore, useGeneralSettingsStore, useGroupStore, useInstanceStore, @@ -400,10 +399,11 @@ useUserStore, useVRCXUpdaterStore, useVrStore, - useVrcxStore, useWorldStore } from '../../../../stores'; import { authRequest, queryRequest } from '../../../../api'; + import { disableGameLogDialog } from '../../../../coordinators/gameLogCoordinator'; + import { clearVRCXCache } from '../../../../coordinators/vrcxCoordinator'; import { openExternalLink } from '../../../../shared/utils'; import AvatarProviderDialog from '../../dialogs/AvatarProviderDialog.vue'; @@ -420,9 +420,7 @@ const { updateVRLastLocation, updateOpenVR } = useVrStore(); const { enablePrimaryPasswordChange } = useAuthStore(); const { cachedConfig } = storeToRefs(useAuthStore()); - const { clearVRCXCache } = useVrcxStore(); const { showConsole } = useUiStore(); - const { disableGameLogDialog } = useGameLogStore(); const generalSettingsStore = useGeneralSettingsStore(); const { udonExceptionLogging, logResourceLoad, logEmptyAvatars, autoLoginDelayEnabled } = diff --git a/src/views/Sidebar/components/FriendItem.vue b/src/views/Sidebar/components/FriendItem.vue index 42acd9f5..2b7d89f4 100644 --- a/src/views/Sidebar/components/FriendItem.vue +++ b/src/views/Sidebar/components/FriendItem.vue @@ -81,6 +81,7 @@ import '@/styles/status-icon.css'; import { showUserDialog } from '../../../coordinators/userCoordinator'; +import { confirmDeleteFriend } from '../../../coordinators/friendRelationshipCoordinator'; const props = defineProps({ friend: { type: Object, required: true }, @@ -89,7 +90,7 @@ import { showUserDialog } from '../../../coordinators/userCoordinator'; const { hideNicknames } = storeToRefs(useAppearanceSettingsStore()); const { isRefreshFriendsLoading, allFavoriteFriendIds } = storeToRefs(useFriendStore()); - const { confirmDeleteFriend } = useFriendStore(); + const { t } = useI18n(); diff --git a/src/views/Sidebar/components/__tests__/FriendItem.test.js b/src/views/Sidebar/components/__tests__/FriendItem.test.js index 13b8779c..e3bf6882 100644 --- a/src/views/Sidebar/components/__tests__/FriendItem.test.js +++ b/src/views/Sidebar/components/__tests__/FriendItem.test.js @@ -7,17 +7,20 @@ const mocks = vi.hoisted(() => ({ }, friendStore: { isRefreshFriendsLoading: false, - allFavoriteFriendIds: new Set(), - confirmDeleteFriend: vi.fn() + allFavoriteFriendIds: new Set() }, - userStore: { - showUserDialog: vi.fn() - } + userStore: {}, + showUserDialog: vi.fn(), + confirmDeleteFriend: vi.fn() })); -vi.mock('pinia', () => ({ - storeToRefs: (store) => store -})); +vi.mock('pinia', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + storeToRefs: (store) => store + }; +}); vi.mock('../../../../stores', () => ({ useAppearanceSettingsStore: () => mocks.appearanceStore, @@ -25,6 +28,14 @@ vi.mock('../../../../stores', () => ({ useUserStore: () => mocks.userStore })); +vi.mock('../../../../coordinators/userCoordinator', () => ({ + showUserDialog: (...args) => mocks.showUserDialog(...args) +})); + +vi.mock('../../../../coordinators/friendRelationshipCoordinator', () => ({ + confirmDeleteFriend: (...args) => mocks.confirmDeleteFriend(...args) +})); + vi.mock('../../../../shared/utils', () => ({ userImage: vi.fn(() => 'https://example.com/avatar.png'), userStatusClass: vi.fn(() => 'status-online') @@ -125,8 +136,8 @@ describe('FriendItem.vue', () => { mocks.appearanceStore.hideNicknames = false; mocks.friendStore.isRefreshFriendsLoading = false; mocks.friendStore.allFavoriteFriendIds = new Set(); - mocks.friendStore.confirmDeleteFriend.mockReset(); - mocks.userStore.showUserDialog.mockReset(); + mocks.confirmDeleteFriend.mockReset(); + mocks.showUserDialog.mockReset(); }); test('renders nickname when hideNicknames is false', () => { @@ -149,7 +160,7 @@ describe('FriendItem.vue', () => { test('clicking row opens user dialog', async () => { const wrapper = mountItem(); await wrapper.get('div').trigger('click'); - expect(mocks.userStore.showUserDialog).toHaveBeenCalledWith('usr_1'); + expect(mocks.showUserDialog).toHaveBeenCalledWith('usr_1'); }); test('renders delete action for orphan friend and triggers confirmDeleteFriend', async () => { @@ -164,9 +175,9 @@ describe('FriendItem.vue', () => { expect(wrapper.text()).toContain('Ghost'); const button = wrapper.get('[data-testid="delete-button"]'); await button.trigger('click'); - expect(mocks.friendStore.confirmDeleteFriend).toHaveBeenCalledWith( + expect(mocks.confirmDeleteFriend).toHaveBeenCalledWith( 'usr_orphan' ); - expect(mocks.userStore.showUserDialog).not.toHaveBeenCalled(); + expect(mocks.showUserDialog).not.toHaveBeenCalled(); }); }); diff --git a/src/views/Sidebar/components/__tests__/FriendsSidebar.test.js b/src/views/Sidebar/components/__tests__/FriendsSidebar.test.js index 85c633f8..4bf2d037 100644 --- a/src/views/Sidebar/components/__tests__/FriendsSidebar.test.js +++ b/src/views/Sidebar/components/__tests__/FriendsSidebar.test.js @@ -23,7 +23,6 @@ const mocks = vi.hoisted(() => ({ gameLogDisabled: { value: false } }, userStore: { - showUserDialog: vi.fn(), showSendBoopDialog: vi.fn(), currentUser: { value: { @@ -78,9 +77,13 @@ const mocks = vi.hoisted(() => ({ } })); -vi.mock('pinia', () => ({ - storeToRefs: (store) => store -})); +vi.mock('pinia', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + storeToRefs: (store) => store + }; +}); vi.mock('@tanstack/vue-virtual', () => ({ useVirtualizer: (optionsRef) => ({ @@ -111,6 +114,10 @@ vi.mock('../../../../stores', () => ({ useUserStore: () => mocks.userStore })); +vi.mock('../../../../coordinators/userCoordinator', () => ({ + showUserDialog: vi.fn() +})); + vi.mock('../../../../shared/utils', () => ({ getFriendsSortFunction: () => (a, b) => a.id.localeCompare(b.id), isRealInstance: (location) => diff --git a/src/views/Sidebar/components/__tests__/NotificationItem.test.js b/src/views/Sidebar/components/__tests__/NotificationItem.test.js index c260f193..4e42b795 100644 --- a/src/views/Sidebar/components/__tests__/NotificationItem.test.js +++ b/src/views/Sidebar/components/__tests__/NotificationItem.test.js @@ -18,23 +18,26 @@ const mocks = vi.hoisted(() => ({ }, userStore: { cachedUsers: new Map(), - showUserDialog: vi.fn(), showSendBoopDialog: vi.fn() }, - groupStore: { - showGroupDialog: vi.fn() - }, + groupStore: {}, locationStore: { lastLocation: { value: { location: 'wrld_home:123' } } }, gameStore: { isGameRunning: { value: true } - } + }, + showUserDialog: vi.fn(), + showGroupDialog: vi.fn() })); -vi.mock('pinia', () => ({ - storeToRefs: (store) => store -})); +vi.mock('pinia', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + storeToRefs: (store) => store + }; +}); vi.mock('../../../../stores', () => ({ useNotificationStore: () => mocks.notificationStore, @@ -44,6 +47,14 @@ vi.mock('../../../../stores', () => ({ useGameStore: () => mocks.gameStore })); +vi.mock('../../../../coordinators/userCoordinator', () => ({ + showUserDialog: (...args) => mocks.showUserDialog(...args) +})); + +vi.mock('../../../../coordinators/groupCoordinator', () => ({ + showGroupDialog: (...args) => mocks.showGroupDialog(...args) +})); + vi.mock('../../../../shared/utils', () => ({ checkCanInvite: vi.fn(() => true), userImage: vi.fn(() => 'https://example.com/avatar.png') @@ -155,9 +166,9 @@ describe('NotificationItem.vue', () => { mocks.notificationStore.queueMarkAsSeen.mockReset(); mocks.notificationStore.openNotificationLink.mockReset(); mocks.notificationStore.isNotificationExpired.mockReturnValue(false); - mocks.userStore.showUserDialog.mockReset(); + mocks.showUserDialog.mockReset(); mocks.userStore.showSendBoopDialog.mockReset(); - mocks.groupStore.showGroupDialog.mockReset(); + mocks.showGroupDialog.mockReset(); mocks.userStore.cachedUsers = new Map(); }); @@ -170,7 +181,7 @@ describe('NotificationItem.vue', () => { expect(wrapper.text()).toContain('Alice'); await wrapper.get('span.truncate.cursor-pointer').trigger('click'); - expect(mocks.userStore.showUserDialog).toHaveBeenCalledWith('usr_123'); + expect(mocks.showUserDialog).toHaveBeenCalledWith('usr_123'); }); test('clicking accept icon calls acceptFriendRequestNotification', async () => {