Hosted rooms: the hub's side
CI / test (push) Successful in 1m5s

Rooms made on the website (hosted_room, migration 0005), the REST routes in
docs/hosted-rooms.md, and the realtime room.* protocol: joining with the
capacity, adult and linked-account rules, seats, numbered messages with
forged senders dropped and a rate limit, closing. The Rooms page lists them,
with a form to make one and the owner's edit and close.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-30 03:21:21 -07:00
1 parent 0c67232425
commit 2a1fc94df6
18 files changed
+3138 -52

No files matched your search

+67
View File
@@ -0,0 +1,67 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { ActionForm, SubmitButton } from "@/components/forms";
import { PageHeader } from "@/components/status";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { FieldGroup } from "@/components/ui/field";
import { api, getMe, unwrap } from "@/lib/api";
import { closeRoom, saveRoom } from "../../actions";
import { RoomFields } from "../../room-fields";
export const metadata: Metadata = { title: "Edit room" };
export default async function EditRoom({ params }: { params: Promise<{ n: string }> }) {
const { n } = await params;
if (!/^[0-9]{1,9}$/.test(n)) notFound();
const me = await getMe();
if (!me) redirect(`/signin?next=/rooms/${n}/edit`);
const [{ rooms }, { links }] = await Promise.all([
api["hosted-rooms"].get({ query: { mine: "1" } }).then(unwrap),
api.account.get().then(unwrap),
]);
const room = rooms.find((r) => String(r.n) === n);
if (!room) notFound();
return (
<div className="space-y-6">
<PageHeader title={`Edit ${room.name}`}>
<Link href="/rooms" className="underline-offset-4 hover:underline">
Back to rooms
</Link>
</PageHeader>
<Card>
<CardHeader>
<CardDescription>
Owned by {room.ownerName} · {room.occupancy} here now
</CardDescription>
</CardHeader>
<CardContent>
<ActionForm action={saveRoom} fields={{ n: room.n }}>
<FieldGroup>
<RoomFields room={room} canAdult={links.some((l) => l.adult)} />
<div>
<SubmitButton pendingLabel="Saving…">Save</SubmitButton>
</div>
</FieldGroup>
</ActionForm>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Close room</CardTitle>
<CardDescription>Everyone in it is sent out. It can&apos;t be opened again.</CardDescription>
</CardHeader>
<CardContent>
<ActionForm action={closeRoom} fields={{ n: room.n }}>
<SubmitButton variant="destructive" pendingLabel="Closing…" confirm={`Close ${room.name}?`}>
Close room
</SubmitButton>
</ActionForm>
</CardContent>
</Card>
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { api, unwrap } from "@/lib/api";
import { attempt, field, type ActionState } from "@/lib/actions";
const rating = (form: FormData) => (field(form, "rating") === "adult" ? ("adult" as const) : ("general" as const));
/** Makes a hosted room, then goes back to the list. */
export async function createRoom(_: ActionState, form: FormData): Promise<ActionState> {
const state = await attempt(async () => {
unwrap(
await api["hosted-rooms"].post({
name: field(form, "name"),
description: field(form, "description"),
roomPid: Number(field(form, "roomPid")),
rating: rating(form),
maxUsers: Number(field(form, "maxUsers")),
imvuId: Number(field(form, "imvuId")),
}),
);
revalidatePath("/rooms");
});
if (state?.error) return state;
redirect("/rooms");
}
/** The owner's changes to a room. */
export async function saveRoom(_: ActionState, form: FormData): Promise<ActionState> {
const n = field(form, "n");
const state = await attempt(async () => {
unwrap(
await api["hosted-rooms"]({ n }).patch({
name: field(form, "name"),
description: field(form, "description"),
roomPid: Number(field(form, "roomPid")),
rating: rating(form),
maxUsers: Number(field(form, "maxUsers")),
}),
);
revalidatePath("/rooms");
});
if (state?.error) return state;
redirect("/rooms");
}
/** Closes a room: everyone in it is sent out. */
export async function closeRoom(_: ActionState, form: FormData): Promise<ActionState> {
const n = field(form, "n");
const state = await attempt(async () => {
unwrap(await api["hosted-rooms"]({ n }).delete());
revalidatePath("/rooms");
});
if (state?.error) return state;
redirect("/rooms");
}
+61
View File
@@ -0,0 +1,61 @@
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { ActionForm, SubmitButton } from "@/components/forms";
import { PageHeader } from "@/components/status";
import { Card, CardContent } from "@/components/ui/card";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { api, getMe, unwrap } from "@/lib/api";
import { createRoom } from "../actions";
import { RoomFields } from "../room-fields";
export const metadata: Metadata = { title: "New room" };
export default async function NewRoom() {
const me = await getMe();
if (!me) redirect("/signin?next=/rooms/new");
const { links } = await api.account.get().then(unwrap);
return (
<div className="space-y-6">
<PageHeader title="New room">
<Link href="/rooms" className="underline-offset-4 hover:underline">
Back to rooms
</Link>
</PageHeader>
<Card>
<CardContent>
{links.length === 0 ? (
<p className="py-2 text-sm text-muted-foreground">
<Link href="/account" className="text-foreground underline underline-offset-4">
Link an IMVU account
</Link>{" "}
first: it&apos;s shown as the room&apos;s owner.
</p>
) : (
<ActionForm action={createRoom}>
<FieldGroup>
<RoomFields canAdult={links.some((l) => l.adult)} />
<Field>
<FieldLabel htmlFor="imvuId">Owner</FieldLabel>
<NativeSelect id="imvuId" name="imvuId" defaultValue={String(links[0]!.imvuId)} className="w-64">
{links.map((l) => (
<NativeSelectOption key={l.imvuId} value={String(l.imvuId)}>
{l.username}
</NativeSelectOption>
))}
</NativeSelect>
<FieldDescription>The IMVU account shown as owner. It can decorate the room.</FieldDescription>
</Field>
<div>
<SubmitButton pendingLabel="Making…">Make room</SubmitButton>
</div>
</FieldGroup>
</ActionForm>
)}
</CardContent>
</Card>
</div>
);
}
+110 -44
View File
@@ -1,55 +1,121 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader, Tag } from "@/components/status";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { api, unwrap } from "@/lib/api";
import { api, getMe, unwrap, type HostedRoom } from "@/lib/api";
export const metadata: Metadata = { title: "Rooms" };
export default async function Rooms() {
const { rooms } = await api.rooms.get().then(unwrap);
const me = await getMe();
const [{ rooms }, { rooms: hosted }, mine] = await Promise.all([
api.rooms.get().then(unwrap),
api["hosted-rooms"].get({ query: {} }).then(unwrap),
me ? api["hosted-rooms"].get({ query: { mine: "1" } }).then(unwrap) : { rooms: [] as HostedRoom[] },
]);
const own = new Set(mine.rooms.map((r) => r.n));
return (
<>
<PageHeader title="Rooms">Public IMVU rooms of T5DE users.</PageHeader>
{rooms.length === 0 ? (
<Card>
<CardContent>
<p className="py-6 text-sm text-muted-foreground">No rooms listed yet.</p>
</CardContent>
</Card>
) : (
<ul className="grid gap-4 sm:grid-cols-2">
{rooms.map((r) => (
<li key={r.instanceId}>
<Card className="h-full overflow-hidden py-0">
{r.imageURL && (
// IMVU's own image of the room, from IMVU.
<img src={r.imageURL} alt="" className="aspect-[332/281] w-full bg-muted object-cover" loading="lazy" />
)}
<CardContent className="space-y-3 pb-6">
<div className="flex flex-wrap items-center gap-2">
<a href={r.url} className="font-medium underline-offset-4 hover:underline" rel="noreferrer">
{r.name}
</a>
{r.adult && <Tag tone="adult">AP</Tag>}
{r.t5de.length > 0 && (
<Tag tone="online" dot>
{r.t5de.length} on T5DE
</Tag>
)}
</div>
<p className="text-xs text-muted-foreground">
{r.owner.username} · {r.occupancy}/{r.capacity}
</p>
{r.description && <p className="line-clamp-3 text-sm whitespace-pre-line">{r.description}</p>}
{r.t5de.length > 0 && (
<p className="text-xs text-muted-foreground">Here now: {r.t5de.map((u) => u.username).join(", ")}</p>
<div className="space-y-10">
<div className="flex flex-wrap items-start justify-between gap-4">
<PageHeader title="Rooms">Rooms on T5DE, and public IMVU rooms of T5DE users.</PageHeader>
{me && (
<Link href="/rooms/new" className={buttonVariants({ className: "px-4" })}>
New room
</Link>
)}
</div>
<section className="space-y-4">
<h2 className="text-lg font-semibold tracking-tight">On T5DE</h2>
{hosted.length === 0 ? (
<Card>
<CardContent>
<p className="py-6 text-sm text-muted-foreground">No rooms yet.</p>
</CardContent>
</Card>
) : (
<ul className="grid gap-4 sm:grid-cols-2">
{hosted.map((r) => (
<li key={r.n}>
<HostedCard room={r} mine={own.has(r.n)} />
</li>
))}
</ul>
)}
</section>
<section className="space-y-4">
<h2 className="text-lg font-semibold tracking-tight">On IMVU</h2>
{rooms.length === 0 ? (
<Card>
<CardContent>
<p className="py-6 text-sm text-muted-foreground">No rooms listed yet.</p>
</CardContent>
</Card>
) : (
<ul className="grid gap-4 sm:grid-cols-2">
{rooms.map((r) => (
<li key={r.instanceId}>
<Card className="h-full overflow-hidden py-0">
{r.imageURL && (
// IMVU's own image of the room, from IMVU.
<img src={r.imageURL} alt="" className="aspect-[332/281] w-full bg-muted object-cover" loading="lazy" />
)}
</CardContent>
</Card>
</li>
))}
</ul>
)}
</>
<CardContent className="space-y-3 pb-6">
<div className="flex flex-wrap items-center gap-2">
<a href={r.url} className="font-medium underline-offset-4 hover:underline" rel="noreferrer">
{r.name}
</a>
{r.adult && <Tag tone="adult">AP</Tag>}
{r.t5de.length > 0 && (
<Tag tone="online" dot>
{r.t5de.length} on T5DE
</Tag>
)}
</div>
<p className="text-xs text-muted-foreground">
{r.owner.username} · {r.occupancy}/{r.capacity}
</p>
{r.description && <p className="line-clamp-3 text-sm whitespace-pre-line">{r.description}</p>}
{r.t5de.length > 0 && (
<p className="text-xs text-muted-foreground">Here now: {r.t5de.map((u) => u.username).join(", ")}</p>
)}
</CardContent>
</Card>
</li>
))}
</ul>
)}
</section>
</div>
);
}
function HostedCard({ room: r, mine }: { room: HostedRoom; mine: boolean }) {
return (
<Card className="h-full">
<CardContent className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{r.name}</span>
{r.rating === "adult" && <Tag tone="adult">Adult</Tag>}
{r.occupancy > 0 && (
<Tag tone="online" dot>
{r.occupancy} here
</Tag>
)}
{mine && (
<Link href={`/rooms/${r.n}/edit`} className={buttonVariants({ variant: "outline", size: "sm", className: "ml-auto" })}>
Edit
</Link>
)}
</div>
<p className="text-xs text-muted-foreground">
{r.ownerName} · {r.occupancy}/{r.maxUsers}
</p>
{r.description && <p className="line-clamp-3 text-sm whitespace-pre-line">{r.description}</p>}
</CardContent>
</Card>
);
}
+60
View File
@@ -0,0 +1,60 @@
// The fields of a hosted room, for making one and for editing it.
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Textarea } from "@/components/ui/textarea";
import type { HostedRoom } from "@/lib/api";
export function RoomFields({ room, canAdult }: { room?: HostedRoom; canAdult: boolean }) {
return (
<>
<Field>
<FieldLabel htmlFor="name">Name</FieldLabel>
<Input id="name" name="name" defaultValue={room?.name} required maxLength={64} className="max-w-md" />
</Field>
<Field>
<FieldLabel htmlFor="description">Description</FieldLabel>
<Textarea id="description" name="description" defaultValue={room?.description} maxLength={512} rows={3} />
</Field>
<div className="grid gap-4 sm:grid-cols-3">
<Field>
<FieldLabel htmlFor="roomPid">Room shell</FieldLabel>
<Input
id="roomPid"
name="roomPid"
type="number"
inputMode="numeric"
min={1}
required
defaultValue={room?.roomPid}
placeholder="Product ID"
/>
<FieldDescription>An IMVU room product&apos;s ID, from its shop page.</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="rating">Rating</FieldLabel>
<NativeSelect id="rating" name="rating" defaultValue={room?.rating ?? "general"} className="w-full">
<NativeSelectOption value="general">General</NativeSelectOption>
<NativeSelectOption value="adult" disabled={!canAdult && room?.rating !== "adult"}>
Adult
</NativeSelectOption>
</NativeSelect>
{!canAdult && <FieldDescription>Adult needs an age-verified account.</FieldDescription>}
</Field>
<Field>
<FieldLabel htmlFor="maxUsers">Max people</FieldLabel>
<Input
id="maxUsers"
name="maxUsers"
type="number"
inputMode="numeric"
min={2}
max={30}
required
defaultValue={room?.maxUsers ?? 10}
/>
</Field>
</div>
</>
);
}
+1
View File
@@ -45,6 +45,7 @@ export type ProductPage = Data<ReturnType<V1["products"]>["get"]>;
export type Revision = ProductPage["revisions"][number];
export type Catalog = Data<V1["catalogs"]["get"]>["catalogs"][number];
export type LibraryEntry = Data<V1["library"]["get"]>["library"][number];
export type HostedRoom = Data<V1["hosted-rooms"]["get"]>["rooms"][number];
/** An error the API answered: message is written to be shown as is. */
export class ApiError extends Error {