diff --git a/src/components/GlobalSearchDialog.vue b/src/components/GlobalSearchDialog.vue new file mode 100644 index 00000000..7c4dc64a --- /dev/null +++ b/src/components/GlobalSearchDialog.vue @@ -0,0 +1,266 @@ + + + + + diff --git a/src/components/GlobalSearchSync.vue b/src/components/GlobalSearchSync.vue new file mode 100644 index 00000000..d466fc6f --- /dev/null +++ b/src/components/GlobalSearchSync.vue @@ -0,0 +1,36 @@ + diff --git a/src/components/ui/kbd/Kbd.vue b/src/components/ui/kbd/Kbd.vue new file mode 100644 index 00000000..3d2a716f --- /dev/null +++ b/src/components/ui/kbd/Kbd.vue @@ -0,0 +1,25 @@ + + + diff --git a/src/components/ui/kbd/KbdGroup.vue b/src/components/ui/kbd/KbdGroup.vue new file mode 100644 index 00000000..26637e75 --- /dev/null +++ b/src/components/ui/kbd/KbdGroup.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/kbd/index.js b/src/components/ui/kbd/index.js new file mode 100644 index 00000000..caf257ac --- /dev/null +++ b/src/components/ui/kbd/index.js @@ -0,0 +1,2 @@ +export { default as Kbd } from './Kbd.vue'; +export { default as KbdGroup } from './KbdGroup.vue'; diff --git a/src/localization/en.json b/src/localization/en.json index 81d1e69f..dff4cc2a 100644 --- a/src/localization/en.json +++ b/src/localization/en.json @@ -76,7 +76,23 @@ } }, "side_panel": { - "search_placeholder": "Search Friend", + "search_placeholder": "Search...", + "search_no_results": "No results found", + "search_min_chars": "Type at least 2 characters", + "search_categories": "Search for...", + "search_scope_all": "Name, memo & note", + "search_scope_own": "Own & favorites", + "search_scope_joined": "Own & joined", + "search_friends": "Friends", + "search_avatars": "Avatars", + "search_own_avatars": "Own Avatars", + "search_fav_avatars": "Favorite Avatars", + "search_worlds": "Worlds", + "search_own_worlds": "Own Worlds", + "search_fav_worlds": "Favorite Worlds", + "search_groups": "Groups", + "search_own_groups": "Own Groups", + "search_joined_groups": "Joined Groups", "search_result_active": "Offline", "search_result_offline": "Active", "search_result_more": "Search More:", diff --git a/src/shared/utils/__tests__/globalSearchUtils.test.js b/src/shared/utils/__tests__/globalSearchUtils.test.js new file mode 100644 index 00000000..330cb1f7 --- /dev/null +++ b/src/shared/utils/__tests__/globalSearchUtils.test.js @@ -0,0 +1,352 @@ +import { + isPrefixMatch, + matchName, + searchAvatars, + searchFavoriteAvatars, + searchFavoriteWorlds, + searchFriends, + searchGroups, + searchWorlds +} from '../globalSearchUtils'; + +const comparer = new Intl.Collator(undefined, { + usage: 'search', + sensitivity: 'base' +}); + +// ── matchName ────────────────────────────────────────────── + +describe('matchName', () => { + test('matches substring', () => { + expect(matchName('HelloWorld', 'llo', comparer)).toBe(true); + }); + + test('case-insensitive', () => { + expect(matchName('Alice', 'alice', comparer)).toBe(true); + }); + + test('strips whitespace from query', () => { + expect(matchName('Alice', 'al ice', comparer)).toBe(true); + }); + + test('returns false for empty inputs', () => { + expect(matchName('', 'query', comparer)).toBe(false); + expect(matchName('name', '', comparer)).toBe(false); + expect(matchName(null, 'query', comparer)).toBe(false); + }); + + test('returns false for whitespace-only query', () => { + expect(matchName('Alice', ' ', comparer)).toBe(false); + }); + + test('no match', () => { + expect(matchName('Alice', 'bob', comparer)).toBe(false); + }); +}); + +// ── isPrefixMatch ────────────────────────────────────────── + +describe('isPrefixMatch', () => { + test('detects prefix', () => { + expect(isPrefixMatch('Alice', 'ali', comparer)).toBe(true); + }); + + test('rejects non-prefix substring', () => { + expect(isPrefixMatch('Alice', 'ice', comparer)).toBe(false); + }); + + test('returns false for empty inputs', () => { + expect(isPrefixMatch('', 'a', comparer)).toBe(false); + expect(isPrefixMatch('Alice', '', comparer)).toBe(false); + }); +}); + +// ── searchFriends ────────────────────────────────────────── + +describe('searchFriends', () => { + /** + * + * @param id + * @param name + * @param memo + * @param note + */ + function makeFriend(id, name, memo = '', note = '') { + return [ + id, + { + id, + name, + memo, + ref: { + currentAvatarThumbnailImageUrl: `img_${id}`, + note, + $userColour: '#fff' + } + } + ]; + } + + const friends = new Map([ + makeFriend('u1', 'Alice'), + makeFriend('u2', 'Bob', '同事', ''), + makeFriend('u3', 'Charlie', '', 'roommate'), + makeFriend('u4', 'Dave') + ]); + + test('matches by name', () => { + const results = searchFriends('alice', friends, comparer); + expect(results).toHaveLength(1); + expect(results[0].id).toBe('u1'); + expect(results[0].matchedField).toBe('name'); + }); + + test('matches by memo', () => { + const results = searchFriends('同事', friends, comparer); + expect(results).toHaveLength(1); + expect(results[0].id).toBe('u2'); + expect(results[0].matchedField).toBe('memo'); + expect(results[0].memo).toBe('同事'); + }); + + test('matches by note', () => { + const results = searchFriends('roommate', friends, comparer); + expect(results).toHaveLength(1); + expect(results[0].id).toBe('u3'); + expect(results[0].matchedField).toBe('note'); + expect(results[0].note).toBe('roommate'); + }); + + test('returns empty for short / empty query', () => { + expect(searchFriends('', friends, comparer)).toEqual([]); + expect(searchFriends(null, friends, comparer)).toEqual([]); + }); + + test('respects limit', () => { + const many = new Map( + Array.from({ length: 20 }, (_, i) => + makeFriend(`u${i}`, `Test${i}`) + ) + ); + expect(searchFriends('Test', many, comparer, 5)).toHaveLength(5); + }); + + test('prefix matches sort first', () => { + const f = new Map([ + makeFriend('u1', 'XAliceX'), + makeFriend('u2', 'Alice') + ]); + const results = searchFriends('Alice', f, comparer); + expect(results[0].id).toBe('u2'); // prefix match first + }); + + test('skips entries without ref', () => { + const broken = new Map([['u1', { id: 'u1', name: 'Test' }]]); + expect(searchFriends('Test', broken, comparer)).toEqual([]); + }); +}); + +// ── searchAvatars ────────────────────────────────────────── + +describe('searchAvatars', () => { + const avatarMap = new Map([ + [ + 'a1', + { + id: 'a1', + name: 'Cool Avatar', + authorId: 'me', + thumbnailImageUrl: 'img1' + } + ], + [ + 'a2', + { + id: 'a2', + name: 'Nice Avatar', + authorId: 'other', + thumbnailImageUrl: 'img2' + } + ], + [ + 'a3', + { + id: 'a3', + name: 'Cool Suit', + authorId: 'me', + thumbnailImageUrl: 'img3' + } + ] + ]); + + test('finds matching avatars', () => { + const results = searchAvatars('Cool', avatarMap, comparer); + expect(results).toHaveLength(2); + }); + + test('filters by authorId', () => { + const results = searchAvatars('Avatar', avatarMap, comparer, 'me'); + expect(results).toHaveLength(1); + expect(results[0].id).toBe('a1'); + }); + + test('returns all when authorId is null', () => { + const results = searchAvatars('Avatar', avatarMap, comparer, null); + expect(results).toHaveLength(2); + }); + + test('returns empty for null map', () => { + expect(searchAvatars('test', null, comparer)).toEqual([]); + }); +}); + +// ── searchWorlds ─────────────────────────────────────────── + +describe('searchWorlds', () => { + const worldMap = new Map([ + [ + 'w1', + { + id: 'w1', + name: 'Fun World', + authorId: 'me', + thumbnailImageUrl: 'img1' + } + ], + [ + 'w2', + { + id: 'w2', + name: 'Fun Park', + authorId: 'other', + thumbnailImageUrl: 'img2' + } + ] + ]); + + test('finds matching worlds', () => { + const results = searchWorlds('Fun', worldMap, comparer); + expect(results).toHaveLength(2); + }); + + test('filters by ownerId (authorId)', () => { + const results = searchWorlds('Fun', worldMap, comparer, 'me'); + expect(results).toHaveLength(1); + expect(results[0].id).toBe('w1'); + }); +}); + +// ── searchGroups ─────────────────────────────────────────── + +describe('searchGroups', () => { + const groupMap = new Map([ + [ + 'g1', + { + id: 'g1', + name: 'My Group', + ownerId: 'me', + iconUrl: 'icon1' + } + ], + [ + 'g2', + { + id: 'g2', + name: 'Other Group', + ownerId: 'other', + iconUrl: 'icon2' + } + ], + [ + 'g3', + { + id: 'g3', + name: 'Another My Group', + ownerId: 'me', + iconUrl: 'icon3' + } + ] + ]); + + test('finds all matching groups', () => { + const results = searchGroups('Group', groupMap, comparer); + expect(results).toHaveLength(3); + }); + + test('filters by ownerId', () => { + const results = searchGroups('Group', groupMap, comparer, 'me'); + expect(results).toHaveLength(2); + expect(results.every((r) => r.id !== 'g2')).toBe(true); + }); + + test('returns all when ownerId is null', () => { + const results = searchGroups('Group', groupMap, comparer, null); + expect(results).toHaveLength(3); + }); +}); + +// ── searchFavoriteAvatars ────────────────────────────────── + +describe('searchFavoriteAvatars', () => { + const favorites = [ + { + name: 'Fav Avatar', + ref: { id: 'fa1', name: 'Fav Avatar', thumbnailImageUrl: 'img1' } + }, + { + name: 'Cool Fav', + ref: { id: 'fa2', name: 'Cool Fav', thumbnailImageUrl: 'img2' } + }, + { name: 'Broken', ref: null } + ]; + + test('finds matching favorite avatars', () => { + const results = searchFavoriteAvatars('Fav', favorites, comparer); + expect(results).toHaveLength(2); + expect(results.map((r) => r.id)).toContain('fa1'); + }); + + test('skips entries with null ref', () => { + const results = searchFavoriteAvatars('Broken', favorites, comparer); + expect(results).toHaveLength(0); + }); + + test('returns empty for null input', () => { + expect(searchFavoriteAvatars('test', null, comparer)).toEqual([]); + }); +}); + +// ── searchFavoriteWorlds ─────────────────────────────────── + +describe('searchFavoriteWorlds', () => { + const favorites = [ + { + name: 'Fav World', + ref: { + id: 'fw1', + name: 'Fav World', + thumbnailImageUrl: 'img1' + } + }, + { + name: 'Cool Place', + ref: { + id: 'fw2', + name: 'Cool Place', + thumbnailImageUrl: 'img2' + } + } + ]; + + test('finds matching favorite worlds', () => { + const results = searchFavoriteWorlds('Cool', favorites, comparer); + expect(results).toHaveLength(1); + expect(results[0].id).toBe('fw2'); + expect(results[0].type).toBe('world'); + }); + + test('returns empty for empty query', () => { + expect(searchFavoriteWorlds('', favorites, comparer)).toEqual([]); + }); +}); diff --git a/src/shared/utils/globalSearchUtils.js b/src/shared/utils/globalSearchUtils.js new file mode 100644 index 00000000..e521680f --- /dev/null +++ b/src/shared/utils/globalSearchUtils.js @@ -0,0 +1,336 @@ +import { localeIncludes } from './base/string'; +import removeConfusables, { removeWhitespace } from '../../service/confusables'; + +/** + * Tests whether a name matches a query using locale-aware comparison. + * Handles confusable-character normalization and whitespace stripping. + * @param {string} name - The display name to test + * @param {string} query - The raw user query (may contain whitespace) + * @param {Intl.Collator} comparer - Locale collator for comparison + * @returns {boolean} + */ +export function matchName(name, query, comparer) { + if (!name || !query) { + return false; + } + const cleanQuery = removeWhitespace(query); + if (!cleanQuery) { + return false; + } + const cleanName = removeConfusables(name); + if (localeIncludes(cleanName, cleanQuery, comparer)) { + return true; + } + // Also check raw name for users searching with special characters + return localeIncludes(name, cleanQuery, comparer); +} + +/** + * Check whether a query starts the name (for prioritizing prefix matches). + * @param {string} name + * @param {string} query + * @param {Intl.Collator} comparer + * @returns {boolean} + */ +export function isPrefixMatch(name, query, comparer) { + if (!name || !query) { + return false; + } + const cleanQuery = removeWhitespace(query); + if (!cleanQuery) { + return false; + } + return ( + comparer.compare(name.substring(0, cleanQuery.length), cleanQuery) === 0 + ); +} + +/** + * Search friends from the friends Map. + * @param {string} query + * @param {Map} friends - friendStore.friends Map + * @param {Intl.Collator} comparer + * @param {number} [limit] + * @returns {Array<{id: string, name: string, type: string, imageUrl: string, ref: object}>} + */ +export function searchFriends(query, friends, comparer, limit = 10) { + if (!query || !friends) { + return []; + } + const results = []; + for (const ctx of friends.values()) { + if (typeof ctx.ref === 'undefined') { + continue; + } + let match = matchName(ctx.name, query, comparer); + let matchedField = match ? 'name' : null; + // Include memo and note matching for friends (with raw query for spaces) + if (!match && ctx.memo) { + match = localeIncludes(ctx.memo, query, comparer); + if (match) matchedField = 'memo'; + } + if (!match && ctx.ref.note) { + match = localeIncludes(ctx.ref.note, query, comparer); + if (match) matchedField = 'note'; + } + if (match) { + results.push({ + id: ctx.id, + name: ctx.name, + type: 'friend', + imageUrl: ctx.ref.currentAvatarThumbnailImageUrl, + memo: ctx.memo || '', + note: ctx.ref.note || '', + matchedField, + ref: ctx.ref + }); + } + } + // Sort: prefix matches first, then alphabetically + 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; + return comparer.compare(a.name, b.name); + }); + if (results.length > limit) { + results.length = limit; + } + return results; +} + +/** + * Search avatars from a Map (cachedAvatars or favorite avatars). + * @param {string} query + * @param {Map} avatarMap + * @param {Intl.Collator} comparer + * @param {string|null} [authorId] - If provided, only match avatars by this author + * @param {number} [limit] + * @returns {Array<{id: string, name: string, type: string, imageUrl: string}>} + */ +export function searchAvatars( + query, + avatarMap, + comparer, + authorId = null, + limit = 10 +) { + if (!query || !avatarMap) { + return []; + } + const results = []; + for (const ref of avatarMap.values()) { + if (!ref || !ref.name) { + continue; + } + if (authorId && ref.authorId !== authorId) { + continue; + } + if (matchName(ref.name, query, comparer)) { + results.push({ + id: ref.id, + name: ref.name, + type: 'avatar', + imageUrl: ref.thumbnailImageUrl || ref.imageUrl + }); + } + } + 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; + return comparer.compare(a.name, b.name); + }); + if (results.length > limit) { + results.length = limit; + } + return results; +} + +/** + * Search worlds from a Map (cachedWorlds or favorite worlds). + * @param {string} query + * @param {Map} worldMap + * @param {Intl.Collator} comparer + * @param {string|null} [ownerId] - If provided, only match worlds owned by this user + * @param {number} [limit] + * @returns {Array<{id: string, name: string, type: string, imageUrl: string}>} + */ +export function searchWorlds( + query, + worldMap, + comparer, + ownerId = null, + limit = 10 +) { + if (!query || !worldMap) { + return []; + } + const results = []; + for (const ref of worldMap.values()) { + if (!ref || !ref.name) { + continue; + } + if (ownerId && ref.authorId !== ownerId) { + continue; + } + if (matchName(ref.name, query, comparer)) { + results.push({ + id: ref.id, + name: ref.name, + type: 'world', + imageUrl: ref.thumbnailImageUrl || ref.imageUrl + }); + } + } + 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; + return comparer.compare(a.name, b.name); + }); + if (results.length > limit) { + results.length = limit; + } + return results; +} + +/** + * Search groups from a Map (currentUserGroups). + * @param {string} query + * @param {Map} groupMap + * @param {Intl.Collator} comparer + * @param {string|null} [ownerId] - If provided, only match groups owned by this user + * @param {number} [limit] + * @returns {Array<{id: string, name: string, type: string, imageUrl: string}>} + */ +export function searchGroups( + query, + groupMap, + comparer, + ownerId = null, + limit = 10 +) { + if (!query || !groupMap) { + return []; + } + const results = []; + for (const ref of groupMap.values()) { + if (!ref || !ref.name) { + continue; + } + if (ownerId && ref.ownerId !== ownerId) { + continue; + } + if (matchName(ref.name, query, comparer)) { + results.push({ + id: ref.id, + name: ref.name, + type: 'group', + imageUrl: ref.iconUrl || ref.bannerUrl + }); + } + } + 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; + return comparer.compare(a.name, b.name); + }); + if (results.length > limit) { + results.length = limit; + } + return results; +} + +/** + * Search favorite avatars from the favoriteStore array. + * @param {string} query + * @param {Array} favoriteAvatars - favoriteStore.favoriteAvatars array of { name, ref } + * @param {Intl.Collator} comparer + * @param {number} [limit] + * @returns {Array<{id: string, name: string, type: string, imageUrl: string}>} + */ +export function searchFavoriteAvatars( + query, + favoriteAvatars, + comparer, + limit = 10 +) { + if (!query || !favoriteAvatars) { + return []; + } + const results = []; + for (const ctx of favoriteAvatars) { + if (!ctx?.ref?.name) { + continue; + } + if (matchName(ctx.ref.name, query, comparer)) { + results.push({ + id: ctx.ref.id, + name: ctx.ref.name, + type: 'avatar', + imageUrl: ctx.ref.thumbnailImageUrl || ctx.ref.imageUrl + }); + } + } + 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; + return comparer.compare(a.name, b.name); + }); + if (results.length > limit) { + results.length = limit; + } + return results; +} + +/** + * Search favorite worlds from the favoriteStore array. + * @param {string} query + * @param {Array} favoriteWorlds - favoriteStore.favoriteWorlds array of { name, ref } + * @param {Intl.Collator} comparer + * @param {number} [limit] + * @returns {Array<{id: string, name: string, type: string, imageUrl: string}>} + */ +export function searchFavoriteWorlds( + query, + favoriteWorlds, + comparer, + limit = 10 +) { + if (!query || !favoriteWorlds) { + return []; + } + const results = []; + for (const ctx of favoriteWorlds) { + if (!ctx?.ref?.name) { + continue; + } + if (matchName(ctx.ref.name, query, comparer)) { + results.push({ + id: ctx.ref.id, + name: ctx.ref.name, + type: 'world', + imageUrl: ctx.ref.thumbnailImageUrl || ctx.ref.imageUrl + }); + } + } + 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; + return comparer.compare(a.name, b.name); + }); + if (results.length > limit) { + results.length = limit; + } + return results; +} diff --git a/src/stores/avatar.js b/src/stores/avatar.js index c1aebed1..c6f6c880 100644 --- a/src/stores/avatar.js +++ b/src/stores/avatar.js @@ -15,6 +15,7 @@ import { import { avatarRequest, miscRequest } from '../api'; import { AppDebug } from '../service/appConfig'; import { database } from '../service/database'; +import { processBulk } from '../service/request'; import { useAdvancedSettingsStore } from './settings/advanced'; import { useAvatarProviderStore } from './avatarProvider'; import { useFavoriteStore } from './favorite'; @@ -79,15 +80,16 @@ export const useAvatarStore = defineStore('Avatar', () => { avatarHistory.value = []; if (isLoggedIn) { getAvatarHistory(); + preloadOwnAvatars(); } }, { flush: 'sync' } ); /** - / * @param {object} json - / * @returns {object} ref - */ + * @param {object} json + * @returns {object} ref + */ function applyAvatar(json) { json.name = replaceBioSymbols(json.name); json.description = replaceBioSymbols(json.description); @@ -332,6 +334,9 @@ export const useAvatarStore = defineStore('Avatar', () => { return ref; } + /** + * + */ function updateVRChatAvatarCache() { const D = avatarDialog.value; if (D.visible) { @@ -398,11 +403,17 @@ export const useAvatarStore = defineStore('Avatar', () => { }); } + /** + * + */ function clearAvatarHistory() { avatarHistory.value = []; database.clearAvatarHistory(); } + /** + * + */ function promptClearAvatarHistory() { modalStore .confirm({ @@ -444,6 +455,11 @@ export const useAvatarStore = defineStore('Avatar', () => { } } + /** + * + * @param type + * @param search + */ async function lookupAvatars(type, search) { const avatars = new Map(); if (type === 'search') { @@ -507,6 +523,11 @@ export const useAvatarStore = defineStore('Avatar', () => { return avatars; } + /** + * + * @param authorId + * @param fileId + */ async function lookupAvatarByImageFileId(authorId, fileId) { for (const providerUrl of avatarProviderStore.avatarRemoteDatabaseProviderList) { const avatar = await lookupAvatarByFileId(providerUrl, fileId); @@ -529,6 +550,11 @@ export const useAvatarStore = defineStore('Avatar', () => { return null; } + /** + * + * @param providerUrl + * @param fileId + */ async function lookupAvatarByFileId(providerUrl, fileId) { try { const url = `${providerUrl}?fileId=${encodeURIComponent(fileId)}`; @@ -568,6 +594,11 @@ export const useAvatarStore = defineStore('Avatar', () => { } } + /** + * + * @param providerUrl + * @param authorId + */ async function lookupAvatarsByAuthor(providerUrl, authorId) { const avatars = []; if (!providerUrl || !authorId) { @@ -615,6 +646,10 @@ export const useAvatarStore = defineStore('Avatar', () => { return avatars; } + /** + * + * @param id + */ function selectAvatarWithConfirmation(id) { modalStore .confirm({ @@ -628,6 +663,10 @@ export const useAvatarStore = defineStore('Avatar', () => { .catch(() => {}); } + /** + * + * @param id + */ async function selectAvatarWithoutConfirmation(id) { if (userStore.currentUser.currentAvatar === id) { toast.info('Avatar already selected'); @@ -642,6 +681,10 @@ export const useAvatarStore = defineStore('Avatar', () => { }); } + /** + * + * @param fileId + */ function checkAvatarCache(fileId) { let avatarId = ''; for (let ref of cachedAvatars.values()) { @@ -652,6 +695,11 @@ export const useAvatarStore = defineStore('Avatar', () => { return avatarId; } + /** + * + * @param fileId + * @param ownerUserId + */ async function checkAvatarCacheRemote(fileId, ownerUserId) { if (advancedSettingsStore.avatarRemoteDatabase) { try { @@ -673,6 +721,12 @@ export const useAvatarStore = defineStore('Avatar', () => { return null; } + /** + * + * @param refUserId + * @param ownerUserId + * @param currentAvatarImageUrl + */ async function showAvatarAuthorDialog( refUserId, ownerUserId, @@ -712,6 +766,10 @@ export const useAvatarStore = defineStore('Avatar', () => { } } + /** + * + * @param avatarId + */ function addAvatarWearTime(avatarId) { if (!userStore.currentUser.$previousAvatarSwapTime || !avatarId) { return; @@ -721,6 +779,30 @@ export const useAvatarStore = defineStore('Avatar', () => { database.addAvatarTimeSpent(avatarId, timeSpent); } + /** + * Preload all own avatars into cache at startup for global search. + */ + async function preloadOwnAvatars() { + const params = { + n: 50, + offset: 0, + sort: 'updated', + order: 'descending', + releaseStatus: 'all', + user: 'me' + }; + await processBulk({ + fn: avatarRequest.getAvatars, + N: -1, + params, + handle: (args) => { + for (const json of args.json) { + applyAvatar(json); + } + } + }); + } + return { avatarDialog, avatarHistory, @@ -741,6 +823,7 @@ export const useAvatarStore = defineStore('Avatar', () => { selectAvatarWithConfirmation, selectAvatarWithoutConfirmation, showAvatarAuthorDialog, - addAvatarWearTime + addAvatarWearTime, + preloadOwnAvatars }; }); diff --git a/src/stores/globalSearch.js b/src/stores/globalSearch.js new file mode 100644 index 00000000..ca13e06e --- /dev/null +++ b/src/stores/globalSearch.js @@ -0,0 +1,192 @@ +import { computed, ref, watch } from 'vue'; +import { defineStore } from 'pinia'; + +import { + searchAvatars, + searchFavoriteAvatars, + searchFavoriteWorlds, + searchFriends, + searchGroups, + searchWorlds +} from '../shared/utils/globalSearchUtils'; +import { useAvatarStore } from './avatar'; +import { useFavoriteStore } from './favorite'; +import { useFriendStore } from './friend'; +import { useGroupStore } from './group'; +import { useUserStore } from './user'; +import { useWorldStore } from './world'; + +export const useGlobalSearchStore = defineStore('GlobalSearch', () => { + const friendStore = useFriendStore(); + const favoriteStore = useFavoriteStore(); + const avatarStore = useAvatarStore(); + const worldStore = useWorldStore(); + const groupStore = useGroupStore(); + const userStore = useUserStore(); + + const isOpen = ref(false); + const query = ref(''); + + const stringComparer = computed( + () => + new Intl.Collator(undefined, { + usage: 'search', + sensitivity: 'base' + }) + ); + + // Reset query when dialog closes + watch(isOpen, (open) => { + if (!open) { + query.value = ''; + } + }); + + const currentUserId = computed(() => userStore.currentUser?.id); + + const friendResults = computed(() => { + if (!query.value || query.value.length < 2) return []; + return searchFriends( + query.value, + friendStore.friends, + stringComparer.value + ); + }); + + // Own avatars (filter cachedAvatars by authorId) + const ownAvatarResults = computed(() => { + if (!query.value || query.value.length < 2) return []; + return searchAvatars( + query.value, + avatarStore.cachedAvatars, + stringComparer.value, + currentUserId.value + ); + }); + + // Favorite avatars (from favoriteStore, deduplicated against own) + const favoriteAvatarResults = computed(() => { + if (!query.value || query.value.length < 2) return []; + const favResults = searchFavoriteAvatars( + query.value, + favoriteStore.favoriteAvatars, + stringComparer.value + ); + // Deduplicate: remove items already in ownAvatarResults + const ownIds = new Set(ownAvatarResults.value.map((r) => r.id)); + return favResults.filter((r) => !ownIds.has(r.id)); + }); + + // Own worlds (filter cachedWorlds by authorId) + const ownWorldResults = computed(() => { + if (!query.value || query.value.length < 2) return []; + return searchWorlds( + query.value, + worldStore.cachedWorlds, + stringComparer.value, + currentUserId.value + ); + }); + + // Favorite worlds (from favoriteStore, deduplicated against own) + const favoriteWorldResults = computed(() => { + if (!query.value || query.value.length < 2) return []; + const favResults = searchFavoriteWorlds( + query.value, + favoriteStore.favoriteWorlds, + stringComparer.value + ); + // Deduplicate: remove items already in ownWorldResults + const ownIds = new Set(ownWorldResults.value.map((r) => r.id)); + return favResults.filter((r) => !ownIds.has(r.id)); + }); + + // Own groups (filter by ownerId === currentUser) + const ownGroupResults = computed(() => { + if (!query.value || query.value.length < 2) return []; + return searchGroups( + query.value, + groupStore.currentUserGroups, + stringComparer.value, + currentUserId.value + ); + }); + + // Joined groups (all matching groups, deduplicated against own) + const joinedGroupResults = computed(() => { + if (!query.value || query.value.length < 2) return []; + const allResults = searchGroups( + query.value, + groupStore.currentUserGroups, + stringComparer.value + ); + const ownIds = new Set(ownGroupResults.value.map((r) => r.id)); + return allResults.filter((r) => !ownIds.has(r.id)); + }); + + const hasResults = computed( + () => + friendResults.value.length > 0 || + ownAvatarResults.value.length > 0 || + favoriteAvatarResults.value.length > 0 || + ownWorldResults.value.length > 0 || + favoriteWorldResults.value.length > 0 || + ownGroupResults.value.length > 0 || + joinedGroupResults.value.length > 0 + ); + + /** + * + */ + function open() { + isOpen.value = true; + } + + /** + * + */ + function close() { + isOpen.value = false; + } + + /** + * @param {{id: string, type: string}} item + */ + function selectResult(item) { + if (!item) return; + + close(); + + switch (item.type) { + case 'friend': + userStore.showUserDialog(item.id); + break; + case 'avatar': + avatarStore.showAvatarDialog(item.id); + break; + case 'world': + worldStore.showWorldDialog(item.id); + break; + case 'group': + groupStore.showGroupDialog(item.id); + break; + } + } + + return { + isOpen, + query, + friendResults, + ownAvatarResults, + favoriteAvatarResults, + ownWorldResults, + favoriteWorldResults, + ownGroupResults, + joinedGroupResults, + hasResults, + + open, + close, + selectResult + }; +}); diff --git a/src/stores/index.js b/src/stores/index.js index 8991c006..6e1d837d 100644 --- a/src/stores/index.js +++ b/src/stores/index.js @@ -16,6 +16,7 @@ import { useGalleryStore } from './gallery'; import { useGameLogStore } from './gameLog'; import { useGameStore } from './game'; import { useGeneralSettingsStore } from './settings/general'; +import { useGlobalSearchStore } from './globalSearch'; import { useGroupStore } from './group'; import { useInstanceStore } from './instance'; import { useInviteStore } from './invite'; @@ -163,7 +164,8 @@ export function createGlobalStores() { auth: useAuthStore(), vrcStatus: useVrcStatusStore(), charts: useChartsStore(), - modal: useModalStore() + modal: useModalStore(), + globalSearch: useGlobalSearchStore() }; } @@ -202,5 +204,6 @@ export { useSharedFeedStore, useUpdateLoopStore, useVrcStatusStore, - useModalStore + useModalStore, + useGlobalSearchStore }; diff --git a/src/stores/world.js b/src/stores/world.js index 3a6b577f..821524bc 100644 --- a/src/stores/world.js +++ b/src/stores/world.js @@ -14,9 +14,8 @@ import { } from '../shared/utils'; import { instanceRequest, miscRequest, worldRequest } from '../api'; import { database } from '../service/database'; -import { useAvatarStore } from './avatar'; +import { processBulk } from '../service/request'; import { useFavoriteStore } from './favorite'; -import { useGroupStore } from './group'; import { useInstanceStore } from './instance'; import { useLocationStore } from './location'; import { useUiStore } from './ui'; @@ -28,8 +27,6 @@ export const useWorldStore = defineStore('World', () => { const favoriteStore = useFavoriteStore(); const instanceStore = useInstanceStore(); const userStore = useUserStore(); - const avatarStore = useAvatarStore(); - const groupStore = useGroupStore(); const uiStore = useUiStore(); const { t } = useI18n(); @@ -64,9 +61,12 @@ export const useWorldStore = defineStore('World', () => { watch( () => watchState.isLoggedIn, - () => { + (isLoggedIn) => { worldDialog.visible = false; cachedWorlds.clear(); + if (isLoggedIn) { + preloadOwnWorlds(); + } }, { flush: 'sync' } ); @@ -210,6 +210,9 @@ export const useWorldStore = defineStore('World', () => { }); } + /** + * + */ function updateVRChatWorldCache() { const D = worldDialog; if (D.visible) { @@ -228,6 +231,10 @@ export const useWorldStore = defineStore('World', () => { } } + /** + * + * @param WorldCache + */ function cleanupWorldCache(WorldCache) { const maxCacheSize = 10000; @@ -339,11 +346,36 @@ export const useWorldStore = defineStore('World', () => { return ref; } + /** + * Preload all own worlds into cache at startup for global search. + */ + async function preloadOwnWorlds() { + const params = { + n: 50, + offset: 0, + sort: 'updated', + order: 'descending', + releaseStatus: 'all', + user: 'me' + }; + await processBulk({ + fn: (p) => worldRequest.getWorlds(p), + N: -1, + params, + handle: (args) => { + for (const json of args.json) { + applyWorld(json); + } + } + }); + } + return { worldDialog, cachedWorlds, showWorldDialog, updateVRChatWorldCache, - applyWorld + applyWorld, + preloadOwnWorlds }; }); diff --git a/src/views/Sidebar/Sidebar.vue b/src/views/Sidebar/Sidebar.vue index 7c847480..15875217 100644 --- a/src/views/Sidebar/Sidebar.vue +++ b/src/views/Sidebar/Sidebar.vue @@ -2,65 +2,17 @@
- - - - - -
- -
- -
-
-
-
+
@@ -270,6 +222,7 @@ +
@@ -283,13 +236,13 @@ SelectTrigger, SelectValue } from '@/components/ui/select'; - import { Bell, RefreshCw, Settings } from 'lucide-vue-next'; + import { Bell, RefreshCw, Search, Settings } from 'lucide-vue-next'; import { Field, FieldContent, FieldLabel } from '@/components/ui/field'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; - import { computed, ref, watch } from 'vue'; + import { computed, ref } from 'vue'; + import { useMagicKeys, whenever } from '@vueuse/core'; import { Button } from '@/components/ui/button'; - import { DataTableEmpty } from '@/components/ui/data-table'; - import { Input } from '@/components/ui/input'; + import { Kbd } from '@/components/ui/kbd'; import { Separator } from '@/components/ui/separator'; import { Spinner } from '@/components/ui/spinner'; import { Switch } from '@/components/ui/switch'; @@ -302,24 +255,37 @@ useFavoriteStore, useFriendStore, useGroupStore, - useNotificationStore, - useSearchStore + useNotificationStore } from '../../stores'; - import { debounce, userImage } from '../../shared/utils'; + import { useGlobalSearchStore } from '../../stores/globalSearch'; import FriendsSidebar from './components/FriendsSidebar.vue'; + import GlobalSearchDialog from '../../components/GlobalSearchDialog.vue'; import GroupOrderSheet from './components/GroupOrderSheet.vue'; import GroupsSidebar from './components/GroupsSidebar.vue'; import NotificationCenterSheet from './components/NotificationCenterSheet.vue'; const { friends, isRefreshFriendsLoading, onlineFriendCount } = storeToRefs(useFriendStore()); const { refreshFriendsList } = useFriendStore(); - const { quickSearchRemoteMethod, quickSearchChange } = useSearchStore(); - const { quickSearchItems } = storeToRefs(useSearchStore()); const { groupInstances } = storeToRefs(useGroupStore()); const { isNotificationCenterOpen, hasUnseenNotifications } = storeToRefs(useNotificationStore()); + const globalSearchStore = useGlobalSearchStore(); const { t } = useI18n(); + const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; + + // Keyboard shortcut: Ctrl+K (Windows) / ⌘K (Mac) + const keys = useMagicKeys(); + whenever(keys['Meta+k'], () => openGlobalSearch()); + whenever(keys['Ctrl+k'], () => openGlobalSearch()); + + /** + * + */ + function openGlobalSearch() { + globalSearchStore.open(); + } + const appearanceSettingsStore = useAppearanceSettingsStore(); const { sidebarSortMethod1, @@ -358,6 +324,10 @@ return sidebarFavoriteGroups.value; }); + /** + * + * @param value + */ function handleFavoriteGroupsChange(value) { if (!value || value.length === 0) { // Deselected all → reset to all (store as empty) @@ -398,31 +368,6 @@ { value: 'friends', label: t('side_panel.friends') }, { value: 'groups', label: t('side_panel.groups') } ]); - - const quickSearchQuery = ref(''); - const isQuickSearchOpen = ref(false); - - const runQuickSearch = debounce((value) => { - quickSearchRemoteMethod(value); - }, 200); - - watch(quickSearchQuery, (value) => { - const query = String(value ?? '').trim(); - if (!query) { - quickSearchRemoteMethod(''); - return; - } - runQuickSearch(query); - }); - - function handleQuickSearchSelect(value) { - if (!value) { - return; - } - isQuickSearchOpen.value = false; - quickSearchQuery.value = ''; - quickSearchChange(String(value)); - }