refactor store

This commit is contained in:
pa
2026-03-10 17:19:03 +09:00
parent 95c4a1d3e6
commit 1cbad7fb60
39 changed files with 1290 additions and 2366 deletions
@@ -1,139 +0,0 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { createAuthAutoLoginCoordinator } from '../authAutoLoginCoordinator';
/**
*
* @returns {Promise<void>} 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);
});
});
@@ -1,96 +0,0 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { createAuthCoordinator } from '../authCoordinator';
/**
*
* @returns {object} Mock dependencies for auth coordinator.
*/
function makeDeps() {
return {
userStore: {
currentUser: { id: 'usr_1' },
setUserDialogVisible: vi.fn(),
applyCurrentUser: vi.fn()
},
notificationStore: {
setNotificationInitStatus: vi.fn()
},
updateLoopStore: {
setNextCurrentUserRefresh: vi.fn()
},
initWebsocket: vi.fn(),
updateStoredUser: vi.fn().mockResolvedValue(undefined),
webApiService: {
clearCookies: vi.fn().mockResolvedValue(undefined)
},
loginForm: {
value: {
lastUserLoggedIn: 'usr_1'
}
},
configRepository: {
remove: vi.fn().mockResolvedValue(undefined)
},
setAttemptingAutoLogin: vi.fn(),
autoLoginAttempts: {
clear: vi.fn()
},
closeWebSocket: vi.fn(),
queryClient: {
clear: vi.fn()
},
watchState: {
isLoggedIn: true,
isFriendsLoaded: true,
isFavoritesLoaded: true
}
};
}
describe('createAuthCoordinator', () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('runLogoutFlow applies all logout side effects', async () => {
const deps = makeDeps();
const coordinator = createAuthCoordinator(deps);
await coordinator.runLogoutFlow();
expect(deps.userStore.setUserDialogVisible).toHaveBeenCalledWith(false);
expect(deps.watchState.isLoggedIn).toBe(false);
expect(deps.watchState.isFriendsLoaded).toBe(false);
expect(deps.watchState.isFavoritesLoaded).toBe(false);
expect(
deps.notificationStore.setNotificationInitStatus
).toHaveBeenCalledWith(false);
expect(deps.updateStoredUser).toHaveBeenCalledWith(
deps.userStore.currentUser
);
expect(deps.webApiService.clearCookies).toHaveBeenCalledTimes(1);
expect(deps.loginForm.value.lastUserLoggedIn).toBe('');
expect(deps.configRepository.remove).toHaveBeenCalledWith(
'lastUserLoggedIn'
);
expect(deps.setAttemptingAutoLogin).toHaveBeenCalledWith(false);
expect(deps.autoLoginAttempts.clear).toHaveBeenCalledTimes(1);
expect(deps.closeWebSocket).toHaveBeenCalledTimes(1);
expect(deps.queryClient.clear).toHaveBeenCalledTimes(1);
});
test('runLoginSuccessFlow applies login success side effects', () => {
const deps = makeDeps();
const coordinator = createAuthCoordinator(deps);
const json = { id: 'usr_2' };
coordinator.runLoginSuccessFlow(json);
expect(
deps.updateLoopStore.setNextCurrentUserRefresh
).toHaveBeenCalledWith(420);
expect(deps.userStore.applyCurrentUser).toHaveBeenCalledWith(json);
expect(deps.initWebsocket).toHaveBeenCalledTimes(1);
});
});
@@ -1,159 +0,0 @@
import { describe, expect, test, vi } from 'vitest';
import { createFriendPresenceCoordinator } from '../friendPresenceCoordinator';
/**
* @returns {object} Mock dependencies and mutable state for friend presence tests.
*/
function makeDeps() {
const friends = new Map();
const cachedUsers = new Map();
const pendingOfflineMap = new Map();
const ref = {
id: 'usr_1',
state: 'online',
displayName: 'User 1',
location: 'wrld_1:1',
$location_at: 100,
$lastFetch: 0,
$online_for: 1,
$offline_for: '',
$active_for: ''
};
const ctx = {
id: 'usr_1',
state: 'online',
ref,
name: 'User 1',
isVIP: false,
pendingOffline: false
};
friends.set('usr_1', ctx);
cachedUsers.set('usr_1', ref);
return {
deps: {
friends,
localFavoriteFriends: new Set(),
pendingOfflineMap,
pendingOfflineDelay: 100,
watchState: { isFriendsLoaded: true },
appDebug: { debugFriendState: false },
getCachedUsers: vi.fn(() => cachedUsers),
isRealInstance: vi.fn(() => false),
requestUser: vi.fn(),
getWorldName: vi.fn().mockResolvedValue('World 1'),
getGroupName: vi.fn().mockResolvedValue('Group 1'),
feedStore: {
addFeed: vi.fn()
},
database: {
addOnlineOfflineToDatabase: vi.fn()
},
updateOnlineFriendCounter: vi.fn(),
now: vi.fn(() => 1000),
nowIso: vi.fn(() => '2025-01-01T00:00:00.000Z')
},
ctx,
ref,
pendingOfflineMap
};
}
describe('createFriendPresenceCoordinator', () => {
test('queues pending offline transition when friend moves from online to offline', async () => {
const { deps, ctx, pendingOfflineMap } = makeDeps();
const coordinator = createFriendPresenceCoordinator(deps);
await coordinator.runUpdateFriendFlow('usr_1', 'offline');
expect(ctx.pendingOffline).toBe(true);
expect(pendingOfflineMap.has('usr_1')).toBe(true);
expect(deps.updateOnlineFriendCounter).not.toHaveBeenCalled();
});
test('processes pending offline queue and applies delayed transition', async () => {
const { deps, ctx, pendingOfflineMap, ref } = makeDeps();
pendingOfflineMap.set('usr_1', {
startTime: 0,
newState: 'offline',
previousLocation: 'wrld_1:1',
previousLocationAt: 500
});
const coordinator = createFriendPresenceCoordinator(deps);
await coordinator.runPendingOfflineTickFlow();
expect(ctx.state).toBe('offline');
expect(ctx.pendingOffline).toBe(false);
expect(pendingOfflineMap.has('usr_1')).toBe(false);
expect(ref.$offline_for).toBe(1000);
expect(deps.feedStore.addFeed).toHaveBeenCalledTimes(1);
expect(deps.feedStore.addFeed).toHaveBeenCalledWith(
expect.objectContaining({
type: 'Offline',
userId: 'usr_1',
location: 'wrld_1:1',
worldName: 'World 1',
groupName: 'Group 1',
time: 500
})
);
expect(deps.database.addOnlineOfflineToDatabase).toHaveBeenCalledTimes(
1
);
expect(deps.updateOnlineFriendCounter).toHaveBeenCalledTimes(1);
});
test('cancels pending offline transition when online state arrives again', async () => {
const { deps, ctx, pendingOfflineMap } = makeDeps();
pendingOfflineMap.set('usr_1', {
startTime: 900,
newState: 'offline',
previousLocation: 'wrld_1:1',
previousLocationAt: 800
});
const coordinator = createFriendPresenceCoordinator(deps);
await coordinator.runUpdateFriendFlow('usr_1', 'online');
expect(ctx.pendingOffline).toBe(false);
expect(pendingOfflineMap.has('usr_1')).toBe(false);
});
test('applies offline to online transition contract immediately', async () => {
const { deps, ctx, ref } = makeDeps();
ctx.state = 'offline';
const coordinator = createFriendPresenceCoordinator(deps);
await coordinator.runUpdateFriendFlow('usr_1', 'online');
expect(ctx.state).toBe('online');
expect(ref.$online_for).toBe(1000);
expect(ref.$offline_for).toBe('');
expect(deps.feedStore.addFeed).toHaveBeenCalledWith(
expect.objectContaining({
type: 'Online',
userId: 'usr_1',
location: 'wrld_1:1',
worldName: 'World 1',
groupName: 'Group 1'
})
);
expect(deps.database.addOnlineOfflineToDatabase).toHaveBeenCalledTimes(
1
);
expect(deps.updateOnlineFriendCounter).toHaveBeenCalledTimes(1);
});
test('returns safely when friend context does not exist', async () => {
const { deps } = makeDeps();
deps.friends.clear();
const coordinator = createFriendPresenceCoordinator(deps);
await coordinator.runUpdateFriendFlow('usr_404', 'online');
expect(deps.feedStore.addFeed).not.toHaveBeenCalled();
expect(deps.updateOnlineFriendCounter).not.toHaveBeenCalled();
});
});
@@ -1,158 +0,0 @@
import { describe, expect, test, vi } from 'vitest';
import { createFriendRelationshipCoordinator } from '../friendRelationshipCoordinator';
/**
* @returns {Promise<void>} 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'
);
});
});
@@ -1,133 +0,0 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { createFriendSyncCoordinator } from '../friendSyncCoordinator';
/**
*
* @returns {object} Mock dependencies for friend sync coordinator.
*/
function makeDeps() {
return {
getNextCurrentUserRefresh: vi.fn(() => 999),
getCurrentUser: vi.fn().mockResolvedValue(undefined),
refreshFriends: vi.fn().mockResolvedValue(undefined),
reconnectWebSocket: vi.fn(),
getCurrentUserId: vi.fn(() => 'usr_1'),
getCurrentUserRef: vi.fn(() => ({ id: 'usr_1' })),
setRefreshFriendsLoading: vi.fn(),
setFriendsLoaded: vi.fn(),
resetFriendLog: vi.fn(),
isFriendLogInitialized: vi.fn().mockResolvedValue(true),
getFriendLog: vi.fn().mockResolvedValue(undefined),
initFriendLog: vi.fn().mockResolvedValue(undefined),
isDontLogMeOut: vi.fn(() => false),
showLoadFailedToast: vi.fn(),
handleLogoutEvent: vi.fn(),
tryApplyFriendOrder: vi.fn(),
getAllUserStats: vi.fn(),
hasLegacyFriendLogData: vi.fn().mockResolvedValue(false),
removeLegacyFeedTable: vi.fn(),
migrateMemos: vi.fn(),
migrateFriendLog: vi.fn()
};
}
describe('createFriendSyncCoordinator', () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('runRefreshFriendsListFlow refreshes current user before friends when refresh window is small', async () => {
const deps = makeDeps();
deps.getNextCurrentUserRefresh.mockReturnValue(100);
const coordinator = createFriendSyncCoordinator(deps);
await coordinator.runRefreshFriendsListFlow();
expect(deps.getCurrentUser).toHaveBeenCalledTimes(1);
expect(deps.refreshFriends).toHaveBeenCalledTimes(1);
expect(deps.reconnectWebSocket).toHaveBeenCalledTimes(1);
});
test('runRefreshFriendsListFlow skips current user refresh when window is large', async () => {
const deps = makeDeps();
deps.getNextCurrentUserRefresh.mockReturnValue(300);
const coordinator = createFriendSyncCoordinator(deps);
await coordinator.runRefreshFriendsListFlow();
expect(deps.getCurrentUser).not.toHaveBeenCalled();
expect(deps.refreshFriends).toHaveBeenCalledTimes(1);
expect(deps.reconnectWebSocket).toHaveBeenCalledTimes(1);
});
test('runInitFriendsListFlow loads existing friend log when initialized', async () => {
const deps = makeDeps();
deps.isFriendLogInitialized.mockResolvedValue(true);
deps.hasLegacyFriendLogData.mockResolvedValue(false);
const coordinator = createFriendSyncCoordinator(deps);
await coordinator.runInitFriendsListFlow();
expect(deps.setRefreshFriendsLoading).toHaveBeenCalledWith(true);
expect(deps.setFriendsLoaded).toHaveBeenCalledWith(false);
expect(deps.resetFriendLog).toHaveBeenCalledTimes(1);
expect(deps.isFriendLogInitialized).toHaveBeenCalledWith('usr_1');
expect(deps.getFriendLog).toHaveBeenCalledWith({ id: 'usr_1' });
expect(deps.initFriendLog).not.toHaveBeenCalled();
expect(deps.tryApplyFriendOrder).toHaveBeenCalledTimes(1);
expect(deps.getAllUserStats).toHaveBeenCalledTimes(1);
});
test('runInitFriendsListFlow initializes new friend log when not initialized', async () => {
const deps = makeDeps();
deps.isFriendLogInitialized.mockResolvedValue(false);
const coordinator = createFriendSyncCoordinator(deps);
await coordinator.runInitFriendsListFlow();
expect(deps.getFriendLog).not.toHaveBeenCalled();
expect(deps.initFriendLog).toHaveBeenCalledWith({ id: 'usr_1' });
});
test('runInitFriendsListFlow performs legacy migration when old data exists', async () => {
const deps = makeDeps();
deps.hasLegacyFriendLogData.mockResolvedValue(true);
const coordinator = createFriendSyncCoordinator(deps);
await coordinator.runInitFriendsListFlow();
expect(deps.removeLegacyFeedTable).toHaveBeenCalledWith('usr_1');
expect(deps.migrateMemos).toHaveBeenCalledTimes(1);
expect(deps.migrateFriendLog).toHaveBeenCalledWith('usr_1');
});
test('runInitFriendsListFlow logs out and rethrows when load fails and dontLogMeOut is false', async () => {
const deps = makeDeps();
const err = new Error('load failed');
deps.getFriendLog.mockRejectedValue(err);
deps.isDontLogMeOut.mockReturnValue(false);
const coordinator = createFriendSyncCoordinator(deps);
await expect(coordinator.runInitFriendsListFlow()).rejects.toThrow(
'load failed'
);
expect(deps.showLoadFailedToast).toHaveBeenCalledTimes(1);
expect(deps.handleLogoutEvent).toHaveBeenCalledTimes(1);
expect(deps.tryApplyFriendOrder).not.toHaveBeenCalled();
});
test('runInitFriendsListFlow continues when load fails and dontLogMeOut is true', async () => {
const deps = makeDeps();
deps.getFriendLog.mockRejectedValue(new Error('load failed'));
deps.isDontLogMeOut.mockReturnValue(true);
const coordinator = createFriendSyncCoordinator(deps);
await coordinator.runInitFriendsListFlow();
expect(deps.showLoadFailedToast).not.toHaveBeenCalled();
expect(deps.handleLogoutEvent).not.toHaveBeenCalled();
expect(deps.tryApplyFriendOrder).toHaveBeenCalledTimes(1);
expect(deps.getAllUserStats).toHaveBeenCalledTimes(1);
});
});
@@ -1,125 +0,0 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { createGameCoordinator } from '../gameCoordinator';
/**
*
* @returns {object} Mock dependencies for game coordinator.
*/
function makeDeps() {
return {
userStore: {
currentUser: {
currentAvatar: 'avtr_1'
},
markCurrentUserGameStarted: vi.fn(),
markCurrentUserGameStopped: vi.fn()
},
instanceStore: {
removeAllQueuedInstances: vi.fn()
},
updateLoopStore: {
setIpcTimeout: vi.fn(),
setNextDiscordUpdate: vi.fn()
},
locationStore: {
lastLocationReset: vi.fn()
},
gameLogStore: {
clearNowPlaying: vi.fn()
},
vrStore: {
updateVRLastLocation: vi.fn()
},
avatarStore: {
addAvatarWearTime: vi.fn()
},
configRepository: {
setBool: vi.fn().mockResolvedValue(undefined)
},
workerTimers: {
setTimeout: vi.fn()
},
checkVRChatDebugLogging: vi.fn(),
autoVRChatCacheManagement: vi.fn(),
checkIfGameCrashed: vi.fn(),
getIsGameNoVR: vi.fn(() => true)
};
}
describe('createGameCoordinator', () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('runGameRunningChangedFlow(true) runs start + shared side effects', async () => {
const deps = makeDeps();
const coordinator = createGameCoordinator(deps);
await coordinator.runGameRunningChangedFlow(true);
expect(deps.userStore.markCurrentUserGameStarted).toHaveBeenCalledTimes(
1
);
expect(deps.configRepository.setBool).not.toHaveBeenCalled();
expect(
deps.userStore.markCurrentUserGameStopped
).not.toHaveBeenCalled();
expect(
deps.instanceStore.removeAllQueuedInstances
).not.toHaveBeenCalled();
expect(deps.autoVRChatCacheManagement).not.toHaveBeenCalled();
expect(deps.checkIfGameCrashed).not.toHaveBeenCalled();
expect(deps.updateLoopStore.setIpcTimeout).not.toHaveBeenCalled();
expect(deps.avatarStore.addAvatarWearTime).not.toHaveBeenCalled();
expect(deps.locationStore.lastLocationReset).toHaveBeenCalledTimes(1);
expect(deps.gameLogStore.clearNowPlaying).toHaveBeenCalledTimes(1);
expect(deps.vrStore.updateVRLastLocation).toHaveBeenCalledTimes(1);
expect(deps.updateLoopStore.setNextDiscordUpdate).toHaveBeenCalledWith(
0
);
expect(deps.workerTimers.setTimeout).toHaveBeenCalledTimes(1);
expect(deps.workerTimers.setTimeout.mock.calls[0][1]).toBe(60000);
const timeoutCb = deps.workerTimers.setTimeout.mock.calls[0][0];
timeoutCb();
expect(deps.checkVRChatDebugLogging).toHaveBeenCalledTimes(1);
});
test('runGameRunningChangedFlow(false) runs stop + shared side effects', async () => {
const deps = makeDeps();
deps.getIsGameNoVR.mockReturnValue(false);
const coordinator = createGameCoordinator(deps);
await coordinator.runGameRunningChangedFlow(false);
expect(deps.getIsGameNoVR).toHaveBeenCalledTimes(1);
expect(deps.configRepository.setBool).toHaveBeenCalledWith(
'isGameNoVR',
false
);
expect(deps.userStore.markCurrentUserGameStopped).toHaveBeenCalledTimes(
1
);
expect(
deps.instanceStore.removeAllQueuedInstances
).toHaveBeenCalledTimes(1);
expect(deps.autoVRChatCacheManagement).toHaveBeenCalledTimes(1);
expect(deps.checkIfGameCrashed).toHaveBeenCalledTimes(1);
expect(deps.updateLoopStore.setIpcTimeout).toHaveBeenCalledWith(0);
expect(deps.avatarStore.addAvatarWearTime).toHaveBeenCalledWith(
'avtr_1'
);
expect(deps.locationStore.lastLocationReset).toHaveBeenCalledTimes(1);
expect(deps.gameLogStore.clearNowPlaying).toHaveBeenCalledTimes(1);
expect(deps.vrStore.updateVRLastLocation).toHaveBeenCalledTimes(1);
expect(deps.updateLoopStore.setNextDiscordUpdate).toHaveBeenCalledWith(
0
);
expect(deps.workerTimers.setTimeout).toHaveBeenCalledTimes(1);
expect(deps.workerTimers.setTimeout.mock.calls[0][1]).toBe(60000);
});
});
@@ -1,251 +0,0 @@
import { describe, expect, test, vi } from 'vitest';
import { createUserEventCoordinator } from '../userEventCoordinator';
/**
* @returns {object} Mock dependencies for user event tests.
*/
function makeDeps() {
const friendRef = {
id: 'usr_1'
};
return {
friendStore: {
friends: new Map([['usr_1', friendRef]])
},
state: {
instancePlayerCount: new Map()
},
parseLocation: vi.fn((location) => {
if (location === 'loc_old') {
return {
tag: 'loc_old',
worldId: 'world_old',
groupId: 'group_old'
};
}
if (location === 'loc_new') {
return {
tag: 'loc_new',
worldId: 'world_new',
groupId: 'group_new'
};
}
return {
tag: location,
worldId: '',
groupId: ''
};
}),
userDialog: {
value: {
$location: {
tag: 'loc_new'
}
}
},
applyUserDialogLocation: vi.fn(),
worldStore: {
worldDialog: {
id: 'world_old'
}
},
groupStore: {
groupDialog: {
id: 'group_new'
}
},
instanceStore: {
applyWorldDialogInstances: vi.fn(),
applyGroupDialogInstances: vi.fn()
},
appDebug: {
debugFriendState: false
},
getWorldName: vi.fn().mockResolvedValue('World'),
getGroupName: vi.fn().mockResolvedValue('Group'),
feedStore: {
addFeed: vi.fn()
},
database: {
addGPSToDatabase: vi.fn(),
addAvatarToDatabase: vi.fn(),
addStatusToDatabase: vi.fn(),
addBioToDatabase: vi.fn()
},
avatarStore: {
getAvatarName: vi.fn().mockResolvedValue({
ownerId: 'usr_owner',
avatarName: 'Avatar'
})
},
generalSettingsStore: {
logEmptyAvatars: false
},
checkNote: vi.fn(),
now: vi.fn(() => 1000),
nowIso: vi.fn(() => '2025-01-01T00:00:00.000Z')
};
}
describe('createUserEventCoordinator', () => {
test('returns early when target user is not in friend map', async () => {
const deps = makeDeps();
deps.friendStore.friends.clear();
const coordinator = createUserEventCoordinator(deps);
await coordinator.runHandleUserUpdateFlow(
{
id: 'usr_404',
displayName: 'Unknown'
},
{
status: ['online', 'offline']
}
);
expect(deps.feedStore.addFeed).not.toHaveBeenCalled();
expect(deps.database.addStatusToDatabase).not.toHaveBeenCalled();
});
test('updates location counters and dialog instance hooks on location change', async () => {
const deps = makeDeps();
deps.state.instancePlayerCount.set('loc_old', 2);
const coordinator = createUserEventCoordinator(deps);
const ref = {
id: 'usr_1',
displayName: 'User 1'
};
await coordinator.runHandleUserUpdateFlow(ref, {
location: ['loc_new', 'loc_old', 50],
state: ['online', 'online']
});
expect(deps.state.instancePlayerCount.get('loc_old')).toBe(1);
expect(deps.state.instancePlayerCount.get('loc_new')).toBe(1);
expect(deps.applyUserDialogLocation).toHaveBeenCalledWith(true);
expect(deps.instanceStore.applyWorldDialogInstances).toHaveBeenCalled();
expect(deps.instanceStore.applyGroupDialogInstances).toHaveBeenCalled();
});
test('writes GPS feed with adjusted traveling time contract', async () => {
const deps = makeDeps();
const coordinator = createUserEventCoordinator(deps);
const ref = {
id: 'usr_1',
displayName: 'User 1',
$previousLocation: 'loc_old',
$travelingToTime: 900,
$location_at: 700
};
await coordinator.runHandleUserUpdateFlow(ref, {
location: ['loc_new', 'traveling', 300]
});
expect(deps.feedStore.addFeed).toHaveBeenCalledWith(
expect.objectContaining({
type: 'GPS',
userId: 'usr_1',
previousLocation: 'loc_old',
location: 'loc_new',
worldName: 'World',
groupName: 'Group',
time: 200
})
);
expect(deps.database.addGPSToDatabase).toHaveBeenCalledTimes(1);
expect(ref.$previousLocation).toBe('');
expect(ref.$travelingToTime).toBe(1000);
});
test('stores previous location while user becomes traveling', async () => {
const deps = makeDeps();
const coordinator = createUserEventCoordinator(deps);
const ref = {
id: 'usr_1',
displayName: 'User 1',
$previousLocation: '',
$travelingToTime: 0
};
await coordinator.runHandleUserUpdateFlow(ref, {
location: ['traveling', 'loc_old']
});
expect(ref.$previousLocation).toBe('loc_old');
expect(ref.$travelingToTime).toBe(1000);
});
test('writes status and bio feeds and triggers note check', async () => {
const deps = makeDeps();
const coordinator = createUserEventCoordinator(deps);
const ref = {
id: 'usr_1',
displayName: 'User 1',
status: 'busy',
statusDescription: 'old',
currentAvatarImageUrl: '',
currentAvatarThumbnailImageUrl: '',
currentAvatarTags: [],
profilePicOverride: ''
};
await coordinator.runHandleUserUpdateFlow(ref, {
status: ['join me', 'busy'],
statusDescription: ['new desc', 'old desc'],
bio: ['new bio', 'old bio'],
note: ['new note', 'old note']
});
expect(deps.feedStore.addFeed).toHaveBeenCalledTimes(2);
expect(deps.database.addStatusToDatabase).toHaveBeenCalledTimes(1);
expect(deps.database.addBioToDatabase).toHaveBeenCalledTimes(1);
expect(deps.checkNote).toHaveBeenCalledWith('usr_1', 'new note');
});
test('writes avatar change feed contract', async () => {
const deps = makeDeps();
deps.generalSettingsStore.logEmptyAvatars = true;
deps.avatarStore.getAvatarName
.mockResolvedValueOnce({
ownerId: 'usr_owner_new',
avatarName: 'Avatar New'
})
.mockResolvedValueOnce({
ownerId: 'usr_owner_old',
avatarName: 'Avatar Old'
});
const coordinator = createUserEventCoordinator(deps);
const ref = {
id: 'usr_1',
displayName: 'User 1',
currentAvatarImageUrl: 'img_old',
currentAvatarThumbnailImageUrl: 'thumb_old',
currentAvatarTags: ['tag_old'],
profilePicOverride: ''
};
await coordinator.runHandleUserUpdateFlow(ref, {
currentAvatarImageUrl: ['img_new', 'img_old'],
currentAvatarThumbnailImageUrl: ['thumb_new', 'thumb_old'],
currentAvatarTags: [['tag_new'], ['tag_old']]
});
expect(deps.database.addAvatarToDatabase).toHaveBeenCalledTimes(1);
expect(deps.feedStore.addFeed).toHaveBeenCalledWith(
expect.objectContaining({
type: 'Avatar',
userId: 'usr_1',
ownerId: 'usr_owner_new',
previousOwnerId: 'usr_owner_old',
avatarName: 'Avatar New',
previousAvatarName: 'Avatar Old',
currentAvatarImageUrl: 'img_new',
previousCurrentAvatarImageUrl: 'img_old'
})
);
});
});
@@ -1,162 +0,0 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { createUserSessionCoordinator } from '../userSessionCoordinator';
/**
*
* @returns {Promise<void>} 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');
});
});
+24 -27
View File
@@ -52,31 +52,28 @@ export async function runHandleAutoLoginFlow({
}
autoLoginAttempts.add(currentTimestamp);
console.log('Attempting automatic login...');
authStore
.relogin(user)
.then(() => {
if (AppDebug.errorNoty) {
toast.dismiss(AppDebug.errorNoty);
}
AppDebug.errorNoty = toast.success(
t('message.auth.auto_login_success')
);
console.log('Automatically logged in.');
})
.catch((err) => {
if (AppDebug.errorNoty) {
toast.dismiss(AppDebug.errorNoty);
}
AppDebug.errorNoty = toast.error(
t('message.auth.auto_login_failed')
);
console.error('Failed to login automatically.', err);
})
.finally(() => {
authStore.setAttemptingAutoLogin(false);
if (!isOnline()) {
AppDebug.errorNoty = toast.error(t('message.auth.offline'));
console.error(`You're offline.`);
}
});
try {
await authStore.relogin(user);
if (AppDebug.errorNoty) {
toast.dismiss(AppDebug.errorNoty);
}
AppDebug.errorNoty = toast.success(
t('message.auth.auto_login_success')
);
console.log('Automatically logged in.');
} catch (err) {
if (AppDebug.errorNoty) {
toast.dismiss(AppDebug.errorNoty);
}
AppDebug.errorNoty = toast.error(
t('message.auth.auto_login_failed')
);
console.error('Failed to login automatically.', err);
} finally {
authStore.setAttemptingAutoLogin(false);
if (!isOnline()) {
AppDebug.errorNoty = toast.error(t('message.auth.offline'));
console.error(`You're offline.`);
}
}
}
@@ -1,13 +1,331 @@
import { i18n } from '../plugin/i18n';
import { database } from '../service/database';
import { friendRequest } from '../api';
import { friendRequest, userRequest } from '../api';
import { getNameColour } from '../shared/utils';
import { handleFavoriteDelete } from './favoriteCoordinator';
import { useAppearanceSettingsStore } from '../stores/settings/appearance';
import { useFavoriteStore } from '../stores/favorite';
import { useFriendStore } from '../stores/friend';
import { useModalStore } from '../stores/modal';
import { useNotificationStore } from '../stores/notification';
import { useSharedFeedStore } from '../stores/sharedFeed';
import { useUiStore } from '../stores/ui';
import { useUserStore } from '../stores/user';
import { watchState } from '../service/watchState';
import configRepository from '../service/config';
/**
* @param {object} args
*/
export function handleFriendStatus(args) {
const userStore = useUserStore();
const D = userStore.userDialog;
if (D.visible === false || D.id !== args.params.userId) {
return;
}
const { json } = args;
D.isFriend = json.isFriend;
D.incomingRequest = json.incomingRequest;
D.outgoingRequest = json.outgoingRequest;
}
/**
* @param {object} args
*/
export function handleFriendDelete(args) {
const userStore = useUserStore();
const friendStore = useFriendStore();
const D = userStore.userDialog;
if (D.visible === false || D.id !== args.params.userId) {
return;
}
D.isFriend = false;
runDeleteFriendshipFlow(args.params.userId);
friendStore.deleteFriend(args.params.userId);
}
/**
* @param {object} args
*/
export function handleFriendAdd(args) {
const friendStore = useFriendStore();
addFriendship(args.params.userId);
friendStore.addFriend(args.params.userId);
}
/**
* @param {string} userId
* @returns {string}
*/
export function getFriendRequest(userId) {
const notificationStore = useNotificationStore();
const array = notificationStore.notificationTable.data;
for (let i = array.length - 1; i >= 0; i--) {
if (
array[i].type === 'friendRequest' &&
array[i].senderUserId === userId
) {
return array[i].id;
}
}
return '';
}
/**
* @param {string} userId
*/
function deleteFriendRequest(userId) {
const notificationStore = useNotificationStore();
const array = notificationStore.notificationTable.data;
for (let i = array.length - 1; i >= 0; i--) {
if (
array[i].type === 'friendRequest' &&
array[i].senderUserId === userId
) {
array.splice(i, 1);
return;
}
}
}
/**
* @param {string} id
*/
export function addFriendship(id) {
const friendStore = useFriendStore();
const userStore = useUserStore();
const notificationStore = useNotificationStore();
const sharedFeedStore = useSharedFeedStore();
const uiStore = useUiStore();
const { friendLog, friendLogTable, state } = friendStore;
if (
!watchState.isFriendsLoaded ||
friendLog.has(id) ||
id === userStore.currentUser.id
) {
return;
}
const ref = userStore.cachedUsers.get(id);
if (typeof ref === 'undefined') {
// deleted account on friends list
return;
}
friendRequest
.getFriendStatus({
userId: id,
currentUserId: userStore.currentUser.id
})
.then((args) => {
if (args.params.currentUserId !== userStore.currentUser.id) {
// safety check for delayed response
return;
}
handleFriendStatus(args);
if (args.json.isFriend && !friendLog.has(id)) {
if (state.friendNumber === 0) {
state.friendNumber = friendStore.friends.size;
}
ref.$friendNumber = ++state.friendNumber;
configRepository.setInt(
`VRCX_friendNumber_${userStore.currentUser.id}`,
state.friendNumber
);
friendStore.addFriend(id, ref.state);
const friendLogHistory = {
created_at: new Date().toJSON(),
type: 'Friend',
userId: id,
displayName: ref.displayName,
friendNumber: ref.$friendNumber
};
friendLogTable.value.data.push(friendLogHistory);
database.addFriendLogHistory(friendLogHistory);
notificationStore.queueFriendLogNoty(friendLogHistory);
sharedFeedStore.addEntry(friendLogHistory);
const friendLogCurrent = {
userId: id,
displayName: ref.displayName,
trustLevel: ref.$trustLevel,
friendNumber: ref.$friendNumber
};
friendLog.set(id, friendLogCurrent);
database.setFriendLogCurrent(friendLogCurrent);
uiStore.notifyMenu('friend-log');
deleteFriendRequest(id);
userRequest
.getUser({
userId: id
})
.then(() => {
if (
userStore.userDialog.visible &&
id === userStore.userDialog.id
) {
userStore.applyUserDialogLocation(true);
}
});
}
});
}
/**
* @param {object} ref
*/
export function updateFriendship(ref) {
const friendStore = useFriendStore();
const notificationStore = useNotificationStore();
const sharedFeedStore = useSharedFeedStore();
const uiStore = useUiStore();
const { friendLog, friendLogTable } = friendStore;
const ctx = friendLog.get(ref.id);
if (!watchState.isFriendsLoaded || typeof ctx === 'undefined') {
return;
}
if (ctx.friendNumber) {
ref.$friendNumber = ctx.friendNumber;
}
if (!ref.$friendNumber) {
ref.$friendNumber = 0; // no null
}
if (ctx.displayName !== ref.displayName) {
if (ctx.displayName) {
const friendLogHistoryDisplayName = {
created_at: new Date().toJSON(),
type: 'DisplayName',
userId: ref.id,
displayName: ref.displayName,
previousDisplayName: ctx.displayName,
friendNumber: ref.$friendNumber
};
friendLogTable.value.data.push(friendLogHistoryDisplayName);
database.addFriendLogHistory(friendLogHistoryDisplayName);
notificationStore.queueFriendLogNoty(
friendLogHistoryDisplayName
);
sharedFeedStore.addEntry(friendLogHistoryDisplayName);
const friendLogCurrent = {
userId: ref.id,
displayName: ref.displayName,
trustLevel: ref.$trustLevel,
friendNumber: ref.$friendNumber
};
friendLog.set(ref.id, friendLogCurrent);
database.setFriendLogCurrent(friendLogCurrent);
ctx.displayName = ref.displayName;
uiStore.notifyMenu('friend-log');
}
}
if (
ref.$trustLevel &&
ctx.trustLevel &&
ctx.trustLevel !== ref.$trustLevel
) {
if (
(ctx.trustLevel === 'Trusted User' &&
ref.$trustLevel === 'Veteran User') ||
(ctx.trustLevel === 'Veteran User' &&
ref.$trustLevel === 'Trusted User')
) {
const friendLogCurrent3 = {
userId: ref.id,
displayName: ref.displayName,
trustLevel: ref.$trustLevel,
friendNumber: ref.$friendNumber
};
friendLog.set(ref.id, friendLogCurrent3);
database.setFriendLogCurrent(friendLogCurrent3);
return;
}
const friendLogHistoryTrustLevel = {
created_at: new Date().toJSON(),
type: 'TrustLevel',
userId: ref.id,
displayName: ref.displayName,
trustLevel: ref.$trustLevel,
previousTrustLevel: ctx.trustLevel,
friendNumber: ref.$friendNumber
};
friendLogTable.value.data.push(friendLogHistoryTrustLevel);
database.addFriendLogHistory(friendLogHistoryTrustLevel);
notificationStore.queueFriendLogNoty(friendLogHistoryTrustLevel);
sharedFeedStore.addEntry(friendLogHistoryTrustLevel);
const friendLogCurrent2 = {
userId: ref.id,
displayName: ref.displayName,
trustLevel: ref.$trustLevel,
friendNumber: ref.$friendNumber
};
friendLog.set(ref.id, friendLogCurrent2);
database.setFriendLogCurrent(friendLogCurrent2);
uiStore.notifyMenu('friend-log');
}
ctx.trustLevel = ref.$trustLevel;
}
/**
* @param {string} id
*/
export function confirmDeleteFriend(id) {
const t = i18n.global.t;
const modalStore = useModalStore();
modalStore
.confirm({
description: t('confirm.unfriend'),
title: t('confirm.title')
})
.then(async ({ ok }) => {
if (!ok) return;
const args = await friendRequest.deleteFriend({
userId: id
});
handleFriendDelete(args);
})
.catch(() => {});
}
/**
* @param {object} ref
*/
export function userOnFriend(ref) {
const friendStore = useFriendStore();
const userStore = useUserStore();
updateFriendship(ref);
if (
watchState.isFriendsLoaded &&
ref.isFriend &&
!friendStore.friendLog.has(ref.id) &&
ref.id !== userStore.currentUser.id
) {
addFriendship(ref.id);
}
}
/**
* @param {object} ref
*/
export function updateUserCurrentStatus(ref) {
const friendStore = useFriendStore();
const userStore = useUserStore();
const appearanceSettingsStore = useAppearanceSettingsStore();
if (watchState.isFriendsLoaded) {
friendStore.refreshFriendsStatus(ref);
}
friendStore.updateOnlineFriendCounter();
if (appearanceSettingsStore.randomUserColours) {
getNameColour(userStore.currentUser.id).then((colour) => {
userStore.setCurrentUserColour(colour);
});
}
}
/**
* Validates and applies unfriend transition side effects.
@@ -43,7 +361,7 @@ export function runDeleteFriendshipFlow(
// safety check for delayed response
return;
}
friendStore.handleFriendStatus(args);
handleFriendStatus(args);
if (!args.json.isFriend && friendLog.has(id)) {
const friendLogHistory = {
created_at: nowIso(),
@@ -84,7 +402,7 @@ export function runUpdateFriendshipsFlow(
const set = new Set();
for (id of ref.friends) {
set.add(id);
friendStore.addFriendship(id);
addFriendship(id);
}
for (id of friendLog.keys()) {
if (id === userStore.currentUser.id) {
+582
View File
@@ -0,0 +1,582 @@
import dayjs from 'dayjs';
import {
createJoinLeaveEntry,
createLocationEntry,
createPortalSpawnEntry,
createResourceLoadEntry,
findUserByDisplayName,
parseLocation,
parseInventoryFromUrl,
parsePrintFromUrl,
replaceBioSymbols
} from '../shared/utils';
import { i18n } from '../plugin/i18n';
import { AppDebug } from '../service/appConfig';
import { database } from '../service/database';
import { runLastLocationResetFlow, runUpdateCurrentUserLocationFlow } from './locationCoordinator';
import { getGroupName } from '../shared/utils';
import { userRequest } from '../api';
import { watchState } from '../service/watchState';
import { toast } from 'vue-sonner';
import { useAdvancedSettingsStore } from '../stores/settings/advanced';
import { useFriendStore } from '../stores/friend';
import { useGalleryStore } from '../stores/gallery';
import { useGameStore } from '../stores/game';
import { useGameLogStore } from '../stores/gameLog';
import { useGeneralSettingsStore } from '../stores/settings/general';
import { useInstanceStore } from '../stores/instance';
import { useLocationStore } from '../stores/location';
import { useModalStore } from '../stores/modal';
import { useNotificationStore } from '../stores/notification';
import { usePhotonStore } from '../stores/photon';
import { useSharedFeedStore } from '../stores/sharedFeed';
import { useUserStore } from '../stores/user';
import { useVrStore } from '../stores/vr';
import { useVrcxStore } from '../stores/vrcx';
import gameLogService from '../service/gameLog.js';
import * as workerTimers from 'worker-timers';
/**
* Loads the player list from game log history and syncs it to
* locationStore, instanceStore, vrStore, and userStore.
*/
export async function tryLoadPlayerList() {
const gameStore = useGameStore();
const locationStore = useLocationStore();
const userStore = useUserStore();
const friendStore = useFriendStore();
const instanceStore = useInstanceStore();
const vrStore = useVrStore();
if (!gameStore.isGameRunning) {
return;
}
console.log('Loading player list from game log...');
let ctx;
let i;
const data = await database.getGamelogDatabase();
if (data.length === 0) {
return;
}
let length = 0;
for (i = data.length - 1; i > -1; i--) {
ctx = data[i];
if (ctx.type === 'Location') {
locationStore.setLastLocation({
date: Date.parse(ctx.created_at),
location: ctx.location,
name: ctx.worldName,
playerList: new Map(),
friendList: new Map()
});
length = i;
break;
}
}
if (length > 0) {
for (i = length + 1; i < data.length; i++) {
ctx = data[i];
if (ctx.type === 'OnPlayerJoined') {
if (!ctx.userId) {
ctx.userId =
findUserByDisplayName(
userStore.cachedUsers,
ctx.displayName
)?.id ?? '';
}
const userMap = {
displayName: ctx.displayName,
userId: ctx.userId,
joinTime: Date.parse(ctx.created_at),
lastAvatar: ''
};
locationStore.lastLocation.playerList.set(
ctx.userId,
userMap
);
if (friendStore.friends.has(ctx.userId)) {
locationStore.lastLocation.friendList.set(
ctx.userId,
userMap
);
}
}
if (ctx.type === 'OnPlayerLeft') {
locationStore.lastLocation.playerList.delete(ctx.userId);
locationStore.lastLocation.friendList.delete(ctx.userId);
}
}
locationStore.lastLocation.playerList.forEach((ref1) => {
if (
ref1.userId &&
typeof ref1.userId === 'string' &&
!userStore.cachedUsers.has(ref1.userId)
) {
userRequest.getUser({ userId: ref1.userId });
}
});
runUpdateCurrentUserLocationFlow();
instanceStore.updateCurrentInstanceWorld();
vrStore.updateVRLastLocation();
instanceStore.getCurrentInstanceUserList();
userStore.applyUserDialogLocation();
instanceStore.applyWorldDialogInstances();
instanceStore.applyGroupDialogInstances();
}
}
/**
* Core game log entry processor. Dispatches game log events to the
* appropriate stores based on type.
*
* @param {object} gameLog
* @param {string} location
*/
export function addGameLogEntry(gameLog, location) {
const gameLogStore = useGameLogStore();
const locationStore = useLocationStore();
const instanceStore = useInstanceStore();
const userStore = useUserStore();
const friendStore = useFriendStore();
const vrStore = useVrStore();
const gameStore = useGameStore();
const vrcxStore = useVrcxStore();
const advancedSettingsStore = useAdvancedSettingsStore();
const generalSettingsStore = useGeneralSettingsStore();
const galleryStore = useGalleryStore();
const photonStore = usePhotonStore();
const sharedFeedStore = useSharedFeedStore();
const notificationStore = useNotificationStore();
let entry = undefined;
if (advancedSettingsStore.gameLogDisabled) {
return;
}
let userId = String(gameLog.userId || '');
if (!userId && gameLog.displayName) {
userId =
findUserByDisplayName(
userStore.cachedUsers,
gameLog.displayName
)?.id ?? '';
}
switch (gameLog.type) {
case 'location-destination':
if (gameStore.isGameRunning) {
gameLogStore.addGameLog({
created_at: gameLog.dt,
type: 'LocationDestination',
location: gameLog.location
});
runLastLocationResetFlow(gameLog.dt);
locationStore.setLastLocationLocation('traveling');
locationStore.setLastLocationDestination(gameLog.location);
locationStore.setLastLocationDestinationTime(
Date.parse(gameLog.dt)
);
gameLogStore.state.lastLocationAvatarList.clear();
instanceStore.removeQueuedInstance(gameLog.location);
runUpdateCurrentUserLocationFlow();
gameLogStore.clearNowPlaying();
instanceStore.updateCurrentInstanceWorld();
userStore.applyUserDialogLocation();
instanceStore.applyWorldDialogInstances();
instanceStore.applyGroupDialogInstances();
}
break;
case 'location':
instanceStore.addInstanceJoinHistory(
locationStore.lastLocation.location,
gameLog.dt
);
const worldName = replaceBioSymbols(gameLog.worldName);
if (gameStore.isGameRunning) {
runLastLocationResetFlow(gameLog.dt);
gameLogStore.clearNowPlaying();
locationStore.setLastLocation({
date: Date.parse(gameLog.dt),
location: gameLog.location,
name: worldName,
playerList: new Map(),
friendList: new Map()
});
instanceStore.removeQueuedInstance(gameLog.location);
runUpdateCurrentUserLocationFlow();
vrStore.updateVRLastLocation();
instanceStore.updateCurrentInstanceWorld();
userStore.applyUserDialogLocation();
instanceStore.applyWorldDialogInstances();
instanceStore.applyGroupDialogInstances();
}
instanceStore.addInstanceJoinHistory(
gameLog.location,
gameLog.dt
);
const L = parseLocation(gameLog.location);
entry = createLocationEntry(
gameLog.dt,
gameLog.location,
L.worldId,
worldName
);
getGroupName(gameLog.location).then((groupName) => {
entry.groupName = groupName;
});
gameLogStore.addGamelogLocationToDatabase(entry);
break;
case 'player-joined':
const joinTime = Date.parse(gameLog.dt);
const userMap = {
displayName: gameLog.displayName,
userId,
joinTime,
lastAvatar: ''
};
locationStore.lastLocation.playerList.set(userId, userMap);
const ref = userStore.cachedUsers.get(userId);
if (!userId) {
console.error('Missing userId:', gameLog.displayName);
} else if (userId === userStore.currentUser.id) {
// skip
} else if (
friendStore.friends.has(userId) &&
typeof ref !== 'undefined'
) {
locationStore.lastLocation.friendList.set(userId, userMap);
if (
ref.location !== locationStore.lastLocation.location &&
ref.travelingToLocation !==
locationStore.lastLocation.location
) {
ref.$location_at = joinTime;
}
} else if (typeof ref !== 'undefined') {
ref.$location_at = joinTime;
} else {
if (AppDebug.debugGameLog || AppDebug.debugWebRequests) {
console.log('Fetching user from gameLog:', userId);
}
userRequest.getUser({ userId });
}
vrStore.updateVRLastLocation();
instanceStore.getCurrentInstanceUserList();
entry = createJoinLeaveEntry(
'OnPlayerJoined',
gameLog.dt,
gameLog.displayName,
location,
userId
);
database.addGamelogJoinLeaveToDatabase(entry);
break;
case 'player-left':
const ref1 = locationStore.lastLocation.playerList.get(userId);
if (typeof ref1 === 'undefined') {
break;
}
const time = dayjs(gameLog.dt) - ref1.joinTime;
locationStore.lastLocation.playerList.delete(userId);
locationStore.lastLocation.friendList.delete(userId);
gameLogStore.state.lastLocationAvatarList.delete(gameLog.displayName);
photonStore.photonLobbyAvatars.delete(userId);
vrStore.updateVRLastLocation();
instanceStore.getCurrentInstanceUserList();
entry = createJoinLeaveEntry(
'OnPlayerLeft',
gameLog.dt,
gameLog.displayName,
location,
userId,
time
);
database.addGamelogJoinLeaveToDatabase(entry);
break;
case 'portal-spawn':
if (vrcxStore.ipcEnabled && gameStore.isGameRunning) {
break;
}
entry = createPortalSpawnEntry(gameLog.dt, location);
database.addGamelogPortalSpawnToDatabase(entry);
break;
case 'video-play':
gameLog.videoUrl = decodeURI(gameLog.videoUrl);
if (gameLogStore.lastVideoUrl === gameLog.videoUrl) {
break;
}
gameLogStore.lastVideoUrl = gameLog.videoUrl;
gameLogStore.addGameLogVideo(gameLog, location, userId);
break;
case 'video-sync':
const timestamp = gameLog.timestamp.replace(/,/g, '');
if (gameLogStore.nowPlaying.playing) {
gameLogStore.nowPlaying.offset = parseInt(timestamp, 10);
}
break;
case 'resource-load-string':
case 'resource-load-image':
if (
!generalSettingsStore.logResourceLoad ||
gameLogStore.lastResourceloadUrl === gameLog.resourceUrl
) {
break;
}
gameLogStore.lastResourceloadUrl = gameLog.resourceUrl;
entry = createResourceLoadEntry(
gameLog.type,
gameLog.dt,
gameLog.resourceUrl,
location
);
database.addGamelogResourceLoadToDatabase(entry);
break;
case 'screenshot':
vrcxStore.processScreenshot(gameLog.screenshotPath);
break;
case 'api-request':
if (AppDebug.debugWebRequests) {
console.log('API Request:', gameLog.url);
}
if (advancedSettingsStore.saveInstanceEmoji) {
const inv = parseInventoryFromUrl(gameLog.url);
if (inv) {
galleryStore.queueCheckInstanceInventory(
inv.inventoryId,
inv.userId
);
}
}
if (advancedSettingsStore.saveInstancePrints) {
const printId = parsePrintFromUrl(gameLog.url);
if (printId) {
galleryStore.queueSavePrintToFile(printId);
}
}
break;
case 'avatar-change':
if (!gameStore.isGameRunning) {
break;
}
let avatarName = gameLogStore.state.lastLocationAvatarList.get(
gameLog.displayName
);
if (
photonStore.photonLoggingEnabled ||
avatarName === gameLog.avatarName
) {
break;
}
if (!avatarName) {
avatarName = gameLog.avatarName;
gameLogStore.state.lastLocationAvatarList.set(
gameLog.displayName,
avatarName
);
break;
}
avatarName = gameLog.avatarName;
gameLogStore.state.lastLocationAvatarList.set(
gameLog.displayName,
avatarName
);
entry = {
created_at: gameLog.dt,
type: 'AvatarChange',
userId,
name: avatarName,
displayName: gameLog.displayName
};
break;
case 'vrcx':
const type = gameLog.data.substr(0, gameLog.data.indexOf(' '));
if (type === 'VideoPlay(PyPyDance)') {
gameLogStore.addGameLogPyPyDance(gameLog, location);
} else if (type === 'VideoPlay(VRDancing)') {
gameLogStore.addGameLogVRDancing(gameLog, location);
} else if (type === 'VideoPlay(ZuwaZuwaDance)') {
gameLogStore.addGameLogZuwaZuwaDance(gameLog, location);
} else if (type === 'LSMedia') {
gameLogStore.addGameLogLSMedia(gameLog, location);
} else if (type === 'VideoPlay(PopcornPalace)') {
gameLogStore.addGameLogPopcornPalace(gameLog, location);
}
break;
case 'photon-id':
if (!gameStore.isGameRunning || !watchState.isFriendsLoaded) {
break;
}
const photonId = parseInt(gameLog.photonId, 10);
const ref2 = photonStore.photonLobby.get(photonId);
if (typeof ref2 === 'undefined') {
const foundUser = findUserByDisplayName(
userStore.cachedUsers,
gameLog.displayName
);
if (foundUser) {
photonStore.photonLobby.set(photonId, foundUser);
photonStore.photonLobbyCurrent.set(photonId, foundUser);
}
const ctx1 = {
displayName: gameLog.displayName
};
photonStore.photonLobby.set(photonId, ctx1);
photonStore.photonLobbyCurrent.set(photonId, ctx1);
instanceStore.getCurrentInstanceUserList();
}
break;
case 'notification':
break;
case 'event':
entry = {
created_at: gameLog.dt,
type: 'Event',
data: gameLog.event
};
database.addGamelogEventToDatabase(entry);
break;
case 'vrc-quit':
if (!gameStore.isGameRunning) {
break;
}
if (advancedSettingsStore.vrcQuitFix) {
const bias = Date.parse(gameLog.dt) + 3000;
if (bias < Date.now()) {
console.log('QuitFix: Bias too low, not killing VRC');
break;
}
AppApi.QuitGame().then((processCount) => {
if (processCount > 1) {
console.log(
'QuitFix: More than 1 process running, not killing VRC'
);
} else if (processCount === 1) {
console.log('QuitFix: Killed VRC');
} else {
console.log(
'QuitFix: Nothing to kill, no VRC process running'
);
}
});
}
break;
case 'openvr-init':
gameStore.setIsGameNoVR(false);
configRepository.setBool('isGameNoVR', gameStore.isGameNoVR);
vrStore.updateOpenVR();
break;
case 'desktop-mode':
gameStore.setIsGameNoVR(true);
configRepository.setBool('isGameNoVR', gameStore.isGameNoVR);
vrStore.updateOpenVR();
break;
case 'udon-exception':
if (generalSettingsStore.udonExceptionLogging) {
console.log('UdonException', gameLog.data);
}
break;
case 'sticker-spawn':
if (!advancedSettingsStore.saveInstanceStickers) {
break;
}
galleryStore.trySaveStickerToFile(
gameLog.displayName,
gameLog.userId,
gameLog.inventoryId
);
break;
}
if (typeof entry !== 'undefined') {
sharedFeedStore.addEntry(entry);
notificationStore.queueGameLogNoty(entry);
gameLogStore.addGameLog(entry);
}
}
/**
* Parses raw game log JSON and delegates to addGameLogEntry.
* Called from C# / updateLoop.
*
* @param {string} json
*/
export function addGameLogEvent(json) {
const locationStore = useLocationStore();
const rawLogs = JSON.parse(json);
const gameLog = gameLogService.parseRawGameLog(
rawLogs[1],
rawLogs[2],
rawLogs.slice(3)
);
if (
AppDebug.debugGameLog &&
gameLog.type !== 'photon-id' &&
gameLog.type !== 'api-request' &&
gameLog.type !== 'udon-exception'
) {
console.log('gameLog:', gameLog);
}
addGameLogEntry(gameLog, locationStore.lastLocation.location);
}
/**
* Starts game log processing from the database tail.
*/
export async function getGameLogTable() {
await database.initTables();
const dateTill = await database.getLastDateGameLogDatabase();
await updateGameLog(dateTill);
}
/**
* Fetches all game log entries since dateTill and processes them.
*
* @param {string} dateTill
*/
async function updateGameLog(dateTill) {
await gameLogService.setDateTill(dateTill);
await new Promise((resolve) => {
workerTimers.setTimeout(resolve, 10000);
});
let location = '';
for (const gameLog of await gameLogService.getAll()) {
if (gameLog.type === 'location') {
location = gameLog.location;
}
addGameLogEntry(gameLog, location);
}
}
/**
* Shows confirmation dialog before toggling the game log disabled setting.
*/
export async function disableGameLogDialog() {
const gameStore = useGameStore();
const advancedSettingsStore = useAdvancedSettingsStore();
const modalStore = useModalStore();
const t = i18n.global.t;
if (gameStore.isGameRunning) {
toast.error(t('message.gamelog.vrchat_must_be_closed'));
return;
}
if (!advancedSettingsStore.gameLogDisabled) {
modalStore
.confirm({
description: t('confirm.disable_gamelog'),
title: t('confirm.title')
})
.then(({ ok }) => {
if (!ok) return;
advancedSettingsStore.setGameLogDisabled();
})
.catch(() => {});
} else {
advancedSettingsStore.setGameLogDisabled();
}
}
import configRepository from '../service/config';
+5 -3
View File
@@ -38,6 +38,7 @@ import {
import { runHandleUserUpdateFlow } from './userEventCoordinator';
import { runUpdateCurrentUserLocationFlow } from './locationCoordinator';
import { runUpdateFriendFlow } from './friendPresenceCoordinator';
import { userOnFriend } from './friendRelationshipCoordinator';
import { handleGroupRepresented } from './groupCoordinator';
import { useAppearanceSettingsStore } from '../stores/settings/appearance';
import { useAuthStore } from '../stores/auth';
@@ -56,7 +57,8 @@ import { useSharedFeedStore } from '../stores/sharedFeed';
import { useUiStore } from '../stores/ui';
import { useUserStore } from '../stores/user';
const robotUrl = `${AppDebug.endpointDomain}/file/file_0e8c4e32-7444-44ea-ade4-313c010d4bae/1/file`;
const getRobotUrl = () =>
`${AppDebug.endpointDomain}/file/file_0e8c4e32-7444-44ea-ade4-313c010d4bae/1/file`;
/**
* @param {import('../types/api/user').GetUserResponse} json
@@ -76,7 +78,7 @@ export function applyUser(json) {
let ref = cachedUsers.get(json.id);
let hasPropChanged = false;
let changedProps = {};
sanitizeUserJson(json, robotUrl);
sanitizeUserJson(json, getRobotUrl());
if (typeof ref === 'undefined') {
ref = reactive(createDefaultUserRef(json));
if (locationStore.lastLocation.playerList.has(json.id)) {
@@ -219,7 +221,7 @@ export function applyUser(json) {
runUpdateFriendFlow(ref.id, ref.state);
}
applyFavorite('friend', ref.id);
friendStore.userOnFriend(ref);
userOnFriend(ref);
const D = userDialog;
if (D.visible && D.id === ref.id) {
D.ref = ref;
+5 -2
View File
@@ -1,5 +1,8 @@
import { getWorldName, parseLocation } from '../shared/utils';
import { runUpdateFriendshipsFlow } from './friendRelationshipCoordinator';
import {
runUpdateFriendshipsFlow,
updateUserCurrentStatus
} from './friendRelationshipCoordinator';
import { useAuthStore } from '../stores/auth';
import { useAvatarStore } from '../stores/avatar';
import { addAvatarToHistory, addAvatarWearTime } from './avatarCoordinator';
@@ -68,7 +71,7 @@ export function runPostApplySyncFlow(ref) {
applyPresenceGroups(ref);
instanceStore.applyQueuedInstance(ref.queuedInstance);
friendStore.updateUserCurrentStatus(ref);
updateUserCurrentStatus(ref);
if (typeof ref.friends !== 'undefined') {
runUpdateFriendshipsFlow(ref);
}
+78
View File
@@ -0,0 +1,78 @@
import { useAvatarStore } from '../stores/avatar';
import { useFavoriteStore } from '../stores/favorite';
import { useFriendStore } from '../stores/friend';
import { useGalleryStore } from '../stores/gallery';
import { useGroupStore } from '../stores/group';
import { useInstanceStore } from '../stores/instance';
import { useLocationStore } from '../stores/location';
import { useUserStore } from '../stores/user';
import { useWorldStore } from '../stores/world';
import { failedGetRequests } from '../service/request';
/**
* Clears caches across multiple stores while preserving data that is
* still needed (friends, current user, favorites, active instances).
*/
export function clearVRCXCache() {
const userStore = useUserStore();
const worldStore = useWorldStore();
const avatarStore = useAvatarStore();
const groupStore = useGroupStore();
const instanceStore = useInstanceStore();
const friendStore = useFriendStore();
const favoriteStore = useFavoriteStore();
const locationStore = useLocationStore();
const galleryStore = useGalleryStore();
console.log('Clearing VRCX cache...');
failedGetRequests.clear();
userStore.cachedUsers.forEach((ref, id) => {
if (
!friendStore.friends.has(id) &&
!locationStore.lastLocation.playerList.has(ref.id) &&
id !== userStore.currentUser.id
) {
userStore.cachedUsers.delete(id);
}
});
worldStore.cachedWorlds.forEach((ref, id) => {
if (
!favoriteStore.getCachedFavoritesByObjectId(id) &&
ref.authorId !== userStore.currentUser.id &&
!favoriteStore.localWorldFavoritesList.includes(id)
) {
worldStore.cachedWorlds.delete(id);
}
});
avatarStore.cachedAvatars.forEach((ref, id) => {
if (
!favoriteStore.getCachedFavoritesByObjectId(id) &&
ref.authorId !== userStore.currentUser.id &&
!favoriteStore.localAvatarFavoritesList.includes(id) &&
!avatarStore.avatarHistory.includes(id)
) {
avatarStore.cachedAvatars.delete(id);
}
});
groupStore.cachedGroups.forEach((ref, id) => {
if (!groupStore.currentUserGroups.has(id)) {
groupStore.cachedGroups.delete(id);
}
});
instanceStore.cachedInstances.forEach((ref, id) => {
if (
[...friendStore.friends.values()].some(
(f) => f.$location?.tag === id
)
) {
return;
}
// delete instances over an hour old
if (Date.parse(ref.$fetchedAt) < Date.now() - 3600000) {
instanceStore.cachedInstances.delete(id);
}
});
avatarStore.cachedAvatarNames.clear();
userStore.customUserTags.clear();
galleryStore.cachedEmoji.clear();
}