fix friends locations

This commit is contained in:
pa
2026-01-16 12:39:07 +09:00
committed by Natsumi
parent 1c1dd2ebc3
commit 8fd24d2914

View File

@@ -58,99 +58,45 @@
<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>
<ScrollArea v-if="settingsReady" ref="scrollbarRef" class="friend-view__scroll" @scroll="handleScroll">
<template v-if="isSameInstanceView">
<div v-if="visibleSameInstanceGroups.length" class="friend-view__instances">
<section
v-for="group in visibleSameInstanceGroups"
:key="group.instanceId"
class="friend-view__instance">
<header class="friend-view__instance-header">
<Location class="extra" :location="group.instanceId" style="display: inline" />
<span class="friend-view__instance-count">{{ group.friends.length }}</span>
</header>
<div
class="friend-view__grid"
:style="
gridStyle(group.friends.length, {
preferredColumns: sameInstanceColumnTarget,
disableAutoStretch: true,
matchMaxColumnWidth: true
})
">
<FriendLocationCard
v-for="friend in group.friends"
:key="friend.id ?? friend.userId ?? friend.displayName"
:friend="friend"
:card-scale="cardScale"
:card-spacing="cardSpacing" />
</div>
</section>
<ScrollArea v-if="settingsReady" ref="scrollbarRef" class="friend-view__scroll">
<div v-if="virtualRows.length" class="friend-view__virtual" :style="virtualListStyle">
<div class="friend-view__virtual-spacer" :style="virtualSpacerStyle">
<div
v-for="vRow in virtualItems"
:key="String(virtualRows[vRow.index]?.key ?? vRow.key)"
class="friend-view__virtual-row"
:data-index="vRow.index"
:ref="(el) => onVirtualRowRef(el)"
:style="virtualRowStyle(vRow.start)">
<template v-if="virtualRows[vRow.index]?.type === 'header'">
<header class="friend-view__instance-header">
<Location
class="extra"
:location="virtualRows[vRow.index].instanceId"
style="display: inline" />
<span class="friend-view__instance-count">{{ virtualRows[vRow.index].count }}</span>
</header>
</template>
<template v-else-if="virtualRows[vRow.index]?.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="item in virtualRows[vRow.index]?.items ?? []"
:key="item.key"
:friend="item.friend"
:card-scale="cardScale"
:card-spacing="cardSpacing"
:display-instance-info="item.displayInstanceInfo" />
</div>
</template>
</div>
</div>
<div v-else class="friend-view__empty">{{ t('view.friends_locations.no_matching_friends') }}</div>
</template>
<template v-else-if="shouldMergeSameInstance">
<div v-if="mergedSameInstanceGroups.length" class="friend-view__instances">
<section
v-for="group in mergedSameInstanceGroups"
:key="group.instanceId"
class="friend-view__instance">
<header class="friend-view__instance-header">
<Location class="extra" :location="group.instanceId" style="display: inline" />
<span class="friend-view__instance-count">{{ group.friends.length }}</span>
</header>
<div
class="friend-view__grid"
:style="
gridStyle(group.friends.length, {
preferredColumns: sameInstanceColumnTarget,
disableAutoStretch: true,
matchMaxColumnWidth: true
})
">
<FriendLocationCard
v-for="friend in group.friends"
:key="friend.id ?? friend.userId ?? friend.displayName"
:friend="friend"
:card-scale="cardScale"
:card-spacing="cardSpacing"
:display-instance-info="false" />
</div>
</section>
</div>
<div v-if="mergedSameInstanceGroups.length && mergedOnlineEntries.length" class="friend-view__divider">
<span class="friend-view__divider-text"></span>
</div>
<div
v-if="mergedOnlineEntries.length"
class="friend-view__grid"
:style="gridStyle(mergedOnlineEntries.length)">
<FriendLocationCard
v-for="entry in mergedOnlineEntries"
:key="entry.id ?? entry.friend.id ?? entry.friend.displayName"
:friend="entry.friend"
:card-scale="cardScale"
:card-spacing="cardSpacing" />
</div>
<div v-if="!mergedSameInstanceGroups.length && !mergedOnlineEntries.length" class="friend-view__empty">
{{ t('view.friends_locations.no_matching_friends') }}
</div>
</template>
<template v-else>
<div v-if="visibleFriends.length" class="friend-view__grid" :style="gridStyle(visibleFriends.length)">
<FriendLocationCard
v-for="entry in visibleFriends"
:key="entry.id ?? entry.friend.id ?? entry.friend.displayName"
:friend="entry.friend"
:card-scale="cardScale"
:card-spacing="cardSpacing" />
</div>
<div v-else class="friend-view__empty">{{ t('view.friends_locations.no_matching_friends') }}</div>
</template>
<div v-if="isLoadingMore" class="friend-view__loading">
<Loader2 class="friend-view__loading-icon" :size="18" />
<span>{{ t('view.friends_locations.loading_more') }}</span>
</div>
<div v-else class="friend-view__empty">{{ t('view.friends_locations.no_matching_friends') }}</div>
</ScrollArea>
<div v-else class="friend-view__initial-loading">
<Loader2 class="friend-view__loading-icon" :size="22" />
@@ -159,7 +105,7 @@
</template>
<script setup>
import { computed, nextTick, onBeforeMount, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { computed, nextTick, onBeforeMount, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Loader2, Settings } from 'lucide-vue-next';
import { Button } from '@/components/ui/button';
@@ -167,6 +113,7 @@
import { ScrollArea } from '@/components/ui/scroll-area';
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';
@@ -249,21 +196,17 @@
const settingsReady = ref(false);
const PAGE_SIZE = 18;
const VIEWPORT_BUFFER = 32;
const activeSegment = ref('online');
const searchTerm = ref('');
const itemsToShow = ref(PAGE_SIZE);
const isLoadingMore = ref(false);
const scrollbarRef = ref();
const scrollViewportRef = shallowRef(null);
const gridWidth = ref(0);
let resizeObserver;
let cleanupResize;
const updateGridWidth = () => {
const wrap = scrollbarRef.value?.viewportEl?.value;
const wrap = scrollViewportRef.value;
if (!wrap) {
return;
}
@@ -277,7 +220,7 @@
cleanupResize = undefined;
}
const wrap = scrollbarRef.value?.viewportEl?.value;
const wrap = scrollViewportRef.value;
if (!wrap) {
return;
}
@@ -418,8 +361,6 @@
}
});
const visibleFriends = computed(() => filteredFriends.value.slice(0, itemsToShow.value));
const isSameInstanceView = computed(
() => showSameInstance.value && activeSegment.value === 'same-instance' && !normalizedSearchTerm.value
);
@@ -454,56 +395,34 @@
}));
};
const visibleSameInstanceGroups = computed(() => {
const sameInstanceGroupsForVirtual = computed(() => {
if (!isSameInstanceView.value) {
return [];
}
return buildSameInstanceGroups(visibleFriends.value);
return buildSameInstanceGroups(filteredFriends.value);
});
const mergedSameInstanceEntries = computed(() => {
if (!shouldMergeSameInstance.value) {
return [];
}
return visibleFriends.value.filter((entry) => entry.section === 'same-instance');
return filteredFriends.value.filter((entry) => entry.section === 'same-instance');
});
const mergedOnlineEntries = computed(() => {
if (!shouldMergeSameInstance.value) {
return [];
}
return visibleFriends.value.filter((entry) => entry.section !== 'same-instance');
return filteredFriends.value.filter((entry) => entry.section !== 'same-instance');
});
const mergedSameInstanceGroups = computed(() => {
if (!shouldMergeSameInstance.value) {
return [];
}
return buildSameInstanceGroups(mergedSameInstanceEntries.value);
});
const sameInstanceColumnTarget = computed(() => {
const groups = isSameInstanceView.value
? visibleSameInstanceGroups.value
: shouldMergeSameInstance.value
? mergedSameInstanceGroups.value
: [];
let maxCount = 0;
for (const group of groups) {
const size = Array.isArray(group?.friends) ? group.friends.length : 0;
if (size > maxCount) {
maxCount = size;
}
}
return maxCount > 0 ? maxCount : null;
});
const gridStyle = computed(() => {
const baseWidth = 220;
const baseGap = 14;
@@ -551,92 +470,191 @@
};
});
const handleScroll = () => {
if (
isLoadingMore.value ||
filteredFriends.value.length === 0 ||
itemsToShow.value >= filteredFriends.value.length
) {
function resolveScrollViewport() {
const rootEl = scrollbarRef.value?.$el ?? null;
if (!rootEl) {
scrollViewportRef.value = null;
return;
}
const wrap = scrollbarRef.value?.viewportEl?.value;
if (!wrap) {
return;
}
const { scrollHeight, scrollTop, clientHeight } = wrap;
if (scrollTop + clientHeight >= scrollHeight - 120) {
loadMoreFriends();
}
};
function loadMoreFriends() {
if (isLoadingMore.value || itemsToShow.value >= filteredFriends.value.length) {
return;
}
isLoadingMore.value = true;
window.setTimeout(() => {
if (itemsToShow.value < filteredFriends.value.length) {
itemsToShow.value = Math.min(itemsToShow.value + PAGE_SIZE, filteredFriends.value.length);
}
isLoadingMore.value = false;
maybeFillViewport();
}, 350);
scrollViewportRef.value = rootEl.querySelector('[data-slot="scroll-area-viewport"]');
}
function maybeFillViewport() {
nextTick(() => {
const wrap = scrollbarRef.value?.viewportEl?.value;
if (!wrap) {
return;
}
const maxColumns = computed(() => {
const styleFn = gridStyle.value;
if (typeof styleFn !== 'function') {
return 1;
}
const { scrollHeight, clientHeight } = wrap;
const hasSpace = scrollHeight <= clientHeight + VIEWPORT_BUFFER;
const containerWidth = Math.max(gridWidth.value ?? 0, 0);
if (!hasSpace || isLoadingMore.value) {
return;
}
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);
if (filteredFriends.value.length > visibleFriends.value.length) {
loadMoreFriends();
}
});
}
watch([searchTerm, activeSegment], () => {
itemsToShow.value = PAGE_SIZE;
nextTick(() => {
updateGridWidth();
maybeFillViewport();
});
return Math.max(1, Math.floor((containerWidth + gap) / (minWidth + gap)) || 1);
});
watch(
() => filteredFriends.value.length,
(length) => {
if (itemsToShow.value > length) {
itemsToShow.value = length;
const chunk = (items = [], size = 1) => {
const out = [];
const n = Math.max(1, Math.floor(size) || 1);
for (let i = 0; i < items.length; i += n) {
out.push(items.slice(i, i + n));
}
return out;
};
const virtualRows = computed(() => {
const rows = [];
const columns = maxColumns.value;
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 : [];
for (const rowFriends of chunk(friends, Math.min(columns, friends.length || 1))) {
rows.push({
type: 'cards',
key: `g:${group.instanceId}:${rowFriends
.map((f) => f?.id ?? f?.userId ?? f?.displayName ?? '')
.join('|')}`,
items: rowFriends.map((friend) => ({
key: `f:${friend?.id ?? friend?.userId ?? friend?.displayName ?? Math.random()}`,
friend,
displayInstanceInfo: true
}))
});
}
}
nextTick(() => {
updateGridWidth();
maybeFillViewport();
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 : [];
for (const rowFriends of chunk(friends, Math.min(columns, friends.length || 1))) {
rows.push({
type: 'cards',
key: `mg:${group.instanceId}:${rowFriends
.map((f) => f?.id ?? f?.userId ?? f?.displayName ?? '')
.join('|')}`,
items: rowFriends.map((friend) => ({
key: `f:${friend?.id ?? friend?.userId ?? friend?.displayName ?? Math.random()}`,
friend,
displayInstanceInfo: false
}))
});
}
}
if (mergedSameInstanceGroups.value.length && mergedOnlineEntries.value.length) {
rows.push({ type: 'divider', key: 'divider:merged' });
}
const online = mergedOnlineEntries.value;
for (const rowEntries of chunk(online, Math.min(columns, online.length || 1))) {
rows.push({
type: 'cards',
key: `o:${rowEntries.map((e) => e?.id ?? '').join('|')}`,
items: rowEntries.map((entry) => ({
key: `e:${entry?.id ?? entry?.friend?.id ?? entry?.friend?.displayName ?? Math.random()}`,
friend: entry.friend,
displayInstanceInfo: true
}))
});
}
return rows;
}
const entries = filteredFriends.value;
for (const rowEntries of chunk(entries, Math.min(columns, entries.length || 1))) {
rows.push({
type: 'cards',
key: `r:${rowEntries.map((e) => e?.id ?? '').join('|')}`,
items: rowEntries.map((entry) => ({
key: `e:${entry?.id ?? entry?.friend?.id ?? entry?.friend?.displayName ?? Math.random()}`,
friend: entry.friend,
displayInstanceInfo: true
}))
});
}
return rows;
});
const estimatedRowHeight = computed(() => {
const base = 148;
return Math.max(64, Math.round(base * cardScale.value * cardSpacing.value));
});
const virtualizerRef = useVirtualizer(
computed(() => ({
count: virtualRows.value.length,
getScrollElement: () => scrollViewportRef.value,
estimateSize: (index) => {
const row = virtualRows.value[index];
if (row?.type === 'header') return 34;
if (row?.type === 'divider') return 18;
return estimatedRowHeight.value;
},
overscan: 10
}))
);
watch([cardScale, cardSpacing], () => {
if (!settingsReady.value) {
const virtualizer = computed(() => virtualizerRef.value);
const virtualItems = computed(() => virtualizer.value?.getVirtualItems?.() ?? []);
const virtualSpacerStyle = computed(() => {
const height = `${virtualizer.value?.getTotalSize?.() ?? 0}px`;
return `height:${height};position:relative;width:100%;`;
});
function virtualRowStyle(start) {
const y = Number(start) || 0;
return `transform:translateY(${y}px);position:absolute;top:0;left:0;width:100%;`;
}
function onVirtualRowRef(el) {
const target = el?.$el ?? el;
if (!target) {
return;
}
virtualizer.value?.measureElement?.(/** @type {Element} */ (target));
}
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
};
});
watch([searchTerm, activeSegment], () => {
virtualizer.value?.scrollToOffset?.(0);
nextTick(() => {
resolveScrollViewport();
updateGridWidth();
maybeFillViewport();
virtualizer.value?.measure?.();
});
});
@@ -648,18 +666,41 @@
activeSegment.value = 'online';
}
itemsToShow.value = PAGE_SIZE;
virtualizer.value?.scrollToOffset?.(0);
nextTick(() => {
resolveScrollViewport();
updateGridWidth();
virtualizer.value?.measure?.();
});
});
watch(
() => filteredFriends.value.length,
() => {
nextTick(() => {
resolveScrollViewport();
updateGridWidth();
virtualizer.value?.measure?.();
});
}
);
watch([cardScale, cardSpacing], () => {
if (!settingsReady.value) {
return;
}
nextTick(() => {
updateGridWidth();
maybeFillViewport();
virtualizer.value?.measure?.();
});
});
onMounted(() => {
nextTick(() => {
resolveScrollViewport();
setupResizeHandling();
maybeFillViewport();
updateGridWidth();
virtualizer.value?.measure?.();
});
});
@@ -696,9 +737,10 @@
} finally {
settingsReady.value = true;
nextTick(() => {
resolveScrollViewport();
setupResizeHandling();
updateGridWidth();
maybeFillViewport();
virtualizer.value?.measure?.();
});
}
}
@@ -747,6 +789,30 @@
color: var(--el-text-color-regular);
}
.friend-view__virtual {
width: 100%;
padding: 2px;
box-sizing: border-box;
}
.friend-view__virtual-spacer {
width: 100%;
}
.friend-view__virtual-row {
width: 100%;
box-sizing: border-box;
}
.friend-view__row {
display: flex;
flex-wrap: nowrap;
gap: var(--friend-card-gap, 14px);
align-items: stretch;
padding: 2px;
box-sizing: border-box;
}
.friend-view__settings-label {
font-size: 13px;
font-weight: 500;