diff --git a/src/components/dialogs/AvatarDialog/useAvatarDialogCommands.js b/src/components/dialogs/AvatarDialog/useAvatarDialogCommands.js index f46d031a..452bb387 100644 --- a/src/components/dialogs/AvatarDialog/useAvatarDialogCommands.js +++ b/src/components/dialogs/AvatarDialog/useAvatarDialogCommands.js @@ -5,6 +5,7 @@ import { avatarRequest, favoriteRequest } from '../../../api'; +import { removeAvatarFromCache } from '../../../coordinators/avatarCoordinator'; import { copyToClipboard, openExternalLink, @@ -378,7 +379,7 @@ export function useAvatarDialogCommands( .deleteAvatar({ avatarId: id }) .then((args) => { const { json } = args; - cachedAvatars.delete(json._id); + removeAvatarFromCache(json._id); if (userDialog.value.id === json.authorId) { const map = new Map(); for (const ref of cachedAvatars.values()) { diff --git a/src/components/dialogs/WorldDialog/useWorldDialogCommands.js b/src/components/dialogs/WorldDialog/useWorldDialogCommands.js index 3c39231f..566ef74e 100644 --- a/src/components/dialogs/WorldDialog/useWorldDialogCommands.js +++ b/src/components/dialogs/WorldDialog/useWorldDialogCommands.js @@ -16,6 +16,7 @@ import { readFileAsBase64, withUploadTimeout } from '../../../shared/utils/imageUpload'; +import { removeWorldFromCache } from '../../../coordinators/worldCoordinator'; /** * Composable for WorldDialog commands, prompt functions, and image upload. @@ -534,7 +535,7 @@ export function useWorldDialogCommands( handler: (id) => { worldRequest.deleteWorld({ worldId: id }).then((args) => { const { json } = args; - cachedWorlds.delete(json.id); + removeWorldFromCache(json.id); if (worldDialog.value.ref.authorId === json.authorId) { const map = new Map(); for (const ref of cachedWorlds.values()) { diff --git a/src/coordinators/avatarCoordinator.js b/src/coordinators/avatarCoordinator.js index ffd793f5..2ae43615 100644 --- a/src/coordinators/avatarCoordinator.js +++ b/src/coordinators/avatarCoordinator.js @@ -24,6 +24,7 @@ import { useAvatarProviderStore } from '../stores/avatarProvider'; import { useAvatarStore } from '../stores/avatar'; import { useFavoriteStore } from '../stores/favorite'; import { useModalStore } from '../stores/modal'; +import { syncAvatarSearchIndex, removeAvatarSearchIndex } from './searchIndexCoordinator'; import { useUiStore } from '../stores/ui'; import { useUserStore } from '../stores/user'; import { useVRCXUpdaterStore } from '../stores/vrcxUpdater'; @@ -67,6 +68,7 @@ export function applyAvatar(json) { database.addAvatarToCache(avatarRef); } patchAvatarFromEvent(ref); + syncAvatarSearchIndex(ref); return ref; } @@ -632,3 +634,12 @@ export async function preloadOwnAvatars() { } }); } + +/** + * @param {string} id + */ +export function removeAvatarFromCache(id) { + const avatarStore = useAvatarStore(); + avatarStore.cachedAvatars.delete(id); + removeAvatarSearchIndex(id); +} diff --git a/src/coordinators/favoriteCoordinator.js b/src/coordinators/favoriteCoordinator.js index fa024b5d..c57f71cb 100644 --- a/src/coordinators/favoriteCoordinator.js +++ b/src/coordinators/favoriteCoordinator.js @@ -7,6 +7,7 @@ import { useFriendStore } from '../stores/friend'; import { useGeneralSettingsStore } from '../stores/settings/general'; import { useUserStore } from '../stores/user'; import { useWorldStore } from '../stores/world'; +import { rebuildFavoriteSearchIndex } from './searchIndexCoordinator'; import { applyWorld } from './worldCoordinator'; import { runUpdateFriendFlow } from './friendPresenceCoordinator'; import { avatarRequest, favoriteRequest, queryRequest } from '../api'; @@ -134,6 +135,7 @@ export function handleFavoriteAtDelete(ref) { avatarStore.setAvatarDialogIsFavorite(false); } favoriteStore.countFavoriteGroups(); + rebuildFavoriteSearchIndex(); } /** @@ -374,6 +376,7 @@ export async function applyFavorite(type, objectId) { } } } + rebuildFavoriteSearchIndex(); } // --- Refresh flows --- @@ -1284,5 +1287,8 @@ export function onLoginStateChanged(isLoggedIn) { friendStore.localFavoriteFriends.clear(); if (isLoggedIn) { initFavorites(); + } else { + rebuildFavoriteSearchIndex(); } } + diff --git a/src/coordinators/friendPresenceCoordinator.js b/src/coordinators/friendPresenceCoordinator.js index a38bd201..d22059a9 100644 --- a/src/coordinators/friendPresenceCoordinator.js +++ b/src/coordinators/friendPresenceCoordinator.js @@ -4,6 +4,7 @@ import { database } from '../services/database'; import { useFeedStore } from '../stores/feed'; import { useFriendStore } from '../stores/friend'; import { useNotificationStore } from '../stores/notification'; +import { syncFriendSearchIndex } from './searchIndexCoordinator'; import { useSharedFeedStore } from '../stores/sharedFeed'; import { useUserStore } from '../stores/user'; import { userRequest } from '../api'; @@ -117,6 +118,7 @@ export async function runUpdateFriendDelayedCheckFlow( } if (ref?.displayName) { ctx.name = ref.displayName; + syncFriendSearchIndex(ctx); } ctx.isVIP = isVIP; } @@ -205,6 +207,7 @@ export async function runUpdateFriendFlow( } if (typeof ref !== 'undefined' && ctx.name !== ref.displayName) { ctx.name = ref.displayName; + syncFriendSearchIndex(ctx); } return; } @@ -216,6 +219,7 @@ export async function runUpdateFriendFlow( ctx.isVIP = isVIP; if (typeof ref !== 'undefined') { ctx.name = ref.displayName; + syncFriendSearchIndex(ctx); } if (!watchState.isFriendsLoaded) { await runUpdateFriendDelayedCheckFlow( @@ -250,6 +254,7 @@ export async function runUpdateFriendFlow( ctx.isVIP = isVIP; if (typeof ref !== 'undefined') { ctx.name = ref.displayName; + syncFriendSearchIndex(ctx); await runUpdateFriendDelayedCheckFlow( ctx, ctx.ref.state, @@ -304,3 +309,4 @@ export async function runPendingOfflineTickFlow({ } } } + diff --git a/src/coordinators/friendRelationshipCoordinator.js b/src/coordinators/friendRelationshipCoordinator.js index 371240f8..f916a072 100644 --- a/src/coordinators/friendRelationshipCoordinator.js +++ b/src/coordinators/friendRelationshipCoordinator.js @@ -11,6 +11,10 @@ import { useNotificationStore } from '../stores/notification'; import { useSharedFeedStore } from '../stores/sharedFeed'; import { useUiStore } from '../stores/ui'; import { useUserStore } from '../stores/user'; +import { + removeFriendSearchIndex, + syncFriendSearchIndex +} from './searchIndexCoordinator'; import { watchState } from '../services/watchState'; import configRepository from '../services/config'; @@ -44,6 +48,7 @@ export function handleFriendDelete(args) { D.isFriend = false; runDeleteFriendshipFlow(args.params.userId); friendStore.deleteFriend(args.params.userId); + removeFriendSearchIndex(args.params.userId); } /** @@ -53,6 +58,10 @@ export function handleFriendAdd(args) { const friendStore = useFriendStore(); addFriendship(args.params.userId); friendStore.addFriend(args.params.userId); + const ctx = friendStore.friends.get(args.params.userId); + if (ctx) { + syncFriendSearchIndex(ctx); + } } /** @@ -136,6 +145,10 @@ export function addFriendship(id) { state.friendNumber ); friendStore.addFriend(id, ref.state); + const friendCtx = friendStore.friends.get(id); + if (friendCtx) { + syncFriendSearchIndex(friendCtx); + } const friendLogHistory = { created_at: new Date().toJSON(), type: 'Friend', @@ -316,7 +329,16 @@ export function updateUserCurrentStatus(ref) { const appearanceSettingsStore = useAppearanceSettingsStore(); if (watchState.isFriendsLoaded) { - friendStore.refreshFriendsStatus(ref); + const { added, removed } = friendStore.refreshFriendsStatus(ref); + for (const id of added) { + const ctx = friendStore.friends.get(id); + if (ctx) { + syncFriendSearchIndex(ctx); + } + } + for (const id of removed) { + removeFriendSearchIndex(id); + } } friendStore.updateOnlineFriendCounter(); @@ -383,6 +405,7 @@ export function runDeleteFriendshipFlow( uiStore.notifyMenu('friend-log'); } friendStore.deleteFriend(id); + removeFriendSearchIndex(id); } }); } diff --git a/src/coordinators/friendSyncCoordinator.js b/src/coordinators/friendSyncCoordinator.js index 41108d76..a28b0d4b 100644 --- a/src/coordinators/friendSyncCoordinator.js +++ b/src/coordinators/friendSyncCoordinator.js @@ -2,6 +2,7 @@ import { toast } from 'vue-sonner'; import { AppDebug } from '../services/appConfig'; import { migrateMemos } from './memoCoordinator'; +import { syncFriendSearchIndex } from './searchIndexCoordinator'; import { reconnectWebSocket } from '../services/websocket'; import { useAuthStore } from '../stores/auth'; import { useFriendStore } from '../stores/friend'; @@ -56,6 +57,11 @@ export async function runInitFriendsListFlow(t) { } } + // bulk sync friends to search index after initial load + for (const ctx of friendStore.friends.values()) { + syncFriendSearchIndex(ctx); + } + friendStore.tryApplyFriendOrder(); // once again friendStore.getAllUserStats(); // joinCount, lastSeen, timeSpent diff --git a/src/coordinators/groupCoordinator.js b/src/coordinators/groupCoordinator.js index f18fb8c7..51b093c1 100644 --- a/src/coordinators/groupCoordinator.js +++ b/src/coordinators/groupCoordinator.js @@ -18,13 +18,13 @@ import { useNotificationStore } from '../stores/notification'; import { useUiStore } from '../stores/ui'; import { useUserStore } from '../stores/user'; import { useGroupStore } from '../stores/group'; +import { syncGroupSearchIndex, removeGroupSearchIndex, clearGroupSearchIndex } from './searchIndexCoordinator'; import { watchState } from '../services/watchState'; import configRepository from '../services/config'; import * as workerTimers from 'worker-timers'; -// ─── Internal helpers (not exported) ───────────────────────────────────────── /** * @param ref @@ -48,7 +48,6 @@ function applyGroupLanguage(ref) { } } -// ─── Core entity application ───────────────────────────────────────────────── /** * @@ -132,6 +131,9 @@ export function applyGroup(json) { D.ref = ref; } patchGroupFromEvent(ref); + if (groupStore.currentUserGroups.has(ref.id)) { + syncGroupSearchIndex(ref); + } return ref; } @@ -178,7 +180,6 @@ export function applyGroupMember(json) { return json; } -// ─── Group change notifications ────────────────────────────────────────────── /** * @@ -274,7 +275,6 @@ function groupRoleChange(ref, oldRoles, newRoles, oldRoleIds, newRoleIds) { } } -// ─── Dialog flows ──────────────────────────────────────────────────────────── /** * @@ -461,7 +461,6 @@ export function getGroupDialogGroup(groupId, existingRef) { }); } -// ─── Group lifecycle flows ─────────────────────────────────────────────────── /** * @@ -508,6 +507,7 @@ export function onGroupJoined(groupId) { name: '', iconUrl: '' }); + syncGroupSearchIndex({ id: groupId, name: '', ownerId: '', iconUrl: '' }); groupRequest.getGroup({ groupId, includeRoles: true }).then((args) => { applyGroup(args.json); saveCurrentUserGroups(); @@ -539,6 +539,7 @@ export async function onGroupLeft(groupId) { } if (groupStore.currentUserGroups.has(groupId)) { groupStore.currentUserGroups.delete(groupId); + removeGroupSearchIndex(groupId); groupChange(ref, 'Left group'); // delay to wait for json to be assigned to ref @@ -546,7 +547,6 @@ export async function onGroupLeft(groupId) { } } -// ─── User group management ─────────────────────────────────────────────────── /** * @@ -589,6 +589,7 @@ export async function loadCurrentUserGroups(userId, groups) { ); groupStore.cachedGroups.clear(); groupStore.currentUserGroups.clear(); + clearGroupSearchIndex(); for (const group of savedGroups) { const json = { id: group.id, @@ -602,6 +603,7 @@ export async function loadCurrentUserGroups(userId, groups) { }; const ref = applyGroup(json); groupStore.currentUserGroups.set(group.id, ref); + syncGroupSearchIndex(ref); } if (groups) { @@ -620,6 +622,7 @@ export async function loadCurrentUserGroups(userId, groups) { }); const ref = applyGroup(args.json); groupStore.currentUserGroups.set(groupId, ref); + syncGroupSearchIndex(ref); } catch (err) { console.error(err); } @@ -643,10 +646,12 @@ export async function getCurrentUserGroups() { }); handleGroupList(args); groupStore.currentUserGroups.clear(); + clearGroupSearchIndex(); for (const group of args.json) { const ref = applyGroup(group); if (!groupStore.currentUserGroups.has(group.id)) { groupStore.currentUserGroups.set(group.id, ref); + syncGroupSearchIndex(ref); } } const args1 = await groupRequest.getGroupPermissions({ @@ -704,7 +709,6 @@ export async function updateInGameGroupOrder() { } } -// ─── Group actions ─────────────────────────────────────────────────────────── /** * diff --git a/src/coordinators/searchIndexCoordinator.js b/src/coordinators/searchIndexCoordinator.js new file mode 100644 index 00000000..dd3a4228 --- /dev/null +++ b/src/coordinators/searchIndexCoordinator.js @@ -0,0 +1,106 @@ +import { watch } from 'vue'; + +import { useSearchIndexStore } from '../stores/searchIndex'; +import { watchState } from '../services/watchState'; + + +/** + * @param {object} ctx + */ +export function syncFriendSearchIndex(ctx) { + useSearchIndexStore().syncFriend(ctx); +} + +/** + * @param {string} id + */ +export function removeFriendSearchIndex(id) { + useSearchIndexStore().removeFriend(id); +} + +export function clearFriendSearchIndex() { + useSearchIndexStore().clearFriends(); +} + + +/** + * @param {object} ref + */ +export function syncAvatarSearchIndex(ref) { + useSearchIndexStore().upsertAvatar(ref); +} + +/** + * @param {string} id + */ +export function removeAvatarSearchIndex(id) { + useSearchIndexStore().removeAvatar(id); +} + +export function clearAvatarSearchIndex() { + useSearchIndexStore().clearAvatars(); +} + +/** + * @param {object} ref + */ +export function syncWorldSearchIndex(ref) { + useSearchIndexStore().upsertWorld(ref); +} + +/** + * @param {string} id + */ +export function removeWorldSearchIndex(id) { + useSearchIndexStore().removeWorld(id); +} + +export function clearWorldSearchIndex() { + useSearchIndexStore().clearWorlds(); +} + +/** + * @param {object} ref + */ +export function syncGroupSearchIndex(ref) { + useSearchIndexStore().upsertGroup(ref); +} + +/** + * @param {string} id + */ +export function removeGroupSearchIndex(id) { + useSearchIndexStore().removeGroup(id); +} + +export function clearGroupSearchIndex() { + useSearchIndexStore().clearGroups(); +} + + +export function rebuildFavoriteSearchIndex() { + useSearchIndexStore().rebuildFavoritesFromStore(); +} + +export function clearFavoriteSearchIndex() { + useSearchIndexStore().clearFavorites(); +} + +/** + * Registers a single login-state watcher that clears the entire search index + * on every login/logout transition, so individual stores don't need to. + */ +export function resetSearchIndexOnLogin() { + const searchIndexStore = useSearchIndexStore(); + watch( + () => watchState.isLoggedIn, + () => { + searchIndexStore.clearFriends(); + searchIndexStore.clearAvatars(); + searchIndexStore.clearWorlds(); + searchIndexStore.clearGroups(); + searchIndexStore.clearFavorites(); + }, + { flush: 'sync' } + ); +} diff --git a/src/coordinators/userCoordinator.js b/src/coordinators/userCoordinator.js index 19bc1a79..868bbd1b 100644 --- a/src/coordinators/userCoordinator.js +++ b/src/coordinators/userCoordinator.js @@ -53,6 +53,8 @@ import { useModerationStore } from '../stores/moderation'; import { useNotificationStore } from '../stores/notification'; import { usePhotonStore } from '../stores/photon'; import { useSearchStore } from '../stores/search'; +import { syncFriendSearchIndex } from './searchIndexCoordinator'; +import { removeAvatarFromCache } from './avatarCoordinator'; import { useSharedFeedStore } from '../stores/sharedFeed'; import { useUiStore } from '../stores/ui'; import { useUserStore } from '../stores/user'; @@ -181,6 +183,7 @@ export function applyUser(json) { if (friendCtx) { friendCtx.ref = ref; friendCtx.name = ref.displayName; + syncFriendSearchIndex(friendCtx); } if (ref.id === currentUser.id) { if (ref.status) { @@ -306,6 +309,7 @@ export function showUserDialog(userId) { } else { ref.$nickName = ''; } + syncFriendSearchIndex(ref); } } }); @@ -583,7 +587,7 @@ export async function refreshUserDialogAvatars(fileId) { }; for (const ref of avatarStore.cachedAvatars.values()) { if (ref.authorId === D.id) { - avatarStore.cachedAvatars.delete(ref.id); + removeAvatarFromCache(ref.id); } } const map = new Map(); diff --git a/src/coordinators/vrcxCoordinator.js b/src/coordinators/vrcxCoordinator.js index 8b95143f..d2448c99 100644 --- a/src/coordinators/vrcxCoordinator.js +++ b/src/coordinators/vrcxCoordinator.js @@ -1,3 +1,5 @@ +import { removeAvatarFromCache } from './avatarCoordinator'; +import { removeWorldFromCache } from './worldCoordinator'; import { useAvatarStore } from '../stores/avatar'; import { useFavoriteStore } from '../stores/favorite'; import { useFriendStore } from '../stores/friend'; @@ -9,6 +11,7 @@ import { useUserStore } from '../stores/user'; import { useWorldStore } from '../stores/world'; import { failedGetRequests } from '../services/request'; + /** * Clears caches across multiple stores while preserving data that is * still needed (friends, current user, favorites, active instances). @@ -41,7 +44,7 @@ export function clearVRCXCache() { ref.authorId !== userStore.currentUser.id && !favoriteStore.localWorldFavoritesList.includes(id) ) { - worldStore.cachedWorlds.delete(id); + removeWorldFromCache(id); } }); avatarStore.cachedAvatars.forEach((ref, id) => { @@ -51,7 +54,7 @@ export function clearVRCXCache() { !favoriteStore.localAvatarFavoritesList.includes(id) && !avatarStore.avatarHistory.includes(id) ) { - avatarStore.cachedAvatars.delete(id); + removeAvatarFromCache(id); } }); groupStore.cachedGroups.forEach((ref, id) => { diff --git a/src/coordinators/worldCoordinator.js b/src/coordinators/worldCoordinator.js index f2b5d334..f5ef4b44 100644 --- a/src/coordinators/worldCoordinator.js +++ b/src/coordinators/worldCoordinator.js @@ -20,6 +20,7 @@ import { applyFavorite } from './favoriteCoordinator'; import { useFavoriteStore } from '../stores/favorite'; import { useInstanceStore } from '../stores/instance'; import { useLocationStore } from '../stores/location'; +import { syncWorldSearchIndex, removeWorldSearchIndex } from './searchIndexCoordinator'; import { useUiStore } from '../stores/ui'; import { useUserStore } from '../stores/user'; import { useWorldStore } from '../stores/world'; @@ -221,6 +222,7 @@ export function applyWorld(json) { database.addWorldToCache(ref); } patchWorldFromEvent(ref); + syncWorldSearchIndex(ref); return ref; } @@ -247,3 +249,12 @@ export async function preloadOwnWorlds() { } }); } + +/** + * @param {string} id + */ +export function removeWorldFromCache(id) { + const worldStore = useWorldStore(); + worldStore.cachedWorlds.delete(id); + removeWorldSearchIndex(id); +} diff --git a/src/stores/__tests__/globalSearch.test.js b/src/stores/__tests__/globalSearch.test.js index beac5b90..9a42ad5e 100644 --- a/src/stores/__tests__/globalSearch.test.js +++ b/src/stores/__tests__/globalSearch.test.js @@ -5,10 +5,7 @@ import { nextTick, reactive } from 'vue'; const mocks = vi.hoisted(() => ({ workerInstances: [], friendStore: null, - favoriteStore: null, - avatarStore: null, - worldStore: null, - groupStore: null, + searchIndexStore: null, userStore: null })); @@ -29,17 +26,8 @@ vi.mock('../searchWorker.js?worker', () => ({ vi.mock('../friend', () => ({ useFriendStore: () => mocks.friendStore })); -vi.mock('../favorite', () => ({ - useFavoriteStore: () => mocks.favoriteStore -})); -vi.mock('../avatar', () => ({ - useAvatarStore: () => mocks.avatarStore -})); -vi.mock('../world', () => ({ - useWorldStore: () => mocks.worldStore -})); -vi.mock('../group', () => ({ - useGroupStore: () => mocks.groupStore +vi.mock('../searchIndex', () => ({ + useSearchIndexStore: () => mocks.searchIndexStore })); vi.mock('../user', () => ({ useUserStore: () => mocks.userStore @@ -67,10 +55,30 @@ import { useGlobalSearchStore } from '../globalSearch'; function setupStores() { mocks.friendStore = reactive({ friends: new Map() }); - mocks.favoriteStore = reactive({ favoriteAvatars: [], favoriteWorlds: [] }); - mocks.avatarStore = reactive({ cachedAvatars: new Map() }); - mocks.worldStore = reactive({ cachedWorlds: new Map() }); - mocks.groupStore = reactive({ currentUserGroups: new Map() }); + mocks.searchIndexStore = reactive({ + version: 0, + getSnapshot() { + const friendsList = []; + for (const ctx of (mocks.friendStore?.friends || new Map()).values()) { + if (typeof ctx.ref === 'undefined') continue; + friendsList.push({ + id: ctx.id, + name: ctx.name, + memo: ctx.memo || '', + note: ctx.ref?.note || '', + imageUrl: ctx.ref?.currentAvatarThumbnailImageUrl || '' + }); + } + return { + friends: friendsList, + avatars: [], + worlds: [], + groups: [], + favAvatars: [], + favWorlds: [] + }; + } + }); mocks.userStore = reactive({ currentUser: { id: 'usr_me' } }); } @@ -181,4 +189,37 @@ describe('useGlobalSearchStore', () => { expect(lastMessage.type).toBe('search'); expect(lastMessage.payload.currentUserId).toBe('usr_other'); }); + + test('re-dispatches search after index update when query is active', async () => { + vi.useFakeTimers(); + const store = useGlobalSearchStore(); + store.isOpen = true; + await nextTick(); + + store.setQuery('ab'); + await nextTick(); + + const worker = mocks.workerInstances[0]; + const callsBefore = worker.postMessage.mock.calls.length; + + // Simulate searchIndex version bump (as if data arrived) + mocks.searchIndexStore.version++; + await nextTick(); + + // Fast-forward the 200ms debounce + vi.advanceTimersByTime(200); + await nextTick(); + + const newCalls = worker.postMessage.mock.calls.slice(callsBefore); + const types = newCalls.map((c) => c[0].type); + expect(types).toContain('updateIndex'); + expect(types).toContain('search'); + + // updateIndex should come before search + const updateIdx = types.indexOf('updateIndex'); + const searchIdx = types.lastIndexOf('search'); + expect(updateIdx).toBeLessThan(searchIdx); + + vi.useRealTimers(); + }); }); diff --git a/src/stores/friend.js b/src/stores/friend.js index c812c1f9..4e40c9b0 100644 --- a/src/stores/friend.js +++ b/src/stores/friend.js @@ -17,6 +17,7 @@ import { runPendingOfflineTickFlow, runUpdateFriendFlow } from '../coordinators/friendPresenceCoordinator'; +import { syncFriendSearchIndex } from '../coordinators/searchIndexCoordinator'; import { updateFriendship, runUpdateFriendshipsFlow @@ -340,19 +341,24 @@ export const useFriendStore = defineStore('Friend', () => { for (id of ref.onlineFriends) { map.set(id, 'online'); } + const added = []; + const removed = []; for (const friend of map) { const [id, state_input] = friend; if (friends.has(id)) { runUpdateFriendFlow(id, state_input); } else { addFriend(id, state_input); + added.push(id); } } for (id of friends.keys()) { if (map.has(id) === false) { deleteFriend(id); + removed.push(id); } } + return { added, removed }; } /** @@ -389,6 +395,7 @@ export const useFriendStore = defineStore('Friend', () => { const array = memo.memo.split('\n'); ctx.$nickName = array[0]; } + syncFriendSearchIndex(ctx); } }); } diff --git a/src/stores/globalSearch.js b/src/stores/globalSearch.js index 158ea264..487929b9 100644 --- a/src/stores/globalSearch.js +++ b/src/stores/globalSearch.js @@ -1,11 +1,8 @@ import { computed, effectScope, ref, watch } from 'vue'; import { defineStore } from 'pinia'; -import { useAvatarStore } from './avatar'; -import { useFavoriteStore } from './favorite'; import { useFriendStore } from './friend'; -import { useGroupStore } from './group'; +import { useSearchIndexStore } from './searchIndex'; import { useUserStore } from './user'; -import { useWorldStore } from './world'; import { showGroupDialog } from '../coordinators/groupCoordinator'; import { showWorldDialog } from '../coordinators/worldCoordinator'; import { showAvatarDialog } from '../coordinators/avatarCoordinator'; @@ -15,11 +12,8 @@ import SearchWorker from './searchWorker.js?worker'; export const useGlobalSearchStore = defineStore('GlobalSearch', () => { const friendStore = useFriendStore(); - const favoriteStore = useFavoriteStore(); - const avatarStore = useAvatarStore(); - const worldStore = useWorldStore(); - const groupStore = useGroupStore(); const userStore = useUserStore(); + const searchIndexStore = useSearchIndexStore(); const isOpen = ref(false); const query = ref(''); @@ -73,81 +67,16 @@ export const useGlobalSearchStore = defineStore('GlobalSearch', () => { indexUpdateTimer = null; if (!isOpen.value) return; sendIndexUpdate(); + if (query.value && query.value.length >= 2) { + dispatchSearch(); + } }, 200); } function sendIndexUpdate() { const w = getWorker(); - - const friends = []; - for (const ctx of friendStore.friends.values()) { - if (typeof ctx.ref === 'undefined') continue; - friends.push({ - id: ctx.id, - name: ctx.name, - memo: ctx.memo || '', - note: ctx.ref.note || '', - imageUrl: ctx.ref.currentAvatarThumbnailImageUrl - }); - } - - const avatars = []; - for (const ref of avatarStore.cachedAvatars.values()) { - if (!ref || !ref.name) continue; - avatars.push({ - id: ref.id, - name: ref.name, - authorId: ref.authorId, - imageUrl: ref.thumbnailImageUrl || ref.imageUrl - }); - } - - const worlds = []; - for (const ref of worldStore.cachedWorlds.values()) { - if (!ref || !ref.name) continue; - worlds.push({ - id: ref.id, - name: ref.name, - authorId: ref.authorId, - imageUrl: ref.thumbnailImageUrl || ref.imageUrl - }); - } - - const groups = []; - for (const ref of groupStore.currentUserGroups.values()) { - if (!ref || !ref.name) continue; - groups.push({ - id: ref.id, - name: ref.name, - ownerId: ref.ownerId, - imageUrl: ref.iconUrl || ref.bannerUrl - }); - } - - const favAvatars = []; - for (const ctx of favoriteStore.favoriteAvatars) { - if (!ctx?.ref?.name) continue; - favAvatars.push({ - id: ctx.ref.id, - name: ctx.ref.name, - imageUrl: ctx.ref.thumbnailImageUrl || ctx.ref.imageUrl - }); - } - - const favWorlds = []; - for (const ctx of favoriteStore.favoriteWorlds) { - if (!ctx?.ref?.name) continue; - favWorlds.push({ - id: ctx.ref.id, - name: ctx.ref.name, - imageUrl: ctx.ref.thumbnailImageUrl || ctx.ref.imageUrl - }); - } - - w.postMessage({ - type: 'updateIndex', - payload: { friends, avatars, worlds, groups, favAvatars, favWorlds } - }); + const payload = searchIndexStore.getSnapshot(); + w.postMessage({ type: 'updateIndex', payload }); } function stopIndexWatchers() { @@ -167,39 +96,8 @@ export const useGlobalSearchStore = defineStore('GlobalSearch', () => { indexWatchScope = effectScope(); indexWatchScope.run(() => { watch( - () => friendStore.friends, - () => scheduleIndexUpdate(), - { deep: true } - ); - - watch( - () => avatarStore.cachedAvatars, - () => scheduleIndexUpdate(), - { deep: true } - ); - - watch( - () => worldStore.cachedWorlds, - () => scheduleIndexUpdate(), - { deep: true } - ); - - watch( - () => groupStore.currentUserGroups, - () => scheduleIndexUpdate(), - { deep: true } - ); - - watch( - () => favoriteStore.favoriteAvatars, - () => scheduleIndexUpdate(), - { deep: true } - ); - - watch( - () => favoriteStore.favoriteWorlds, - () => scheduleIndexUpdate(), - { deep: true } + () => searchIndexStore.version, + () => scheduleIndexUpdate() ); }); } diff --git a/src/stores/searchIndex.js b/src/stores/searchIndex.js new file mode 100644 index 00000000..fb8df175 --- /dev/null +++ b/src/stores/searchIndex.js @@ -0,0 +1,294 @@ +import { ref } from 'vue'; +import { defineStore } from 'pinia'; +import { useFavoriteStore } from './favorite'; + +export const useSearchIndexStore = defineStore('SearchIndex', () => { + const friends = new Map(); + const avatars = new Map(); + const worlds = new Map(); + const groups = new Map(); + const favAvatars = new Map(); + const favWorlds = new Map(); + + const version = ref(0); + + + /** + * Sync a friend context into the search index. + * Extracts only the fields needed for searching. + * @param {object} ctx - Friend context from friendStore.friends + */ + function syncFriend(ctx) { + if (!ctx || !ctx.id) return; + const entry = { + id: ctx.id, + name: ctx.name || '', + memo: ctx.memo || '', + note: ctx.ref?.note || '', + imageUrl: ctx.ref?.currentAvatarThumbnailImageUrl || '' + }; + const existing = friends.get(ctx.id); + if ( + existing && + existing.name === entry.name && + existing.memo === entry.memo && + existing.note === entry.note && + existing.imageUrl === entry.imageUrl + ) { + return; + } + friends.set(ctx.id, entry); + version.value++; + } + + /** + * @param {string} id + */ + function removeFriend(id) { + if (friends.delete(id)) { + version.value++; + } + } + + function clearFriends() { + if (friends.size > 0) { + friends.clear(); + version.value++; + } + } + + + /** + * @param {object} ref - Avatar data object + */ + function upsertAvatar(ref) { + if (!ref || !ref.id) return; + const entry = { + id: ref.id, + name: ref.name || '', + authorId: ref.authorId || '', + imageUrl: ref.thumbnailImageUrl || ref.imageUrl || '' + }; + const existing = avatars.get(ref.id); + if ( + existing && + existing.name === entry.name && + existing.authorId === entry.authorId && + existing.imageUrl === entry.imageUrl + ) { + return; + } + avatars.set(ref.id, entry); + version.value++; + } + + /** + * @param {string} id + */ + function removeAvatar(id) { + if (avatars.delete(id)) { + version.value++; + } + } + + function clearAvatars() { + if (avatars.size > 0) { + avatars.clear(); + version.value++; + } + } + + + /** + * @param {object} ref - World data object + */ + function upsertWorld(ref) { + if (!ref || !ref.id) return; + const entry = { + id: ref.id, + name: ref.name || '', + authorId: ref.authorId || '', + imageUrl: ref.thumbnailImageUrl || ref.imageUrl || '' + }; + const existing = worlds.get(ref.id); + if ( + existing && + existing.name === entry.name && + existing.authorId === entry.authorId && + existing.imageUrl === entry.imageUrl + ) { + return; + } + worlds.set(ref.id, entry); + version.value++; + } + + /** + * @param {string} id + */ + function removeWorld(id) { + if (worlds.delete(id)) { + version.value++; + } + } + + function clearWorlds() { + if (worlds.size > 0) { + worlds.clear(); + version.value++; + } + } + + + /** + * @param {object} ref - Group data object + */ + function upsertGroup(ref) { + if (!ref || !ref.id) return; + const entry = { + id: ref.id, + name: ref.name || '', + ownerId: ref.ownerId || '', + imageUrl: ref.iconUrl || ref.bannerUrl || '' + }; + const existing = groups.get(ref.id); + if ( + existing && + existing.name === entry.name && + existing.ownerId === entry.ownerId && + existing.imageUrl === entry.imageUrl + ) { + return; + } + groups.set(ref.id, entry); + version.value++; + } + + /** + * @param {string} id + */ + function removeGroup(id) { + if (groups.delete(id)) { + version.value++; + } + } + + function clearGroups() { + if (groups.size > 0) { + groups.clear(); + version.value++; + } + } + + + function rebuildFavoritesFromStore() { + const favoriteStore = useFavoriteStore(); + + const newFavAvatars = new Map(); + for (const ctx of favoriteStore.favoriteAvatars) { + if (!ctx?.ref?.name) continue; + newFavAvatars.set(ctx.ref.id, { + id: ctx.ref.id, + name: ctx.ref.name, + imageUrl: ctx.ref.thumbnailImageUrl || ctx.ref.imageUrl || '' + }); + } + + const newFavWorlds = new Map(); + for (const ctx of favoriteStore.favoriteWorlds) { + if (!ctx?.ref?.name) continue; + newFavWorlds.set(ctx.ref.id, { + id: ctx.ref.id, + name: ctx.ref.name, + imageUrl: ctx.ref.thumbnailImageUrl || ctx.ref.imageUrl || '' + }); + } + + let changed = false; + if (favAvatars.size !== newFavAvatars.size) { + changed = true; + } else { + for (const [id, entry] of newFavAvatars) { + const existing = favAvatars.get(id); + if (!existing || existing.name !== entry.name || existing.imageUrl !== entry.imageUrl) { + changed = true; + break; + } + } + } + if (favWorlds.size !== newFavWorlds.size) { + changed = true; + } else if (!changed) { + for (const [id, entry] of newFavWorlds) { + const existing = favWorlds.get(id); + if (!existing || existing.name !== entry.name || existing.imageUrl !== entry.imageUrl) { + changed = true; + break; + } + } + } + + if (changed) { + favAvatars.clear(); + for (const [id, entry] of newFavAvatars) { + favAvatars.set(id, entry); + } + favWorlds.clear(); + for (const [id, entry] of newFavWorlds) { + favWorlds.set(id, entry); + } + version.value++; + } + } + + function clearFavorites() { + if (favAvatars.size > 0 || favWorlds.size > 0) { + favAvatars.clear(); + favWorlds.clear(); + version.value++; + } + } + + + /** + * Build a snapshot from the internal index maps. + * Used by globalSearch to send data to the Worker. + * @returns {object} Plain object arrays ready for postMessage. + */ + function getSnapshot() { + return { + friends: Array.from(friends.values()), + avatars: Array.from(avatars.values()), + worlds: Array.from(worlds.values()), + groups: Array.from(groups.values()), + favAvatars: Array.from(favAvatars.values()), + favWorlds: Array.from(favWorlds.values()) + }; + } + + + + return { + version, + + syncFriend, + removeFriend, + clearFriends, + + upsertAvatar, + removeAvatar, + clearAvatars, + + upsertWorld, + removeWorld, + clearWorlds, + + upsertGroup, + removeGroup, + clearGroups, + rebuildFavoritesFromStore, + clearFavorites, + + getSnapshot + }; +}); diff --git a/src/stores/searchWorker.js b/src/stores/searchWorker.js index d69146e4..57bb0bbe 100644 --- a/src/stores/searchWorker.js +++ b/src/stores/searchWorker.js @@ -88,7 +88,6 @@ function removeWhitespace(a) { return a.replace(/\s/g, ''); } -// ── Locale-aware string search ────────────────────────────────────── function localeIncludes(str, search, comparer) { if (search === '') return true; @@ -104,25 +103,20 @@ function localeIncludes(str, search, comparer) { return false; } -function matchName(name, query, comparer) { - if (!name || !query) return false; - const cleanQuery = removeWhitespace(query); - if (!cleanQuery) return false; - const cleanName = removeConfusables(name); +function matchName(name, cleanQuery, comparer, normalizedName) { + if (!name || !cleanQuery) return false; + const cleanName = normalizedName || removeConfusables(name); if (localeIncludes(cleanName, cleanQuery, comparer)) return true; return localeIncludes(name, cleanQuery, comparer); } -function isPrefixMatch(name, query, comparer) { - if (!name || !query) return false; - const cleanQuery = removeWhitespace(query); - if (!cleanQuery) return false; +function isPrefixMatch(name, cleanQuery, comparer) { + if (!name || !cleanQuery) return false; return ( comparer.compare(name.substring(0, cleanQuery.length), cleanQuery) === 0 ); } -// ── Index data (updated from main thread) ─────────────────────────── let indexedFriends = []; // { id, name, memo, note, imageUrl } let indexedAvatars = []; // { id, name, authorId, imageUrl } @@ -133,22 +127,54 @@ let indexedFavWorlds = []; // { id, name, imageUrl } /** * Update the search index with fresh data snapshots. + * Pre-computes normalized names to avoid per-search confusables overhead. + * @param payload */ function updateIndex(payload) { - if (payload.friends) indexedFriends = payload.friends; - if (payload.avatars) indexedAvatars = payload.avatars; - if (payload.worlds) indexedWorlds = payload.worlds; - if (payload.groups) indexedGroups = payload.groups; - if (payload.favAvatars) indexedFavAvatars = payload.favAvatars; - if (payload.favWorlds) indexedFavWorlds = payload.favWorlds; + if (payload.friends) { + indexedFriends = payload.friends; + for (const f of indexedFriends) { + f._normalized = f.name ? removeConfusables(f.name) : ''; + } + } + if (payload.avatars) { + indexedAvatars = payload.avatars; + for (const a of indexedAvatars) { + a._normalized = a.name ? removeConfusables(a.name) : ''; + } + } + if (payload.worlds) { + indexedWorlds = payload.worlds; + for (const w of indexedWorlds) { + w._normalized = w.name ? removeConfusables(w.name) : ''; + } + } + if (payload.groups) { + indexedGroups = payload.groups; + for (const g of indexedGroups) { + g._normalized = g.name ? removeConfusables(g.name) : ''; + } + } + if (payload.favAvatars) { + indexedFavAvatars = payload.favAvatars; + for (const a of indexedFavAvatars) { + a._normalized = a.name ? removeConfusables(a.name) : ''; + } + } + if (payload.favWorlds) { + indexedFavWorlds = payload.favWorlds; + for (const w of indexedFavWorlds) { + w._normalized = w.name ? removeConfusables(w.name) : ''; + } + } } // ── Search functions ──────────────────────────────────────────────── -function searchFriends(query, comparer, limit = 10) { +function searchFriends(query, cleanQuery, comparer, limit = 10) { const results = []; for (const ctx of indexedFriends) { - let match = matchName(ctx.name, query, comparer); + let match = matchName(ctx.name, cleanQuery, comparer, ctx._normalized); let matchedField = match ? 'name' : null; if (!match && ctx.memo) { match = localeIncludes(ctx.memo, query, comparer); @@ -170,19 +196,25 @@ function searchFriends(query, comparer, limit = 10) { }); } } + // Pre-compute prefix flags to avoid repeated Collator calls in sort + for (const r of results) { + r._isPrefix = isPrefixMatch(r.name, cleanQuery, comparer); + } results.sort((a, b) => { - const aPrefix = isPrefixMatch(a.name, query, comparer); - const bPrefix = isPrefixMatch(b.name, query, comparer); - if (aPrefix && !bPrefix) return -1; - if (bPrefix && !aPrefix) return 1; + if (a._isPrefix && !b._isPrefix) return -1; + if (b._isPrefix && !a._isPrefix) return 1; return comparer.compare(a.name, b.name); }); if (results.length > limit) results.length = limit; + // Clean up internal sort field before returning + for (const r of results) { + delete r._isPrefix; + } return results; } function searchItems( - query, + cleanQuery, items, type, comparer, @@ -194,7 +226,7 @@ function searchItems( for (const ref of items) { if (!ref || !ref.name) continue; if (ownerId && ref[ownerKey] !== ownerId) continue; - if (matchName(ref.name, query, comparer)) { + if (matchName(ref.name, cleanQuery, comparer, ref._normalized)) { results.push({ id: ref.id, name: ref.name, @@ -203,14 +235,20 @@ function searchItems( }); } } + // Pre-compute prefix flags to avoid repeated Collator calls in sort + for (const r of results) { + r._isPrefix = isPrefixMatch(r.name, cleanQuery, comparer); + } results.sort((a, b) => { - const aPrefix = isPrefixMatch(a.name, query, comparer); - const bPrefix = isPrefixMatch(b.name, query, comparer); - if (aPrefix && !bPrefix) return -1; - if (bPrefix && !aPrefix) return 1; + if (a._isPrefix && !b._isPrefix) return -1; + if (b._isPrefix && !a._isPrefix) return 1; return comparer.compare(a.name, b.name); }); if (results.length > limit) results.length = limit; + // Clean up internal sort field before returning + for (const r of results) { + delete r._isPrefix; + } return results; } @@ -239,9 +277,12 @@ function handleSearch(payload) { sensitivity: 'base' }); - const friends = searchFriends(query, comparer); + // Pre-compute cleaned query once for all name searches + const cleanQuery = removeWhitespace(query); + + const friends = searchFriends(query, cleanQuery, comparer); const ownAvatars = searchItems( - query, + cleanQuery, indexedAvatars, 'avatar', comparer, @@ -249,7 +290,7 @@ function handleSearch(payload) { currentUserId ); const favAvatars = searchItems( - query, + cleanQuery, indexedFavAvatars, 'avatar', comparer, @@ -257,7 +298,7 @@ function handleSearch(payload) { null ); const ownWorlds = searchItems( - query, + cleanQuery, indexedWorlds, 'world', comparer, @@ -265,7 +306,7 @@ function handleSearch(payload) { currentUserId ); const favWorlds = searchItems( - query, + cleanQuery, indexedFavWorlds, 'world', comparer, @@ -273,7 +314,7 @@ function handleSearch(payload) { null ); const ownGroups = searchItems( - query, + cleanQuery, indexedGroups, 'group', comparer, @@ -281,7 +322,7 @@ function handleSearch(payload) { currentUserId ); const joinedGroups = searchItems( - query, + cleanQuery, indexedGroups, 'group', comparer, @@ -314,7 +355,6 @@ function handleSearch(payload) { }); } -// ── Message handler ───────────────────────────────────────────────── self.addEventListener('message', (event) => { const { type, payload } = event.data; diff --git a/src/stores/user.js b/src/stores/user.js index 7d3495a1..28507399 100644 --- a/src/stores/user.js +++ b/src/stores/user.js @@ -21,6 +21,7 @@ import { useAppearanceSettingsStore } from './settings/appearance'; import { useFriendStore } from './friend'; import { useInstanceStore } from './instance'; import { useLocationStore } from './location'; +import { syncFriendSearchIndex } from '../coordinators/searchIndexCoordinator'; import { useUiStore } from './ui'; import { watchState } from '../services/watchState'; @@ -499,6 +500,10 @@ export const useUserStore = defineStore('User', () => { const user = users.get(note.userId); if (user) { user.note = note.note; + const friendCtx = friendStore.friends.get(note.userId); + if (friendCtx) { + syncFriendSearchIndex(friendCtx); + } } if ( !state.lastDbNoteDate || @@ -568,6 +573,10 @@ export const useUserStore = defineStore('User', () => { const user = users.get(note.targetUserId); if (user) { user.note = note.note; + const friendCtx = friendStore.friends.get(note.targetUserId); + if (friendCtx) { + syncFriendSearchIndex(friendCtx); + } } } } diff --git a/src/stores/vrcx.js b/src/stores/vrcx.js index 363cdd07..f4526759 100644 --- a/src/stores/vrcx.js +++ b/src/stores/vrcx.js @@ -44,6 +44,7 @@ import { useUpdateLoopStore } from './updateLoop'; import { useUserStore } from './user'; import { useVrcStatusStore } from './vrcStatus'; import { clearVRCXCache } from '../coordinators/vrcxCoordinator'; +import { resetSearchIndexOnLogin } from '../coordinators/searchIndexCoordinator'; import { watchState } from '../services/watchState'; import configRepository from '../services/config'; @@ -177,6 +178,7 @@ export const useVrcxStore = defineStore('Vrcx', () => { refreshCustomScript(); } + resetSearchIndexOnLogin(); init(); /** diff --git a/src/views/Tools/dialogs/ExportAvatarsListDialog.vue b/src/views/Tools/dialogs/ExportAvatarsListDialog.vue index 9ce03e1e..f51d38c5 100644 --- a/src/views/Tools/dialogs/ExportAvatarsListDialog.vue +++ b/src/views/Tools/dialogs/ExportAvatarsListDialog.vue @@ -22,6 +22,7 @@ import { useI18n } from 'vue-i18n'; import { useAvatarStore, useUserStore } from '../../../stores'; + import { removeAvatarFromCache } from '../../../coordinators/avatarCoordinator'; import { avatarRequest } from '../../../api'; import { processBulk } from '../../../services/request'; @@ -64,7 +65,7 @@ loading.value = true; for (const ref of cachedAvatars.values()) { if (ref.authorId === currentUser.value.id) { - cachedAvatars.delete(ref.id); + removeAvatarFromCache(ref.id); } } const params = {