mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-13 10:58:59 +00:00
✨ feat: Instance grouping and user count
This commit is contained in:
Vendored
+2
@@ -14,6 +14,7 @@ export const policies = {
|
|||||||
userGroups: { ttl: 15 * 60_000, staleWhileRevalidate: 60 * 60_000 },
|
userGroups: { ttl: 15 * 60_000, staleWhileRevalidate: 60 * 60_000 },
|
||||||
representedGroup: { 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 },
|
group: { ttl: 30 * 60_000, staleWhileRevalidate: 2 * 60 * 60_000 },
|
||||||
|
instance: { ttl: 30_000, staleWhileRevalidate: 60_000 },
|
||||||
} satisfies Record<string, CachePolicy>;
|
} satisfies Record<string, CachePolicy>;
|
||||||
|
|
||||||
export const cacheKeys = {
|
export const cacheKeys = {
|
||||||
@@ -31,4 +32,5 @@ export const cacheKeys = {
|
|||||||
userGroups: (id: string) => `user:groups:${id}`,
|
userGroups: (id: string) => `user:groups:${id}`,
|
||||||
representedGroup: (id: string) => `user:group:represented:${id}`,
|
representedGroup: (id: string) => `user:group:represented:${id}`,
|
||||||
group: (id: string) => `group:${id}`,
|
group: (id: string) => `group:${id}`,
|
||||||
|
instance: (location: string) => `instance:${location}`,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as auth from "../vrchat/authService";
|
|||||||
import * as users from "../vrchat/userService";
|
import * as users from "../vrchat/userService";
|
||||||
import * as friends from "../vrchat/friendsService";
|
import * as friends from "../vrchat/friendsService";
|
||||||
import * as worlds from "../vrchat/worldService";
|
import * as worlds from "../vrchat/worldService";
|
||||||
|
import * as instances from "../vrchat/instanceService";
|
||||||
import * as avatars from "../vrchat/avatarService";
|
import * as avatars from "../vrchat/avatarService";
|
||||||
import * as groups from "../vrchat/groupService";
|
import * as groups from "../vrchat/groupService";
|
||||||
import * as settings from "../vrchat/settingsService";
|
import * as settings from "../vrchat/settingsService";
|
||||||
@@ -46,6 +47,9 @@ const handlers = {
|
|||||||
"world:get": (worldId) => guard(() => worlds.getWorld(worldId)),
|
"world:get": (worldId) => guard(() => worlds.getWorld(worldId)),
|
||||||
"world:snapshot": () => guard(async () => worldStore.snapshot()),
|
"world:snapshot": () => guard(async () => worldStore.snapshot()),
|
||||||
|
|
||||||
|
"instance:get": ({ worldId, instanceId }) =>
|
||||||
|
guard(() => instances.getInstance(worldId, instanceId)),
|
||||||
|
|
||||||
"avatar:get": (avatarId) => guard(() => avatars.getAvatar(avatarId)),
|
"avatar:get": (avatarId) => guard(() => avatars.getAvatar(avatarId)),
|
||||||
"avatar:favorites": () => guard(() => avatars.getFavoritedAvatars()),
|
"avatar:favorites": () => guard(() => avatars.getFavoritedAvatars()),
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { app } from "electron";
|
import { app } from "electron";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
import { KeyvFile } from "keyv-file";
|
import { KeyvFile } from "keyv-file";
|
||||||
import { VRChat } from "vrchat";
|
import { VRChat } from "vrchat";
|
||||||
import { activeId, pendingFile, sessionFile } from "../accounts/store";
|
import { activeId, pendingFile, sessionFile } from "../accounts/store";
|
||||||
@@ -59,10 +60,33 @@ export function closeClients(): void {
|
|||||||
loginClient?.pipeline.close();
|
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> {
|
export async function getPipelineAuthToken(vrc: VRChat): Promise<string | null> {
|
||||||
const getCookies = (
|
const id = activeId();
|
||||||
vrc as unknown as { getCookies?: () => Promise<{ name: string; value: string }[]> }
|
if (id) {
|
||||||
).getCookies;
|
const fromDisk = readAuthCookieFromSession(sessionFile(id));
|
||||||
|
if (fromDisk) return fromDisk;
|
||||||
|
}
|
||||||
|
const getCookies = (vrc as unknown as { getCookies?: () => Promise<SessionCookie[]> }).getCookies;
|
||||||
const cookies = (await getCookies?.()) ?? [];
|
const cookies = (await getCookies?.()) ?? [];
|
||||||
return cookies.find((c) => c.name === "auth")?.value ?? null;
|
return cookies.find((c) => c.name === "auth")?.value ?? null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,12 +8,14 @@ import type {
|
|||||||
User,
|
User,
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
LimitedUserFriend,
|
LimitedUserFriend,
|
||||||
|
Instance as SdkInstance,
|
||||||
} from "vrchat";
|
} from "vrchat";
|
||||||
import type { TrustRank, UserProfile, UserStatus } from "../../shared/types/user";
|
import type { TrustRank, UserProfile, UserStatus } from "../../shared/types/user";
|
||||||
import type { CurrentUserSummary } from "../../shared/types/auth";
|
import type { CurrentUserSummary } from "../../shared/types/auth";
|
||||||
import type { ReleaseStatus, World, WorldPlatforms } from "../../shared/types/world";
|
import type { ReleaseStatus, World, WorldPlatforms } from "../../shared/types/world";
|
||||||
import type { Avatar } from "../../shared/types/avatar";
|
import type { Avatar } from "../../shared/types/avatar";
|
||||||
import type { Group } from "../../shared/types/group";
|
import type { Group } from "../../shared/types/group";
|
||||||
|
import type { Instance } from "../../shared/types/instance";
|
||||||
|
|
||||||
interface RawUser {
|
interface RawUser {
|
||||||
id: string;
|
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 {
|
export function toGroup(raw: LimitedUserGroups | RepresentedGroup): Group {
|
||||||
return {
|
return {
|
||||||
id: raw.groupId ?? "",
|
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 { isOnline, locationLabel, statusMeta } from "../../lib/vrchat";
|
||||||
import { Badge, PresenceAvatar } from "../../components/ui";
|
import { Badge, PresenceAvatar } from "../../components/ui";
|
||||||
import { useFriends, useSelf } from "../../store/social";
|
import { useFriends, useSelf } from "../../store/social";
|
||||||
import { useWorldName } from "../../store/worlds";
|
import { useWorldName } from "../../store/worlds";
|
||||||
import { parseLocation, type UserProfile } from "../../../../shared/types/user";
|
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 }) {
|
export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) {
|
||||||
const friends = useFriends();
|
const friends = useFriends();
|
||||||
const self = useSelf();
|
const self = useSelf();
|
||||||
|
const selfId = self?.id;
|
||||||
|
|
||||||
const sorted = useMemo(
|
const sorted = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -18,8 +27,57 @@ export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) {
|
|||||||
[friends],
|
[friends],
|
||||||
);
|
);
|
||||||
|
|
||||||
const online = sorted.filter(isOnline);
|
const online = useMemo(() => sorted.filter(isOnline), [sorted]);
|
||||||
const offline = sorted.filter((f) => !isOnline(f));
|
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 (
|
return (
|
||||||
<aside className="friendsbar flex h-full flex-col overflow-hidden border-l border-border bg-surface">
|
<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>
|
</header>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-2">
|
<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 ? (
|
{friends.length === 0 ? (
|
||||||
<p className="p-3 text-[13px] text-faint">No friends online yet.</p>
|
<p className="p-3 text-[13px] text-faint">No friends online yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{online.map((f) => (
|
{instances.map((inst) => (
|
||||||
<FriendRow key={f.id} friend={f} onOpen={onOpen} />
|
<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} />
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{offline.length ? (
|
{offline.length ? (
|
||||||
<div className="friendsbar__text px-2 pb-1.5 pt-3 text-[11px] font-semibold uppercase tracking-wide text-faint">
|
<Section
|
||||||
Offline · {offline.length}
|
title={<span className="truncate">Offline</span>}
|
||||||
</div>
|
count={offline.length}
|
||||||
|
open={!collapsed.has("offline")}
|
||||||
|
onToggle={() => toggle("offline")}
|
||||||
|
>
|
||||||
|
{offline.map((f) => (
|
||||||
|
<FriendRow key={f.id} friend={f} onOpen={onOpen} dim />
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
) : null}
|
) : null}
|
||||||
{offline.map((f) => (
|
|
||||||
<FriendRow key={f.id} friend={f} onOpen={onOpen} dim />
|
|
||||||
))}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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({
|
function FriendRow({
|
||||||
friend,
|
friend,
|
||||||
onOpen,
|
onOpen,
|
||||||
dim,
|
dim,
|
||||||
isSelf,
|
isSelf,
|
||||||
|
selfChrome,
|
||||||
|
hideLocation,
|
||||||
}: {
|
}: {
|
||||||
friend: UserProfile;
|
friend: UserProfile;
|
||||||
onOpen: (id: string) => void;
|
onOpen: (id: string) => void;
|
||||||
dim?: boolean;
|
dim?: boolean;
|
||||||
isSelf?: boolean;
|
isSelf?: boolean;
|
||||||
|
selfChrome?: boolean;
|
||||||
|
hideLocation?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const status = statusMeta[isOnline(friend) || isSelf ? friend.status : "offline"];
|
const status = statusMeta[isOnline(friend) || isSelf ? friend.status : "offline"];
|
||||||
const sub = friend.statusDescription || status.label;
|
const sub = friend.statusDescription || status.label;
|
||||||
const parsed = parseLocation(friend.location);
|
const parsed = parseLocation(friend.location);
|
||||||
const worldName = useWorldName(parsed?.worldId);
|
const worldName = useWorldName(parsed?.worldId);
|
||||||
const location = parsed
|
const location = parsed
|
||||||
? worldName
|
? `${worldName ? `in ${worldName}` : "In a world"} (#${parsed.instanceId})`
|
||||||
? `in ${worldName}`
|
|
||||||
: "In a world"
|
|
||||||
: locationLabel(friend.location);
|
: locationLabel(friend.location);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => onOpen(friend.id)}
|
onClick={() => onOpen(isSelf ? "me" : friend.id)}
|
||||||
title={friend.displayName}
|
title={friend.displayName}
|
||||||
className={[
|
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",
|
"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" : "",
|
dim ? "opacity-55 hover:opacity-100" : "",
|
||||||
isSelf
|
selfChrome
|
||||||
? "mb-2 rounded-b-none border-b border-border bg-surface-2 hover:bg-surface-hover"
|
? "mb-2 rounded-b-none border-b border-border bg-surface-2 hover:bg-surface-hover"
|
||||||
: "",
|
: "",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
@@ -96,7 +247,9 @@ function FriendRow({
|
|||||||
{isSelf ? <Badge tone="accent">You</Badge> : null}
|
{isSelf ? <Badge tone="accent">You</Badge> : null}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate text-[12px] text-muted">{sub}</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>
|
</span>
|
||||||
</button>
|
</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 { parseLocation, type Location } from "../../../../shared/types/user";
|
||||||
import { HoverImage } from "../../components/ui";
|
import { HoverImage } from "../../components/ui";
|
||||||
import { useWorld } from "../../store/worlds";
|
import { useWorld } from "../../store/worlds";
|
||||||
import { useNav } from "../navigation/NavContext";
|
import { useNav } from "../navigation/NavContext";
|
||||||
|
import { api } from "../../lib/api";
|
||||||
|
import { useAsync } from "../../lib/useAsync";
|
||||||
import { locationLabel, regionLabels } from "../../lib/vrchat";
|
import { locationLabel, regionLabels } from "../../lib/vrchat";
|
||||||
|
|
||||||
export function LocationSection({ location }: { location?: Location }) {
|
export function LocationSection({ location }: { location?: Location }) {
|
||||||
const { openWorld } = useNav();
|
const { openWorld } = useNav();
|
||||||
const parsed = parseLocation(location);
|
const parsed = parseLocation(location);
|
||||||
const world = useWorld(parsed?.worldId);
|
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) {
|
if (parsed && !world) {
|
||||||
return (
|
return (
|
||||||
@@ -44,17 +51,26 @@ export function LocationSection({ location }: { location?: Location }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px] text-faint">
|
<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="truncate">by {world.authorName}</span>
|
||||||
|
<span className="inline-flex items-center gap-1" title="Instance">
|
||||||
|
<Hash size={11} />
|
||||||
|
{parsed.instanceId}
|
||||||
|
</span>
|
||||||
{region ? (
|
{region ? (
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<Globe size={11} /> {region}
|
<Globe size={11} /> {region}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{world.occupants > 0 ? (
|
{inInstance ? (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center gap-1"
|
className="inline-flex items-center gap-1"
|
||||||
style={{ color: "var(--status-active)" }}
|
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>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ export const api = {
|
|||||||
get: (worldId: string) => call("world:get", worldId),
|
get: (worldId: string) => call("world:get", worldId),
|
||||||
snapshot: () => call("world:snapshot"),
|
snapshot: () => call("world:snapshot"),
|
||||||
},
|
},
|
||||||
|
instance: {
|
||||||
|
get: (worldId: string, instanceId: string) => call("instance:get", { worldId, instanceId }),
|
||||||
|
},
|
||||||
avatar: {
|
avatar: {
|
||||||
get: (avatarId: string) => call("avatar:get", avatarId),
|
get: (avatarId: string) => call("avatar:get", avatarId),
|
||||||
favorites: () => call("avatar:favorites"),
|
favorites: () => call("avatar:favorites"),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
} from "./types/auth";
|
} from "./types/auth";
|
||||||
import type { SocialSnapshot, UserProfile, UserStatus } from "./types/user";
|
import type { SocialSnapshot, UserProfile, UserStatus } from "./types/user";
|
||||||
import type { FavoriteWorldFolder, World, WorldSnapshot } from "./types/world";
|
import type { FavoriteWorldFolder, World, WorldSnapshot } from "./types/world";
|
||||||
|
import type { Instance } from "./types/instance";
|
||||||
import type { Avatar } from "./types/avatar";
|
import type { Avatar } from "./types/avatar";
|
||||||
import type { RepoStats, StoredEntity } from "./types/repository";
|
import type { RepoStats, StoredEntity } from "./types/repository";
|
||||||
import type { AccountSettings, ContentFilterKey, Pending2Fa, RecoveryCode } from "./types/settings";
|
import type { AccountSettings, ContentFilterKey, Pending2Fa, RecoveryCode } from "./types/settings";
|
||||||
@@ -46,6 +47,8 @@ export interface IpcRequests {
|
|||||||
"world:get": (worldId: string) => IpcResult<World>;
|
"world:get": (worldId: string) => IpcResult<World>;
|
||||||
"world:snapshot": () => IpcResult<WorldSnapshot>;
|
"world:snapshot": () => IpcResult<WorldSnapshot>;
|
||||||
|
|
||||||
|
"instance:get": (location: { worldId: string; instanceId: string }) => IpcResult<Instance>;
|
||||||
|
|
||||||
"avatar:get": (avatarId: string) => IpcResult<Avatar>;
|
"avatar:get": (avatarId: string) => IpcResult<Avatar>;
|
||||||
"avatar:favorites": () => IpcResult<Avatar[]>;
|
"avatar:favorites": () => IpcResult<Avatar[]>;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ export type Location = "offline" | "private" | "traveling" | "" | string;
|
|||||||
export interface ParsedLocation {
|
export interface ParsedLocation {
|
||||||
worldId: string;
|
worldId: string;
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
|
instance: string;
|
||||||
region?: string;
|
region?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ export function parseLocation(loc?: string): ParsedLocation | null {
|
|||||||
if (!worldId?.startsWith("wrld_") || !rest) return null;
|
if (!worldId?.startsWith("wrld_") || !rest) return null;
|
||||||
const instanceId = rest.split("~")[0];
|
const instanceId = rest.split("~")[0];
|
||||||
const region = /~region\(([^)]+)\)/.exec(rest)?.[1];
|
const region = /~region\(([^)]+)\)/.exec(rest)?.[1];
|
||||||
return { worldId, instanceId, region };
|
return { worldId, instanceId, instance: rest, region };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Badge {
|
export interface Badge {
|
||||||
|
|||||||
Reference in New Issue
Block a user