feat: World Discovery

This commit is contained in:
2026-06-29 02:33:22 +07:00
parent 716fb82ae1
commit 99ac7761ce
29 changed files with 535 additions and 83 deletions
+2
View File
@@ -5,6 +5,7 @@ export const policies = {
user: { ttl: 5 * 60_000, staleWhileRevalidate: 10 * 60_000 },
userSearch: { ttl: 5 * 60_000 },
worldSearch: { ttl: 5 * 60_000 },
discover: { ttl: 10 * 60_000, staleWhileRevalidate: 30 * 60_000 },
friends: { ttl: 5 * 60_000, staleWhileRevalidate: 60_000 },
userWorlds: { ttl: 15 * 60_000, staleWhileRevalidate: 60 * 60_000 },
favoriteWorlds: { ttl: 15 * 60_000, staleWhileRevalidate: 60 * 60_000 },
@@ -25,6 +26,7 @@ export const cacheKeys = {
userByName: (name: string) => `user:name:${name.toLowerCase()}`,
userSearch: (q: string) => `user:search:${q.trim().toLowerCase()}`,
worldSearch: (q: string) => `world:search:${q.trim().toLowerCase()}`,
discover: () => "world:discover",
friends: () => "friends",
userWorlds: (id: string) => `user:worlds:${id}`,
favoriteWorlds: (id: string) => `worlds:favorites:${id}`,
+1
View File
@@ -50,6 +50,7 @@ const handlers = {
}),
"world:favorites": (userId) => guard(() => worlds.getFavoriteWorlds(userId)),
"world:search": (query) => guard(() => worlds.searchWorlds(query)),
"world:discover": () => guard(() => worlds.getDiscover()),
"world:get": (worldId) => guard(() => worlds.getWorld(worldId)),
"world:snapshot": () => guard(async () => worldStore.snapshot()),
+88 -1
View File
@@ -1,4 +1,4 @@
import type { VRChat, FavoritedWorld } from "vrchat";
import type { VRChat, FavoritedWorld, LimitedWorld } from "vrchat";
// VRChat web routes that are missing from the SDK.
@@ -37,3 +37,90 @@ export async function getFavoriteGroupWorlds(
}
return worlds;
}
interface InfoPushContentList {
name: string | { fallback?: string };
shortName?: string | null;
tag?: string | null;
sortHeading?: string;
sortOrder?: string;
sortOwnership?: string;
platform?: string;
}
interface InfoPushEntry {
id: string;
priority?: number;
startDate?: string;
endDate?: string;
data?: { contentList?: InfoPushContentList };
}
export interface WorldCategory {
id: string;
name: string;
tag?: string;
sort?: string;
order?: string;
platform?: string;
}
export async function getWorldCategories(vrc: VRChat): Promise<WorldCategory[]> {
const { data } = await vrc.client.get<InfoPushEntry[], unknown, true>({
url: "/infoPush",
query: { include: "user-all", require: "world-category" },
throwOnError: true,
});
// vrchat gives events the lowest priority number, but we want them first
return data
.filter((e) => e.data?.contentList)
.sort((a, b) => {
const ev = Number(isEvent(b)) - Number(isEvent(a));
if (ev !== 0) return ev;
return (a.priority ?? 0) - (b.priority ?? 0);
})
.map((e) => {
const cl = e.data!.contentList!;
return {
id: e.id,
name: categoryName(cl),
tag: cl.tag || undefined,
sort: cl.sortHeading,
order: cl.sortOrder,
platform: cl.platform === "ThisPlatformSupported" ? undefined : cl.platform,
};
});
}
function isEvent(e: InfoPushEntry): boolean {
const tag = e.data?.contentList?.tag ?? "";
return tag.startsWith("admin_") || Boolean(e.startDate) || Boolean(e.endDate);
}
function categoryName(cl: InfoPushContentList): string {
if (typeof cl.name === "string") return cl.name;
return cl.name.fallback ?? cl.shortName ?? "Worlds";
}
export async function getCategoryWorlds(
vrc: VRChat,
category: WorldCategory,
n: number,
): Promise<LimitedWorld[]> {
const tags = ["system_approved", category.tag].filter(Boolean).join(",");
const { data } = await vrc.client.get<LimitedWorld[], unknown, true>({
url: "/worlds",
query: {
releaseStatus: "public",
sort: category.sort,
order: category.order ?? "descending",
tag: tags || undefined,
featured: false,
n,
offset: 0,
},
throwOnError: true,
});
return data;
}
+27 -2
View File
@@ -1,7 +1,12 @@
import type { VRChat } from "vrchat";
import type { FavoriteWorldFolder, World } from "../../shared/types/world";
import type { DiscoverCategory, FavoriteWorldFolder, World } from "../../shared/types/world";
import { httpStatusOf } from "./errors";
import { getFavoriteGroupWorlds, type WorldFavoriteGroupType } from "./rawEndpoints";
import {
getCategoryWorlds,
getFavoriteGroupWorlds,
getWorldCategories,
type WorldFavoriteGroupType,
} from "./rawEndpoints";
import { toWorld } from "./mappers";
import { cachedRead } from "./cachedRead";
import { worldStore } from "../store/worldStore";
@@ -131,6 +136,26 @@ export async function searchWorlds(query: string): Promise<World[]> {
return worlds;
}
const DISCOVER_ROW_SIZE = 12;
export async function getDiscover(): Promise<DiscoverCategory[]> {
const categories = await cachedRead(cacheKeys.discover(), policies.discover, async (vrc) => {
const cats = await getWorldCategories(vrc);
const rows = await Promise.all(cats.map((cat) => loadCategory(vrc, cat).catch(() => null)));
return rows.filter((r): r is DiscoverCategory => r !== null && r.worlds.length > 0);
});
for (const cat of categories) for (const w of cat.worlds) worldStore.addWorld(w);
return categories;
}
async function loadCategory(
vrc: VRChat,
cat: Awaited<ReturnType<typeof getWorldCategories>>[number],
): Promise<DiscoverCategory> {
const raw = await getCategoryWorlds(vrc, cat, DISCOVER_ROW_SIZE);
return { id: cat.id, name: cat.name, worlds: raw.map(toWorld) };
}
export async function getUserWorlds(userId: string, isSelf: boolean): Promise<World[]> {
const worlds = await cachedRead(
cacheKeys.userWorlds(userId),
+18 -2
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { Fragment, useEffect, useState } from "react";
import {
ArrowLeft,
ChevronLeft,
@@ -6,6 +6,7 @@ import {
CircleDot,
ExternalLink,
Images,
Globe2,
Search,
Settings,
SlidersHorizontal,
@@ -14,6 +15,7 @@ import {
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { ProfileView } from "../features/profile/ProfileView";
import { MyWorldsView } from "../features/profile/MyWorldsView";
import { WorldView } from "../features/world/WorldView";
import { InstanceView } from "../features/world/InstanceView";
import { GroupView } from "../features/group/GroupView";
@@ -45,6 +47,7 @@ type NavItem = {
onClick: () => void;
kind?: View["kind"];
external?: boolean;
divider?: boolean;
};
function Shell() {
@@ -83,6 +86,14 @@ function Shell() {
kind: "enhancements",
onClick: () => nav.openEnhancements(),
},
{
id: "worlds",
label: t("nav:worlds"),
icon: Globe2,
kind: "worlds",
divider: true,
onClick: () => nav.openWorlds(),
},
{
id: "account",
label: t("nav:account"),
@@ -95,6 +106,7 @@ function Shell() {
label: t("nav:settings"),
icon: SlidersHorizontal,
kind: "settings",
divider: true,
onClick: () => nav.openSettings(),
},
{
@@ -138,8 +150,9 @@ function Shell() {
const Icon = item.icon;
const active = !item.external && nav.current.kind === item.kind;
return (
<Fragment key={item.id}>
{item.divider ? <div className="navitem-divider" aria-hidden /> : null}
<button
key={item.id}
className={`navitem ${active ? "is-active" : ""}`}
onClick={item.onClick}
title={item.label}
@@ -154,6 +167,7 @@ function Shell() {
</span>
) : null}
</button>
</Fragment>
);
})}
</nav>
@@ -177,6 +191,8 @@ function Shell() {
<InstanceView worldId={nav.current.worldId} instanceId={nav.current.instanceId} />
) : nav.current.kind === "group" ? (
<GroupView groupId={nav.current.id} />
) : nav.current.kind === "worlds" ? (
<MyWorldsView />
) : nav.current.kind === "account" ? (
<AccountSettingsView />
) : nav.current.kind === "settings" ? (
+1 -1
View File
@@ -1,7 +1,7 @@
import type { ButtonHTMLAttributes } from "react";
const BASE =
"group block overflow-hidden rounded-lg border border-border bg-surface text-left transition-colors";
"group block w-full overflow-hidden rounded-lg border border-border bg-surface text-left transition-colors";
export function Card({
className = "",
@@ -1,39 +1,19 @@
import { useEffect, useState } from "react";
import { ChevronRight } from "lucide-react";
import { Button } from "../../components/ui/Button";
import { api, events } from "../../lib/api";
import { api } from "../../lib/api";
import { useI18n } from "../../lib/i18n";
import { useGameLaunch } from "./useGameLaunch";
export function LaunchButton() {
const { t } = useI18n();
const [running, setRunning] = useState(false);
const [supported, setSupported] = useState(true);
const [launching, setLaunching] = useState(false);
useEffect(() => {
void api.game.status().then((s) => {
setRunning(s.running);
setSupported(s.supported);
});
return events.on("game:changed", (s) => {
setRunning(s.running);
if (s.launching) setLaunching(true);
});
}, []);
useEffect(() => {
if (running) setLaunching(false);
}, [running]);
const { running, supported, launching, markLaunching } = useGameLaunch();
const onClick = async () => {
if (launching) return;
if (!running) setLaunching(true);
if (!running) markLaunching();
try {
const s = await api.game.launch();
setRunning(s.running);
} catch {
setLaunching(false);
}
await api.game.launch();
} catch {}
};
if (!supported) {
@@ -0,0 +1,22 @@
import { useEffect, useState } from "react";
import { api, events } from "../../lib/api";
export function useGameLaunch() {
const [running, setRunning] = useState(false);
const [supported, setSupported] = useState(true);
const [launching, setLaunching] = useState(false);
useEffect(() => {
void api.game.status().then((s) => {
setRunning(s.running);
setSupported(s.supported);
});
return events.on("game:changed", (s) => {
setRunning(s.running);
if (s.launching) setLaunching(true);
if (s.running) setLaunching(false);
});
}, []);
return { running, supported, launching, markLaunching: () => setLaunching(true) };
}
@@ -5,6 +5,7 @@ export type View =
| { kind: "world"; id: string }
| { kind: "instance"; worldId: string; instanceId: string; location: string }
| { kind: "group"; id: string }
| { kind: "worlds" }
| { kind: "account" }
| { kind: "settings" }
| { kind: "enhancements" }
@@ -18,6 +19,7 @@ interface Nav {
openWorld: (id: string) => void;
openInstance: (worldId: string, instanceId: string, location: string) => void;
openGroup: (id: string) => void;
openWorlds: () => void;
openAccount: () => void;
openSettings: () => void;
openEnhancements: () => void;
@@ -49,6 +51,7 @@ export function NavProvider({ children }: { children: React.ReactNode }) {
openInstance: (worldId, instanceId, location) =>
push({ kind: "instance", worldId, instanceId, location }),
openGroup: (id) => push({ kind: "group", id }),
openWorlds: () => root({ kind: "worlds" }),
openAccount: () => root({ kind: "account" }),
openSettings: () => root({ kind: "settings" }),
openEnhancements: () => root({ kind: "enhancements" }),
@@ -0,0 +1,117 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import type { DiscoverCategory } from "../../../../shared/types/world";
import { Banner, SkeletonGrid } from "../../components/ui";
import { useT } from "../../lib/i18n";
import { api } from "../../lib/api";
import { useAsync } from "../../lib/useAsync";
import { WorldCard } from "./WorldsSection";
import "./profile.css";
export function DiscoverSection() {
const t = useT();
const state = useAsync(() => api.world.discover(), [], "Failed to load worlds.");
if (state.status === "loading") {
return (
<div className="flex flex-col gap-7">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="flex flex-col gap-3">
<div className="h-4 w-40 rounded-md bg-surface-hover" />
<SkeletonGrid count={4} />
</div>
))}
</div>
);
}
if (state.status === "error") return <Banner>{state.message}</Banner>;
if (!state.data.length)
return <p className="text-[13.5px] text-faint">{t("profile:worlds.empty")}</p>;
return (
<div className="flex flex-col gap-7">
{state.data.map((cat) => (
<Row key={cat.id} category={cat} />
))}
</div>
);
}
function Row({ category }: { category: DiscoverCategory }) {
const ref = useRef<HTMLDivElement>(null);
const [atStart, setAtStart] = useState(true);
const [atEnd, setAtEnd] = useState(false);
const sync = useCallback(() => {
const el = ref.current;
if (!el) return;
const max = el.scrollWidth - el.clientWidth;
setAtStart(el.scrollLeft <= 1);
setAtEnd(el.scrollLeft >= max - 1);
}, []);
useEffect(() => {
sync();
const el = ref.current;
if (!el) return;
const ro = new ResizeObserver(sync);
ro.observe(el);
return () => ro.disconnect();
}, [sync]);
const page = useCallback((dir: 1 | -1) => {
const el = ref.current;
if (!el) return;
el.scrollBy({ left: dir * el.clientWidth * 0.85, behavior: "smooth" });
}, []);
return (
<section className="flex flex-col gap-3">
<h3 className="text-[15px] font-semibold tracking-[-0.2px]">{category.name}</h3>
<div className="discover-rail relative" data-at-start={atStart} data-at-end={atEnd}>
<Arrow dir={-1} disabled={atStart} onClick={() => page(-1)} />
<Arrow dir={1} disabled={atEnd} onClick={() => page(1)} />
<div
ref={ref}
onScroll={sync}
className="discover-row -mx-1 flex snap-x scroll-smooth gap-3 overflow-x-auto px-1 pb-1"
>
{category.worlds.map((w) => (
<div key={w.id} className="w-[200px] shrink-0 snap-start">
<WorldCard world={w} />
</div>
))}
</div>
</div>
</section>
);
}
function Arrow({
dir,
disabled,
onClick,
}: {
dir: 1 | -1;
disabled: boolean;
onClick: () => void;
}) {
const Icon = dir === 1 ? ChevronRight : ChevronLeft;
return (
<button
type="button"
aria-label={dir === 1 ? "Scroll right" : "Scroll left"}
tabIndex={-1}
disabled={disabled}
onClick={onClick}
className={`discover-arrow absolute top-1/2 z-10 grid size-9 place-items-center rounded-full border border-border bg-surface/90 text-text shadow-md backdrop-blur hover:bg-surface ${
dir === 1 ? "right-1" : "left-1"
}`}
>
<Icon size={18} />
</button>
);
}
@@ -0,0 +1,59 @@
import { useState } from "react";
import { Banner, Loader, Tabs } from "../../components/ui";
import { useT } from "../../lib/i18n";
import { useProfile } from "./useProfile";
import { WorldsSection, FavoriteWorldsSection, WorldSearch } from "./WorldsSection";
import { DiscoverSection } from "./DiscoverSection";
const SHELL = "mx-auto flex w-full max-w-[1100px] flex-col gap-5 px-12 pb-16 pt-10";
type Tab = "discover" | "worlds" | "favorites";
export function MyWorldsView() {
const t = useT();
const state = useProfile("me");
const [tab, setTab] = useState<Tab>("discover");
if (state.status === "loading") return <Loader className="absolute inset-0" />;
if (state.status === "error")
return (
<div className={SHELL}>
<Banner>{state.message}</Banner>
</div>
);
const userId = state.profile.id;
return (
<div className={SHELL}>
<header>
<h1 className="text-[26px] font-bold tracking-[-0.4px]">{t("nav:worlds")}</h1>
</header>
<Tabs
tabs={[
{ id: "discover", label: t("profile:tabs.discover") },
{ id: "worlds", label: t("profile:tabs.worlds") },
{ id: "favorites", label: t("profile:tabs.favorites") },
]}
active={tab}
onChange={setTab}
/>
{tab === "discover" ? (
<div className="rise-in">
<DiscoverSection />
</div>
) : tab === "worlds" ? (
<div className="rise-in">
<WorldSearch>{(filter) => <WorldsSection userId={userId} filter={filter} />}</WorldSearch>
</div>
) : (
<div className="rise-in">
<WorldSearch>
{(filter) => <FavoriteWorldsSection userId={userId} filter={filter} />}
</WorldSearch>
</div>
)}
</div>
);
}
@@ -27,7 +27,7 @@ import {
trustMeta,
} from "../../lib/vrchat";
import { useProfile } from "./useProfile";
import { WorldsSection, FavoriteWorldsSection } from "./WorldsSection";
import { WorldsSection, FavoriteWorldsSection, WorldSearch } from "./WorldsSection";
import { GroupsSection } from "./GroupsSection";
import { LocationSection } from "./LocationSection";
import { COL, COL_WIDE } from "../../lib/layout";
@@ -290,16 +290,19 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
</div>
) : null}
{/* don't hit tab endpoints until the tab opens */}
{tab === "worlds" ? (
<div className="rise-in">
<WorldsSection userId={profile.id} />
<WorldSearch>
{(filter) => <WorldsSection userId={profile.id} filter={filter} />}
</WorldSearch>
</div>
) : null}
{tab === "favorites" ? (
<div className="rise-in">
<FavoriteWorldsSection userId={profile.id} />
<WorldSearch>
{(filter) => <FavoriteWorldsSection userId={profile.id} filter={filter} />}
</WorldSearch>
</div>
) : null}
@@ -1,26 +1,58 @@
import { useMemo, useState } from "react";
import { Circle, Star, Users } from "lucide-react";
import type { World } from "../../../../shared/types/world";
import { Card, CollapsibleCard, HoverImage, SkeletonGrid, Tag } from "../../components/ui";
import { Card, CollapsibleCard, Field, HoverImage, SkeletonGrid, Tag } from "../../components/ui";
import { compactNumber } from "../../lib/format";
import { useT } from "../../lib/i18n";
import { useNav } from "../navigation/NavContext";
import { useUserWorlds } from "./useUserWorlds";
import { useFavoriteWorlds } from "./useFavoriteWorlds";
export function WorldsSection({ userId }: { userId: string }) {
type WorldFilter = (world: World) => boolean;
function matchWorld(query: string): WorldFilter {
const q = query.trim().toLowerCase();
if (!q) return () => true;
const terms = q.split(/\s+/);
return (w) => {
const haystack = `${w.name} ${w.authorName} ${w.description} ${w.tags.join(" ")}`.toLowerCase();
return terms.every((term) => haystack.includes(term));
};
}
export function WorldSearch({ children }: { children: (filter: WorldFilter) => React.ReactNode }) {
const t = useT();
const { status, worlds, message } = useUserWorlds(userId);
const [query, setQuery] = useState("");
const filter = useMemo(() => matchWorld(query), [query]);
return (
<WorldGrid
title={t("profile:worlds.title")}
status={status}
worlds={worlds}
message={message}
<div className="flex flex-col gap-5">
<Field
label={t("profile:worlds.search.label")}
placeholder={t("profile:worlds.search.placeholder")}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{children(filter)}
</div>
);
}
export function FavoriteWorldsSection({ userId }: { userId: string }) {
export function WorldsSection({ userId, filter }: { userId: string; filter?: WorldFilter }) {
const t = useT();
const { status, worlds, message } = useUserWorlds(userId);
const shown = filter ? worlds.filter(filter) : worlds;
return (
<WorldGrid title={t("profile:worlds.title")} status={status} worlds={shown} message={message} />
);
}
export function FavoriteWorldsSection({
userId,
filter,
}: {
userId: string;
filter?: WorldFilter;
}) {
const t = useT();
const { status, folders } = useFavoriteWorlds(userId);
const loading = status === "loading";
@@ -33,11 +65,15 @@ export function FavoriteWorldsSection({ userId }: { userId: string }) {
);
}
if (!folders.length) return null;
const shown = filter
? folders.map((f) => ({ ...f, worlds: f.worlds.filter(filter) })).filter((f) => f.worlds.length)
: folders;
if (!shown.length) return null;
return (
<div className="flex flex-col gap-5">
{folders.map((folder) => (
{shown.map((folder) => (
<CollapsibleCard key={folder.name} title={folder.displayName} count={folder.worlds.length}>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{folder.worlds.map((w) => (
@@ -109,7 +145,7 @@ function Wrap({
);
}
function WorldCard({ world }: { world: World }) {
export function WorldCard({ world }: { world: World }) {
const t = useT();
const { openWorld } = useNav();
const img = world.thumbnailImageUrl || world.imageUrl;
@@ -1,3 +1,58 @@
.discover-row {
scrollbar-width: none;
scroll-padding-inline: 4px;
}
.discover-row::-webkit-scrollbar {
display: none;
}
.discover-rail[data-at-start="false"] {
-webkit-mask-image: linear-gradient(to right, transparent, #000 28px);
mask-image: linear-gradient(to right, transparent, #000 28px);
}
.discover-rail[data-at-end="false"] {
-webkit-mask-image: linear-gradient(to left, transparent, #000 28px);
mask-image: linear-gradient(to left, transparent, #000 28px);
}
.discover-rail[data-at-start="false"][data-at-end="false"] {
-webkit-mask-image: linear-gradient(
to right,
transparent,
#000 28px,
#000 calc(100% - 28px),
transparent
);
mask-image: linear-gradient(
to right,
transparent,
#000 28px,
#000 calc(100% - 28px),
transparent
);
}
.discover-arrow {
opacity: 0;
pointer-events: none;
transform: translateY(-50%) scale(0.96);
transition:
opacity var(--dur) var(--ease),
transform var(--dur) var(--ease),
background var(--dur) var(--ease);
}
.discover-rail:hover .discover-arrow:not([disabled]) {
opacity: 1;
pointer-events: auto;
transform: translateY(-50%) scale(1);
}
.discover-rail:hover .discover-arrow:not([disabled]):hover {
transform: translateY(-50%) scale(1.07);
}
@media (prefers-reduced-motion: reduce) {
.discover-row {
scroll-behavior: auto;
}
}
.profile__banner {
height: clamp(200px, 32vh, 340px);
flex: none;
@@ -5,6 +5,7 @@ import { api, errorMessage } from "../../lib/api";
import { useCopied } from "../../lib/useCopied";
import { accessLabel, regionFlag, regionLabel } from "../../lib/vrchat";
import { useT } from "../../lib/i18n";
import { useGameLaunch } from "../game/useGameLaunch";
import type {
CreateInstanceInput,
CreateInstanceType,
@@ -185,20 +186,18 @@ function Picker<T extends string>({
function ResultView({ instance }: { instance: Instance }) {
const t = useT();
const { running, launching, markLaunching } = useGameLaunch();
const [inviteSent, setInviteSent] = useState(false);
const [inviteError, setInviteError] = useState<string | null>(null);
const [launching, setLaunching] = useState(false);
const [launchError, setLaunchError] = useState<string | null>(null);
const launch = async () => {
setLaunching(true);
if (!running) markLaunching();
setLaunchError(null);
try {
await api.game.join(instance.location);
} catch (err) {
setLaunchError(errorMessage(err, "Failed to launch VRChat"));
} finally {
setLaunching(false);
}
};
@@ -220,9 +219,13 @@ function ResultView({ instance }: { instance: Instance }) {
{canLaunch ? (
<div className="flex flex-col gap-1.5">
<Button variant="primary" onClick={launch} loading={launching} block>
<Button variant="primary" onClick={launch} loading={launching} disabled={running} block>
{!launching ? <Play size={15} /> : null}
{launching ? t("world:create.launching") : t("world:create.launch")}
{running
? t("world:create.running")
: launching
? t("world:create.launching")
: t("world:create.launch")}
</Button>
{launchError ? <span className="text-[12px] text-danger">{launchError}</span> : null}
</div>
@@ -9,6 +9,7 @@ import { useNav } from "../navigation/NavContext";
import { useT } from "../../lib/i18n";
import { COL_WIDE } from "../../lib/layout";
import { accessLabel, regionFlag, regionLabel } from "../../lib/vrchat";
import { useGameLaunch } from "../game/useGameLaunch";
import "../profile/profile.css";
export function InstanceView({ worldId, instanceId }: { worldId: string; instanceId: string }) {
@@ -131,20 +132,18 @@ function InstanceCard({
function JoinActions({ instance }: { instance: Instance }) {
const t = useT();
const [joining, setJoining] = useState(false);
const { running, launching, markLaunching } = useGameLaunch();
const [joinError, setJoinError] = useState<string | null>(null);
const [inviteSent, setInviteSent] = useState(false);
const [inviteError, setInviteError] = useState<string | null>(null);
const join = async () => {
setJoining(true);
if (!running) markLaunching();
setJoinError(null);
try {
await api.game.join(instance.location);
} catch (err) {
setJoinError(errorMessage(err, "Failed to launch VRChat"));
} finally {
setJoining(false);
}
};
@@ -168,9 +167,13 @@ function JoinActions({ instance }: { instance: Instance }) {
{inviteSent ? t("world:instance.inviteSent") : t("world:instance.inviteMe")}
</Button>
{canLaunch ? (
<Button variant="primary" onClick={join} loading={joining}>
{!joining ? <Play size={15} /> : null}
{joining ? t("world:instance.joining") : t("world:instance.join")}
<Button variant="primary" onClick={join} loading={launching} disabled={running}>
{!launching ? <Play size={15} /> : null}
{running
? t("world:instance.running")
: launching
? t("world:instance.joining")
: t("world:instance.join")}
</Button>
) : null}
</div>
+1
View File
@@ -58,6 +58,7 @@ export const api = {
world: {
byUser: (userId: string) => call("world:byUser", userId),
search: (query: string) => call("world:search", query),
discover: () => call("world:discover"),
favorites: (userId: string) => call("world:favorites", userId),
get: (worldId: string) => call("world:get", worldId),
snapshot: () => call("world:snapshot"),
@@ -2,6 +2,7 @@
"search": "Search",
"gallery": "Gallery",
"enhancements": "Enhancements",
"worlds": "Worlds",
"account": "Account Settings",
"settings": "Settings",
"debug": "Debug",
@@ -10,7 +10,8 @@
"addFriendError": "Couldn't send friend request.",
"tabs": {
"overview": "Overview",
"worlds": "Worlds",
"discover": "Discover",
"worlds": "Uploaded Worlds",
"favorites": "Favorite Worlds",
"groups": "Groups"
},
@@ -48,7 +49,12 @@
"visits": "visits",
"capacity": "capacity"
},
"visitsCount": "{{formattedCount}} visits"
"visitsCount": "{{formattedCount}} visits",
"search": {
"label": "Search worlds",
"placeholder": "Filter by name, author, or tag…"
},
"empty": "No worlds to show."
},
"groups": {
"title": "Groups",
@@ -27,6 +27,7 @@
"ready": "Instance created.",
"launch": "Launch VRChat",
"launching": "Launching…",
"running": "VRChat Running…",
"selfInvite": "Invite myself",
"selfInviteSent": "Invite sent. Check VRChat.",
"lockedLink": "locked link",
@@ -46,6 +47,7 @@
"queue": "In queue",
"join": "Join in VRChat",
"joining": "Launching…",
"running": "VRChat Running…",
"inviteMe": "Invite me",
"inviteSent": "Invite sent. Check VRChat.",
"worldDetails": "View world details",
@@ -2,6 +2,7 @@
"search": "検索",
"gallery": "ギャラリー",
"enhancements": "拡張機能",
"worlds": "ワールド",
"account": "アカウント設定",
"settings": "設定",
"debug": "デバッグ",
@@ -10,7 +10,8 @@
"addFriendError": "フレンド申請を送信できませんでした。",
"tabs": {
"overview": "概要",
"worlds": "ワールド",
"discover": "見つける",
"worlds": "アップロードしたワールド",
"favorites": "お気に入りのワールド",
"groups": "グループ"
},
@@ -48,7 +49,12 @@
"visits": "訪問数",
"capacity": "定員"
},
"visitsCount": "{{formattedCount}} 回訪問"
"visitsCount": "{{formattedCount}} 回訪問",
"search": {
"label": "ワールドを検索",
"placeholder": "名前・作者・タグで絞り込み…"
},
"empty": "表示するワールドがありません。"
},
"groups": {
"title": "グループ",
@@ -27,6 +27,7 @@
"ready": "インスタンスを作成しました。",
"launch": "VRChatを起動",
"launching": "起動中…",
"running": "VRChat実行中…",
"selfInvite": "自分を招待",
"selfInviteSent": "招待を送信しました。VRChatを確認してください",
"lockedLink": "ロック付きリンク",
@@ -46,6 +47,7 @@
"queue": "待機列",
"join": "VRChatで参加",
"joining": "起動中…",
"running": "VRChat実行中…",
"inviteMe": "自分を招待",
"inviteSent": "招待を送信しました。VRChatを確認してください",
"worldDetails": "ワールドの詳細を見る",
@@ -2,6 +2,7 @@
"search": "ค้นหา",
"gallery": "แกลเลอรี",
"enhancements": "ส่วนเสริม",
"worlds": "โลก",
"account": "ตั้งค่าบัญชี",
"settings": "ตั้งค่า",
"debug": "ดีบัก",
@@ -10,7 +10,8 @@
"addFriendError": "ส่งคำขอเป็นเพื่อนไม่สำเร็จ",
"tabs": {
"overview": "ภาพรวม",
"worlds": "เวิลด์",
"discover": "ค้นพบ",
"worlds": "เวิลด์ที่อัปโหลด",
"favorites": "เวิลด์ที่ชื่นชอบ",
"groups": "กลุ่ม"
},
@@ -48,7 +49,12 @@
"visits": "การเข้าชม",
"capacity": "ความจุ"
},
"visitsCount": "เข้าชม {{formattedCount}} ครั้ง"
"visitsCount": "เข้าชม {{formattedCount}} ครั้ง",
"search": {
"label": "ค้นหาโลก",
"placeholder": "กรองตามชื่อ ผู้สร้าง หรือแท็ก…"
},
"empty": "ไม่มีโลกที่จะแสดง"
},
"groups": {
"title": "กลุ่ม",
@@ -27,6 +27,7 @@
"ready": "สร้างอินสแตนซ์แล้ว",
"launch": "เปิด VRChat",
"launching": "กำลังเปิด…",
"running": "VRChat กำลังทำงาน…",
"selfInvite": "เชิญตัวเอง",
"selfInviteSent": "ส่งคำเชิญแล้ว ตรวจสอบใน VRChat",
"lockedLink": "ลิงก์ที่ล็อก",
@@ -46,6 +47,7 @@
"queue": "อยู่ในคิว",
"join": "เข้าร่วมใน VRChat",
"joining": "กำลังเปิด…",
"running": "VRChat กำลังทำงาน…",
"inviteMe": "เชิญตัวเอง",
"inviteSent": "ส่งคำเชิญแล้ว ตรวจสอบใน VRChat",
"worldDetails": "ดูรายละเอียดเวิลด์",
+5
View File
@@ -123,6 +123,11 @@
flex-direction: column;
gap: 2px;
}
.navitem-divider {
height: 1px;
margin: 6px 8px;
background: var(--border);
}
.navitem {
display: flex;
align-items: center;
+2 -1
View File
@@ -6,7 +6,7 @@ import type {
TwoFactorPayload,
} from "./types/auth";
import type { SocialSnapshot, UserProfile, UserStatus } from "./types/user";
import type { FavoriteWorldFolder, World, WorldSnapshot } from "./types/world";
import type { DiscoverCategory, FavoriteWorldFolder, World, WorldSnapshot } from "./types/world";
import type { CreateInstanceInput, Instance, InstanceRegion } from "./types/instance";
import type { UnityStatus } from "./types/unity";
import type { Avatar } from "./types/avatar";
@@ -48,6 +48,7 @@ export interface IpcRequests {
"world:byUser": (userId: string) => IpcResult<World[]>;
"world:search": (query: string) => IpcResult<World[]>;
"world:discover": () => IpcResult<DiscoverCategory[]>;
"world:favorites": (userId: string) => IpcResult<FavoriteWorldFolder[]>;
"world:get": (worldId: string) => IpcResult<World>;
"world:snapshot": () => IpcResult<WorldSnapshot>;
+6
View File
@@ -45,3 +45,9 @@ export interface FavoriteWorldFolder {
displayName: string;
worldIds: string[];
}
export interface DiscoverCategory {
id: string;
name: string;
worlds: World[];
}