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));
- }