diff --git a/src/coordinators/gameLogCoordinator.js b/src/coordinators/gameLogCoordinator.js index ebd7b2b0..8f9fb137 100644 --- a/src/coordinators/gameLogCoordinator.js +++ b/src/coordinators/gameLogCoordinator.js @@ -88,7 +88,8 @@ export async function tryLoadPlayerList() { ctx.userId = findUserByDisplayName( userStore.cachedUsers, - ctx.displayName + ctx.displayName, + userStore.cachedUserIdsByDisplayName )?.id ?? ''; } const userMap = { @@ -159,8 +160,11 @@ export function addGameLogEntry(gameLog, location) { let userId = String(gameLog.userId || ''); if (!userId && gameLog.displayName) { userId = - findUserByDisplayName(userStore.cachedUsers, gameLog.displayName) - ?.id ?? ''; + findUserByDisplayName( + userStore.cachedUsers, + gameLog.displayName, + userStore.cachedUserIdsByDisplayName + )?.id ?? ''; } switch (gameLog.type) { case 'location-destination': @@ -408,7 +412,8 @@ export function addGameLogEntry(gameLog, location) { if (typeof ref2 === 'undefined') { const foundUser = findUserByDisplayName( userStore.cachedUsers, - gameLog.displayName + gameLog.displayName, + userStore.cachedUserIdsByDisplayName ); if (foundUser) { photonStore.photonLobby.set(photonId, foundUser); diff --git a/src/coordinators/userCoordinator.js b/src/coordinators/userCoordinator.js index 7c3f9a82..d597d1d9 100644 --- a/src/coordinators/userCoordinator.js +++ b/src/coordinators/userCoordinator.js @@ -80,11 +80,14 @@ export function applyUser(json) { cachedUsers, currentTravelers, customUserTags, + rebuildCachedUserDisplayNameIndex, + setCachedUser, state, userDialog } = userStore; let ref = cachedUsers.get(json.id); + let previousDisplayName = ''; let hasPropChanged = false; let changedProps = {}; sanitizeUserJson(json, getRobotUrl()); @@ -111,18 +114,24 @@ export function applyUser(json) { ref.$customTag = ''; ref.$customTagColour = ''; } - evictMapCache( + const { deletedCount } = evictMapCache( cachedUsers, friendStore.friends.size + 300, (_value, key) => friendStore.friends.has(key), { logLabel: 'User cache cleanup' } ); - cachedUsers.set(ref.id, ref); + if (deletedCount > 0) { + setCachedUser(ref, '', { skipIndex: true }); + rebuildCachedUserDisplayNameIndex(); + } else { + setCachedUser(ref); + } runUpdateFriendFlow(ref.id); } else { if (json.state !== 'online') { runUpdateFriendFlow(ref.id, json.state); } + previousDisplayName = ref.displayName; const { hasPropChanged: _hasPropChanged, changedProps: _changedProps } = diffObjectProps(ref, json, arraysMatch); for (const prop in json) { @@ -130,6 +139,7 @@ export function applyUser(json) { ref[prop] = json[prop]; } } + setCachedUser(ref, previousDisplayName); hasPropChanged = _hasPropChanged; changedProps = _changedProps; } @@ -637,7 +647,11 @@ export async function lookupUser(ref) { if (!ref.displayName || ref.displayName.substring(0, 3) === 'ID:') { return; } - const found = findUserByDisplayName(userStore.cachedUsers, ref.displayName); + const found = findUserByDisplayName( + userStore.cachedUsers, + ref.displayName, + userStore.cachedUserIdsByDisplayName + ); if (found) { showUserDialog(found.id); return; diff --git a/src/coordinators/userSessionCoordinator.js b/src/coordinators/userSessionCoordinator.js index 332d8d7c..340bcedd 100644 --- a/src/coordinators/userSessionCoordinator.js +++ b/src/coordinators/userSessionCoordinator.js @@ -51,7 +51,7 @@ export function runFirstLoginFlow(ref, { now = Date.now } = {}) { if (gameStore.isGameRunning) { ref.$previousAvatarSwapTime = now(); } - userStore.cachedUsers.clear(); // clear before running applyUser + userStore.clearCachedUsers(); // clear before running applyUser userStore.setCurrentUser(ref); authStore.loginComplete(); } diff --git a/src/coordinators/vrcxCoordinator.js b/src/coordinators/vrcxCoordinator.js index d2448c99..1f13e501 100644 --- a/src/coordinators/vrcxCoordinator.js +++ b/src/coordinators/vrcxCoordinator.js @@ -35,7 +35,7 @@ export function clearVRCXCache() { !locationStore.lastLocation.playerList.has(ref.id) && id !== userStore.currentUser.id ) { - userStore.cachedUsers.delete(id); + userStore.deleteCachedUser(id); } }); worldStore.cachedWorlds.forEach((ref, id) => { diff --git a/src/shared/utils/__tests__/user.findByDisplayName.test.js b/src/shared/utils/__tests__/user.findByDisplayName.test.js index 3fb4cef2..65e07156 100644 --- a/src/shared/utils/__tests__/user.findByDisplayName.test.js +++ b/src/shared/utils/__tests__/user.findByDisplayName.test.js @@ -17,6 +17,19 @@ describe('findUserByDisplayName', () => { return map; } + function createDisplayNameIndex(entries) { + const index = new Map(); + for (const entry of entries) { + let ids = index.get(entry.displayName); + if (!ids) { + ids = new Set(); + index.set(entry.displayName, ids); + } + ids.add(entry.id); + } + return index; + } + test('returns the user matching displayName', () => { const users = createCachedUsers([ { id: 'usr_1', displayName: 'Alice' }, @@ -56,4 +69,31 @@ describe('findUserByDisplayName', () => { expect(findUserByDisplayName(users, 'alice')).toBeUndefined(); expect(findUserByDisplayName(users, 'ALICE')).toBeUndefined(); }); + + test('uses displayName index when provided', () => { + const entries = [ + { id: 'usr_1', displayName: 'Alice' }, + { id: 'usr_2', displayName: 'Bob' } + ]; + const users = createCachedUsers(entries); + const index = createDisplayNameIndex(entries); + + const result = findUserByDisplayName(users, 'Bob', index); + + expect(result).toEqual({ id: 'usr_2', displayName: 'Bob' }); + }); + + test('indexed lookup falls back to next duplicate when first user is missing', () => { + const entries = [ + { id: 'usr_1', displayName: 'Alice' }, + { id: 'usr_2', displayName: 'Alice' } + ]; + const users = createCachedUsers(entries); + users.delete('usr_1'); + const index = createDisplayNameIndex(entries); + + const result = findUserByDisplayName(users, 'Alice', index); + + expect(result).toEqual({ id: 'usr_2', displayName: 'Alice' }); + }); }); diff --git a/src/shared/utils/user.js b/src/shared/utils/user.js index 79d0d593..187217e9 100644 --- a/src/shared/utils/user.js +++ b/src/shared/utils/user.js @@ -275,9 +275,23 @@ function userOnlineFor(ref) { * Find a user object from cachedUsers by displayName. * @param {Map} cachedUsers * @param {string} displayName + * @param {Map>} [cachedUserIdsByDisplayName] * @returns {object|undefined} */ -function findUserByDisplayName(cachedUsers, displayName) { +function findUserByDisplayName( + cachedUsers, + displayName, + cachedUserIdsByDisplayName +) { + const indexedUserIds = cachedUserIdsByDisplayName?.get(displayName); + if (indexedUserIds) { + for (const userId of indexedUserIds) { + const ref = cachedUsers.get(userId); + if (ref?.displayName === displayName) { + return ref; + } + } + } for (const ref of cachedUsers.values()) { if (ref.displayName === displayName) { return ref; diff --git a/src/stores/gameLog/index.js b/src/stores/gameLog/index.js index 0305338c..16ac5046 100644 --- a/src/stores/gameLog/index.js +++ b/src/stores/gameLog/index.js @@ -193,7 +193,8 @@ export const useGameLogStore = defineStore('GameLog', () => { ctx.userId = findUserByDisplayName( userStore.cachedUsers, - ctx.displayName + ctx.displayName, + userStore.cachedUserIdsByDisplayName )?.id ?? ''; } notificationStore.queueGameLogNoty(ctx); diff --git a/src/stores/gameLog/mediaParsers.js b/src/stores/gameLog/mediaParsers.js index 8d2765d2..3e69694e 100644 --- a/src/stores/gameLog/mediaParsers.js +++ b/src/stores/gameLog/mediaParsers.js @@ -139,8 +139,11 @@ export function createMediaParsers({ let userId = ''; if (displayName) { userId = - findUserByDisplayName(userStore.cachedUsers, displayName)?.id ?? - ''; + findUserByDisplayName( + userStore.cachedUsers, + displayName, + userStore.cachedUserIdsByDisplayName + )?.id ?? ''; } if (videoId === 'YouTube') { const entry1 = { @@ -207,8 +210,11 @@ export function createMediaParsers({ let userId = ''; if (displayName) { userId = - findUserByDisplayName(userStore.cachedUsers, displayName)?.id ?? - ''; + findUserByDisplayName( + userStore.cachedUsers, + displayName, + userStore.cachedUserIdsByDisplayName + )?.id ?? ''; } if (videoId === 'YouTube') { const entry1 = { @@ -270,8 +276,11 @@ export function createMediaParsers({ let userId = ''; if (displayName) { userId = - findUserByDisplayName(userStore.cachedUsers, displayName)?.id ?? - ''; + findUserByDisplayName( + userStore.cachedUsers, + displayName, + userStore.cachedUserIdsByDisplayName + )?.id ?? ''; } if (videoId === 'YouTube') { const entry1 = { @@ -327,8 +336,11 @@ export function createMediaParsers({ let userId = ''; if (displayName) { userId = - findUserByDisplayName(userStore.cachedUsers, displayName)?.id ?? - ''; + findUserByDisplayName( + userStore.cachedUsers, + displayName, + userStore.cachedUserIdsByDisplayName + )?.id ?? ''; } const entry1 = { created_at: gameLog.dt, @@ -384,8 +396,11 @@ export function createMediaParsers({ let userId = ''; if (displayName) { userId = - findUserByDisplayName(userStore.cachedUsers, displayName)?.id ?? - ''; + findUserByDisplayName( + userStore.cachedUsers, + displayName, + userStore.cachedUserIdsByDisplayName + )?.id ?? ''; } const entry1 = { created_at: gameLog.dt, diff --git a/src/stores/notification/index.js b/src/stores/notification/index.js index f862d2fb..80adb9af 100644 --- a/src/stores/notification/index.js +++ b/src/stores/notification/index.js @@ -1018,8 +1018,11 @@ export const useNotificationStore = defineStore('Notification', () => { if (id) return id; if (noty.displayName) { return ( - findUserByDisplayName(userStore.cachedUsers, noty.displayName) - ?.id ?? '' + findUserByDisplayName( + userStore.cachedUsers, + noty.displayName, + userStore.cachedUserIdsByDisplayName + )?.id ?? '' ); } return ''; @@ -1086,7 +1089,8 @@ export const useNotificationStore = defineStore('Notification', () => { } else if (noty.displayName) { const ref = findUserByDisplayName( userStore.cachedUsers, - noty.displayName + noty.displayName, + userStore.cachedUserIdsByDisplayName ); if (ref) { noty.isFriend = friendStore.friends.has(ref.id); diff --git a/src/stores/user.js b/src/stores/user.js index 28507399..7d681ddb 100644 --- a/src/stores/user.js +++ b/src/stores/user.js @@ -271,6 +271,78 @@ export const useUserStore = defineStore('User', () => { }); const cachedUsers = shallowReactive(new Map()); + const cachedUserIdsByDisplayName = shallowReactive(new Map()); + + function addCachedUserDisplayNameEntry(displayName, userId) { + if (!displayName || !userId) { + return; + } + let userIds = cachedUserIdsByDisplayName.get(displayName); + if (!userIds) { + userIds = new Set(); + cachedUserIdsByDisplayName.set(displayName, userIds); + } + userIds.add(userId); + } + + function removeCachedUserDisplayNameEntry(displayName, userId) { + if (!displayName || !userId) { + return; + } + const userIds = cachedUserIdsByDisplayName.get(displayName); + if (!userIds) { + return; + } + userIds.delete(userId); + if (userIds.size === 0) { + cachedUserIdsByDisplayName.delete(displayName); + } + } + + function syncCachedUserDisplayName(ref, previousDisplayName = '') { + if (!ref?.id) { + return; + } + if (previousDisplayName && previousDisplayName !== ref.displayName) { + removeCachedUserDisplayNameEntry(previousDisplayName, ref.id); + } + addCachedUserDisplayNameEntry(ref.displayName, ref.id); + } + + function setCachedUser( + ref, + previousDisplayName = '', + { skipIndex = false } = {} + ) { + if (!ref?.id) { + return; + } + cachedUsers.set(ref.id, ref); + if (!skipIndex) { + syncCachedUserDisplayName(ref, previousDisplayName); + } + } + + function deleteCachedUser(userId) { + const ref = cachedUsers.get(userId); + if (!ref) { + return false; + } + removeCachedUserDisplayNameEntry(ref.displayName, userId); + return cachedUsers.delete(userId); + } + + function clearCachedUsers() { + cachedUsers.clear(); + cachedUserIdsByDisplayName.clear(); + } + + function rebuildCachedUserDisplayNameIndex() { + cachedUserIdsByDisplayName.clear(); + for (const ref of cachedUsers.values()) { + addCachedUserDisplayNameEntry(ref.displayName, ref.id); + } + } const isLocalUserVrcPlusSupporter = computed( () => currentUser.value.$isVRCPlus || AppDebug.debugVrcPlus @@ -730,10 +802,16 @@ export const useUserStore = defineStore('User', () => { showUserDialogHistory, customUserTags, cachedUsers, + cachedUserIdsByDisplayName, isLocalUserVrcPlusSupporter, applyUserLanguage, applyPresenceLocation, applyUserDialogLocation, + setCachedUser, + syncCachedUserDisplayName, + deleteCachedUser, + clearCachedUsers, + rebuildCachedUserDisplayNameIndex, sortUserDialogAvatars, initUserNotes, showSendBoopDialog,