Files
VRCX/src/views/Sidebar/components/GroupsSidebar.vue
2026-03-16 10:44:43 +09:00

246 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div ref="scrollRootRef" class="relative h-full">
<div ref="scrollViewportRef" class="h-full w-full overflow-auto">
<div class="px-1.5 py-2.5">
<div v-if="virtualRows.length" class="relative w-full box-border" :style="virtualContainerStyle">
<template v-for="item in virtualItems" :key="String(item.virtualItem.key)">
<div
v-if="item.row"
class="absolute left-0 top-0 w-full box-border"
:data-index="item.virtualItem.index"
:ref="virtualizer.measureElement"
:style="rowStyle(item)">
<template v-if="item.row.type === 'group-header'">
<div
class="cursor-pointer pt-4 pb-1.5 text-xs"
:style="
item.row.headerPaddingTop
? { paddingTop: item.row.headerPaddingTop }
: undefined
">
<div
@click="toggleGroupSidebarCollapse(item.row.groupId)"
class="flex items-center">
<ChevronDown
class="transition-transform duration-200 ease-in-out"
:class="{ '-rotate-90': item.row.isCollapsed }" />
<span class="ml-1.5"> {{ item.row.label }} {{ item.row.count }} </span>
</div>
</div>
</template>
<template v-else-if="item.row.type === 'group-item'">
<ContextMenu>
<ContextMenuTrigger as-child>
<div
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer hover:bg-muted/50 hover:rounded-lg"
@click="showGroupDialog(item.row.ownerId)">
<template v-if="item.row.isVisible">
<div class="relative inline-block flex-none size-9 mr-2.5">
<Avatar class="size-9">
<AvatarImage :src="getSmallGroupIconUrl(item.row.iconUrl)" class="object-cover" />
<AvatarFallback>
<Users class="size-4 text-muted-foreground" />
</AvatarFallback>
</Avatar>
</div>
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">
<span v-text="item.row.name"></span>
<span class="ml-1.5 font-normal">
({{ item.row.userCount }}/{{ item.row.capacity }})
</span>
</span>
<Location
class="text-xs"
:location="item.row.location"
:link="false" />
</div>
</template>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
:disabled="!checkCanInviteSelf(item.row.location)"
@click="groupInstanceLaunch(item.row.location)">
{{ t('dialog.user.info.launch_invite_tooltip') }}
</ContextMenuItem>
<ContextMenuItem
:disabled="!checkCanInviteSelf(item.row.location)"
@click="groupInstanceSelfInvite(item.row.location)">
{{ t('dialog.user.info.self_invite_tooltip') }}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</template>
</div>
</template>
</div>
</div>
</div>
<BackToTop :virtualizer="virtualizer" :target="scrollViewportRef" :tooltip="false" />
</div>
</template>
<script setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { ChevronDown, Users } from 'lucide-vue-next';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { storeToRefs } from 'pinia';
import { toast } from 'vue-sonner';
import { useI18n } from 'vue-i18n';
import { useVirtualizer } from '@tanstack/vue-virtual';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger
} from '../../../components/ui/context-menu';
import { buildGroupHeaderRow, buildGroupItemRow, estimateGroupRowSize, getGroupId } from '../groupsSidebarUtils';
import { convertFileUrlToImageUrl, parseLocation } from '../../../shared/utils';
import { useInviteChecks } from '../../../composables/useInviteChecks';
import { useAppearanceSettingsStore, useGroupStore, useLaunchStore } from '../../../stores';
import { showGroupDialog } from '../../../coordinators/groupCoordinator';
import { instanceRequest } from '../../../api';
import BackToTop from '../../../components/BackToTop.vue';
import Location from '../../../components/Location.vue';
const { t } = useI18n();
const launchStore = useLaunchStore();
const { isAgeGatedInstancesVisible } = storeToRefs(useAppearanceSettingsStore());
const { sortGroupInstancesByInGame } = useGroupStore();
const { groupInstances } = storeToRefs(useGroupStore());
const { checkCanInviteSelf } = useInviteChecks();
const groupInstancesCfg = ref({});
const scrollViewportRef = ref(null);
const scrollRootRef = ref(null);
const groupedGroupInstances = computed(() => {
const groupMap = new Map();
groupInstances.value.forEach((ref) => {
const groupId = ref.group.groupId;
if (!groupMap.has(groupId)) {
groupMap.set(groupId, []);
}
groupMap.get(groupId).push(ref);
if (!groupInstancesCfg.value[ref.group?.groupId]) {
groupInstancesCfg.value = {
[ref.group.groupId]: {
isCollapsed: false
},
...groupInstancesCfg.value
};
}
});
return Array.from(groupMap.values()).sort(sortGroupInstancesByInGame);
});
const buildGroupHeaderRowLocal = (group, index) => buildGroupHeaderRow(group, index, groupInstancesCfg.value);
const buildGroupItemRowLocal = (ref, index, groupId) =>
buildGroupItemRow(ref, index, groupId, isAgeGatedInstancesVisible.value);
const virtualRows = computed(() => {
const rows = [];
groupedGroupInstances.value.forEach((group, index) => {
if (!group?.length) return;
const groupId = getGroupId(group);
rows.push(buildGroupHeaderRowLocal(group, index));
if (!groupInstancesCfg.value[groupId]?.isCollapsed) {
group.forEach((ref, idx) => {
rows.push(buildGroupItemRowLocal(ref, idx, groupId));
});
}
});
return rows;
});
const estimateRowSize = (row) => estimateGroupRowSize(row);
const virtualizer = useVirtualizer(
computed(() => ({
count: virtualRows.value.length,
getScrollElement: () => scrollViewportRef.value,
estimateSize: (index) => estimateRowSize(virtualRows.value[index]),
getItemKey: (index) => virtualRows.value[index]?.key ?? index,
overscan: 6
}))
);
const virtualItems = computed(() => {
const items = virtualizer.value?.getVirtualItems?.() ?? [];
return items.map((virtualItem) => ({
virtualItem,
row: virtualRows.value[virtualItem.index]
}));
});
const virtualContainerStyle = computed(() => ({
height: `${virtualizer.value?.getTotalSize?.() ?? 0}px`,
width: '100%'
}));
const rowStyle = (item) => ({
transform: `translateY(${item.virtualItem.start}px)`
});
/**
*
* @param url
*/
function getSmallGroupIconUrl(url) {
return convertFileUrlToImageUrl(url);
}
/**
*
* @param groupId
*/
function toggleGroupSidebarCollapse(groupId) {
groupInstancesCfg.value[groupId].isCollapsed = !groupInstancesCfg.value[groupId].isCollapsed;
}
/**
* @param {string} location - Instance location tag
*/
function groupInstanceLaunch(location) {
if (!location) return;
launchStore.showLaunchDialog(location);
}
/**
* @param {string} location - Instance location tag
*/
function groupInstanceSelfInvite(location) {
if (!location) return;
const L = parseLocation(location);
if (!L.isRealInstance) return;
instanceRequest
.selfInvite({
instanceId: L.instanceId,
worldId: L.worldId
})
.then(() => {
toast.success(t('message.invite.self_sent'));
});
}
onMounted(() => {
nextTick(() => {
virtualizer.value?.measure?.();
});
});
watch(virtualRows, () => {
nextTick(() => {
virtualizer.value?.measure?.();
});
});
</script>