mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-18 22:33:50 +02:00
984 lines
34 KiB
Vue
984 lines
34 KiB
Vue
<template>
|
|
<div class="friend-view x-container">
|
|
<div v-if="settingsReady" class="friend-view__toolbar">
|
|
<Tabs v-model="activeSegment" class="friend-view__tabs">
|
|
<TabsList>
|
|
<TabsTrigger v-for="option in segmentedOptions" :key="option.value" :value="option.value">
|
|
{{ option.label }}
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
</Tabs>
|
|
<div class="friend-view__actions">
|
|
<InputGroupSearch v-model="searchTerm" class="friend-view__search" placeholder="Search Friend" />
|
|
<TooltipWrapper :content="t('view.charts.instance_activity.settings.header')" side="top">
|
|
<div>
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button class="rounded-full mr-2" size="icon" variant="ghost">
|
|
<Settings />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent side="bottom" class="w-87.5">
|
|
<div class="friend-view__settings">
|
|
<Field orientation="horizontal" class="friend-view__settings-row">
|
|
<FieldLabel class="friend-view__settings-label">{{
|
|
t('view.friends_locations.separate_same_instance_friends')
|
|
}}</FieldLabel>
|
|
<FieldContent>
|
|
<Switch v-model="showSameInstance" />
|
|
</FieldContent>
|
|
</Field>
|
|
<Field orientation="horizontal" class="friend-view__settings-row">
|
|
<FieldLabel class="friend-view__settings-label">
|
|
{{ t('view.friends_locations.scale') }}
|
|
</FieldLabel>
|
|
<FieldContent>
|
|
<div class="friend-view__scale-control">
|
|
<span class="friend-view__scale-value"
|
|
>{{ cardScalePercentLabel }} </span
|
|
>
|
|
<Slider
|
|
v-model="cardScaleValue"
|
|
class="friend-view__slider"
|
|
:min="0.5"
|
|
:max="1.0"
|
|
:step="0.01" />
|
|
</div>
|
|
</FieldContent>
|
|
</Field>
|
|
<Field orientation="horizontal" class="friend-view__settings-row">
|
|
<FieldLabel class="friend-view__settings-label">
|
|
{{ t('view.friends_locations.spacing') }}
|
|
</FieldLabel>
|
|
<FieldContent>
|
|
<div class="friend-view__scale-control">
|
|
<span class="friend-view__scale-value"
|
|
>{{ cardSpacingPercentLabel }} </span
|
|
>
|
|
<Slider
|
|
v-model="cardSpacingValue"
|
|
class="friend-view__slider"
|
|
:min="0.25"
|
|
:max="1.0"
|
|
:step="0.05" />
|
|
</div>
|
|
</FieldContent>
|
|
</Field>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
</TooltipWrapper>
|
|
</div>
|
|
</div>
|
|
<div v-else class="friend-view__toolbar friend-view__toolbar--loading">
|
|
<span class="friend-view__loading-text">{{ t('view.friends_locations.loading_more') }}</span>
|
|
</div>
|
|
<div v-if="settingsReady" ref="scrollbarRef" class="friend-view__scroll">
|
|
<div v-if="virtualRows.length" class="friend-view__virtual" :style="virtualContainerStyle">
|
|
<template v-for="item in virtualItems" :key="String(item.virtualItem.key)">
|
|
<div
|
|
v-if="item.row"
|
|
class="friend-view__virtual-row"
|
|
:class="`friend-view__virtual-row--${item.row.type}`"
|
|
:data-index="item.virtualItem.index"
|
|
:ref="virtualizer.measureElement"
|
|
:style="{ transform: `translateY(${item.virtualItem.start}px)` }">
|
|
<template v-if="item.row.type === 'header'">
|
|
<header class="friend-view__instance-header">
|
|
<Location
|
|
class="text-xs"
|
|
:location="getRowInstanceId(item.row)"
|
|
style="display: inline" />
|
|
<span class="friend-view__instance-count">({{ getRowCount(item.row) }})</span>
|
|
</header>
|
|
</template>
|
|
|
|
<template v-else-if="item.row.type === 'divider'">
|
|
<div class="friend-view__divider"><span class="friend-view__divider-text"></span></div>
|
|
</template>
|
|
|
|
<template v-else>
|
|
<div class="friend-view__row">
|
|
<FriendLocationCard
|
|
v-for="card in getRowItems(item.row)"
|
|
:key="card.key"
|
|
:friend="card.friend"
|
|
:card-scale="cardScale"
|
|
:card-spacing="cardSpacing"
|
|
:display-instance-info="card.displayInstanceInfo" />
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<div v-else class="friend-view__empty">
|
|
<DataTableEmpty type="nomatch" />
|
|
</div>
|
|
</div>
|
|
<div v-else class="friend-view__initial-loading">
|
|
<Loader2 class="friend-view__loading-icon" :size="22" />
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, nextTick, onBeforeMount, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
|
import { Field, FieldContent, FieldLabel } from '@/components/ui/field';
|
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { Loader2, Settings } from 'lucide-vue-next';
|
|
import { Button } from '@/components/ui/button';
|
|
import { DataTableEmpty } from '@/components/ui/data-table';
|
|
import { InputGroupSearch } from '@/components/ui/input-group';
|
|
import { storeToRefs } from 'pinia';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { useVirtualizer } from '@tanstack/vue-virtual';
|
|
|
|
import { Popover, PopoverContent, PopoverTrigger } from '../../components/ui/popover';
|
|
import { Slider } from '../../components/ui/slider';
|
|
import { Switch } from '../../components/ui/switch';
|
|
import { getFriendsLocations } from '../../shared/utils/location.js';
|
|
import { useFriendStore } from '../../stores';
|
|
|
|
import FriendLocationCard from './components/FriendsLocationsCard.vue';
|
|
import configRepository from '../../service/config.js';
|
|
|
|
const { t } = useI18n();
|
|
|
|
const friendStore = useFriendStore();
|
|
const { onlineFriends, vipFriends, activeFriends, offlineFriends, friendsInSameInstance } =
|
|
storeToRefs(friendStore);
|
|
|
|
const SEGMENTED_BASE_OPTIONS = [
|
|
{ label: t('view.friends_locations.online'), value: 'online' },
|
|
{ label: t('view.friends_locations.favorite'), value: 'favorite' },
|
|
{ label: t('view.friends_locations.same_instance'), value: 'same-instance' },
|
|
{ label: t('view.friends_locations.active'), value: 'active' },
|
|
{ label: t('view.friends_locations.offline'), value: 'offline' }
|
|
];
|
|
|
|
const segmentedOptions = computed(() =>
|
|
showSameInstance.value
|
|
? SEGMENTED_BASE_OPTIONS
|
|
: SEGMENTED_BASE_OPTIONS.filter((option) => option.value !== 'same-instance')
|
|
);
|
|
|
|
const cardScaleBase = ref(1);
|
|
const cardSpacingBase = ref(1);
|
|
|
|
const cardScale = computed({
|
|
get: () => cardScaleBase.value,
|
|
set: (value) => {
|
|
cardScaleBase.value = value;
|
|
configRepository.setString('VRCX_FriendLocationCardScale', value.toString());
|
|
}
|
|
});
|
|
|
|
const cardSpacing = computed({
|
|
get: () => cardSpacingBase.value,
|
|
set: (value) => {
|
|
cardSpacingBase.value = value;
|
|
configRepository.setString('VRCX_FriendLocationCardSpacing', value.toString());
|
|
}
|
|
});
|
|
|
|
const cardScalePercentLabel = computed(() => `${Math.round(cardScale.value * 100)}%`);
|
|
const cardSpacingPercentLabel = computed(() => `${Math.round(cardSpacing.value * 100)}%`);
|
|
const cardScaleValue = computed({
|
|
get: () => [cardScale.value],
|
|
set: (value) => {
|
|
const next = value?.[0];
|
|
if (typeof next === 'number') {
|
|
cardScale.value = next;
|
|
}
|
|
}
|
|
});
|
|
const cardSpacingValue = computed({
|
|
get: () => [cardSpacing.value],
|
|
set: (value) => {
|
|
const next = value?.[0];
|
|
if (typeof next === 'number') {
|
|
cardSpacing.value = next;
|
|
}
|
|
}
|
|
});
|
|
|
|
const showSameInstanceBase = ref(false);
|
|
|
|
const showSameInstance = computed({
|
|
get: () => showSameInstanceBase.value,
|
|
set: (value) => {
|
|
showSameInstanceBase.value = value;
|
|
configRepository.setBool('VRCX_FriendLocationShowSameInstance', value);
|
|
}
|
|
});
|
|
|
|
const settingsReady = ref(false);
|
|
|
|
const activeSegment = ref('online');
|
|
const searchTerm = ref('');
|
|
|
|
const scrollbarRef = ref();
|
|
const gridWidth = ref(0);
|
|
let resizeObserver;
|
|
let cleanupResize;
|
|
|
|
const updateGridWidth = () => {
|
|
const wrap = scrollbarRef.value;
|
|
if (!wrap) {
|
|
return;
|
|
}
|
|
|
|
gridWidth.value = wrap.clientWidth ?? 0;
|
|
};
|
|
|
|
const setupResizeHandling = () => {
|
|
if (cleanupResize) {
|
|
cleanupResize();
|
|
cleanupResize = undefined;
|
|
}
|
|
|
|
const wrap = scrollbarRef.value;
|
|
if (!wrap) {
|
|
return;
|
|
}
|
|
|
|
updateGridWidth();
|
|
|
|
if (typeof ResizeObserver !== 'undefined') {
|
|
resizeObserver = new ResizeObserver((entries) => {
|
|
if (!entries || entries.length === 0) {
|
|
return;
|
|
}
|
|
const [entry] = entries;
|
|
gridWidth.value = entry.contentRect?.width ?? wrap.clientWidth ?? 0;
|
|
});
|
|
resizeObserver.observe(wrap);
|
|
cleanupResize = () => {
|
|
resizeObserver?.disconnect();
|
|
resizeObserver = undefined;
|
|
};
|
|
return;
|
|
}
|
|
|
|
if (typeof window !== 'undefined') {
|
|
const handleResize = () => {
|
|
updateGridWidth();
|
|
};
|
|
window.addEventListener('resize', handleResize, { passive: true });
|
|
cleanupResize = () => {
|
|
window.removeEventListener('resize', handleResize);
|
|
};
|
|
}
|
|
};
|
|
|
|
const normalizedSearchTerm = computed(() => searchTerm.value.trim().toLowerCase());
|
|
|
|
const toEntries = (list = [], instanceId) =>
|
|
Array.isArray(list)
|
|
? list.map((friend) => ({
|
|
id: friend.id ?? friend.userId ?? friend.displayName,
|
|
friend,
|
|
instanceId
|
|
}))
|
|
: [];
|
|
|
|
const sameInstanceGroups = computed(() => {
|
|
const source = friendsInSameInstance?.value;
|
|
if (!Array.isArray(source) || source.length === 0) return [];
|
|
|
|
return source
|
|
.map((group, index) => {
|
|
if (!Array.isArray(group) || group.length === 0) return null;
|
|
const friends = group;
|
|
const instanceId = getFriendsLocations(friends) || `instance-${index + 1}`;
|
|
return {
|
|
instanceId: String(instanceId),
|
|
friends
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
});
|
|
|
|
const sameInstanceEntries = computed(() =>
|
|
sameInstanceGroups.value.flatMap((group) => toEntries(group.friends, group.instanceId))
|
|
);
|
|
|
|
const uniqueEntries = (entries = []) => {
|
|
const seen = new Set();
|
|
return entries.filter((entry) => {
|
|
const key = entry.id;
|
|
if (!key) {
|
|
return true;
|
|
}
|
|
if (seen.has(key)) {
|
|
return false;
|
|
}
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
};
|
|
|
|
const filteredFriends = computed(() => {
|
|
if (normalizedSearchTerm.value) {
|
|
const pools = [
|
|
...toEntries(vipFriends.value),
|
|
...toEntries(onlineFriends.value),
|
|
...toEntries(activeFriends.value),
|
|
...toEntries(offlineFriends.value)
|
|
];
|
|
|
|
return uniqueEntries(pools).filter(({ friend }) => {
|
|
const haystack =
|
|
`${friend.displayName ?? friend.name ?? ''} ${friend.signature ?? ''} ${friend.worldName ?? ''}`.toLowerCase();
|
|
return haystack.includes(normalizedSearchTerm.value);
|
|
});
|
|
}
|
|
|
|
switch (activeSegment.value) {
|
|
case 'online': {
|
|
if (!showSameInstance.value) {
|
|
const sameEntries = sameInstanceEntries.value.map((entry) => ({
|
|
...entry,
|
|
section: 'same-instance'
|
|
}));
|
|
|
|
const seenIds = new Set(
|
|
sameEntries
|
|
.map((entry) => entry.id)
|
|
.filter((id) => typeof id === 'string' || typeof id === 'number')
|
|
);
|
|
|
|
const remainingOnline = toEntries(onlineFriends.value)
|
|
.filter((entry) => {
|
|
if (!entry?.id) {
|
|
return true;
|
|
}
|
|
return !seenIds.has(entry.id);
|
|
})
|
|
.map((entry) => ({
|
|
...entry,
|
|
section: 'online'
|
|
}));
|
|
|
|
return [...sameEntries, ...remainingOnline];
|
|
}
|
|
|
|
return toEntries(onlineFriends.value);
|
|
}
|
|
case 'favorite':
|
|
return toEntries(vipFriends.value);
|
|
case 'same-instance':
|
|
return sameInstanceEntries.value;
|
|
case 'active':
|
|
return toEntries(activeFriends.value);
|
|
case 'offline':
|
|
return toEntries(offlineFriends.value);
|
|
default:
|
|
return [];
|
|
}
|
|
});
|
|
|
|
const isSameInstanceView = computed(
|
|
() => showSameInstance.value && activeSegment.value === 'same-instance' && !normalizedSearchTerm.value
|
|
);
|
|
|
|
const shouldMergeSameInstance = computed(
|
|
() => !showSameInstance.value && activeSegment.value === 'online' && !normalizedSearchTerm.value
|
|
);
|
|
|
|
const buildSameInstanceGroups = (entries = []) => {
|
|
if (!Array.isArray(entries) || entries.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const grouped = new Map();
|
|
|
|
for (const entry of entries) {
|
|
const bucketId = entry.instanceId ?? entry.friend?.ref?.location ?? null;
|
|
if (!bucketId) {
|
|
continue;
|
|
}
|
|
if (!grouped.has(bucketId)) {
|
|
grouped.set(bucketId, []);
|
|
}
|
|
grouped.get(bucketId).push(entry.friend);
|
|
}
|
|
|
|
return sameInstanceGroups.value
|
|
.filter((group) => grouped.has(group.instanceId))
|
|
.map((group) => ({
|
|
instanceId: group.instanceId,
|
|
friends: grouped.get(group.instanceId) ?? []
|
|
}));
|
|
};
|
|
|
|
const sameInstanceGroupsForVirtual = computed(() => {
|
|
if (!isSameInstanceView.value) {
|
|
return [];
|
|
}
|
|
return buildSameInstanceGroups(filteredFriends.value);
|
|
});
|
|
|
|
const mergedSameInstanceEntries = computed(() => {
|
|
if (!shouldMergeSameInstance.value) {
|
|
return [];
|
|
}
|
|
return filteredFriends.value.filter((entry) => entry.section === 'same-instance');
|
|
});
|
|
|
|
const mergedOnlineEntries = computed(() => {
|
|
if (!shouldMergeSameInstance.value) {
|
|
return [];
|
|
}
|
|
return filteredFriends.value.filter((entry) => entry.section !== 'same-instance');
|
|
});
|
|
|
|
const mergedSameInstanceGroups = computed(() => {
|
|
if (!shouldMergeSameInstance.value) {
|
|
return [];
|
|
}
|
|
return buildSameInstanceGroups(mergedSameInstanceEntries.value);
|
|
});
|
|
|
|
const gridStyle = computed(() => {
|
|
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;
|
|
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 {
|
|
minWidth,
|
|
gap,
|
|
columns,
|
|
cardWidth
|
|
};
|
|
};
|
|
|
|
const chunkCardItems = (items = [], keyPrefix = 'row') => {
|
|
const safeItems = Array.isArray(items) ? items : [];
|
|
if (!safeItems.length) {
|
|
return [];
|
|
}
|
|
const { columns } = getGridMetrics(safeItems.length, { matchMaxColumnWidth: true });
|
|
const safeColumns = Math.max(1, columns || 1);
|
|
const rows = [];
|
|
|
|
for (let index = 0; index < safeItems.length; index += safeColumns) {
|
|
rows.push({
|
|
type: 'cards',
|
|
key: `${keyPrefix}:${index}`,
|
|
items: safeItems.slice(index, index + safeColumns)
|
|
});
|
|
}
|
|
|
|
return rows;
|
|
};
|
|
|
|
const virtualRows = computed(() => {
|
|
const rows = [];
|
|
|
|
if (isSameInstanceView.value) {
|
|
for (const group of sameInstanceGroupsForVirtual.value) {
|
|
rows.push({
|
|
type: 'header',
|
|
key: `h:${group.instanceId}`,
|
|
instanceId: group.instanceId,
|
|
count: Array.isArray(group.friends) ? group.friends.length : 0
|
|
});
|
|
|
|
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()}`,
|
|
friend,
|
|
displayInstanceInfo: true
|
|
}));
|
|
rows.push(...chunkCardItems(items, `g:${group.instanceId}`));
|
|
}
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
|
|
if (shouldMergeSameInstance.value) {
|
|
for (const group of mergedSameInstanceGroups.value) {
|
|
rows.push({
|
|
type: 'header',
|
|
key: `h:${group.instanceId}`,
|
|
instanceId: group.instanceId,
|
|
count: Array.isArray(group.friends) ? group.friends.length : 0
|
|
});
|
|
|
|
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()}`,
|
|
friend,
|
|
displayInstanceInfo: false
|
|
}));
|
|
rows.push(...chunkCardItems(items, `mg:${group.instanceId}`));
|
|
}
|
|
}
|
|
|
|
if (mergedSameInstanceGroups.value.length && mergedOnlineEntries.value.length) {
|
|
rows.push({ type: 'divider', key: 'divider:merged' });
|
|
}
|
|
|
|
const online = mergedOnlineEntries.value;
|
|
if (online.length) {
|
|
const items = online.map((entry) => ({
|
|
key: `e:${entry?.id ?? entry?.friend?.id ?? entry?.friend?.displayName ?? Math.random()}`,
|
|
friend: entry.friend,
|
|
displayInstanceInfo: true
|
|
}));
|
|
rows.push(...chunkCardItems(items, 'o:merged'));
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
|
|
const entries = filteredFriends.value;
|
|
if (entries.length) {
|
|
const items = entries.map((entry) => ({
|
|
key: `e:${entry?.id ?? entry?.friend?.id ?? entry?.friend?.displayName ?? Math.random()}`,
|
|
friend: entry.friend,
|
|
displayInstanceInfo: true
|
|
}));
|
|
rows.push(...chunkCardItems(items, 'r:all'));
|
|
}
|
|
return rows;
|
|
});
|
|
|
|
const virtualListStyle = computed(() => {
|
|
const styleFn = gridStyle.value;
|
|
const total = filteredFriends.value.length;
|
|
|
|
// Use matchMaxColumnWidth so rows don't collapse to minWidth on short rows.
|
|
const vars = typeof styleFn === 'function' ? styleFn(total, { matchMaxColumnWidth: true }) : {};
|
|
return {
|
|
...vars
|
|
};
|
|
});
|
|
|
|
const estimateRowSize = (row) => {
|
|
if (!row) {
|
|
return 48;
|
|
}
|
|
if (row.type === 'header') {
|
|
return 32;
|
|
}
|
|
if (row.type === 'divider') {
|
|
return 36;
|
|
}
|
|
|
|
const itemCount = Array.isArray(row.items) ? row.items.length : 0;
|
|
const { columns, gap } = getGridMetrics(itemCount, { matchMaxColumnWidth: true });
|
|
const safeColumns = Math.max(1, columns || 1);
|
|
const rows = Math.max(1, Math.ceil(itemCount / safeColumns));
|
|
const scale = cardScale.value;
|
|
const spacing = cardSpacing.value;
|
|
const baseCardHeight = 150;
|
|
const cardHeight = baseCardHeight * scale * spacing;
|
|
const rowGap = Math.max(0, gap - 4);
|
|
|
|
return rows * cardHeight + (rows - 1) * rowGap + 8;
|
|
};
|
|
|
|
const virtualizer = useVirtualizer(
|
|
computed(() => ({
|
|
count: virtualRows.value.length,
|
|
getScrollElement: () => scrollbarRef.value,
|
|
estimateSize: (index) => estimateRowSize(virtualRows.value[index]),
|
|
overscan: 5
|
|
}))
|
|
);
|
|
|
|
const virtualItems = computed(() => {
|
|
const items = virtualizer.value?.getVirtualItems?.() ?? [];
|
|
return items.map((virtualItem) => ({
|
|
virtualItem,
|
|
row: virtualRows.value[virtualItem.index]
|
|
}));
|
|
});
|
|
|
|
const virtualContainerStyle = computed(() => ({
|
|
...virtualListStyle.value,
|
|
height: `${virtualizer.value?.getTotalSize?.() ?? 0}px`
|
|
}));
|
|
|
|
const getRowItems = (row) => (row && Array.isArray(row.items) ? row.items : []);
|
|
const getRowInstanceId = (row) => (row && row.type === 'header' ? row.instanceId : '');
|
|
const getRowCount = (row) => (row && row.type === 'header' ? row.count : 0);
|
|
|
|
watch([searchTerm, activeSegment], () => {
|
|
nextTick(() => {
|
|
updateGridWidth();
|
|
virtualizer.value?.measure?.();
|
|
});
|
|
});
|
|
|
|
watch(showSameInstance, (value) => {
|
|
if (!settingsReady.value) {
|
|
return;
|
|
}
|
|
if (!value && activeSegment.value === 'same-instance') {
|
|
activeSegment.value = 'online';
|
|
}
|
|
|
|
nextTick(() => {
|
|
updateGridWidth();
|
|
virtualizer.value?.measure?.();
|
|
});
|
|
});
|
|
|
|
watch(
|
|
() => filteredFriends.value.length,
|
|
() => {
|
|
nextTick(() => {
|
|
updateGridWidth();
|
|
virtualizer.value?.measure?.();
|
|
});
|
|
}
|
|
);
|
|
|
|
watch([cardScale, cardSpacing], () => {
|
|
if (!settingsReady.value) {
|
|
return;
|
|
}
|
|
nextTick(() => {
|
|
updateGridWidth();
|
|
virtualizer.value?.measure?.();
|
|
});
|
|
});
|
|
|
|
watch(virtualRows, () => {
|
|
nextTick(() => {
|
|
virtualizer.value?.measure?.();
|
|
});
|
|
});
|
|
|
|
onMounted(() => {
|
|
nextTick(() => {
|
|
setupResizeHandling();
|
|
updateGridWidth();
|
|
virtualizer.value?.measure?.();
|
|
});
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
if (cleanupResize) {
|
|
cleanupResize();
|
|
cleanupResize = undefined;
|
|
}
|
|
});
|
|
|
|
async function loadInitialSettings() {
|
|
try {
|
|
const [storedScale, storedSpacing, storedShowSameInstance] = await Promise.all([
|
|
configRepository.getString('VRCX_FriendLocationCardScale', '1'),
|
|
configRepository.getString('VRCX_FriendLocationCardSpacing', '1'),
|
|
configRepository.getBool('VRCX_FriendLocationShowSameInstance', null)
|
|
]);
|
|
|
|
const parsedScale = parseFloat(storedScale);
|
|
if (!Number.isNaN(parsedScale) && parsedScale > 0) {
|
|
cardScaleBase.value = parsedScale;
|
|
}
|
|
|
|
const parsedSpacing = parseFloat(storedSpacing);
|
|
if (!Number.isNaN(parsedSpacing) && parsedSpacing > 0) {
|
|
cardSpacingBase.value = parsedSpacing;
|
|
}
|
|
|
|
if (storedShowSameInstance !== null && storedShowSameInstance !== undefined) {
|
|
showSameInstanceBase.value = Boolean(storedShowSameInstance);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load Friend Location preferences', error);
|
|
} finally {
|
|
settingsReady.value = true;
|
|
nextTick(() => {
|
|
setupResizeHandling();
|
|
updateGridWidth();
|
|
virtualizer.value?.measure?.();
|
|
});
|
|
}
|
|
}
|
|
|
|
onBeforeMount(() => {
|
|
loadInitialSettings();
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.friend-view {
|
|
display: grid;
|
|
grid-template-rows: auto 1fr;
|
|
gap: 16px;
|
|
min-height: 0;
|
|
height: 100%;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.friend-view.x-container {
|
|
overflow: hidden;
|
|
}
|
|
|
|
.friend-view__toolbar {
|
|
display: flex;
|
|
gap: 20px;
|
|
align-items: center;
|
|
padding: 6px 2px 0 2px;
|
|
}
|
|
|
|
.friend-view__tabs {
|
|
gap: 0;
|
|
}
|
|
|
|
.friend-view__toolbar--loading {
|
|
justify-content: flex-end;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.friend-view__settings {
|
|
display: grid;
|
|
gap: 12px;
|
|
}
|
|
|
|
.friend-view__loading-text {
|
|
padding-right: 12px;
|
|
}
|
|
|
|
.friend-view__actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
flex: 1;
|
|
flex-wrap: wrap;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.friend-view__virtual {
|
|
width: 100%;
|
|
padding: 2px;
|
|
box-sizing: border-box;
|
|
position: relative;
|
|
}
|
|
|
|
.friend-view__virtual-row {
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
padding-bottom: calc(var(--friend-card-gap, 14px) - 4px);
|
|
}
|
|
|
|
.friend-view__virtual-row--header {
|
|
padding: 4px 10px;
|
|
padding-bottom: calc(var(--friend-card-gap, 14px) - 4px);
|
|
}
|
|
|
|
.friend-view__virtual-row--divider {
|
|
padding: 16px 4px;
|
|
padding-bottom: calc(var(--friend-card-gap, 14px) - 4px);
|
|
}
|
|
|
|
.friend-view__virtual-row--cards {
|
|
padding: 2px;
|
|
padding-bottom: calc(var(--friend-card-gap, 14px) - 4px);
|
|
}
|
|
|
|
.friend-view__row {
|
|
display: grid;
|
|
grid-template-columns: repeat(
|
|
var(--friend-grid-columns, 1),
|
|
minmax(var(--friend-card-min-width, 200px), var(--friend-card-target-width, 1fr))
|
|
);
|
|
gap: var(--friend-card-gap, 14px);
|
|
justify-content: start;
|
|
padding: 2px;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.friend-view__settings-label {
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
margin-right: 8px;
|
|
}
|
|
|
|
.friend-view__settings-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
}
|
|
|
|
.friend-view__scale-control {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
min-width: 160px;
|
|
}
|
|
|
|
.friend-view__scale-value {
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
min-width: 42px;
|
|
text-align: right;
|
|
}
|
|
|
|
.friend-view__slider {
|
|
width: 160px;
|
|
margin-right: 12px;
|
|
}
|
|
|
|
.friend-view__search {
|
|
width: 240px;
|
|
flex: 1;
|
|
}
|
|
|
|
.friend-view__scroll {
|
|
overflow: auto;
|
|
min-height: 0;
|
|
height: 100%;
|
|
}
|
|
|
|
.friend-view__initial-loading {
|
|
display: grid;
|
|
place-items: center;
|
|
min-height: 240px;
|
|
}
|
|
|
|
.friend-view__instance-header {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 4px 2px;
|
|
font-weight: 600;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.friend-view__divider {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.friend-view__divider::before,
|
|
.friend-view__divider::after {
|
|
content: '';
|
|
flex: 1;
|
|
height: 1px;
|
|
}
|
|
|
|
.friend-view__divider-text {
|
|
flex: none;
|
|
}
|
|
|
|
.friend-view__instance-count {
|
|
font-size: 12px;
|
|
}
|
|
|
|
.friend-view__empty {
|
|
display: grid;
|
|
place-items: center;
|
|
min-height: 240px;
|
|
font-size: 15px;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.friend-view__loading-icon {
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
from {
|
|
transform: rotate(0);
|
|
}
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
</style>
|