improve performance and clean up

This commit is contained in:
pa
2026-03-15 20:52:01 +09:00
parent af389e645d
commit 91c056b5a3
7 changed files with 132 additions and 256 deletions

View File

@@ -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());
}
/**

View File

@@ -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",

View File

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

View File

@@ -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];

View File

@@ -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 () => {

View File

@@ -9,7 +9,10 @@
</TabsList>
</Tabs>
<div class="friend-view__actions">
<InputGroupSearch v-model="searchTerm" class="friend-view__search" placeholder="Search Friend" />
<InputGroupSearch
v-model="searchTerm"
class="friend-view__search"
:placeholder="t('view.friends_locations.search_placeholder')" />
<TooltipWrapper :content="t('view.charts.instance_activity.settings.header')" side="top">
<div>
<Popover>
@@ -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 });
});
}
}

View File

@@ -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 () => {