feat: Instance grouping and user count

This commit is contained in:
2026-06-28 03:49:50 +07:00
parent 2dd6931425
commit 387fa18927
11 changed files with 281 additions and 25 deletions
+2
View File
@@ -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<string, CachePolicy>;
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}`,
};
+4
View File
@@ -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()),
+27 -3
View File
@@ -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<string | null> {
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<SessionCookie[]> }).getCookies;
const cookies = (await getCookies?.()) ?? [];
return cookies.find((c) => c.name === "auth")?.value ?? null;
}
+15
View File
@@ -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<Instance> {
return cachedRead(
cacheKeys.instance(`${worldId}:${instanceId}`),
policies.instance,
async (vrc) => {
const { data } = await vrc.getInstance({ path: { worldId, instanceId }, throwOnError: true });
return toInstance(data);
},
);
}
+20
View File
@@ -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 ?? "",
@@ -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<string, UserProfile[]>();
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<Set<string>>(() => 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 (
<aside className="friendsbar flex h-full flex-col overflow-hidden border-l border-border bg-surface">
@@ -31,22 +89,57 @@ export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) {
</header>
<div className="flex-1 overflow-y-auto p-2">
{self ? <FriendRow friend={self} onOpen={() => onOpen("me")} isSelf /> : null}
{selfAlone ? <FriendRow friend={self} onOpen={onOpen} isSelf selfChrome /> : null}
{friends.length === 0 ? (
<p className="p-3 text-[13px] text-faint">No friends online yet.</p>
) : (
<>
{online.map((f) => (
{instances.map((inst) => (
<Section
key={inst.key}
title={<InstanceTitle worldId={inst.worldId} instanceId={inst.instanceId} />}
count={inst.members.length}
open={!collapsed.has(inst.key)}
onToggle={() => toggle(inst.key)}
>
{inst.members.map((f) => (
<FriendRow
key={f.id}
friend={f}
onOpen={onOpen}
hideLocation
isSelf={f.id === selfId}
/>
))}
</Section>
))}
{alone.length ? (
<Section
title={<span className="truncate">Online</span>}
count={alone.length}
open={!collapsed.has("online")}
onToggle={() => toggle("online")}
>
{alone.map((f) => (
<FriendRow key={f.id} friend={f} onOpen={onOpen} />
))}
{offline.length ? (
<div className="friendsbar__text px-2 pb-1.5 pt-3 text-[11px] font-semibold uppercase tracking-wide text-faint">
Offline · {offline.length}
</div>
</Section>
) : null}
{offline.length ? (
<Section
title={<span className="truncate">Offline</span>}
count={offline.length}
open={!collapsed.has("offline")}
onToggle={() => toggle("offline")}
>
{offline.map((f) => (
<FriendRow key={f.id} friend={f} onOpen={onOpen} dim />
))}
</Section>
) : null}
</>
)}
</div>
@@ -54,34 +147,92 @@ export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) {
);
}
function SectionHeader({
open,
onToggle,
children,
}: {
open: boolean;
onToggle: () => void;
children: ReactNode;
}) {
return (
<button
onClick={onToggle}
className="friendsbar__text flex w-full items-center gap-1.5 px-2 pb-1 pt-3 text-[11px] font-semibold uppercase tracking-wide text-faint transition-colors hover:text-muted"
>
<span className="shrink-0">
{open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</span>
{children}
</button>
);
}
function Section({
title,
count,
open,
onToggle,
children,
}: {
title: ReactNode;
count: number;
open: boolean;
onToggle: () => void;
children: ReactNode;
}) {
return (
<div>
<SectionHeader open={open} onToggle={onToggle}>
{title}
<span className="ml-auto shrink-0 normal-case">{count}</span>
</SectionHeader>
{open ? <div>{children}</div> : null}
</div>
);
}
function InstanceTitle({ worldId, instanceId }: { worldId: string; instanceId: string }) {
const worldName = useWorldName(worldId);
return (
<>
<span className="truncate">{worldName ?? "In a world"}</span>
<span className="shrink-0 normal-case text-faint">(#{instanceId})</span>
</>
);
}
function FriendRow({
friend,
onOpen,
dim,
isSelf,
selfChrome,
hideLocation,
}: {
friend: UserProfile;
onOpen: (id: string) => void;
dim?: boolean;
isSelf?: boolean;
selfChrome?: boolean;
hideLocation?: boolean;
}) {
const status = statusMeta[isOnline(friend) || isSelf ? friend.status : "offline"];
const sub = friend.statusDescription || status.label;
const parsed = parseLocation(friend.location);
const worldName = useWorldName(parsed?.worldId);
const location = parsed
? worldName
? `in ${worldName}`
: "In a world"
? `${worldName ? `in ${worldName}` : "In a world"} (#${parsed.instanceId})`
: locationLabel(friend.location);
return (
<button
onClick={() => onOpen(friend.id)}
onClick={() => onOpen(isSelf ? "me" : friend.id)}
title={friend.displayName}
className={[
"friend-row flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-surface-2",
dim ? "opacity-55 hover:opacity-100" : "",
isSelf
selfChrome
? "mb-2 rounded-b-none border-b border-border bg-surface-2 hover:bg-surface-hover"
: "",
].join(" ")}
@@ -96,7 +247,9 @@ function FriendRow({
{isSelf ? <Badge tone="accent">You</Badge> : null}
</span>
<span className="truncate text-[12px] text-muted">{sub}</span>
{location ? <span className="truncate text-[11px] text-faint">{location}</span> : null}
{!hideLocation && location ? (
<span className="truncate text-[11px] text-faint">{location}</span>
) : null}
</span>
</button>
);
@@ -1,14 +1,21 @@
import { ChevronRight, Globe, MapPin, Users } from "lucide-react";
import { ChevronRight, Globe, Hash, MapPin, Users } from "lucide-react";
import { parseLocation, type Location } from "../../../../shared/types/user";
import { HoverImage } from "../../components/ui";
import { useWorld } from "../../store/worlds";
import { useNav } from "../navigation/NavContext";
import { api } from "../../lib/api";
import { useAsync } from "../../lib/useAsync";
import { locationLabel, regionLabels } from "../../lib/vrchat";
export function LocationSection({ location }: { location?: Location }) {
const { openWorld } = useNav();
const parsed = parseLocation(location);
const world = useWorld(parsed?.worldId);
const instance = useAsync(
() => (parsed ? api.instance.get(parsed.worldId, parsed.instance) : Promise.resolve(null)),
[parsed?.worldId, parsed?.instance],
);
const inInstance = instance.status === "ready" ? instance.data : null;
if (parsed && !world) {
return (
@@ -44,17 +51,26 @@ export function LocationSection({ location }: { location?: Location }) {
</div>
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px] text-faint">
<span className="truncate">by {world.authorName}</span>
<span className="inline-flex items-center gap-1" title="Instance">
<Hash size={11} />
{parsed.instanceId}
</span>
{region ? (
<span className="inline-flex items-center gap-1">
<Globe size={11} /> {region}
</span>
) : null}
{world.occupants > 0 ? (
{inInstance ? (
<span
className="inline-flex items-center gap-1"
style={{ color: "var(--status-active)" }}
>
<Users size={11} /> {world.occupants} here
<Users size={11} /> {inInstance.userCount} in instance
</span>
) : null}
{world.occupants > 0 ? (
<span className="inline-flex items-center gap-1">
<Users size={11} /> {world.occupants} in world
</span>
) : null}
</div>
+3
View File
@@ -55,6 +55,9 @@ export const api = {
get: (worldId: string) => call("world:get", worldId),
snapshot: () => call("world:snapshot"),
},
instance: {
get: (worldId: string, instanceId: string) => call("instance:get", { worldId, instanceId }),
},
avatar: {
get: (avatarId: string) => call("avatar:get", avatarId),
favorites: () => call("avatar:favorites"),
+3
View File
@@ -7,6 +7,7 @@ import type {
} from "./types/auth";
import type { SocialSnapshot, UserProfile, UserStatus } from "./types/user";
import type { FavoriteWorldFolder, World, WorldSnapshot } from "./types/world";
import type { Instance } from "./types/instance";
import type { Avatar } from "./types/avatar";
import type { RepoStats, StoredEntity } from "./types/repository";
import type { AccountSettings, ContentFilterKey, Pending2Fa, RecoveryCode } from "./types/settings";
@@ -46,6 +47,8 @@ export interface IpcRequests {
"world:get": (worldId: string) => IpcResult<World>;
"world:snapshot": () => IpcResult<WorldSnapshot>;
"instance:get": (location: { worldId: string; instanceId: string }) => IpcResult<Instance>;
"avatar:get": (avatarId: string) => IpcResult<Avatar>;
"avatar:favorites": () => IpcResult<Avatar[]>;
+15
View File
@@ -0,0 +1,15 @@
export interface Instance {
id: string;
location: string;
worldId: string;
instanceId: string;
type: string;
region?: string;
ownerId?: string;
userCount: number;
capacity: number;
recommendedCapacity?: number;
full: boolean;
queueEnabled: boolean;
queueSize: number;
}
+2 -1
View File
@@ -17,6 +17,7 @@ export type Location = "offline" | "private" | "traveling" | "" | string;
export interface ParsedLocation {
worldId: string;
instanceId: string;
instance: string;
region?: string;
}
@@ -26,7 +27,7 @@ export function parseLocation(loc?: string): ParsedLocation | null {
if (!worldId?.startsWith("wrld_") || !rest) return null;
const instanceId = rest.split("~")[0];
const region = /~region\(([^)]+)\)/.exec(rest)?.[1];
return { worldId, instanceId, region };
return { worldId, instanceId, instance: rest, region };
}
export interface Badge {