diff --git a/src/coordinators/userCoordinator.js b/src/coordinators/userCoordinator.js
index d597d1d9..4abc2c78 100644
--- a/src/coordinators/userCoordinator.js
+++ b/src/coordinators/userCoordinator.js
@@ -289,7 +289,6 @@ export function showUserDialog(userId) {
const moderationStore = useModerationStore();
const favoriteStore = useFavoriteStore();
const locationStore = useLocationStore();
- const searchStore = useSearchStore();
const appearanceSettingsStore = useAppearanceSettingsStore();
const t = i18n.global.t;
@@ -530,7 +529,6 @@ export function showUserDialog(userId) {
});
showUserDialogHistory.delete(userId);
showUserDialogHistory.add(userId);
- searchStore.setQuickSearchItems(searchStore.quickSearchUserHistory());
}
/**
diff --git a/src/localization/en.json b/src/localization/en.json
index 9a66edbd..9b648895 100644
--- a/src/localization/en.json
+++ b/src/localization/en.json
@@ -235,6 +235,7 @@
"same_instance": "Same instance",
"active": "Active",
"offline": "Offline",
+ "search_placeholder": "Search friends",
"spacing": "Spacing",
"scale": "Scale",
"separate_same_instance_friends": "Separate Same Instance Friends",
diff --git a/src/stores/search.js b/src/stores/search.js
index 668d83e6..be154a2a 100644
--- a/src/stores/search.js
+++ b/src/stores/search.js
@@ -2,37 +2,26 @@ import { computed, ref, watch } from 'vue';
import { defineStore } from 'pinia';
import { toast } from 'vue-sonner';
import { useI18n } from 'vue-i18n';
-import { useRouter } from 'vue-router';
-import { compareByName, localeIncludes } from '../shared/utils';
import { instanceRequest, userRequest } from '../api';
import { groupRequest } from '../api/';
-import removeConfusables, { removeWhitespace } from '../services/confusables';
import { useAppearanceSettingsStore } from './settings/appearance';
-import { useFriendStore } from './friend';
import { showGroupDialog } from '../coordinators/groupCoordinator';
import { showWorldDialog } from '../coordinators/worldCoordinator';
import { showAvatarDialog } from '../coordinators/avatarCoordinator';
-import {
- applyUser,
- showUserDialog,
- lookupUser
-} from '../coordinators/userCoordinator';
+import { applyUser, showUserDialog } from '../coordinators/userCoordinator';
import { useModalStore } from './modal';
import { useUserStore } from './user';
import { watchState } from '../services/watchState';
export const useSearchStore = defineStore('Search', () => {
const userStore = useUserStore();
- const router = useRouter();
const appearanceSettingsStore = useAppearanceSettingsStore();
- const friendStore = useFriendStore();
const modalStore = useModalStore();
const { t } = useI18n();
const searchText = ref('');
const searchUserResults = ref([]);
- const quickSearchItems = ref([]);
const friendsListSearch = ref('');
const directAccessPrompt = ref(null);
@@ -65,13 +54,6 @@ export const useSearchStore = defineStore('Search', () => {
searchText.value = value;
}
- /**
- * @param {Array} value
- */
- function setQuickSearchItems(value) {
- quickSearchItems.value = value;
- }
-
async function searchUserByDisplayName(displayName) {
const params = {
n: 10,
@@ -110,133 +92,6 @@ export const useSearchStore = defineStore('Search', () => {
});
}
- function quickSearchRemoteMethod(query) {
- if (!query) {
- quickSearchItems.value = quickSearchUserHistory();
- return;
- }
-
- if (query.length < 2) {
- quickSearchItems.value = quickSearchUserHistory();
- return;
- }
-
- const results = [];
- const cleanQuery = removeWhitespace(query);
- if (!cleanQuery) {
- quickSearchItems.value = quickSearchUserHistory();
- return;
- }
-
- for (const ctx of friendStore.friends.values()) {
- if (typeof ctx.ref === 'undefined') {
- continue;
- }
-
- const cleanName = removeConfusables(ctx.name);
- let match = localeIncludes(
- cleanName,
- cleanQuery,
- stringComparer.value
- );
- if (!match) {
- // Also check regular name in case search is with special characters
- match = localeIncludes(
- ctx.name,
- cleanQuery,
- stringComparer.value
- );
- }
- // Use query with whitespace for notes and memos as people are more
- // likely to include spaces in memos and notes
- if (!match && ctx.memo) {
- match = localeIncludes(ctx.memo, query, stringComparer.value);
- }
- if (!match && ctx.ref.note) {
- match = localeIncludes(
- ctx.ref.note,
- query,
- stringComparer.value
- );
- }
-
- if (match) {
- results.push({
- value: ctx.id,
- label: ctx.name,
- ref: ctx.ref,
- name: ctx.name
- });
- }
- }
-
- results.sort(function (a, b) {
- const A =
- stringComparer.value.compare(
- a.name.substring(0, cleanQuery.length),
- cleanQuery
- ) === 0;
- const B =
- stringComparer.value.compare(
- b.name.substring(0, cleanQuery.length),
- cleanQuery
- ) === 0;
- if (A && !B) {
- return -1;
- } else if (B && !A) {
- return 1;
- }
- return compareByName(a, b);
- });
- if (results.length > 4) {
- results.length = 4;
- }
- results.push({
- value: `search:${query}`,
- label: query
- });
-
- quickSearchItems.value = results;
- }
-
- function quickSearchChange(value) {
- if (!value) {
- return;
- }
-
- if (value.startsWith('search:')) {
- const searchTerm = value.slice(7);
- if (quickSearchItems.value.length > 1 && searchTerm.length) {
- friendsListSearch.value = searchTerm;
- router.push({ name: 'friend-list' });
- } else {
- router.push({ name: 'search' });
- searchText.value = searchTerm;
- lookupUser({ displayName: searchTerm });
- }
- } else {
- showUserDialog(value);
- }
- }
-
- function quickSearchUserHistory() {
- const userHistory = Array.from(userStore.showUserDialogHistory.values())
- .reverse()
- .slice(0, 5);
- const results = [];
- userHistory.forEach((userId) => {
- const ref = userStore.cachedUsers.get(userId);
- if (typeof ref !== 'undefined') {
- results.push({
- value: ref.id,
- label: ref.name,
- ref
- });
- }
- });
- return results;
- }
-
async function directAccessPaste() {
let cbText = '';
if (LINUX) {
@@ -430,20 +285,15 @@ export const useSearchStore = defineStore('Search', () => {
searchText,
searchUserResults,
stringComparer,
- quickSearchItems,
friendsListSearch,
clearSearch,
searchUserByDisplayName,
moreSearchUser,
- quickSearchUserHistory,
- quickSearchRemoteMethod,
- quickSearchChange,
directAccessParse,
directAccessPaste,
directAccessWorld,
verifyShortName,
- setSearchText,
- setQuickSearchItems
+ setSearchText
};
});
diff --git a/src/views/FriendList/FriendList.vue b/src/views/FriendList/FriendList.vue
index bd6ba399..d44e5094 100644
--- a/src/views/FriendList/FriendList.vue
+++ b/src/views/FriendList/FriendList.vue
@@ -134,9 +134,7 @@
useAppearanceSettingsStore,
useFriendStore,
useModalStore,
- useSearchStore,
- useUserStore,
- useVrcxStore
+ useSearchStore
} from '../../stores';
import { friendRequest, userRequest } from '../../api';
import { DataTableLayout } from '../../components/ui/data-table';
@@ -261,6 +259,7 @@
watch(
() => route.path,
() => {
+ refreshFriendStats();
nextTick(() => friendsListSearchChange());
},
{ immediate: true }
@@ -269,10 +268,16 @@
watch(
() => friends.value.size,
() => {
+ refreshFriendStats();
friendsListSearchChange();
}
);
+ function refreshFriendStats() {
+ getAllUserStats();
+ getAllUserMutualCount();
+ }
+
/**
*
*/
@@ -319,8 +324,6 @@
results.push(ctx.ref);
}
friendsListDisplayData.value = results;
- getAllUserStats();
- getAllUserMutualCount();
table.setPageIndex(0);
table.setSorting([...defaultSorting]);
sorting.value = [...defaultSorting];
diff --git a/src/views/FriendList/__tests__/FriendList.test.js b/src/views/FriendList/__tests__/FriendList.test.js
index 89bc6b06..d3683fd8 100644
--- a/src/views/FriendList/__tests__/FriendList.test.js
+++ b/src/views/FriendList/__tests__/FriendList.test.js
@@ -312,6 +312,8 @@ describe('FriendList.vue', () => {
const wrapper = mount(FriendList);
await flushAsync();
+ expect(mocks.getAllUserStats).toHaveBeenCalledTimes(1);
+ expect(mocks.getAllUserMutualCount).toHaveBeenCalledTimes(1);
wrapper.vm.friendsListSearchFilterVIP = true;
wrapper.vm.friendsListSearchChange();
@@ -320,8 +322,8 @@ describe('FriendList.vue', () => {
expect(
wrapper.vm.friendsListDisplayData.map((item) => item.id)
).toEqual(['usr_1']);
- expect(mocks.getAllUserStats).toHaveBeenCalled();
- expect(mocks.getAllUserMutualCount).toHaveBeenCalled();
+ expect(mocks.getAllUserStats).toHaveBeenCalledTimes(1);
+ expect(mocks.getAllUserMutualCount).toHaveBeenCalledTimes(1);
});
test('opens charts tab from toolbar button', async () => {
diff --git a/src/views/FriendsLocations/FriendsLocations.vue b/src/views/FriendsLocations/FriendsLocations.vue
index a1767307..acbebf8c 100644
--- a/src/views/FriendsLocations/FriendsLocations.vue
+++ b/src/views/FriendsLocations/FriendsLocations.vue
@@ -9,7 +9,10 @@
-
+
@@ -151,7 +154,7 @@
import { Slider } from '../../components/ui/slider';
import { Switch } from '../../components/ui/switch';
import { getFriendsLocations } from '../../shared/utils/location.js';
- import { getFriendsSortFunction } from '../../shared/utils';
+ import { debounce, getFriendsSortFunction } from '../../shared/utils';
import FriendLocationCard from './components/FriendsLocationsCard.vue';
import configRepository from '../../services/config.js';
@@ -196,12 +199,18 @@
const cardScaleBase = ref(1);
const cardSpacingBase = ref(1);
+ const persistCardScale = debounce((value) => {
+ configRepository.setString('VRCX_FriendLocationCardScale', value.toString());
+ }, 200);
+ const persistCardSpacing = debounce((value) => {
+ configRepository.setString('VRCX_FriendLocationCardSpacing', value.toString());
+ }, 200);
const cardScale = computed({
get: () => cardScaleBase.value,
set: (value) => {
cardScaleBase.value = value;
- configRepository.setString('VRCX_FriendLocationCardScale', value.toString());
+ persistCardScale(value);
}
});
@@ -209,7 +218,7 @@
get: () => cardSpacingBase.value,
set: (value) => {
cardSpacingBase.value = value;
- configRepository.setString('VRCX_FriendLocationCardSpacing', value.toString());
+ persistCardSpacing(value);
}
});
@@ -251,6 +260,8 @@
const scrollbarRef = ref();
const gridWidth = ref(0);
+ let measureScheduled = false;
+ let pendingGridWidthUpdate = false;
let resizeObserver;
let cleanupResize;
@@ -314,6 +325,29 @@
}))
: [];
+ const getFriendIdentity = (friend) => friend?.id ?? friend?.userId ?? friend?.displayName ?? 'unknown';
+
+ const getEntryIdentity = (entry) => entry?.id ?? getFriendIdentity(entry?.friend);
+
+ const scheduleVirtualMeasure = ({ updateGridWidth: shouldUpdateGridWidth = false } = {}) => {
+ pendingGridWidthUpdate = pendingGridWidthUpdate || shouldUpdateGridWidth;
+ if (measureScheduled) {
+ return;
+ }
+
+ measureScheduled = true;
+ nextTick(() => {
+ measureScheduled = false;
+
+ if (pendingGridWidthUpdate) {
+ pendingGridWidthUpdate = false;
+ updateGridWidth();
+ }
+
+ virtualizer.value?.measure?.();
+ });
+ };
+
const sameInstanceGroups = computed(() => {
const source = friendsInSameInstance?.value;
if (!Array.isArray(source) || source.length === 0) return [];
@@ -379,10 +413,6 @@
return allFavoriteOnlineFriends.value.filter((friend) => displayedVipIds.value.has(friend.id));
});
- const vipFriendsByGroupStatus = computed(() => {
- return visibleFavoriteOnlineFriends.value;
- });
-
const onlineFriendsByGroupStatus = computed(() => {
const selectedGroups = sidebarFavoriteGroups.value;
if (selectedGroups.length === 0) {
@@ -501,7 +531,7 @@
return toEntries(onlineFriendsByGroupStatus.value);
}
case 'favorite':
- return toEntries(vipFriendsByGroupStatus.value);
+ return toEntries(visibleFavoriteOnlineFriends.value);
case 'same-instance':
return sameInstanceEntries.value;
case 'active':
@@ -554,18 +584,36 @@
return buildSameInstanceGroups(filteredFriends.value);
});
- const mergedSameInstanceEntries = computed(() => {
+ const mergedEntriesBySection = computed(() => {
if (!shouldMergeSameInstance.value) {
- return [];
+ return {
+ sameInstance: [],
+ online: []
+ };
}
- return filteredFriends.value.filter((entry) => entry.section === 'same-instance');
+
+ const sameInstance = [];
+ const online = [];
+ for (const entry of filteredFriends.value) {
+ if (entry.section === 'same-instance') {
+ sameInstance.push(entry);
+ } else {
+ online.push(entry);
+ }
+ }
+
+ return {
+ sameInstance,
+ online
+ };
+ });
+
+ const mergedSameInstanceEntries = computed(() => {
+ return mergedEntriesBySection.value.sameInstance;
});
const mergedOnlineEntries = computed(() => {
- if (!shouldMergeSameInstance.value) {
- return [];
- }
- return filteredFriends.value.filter((entry) => entry.section !== 'same-instance');
+ return mergedEntriesBySection.value.online;
});
const mergedSameInstanceGroups = computed(() => {
@@ -575,61 +623,13 @@
return buildSameInstanceGroups(mergedSameInstanceEntries.value);
});
- const gridStyle = computed(() => {
+ const computeGridLayout = (count = 1, options = {}) => {
const baseWidth = 220;
const baseGap = 14;
const scale = cardScale.value;
const spacing = cardSpacing.value;
const minWidth = baseWidth * scale;
const gap = Math.max(6, (baseGap + (scale - 1) * 10) * spacing);
-
- return (count = 1, options = {}) => {
- const containerWidth = Math.max(gridWidth.value ?? 0, 0);
- const itemCount = Math.max(Number(count) || 0, 0);
- const safeCount = itemCount > 0 ? itemCount : 1;
- const maxColumns = Math.max(1, Math.floor((containerWidth + gap) / (minWidth + gap)) || 1);
- const preferredColumns = options?.preferredColumns;
- const requestedColumns = preferredColumns
- ? Math.max(1, Math.min(Math.round(preferredColumns), maxColumns))
- : maxColumns;
- const columns = Math.max(1, Math.min(safeCount, requestedColumns));
- const forceStretch = Boolean(options?.forceStretch);
- const disableAutoStretch = Boolean(options?.disableAutoStretch);
- const matchMaxColumnWidth = Boolean(options?.matchMaxColumnWidth);
- const shouldStretch = !disableAutoStretch && (forceStretch || itemCount >= maxColumns);
-
- let cardWidth = minWidth;
- const maxColumnWidth = maxColumns > 0 ? (containerWidth - gap * (maxColumns - 1)) / maxColumns : minWidth;
-
- if (shouldStretch && columns > 0) {
- const columnsWidth = containerWidth - gap * (columns - 1);
- const rawWidth = columnsWidth > 0 ? columnsWidth / columns : minWidth;
-
- if (Number.isFinite(rawWidth) && rawWidth > 0) {
- cardWidth = Math.max(minWidth, rawWidth);
- }
- } else if (matchMaxColumnWidth && Number.isFinite(maxColumnWidth) && maxColumnWidth > 0) {
- cardWidth = Math.max(minWidth, maxColumnWidth);
- }
-
- return {
- '--friend-card-min-width': `${Math.round(minWidth)}px`,
- '--friend-card-gap': `${Math.round(gap)}px`,
- '--friend-card-target-width': `${Math.round(cardWidth)}px`,
- '--friend-grid-columns': `${columns}`,
- '--friend-card-spacing': `${spacing.toFixed(2)}`
- };
- };
- });
-
- const getGridMetrics = (count = 1, options = {}) => {
- const baseWidth = 220;
- const baseGap = 14;
- const scale = cardScale.value;
- const spacing = cardSpacing.value;
- const minWidth = baseWidth * scale;
- const gap = Math.max(6, (baseGap + (scale - 1) * 10) * spacing);
-
const containerWidth = Math.max(gridWidth.value ?? 0, 0);
const itemCount = Math.max(Number(count) || 0, 0);
const safeCount = itemCount > 0 ? itemCount : 1;
@@ -666,12 +666,25 @@
};
};
+ const gridStyle = computed(() => {
+ return (count = 1, options = {}) => {
+ const { minWidth, gap, cardWidth, columns } = computeGridLayout(count, options);
+ return {
+ '--friend-card-min-width': `${Math.round(minWidth)}px`,
+ '--friend-card-gap': `${Math.round(gap)}px`,
+ '--friend-card-target-width': `${Math.round(cardWidth)}px`,
+ '--friend-grid-columns': `${columns}`,
+ '--friend-card-spacing': `${cardSpacing.value.toFixed(2)}`
+ };
+ };
+ });
+
const chunkCardItems = (items = [], keyPrefix = 'row') => {
const safeItems = Array.isArray(items) ? items : [];
if (!safeItems.length) {
return [];
}
- const { columns } = getGridMetrics(safeItems.length, { matchMaxColumnWidth: true });
+ const { columns } = computeGridLayout(safeItems.length, { matchMaxColumnWidth: true });
const safeColumns = Math.max(1, columns || 1);
const rows = [];
@@ -705,7 +718,7 @@
const friends = Array.isArray(group.friends) ? group.friends : [];
if (friends.length) {
const items = friends.map((friend) => ({
- key: `f:${friend?.id ?? friend?.userId ?? friend?.displayName ?? Math.random()}`,
+ key: `f:${getFriendIdentity(friend)}`,
friend,
displayInstanceInfo: true
}));
@@ -728,7 +741,7 @@
const friends = Array.isArray(group.friends) ? group.friends : [];
if (friends.length) {
const items = friends.map((friend) => ({
- key: `f:${friend?.id ?? friend?.userId ?? friend?.displayName ?? Math.random()}`,
+ key: `f:${getFriendIdentity(friend)}`,
friend,
displayInstanceInfo: false
}));
@@ -743,7 +756,7 @@
const online = mergedOnlineEntries.value;
if (online.length) {
const items = online.map((entry) => ({
- key: `e:${entry?.id ?? entry?.friend?.id ?? entry?.friend?.displayName ?? Math.random()}`,
+ key: `e:${getEntryIdentity(entry)}`,
friend: entry.friend,
displayInstanceInfo: true
}));
@@ -766,7 +779,7 @@
});
if (!isCollapsed) {
const items = group.friends.map((friend) => ({
- key: `fg:${group.key}:${friend?.id ?? friend?.userId ?? friend?.displayName ?? Math.random()}`,
+ key: `fg:${group.key}:${getFriendIdentity(friend)}`,
friend,
displayInstanceInfo: true
}));
@@ -779,7 +792,7 @@
const entries = filteredFriends.value;
if (entries.length) {
const items = entries.map((entry) => ({
- key: `e:${entry?.id ?? entry?.friend?.id ?? entry?.friend?.displayName ?? Math.random()}`,
+ key: `e:${getEntryIdentity(entry)}`,
friend: entry.friend,
displayInstanceInfo: true
}));
@@ -814,7 +827,7 @@
}
const itemCount = Array.isArray(row.items) ? row.items.length : 0;
- const { columns, gap } = getGridMetrics(itemCount, { matchMaxColumnWidth: true });
+ const { columns, gap } = computeGridLayout(itemCount, { matchMaxColumnWidth: true });
const safeColumns = Math.max(1, columns || 1);
const rows = Math.max(1, Math.ceil(itemCount / safeColumns));
const scale = cardScale.value;
@@ -853,10 +866,7 @@
const getRowCount = (row) => (row && row.type === 'header' ? row.count : 0);
watch([searchTerm, activeSegment], () => {
- nextTick(() => {
- updateGridWidth();
- virtualizer.value?.measure?.();
- });
+ scheduleVirtualMeasure({ updateGridWidth: true });
});
watch(showSameInstance, (value) => {
@@ -867,19 +877,13 @@
activeSegment.value = 'online';
}
- nextTick(() => {
- updateGridWidth();
- virtualizer.value?.measure?.();
- });
+ scheduleVirtualMeasure({ updateGridWidth: true });
});
watch(
() => filteredFriends.value.length,
() => {
- nextTick(() => {
- updateGridWidth();
- virtualizer.value?.measure?.();
- });
+ scheduleVirtualMeasure({ updateGridWidth: true });
}
);
@@ -887,23 +891,17 @@
if (!settingsReady.value) {
return;
}
- nextTick(() => {
- updateGridWidth();
- virtualizer.value?.measure?.();
- });
+ scheduleVirtualMeasure({ updateGridWidth: true });
});
watch(virtualRows, () => {
- nextTick(() => {
- virtualizer.value?.measure?.();
- });
+ scheduleVirtualMeasure();
});
onMounted(() => {
nextTick(() => {
setupResizeHandling();
- updateGridWidth();
- virtualizer.value?.measure?.();
+ scheduleVirtualMeasure({ updateGridWidth: true });
});
});
@@ -944,8 +942,7 @@
settingsReady.value = true;
nextTick(() => {
setupResizeHandling();
- updateGridWidth();
- virtualizer.value?.measure?.();
+ scheduleVirtualMeasure({ updateGridWidth: true });
});
}
}
diff --git a/src/views/FriendsLocations/__tests__/FriendsLocations.test.js b/src/views/FriendsLocations/__tests__/FriendsLocations.test.js
index 9d211877..485e0bff 100644
--- a/src/views/FriendsLocations/__tests__/FriendsLocations.test.js
+++ b/src/views/FriendsLocations/__tests__/FriendsLocations.test.js
@@ -94,6 +94,13 @@ vi.mock('../../../shared/utils/location.js', () => ({
}));
vi.mock('../../../shared/utils', () => ({
+ debounce: (fn, delay) => {
+ let timer = null;
+ return (...args) => {
+ clearTimeout(timer);
+ timer = setTimeout(() => fn(...args), delay);
+ };
+ },
getFriendsSortFunction: () => (a, b) =>
String(a?.displayName ?? '').localeCompare(String(b?.displayName ?? ''))
}));
@@ -295,13 +302,17 @@ describe('FriendsLocations.vue', () => {
});
test('persists card scale and same-instance preferences', async () => {
+ vi.useFakeTimers();
const wrapper = mount(FriendsLocations);
await flushSettings();
+ mocks.configSetString.mockClear();
+ mocks.configSetBool.mockClear();
await wrapper.get('[data-testid="set-scale"]').trigger('click');
await wrapper
.get('[data-testid="toggle-same-instance"]')
.trigger('click');
+ vi.advanceTimersByTime(200);
expect(mocks.configSetString).toHaveBeenCalledWith(
'VRCX_FriendLocationCardScale',
@@ -311,6 +322,20 @@ describe('FriendsLocations.vue', () => {
'VRCX_FriendLocationShowSameInstance',
true
);
+ vi.useRealTimers();
+ });
+
+ test('coalesces repeated virtualizer measure requests in the same tick', async () => {
+ const wrapper = mount(FriendsLocations);
+ await flushSettings();
+ mocks.virtualMeasure.mockClear();
+
+ wrapper.vm.searchTerm = 'alice';
+ wrapper.vm.activeSegment = 'offline';
+ await nextTick();
+ await nextTick();
+
+ expect(mocks.virtualMeasure).toHaveBeenCalledTimes(1);
});
test('renders empty state when no rows match', async () => {