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