From 387fa1892727a7d65d5f2773591bcc9b0e651b43 Mon Sep 17 00:00:00 2001 From: Yuzu Date: Sun, 28 Jun 2026 03:49:20 +0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20Instance=20grouping=20and?= =?UTF-8?q?=20user=20count?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/cache/policies.ts | 2 + src/main/ipc/handlers.ts | 4 + src/main/vrchat/client.ts | 30 ++- src/main/vrchat/instanceService.ts | 15 ++ src/main/vrchat/mappers.ts | 20 ++ .../src/features/friends/FriendsSidebar.tsx | 189 ++++++++++++++++-- .../src/features/profile/LocationSection.tsx | 22 +- src/renderer/src/lib/api.ts | 3 + src/shared/ipc.ts | 3 + src/shared/types/instance.ts | 15 ++ src/shared/types/user.ts | 3 +- 11 files changed, 281 insertions(+), 25 deletions(-) create mode 100644 src/main/vrchat/instanceService.ts create mode 100644 src/shared/types/instance.ts diff --git a/src/main/cache/policies.ts b/src/main/cache/policies.ts index 6c40ca8..2bb99dd 100644 --- a/src/main/cache/policies.ts +++ b/src/main/cache/policies.ts @@ -14,6 +14,7 @@ export const policies = { userGroups: { ttl: 15 * 60_000, staleWhileRevalidate: 60 * 60_000 }, representedGroup: { ttl: 15 * 60_000, staleWhileRevalidate: 60 * 60_000 }, group: { ttl: 30 * 60_000, staleWhileRevalidate: 2 * 60 * 60_000 }, + instance: { ttl: 30_000, staleWhileRevalidate: 60_000 }, } satisfies Record; export const cacheKeys = { @@ -31,4 +32,5 @@ export const cacheKeys = { userGroups: (id: string) => `user:groups:${id}`, representedGroup: (id: string) => `user:group:represented:${id}`, group: (id: string) => `group:${id}`, + instance: (location: string) => `instance:${location}`, }; diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index b73108f..430bedc 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -5,6 +5,7 @@ import * as auth from "../vrchat/authService"; import * as users from "../vrchat/userService"; import * as friends from "../vrchat/friendsService"; import * as worlds from "../vrchat/worldService"; +import * as instances from "../vrchat/instanceService"; import * as avatars from "../vrchat/avatarService"; import * as groups from "../vrchat/groupService"; import * as settings from "../vrchat/settingsService"; @@ -46,6 +47,9 @@ const handlers = { "world:get": (worldId) => guard(() => worlds.getWorld(worldId)), "world:snapshot": () => guard(async () => worldStore.snapshot()), + "instance:get": ({ worldId, instanceId }) => + guard(() => instances.getInstance(worldId, instanceId)), + "avatar:get": (avatarId) => guard(() => avatars.getAvatar(avatarId)), "avatar:favorites": () => guard(() => avatars.getFavoritedAvatars()), diff --git a/src/main/vrchat/client.ts b/src/main/vrchat/client.ts index 504d9a7..bd98db6 100644 --- a/src/main/vrchat/client.ts +++ b/src/main/vrchat/client.ts @@ -1,4 +1,5 @@ import { app } from "electron"; +import { readFileSync } from "node:fs"; import { KeyvFile } from "keyv-file"; import { VRChat } from "vrchat"; import { activeId, pendingFile, sessionFile } from "../accounts/store"; @@ -59,10 +60,33 @@ export function closeClients(): void { loginClient?.pipeline.close(); } +interface SessionCookie { + name: string; + value: string; +} + +function readAuthCookieFromSession(filename: string): string | null { + try { + const raw = JSON.parse(readFileSync(filename, "utf8")) as { + cache?: [string, { value?: string }][]; + }; + const entry = raw.cache?.find(([k]) => k === "keyv:cookies")?.[1]; + if (!entry?.value) return null; + const outer = JSON.parse(entry.value) as { value?: SessionCookie[] }; + const auth = outer.value?.find((c) => c.name === "auth"); + return auth?.value ?? null; + } catch { + return null; + } +} + export async function getPipelineAuthToken(vrc: VRChat): Promise { - const getCookies = ( - vrc as unknown as { getCookies?: () => Promise<{ name: string; value: string }[]> } - ).getCookies; + const id = activeId(); + if (id) { + const fromDisk = readAuthCookieFromSession(sessionFile(id)); + if (fromDisk) return fromDisk; + } + const getCookies = (vrc as unknown as { getCookies?: () => Promise }).getCookies; const cookies = (await getCookies?.()) ?? []; return cookies.find((c) => c.name === "auth")?.value ?? null; } diff --git a/src/main/vrchat/instanceService.ts b/src/main/vrchat/instanceService.ts new file mode 100644 index 0000000..9d4d89d --- /dev/null +++ b/src/main/vrchat/instanceService.ts @@ -0,0 +1,15 @@ +import type { Instance } from "../../shared/types/instance"; +import { toInstance } from "./mappers"; +import { cachedRead } from "./cachedRead"; +import { cacheKeys, policies } from "../cache/policies"; + +export async function getInstance(worldId: string, instanceId: string): Promise { + return cachedRead( + cacheKeys.instance(`${worldId}:${instanceId}`), + policies.instance, + async (vrc) => { + const { data } = await vrc.getInstance({ path: { worldId, instanceId }, throwOnError: true }); + return toInstance(data); + }, + ); +} diff --git a/src/main/vrchat/mappers.ts b/src/main/vrchat/mappers.ts index 5220e72..bfe94fc 100644 --- a/src/main/vrchat/mappers.ts +++ b/src/main/vrchat/mappers.ts @@ -8,12 +8,14 @@ import type { User, CurrentUser, LimitedUserFriend, + Instance as SdkInstance, } from "vrchat"; import type { TrustRank, UserProfile, UserStatus } from "../../shared/types/user"; import type { CurrentUserSummary } from "../../shared/types/auth"; import type { ReleaseStatus, World, WorldPlatforms } from "../../shared/types/world"; import type { Avatar } from "../../shared/types/avatar"; import type { Group } from "../../shared/types/group"; +import type { Instance } from "../../shared/types/instance"; interface RawUser { id: string; @@ -194,6 +196,24 @@ export function toWorld(raw: RawWorld): World { }; } +export function toInstance(raw: SdkInstance): Instance { + return { + id: raw.id, + location: raw.location, + worldId: raw.worldId, + instanceId: raw.instanceId, + type: raw.type, + region: raw.region ?? undefined, + ownerId: raw.ownerId ?? undefined, + userCount: raw.userCount ?? 0, + capacity: raw.capacity ?? raw.recommendedCapacity ?? 0, + recommendedCapacity: raw.recommendedCapacity, + full: raw.full ?? false, + queueEnabled: raw.queueEnabled ?? false, + queueSize: raw.queueSize ?? 0, + }; +} + export function toGroup(raw: LimitedUserGroups | RepresentedGroup): Group { return { id: raw.groupId ?? "", diff --git a/src/renderer/src/features/friends/FriendsSidebar.tsx b/src/renderer/src/features/friends/FriendsSidebar.tsx index 9df60dc..b422404 100644 --- a/src/renderer/src/features/friends/FriendsSidebar.tsx +++ b/src/renderer/src/features/friends/FriendsSidebar.tsx @@ -1,13 +1,22 @@ -import { useMemo } from "react"; +import { useMemo, useState, type ReactNode } from "react"; +import { ChevronDown, ChevronRight } from "lucide-react"; import { isOnline, locationLabel, statusMeta } from "../../lib/vrchat"; import { Badge, PresenceAvatar } from "../../components/ui"; import { useFriends, useSelf } from "../../store/social"; import { useWorldName } from "../../store/worlds"; import { parseLocation, type UserProfile } from "../../../../shared/types/user"; +interface InstanceSection { + key: string; + worldId: string; + instanceId: string; + members: UserProfile[]; +} + export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) { const friends = useFriends(); const self = useSelf(); + const selfId = self?.id; const sorted = useMemo( () => @@ -18,8 +27,57 @@ export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) { [friends], ); - const online = sorted.filter(isOnline); - const offline = sorted.filter((f) => !isOnline(f)); + const online = useMemo(() => sorted.filter(isOnline), [sorted]); + const offline = useMemo(() => sorted.filter((f) => !isOnline(f)), [sorted]); + + const { instances, alone } = useMemo(() => { + const byInstance = new Map(); + const alone: UserProfile[] = []; + const people = self && isOnline(self) ? [self, ...online] : online; + for (const f of people) { + const parsed = parseLocation(f.location); + if (!parsed) { + if (f.id !== selfId) alone.push(f); + continue; + } + const key = `${parsed.worldId}:${parsed.instanceId}`; + const list = byInstance.get(key); + if (list) list.push(f); + else byInstance.set(key, [f]); + } + const instances: InstanceSection[] = []; + for (const [key, members] of byInstance) { + if (members.length < 2) { + if (members[0].id !== selfId) alone.push(members[0]); + continue; + } + members.sort( + (a, b) => + Number(b.id === selfId) - Number(a.id === selfId) || + a.displayName.localeCompare(b.displayName), + ); + const [worldId, instanceId] = key.split(":"); + instances.push({ key, worldId, instanceId, members }); + } + instances.sort( + (a, b) => + Number(b.members.some((m) => m.id === selfId)) - + Number(a.members.some((m) => m.id === selfId)) || b.members.length - a.members.length, + ); + alone.sort((a, b) => a.displayName.localeCompare(b.displayName)); + return { instances, alone }; + }, [online, self, selfId]); + + const [collapsed, setCollapsed] = useState>(() => new Set(["offline"])); + const toggle = (id: string) => + setCollapsed((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + + const selfAlone = + self && isOnline(self) && !instances.some((i) => i.members.some((m) => m.id === selfId)); return (