From 914642154fa365844b41360746ccfe6c6d6de0d2 Mon Sep 17 00:00:00 2001 From: pa Date: Sun, 8 Mar 2026 23:18:38 +0900 Subject: [PATCH] Introduce coordinator --- .../authAutoLoginCoordinator.test.js | 139 ++++++ src/stores/__tests__/authCoordinator.test.js | 96 ++++ .../friendPresenceCoordinator.test.js | 159 +++++++ .../friendRelationshipCoordinator.test.js | 158 +++++++ .../__tests__/friendSyncCoordinator.test.js | 133 ++++++ src/stores/__tests__/gameCoordinator.test.js | 125 ++++++ .../__tests__/userEventCoordinator.test.js | 251 +++++++++++ .../__tests__/userSessionCoordinator.test.js | 162 +++++++ src/stores/auth.js | 147 +++---- .../coordinators/authAutoLoginCoordinator.js | 80 ++++ src/stores/coordinators/authCoordinator.js | 56 +++ .../coordinators/friendPresenceCoordinator.js | 289 ++++++++++++ .../friendRelationshipCoordinator.js | 88 ++++ .../coordinators/friendSyncCoordinator.js | 81 ++++ src/stores/coordinators/gameCoordinator.js | 49 +++ .../coordinators/userEventCoordinator.js | 334 ++++++++++++++ .../coordinators/userSessionCoordinator.js | 88 ++++ src/stores/friend.js | 415 ++++-------------- src/stores/game.js | 74 +++- src/stores/user.js | 376 +++------------- 20 files changed, 2542 insertions(+), 758 deletions(-) create mode 100644 src/stores/__tests__/authAutoLoginCoordinator.test.js create mode 100644 src/stores/__tests__/authCoordinator.test.js create mode 100644 src/stores/__tests__/friendPresenceCoordinator.test.js create mode 100644 src/stores/__tests__/friendRelationshipCoordinator.test.js create mode 100644 src/stores/__tests__/friendSyncCoordinator.test.js create mode 100644 src/stores/__tests__/gameCoordinator.test.js create mode 100644 src/stores/__tests__/userEventCoordinator.test.js create mode 100644 src/stores/__tests__/userSessionCoordinator.test.js create mode 100644 src/stores/coordinators/authAutoLoginCoordinator.js create mode 100644 src/stores/coordinators/authCoordinator.js create mode 100644 src/stores/coordinators/friendPresenceCoordinator.js create mode 100644 src/stores/coordinators/friendRelationshipCoordinator.js create mode 100644 src/stores/coordinators/friendSyncCoordinator.js create mode 100644 src/stores/coordinators/gameCoordinator.js create mode 100644 src/stores/coordinators/userEventCoordinator.js create mode 100644 src/stores/coordinators/userSessionCoordinator.js diff --git a/src/stores/__tests__/authAutoLoginCoordinator.test.js b/src/stores/__tests__/authAutoLoginCoordinator.test.js new file mode 100644 index 00000000..cc76524a --- /dev/null +++ b/src/stores/__tests__/authAutoLoginCoordinator.test.js @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { createAuthAutoLoginCoordinator } from '../coordinators/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/stores/__tests__/authCoordinator.test.js b/src/stores/__tests__/authCoordinator.test.js new file mode 100644 index 00000000..215f3ba0 --- /dev/null +++ b/src/stores/__tests__/authCoordinator.test.js @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { createAuthCoordinator } from '../coordinators/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/stores/__tests__/friendPresenceCoordinator.test.js b/src/stores/__tests__/friendPresenceCoordinator.test.js new file mode 100644 index 00000000..ccd807b1 --- /dev/null +++ b/src/stores/__tests__/friendPresenceCoordinator.test.js @@ -0,0 +1,159 @@ +import { describe, expect, test, vi } from 'vitest'; + +import { createFriendPresenceCoordinator } from '../coordinators/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/stores/__tests__/friendRelationshipCoordinator.test.js b/src/stores/__tests__/friendRelationshipCoordinator.test.js new file mode 100644 index 00000000..86b1580b --- /dev/null +++ b/src/stores/__tests__/friendRelationshipCoordinator.test.js @@ -0,0 +1,158 @@ +import { describe, expect, test, vi } from 'vitest'; + +import { createFriendRelationshipCoordinator } from '../coordinators/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/stores/__tests__/friendSyncCoordinator.test.js b/src/stores/__tests__/friendSyncCoordinator.test.js new file mode 100644 index 00000000..281cebc7 --- /dev/null +++ b/src/stores/__tests__/friendSyncCoordinator.test.js @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { createFriendSyncCoordinator } from '../coordinators/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/stores/__tests__/gameCoordinator.test.js b/src/stores/__tests__/gameCoordinator.test.js new file mode 100644 index 00000000..6884482c --- /dev/null +++ b/src/stores/__tests__/gameCoordinator.test.js @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { createGameCoordinator } from '../coordinators/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/stores/__tests__/userEventCoordinator.test.js b/src/stores/__tests__/userEventCoordinator.test.js new file mode 100644 index 00000000..8966c782 --- /dev/null +++ b/src/stores/__tests__/userEventCoordinator.test.js @@ -0,0 +1,251 @@ +import { describe, expect, test, vi } from 'vitest'; + +import { createUserEventCoordinator } from '../coordinators/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/stores/__tests__/userSessionCoordinator.test.js b/src/stores/__tests__/userSessionCoordinator.test.js new file mode 100644 index 00000000..134be238 --- /dev/null +++ b/src/stores/__tests__/userSessionCoordinator.test.js @@ -0,0 +1,162 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { createUserSessionCoordinator } from '../coordinators/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/stores/auth.js b/src/stores/auth.js index 43c95edb..84af8048 100644 --- a/src/stores/auth.js +++ b/src/stores/auth.js @@ -8,6 +8,8 @@ import Noty from 'noty'; import { closeWebSocket, initWebsocket } from '../service/websocket'; import { AppDebug } from '../service/appConfig'; import { authRequest } from '../api'; +import { createAuthAutoLoginCoordinator } from './coordinators/authAutoLoginCoordinator'; +import { createAuthCoordinator } from './coordinators/authCoordinator'; import { database } from '../service/database'; import { escapeTag } from '../shared/utils'; import { queryClient } from '../query'; @@ -174,20 +176,7 @@ export const useAuthStore = defineStore('Auth', () => { }) }).show(); } - userStore.setUserDialogVisible(false); - watchState.isLoggedIn = false; - watchState.isFriendsLoaded = false; - watchState.isFavoritesLoaded = false; - notificationStore.setNotificationInitStatus(false); - await updateStoredUser(userStore.currentUser); - webApiService.clearCookies(); - loginForm.value.lastUserLoggedIn = ''; - await configRepository.remove('lastUserLoggedIn'); - // workerTimers.setTimeout(() => location.reload(), 500); - attemptingAutoLogin.value = false; - state.autoLoginAttempts.clear(); - closeWebSocket(); - queryClient.clear(); + await authCoordinator.runLogoutFlow(); } /** @@ -848,9 +837,7 @@ export const useAuthStore = defineStore('Auth', () => { } else if (json.requiresTwoFactorAuth) { promptTOTP(); } else { - updateLoopStore.setNextCurrentUserRefresh(420); // 7mins - userStore.applyCurrentUser(json); - initWebsocket(); + authCoordinator.runLoginSuccessFlow(json); } } @@ -858,70 +845,7 @@ export const useAuthStore = defineStore('Auth', () => { * */ async function handleAutoLogin() { - if (attemptingAutoLogin.value) { - return; - } - attemptingAutoLogin.value = true; - const user = await getSavedCredentials( - loginForm.value.lastUserLoggedIn - ); - if (!user) { - attemptingAutoLogin.value = false; - return; - } - if (advancedSettingsStore.enablePrimaryPassword) { - console.error( - 'Primary password is enabled, this disables auto login.' - ); - attemptingAutoLogin.value = false; - handleLogoutEvent(); - return; - } - const attemptsInLastHour = Array.from(state.autoLoginAttempts).filter( - (timestamp) => timestamp > new Date().getTime() - 3600000 - ).length; - if (attemptsInLastHour >= 3) { - console.error( - 'More than 3 auto login attempts within the past hour, logging out instead of attempting auto login.' - ); - attemptingAutoLogin.value = false; - handleLogoutEvent(); - AppApi.FlashWindow(); - return; - } - state.autoLoginAttempts.add(new Date().getTime()); - console.log('Attempting automatic login...'); - relogin(user) - .then(() => { - if (AppDebug.errorNoty) { - AppDebug.errorNoty.close(); - } - AppDebug.errorNoty = new Noty({ - type: 'success', - text: t('message.auth.auto_login_success') - }).show(); - console.log('Automatically logged in.'); - }) - .catch((err) => { - if (AppDebug.errorNoty) { - AppDebug.errorNoty.close(); - } - AppDebug.errorNoty = new Noty({ - type: 'error', - text: t('message.auth.auto_login_failed') - }).show(); - console.error('Failed to login automatically.', err); - }) - .finally(() => { - attemptingAutoLogin.value = false; - if (!navigator.onLine) { - AppDebug.errorNoty = new Noty({ - type: 'error', - text: t('message.auth.offline') - }).show(); - console.error(`You're offline.`); - } - }); + await authAutoLoginCoordinator.runHandleAutoLoginFlow(); } /** @@ -964,15 +888,76 @@ export const useAuthStore = defineStore('Auth', () => { AppApi.CheckGameRunning(); // restore state from hot-reload } + /** + * @param {object} value Latest config payload. + */ function setCachedConfig(value) { cachedConfig.value = value; state.cachedConfig = value; } + /** + * @param {boolean} value Auto-login attempt flag. + */ function setAttemptingAutoLogin(value) { attemptingAutoLogin.value = value; } + const authAutoLoginCoordinator = createAuthAutoLoginCoordinator({ + getIsAttemptingAutoLogin: () => attemptingAutoLogin.value, + setAttemptingAutoLogin, + getLastUserLoggedIn: () => loginForm.value.lastUserLoggedIn, + getSavedCredentials, + isPrimaryPasswordEnabled: () => + advancedSettingsStore.enablePrimaryPassword, + handleLogoutEvent, + autoLoginAttempts: state.autoLoginAttempts, + relogin, + notifyAutoLoginSuccess: () => { + if (AppDebug.errorNoty) { + AppDebug.errorNoty.close(); + } + AppDebug.errorNoty = new Noty({ + type: 'success', + text: t('message.auth.auto_login_success') + }).show(); + }, + notifyAutoLoginFailed: () => { + if (AppDebug.errorNoty) { + AppDebug.errorNoty.close(); + } + AppDebug.errorNoty = new Noty({ + type: 'error', + text: t('message.auth.auto_login_failed') + }).show(); + }, + notifyOffline: () => { + AppDebug.errorNoty = new Noty({ + type: 'error', + text: t('message.auth.offline') + }).show(); + }, + flashWindow: () => AppApi.FlashWindow(), + isOnline: () => navigator.onLine, + now: () => Date.now() + }); + + const authCoordinator = createAuthCoordinator({ + userStore, + notificationStore, + updateLoopStore, + initWebsocket, + updateStoredUser, + webApiService, + loginForm, + configRepository, + setAttemptingAutoLogin, + autoLoginAttempts: state.autoLoginAttempts, + closeWebSocket, + queryClient, + watchState + }); + return { state, diff --git a/src/stores/coordinators/authAutoLoginCoordinator.js b/src/stores/coordinators/authAutoLoginCoordinator.js new file mode 100644 index 00000000..07510862 --- /dev/null +++ b/src/stores/coordinators/authAutoLoginCoordinator.js @@ -0,0 +1,80 @@ +/** + * @param {object} deps Coordinator dependencies. + * @returns {object} Auto-login flow coordinator methods. + */ +export function createAuthAutoLoginCoordinator(deps) { + const { + getIsAttemptingAutoLogin, + setAttemptingAutoLogin, + getLastUserLoggedIn, + getSavedCredentials, + isPrimaryPasswordEnabled, + handleLogoutEvent, + autoLoginAttempts, + relogin, + notifyAutoLoginSuccess, + notifyAutoLoginFailed, + notifyOffline, + flashWindow, + isOnline, + now + } = deps; + + /** + * Runs the full auto-login orchestration flow. + */ + async function runHandleAutoLoginFlow() { + if (getIsAttemptingAutoLogin()) { + return; + } + setAttemptingAutoLogin(true); + const user = await getSavedCredentials(getLastUserLoggedIn()); + if (!user) { + setAttemptingAutoLogin(false); + return; + } + if (isPrimaryPasswordEnabled()) { + console.error( + 'Primary password is enabled, this disables auto login.' + ); + setAttemptingAutoLogin(false); + await handleLogoutEvent(); + return; + } + const currentTimestamp = now(); + const attemptsInLastHour = Array.from(autoLoginAttempts).filter( + (timestamp) => timestamp > currentTimestamp - 3600000 + ).length; + if (attemptsInLastHour >= 3) { + console.error( + 'More than 3 auto login attempts within the past hour, logging out instead of attempting auto login.' + ); + setAttemptingAutoLogin(false); + await handleLogoutEvent(); + flashWindow(); + return; + } + autoLoginAttempts.add(currentTimestamp); + console.log('Attempting automatic login...'); + relogin(user) + .then(() => { + notifyAutoLoginSuccess(); + console.log('Automatically logged in.'); + }) + .catch((err) => { + notifyAutoLoginFailed(); + console.error('Failed to login automatically.', err); + }) + .finally(() => { + setAttemptingAutoLogin(false); + if (!isOnline()) { + notifyOffline(); + console.error(`You're offline.`); + } + }); + } + + return { + runHandleAutoLoginFlow + }; +} diff --git a/src/stores/coordinators/authCoordinator.js b/src/stores/coordinators/authCoordinator.js new file mode 100644 index 00000000..72277691 --- /dev/null +++ b/src/stores/coordinators/authCoordinator.js @@ -0,0 +1,56 @@ +/** + * @param {object} deps Coordinator dependencies. + * @returns {object} Auth flow coordinator methods. + */ +export function createAuthCoordinator(deps) { + const { + userStore, + notificationStore, + updateLoopStore, + initWebsocket, + updateStoredUser, + webApiService, + loginForm, + configRepository, + setAttemptingAutoLogin, + autoLoginAttempts, + closeWebSocket, + queryClient, + watchState + } = deps; + + /** + * Runs the shared logout side effects. + */ + async function runLogoutFlow() { + userStore.setUserDialogVisible(false); + watchState.isLoggedIn = false; + watchState.isFriendsLoaded = false; + watchState.isFavoritesLoaded = false; + notificationStore.setNotificationInitStatus(false); + await updateStoredUser(userStore.currentUser); + webApiService.clearCookies(); + loginForm.value.lastUserLoggedIn = ''; + // workerTimers.setTimeout(() => location.reload(), 500); + await configRepository.remove('lastUserLoggedIn'); + setAttemptingAutoLogin(false); + autoLoginAttempts.clear(); + closeWebSocket(); + queryClient.clear(); + } + + /** + * Runs post-login side effects after a successful auth response. + * @param {object} json Current user payload from auth API. + */ + function runLoginSuccessFlow(json) { + updateLoopStore.setNextCurrentUserRefresh(420); // 7mins + userStore.applyCurrentUser(json); + initWebsocket(); + } + + return { + runLogoutFlow, + runLoginSuccessFlow + }; +} diff --git a/src/stores/coordinators/friendPresenceCoordinator.js b/src/stores/coordinators/friendPresenceCoordinator.js new file mode 100644 index 00000000..cb193ebc --- /dev/null +++ b/src/stores/coordinators/friendPresenceCoordinator.js @@ -0,0 +1,289 @@ +/** + * @param {object} deps Coordinator dependencies. + * @returns {object} Friend presence coordinator methods. + */ +export function createFriendPresenceCoordinator(deps) { + const { + friends, + localFavoriteFriends, + pendingOfflineMap, + pendingOfflineDelay, + watchState, + appDebug, + getCachedUsers, + isRealInstance, + requestUser, + getWorldName, + getGroupName, + feedStore, + database, + updateOnlineFriendCounter, + now, + nowIso + } = deps; + + /** + * @param {object} ctx + * @param {string} newState + * @param {string} location + * @param {number} $location_at + */ + async function runUpdateFriendDelayedCheckFlow( + ctx, + newState, + location, + $location_at + ) { + let feed; + let groupName; + let worldName; + const id = ctx.id; + if (appDebug.debugFriendState) { + console.log( + `${ctx.name} updateFriendState ${ctx.state} -> ${newState}` + ); + if ( + typeof ctx.ref !== 'undefined' && + location !== ctx.ref.location + ) { + console.log( + `${ctx.name} pendingOfflineLocation ${location} -> ${ctx.ref.location}` + ); + } + } + if (!friends.has(id)) { + console.log('Friend not found', id); + return; + } + const isVIP = localFavoriteFriends.has(id); + const ref = ctx.ref; + if (ctx.state !== newState && typeof ctx.ref !== 'undefined') { + if ( + (newState === 'offline' || newState === 'active') && + ctx.state === 'online' + ) { + ctx.ref.$online_for = ''; + ctx.ref.$offline_for = now(); + ctx.ref.$active_for = ''; + if (newState === 'active') { + ctx.ref.$active_for = now(); + } + const ts = now(); + const time = ts - $location_at; + worldName = await getWorldName(location); + groupName = await getGroupName(location); + feed = { + created_at: nowIso(), + type: 'Offline', + userId: ref.id, + displayName: ref.displayName, + location, + worldName, + groupName, + time + }; + feedStore.addFeed(feed); + database.addOnlineOfflineToDatabase(feed); + } else if ( + newState === 'online' && + (ctx.state === 'offline' || ctx.state === 'active') + ) { + ctx.ref.$previousLocation = ''; + ctx.ref.$travelingToTime = now(); + ctx.ref.$location_at = now(); + ctx.ref.$online_for = now(); + ctx.ref.$offline_for = ''; + ctx.ref.$active_for = ''; + worldName = await getWorldName(location); + groupName = await getGroupName(location); + feed = { + created_at: nowIso(), + type: 'Online', + userId: id, + displayName: ctx.name, + location, + worldName, + groupName, + time: '' + }; + feedStore.addFeed(feed); + database.addOnlineOfflineToDatabase(feed); + } + if (newState === 'active') { + ctx.ref.$active_for = now(); + } + } + if (ctx.state !== newState) { + ctx.state = newState; + updateOnlineFriendCounter(); + } + if (ref?.displayName) { + ctx.name = ref.displayName; + } + ctx.isVIP = isVIP; + } + + /** + * Handles immediate friend presence updates and pending-offline orchestration. + * @param {string} id Friend id. + * @param {string | undefined} stateInput Optional incoming state. + */ + async function runUpdateFriendFlow(id, stateInput = undefined) { + const ctx = friends.get(id); + if (typeof ctx === 'undefined') { + return; + } + const ref = getCachedUsers().get(id); + + if (stateInput === 'online') { + const pendingOffline = pendingOfflineMap.get(id); + if (appDebug.debugFriendState && pendingOffline) { + const time = (now() - pendingOffline.startTime) / 1000; + console.log(`${ctx.name} pendingOfflineCancelTime ${time}`); + } + ctx.pendingOffline = false; + pendingOfflineMap.delete(id); + } + const isVIP = localFavoriteFriends.has(id); + let location = ''; + let $location_at = undefined; + if (typeof ref !== 'undefined') { + location = ref.location; + $location_at = ref.$location_at; + + const currentState = stateInput || ctx.state; + // wtf, fetch user if offline in an instance + if ( + currentState !== 'online' && + isRealInstance(ref.location) && + ref.$lastFetch < now() - 10000 // 10 seconds + ) { + console.log( + `Fetching offline friend in an instance ${ctx.name}` + ); + requestUser(id); + } + // wtf, fetch user if online in an offline location + if ( + currentState === 'online' && + ref.location === 'offline' && + ref.$lastFetch < now() - 10000 // 10 seconds + ) { + console.log( + `Fetching online friend in an offline location ${ctx.name}` + ); + requestUser(id); + } + } + if (typeof stateInput === 'undefined' || ctx.state === stateInput) { + // this is should be: undefined -> user + if (ctx.ref !== ref) { + ctx.ref = ref; + // NOTE + // AddFriend (CurrentUser) 이후, + // 서버에서 오는 순서라고 보면 될 듯. + if (ctx.state === 'online') { + if (watchState.isFriendsLoaded) { + requestUser(id); + } + } + } + if (ctx.isVIP !== isVIP) { + ctx.isVIP = isVIP; + } + if (typeof ref !== 'undefined' && ctx.name !== ref.displayName) { + ctx.name = ref.displayName; + } + return; + } + if ( + ctx.state === 'online' && + (stateInput === 'active' || stateInput === 'offline') + ) { + ctx.ref = ref; + ctx.isVIP = isVIP; + if (typeof ref !== 'undefined') { + ctx.name = ref.displayName; + } + if (!watchState.isFriendsLoaded) { + await runUpdateFriendDelayedCheckFlow( + ctx, + stateInput, + location, + $location_at + ); + return; + } + // prevent status flapping + if (pendingOfflineMap.has(id)) { + if (appDebug.debugFriendState) { + console.log(ctx.name, 'pendingOfflineAlreadyWaiting'); + } + return; + } + if (appDebug.debugFriendState) { + console.log(ctx.name, 'pendingOfflineBegin'); + } + pendingOfflineMap.set(id, { + startTime: now(), + newState: stateInput, + previousLocation: location, + previousLocationAt: $location_at + }); + ctx.pendingOffline = true; + return; + } + ctx.ref = ref; + ctx.isVIP = isVIP; + if (typeof ref !== 'undefined') { + ctx.name = ref.displayName; + await runUpdateFriendDelayedCheckFlow( + ctx, + ctx.ref.state, + location, + $location_at + ); + } + } + + /** + * Processes pending-offline entries and executes delayed transitions. + */ + async function runPendingOfflineTickFlow() { + const currentTime = now(); + for (const [id, pending] of pendingOfflineMap.entries()) { + if (currentTime - pending.startTime >= pendingOfflineDelay) { + const ctx = friends.get(id); + if (typeof ctx === 'undefined') { + pendingOfflineMap.delete(id); + continue; + } + ctx.pendingOffline = false; + if (pending.newState === ctx.state) { + console.error( + ctx.name, + 'pendingOfflineCancelledStateMatched, this should never happen' + ); + pendingOfflineMap.delete(id); + continue; + } + if (appDebug.debugFriendState) { + console.log(ctx.name, 'pendingOfflineEnd'); + } + pendingOfflineMap.delete(id); + await runUpdateFriendDelayedCheckFlow( + ctx, + pending.newState, + pending.previousLocation, + pending.previousLocationAt + ); + } + } + } + + return { + runUpdateFriendFlow, + runUpdateFriendDelayedCheckFlow, + runPendingOfflineTickFlow + }; +} diff --git a/src/stores/coordinators/friendRelationshipCoordinator.js b/src/stores/coordinators/friendRelationshipCoordinator.js new file mode 100644 index 00000000..0811680b --- /dev/null +++ b/src/stores/coordinators/friendRelationshipCoordinator.js @@ -0,0 +1,88 @@ +/** + * @param {object} deps Coordinator dependencies. + * @returns {object} Friend relationship coordinator methods. + */ +export function createFriendRelationshipCoordinator(deps) { + const { + friendLog, + friendLogTable, + getCurrentUserId, + requestFriendStatus, + handleFriendStatus, + addFriendship, + deleteFriend, + database, + notificationStore, + sharedFeedStore, + favoriteStore, + uiStore, + shouldNotifyUnfriend, + nowIso + } = deps; + + /** + * Validates and applies unfriend transition side effects. + * @param {string} id User id. + */ + function runDeleteFriendshipFlow(id) { + const ctx = friendLog.get(id); + if (typeof ctx === 'undefined') { + return; + } + requestFriendStatus({ + userId: id, + currentUserId: getCurrentUserId() + }).then((args) => { + if (args.params.currentUserId !== getCurrentUserId()) { + // safety check for delayed response + return; + } + handleFriendStatus(args); + if (!args.json.isFriend && friendLog.has(id)) { + const friendLogHistory = { + created_at: nowIso(), + type: 'Unfriend', + userId: id, + displayName: ctx.displayName || id + }; + friendLogTable.value.data.push(friendLogHistory); + database.addFriendLogHistory(friendLogHistory); + notificationStore.queueFriendLogNoty(friendLogHistory); + sharedFeedStore.addEntry(friendLogHistory); + friendLog.delete(id); + database.deleteFriendLogCurrent(id); + favoriteStore.handleFavoriteDelete(id); + if (shouldNotifyUnfriend()) { + uiStore.notifyMenu('friend-log'); + } + deleteFriend(id); + } + }); + } + + /** + * Reconciles current friend list against local friend log. + * @param {object} ref Current user reference. + */ + function runUpdateFriendshipsFlow(ref) { + let id; + const set = new Set(); + for (id of ref.friends) { + set.add(id); + addFriendship(id); + } + for (id of friendLog.keys()) { + if (id === getCurrentUserId()) { + friendLog.delete(id); + database.deleteFriendLogCurrent(id); + } else if (!set.has(id)) { + runDeleteFriendshipFlow(id); + } + } + } + + return { + runDeleteFriendshipFlow, + runUpdateFriendshipsFlow + }; +} diff --git a/src/stores/coordinators/friendSyncCoordinator.js b/src/stores/coordinators/friendSyncCoordinator.js new file mode 100644 index 00000000..927d43db --- /dev/null +++ b/src/stores/coordinators/friendSyncCoordinator.js @@ -0,0 +1,81 @@ +/** + * @param {object} deps Coordinator dependencies. + * @returns {object} Friend sync coordinator methods. + */ +export function createFriendSyncCoordinator(deps) { + const { + getNextCurrentUserRefresh, + getCurrentUser, + refreshFriends, + reconnectWebSocket, + getCurrentUserId, + getCurrentUserRef, + setRefreshFriendsLoading, + setFriendsLoaded, + resetFriendLog, + isFriendLogInitialized, + getFriendLog, + initFriendLog, + isDontLogMeOut, + showLoadFailedToast, + handleLogoutEvent, + tryApplyFriendOrder, + getAllUserStats, + hasLegacyFriendLogData, + removeLegacyFeedTable, + migrateMemos, + migrateFriendLog + } = deps; + + /** + * Runs friend list refresh orchestration. + */ + async function runRefreshFriendsListFlow() { + // If we just got user less then 2 min before code call, don't call it again + if (getNextCurrentUserRefresh() < 300) { + await getCurrentUser(); + } + await refreshFriends(); + reconnectWebSocket(); + } + + /** + * Runs full friend list initialization orchestration. + */ + async function runInitFriendsListFlow() { + const userId = getCurrentUserId(); + setRefreshFriendsLoading(true); + setFriendsLoaded(false); + resetFriendLog(); + + try { + const currentUser = getCurrentUserRef(); + if (await isFriendLogInitialized(userId)) { + await getFriendLog(currentUser); + } else { + await initFriendLog(currentUser); + } + } catch (err) { + if (!isDontLogMeOut()) { + showLoadFailedToast(); + handleLogoutEvent(); + throw err; + } + } + + tryApplyFriendOrder(); // once again + getAllUserStats(); // joinCount, lastSeen, timeSpent + + // remove old data from json file and migrate to SQLite (July 2021) + if (await hasLegacyFriendLogData(userId)) { + removeLegacyFeedTable(userId); + migrateMemos(); + migrateFriendLog(userId); + } + } + + return { + runRefreshFriendsListFlow, + runInitFriendsListFlow + }; +} diff --git a/src/stores/coordinators/gameCoordinator.js b/src/stores/coordinators/gameCoordinator.js new file mode 100644 index 00000000..694e2193 --- /dev/null +++ b/src/stores/coordinators/gameCoordinator.js @@ -0,0 +1,49 @@ +/** + * @param {object} deps Coordinator dependencies. + * @returns {object} Game flow coordinator methods. + */ +export function createGameCoordinator(deps) { + const { + userStore, + instanceStore, + updateLoopStore, + locationStore, + gameLogStore, + vrStore, + avatarStore, + configRepository, + workerTimers, + checkVRChatDebugLogging, + autoVRChatCacheManagement, + checkIfGameCrashed, + getIsGameNoVR + } = deps; + + /** + * Runs shared side effects when game running state changes. + * @param {boolean} isGameRunning Whether VRChat is running. + */ + async function runGameRunningChangedFlow(isGameRunning) { + if (isGameRunning) { + userStore.markCurrentUserGameStarted(); + } else { + await configRepository.setBool('isGameNoVR', getIsGameNoVR()); + userStore.markCurrentUserGameStopped(); + instanceStore.removeAllQueuedInstances(); + autoVRChatCacheManagement(); + checkIfGameCrashed(); + updateLoopStore.setIpcTimeout(0); + avatarStore.addAvatarWearTime(userStore.currentUser.currentAvatar); + } + + locationStore.lastLocationReset(); + gameLogStore.clearNowPlaying(); + vrStore.updateVRLastLocation(); + workerTimers.setTimeout(() => checkVRChatDebugLogging(), 60000); + updateLoopStore.setNextDiscordUpdate(0); + } + + return { + runGameRunningChangedFlow + }; +} diff --git a/src/stores/coordinators/userEventCoordinator.js b/src/stores/coordinators/userEventCoordinator.js new file mode 100644 index 00000000..852c4ddf --- /dev/null +++ b/src/stores/coordinators/userEventCoordinator.js @@ -0,0 +1,334 @@ +/** + * @param {object} deps Coordinator dependencies. + * @returns {object} User event coordinator methods. + */ +export function createUserEventCoordinator(deps) { + const { + friendStore, + state, + parseLocation, + userDialog, + applyUserDialogLocation, + worldStore, + groupStore, + instanceStore, + appDebug, + getWorldName, + getGroupName, + feedStore, + database, + avatarStore, + generalSettingsStore, + checkNote, + now, + nowIso + } = deps; + + /** + * Handles user diff events and applies cross-store side effects. + * @param {object} ref Updated user reference. + * @param {object} props Changed props with [new, old] tuples. + * @returns {Promise} + */ + async function runHandleUserUpdateFlow(ref, props) { + let feed; + let newLocation; + let previousLocation; + const friend = friendStore.friends.get(ref.id); + if (typeof friend === 'undefined') { + return; + } + if (props.location) { + // update instancePlayerCount + previousLocation = props.location[1]; + newLocation = props.location[0]; + let oldCount = state.instancePlayerCount.get(previousLocation); + if (typeof oldCount !== 'undefined') { + oldCount--; + if (oldCount <= 0) { + state.instancePlayerCount.delete(previousLocation); + } else { + state.instancePlayerCount.set(previousLocation, oldCount); + } + } + let newCount = state.instancePlayerCount.get(newLocation); + if (typeof newCount === 'undefined') { + newCount = 0; + } + newCount++; + state.instancePlayerCount.set(newLocation, newCount); + + const previousLocationL = parseLocation(previousLocation); + const newLocationL = parseLocation(newLocation); + if ( + previousLocationL.tag === userDialog.value.$location.tag || + newLocationL.tag === userDialog.value.$location.tag + ) { + // update user dialog instance occupants + applyUserDialogLocation(true); + } + if ( + previousLocationL.worldId === worldStore.worldDialog.id || + newLocationL.worldId === worldStore.worldDialog.id + ) { + instanceStore.applyWorldDialogInstances(); + } + if ( + previousLocationL.groupId === groupStore.groupDialog.id || + newLocationL.groupId === groupStore.groupDialog.id + ) { + instanceStore.applyGroupDialogInstances(); + } + } + if ( + !props.state && + props.location && + props.location[0] !== 'offline' && + props.location[0] !== '' && + props.location[1] !== 'offline' && + props.location[1] !== '' && + props.location[0] !== 'traveling' + ) { + // skip GPS if user is offline or traveling + previousLocation = props.location[1]; + newLocation = props.location[0]; + let time = props.location[2]; + if (previousLocation === 'traveling' && ref.$previousLocation) { + previousLocation = ref.$previousLocation; + const travelTime = now() - ref.$travelingToTime; + time -= travelTime; + if (time < 0) { + time = 0; + } + } + if (appDebug.debugFriendState && previousLocation) { + console.log( + `${ref.displayName} GPS ${previousLocation} -> ${newLocation}` + ); + } + if (previousLocation === 'offline') { + previousLocation = ''; + } + if (!previousLocation) { + // no previous location + if (appDebug.debugFriendState) { + console.log( + ref.displayName, + 'Ignoring GPS, no previous location', + newLocation + ); + } + } else if (ref.$previousLocation === newLocation) { + // location traveled to is the same + ref.$location_at = now() - time; + } else { + const worldName = await getWorldName(newLocation); + const groupName = await getGroupName(newLocation); + feed = { + created_at: nowIso(), + type: 'GPS', + userId: ref.id, + displayName: ref.displayName, + location: newLocation, + worldName, + groupName, + previousLocation, + time + }; + feedStore.addFeed(feed); + database.addGPSToDatabase(feed); + // clear previousLocation after GPS + ref.$previousLocation = ''; + ref.$travelingToTime = now(); + } + } + if ( + props.location && + props.location[0] === 'traveling' && + props.location[1] !== 'traveling' + ) { + // store previous location when user is traveling + ref.$previousLocation = props.location[1]; + ref.$travelingToTime = now(); + } + let imageMatches = false; + if ( + props.currentAvatarThumbnailImageUrl && + props.currentAvatarThumbnailImageUrl[0] && + props.currentAvatarThumbnailImageUrl[1] && + props.currentAvatarThumbnailImageUrl[0] === + props.currentAvatarThumbnailImageUrl[1] + ) { + imageMatches = true; + } + if ( + (((props.currentAvatarImageUrl || + props.currentAvatarThumbnailImageUrl) && + !ref.profilePicOverride) || + props.currentAvatarTags) && + !imageMatches + ) { + let currentAvatarImageUrl = ''; + let previousCurrentAvatarImageUrl = ''; + let currentAvatarThumbnailImageUrl = ''; + let previousCurrentAvatarThumbnailImageUrl = ''; + let currentAvatarTags = ''; + let previousCurrentAvatarTags = ''; + if (props.currentAvatarImageUrl) { + currentAvatarImageUrl = props.currentAvatarImageUrl[0]; + previousCurrentAvatarImageUrl = props.currentAvatarImageUrl[1]; + } else { + currentAvatarImageUrl = ref.currentAvatarImageUrl; + previousCurrentAvatarImageUrl = ref.currentAvatarImageUrl; + } + if (props.currentAvatarThumbnailImageUrl) { + currentAvatarThumbnailImageUrl = + props.currentAvatarThumbnailImageUrl[0]; + previousCurrentAvatarThumbnailImageUrl = + props.currentAvatarThumbnailImageUrl[1]; + } else { + currentAvatarThumbnailImageUrl = + ref.currentAvatarThumbnailImageUrl; + previousCurrentAvatarThumbnailImageUrl = + ref.currentAvatarThumbnailImageUrl; + } + if (props.currentAvatarTags) { + currentAvatarTags = props.currentAvatarTags[0]; + previousCurrentAvatarTags = props.currentAvatarTags[1]; + if ( + ref.profilePicOverride && + !props.currentAvatarThumbnailImageUrl + ) { + // forget last seen avatar + ref.currentAvatarImageUrl = ''; + ref.currentAvatarThumbnailImageUrl = ''; + } + } else { + currentAvatarTags = ref.currentAvatarTags; + previousCurrentAvatarTags = ref.currentAvatarTags; + } + if ( + generalSettingsStore.logEmptyAvatars || + ref.currentAvatarImageUrl + ) { + let avatarInfo = { + ownerId: '', + avatarName: '' + }; + try { + avatarInfo = await avatarStore.getAvatarName( + currentAvatarImageUrl + ); + } catch (err) { + console.log(err); + } + let previousAvatarInfo = { + ownerId: '', + avatarName: '' + }; + try { + previousAvatarInfo = await avatarStore.getAvatarName( + previousCurrentAvatarImageUrl + ); + } catch (err) { + console.log(err); + } + feed = { + created_at: nowIso(), + type: 'Avatar', + userId: ref.id, + displayName: ref.displayName, + ownerId: avatarInfo.ownerId, + previousOwnerId: previousAvatarInfo.ownerId, + avatarName: avatarInfo.avatarName, + previousAvatarName: previousAvatarInfo.avatarName, + currentAvatarImageUrl, + currentAvatarThumbnailImageUrl, + previousCurrentAvatarImageUrl, + previousCurrentAvatarThumbnailImageUrl, + currentAvatarTags, + previousCurrentAvatarTags + }; + feedStore.addFeed(feed); + database.addAvatarToDatabase(feed); + } + } + // if status is offline, ignore status and statusDescription + if ( + (props.status && + props.status[0] !== 'offline' && + props.status[1] !== 'offline') || + (!props.status && props.statusDescription) + ) { + let status = ''; + let previousStatus = ''; + let statusDescription = ''; + let previousStatusDescription = ''; + if (props.status) { + if (props.status[0]) { + status = props.status[0]; + } + if (props.status[1]) { + previousStatus = props.status[1]; + } + } else if (ref.status) { + status = ref.status; + previousStatus = ref.status; + } + if (props.statusDescription) { + if (props.statusDescription[0]) { + statusDescription = props.statusDescription[0]; + } + if (props.statusDescription[1]) { + previousStatusDescription = props.statusDescription[1]; + } + } else if (ref.statusDescription) { + statusDescription = ref.statusDescription; + previousStatusDescription = ref.statusDescription; + } + feed = { + created_at: nowIso(), + type: 'Status', + userId: ref.id, + displayName: ref.displayName, + status, + statusDescription, + previousStatus, + previousStatusDescription + }; + feedStore.addFeed(feed); + database.addStatusToDatabase(feed); + } + if (props.bio && props.bio[0] && props.bio[1]) { + let bio = ''; + let previousBio = ''; + if (props.bio[0]) { + bio = props.bio[0]; + } + if (props.bio[1]) { + previousBio = props.bio[1]; + } + feed = { + created_at: nowIso(), + type: 'Bio', + userId: ref.id, + displayName: ref.displayName, + bio, + previousBio + }; + feedStore.addFeed(feed); + database.addBioToDatabase(feed); + } + if ( + props.note && + props.note[0] !== null && + props.note[0] !== props.note[1] + ) { + checkNote(ref.id, props.note[0]); + } + } + + return { + runHandleUserUpdateFlow + }; +} diff --git a/src/stores/coordinators/userSessionCoordinator.js b/src/stores/coordinators/userSessionCoordinator.js new file mode 100644 index 00000000..4e77d0a2 --- /dev/null +++ b/src/stores/coordinators/userSessionCoordinator.js @@ -0,0 +1,88 @@ +/** + * @param {object} deps Coordinator dependencies. + * @returns {object} User session coordinator methods. + */ +export function createUserSessionCoordinator(deps) { + const { + avatarStore, + gameStore, + groupStore, + instanceStore, + friendStore, + authStore, + cachedUsers, + currentUser, + userDialog, + getWorldName, + parseLocation, + now + } = deps; + + /** + * Runs avatar transition side effects for current user updates. + * @param {object} args Avatar transition context. + * @param {object} args.json Current user payload. + * @param {object} args.ref Current user state reference. + * @param {boolean} args.isLoggedIn Whether current user is already logged in. + */ + function runAvatarSwapFlow({ json, ref, isLoggedIn }) { + if (!isLoggedIn) { + return; + } + if (json.currentAvatar !== ref.currentAvatar) { + avatarStore.addAvatarToHistory(json.currentAvatar); + if (gameStore.isGameRunning) { + avatarStore.addAvatarWearTime(ref.currentAvatar); + ref.$previousAvatarSwapTime = now(); + } + } + } + + /** + * Runs one-time side effects for first current-user hydration after login. + * @param {object} ref Current user state reference. + */ + function runFirstLoginFlow(ref) { + if (gameStore.isGameRunning) { + ref.$previousAvatarSwapTime = now(); + } + cachedUsers.clear(); // clear before running applyUser + currentUser.value = ref; + authStore.loginComplete(); + } + + /** + * Runs cross-store synchronization after current-user data is applied. + * @param {object} ref Current user state reference. + */ + function runPostApplySyncFlow(ref) { + groupStore.applyPresenceGroups(ref); + instanceStore.applyQueuedInstance(ref.queuedInstance); + friendStore.updateUserCurrentStatus(ref); + friendStore.updateFriendships(ref); + } + + /** + * Syncs home location derived state and visible dialog display name. + * @param {object} ref Current user state reference. + */ + function runHomeLocationSyncFlow(ref) { + if (ref.homeLocation === ref.$homeLocation?.tag) { + return; + } + ref.$homeLocation = parseLocation(ref.homeLocation); + // apply home location name to user dialog + if (userDialog.value.visible && userDialog.value.id === ref.id) { + getWorldName(currentUser.value.homeLocation).then((worldName) => { + userDialog.value.$homeLocationName = worldName; + }); + } + } + + return { + runAvatarSwapFlow, + runFirstLoginFlow, + runPostApplySyncFlow, + runHomeLocationSyncFlow + }; +} diff --git a/src/stores/friend.js b/src/stores/friend.js index adb9c2c2..a2eac6c5 100644 --- a/src/stores/friend.js +++ b/src/stores/friend.js @@ -18,6 +18,9 @@ import { } from '../shared/utils'; import { friendRequest, userRequest } from '../api'; import { AppDebug } from '../service/appConfig'; +import { createFriendPresenceCoordinator } from './coordinators/friendPresenceCoordinator'; +import { createFriendRelationshipCoordinator } from './coordinators/friendRelationshipCoordinator'; +import { createFriendSyncCoordinator } from './coordinators/friendSyncCoordinator'; import { database } from '../service/database'; import { reconnectWebSocket } from '../service/websocket'; import { useAppearanceSettingsStore } from './settings/appearance'; @@ -377,264 +380,15 @@ export const useFriendStore = defineStore('Friend', () => { * @param {string?} stateInput */ function updateFriend(id, stateInput = undefined) { - const ctx = friends.get(id); - if (typeof ctx === 'undefined') { - return; - } - const ref = userStore.cachedUsers.get(id); - if (stateInput && typeof ref !== 'undefined') { - ctx.ref.state = stateInput; - } - if (stateInput === 'online') { - const pendingOffline = pendingOfflineMap.get(id); - if (AppDebug.debugFriendState && pendingOffline) { - const time = (Date.now() - pendingOffline.startTime) / 1000; - console.log(`${ctx.name} pendingOfflineCancelTime ${time}`); - } - ctx.pendingOffline = false; - pendingOfflineMap.delete(id); - } - const isVIP = localFavoriteFriends.has(id); - let location = ''; - let $location_at = undefined; - if (typeof ref !== 'undefined') { - location = ref.location; - $location_at = ref.$location_at; - - const currentState = stateInput || ctx.state; - // wtf, fetch user if offline in an instance - if ( - currentState !== 'online' && - isRealInstance(ref.location) && - ref.$lastFetch < Date.now() - 10000 // 10 seconds - ) { - console.log( - `Fetching offline friend in an instance ${ctx.name}` - ); - userRequest.getUser({ - userId: id - }); - } - // wtf, fetch user if online in an offline location - if ( - currentState === 'online' && - ref.location === 'offline' && - ref.$lastFetch < Date.now() - 10000 // 10 seconds - ) { - console.log( - `Fetching online friend in an offline location ${ctx.name}` - ); - userRequest.getUser({ - userId: id - }); - } - } - if (typeof stateInput === 'undefined' || ctx.state === stateInput) { - // this is should be: undefined -> user - if (ctx.ref !== ref) { - ctx.ref = ref; - // NOTE - // AddFriend (CurrentUser) 이후, - // 서버에서 오는 순서라고 보면 될 듯. - if (ctx.state === 'online') { - if (watchState.isFriendsLoaded) { - userRequest.getUser({ - userId: id - }); - } - } - } - if (ctx.isVIP !== isVIP) { - ctx.isVIP = isVIP; - } - if (typeof ref !== 'undefined' && ctx.name !== ref.displayName) { - ctx.name = ref.displayName; - } - } else if ( - ctx.state === 'online' && - (stateInput === 'active' || stateInput === 'offline') - ) { - ctx.ref = ref; - ctx.isVIP = isVIP; - if (typeof ref !== 'undefined') { - ctx.name = ref.displayName; - } - if (!watchState.isFriendsLoaded) { - updateFriendDelayedCheck( - ctx, - stateInput, - location, - $location_at - ); - return; - } - // prevent status flapping - if (pendingOfflineMap.has(id)) { - if (AppDebug.debugFriendState) { - console.log(ctx.name, 'pendingOfflineAlreadyWaiting'); - } - return; - } - if (AppDebug.debugFriendState) { - console.log(ctx.name, 'pendingOfflineBegin'); - } - pendingOfflineMap.set(id, { - startTime: Date.now(), - newState: stateInput, - previousLocation: location, - previousLocationAt: $location_at - }); - ctx.pendingOffline = true; - } else { - ctx.ref = ref; - ctx.isVIP = isVIP; - if (typeof ref !== 'undefined') { - ctx.name = ref.displayName; - updateFriendDelayedCheck( - ctx, - ctx.ref.state, - location, - $location_at - ); - } - } + friendPresenceCoordinator.runUpdateFriendFlow(id, stateInput); } async function pendingOfflineWorkerFunction() { pendingOfflineWorker = workerTimers.setInterval(() => { - const now = Date.now(); - for (const [id, pending] of pendingOfflineMap.entries()) { - if (now - pending.startTime >= pendingOfflineDelay) { - const ctx = friends.get(id); - if (typeof ctx === 'undefined') { - pendingOfflineMap.delete(id); - continue; - } - ctx.pendingOffline = false; - if (pending.newState === ctx.state) { - console.error( - ctx.name, - 'pendingOfflineCancelledStateMatched, this should never happen' - ); - pendingOfflineMap.delete(id); - continue; - } - if (AppDebug.debugFriendState) { - console.log(ctx.name, 'pendingOfflineEnd'); - } - pendingOfflineMap.delete(id); - updateFriendDelayedCheck( - ctx, - pending.newState, - pending.previousLocation, - pending.previousLocationAt - ); - } - } + friendPresenceCoordinator.runPendingOfflineTickFlow(); }, 1000); } - /** - * @param {Object} ctx - * @param {string} newState - * @param {string} location - * @param {number} $location_at - */ - async function updateFriendDelayedCheck( - ctx, - newState, - location, - $location_at - ) { - let feed; - let groupName; - let worldName; - const id = ctx.id; - if (AppDebug.debugFriendState) { - console.log( - `${ctx.name} updateFriendState ${ctx.state} -> ${newState}` - ); - if ( - typeof ctx.ref !== 'undefined' && - location !== ctx.ref.location - ) { - console.log( - `${ctx.name} pendingOfflineLocation ${location} -> ${ctx.ref.location}` - ); - } - } - if (!friends.has(id)) { - console.log('Friend not found', id); - return; - } - const isVIP = localFavoriteFriends.has(id); - const ref = ctx.ref; - if (ctx.state !== newState && typeof ctx.ref !== 'undefined') { - if ( - (newState === 'offline' || newState === 'active') && - ctx.state === 'online' - ) { - ctx.ref.$online_for = ''; - ctx.ref.$offline_for = Date.now(); - ctx.ref.$active_for = ''; - if (newState === 'active') { - ctx.ref.$active_for = Date.now(); - } - const ts = Date.now(); - const time = ts - $location_at; - worldName = await getWorldName(location); - groupName = await getGroupName(location); - feed = { - created_at: new Date().toJSON(), - type: 'Offline', - userId: ref.id, - displayName: ref.displayName, - location, - worldName, - groupName, - time - }; - feedStore.addFeed(feed); - database.addOnlineOfflineToDatabase(feed); - } else if ( - newState === 'online' && - (ctx.state === 'offline' || ctx.state === 'active') - ) { - ctx.ref.$previousLocation = ''; - ctx.ref.$travelingToTime = Date.now(); - ctx.ref.$location_at = Date.now(); - ctx.ref.$online_for = Date.now(); - ctx.ref.$offline_for = ''; - ctx.ref.$active_for = ''; - worldName = await getWorldName(location); - groupName = await getGroupName(location); - feed = { - created_at: new Date().toJSON(), - type: 'Online', - userId: id, - displayName: ctx.name, - location, - worldName, - groupName, - time: '' - }; - feedStore.addFeed(feed); - database.addOnlineOfflineToDatabase(feed); - } - if (newState === 'active') { - ctx.ref.$active_for = Date.now(); - } - } - if (ctx.state !== newState) { - ctx.state = newState; - updateOnlineFriendCounter(); - } - if (ref?.displayName) { - ctx.name = ref.displayName; - } - ctx.isVIP = isVIP; - } - /** * @param {string} id */ @@ -903,12 +657,7 @@ export const useFriendStore = defineStore('Friend', () => { * @returns {Promise} */ async function refreshFriendsList() { - // If we just got user less then 2 min before code call, don't call it again - if (updateLoopStore.nextCurrentUserRefresh < 300) { - await userStore.getCurrentUser(); - } - await refreshFriends(); - reconnectWebSocket(); + await friendSyncCoordinator.runRefreshFriendsListFlow(); } function updateOnlineFriendCounter(forceUpdate = false) { @@ -1099,41 +848,7 @@ export const useFriendStore = defineStore('Friend', () => { * @param {string} id */ function deleteFriendship(id) { - const ctx = friendLog.get(id); - if (typeof ctx === 'undefined') { - 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)) { - const friendLogHistory = { - created_at: new Date().toJSON(), - type: 'Unfriend', - userId: id, - displayName: ctx.displayName || id - }; - friendLogTable.value.data.push(friendLogHistory); - database.addFriendLogHistory(friendLogHistory); - notificationStore.queueFriendLogNoty(friendLogHistory); - sharedFeedStore.addEntry(friendLogHistory); - friendLog.delete(id); - database.deleteFriendLogCurrent(id); - favoriteStore.handleFavoriteDelete(id); - if (!appearanceSettingsStore.hideUnfriends) { - uiStore.notifyMenu('friend-log'); - } - deleteFriend(id); - } - }); + friendRelationshipCoordinator.runDeleteFriendshipFlow(id); } /** @@ -1141,20 +856,7 @@ export const useFriendStore = defineStore('Friend', () => { * @param {object} ref */ function updateFriendships(ref) { - let id; - const set = new Set(); - for (id of ref.friends) { - set.add(id); - addFriendship(id); - } - for (id of friendLog.keys()) { - if (id === userStore.currentUser.id) { - friendLog.delete(id); - database.deleteFriendLogCurrent(id); - } else if (!set.has(id)) { - deleteFriendship(id); - } - } + friendRelationshipCoordinator.runUpdateFriendshipsFlow(ref); } /** @@ -1654,34 +1356,7 @@ export const useFriendStore = defineStore('Friend', () => { } async function initFriendsList() { - const userId = userStore.currentUser.id; - isRefreshFriendsLoading.value = true; - watchState.isFriendsLoaded = false; - friendLog = new Map(); - - try { - if (await configRepository.getBool(`friendLogInit_${userId}`)) { - await getFriendLog(userStore.currentUser); - } else { - await initFriendLog(userStore.currentUser); - } - } catch (err) { - if (!AppDebug.dontLogMeOut) { - toast.error(t('message.friend.load_failed')); - authStore.handleLogoutEvent(); - throw err; - } - } - - tryApplyFriendOrder(); // once again - getAllUserStats(); // joinCount, lastSeen, timeSpent - - // remove old data from json file and migrate to SQLite (July 2021) - if (await VRCXStorage.Get(`${userId}_friendLogUpdatedAt`)) { - VRCXStorage.Remove(`${userId}_feedTable`); - migrateMemos(); - migrateFriendLog(userId); - } + await friendSyncCoordinator.runInitFriendsListFlow(); } /** @@ -1691,6 +1366,78 @@ export const useFriendStore = defineStore('Friend', () => { isRefreshFriendsLoading.value = value; } + const friendPresenceCoordinator = createFriendPresenceCoordinator({ + friends, + localFavoriteFriends, + pendingOfflineMap, + pendingOfflineDelay, + watchState, + appDebug: AppDebug, + getCachedUsers: () => userStore.cachedUsers, + isRealInstance, + requestUser: (userId) => + userRequest.getUser({ + userId + }), + getWorldName, + getGroupName, + feedStore, + database, + updateOnlineFriendCounter, + now: () => Date.now(), + nowIso: () => new Date().toJSON() + }); + + const friendRelationshipCoordinator = createFriendRelationshipCoordinator({ + friendLog, + friendLogTable, + getCurrentUserId: () => userStore.currentUser.id, + requestFriendStatus: (params) => friendRequest.getFriendStatus(params), + handleFriendStatus, + addFriendship, + deleteFriend, + database, + notificationStore, + sharedFeedStore, + favoriteStore, + uiStore, + shouldNotifyUnfriend: () => !appearanceSettingsStore.hideUnfriends, + nowIso: () => new Date().toJSON() + }); + + const friendSyncCoordinator = createFriendSyncCoordinator({ + getNextCurrentUserRefresh: () => updateLoopStore.nextCurrentUserRefresh, + getCurrentUser: () => userStore.getCurrentUser(), + refreshFriends, + reconnectWebSocket, + getCurrentUserId: () => userStore.currentUser.id, + getCurrentUserRef: () => userStore.currentUser, + setRefreshFriendsLoading: (value) => { + isRefreshFriendsLoading.value = value; + }, + setFriendsLoaded: (value) => { + watchState.isFriendsLoaded = value; + }, + resetFriendLog: () => { + friendLog = new Map(); + }, + isFriendLogInitialized: (userId) => + configRepository.getBool(`friendLogInit_${userId}`), + getFriendLog, + initFriendLog, + isDontLogMeOut: () => AppDebug.dontLogMeOut, + showLoadFailedToast: () => toast.error(t('message.friend.load_failed')), + handleLogoutEvent: () => authStore.handleLogoutEvent(), + tryApplyFriendOrder, + getAllUserStats, + hasLegacyFriendLogData: (userId) => + VRCXStorage.Get(`${userId}_friendLogUpdatedAt`), + removeLegacyFeedTable: (userId) => + VRCXStorage.Remove(`${userId}_feedTable`), + migrateMemos, + migrateFriendLog + }); + return { state, diff --git a/src/stores/game.js b/src/stores/game.js index 5c28110a..a27c71d0 100644 --- a/src/stores/game.js +++ b/src/stores/game.js @@ -6,6 +6,7 @@ import { deleteVRChatCache as _deleteVRChatCache, isRealInstance } from '../shared/utils'; +import { createGameCoordinator } from './coordinators/gameCoordinator'; import { database } from '../service/database'; import { useAdvancedSettingsStore } from './settings/advanced'; import { useAvatarStore } from './avatar'; @@ -56,12 +57,18 @@ export const useGameStore = defineStore('Game', () => { const isHmdAfk = ref(false); + /** + * + */ async function init() { isGameNoVR.value = await configRepository.getBool('isGameNoVR'); } init(); + /** + * @param {object} ref Avatar or world reference payload. + */ async function deleteVRChatCache(ref) { await _deleteVRChatCache(ref); getVRChatCacheSize(); @@ -69,12 +76,18 @@ export const useGameStore = defineStore('Game', () => { avatarStore.updateVRChatAvatarCache(); } + /** + * + */ function autoVRChatCacheManagement() { if (advancedSettingsStore.autoSweepVRChatCache) { sweepVRChatCache(); } } + /** + * + */ async function sweepVRChatCache() { try { const output = await AssetBundleManager.SweepCache(); @@ -87,6 +100,9 @@ export const useGameStore = defineStore('Game', () => { } } + /** + * + */ function checkIfGameCrashed() { if (!advancedSettingsStore.relaunchVRChatAfterCrash) { return; @@ -118,6 +134,9 @@ export const useGameStore = defineStore('Game', () => { }); } + /** + * @param {string} location Last known location to relaunch. + */ function restartCrashedGame(location) { if (!isGameNoVR.value && !isSteamVRRunning.value) { console.log("SteamVR isn't running, not relaunching VRChat"); @@ -137,6 +156,9 @@ export const useGameStore = defineStore('Game', () => { launchStore.launchGame(location, '', isGameNoVR.value); } + /** + * + */ async function getVRChatCacheSize() { VRChatCacheSizeLoading.value = true; const totalCacheSize = 30; @@ -146,32 +168,34 @@ export const useGameStore = defineStore('Game', () => { VRChatCacheSizeLoading.value = false; } + const gameCoordinator = createGameCoordinator({ + userStore, + instanceStore, + updateLoopStore, + locationStore, + gameLogStore, + vrStore, + avatarStore, + configRepository, + workerTimers, + checkVRChatDebugLogging, + autoVRChatCacheManagement, + checkIfGameCrashed, + getIsGameNoVR: () => isGameNoVR.value + }); + // use in C# + /** + * @param {boolean} isGameRunningArg Game running flag from IPC. + * @param {boolean} isSteamVRRunningArg SteamVR running flag from IPC. + */ async function updateIsGameRunning(isGameRunningArg, isSteamVRRunningArg) { - const avatarStore = useAvatarStore(); if (advancedSettingsStore.gameLogDisabled) { return; } if (isGameRunningArg !== isGameRunning.value) { isGameRunning.value = isGameRunningArg; - if (isGameRunningArg) { - userStore.markCurrentUserGameStarted(); - } else { - await configRepository.setBool('isGameNoVR', isGameNoVR.value); - userStore.markCurrentUserGameStopped(); - instanceStore.removeAllQueuedInstances(); - autoVRChatCacheManagement(); - checkIfGameCrashed(); - updateLoopStore.setIpcTimeout(0); - avatarStore.addAvatarWearTime( - userStore.currentUser.currentAvatar - ); - } - locationStore.lastLocationReset(); - gameLogStore.clearNowPlaying(); - vrStore.updateVRLastLocation(); - workerTimers.setTimeout(() => checkVRChatDebugLogging(), 60000); - updateLoopStore.setNextDiscordUpdate(0); + await gameCoordinator.runGameRunningChangedFlow(isGameRunningArg); console.log(new Date(), 'isGameRunning', isGameRunningArg); } @@ -183,6 +207,9 @@ export const useGameStore = defineStore('Game', () => { } // use in C# + /** + * @param {boolean} isHmdAfkArg HMD AFK flag from VR polling. + */ function updateIsHmdAfk(isHmdAfkArg) { if (isHmdAfkArg !== isHmdAfk.value) { isHmdAfk.value = isHmdAfkArg; @@ -191,12 +218,15 @@ export const useGameStore = defineStore('Game', () => { } /** - * @param {boolean} value + * @param {boolean} value Whether game was launched in non-VR mode. */ function setIsGameNoVR(value) { isGameNoVR.value = value; } + /** + * + */ async function checkVRChatDebugLogging() { if (advancedSettingsStore.gameLogDisabled) { return; @@ -241,6 +271,10 @@ export const useGameStore = defineStore('Game', () => { } } + /** + * @param {string} key VRChat registry key. + * @returns {Promise} Registry key value. + */ async function getVRChatRegistryKey(key) { if (LINUX) { return AppApi.GetVRChatRegistryKeyString(key); diff --git a/src/stores/user.js b/src/stores/user.js index 01948a21..8c880883 100644 --- a/src/stores/user.js +++ b/src/stores/user.js @@ -35,6 +35,8 @@ import { } from '../api'; import { processBulk, request } from '../service/request'; import { AppDebug } from '../service/appConfig'; +import { createUserEventCoordinator } from './coordinators/userEventCoordinator'; +import { createUserSessionCoordinator } from './coordinators/userSessionCoordinator'; import { database } from '../service/database'; import { patchUserFromEvent } from '../query'; import { useAppearanceSettingsStore } from './settings/appearance'; @@ -1144,301 +1146,7 @@ export const useUserStore = defineStore('User', () => { * @returns {Promise} */ async function handleUserUpdate(ref, props) { - let feed; - let newLocation; - let previousLocation; - const friend = friendStore.friends.get(ref.id); - if (typeof friend === 'undefined') { - return; - } - if (props.location) { - // update instancePlayerCount - previousLocation = props.location[1]; - newLocation = props.location[0]; - let oldCount = state.instancePlayerCount.get(previousLocation); - if (typeof oldCount !== 'undefined') { - oldCount--; - if (oldCount <= 0) { - state.instancePlayerCount.delete(previousLocation); - } else { - state.instancePlayerCount.set(previousLocation, oldCount); - } - } - let newCount = state.instancePlayerCount.get(newLocation); - if (typeof newCount === 'undefined') { - newCount = 0; - } - newCount++; - state.instancePlayerCount.set(newLocation, newCount); - - const previousLocationL = parseLocation(previousLocation); - const newLocationL = parseLocation(newLocation); - if ( - previousLocationL.tag === userDialog.value.$location.tag || - newLocationL.tag === userDialog.value.$location.tag - ) { - // update user dialog instance occupants - applyUserDialogLocation(true); - } - if ( - previousLocationL.worldId === worldStore.worldDialog.id || - newLocationL.worldId === worldStore.worldDialog.id - ) { - instanceStore.applyWorldDialogInstances(); - } - if ( - previousLocationL.groupId === groupStore.groupDialog.id || - newLocationL.groupId === groupStore.groupDialog.id - ) { - instanceStore.applyGroupDialogInstances(); - } - } - if ( - !props.state && - props.location && - props.location[0] !== 'offline' && - props.location[0] !== '' && - props.location[1] !== 'offline' && - props.location[1] !== '' && - props.location[0] !== 'traveling' - ) { - // skip GPS if user is offline or traveling - previousLocation = props.location[1]; - newLocation = props.location[0]; - let time = props.location[2]; - if (previousLocation === 'traveling' && ref.$previousLocation) { - previousLocation = ref.$previousLocation; - const travelTime = Date.now() - ref.$travelingToTime; - time -= travelTime; - if (time < 0) { - time = 0; - } - } - if (AppDebug.debugFriendState && previousLocation) { - console.log( - `${ref.displayName} GPS ${previousLocation} -> ${newLocation}` - ); - } - if (previousLocation === 'offline') { - previousLocation = ''; - } - if (!previousLocation) { - // no previous location - if (AppDebug.debugFriendState) { - console.log( - ref.displayName, - 'Ignoring GPS, no previous location', - newLocation - ); - } - } else if (ref.$previousLocation === newLocation) { - // location traveled to is the same - ref.$location_at = Date.now() - time; - } else { - const worldName = await getWorldName(newLocation); - const groupName = await getGroupName(newLocation); - feed = { - created_at: new Date().toJSON(), - type: 'GPS', - userId: ref.id, - displayName: ref.displayName, - location: newLocation, - worldName, - groupName, - previousLocation, - time - }; - feedStore.addFeed(feed); - database.addGPSToDatabase(feed); - // clear previousLocation after GPS - ref.$previousLocation = ''; - ref.$travelingToTime = Date.now(); - } - } - if ( - props.location && - props.location[0] === 'traveling' && - props.location[1] !== 'traveling' - ) { - // store previous location when user is traveling - ref.$previousLocation = props.location[1]; - ref.$travelingToTime = Date.now(); - } - let imageMatches = false; - if ( - props.currentAvatarThumbnailImageUrl && - props.currentAvatarThumbnailImageUrl[0] && - props.currentAvatarThumbnailImageUrl[1] && - props.currentAvatarThumbnailImageUrl[0] === - props.currentAvatarThumbnailImageUrl[1] - ) { - imageMatches = true; - } - if ( - (((props.currentAvatarImageUrl || - props.currentAvatarThumbnailImageUrl) && - !ref.profilePicOverride) || - props.currentAvatarTags) && - !imageMatches - ) { - let currentAvatarImageUrl = ''; - let previousCurrentAvatarImageUrl = ''; - let currentAvatarThumbnailImageUrl = ''; - let previousCurrentAvatarThumbnailImageUrl = ''; - let currentAvatarTags = ''; - let previousCurrentAvatarTags = ''; - if (props.currentAvatarImageUrl) { - currentAvatarImageUrl = props.currentAvatarImageUrl[0]; - previousCurrentAvatarImageUrl = props.currentAvatarImageUrl[1]; - } else { - currentAvatarImageUrl = ref.currentAvatarImageUrl; - previousCurrentAvatarImageUrl = ref.currentAvatarImageUrl; - } - if (props.currentAvatarThumbnailImageUrl) { - currentAvatarThumbnailImageUrl = - props.currentAvatarThumbnailImageUrl[0]; - previousCurrentAvatarThumbnailImageUrl = - props.currentAvatarThumbnailImageUrl[1]; - } else { - currentAvatarThumbnailImageUrl = - ref.currentAvatarThumbnailImageUrl; - previousCurrentAvatarThumbnailImageUrl = - ref.currentAvatarThumbnailImageUrl; - } - if (props.currentAvatarTags) { - currentAvatarTags = props.currentAvatarTags[0]; - previousCurrentAvatarTags = props.currentAvatarTags[1]; - if ( - ref.profilePicOverride && - !props.currentAvatarThumbnailImageUrl - ) { - // forget last seen avatar - ref.currentAvatarImageUrl = ''; - ref.currentAvatarThumbnailImageUrl = ''; - } - } else { - currentAvatarTags = ref.currentAvatarTags; - previousCurrentAvatarTags = ref.currentAvatarTags; - } - if ( - generalSettingsStore.logEmptyAvatars || - ref.currentAvatarImageUrl - ) { - let avatarInfo = { - ownerId: '', - avatarName: '' - }; - try { - avatarInfo = await avatarStore.getAvatarName( - currentAvatarImageUrl - ); - } catch (err) { - console.log(err); - } - let previousAvatarInfo = { - ownerId: '', - avatarName: '' - }; - try { - previousAvatarInfo = await avatarStore.getAvatarName( - previousCurrentAvatarImageUrl - ); - } catch (err) { - console.log(err); - } - feed = { - created_at: new Date().toJSON(), - type: 'Avatar', - userId: ref.id, - displayName: ref.displayName, - ownerId: avatarInfo.ownerId, - previousOwnerId: previousAvatarInfo.ownerId, - avatarName: avatarInfo.avatarName, - previousAvatarName: previousAvatarInfo.avatarName, - currentAvatarImageUrl, - currentAvatarThumbnailImageUrl, - previousCurrentAvatarImageUrl, - previousCurrentAvatarThumbnailImageUrl, - currentAvatarTags, - previousCurrentAvatarTags - }; - feedStore.addFeed(feed); - database.addAvatarToDatabase(feed); - } - } - // if status is offline, ignore status and statusDescription - if ( - (props.status && - props.status[0] !== 'offline' && - props.status[1] !== 'offline') || - (!props.status && props.statusDescription) - ) { - let status = ''; - let previousStatus = ''; - let statusDescription = ''; - let previousStatusDescription = ''; - if (props.status) { - if (props.status[0]) { - status = props.status[0]; - } - if (props.status[1]) { - previousStatus = props.status[1]; - } - } else if (ref.status) { - status = ref.status; - previousStatus = ref.status; - } - if (props.statusDescription) { - if (props.statusDescription[0]) { - statusDescription = props.statusDescription[0]; - } - if (props.statusDescription[1]) { - previousStatusDescription = props.statusDescription[1]; - } - } else if (ref.statusDescription) { - statusDescription = ref.statusDescription; - previousStatusDescription = ref.statusDescription; - } - feed = { - created_at: new Date().toJSON(), - type: 'Status', - userId: ref.id, - displayName: ref.displayName, - status, - statusDescription, - previousStatus, - previousStatusDescription - }; - feedStore.addFeed(feed); - database.addStatusToDatabase(feed); - } - if (props.bio && props.bio[0] && props.bio[1]) { - let bio = ''; - let previousBio = ''; - if (props.bio[0]) { - bio = props.bio[0]; - } - if (props.bio[1]) { - previousBio = props.bio[1]; - } - feed = { - created_at: new Date().toJSON(), - type: 'Bio', - userId: ref.id, - displayName: ref.displayName, - bio, - previousBio - }; - feedStore.addFeed(feed); - database.addBioToDatabase(feed); - } - if ( - props.note && - props.note[0] !== null && - props.note[0] !== props.note[1] - ) { - checkNote(ref.id, props.note[0]); - } + await userEventCoordinator.runHandleUserUpdateFlow(ref, props); } /** @@ -1718,14 +1426,12 @@ export const useUserStore = defineStore('User', () => { function applyCurrentUser(json) { authStore.setAttemptingAutoLogin(false); let ref = currentUser.value; + userSessionCoordinator.runAvatarSwapFlow({ + json, + ref, + isLoggedIn: watchState.isLoggedIn + }); if (watchState.isLoggedIn) { - if (json.currentAvatar !== ref.currentAvatar) { - avatarStore.addAvatarToHistory(json.currentAvatar); - if (gameStore.isGameRunning) { - avatarStore.addAvatarWearTime(ref.currentAvatar); - ref.$previousAvatarSwapTime = Date.now(); - } - } for (const prop in json) { if (typeof json[prop] !== 'undefined') { ref[prop] = json[prop]; @@ -1844,33 +1550,15 @@ export const useUserStore = defineStore('User', () => { $travelingToLocation: '', ...json }; - if (gameStore.isGameRunning) { - ref.$previousAvatarSwapTime = Date.now(); - } - cachedUsers.clear(); // clear before running applyUser - currentUser.value = ref; - authStore.loginComplete(); + userSessionCoordinator.runFirstLoginFlow(ref); } ref.$isVRCPlus = ref.tags.includes('system_supporter'); appearanceSettingsStore.applyUserTrustLevel(ref); applyUserLanguage(ref); applyPresenceLocation(ref); - groupStore.applyPresenceGroups(ref); - instanceStore.applyQueuedInstance(ref.queuedInstance); - friendStore.updateUserCurrentStatus(ref); - friendStore.updateFriendships(ref); - if (ref.homeLocation !== ref.$homeLocation?.tag) { - ref.$homeLocation = parseLocation(ref.homeLocation); - // apply home location name to user dialog - if (userDialog.value.visible && userDialog.value.id === ref.id) { - getWorldName(currentUser.value.homeLocation).then( - (worldName) => { - userDialog.value.$homeLocationName = worldName; - } - ); - } - } + userSessionCoordinator.runPostApplySyncFlow(ref); + userSessionCoordinator.runHomeLocationSyncFlow(ref); // when isGameRunning use gameLog instead of API const $location = parseLocation(locationStore.lastLocation.location); @@ -2024,12 +1712,18 @@ export const useUserStore = defineStore('User', () => { currentUser.value.$travelingToTime = value; } + /** + * + */ function markCurrentUserGameStarted() { currentUser.value.$online_for = Date.now(); currentUser.value.$offline_for = ''; currentUser.value.$previousAvatarSwapTime = Date.now(); } + /** + * + */ function markCurrentUserGameStopped() { currentUser.value.$online_for = 0; currentUser.value.$offline_for = Date.now(); @@ -2055,6 +1749,42 @@ export const useUserStore = defineStore('User', () => { }); } + const userSessionCoordinator = createUserSessionCoordinator({ + avatarStore, + gameStore, + groupStore, + instanceStore, + friendStore, + authStore, + cachedUsers, + currentUser, + userDialog, + getWorldName, + parseLocation, + now: () => Date.now() + }); + + const userEventCoordinator = createUserEventCoordinator({ + friendStore, + state, + parseLocation, + userDialog, + applyUserDialogLocation, + worldStore, + groupStore, + instanceStore, + appDebug: AppDebug, + getWorldName, + getGroupName, + feedStore, + database, + avatarStore, + generalSettingsStore, + checkNote, + now: () => Date.now(), + nowIso: () => new Date().toJSON() + }); + return { state,