import { useCallback, useEffect, useRef, useState } from 'react' import { Accessibility } from '@repo/domain/src/enums' import { GAME_VERSION } from '@repo/domain/src/presence-db' import { NotificationType } from '../../../notify/src/notification-types' import { authFailure, authUnreachable } from '../auth-messages' import { DISCORD_INVITE, DOWNLOAD_URL, LICENSE_URL, QUEST_DOWNLOAD_URL, SOURCE_REPO, } from '../links' import { downgradeRoom, needsDowngrade } from '../room-converter' // The session token, the worker hostnames and the `call` every request goes through — // see api.ts for why they're a module of their own rather than defined here. import { accountIdForUsername, call, hasToken, isAdmin, isDeveloper, setHosts, setToken, useAction, usernamesFor, where, } from './api' import { customAvatarItemIdFromPath, ItemPage } from './Item' import { ModerationPage } from './Moderation' import { StatsPage } from './Stats' import type { ReactNode } from 'react' import type { Hosts } from './api' /** * Site config from `www`. `signupEnabled` is false when the operator has no Turnstile * keypair configured — web signup runs behind that bot check, so without it the endpoint * is closed and the UI must not offer the form. */ interface SiteConfig { signupEnabled: boolean turnstileSiteKey: string | null /** * Whether the Discord-verified benefits claim is configured. False when the operator * has no Discord app/guild/role set, in which case the claim page and its links stay * hidden — the endpoint would refuse anyway. */ benefitsEnabled: boolean /** * The Discord consent URL to send the player to, assembled by `www` (scopes and the * redirect URI are its business, and must match what the claim will accept). Null when * benefits are off. The `state` nonce is appended here — see `startDiscordAuth`. */ discordAuthorizeUrl: string | null } /** The private self DTO from `accounts` (`GET /account/me`). */ interface SelfAccount { accountId: number username: string displayName: string email: string | null /** A key on the `img` worker — `DefaultProfileImage.jpg` until the player sets one. */ profileImage: string /** * Username changes left on the account — each change spends one, and an account * starts with one. Absent on an older self DTO, which reads as "unknown": the form * stays usable and lets the server be the one to refuse. */ availableUsernameChanges?: number } /** * One subroom, as `rooms` re-attaches them to every room read. A room is a container; * the subrooms are the actual places players load into, each with its own accessibility * and its own save history. */ interface SubRoom { SubRoomId: number Name: string /** Set INDEPENDENTLY of the room's — a public room can hold a private subroom. */ Accessibility: number IsSandbox: boolean MaxPlayers: number /** The subroom's scene-data key, served back by `cdn` under `room/`. */ DataBlob?: string /** * A save posted without `AutoPublish` waits here. Cleared when that save is * published, so a non-null value means "edited since players last saw a change". */ StagedSubRoomDataSaveId: number | null /** * What players actually load. Null until the first publish — and a subroom without * one silently loads nothing, which is worth surfacing to an owner who can't tell * that apart from a broken room. */ CurrentSave: { SubRoomDataSaveId: number CreatedAt: string Description: string /** * The scene-data key for the published save — what the client downloads to load * the place, and the file worth keeping a copy of. `subRoomDataBlob()` resolves * this one first, ahead of the subroom's own. */ DataBlob: string } | null } /** * One room from `rooms` (`GET /rooms/ownedby/me`), narrowed to what these pages draw. * The worker serves the stored room blob verbatim — dozens of fields the game needs and * the website doesn't — so only the ones read here are declared. */ interface OwnedRoom { RoomId: number Name: string Description: string /** Who made it — the account the public page links to, and whom the owner view is for. */ CreatorAccountId: number /** A key on the `img` worker; a room with no image of its own gets the fallback. */ ImageName: string /** The `Accessibility` ordinal, NOT the enum name — see ACCESSIBILITY_LABEL. */ Accessibility: number CreatedAt: string MaxPlayers: number /** False blocks `POST /rooms/{id}/clone` — nobody can take a copy of the room. */ CloningAllowed: boolean SupportsScreens: boolean SupportsWalkVR: boolean SupportsTeleportVR: boolean SupportsQuest2: boolean SupportsMobile: boolean SupportsJuniors: boolean /** `Type` 0 is a tag the owner set, 2 one the server derived. */ Tags: Array<{ Tag: string; Type: number }> SubRooms: SubRoom[] /** Always present: the worker folds the live counters in on every read. */ Stats: { CheerCount: number FavoriteCount: number VisitorCount: number VisitCount: number } } /** * What an `Accessibility` ordinal is called on screen — rooms and subrooms both carry * one. The two dev values are reachable (the game sets them), so they're named rather * than left to fall through to the unknown case in `accessibilityLabel`. */ const ACCESSIBILITY_LABEL: Record = { [Accessibility.Private]: 'Private', [Accessibility.Public]: 'Public', [Accessibility.Unlisted]: 'Unlisted', [Accessibility.Dev_only]: 'Dev only', [Accessibility.Dev_Unlisted]: 'Dev unlisted', } /** * RecNet (4) is the web platform, stamped as the token's `platform` claim on sign-in. * NOT passed on signup: create_account treats an asserted platform as one to verify * against Steam and rejects RecNet — the web signup is the (platform-less) password * account path. */ const WEB_PLATFORM = '4' /** The signed-in account, straight from `accounts`. */ const fetchMe = (): Promise => call(`${where().accounts}/account/me`, { authed: true }) /** * The caller's own rooms, from the `rooms` worker — the same list the game's "My Rooms" * loads. `ownedby/me` rather than `createdby/me`: the dorm is auto-provisioned, not a * room the player made, and it's the one room they can't do anything with from here. * * The worker deliberately does NOT filter on accessibility for this list, so a room that * has never been published shows up — which is the point, since that's the one its owner * is most likely to be looking for. * * Sorted newest-first here rather than upstream: the query has no ORDER BY (D1 hands * back insertion order, which is not a promise), and the room someone just made is the * one they came to see. */ async function fetchMyRooms(): Promise { const rooms = await call(`${where().rooms}/rooms/ownedby/me`, { authed: true }) // A bare array is the contract; anything else is treated as "no rooms" rather than // thrown, since `.sort` on a non-array would surface as an unreadable TypeError. if (!Array.isArray(rooms)) return [] // ISO-8601 timestamps, so lexical order IS chronological order. return [...rooms].sort((a, b) => (a.CreatedAt < b.CreatedAt ? 1 : -1)) } /** * The `UploadFileType` a room's scene data is posted under. `storage` maps this to the * `room/` subfolder of the CDN bucket — the one prefix `cdn`'s `GET /room/:dataBlob` * reads back, and so the only one a `DataBlob` key can point into. */ const FILE_TYPE_ROOM_SAVE = '1' /** * The `UploadFileType` a picture is posted under. `storage` files it under `image/`, * which is where the `img` worker resolves an extensionless key — so the name it hands * back is exactly what a room's `ImageName` holds, and what the hero draws. */ const FILE_TYPE_IMAGE = '3' /** * The game build this server targets, as `YYYY-MM-DD` — read from the same `GAME_VERSION` * the auth token and presence carry rather than written out again here, so upgrading the * client moves this line with it instead of leaving a stale date on the upload form. * * It's shown because a scene blob is only loadable by the build that wrote it (or older * ones that understand it): a save taken out of a room built on a later version can fail * outright, and nothing between here and the game says why. */ const CLIENT_BUILD_DATE = `${GAME_VERSION.slice(0, 4)}-${GAME_VERSION.slice(4, 6)}-${GAME_VERSION.slice(6, 8)}` /** * Upload a scene blob to `storage` and return the key it was stored under — the * `/` name every `DataBlob` field holds. * * This is the same two-step the game does: the bytes go to `storage` first, and only its * generated name is handed to `rooms`. Nothing about the file is validated here — the * server doesn't parse a room blob either, so the only honest validation available is * whether the game can load it afterwards. Hand this the file `prepareRoomBlob` returned. */ async function uploadRoomBlob(file: File): Promise { return uploadToStorage(file, FILE_TYPE_ROOM_SAVE) } /** * The scene files the upload form takes, by extension: a `.room` is a save as this server * stores it, a `.binpb` a scene taken from a newer build. Only checked in the browser — * the blob is opaque to the server, which stores whatever it is given. */ const ROOM_BLOB_EXTENSIONS = ['.room', '.binpb'] function isRoomBlobFile(filename: string): boolean { const name = filename.toLowerCase() return ROOM_BLOB_EXTENSIONS.some((ext) => name.endsWith(ext)) } /** * The file that actually gets stored for a picked scene file. A scene taken from a newer * build can't be parsed by the build this server runs as it stands, so when the owner asks * for it the file is downgraded first (see `room-converter.ts`); otherwise it passes * through untouched. * * Done here in the browser, before either request, because the upload goes straight to * `storage` and the save's `Hash` has to describe the bytes that were stored — so both * the upload and `blobHash` must be given this file, not the one that was picked. */ async function prepareRoomBlob(file: File, downgrade: boolean): Promise { if (!downgrade) return file const { data } = downgradeRoom(new Uint8Array(await file.arrayBuffer())) return new File([data], file.name, { type: 'application/octet-stream' }) } /** * Post a file to `storage` under one `UploadFileType` and return the generated * `/` key. The type decides the bucket folder, and so which worker can serve * the file back: a scene blob is only reachable through `cdn`, a picture only through * `img`, so a caller has to name the right one for the key to mean anything later. */ async function uploadToStorage(file: File, fileType: string): Promise { const form = new FormData() form.set('FileType', fileType) form.set('File', file) const { filename } = await call<{ filename?: string }>(`${where().storage}/upload`, { method: 'POST', multipart: form, authed: true, }) if (!filename) throw new Error('The storage worker accepted the file but returned no name.') return filename } /** * Point a room at an already-uploaded picture — `PUT /rooms/{id}/image`, the call the * game makes after a player picks a photo for the room. * * Owner-only on the server, which is one rule stricter than the scene-data save (a * co-owner may save, but not change the image). The reply is the PascalCase * `{ Success, Error }` envelope at HTTP 200 with a null `Value` — it does NOT carry the * room, so the page patches `ImageName` onto the room it already has rather than * re-fetching. That's the same thing the game does on the `RoomUpdate` this pushes. */ async function setRoomImage(roomId: number, imageName: string): Promise { const res = await call<{ Success?: boolean; Error?: string | null }>( `${where().rooms}/rooms/${roomId}/image`, { method: 'PUT', authed: true, form: { imageName } } ) if (res.Success !== true) { throw new Error(res.Error || 'The rooms worker refused the image.') } } /** * The `SavedImageType` a profile picture is uploaded under. `api` files the object under * `profile/` and, seeing this type, writes it onto the account row as well — so the * upload alone would change the avatar. The game still follows it with the `accounts` * PUT (see `setProfileImage`), and so does this page: that call is the one that pushes * the `AccountUpdate` frame, without which a running client keeps drawing the old face. */ const SAVED_IMAGE_TYPE_PROFILE = 4 /** * Upload a profile picture the way the game does — `POST /api/images/v4/uploadsaved` on * `api`, NOT `storage`'s `/upload` that the room image uses. The two land in different * buckets: a room image is a plain object `img` serves by key, while a saved image also * gets a row in the `image` table (typed as a profile thumbnail, so it stays out of the * photo feed, which lists share-camera shots only). Returns the key `img` serves it by. */ async function uploadProfileImage(file: File): Promise { const form = new FormData() form.set('imgMeta', JSON.stringify({ savedImageType: SAVED_IMAGE_TYPE_PROFILE })) form.set('image', file) const { ImageName } = await call<{ ImageName?: string }>( `${where().api}/api/images/v4/uploadsaved`, { method: 'POST', multipart: form, authed: true } ) if (!ImageName) throw new Error('The api worker accepted the picture but returned no name.') return ImageName } /** * Point the account at an already-uploaded picture — `PUT /account/me/profileimage` on * `accounts`, the call the game makes after the upload. Answers the lowercase * `{ success }` envelope, not the room image's PascalCase one, and carries no account: * the page patches `profileImage` onto the one it holds, as it does for a room. */ async function setProfileImage(imageName: string): Promise { const res = await call<{ success?: boolean }>(`${where().accounts}/account/me/profileimage`, { method: 'PUT', authed: true, form: { imageName }, refusal: 'The accounts worker refused the image.', }) if (res.success !== true) throw new Error('The accounts worker refused the image.') } /** * The blob's SHA-256, base64 — the encoding this API's hash fields use (an invention's * `BlobHash` comes back the same way). `rooms` only echoes it back on the save, but a * save whose hash doesn't describe its blob is worse than one carrying none. */ async function blobHash(file: File): Promise { const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', await file.arrayBuffer())) let binary = '' for (const byte of digest) binary += String.fromCharCode(byte) return btoa(binary) } /** * Record a room save against one subroom, pointing it at an already-uploaded blob. * * `AutoPublish` decides whether players see it now or whether it waits on the room's * publish step, exactly as it does for the game — the site doesn't get its own rule. * The envelope answers HTTP 200 either way and puts the refusal in `error`, so success * has to be read from the body rather than the status. `value.room` is the updated room, * which the page re-renders from rather than re-fetching the whole list. */ async function saveSubRoomBlob( roomId: number, subRoomId: number, input: { filename: string; hash: string; description: string; autoPublish: boolean } ): Promise { const res = await call<{ success?: boolean error?: string | null value?: { room?: OwnedRoom } | null }>(`${where().rooms}/rooms/${roomId}/subrooms/${subRoomId}/data`, { method: 'POST', authed: true, json: { SubRoomData: { Filename: input.filename, Hash: input.hash }, Description: input.description, AutoPublish: input.autoPublish, }, }) if (res.success !== true) { throw new Error(res.error || 'The rooms worker refused the save.') } const room = res.value?.room if (!room) throw new Error('The save was recorded but the room came back empty.') return room } /** * Sign in with auth's password grant, posted directly the way the game posts it. The * account is resolved by `username` (case-insensitive) — web players sign in with their * username, not the numeric account id. * * A refusal is translated through the table shared with the worker (see * `auth-messages.ts`): auth's `error` is always a machine code, and the reason in * `error_description` is written for an operator, not a player. */ async function signIn(username: string, password: string): Promise { const res = await fetch(`${where().auth}/connect/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'password', username, platform: WEB_PLATFORM, password, }).toString(), }).catch(() => null) if (res === null) throw new Error(authUnreachable('login')) const data = (await res.json().catch(() => ({}))) as Record if (!res.ok) { const code = typeof data.error === 'string' ? data.error : '' const description = typeof data.error_description === 'string' ? data.error_description : '' throw new Error(authFailure('login', res.status, code, description).message) } if (typeof data.access_token !== 'string') throw new Error(authUnreachable('login')) setToken(data.access_token) } /** * Create an account — the one flow that goes through `www`, because it's gated by * Turnstile and that check needs a secret key a page can't hold. www hands back auth's * token response unchanged, so the session is established just as sign-in establishes it. */ async function signUp(password: string, turnstileToken: string): Promise { const data = await call<{ access_token?: string }>('/api/signup', { json: { password, turnstileToken }, }) if (typeof data.access_token !== 'string') throw new Error(authUnreachable('signup')) setToken(data.access_token) } /** * Change the username. * * `accounts` answers this one in its own envelope — `{ success, error, value }` at HTTP * 200 even when it refused (taken name, no changes left) — so a 200 is not enough to * call it done. The sentences it writes are already player-facing, so they're shown as-is. * * On success the SELF account is re-read rather than using the envelope's `value`: that * is the PUBLIC DTO, and it carries no `availableUsernameChanges` — the very field this * form needs to know whether another change is left. */ async function changeUsername(username: string): Promise { const result = await call<{ error?: unknown }>(`${where().accounts}/account/me/username`, { method: 'PUT', form: { username }, authed: true, }) const refusal = typeof result.error === 'string' ? result.error : '' if (refusal !== '') throw new Error(refusal) return fetchMe() } /** * Set the account's email. * * The address is NOT checked here first. `accounts` validates it with `isemail`, which * can't come along into the browser (it reaches for node's `util`, which vite stubs with * a throwing Proxy in dev) — and a second, looser copy of the rule would only disagree * with the real one. The server decides; this just names the refusal it answers with. */ const saveEmail = (email: string): Promise => call(`${where().accounts}/account/me/email`, { form: { email }, authed: true, refusal: 'That email address looks wrong.', }) /** Change the account's password. Lives on `auth`, not `accounts`. */ const changePassword = (oldPassword: string, newPassword: string): Promise => call(`${where().auth}/account/me/changepassword`, { form: { oldPassword, newPassword }, authed: true, }) /** Where this account's benefits stand: `www` reads them off the account row. */ interface BenefitsStatus { /** Whether the account already has Rec Room Plus. */ hasPlus: boolean /** Whether a Discord identity is already tied to it. Which one is deliberately not served. */ linked: boolean } /** * The two ends of the benefits claim. Both live on `www` rather than on one of the game * workers, because the claim needs the Discord client secret — see www.app.ts. */ const fetchBenefitsStatus = (): Promise => call('/api/benefits/status', { authed: true }) /** Redeem the code Discord sent us back with. The access token never reaches this page. */ const claimBenefits = (code: string): Promise<{ discordUsername?: string }> => call<{ discordUsername?: string }>('/api/benefits/claim', { json: { code }, authed: true }) /** * The per-attempt CSRF nonce for the Discord round-trip, in sessionStorage. * * OAuth's `state` only means anything if the same page that minted it is the one that * checks it, so it can't come from the server. sessionStorage rather than localStorage: * it belongs to this tab and this attempt, and it should not outlive the tab that started * the flow. */ const OAUTH_STATE_KEY = 'rf_discord_state' /** * Send the browser to Discord's consent screen. * * A real navigation, not a client-side route — Discord is another origin. The `state` is * minted here and stashed for the return leg; `www` built everything else about the URL * (see `/api/config`), so this only ever appends the one parameter it owns. */ function startDiscordAuth(authorizeUrl: string) { const state = crypto.randomUUID() sessionStorage.setItem(OAUTH_STATE_KEY, state) const url = new URL(authorizeUrl) url.searchParams.set('state', state) window.location.assign(url.toString()) } /** * Admin-only broadcasts. The token goes to `notify`, which enforces the admin-role gate * — so a session without the role is rejected there (403) even though the UI shows no * button. The maintenance frame carries `Msg: { StartsInMinutes }`, matching the game * client's ServerMaintenance handler. */ const broadcastMaintenance = (startsInMinutes: number): Promise<{ delivered?: number }> => call<{ delivered?: number }>(`${where().notify}/internal/broadcast`, { json: { notificationType: NotificationType.ServerMaintenance, data: { StartsInMinutes: startsInMinutes }, }, authed: true, }) const coachMessageAll = (messageContent: string): Promise<{ sent?: number }> => call<{ sent?: number }>(`${where().notify}/internal/coach-message-all`, { json: { messageContent }, authed: true, }) /** * The same coach message to ONE player. `notify` queues it when they're offline, so * `queued` (rather than a 0 delivery) is what "they weren't online" looks like here — * it still arrives on their next connect, unlike the broadcast. */ const coachMessage = ( playerId: number, messageContent: string ): Promise<{ delivered?: number; queued?: boolean }> => call<{ delivered?: number; queued?: boolean }>(`${where().notify}/internal/coach-message`, { json: { playerId, messageContent }, authed: true, }) /** Minimal history-based router: current pathname + a navigate() that pushes state. */ function useRouter() { // Pathname and query string are tracked SEPARATELY rather than as one string, because // the pages match on the pathname alone and one of them is reached with a query on it: // `/claim?code=…` is Discord's redirect, so a `path` that carried the search would stop // matching `/claim` the moment it mattered. `search` is here for the pages whose own // state belongs in the URL — the moderation panel's filters and paging — so that the // back button walks back through them instead of leaving the page. const [location, setLocation] = useState(() => ({ path: window.location.pathname, search: window.location.search, })) useEffect(() => { const onPop = () => setLocation({ path: window.location.pathname, search: window.location.search }) window.addEventListener('popstate', onPop) return () => window.removeEventListener('popstate', onPop) }, []) const navigate = useCallback((to: string) => { // `to` may carry a query string, so the "did anything change" test is against the // whole of what's on screen — otherwise a filter change (same path, new query) would // never push, and the back button would have nothing to walk back through. const [path, search = ''] = to.split('?') // Read before pushing: afterwards `window.location` IS the destination, and every // "did the page change" test would answer no. const samePage = path === window.location.pathname if (to !== window.location.pathname + window.location.search) { window.history.pushState(null, '', to) // Staying on the same page with a different query is a re-filtered list, where // jumping to the top would throw away the reader's place. A real page change // still scrolls up. if (!samePage) window.scrollTo(0, 0) } setLocation({ path: path ?? '/', search: search === '' ? '' : `?${search}` }) }, []) return { ...location, navigate } } type Navigate = (to: string) => void /** An in-app link that routes client-side instead of doing a full page load. */ function Link({ to, navigate, className, children, }: { to: string navigate: Navigate className?: string children: ReactNode }) { return ( { e.preventDefault() navigate(to) }} > {children} ) } /** * The benefits claim itself: where the player stands, and the button that starts (or * re-runs) the Discord round-trip. * * This is BOTH ends of the OAuth round-trip: it sends the player to Discord, and it is * what renders when Discord sends them back. Which half is running is decided by whether * the URL carries a `code`. * * What it never holds is a Discord access token. It forwards the one-time `code` to * `www`, which does the exchange with the client secret and answers with a verdict; that * is the whole reason this one feature has a server side at all. * * Rendered in TWO places, which is why it is a component rather than a page. Its home is * the "Claim benefits" tab in the account dashboard, where someone would go looking for * it. But it also has to render on `/claim`, because that path is Discord's registered * redirect URI — the browser comes back to it with a `?code=`, and it is the only URL a * cold load can land on mid-flow. One component means the two can't drift. * * The effect keys off whether the URL carries a code, so the same code covers both: on * the dashboard there is none, and it just reports status. */ function BenefitsPanel({ account, config }: { account: SelfAccount; config: SiteConfig }) { const [status, setStatus] = useState(undefined) const [error, setError] = useState('') const [done, setDone] = useState('') const [pending, setPending] = useState(false) // Shown after a successful claim only. Plus rides on the game's token as `rn.plus`, // stamped at login, so the copy of it the player is holding still says they have none — // and tokens last a day and are never refreshed. Without this line the claim looks like // it silently did nothing, which is the single most likely support question here. const [relogin, setRelogin] = useState(false) // StrictMode runs effects twice in dev, and a Discord code is single-use: the second // run would redeem a spent code and report a failure over a claim that just worked. const redeemed = useRef(false) useEffect(() => { const params = new URLSearchParams(window.location.search) const code = params.get('code') const state = params.get('state') const expected = sessionStorage.getItem(OAUTH_STATE_KEY) if (code === null) { // Nothing came back from Discord — either the dashboard tab, or `/claim` opened // directly. Just show where they stand. Discord also returns with // `?error=access_denied` when someone cancels: no code, nothing to say, and the // button is right there to try again. void fetchBenefitsStatus() .then(setStatus) .catch(() => setStatus(undefined)) return } // The return leg. Strip the query first, whatever happens next: the code is spent by // the request below, so a reload must not carry it (and a code has no business // sitting in the address bar, or in whatever the player pastes it into). replaceState // rather than a route change, so Back doesn't walk into a used code either. window.history.replaceState(null, '', '/claim') if (redeemed.current) return redeemed.current = true sessionStorage.removeItem(OAUTH_STATE_KEY) // The nonce this tab minted must be the one that came back. A mismatch means the // round-trip wasn't started here, which is exactly what `state` exists to catch. if (state === null || expected === null || state !== expected) { setError('That Discord sign-in did not match this browser. Please start again.') return } setPending(true) claimBenefits(code) .then((result) => { setStatus({ hasPlus: true, linked: true }) setDone( result.discordUsername ? `Verified as ${result.discordUsername} — Rec Room Plus is now on your account.` : 'Verified — Rec Room Plus is now on your account.' ) setRelogin(true) }) .catch((err: unknown) => setError(err instanceof Error ? err.message : String(err))) .finally(() => setPending(false)) }, []) // Read into a local so the narrowing survives into the click handlers below. const authorizeUrl = config.discordAuthorizeUrl if (!config.benefitsEnabled || authorizeUrl === null) { return (

Claim benefits

Benefit claims aren’t available on this server right now.

) } const claimed = status?.hasPlus === true return (

Rec Room Plus

Members of our Discord with a supporter role get Rec Room Plus on their account. Verify with Discord and we’ll check your roles — we only ever read your username and which roles you hold in our server.

Claiming as @{account.username} (#{account.accountId}). A Discord account can claim on one RecFlare account only.

{error &&

{error}

} {done &&

{done}

} {relogin && (

Restart Rec Room and sign in again to pick it up — your game reads Rec Room Plus from the session it signed in with, so it won’t show until then.

)} {pending ? (

Checking your Discord roles…

) : claimed ? ( // Already claimed. The button stays, because a player whose roles changed (or who // re-linked) can safely run it again — the claim is idempotent on their own // account — but it no longer reads as the thing to do. <> {!done && ( <>

Rec Room Plus is active on this account.

If the game doesn’t show it, sign out and back in — Rec Room Plus is read from the session your game signed in with.

)} ) : ( )}
) } /** * `/claim` — the page Discord redirects back to. * * Not linked from anywhere any more: the claim lives in the account dashboard's "Claim * benefits" tab. This route still has to exist and still has to work on a cold load, * because it is the app's registered `redirect_uri` — the browser arrives here from * Discord carrying the `?code=`, with whatever session it has. * * Signing in comes FIRST, and not only because the grant needs an account to land on: the * bearer token is what tells `www` whose row to write, so a claim without one has no * subject. Hence the sign-in card rather than a redirect — someone who arrives here from a * link should be told what this is before being bounced to a login form. */ function ClaimPage({ account, config, navigate, }: { account: SelfAccount | null | undefined config: SiteConfig | undefined navigate: Navigate }) { if (account === undefined || config === undefined) { return (

Loading…

) } if (account === null) { return (

Claim your benefits

Sign in first

Benefits are granted to a RecFlare account, so we need to know which one is yours before you verify with Discord. If you were part-way through a claim, start it again from your account page once you’re signed in.

Sign in
) } return (

Claim your benefits

) } /** * The room id in `/rooms/`, or null for any other path. Numeric rather than the * room's name: a name is renameable (`PUT /rooms/{id}/name`), so a link someone * bookmarked would rot the moment they renamed the room. */ function roomIdFromPath(path: string): number | null { const match = /^\/rooms\/(\d+)$/.exec(path) return match ? Number.parseInt(match[1], 10) : null } /** Public account shape, from `GET /account/:id` or `/account/search`. */ interface PublicAccount { accountId: number username: string displayName: string profileImage: string bannerImage: string createdAt: string } /** * A player's photo, from the `ImagesPlayer` projection. Public unless the list was the * viewer's own (see fetchPlayerPhotos), which is the one way a private one gets here — * `Accessibility` is what tells the grid to mark it. */ interface PublicPhoto { SavedImageId: number ImageName: string CreatedAt: string CheerCount: number Accessibility: number } /** A player's public room — the narrow shape `/rooms/createdby/:id` serves. */ interface PublicRoom { RoomId: number Name: string ImageName: string Stats: { VisitCount: number; CheerCount: number; FavoriteCount: number } } /** Prefix-search accounts by username — backs the header search bar. */ async function searchPlayers(query: string): Promise { if (query.trim() === '') return [] return call( `${where().accounts}/account/search?name=${encodeURIComponent(query.trim())}` ) } const fetchPublicAccount = (username: string): Promise => searchPlayers(username).then( (matches) => matches.find((m) => m.username.toLowerCase() === username.toLowerCase()) ?? null ) /** * The photos a player has taken. Sent WITH the session token when there is one: `api` * reads it and, only when the list is the caller's own, includes the photos they keep * private — so a signed-in player sees all of theirs, on `/account` and on their own * `/u/`, while everyone else's profile is the public list either way. Nothing * here decides who the viewer is; the worker does, from the token. */ const fetchPlayerPhotos = (accountId: number): Promise => call(`${where().api}/api/images/v4/player/${accountId}`, { authed: true }) const fetchPublicRooms = (accountId: number): Promise => call(`${where().rooms}/rooms/ownedby/${accountId}`) /** * One room by id, through the public lookup. `{}` (not a 404) when there's no such room — * the client reads an empty object as "no room", and so does this: null. */ async function fetchPublicRoom(roomId: number): Promise { const room = await call>(`${where().rooms}/rooms?id=${roomId}`) return 'RoomId' in room ? (room as OwnedRoom) : null } /** * The public photos taken in a room. Asked for with `filter=1` (camera photos), as the * game asks: without it the feed also carries the room's own thumbnail. The feed serves * the raw `SavedImage` record (`Id`, not `SavedImageId` — see the api worker's note on * why the two lists differ), so it's narrowed here to the shape the photo grid draws. */ const fetchRoomPhotos = (roomId: number): Promise => call< Array<{ Id: number ImageName: string CreatedAt: string CheerCount: number Accessibility: number }> >(`${where().api}/api/images/v4/room/${roomId}?filter=1`).then((images) => images.map((i) => ({ SavedImageId: i.Id, ImageName: i.ImageName, CreatedAt: i.CreatedAt, CheerCount: i.CheerCount, Accessibility: i.Accessibility, })) ) /** * Delete a room outright — the staff takedown. `rooms` lets the owner or a developer / * moderator through and refuses everyone else; it answers a refusal as a 200 carrying * `Success: false` (the game's envelope), so the check is on the body, not the status. */ async function deleteRoom(roomId: number): Promise { const result = await call<{ Success: boolean; Error: string | null }>( `${where().rooms}/rooms/${roomId}`, { method: 'DELETE', authed: true } ) if (!result.Success) throw new Error(result.Error || 'The room couldn’t be deleted.') } /** The `/u/` path, or null for any other path. */ function usernameFromPath(path: string): string | null { const match = /^\/u\/([^/]+)$/.exec(path) return match ? decodeURIComponent(match[1]) : null } // The nav's player search. `/u/:name` profiles stay reachable by URL either way; this only // decides whether the search bar that leads to them is shown. const SHOW_PLAYER_SEARCH: boolean = true /** * The header search bar — a rec.net-style bubble dropdown of matching players as you * type. Debounced so it doesn't fire a search per keystroke; closes on selecting a * result, on Escape, or on clicking outside it. */ function PlayerSearch({ navigate }: { navigate: Navigate }) { const [query, setQuery] = useState('') const [results, setResults] = useState([]) const [open, setOpen] = useState(false) const containerRef = useRef(null) useEffect(() => { if (query.trim() === '') { setResults([]) return } const timeout = setTimeout(() => { void searchPlayers(query) .then((r) => setResults(r.slice(0, 6))) .catch(() => setResults([])) }, 250) return () => clearTimeout(timeout) }, [query]) useEffect(() => { const onClickOutside = (e: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setOpen(false) } } document.addEventListener('mousedown', onClickOutside) return () => document.removeEventListener('mousedown', onClickOutside) }, []) const go = (username: string) => { setOpen(false) setQuery('') navigate(`/u/${encodeURIComponent(username)}`) } return (
{ setQuery(e.target.value) setOpen(true) }} onFocus={() => setOpen(true)} onKeyDown={(e) => { if (e.key === 'Escape') setOpen(false) if (e.key === 'Enter' && results[0]) go(results[0].username) }} /> {open && results.length > 0 && (
{results.map((r) => ( ))}
)}
) } /** * A grid of photo thumbnails; clicking one opens it full size. * * The thumbnail is the img worker's cached 256px variant; the popup asks for the same key * with NO transform, which is the original upload. The popup is a native `` opened * with `showModal()`, so Escape, the backdrop, and keeping focus inside it are the * browser's work rather than ours — all that's added is closing on a click anywhere, since * there is nothing in it to interact with but the photo. * * Safe to call `where()` in render: a caller only has photos to pass once a fetch that * went through it has resolved (see MyRooms). */ function PhotoGrid({ photos }: { photos: PublicPhoto[] }) { const [open, setOpen] = useState(null) const dialogRef = useRef(null) useEffect(() => { if (open) dialogRef.current?.showModal() }, [open]) return ( <>
{photos.map((p) => ( ))}
{/* Mounted only while open, so the full-size image isn't fetched until asked for. `onClose` covers Escape, which closes the dialog without going through us. */} {open && ( setOpen(null)} onClick={() => setOpen(null)} > )} ) } /** * A player's public profile page (`/u/`) — banner, avatar, bio-adjacent * info, their public rooms, and their public photos. Read-only: this is the * "rec.net-style profile" other players browse to, not the owner's own dashboard * (that stays on `/account`). */ function PlayerPage({ username, config, navigate, }: { username: string config: SiteConfig | undefined navigate: Navigate }) { const [account, setAccount] = useState(undefined) const [rooms, setRooms] = useState(null) const [photos, setPhotos] = useState(null) const [error, setError] = useState('') useEffect(() => { // Waits for the config, not the session: this page is public, but every fetch on it // goes through `where()`, which has no hostnames until `/api/config` lands. On a cold // load of `/u/` the effect would otherwise fire first and the page would be // stuck on "Still starting up". if (config === undefined) return setAccount(undefined) setRooms(null) setPhotos(null) void fetchPublicAccount(username) .then((a) => { setAccount(a) if (a) { void fetchPublicRooms(a.accountId) .then(setRooms) .catch(() => setRooms([])) void fetchPlayerPhotos(a.accountId) .then(setPhotos) .catch(() => setPhotos([])) } }) .catch((e) => setError(e instanceof Error ? e.message : String(e))) }, [username, config]) if (error) { return (

{error}

) } if (account === undefined) { return (

Loading…

) } if (account === null) { return (

There's no player called @{username}.

) } const created = new Date(account.createdAt) return (
{account.bannerImage && ( )}

{account.displayName || account.username}

@{account.username} {!Number.isNaN(created.getTime()) && ` · joined ${created.toLocaleDateString()}`}

Rooms

{rooms === null ? (

Loading…

) : rooms.length === 0 ? (

No public rooms.

) : (
    {rooms.map((room) => (
  • ^{room.Name}

    {room.Stats.VisitCount.toLocaleString()} visits

  • ))}
)}

Photos

{photos === null ? (

Loading…

) : photos.length === 0 ? (

No public photos.

) : ( )}
{/* Staff only, and cosmetic: hidden for everyone else, but every endpoint behind it is gated by `requireStaff`. */} {isAdmin() && }
) } /** * The staff card at the foot of a player's profile: account fixes the game gives staff no * way to make. Each is a `www` endpoint (see src/staff.ts) that records itself on * `audit_log`. Clearing a password asks twice, since it logs the player out of password * sign-in until they set a new one; the rest are one click. */ /** The optional message on the box a staff gift comes in — one field, shared by every gift below it. */ function GiftMessageField({ value, onChange }: { value: string; onChange: (v: string) => void }) { return ( ) } function StaffPlayerActions({ account, navigate }: { account: PublicAccount; navigate: Navigate }) { const [tokens, setTokens] = useState('') // One message for every box sent from this card; it stays put between sends. const [message, setMessage] = useState('') const [confirmingClear, setConfirmingClear] = useState(false) const [itemId, setItemId] = useState('') const [xp, setXp] = useState('') const gift = useAction() const xpGift = useAction() const itemGift = useAction() const usernameChange = useAction() const clearPassword = useAction() const base = `/api/staff/players/${account.accountId}` return (

Staff

{/* The gifts are developer-only; www refuses them to moderators. */} {isDeveloper() && ( <>
{ e.preventDefault() void gift.run(async () => { const amount = Number(tokens) const res = await call<{ balance: number }>(`${base}/gift-tokens`, { authed: true, json: { amount, message: message.trim() }, }) setTokens('') const what = amount < 0 ? `Took ${Math.abs(amount).toLocaleString()} tokens back` : amount === 0 ? 'Sent an empty box' : `Sent ${amount.toLocaleString()} tokens` return `${what}. @${account.username} now has ${res.balance.toLocaleString()}.` }) }} > {gift.error &&

{gift.error}

} {gift.done &&

{gift.done}

}
{ e.preventDefault() void xpGift.run(async () => { const amount = Number(xp) const res = await call<{ level: number; levelsGained: number }>(`${base}/gift-xp`, { authed: true, json: { amount, message: message.trim() }, }) setXp('') const levels = res.levelsGained > 0 ? ` They went up ${res.levelsGained} level${res.levelsGained === 1 ? '' : 's'}, to ${res.level}.` : ` They're level ${res.level}.` return `Sent ${amount.toLocaleString()} XP.${levels}` }) }} > {xpGift.error &&

{xpGift.error}

} {xpGift.done &&

{xpGift.done}

}
{ e.preventDefault() void itemGift.run(async () => { const res = await call<{ kind: 'custom_item' | 'skin' | 'consumable' name: string prefabName?: string owned?: number }>(`${base}/gift-item`, { authed: true, json: { itemId: itemId.trim(), message: message.trim() }, }) setItemId('') switch (res.kind) { case 'skin': return `Sent the skin “${res.name}” (${res.prefabName}) to @${account.username}.` case 'consumable': return `Sent one “${res.name}” to @${account.username}. They now have ${(res.owned ?? 1).toLocaleString()}.` default: return `Sent the custom item “${res.name}” to @${account.username}.` } }) }} > {itemGift.error &&

{itemGift.error}

} {itemGift.done &&

{itemGift.done}

}
)}
{confirmingClear ? ( <> ) : ( )}

Clear password: Only use if the player has requested a password clear and they can still login via Steam or Meta.

{usernameChange.error &&

{usernameChange.error}

} {usernameChange.done &&

{usernameChange.done}

} {clearPassword.error &&

{clearPassword.error}

} {clearPassword.done &&

{clearPassword.done}

}
) } export function App() { // undefined = still checking the session; null = signed out. const [account, setAccount] = useState(undefined) // undefined until the config lands. Signup is treated as closed until told otherwise, // so a slow (or failed) config fetch can't flash a form the server would refuse. const [config, setConfig] = useState(undefined) const { path, search, navigate } = useRouter() const roomId = roomIdFromPath(path) const lookupUsername = usernameFromPath(path) const customAvatarItemId = customAvatarItemIdFromPath(path) useEffect(() => { // Config first, and everything else after it: it carries the hostnames every other // call needs. A config that doesn't land leaves the page signed out with signup // closed rather than guessing where the workers are. call('/api/config') .then(async ({ hosts: resolved, ...site }) => { setHosts(resolved) setConfig(site) if (!hasToken()) return setAccount(null) // A stored token that `accounts` rejects is stale — `call` has already dropped // it, so this just falls back to signed-out rather than surfacing an error. await fetchMe() .then(setAccount) .catch(() => setAccount(null)) }) .catch(() => { setConfig({ signupEnabled: false, turnstileSiteKey: null, benefitsEnabled: false, discordAuthorizeUrl: null, }) setAccount(null) }) }, []) // Nothing to tell a server: the access token is a stateless JWT, so dropping it here // IS the sign-out. (The refresh token auth issues alongside it is never stored, so a // closed session leaves nothing behind to redeem.) const logout = useCallback(() => { setToken(null) setAccount(null) navigate('/') }, [navigate]) return ( <> {path === '/login' || path === '/signup' ? ( // One page, two doors. `/signup` exists so the homepage's create-account link // lands on that tab instead of dropping people on sign-in to find it — and so // the URL is linkable. Unknown paths fall back to index.html (see the assets // config in wrangler.jsonc), so a cold load of /signup reaches the SPA. ) : path === '/account' ? ( ) : path === '/claim' ? ( // Its own page rather than a dashboard tab: this path is Discord's registered // redirect URI, so it has to be one stable URL a cold load can land on. ) : path === '/moderation' || path.startsWith('/moderation/') ? ( // A whole surface rather than another dashboard tab, and its own URLS: the // panel routes UNDER this prefix (`/moderation/players/123`, and its filters in // the query string), so the back button walks back through a moderator's // session instead of dropping them on the homepage. Matched by prefix here for // that reason; the panel picks the view apart itself. // // None of these paths is in wrangler.jsonc's `run_worker_first`, so a cold load // on any of them falls through to the SPA shell; the page then gates itself on // the token's role, and every endpoint behind it re-checks. ) : path === '/stats' ? ( // Unlinked on purpose — nothing in the nav or footer points here; it's for whoever // is handed the URL. Public all the same, and a client-side route like the rest: // its data comes from `/api/stats/online`, which the existing `/api/*` allowlist // entry already sends to the Worker. ) : customAvatarItemId !== null ? ( // The game's share link for a store item (`/d/store/customavataritem/`), so // the path is the client's, not ours. Not in `run_worker_first` either: a cold // load falls through to the SPA shell like every other page here. Gated on the // session rather than the config because the lookup behind it needs a token. ) : lookupUsername !== null ? ( ) : roomId !== null ? ( ) : ( )} ) } /** Footer: where to go next, plus the affiliation disclaimer. */ function SiteFooter() { return (
MIT licensed {' '} — made by fans, not affiliated with Rec Room Inc.
) } /** Top nav: brand → home, plus a sign-in / my-account link for the session. */ function NavBar({ account, path, navigate, onLogout, }: { account: SelfAccount | null | undefined path: string navigate: Navigate onLogout: () => void }) { return (
RecFlare
) } /** * How many photos the hero asks the feed for. Explicit rather than left to the api's * default, since the count is a design decision here: the stage rotates one photo every * six seconds, so ten is a minute of it — long enough that a repeat visitor sees fresh * photos, short enough that the arrows stay walkable and the payload stays small. */ const SLIDESHOW_TAKE = 10 /** A recent public image plus who took it and where. */ interface Slide { url: string username: string roomName: string | null } /** * Loads the public photo feed once. `slides === null` means still in flight. * * Waits for the config, since the feed is served by the `api` worker — the same public * endpoint the game reads it from — and its hostname arrives with the config. Each entry * names an image; the browsable URL for it is on the `img` worker. */ function useSlideshow(config: SiteConfig | undefined) { const [slides, setSlides] = useState(null) const [error, setError] = useState('') useEffect(() => { if (config === undefined) return type Feed = { Images?: Array<{ ImageName: string; Username: string; RoomName: string | null }> } // Wrapped in an async call rather than started directly, because `where()` THROWS // when the config didn't land — synchronously, which straight out of an effect // would take the page down instead of leaving an empty stage behind the fold. void (async () => { const h = where() const d = await call(`${h.api}/api/images/v1/slideshow?take=${SLIDESHOW_TAKE}`) setSlides( (d.Images ?? []).map((i) => ({ url: `${h.img}/${i.ImageName}`, username: i.Username, roomName: i.RoomName, })) ) })().catch((e) => setError(e instanceof Error ? e.message : String(e))) }, [config]) return { slides, error } } /** * Public homepage. The stage leads: photos players actually took, with the way in * on top of them. Everything about how the thing is built sits below, for whoever * scrolls looking for it. */ function HomePage({ account, config, navigate, }: { account: SelfAccount | null | undefined config: SiteConfig | undefined navigate: Navigate }) { const feed = useSlideshow(config) // The signup offer only makes sense to a signed-out visitor when the server would // actually take one. `account === undefined` is still-checking, so it shows nothing // rather than offering an account to someone who already has one. const offerSignup = account === null && config?.signupEnabled === true return (
) } /** * The hero: the headline and the way in on the left, a rotating in-game photo on the * right. The photo is proof, never the payload — when the feed is slow or down the * frame holds its space and the left half reads the same, so "Play now!" is reachable * either way. */ function Stage({ slides, offerSignup, navigate, }: { slides: Slide[] | null offerSignup: boolean navigate: Navigate }) { const [idx, setIdx] = useState(0) const count = slides?.length ?? 0 // A timeout keyed on the current slide rather than one long-lived interval: steering // by hand re-arms it, so a photo you just picked gets its full six seconds. useEffect(() => { if (count < 2) return const t = setTimeout(() => setIdx((i) => (i + 1) % count), 6000) return () => clearTimeout(t) }, [count, idx]) const slide = slides && slides.length > 0 ? slides[idx] : null const step = (by: number) => setIdx((i) => (i + by + count) % count) return (
{/* Deliberately doesn't name the game: this is a fan project, so the trademark stays out of the headline and appears lower down, in plain nominative use next to the disclaimer. */}

Play today!

The servers you remember, rebuilt and running — free, open source, and up right now.

{/* A line rather than a fourth button: the download is the point of this page, and launching the game makes an account by itself — signing up here is the way in for someone who wants one first. Hidden entirely when signup is closed, matching /login, which hides its create-account tab the same way. */} {offerSignup && (

New here?{' '} Create an account

)}
{slide && ( {`Photo )}
{/* Always mounted, so the frame doesn't shift down when the feed lands. */}
{slide && ( Photo by @{slide.username} {slide.roomName && ` in ${slide.roomName}`} )} {/* Arrows and a count, not a dot per photo: a dot each is wide enough to shove the headline's half of the split off the page, and it would have to be rebuilt the moment SLIDESHOW_TAKE grows. */} {count > 1 && ( {idx + 1} / {count} )}
) } /** The slideshow's back/forward mark. Decorative — the buttons carry the label. */ function Chevron({ next }: { next?: boolean }) { return ( ) } /** What RecFlare is, under the fold, for whoever wants it. */ function About({ slides, error }: { slides: Slide[] | null; error: string }) { // The feed answering is proof the server replied, so the indicator can't claim // the server is up when it isn't. const state = slides !== null ? 'online' : error ? 'down' : 'checking' return (

A cloud architected server for the 2023/2025 game clients

A free fan project, made by players who missed it. Aiming to be{' '} feature-complete and infinitely scalable —{' '} architected for the cloud, no gatekeeping, no basement server.

{state === 'online' ? 'Servers are up' : state === 'down' ? "Can't reach the servers" : 'Checking…'}

{/* Only when it's actually up: when it isn't, people want the status, not the joke. */} {state === 'online' &&

The cloud never goes down, right?

}
) } /** * The sign-in page — sign in, plus create-account when the server says signup is open * (it needs a Turnstile keypair; see SiteConfig). Redirects to the account page once a * session exists, however it was obtained. */ /** * A `?next=` worth redirecting to after sign-in, or null. Only an absolute PATH on this * site qualifies: it has to start with one slash and not two (`//host` is scheme-relative, * i.e. another site), and carry no scheme. Anything else is dropped rather than repaired. */ function safeNextPath(raw: string | null): string | null { if (!raw || !raw.startsWith('/') || raw.startsWith('//') || raw.startsWith('/\\')) return null return raw } function LoginPage({ account, config, initialTab, search, navigate, onAuthed, }: { account: SelfAccount | null | undefined config: SiteConfig | undefined initialTab: 'signup' | 'login' /** The query string, for the `?next=` a page that needs a session sends people here with. */ search: string navigate: Navigate onAuthed: (a: SelfAccount) => void }) { // The tab IS the route (`/login` vs `/signup`) rather than local state, so the two can // never disagree — switching tabs pushes history, and back goes back to the other one. const tab = initialTab // Where to go once signed in: the page that sent the visitor here, when one did (an // item's share link needs a session to render), otherwise the account page. Only a // same-site path is honoured — one leading slash, so `//evil.example` can't ride in // through a link someone was handed — since this is a redirect after a sign-in. const next = safeNextPath(new URLSearchParams(search).get('next')) ?? '/account' useEffect(() => { if (account) navigate(next) }, [account, navigate, next]) const authed = (a: SelfAccount) => { onAuthed(a) navigate(next) } const siteKey = config?.signupEnabled ? config.turnstileSiteKey : null return (
{siteKey && (
{/* The query rides along, so switching doors keeps the `?next=`. */}
)} {siteKey && tab === 'signup' ? ( <>

Create account

A username is assigned for you — you'll see it on your account page. Choose a password, and the two together sign you in here and in the game.

) : ( <>

Sign in

Use your username and password. Launching the game also creates an account, linked to your Steam ID — set a password on it and it signs in here too.

{/* The tabs above already offer this; the line under the button is where someone who just found out they have no account is actually looking. Gated on the same key, so it can't point at a door that isn't there. */} {siteKey && (

Don't have an account?{' '} Create one

)} )}
) } /** The signed-in account page. Redirects to sign-in when there's no session. */ function AccountPage({ account, config, navigate, onChange, }: { account: SelfAccount | null | undefined config: SiteConfig | undefined navigate: Navigate onChange: (a: SelfAccount) => void }) { useEffect(() => { if (account === null) navigate('/login') }, [account, navigate]) if (!account) { return (

{account === undefined ? 'Loading…' : 'Redirecting…'}

) } return (

My account

) } /** * One room's own page — what it is, how it's set up, and the subrooms inside it. * * The room is found in the caller's OWN list rather than read from the public * `GET /rooms?id=`, which is unfiltered by design (the game looks any room up that way). * Going through `ownedby/me` is what makes this the owner's page: a room that isn't * yours simply isn't in the list, so there's no second ownership rule here to drift out * of step with the one the mutating endpoints enforce. */ function RoomPage({ account, config, roomId, navigate, }: { account: SelfAccount | null | undefined config: SiteConfig | undefined roomId: number navigate: Navigate }) { // undefined = not fetched yet; null = no such room. const [room, setRoom] = useState(undefined) // The signed-in player's own rooms, or null while unknown. Decides which view this is: // a room in this list gets the owner's editable page, anything else the public one. const [mine, setMine] = useState(null) // Set by the staff takedown. The room is gone by then, so the page says so rather than // re-fetching and answering with the "private or missing" line, which would be a lie. const [takenDown, setTakenDown] = useState(null) const [error, setError] = useState('') const accountId = account?.accountId useEffect(() => { // Waits for the config (`where()` has no hostnames before it), not the session — // the lookup is public, and a signed-out visitor is this page's main reader. if (config === undefined) return setRoom(undefined) void fetchPublicRoom(roomId) .then(setRoom) .catch((e) => setError(e instanceof Error ? e.message : String(e))) }, [roomId, config]) useEffect(() => { // `account === undefined` only means the stored token hasn't been checked yet. if (accountId === undefined) return void fetchMyRooms() .then(setMine) // The owner's list failing to load must not hide a public room — it just reads // as somebody else's. .catch(() => setMine([])) }, [accountId]) // Whose page this is isn't known until both the room and (for a signed-in player) the // owner's list are in. Choosing early would flash the public view at an owner. const owned = mine?.find((r) => r.RoomId === roomId) const settled = room !== undefined && (account === null || (account !== undefined && mine !== null)) if (takenDown !== null) { return (

^{takenDown} was deleted.

← Home

) } if (error) { return (

{error}

) } if (!settled) { return (

Loading…

) } if (owned) { return (

← My rooms

setMine((current) => (current ?? []).map((r) => (r.RoomId === updated.RoomId ? updated : r)) ) } />
) } // Unlisted rooms are reachable by link, which is what a URL is. Everything else that // isn't Public is somebody's private space, and gets the same sentence a missing room // does: which of the two it is answers a question a stranger has no business asking. const visible = room !== null && (room.Accessibility === Accessibility.Public || room.Accessibility === Accessibility.Unlisted) return (
{visible ? ( setTakenDown(room.Name)} /> ) : (

There's no room here — it's private, or it doesn't exist.

)}
) } /** * A room as anyone sees it: the same hero the owner's page draws, with the creator linked * to their profile in place of the upload control, then the room's public facts and the * photos players took in it — read-only, since nothing here is the viewer's to change. * No subroom list: a visitor has no use for the room's internals, and the owner gets them * (with the scene-data tool) on their own page instead. */ function PublicRoomView({ room, navigate, onTakenDown, }: { room: OwnedRoom navigate: Navigate onTakenDown: () => void }) { const [creator, setCreator] = useState<{ username: string; displayName: string } | null>(null) const [photos, setPhotos] = useState(null) const created = new Date(room.CreatedAt) useEffect(() => { setCreator(null) setPhotos(null) void usernamesFor([room.CreatorAccountId]).then((names) => setCreator(names.get(room.CreatorAccountId) ?? null) ) void fetchRoomPhotos(room.RoomId) .then(setPhotos) .catch(() => setPhotos([])) }, [room.RoomId, room.CreatorAccountId]) return ( <>

^{room.Name}

{creator && (

by{' '} {creator.displayName || creator.username}

)} {room.Description ? (

{room.Description}

) : (

No description.

)}

{room.Stats.VisitCount.toLocaleString()} visit {room.Stats.VisitCount === 1 ? '' : 's'} · {room.Stats.FavoriteCount.toLocaleString()}{' '} favourite {room.Stats.FavoriteCount === 1 ? '' : 's'} · {room.Stats.CheerCount.toLocaleString()}{' '} cheer{room.Stats.CheerCount === 1 ? '' : 's'}

About this room

Max players
{room.MaxPlayers}
Cloning
{room.CloningAllowed ? 'Anyone may clone this room' : 'Not allowed'}
Tags
{room.Tags?.length ? room.Tags.map((t) => `#${t.Tag}`).join(' ') : 'None'}
Created
{Number.isNaN(created.getTime()) ? room.CreatedAt : created.toLocaleDateString()}

Photos taken here

{photos === null ? (

Loading…

) : photos.length === 0 ? (

Nobody has taken a photo here yet.

) : ( )}
{/* Staff only, and cosmetic: hidden for everyone else, but `rooms` checks the token's role itself on the DELETE. */} {isAdmin() && ( <> {isDeveloper() && } )} ) } /** * The staff takedown, at the foot of a public room's page: the room is already on screen, * so there's nothing to look up and nothing to confirm but the deletion itself. Two * steps — a plain card, then the same accent-bordered confirm the ban form uses — since * this is the one thing on the site that can't be undone. */ /** * Pay everyone standing in the room, across every instance of it, from the room's own page. * The audience is read when the button is pressed, so it is whoever is in there at that * moment — there is nothing to pick and nobody to look up. */ function StaffRoomTokens({ room }: { room: OwnedRoom }) { const [tokens, setTokens] = useState('') const [message, setMessage] = useState('') const { pending, error, done, run } = useAction() return (

Staff

{ e.preventDefault() void run(async () => { const amount = Number(tokens) const res = await call<{ paid: number[]; skipped: number[] }>( `/api/staff/rooms/${room.RoomId}/gift-tokens`, { authed: true, json: { amount, message: message.trim() } } ) setTokens('') setMessage('') const n = res.paid.length const missed = res.skipped.length > 0 ? ` ${res.skipped.length} couldn't spare it and were skipped.` : '' return `Sent ${amount.toLocaleString()} tokens to ${n} player${n === 1 ? '' : 's'} in ^${room.Name}.${missed}` }) }} > {error &&

{error}

} {done &&

{done}

}
) } function StaffTakedown({ room, onTakenDown }: { room: OwnedRoom; onTakenDown: () => void }) { const [confirming, setConfirming] = useState(false) const { pending, error, run } = useAction() if (!confirming) { return (

Delete room

Deleting a room removes it for everyone: the room, its subrooms and their saves, and its picture. Photos players took in it stay on their profiles.

) } return (

Delete ^{room.Name}?

{ e.preventDefault() void run(async () => { await deleteRoom(room.RoomId) onTakenDown() return '' }) }} >

This deletes the room for everyone, including its creator, and can't be undone.

{error &&

{error}

}
) } /** The platforms a room says it supports, named the way the game names them. */ function platformList(room: OwnedRoom): string[] { const on: string[] = [] if (room.SupportsScreens) on.push('Screens') if (room.SupportsWalkVR) on.push('VR (walk)') if (room.SupportsTeleportVR) on.push('VR (teleport)') if (room.SupportsQuest2) on.push('Quest 2') if (room.SupportsMobile) on.push('Mobile') if (room.SupportsJuniors) on.push('Juniors') return on } /** * A room's settings and its subrooms. Its own fields are read-only — rooms are edited in * game — with two exceptions, both things the game gives an owner no way to do: a * subroom's scene data can be replaced from here (the game can only save what it just * built, never restore a file they kept), and so can the room's image (the game can only * set it to a photo taken inside the room). */ function RoomDetail({ room, imgHost, cdnHost, onRoomChange, }: { room: OwnedRoom imgHost: string cdnHost: string onRoomChange: (room: OwnedRoom) => void }) { const created = new Date(room.CreatedAt) const platforms = platformList(room) const subRooms = room.SubRooms ?? [] return ( <>
{/* 512 rather than the list's 256: this one is displayed large. Both are sizes the img worker allows, so each is a cached variant. */} onRoomChange({ ...room, ImageName: imageName })} />

^{room.Name}

{room.Description ? (

{room.Description}

) : (

No description set.

)}

{room.Stats.VisitCount.toLocaleString()} visit {room.Stats.VisitCount === 1 ? '' : 's'} · {room.Stats.FavoriteCount.toLocaleString()}{' '} favourite {room.Stats.FavoriteCount === 1 ? '' : 's'} · {room.Stats.CheerCount.toLocaleString()}{' '} cheer{room.Stats.CheerCount === 1 ? '' : 's'}

Settings

Room id
{room.RoomId}
Visibility
{accessibilityLabel(room.Accessibility)}
Max players
{room.MaxPlayers}
Cloning
{room.CloningAllowed ? 'Anyone may clone this room' : 'Nobody may clone this room'}
Plays on
{platforms.length > 0 ? platforms.join(', ') : 'Nothing — no platform is enabled'}
Tags
{room.Tags?.length ? room.Tags.map((t) => t.Tag).join(', ') : 'None'}
Created
{Number.isNaN(created.getTime()) ? room.CreatedAt : created.toLocaleDateString()}

Subrooms

The places inside the room players actually load into. Each keeps its own accessibility and its own saves, so a public room can still hold a subroom nobody else can reach.

{subRooms.length === 0 ? (

This room has no subrooms.

) : (
    {subRooms.map((sub) => ( ))}
)}
) } /** One subroom: what it is, and — the part an owner can't see anywhere else — its save. */ function SubRoomRow({ sub, roomId, roomName, cdnHost, onRoomChange, }: { sub: SubRoom roomId: number roomName: string cdnHost: string onRoomChange: (room: OwnedRoom) => void }) { const save = sub.CurrentSave ?? null const saved = save ? new Date(save.CreatedAt) : null // Cleared when that save is published (see publishSubRoomSave), so a value here always // means work the owner saved but players still can't see. const staged = sub.StagedSubRoomDataSaveId !== null && sub.StagedSubRoomDataSaveId !== undefined const name = sub.Name || `Subroom ${sub.SubRoomId}` return (
  • {name} {sub.IsSandbox && Sandbox}

    #{sub.SubRoomId} · up to {sub.MaxPlayers} players

    {save === null ? ( // A subroom with no published save loads an empty scene without erroring, which // from the inside looks exactly like a broken room. Say so plainly. Never published — players load an empty scene. ) : ( <> Published save #{save.SubRoomDataSaveId} {saved && !Number.isNaN(saved.getTime()) && `, saved ${saved.toLocaleString()}`} {save.Description && ` — “${save.Description}”`} )} {staged && ( · a newer save is staged, waiting to be published. )}

    {/* The published save's blob first: that's the copy of the room worth keeping, and the one the client resolves ahead of the subroom's own key. */} {save?.DataBlob && ( )} {sub.DataBlob && ( )}
  • ) } /** * Replace one subroom's scene data with a file from disk. * * The two steps are the game's own: the bytes go to `storage` under the RoomSave type, * and the key it hands back is posted to the subroom's `…/data` route as * `SubRoomData.Filename`. So this is a room save like any other — it lands in the * subroom's history beside the ones the game wrote, and both endpoints are already gated * on the room's creator (or a co-owner), which is why there is no ownership check here: * the page only lists rooms that came back from `ownedby/me` in the first place. * * Publishing is offered rather than assumed. A save normally only STAGES — players keep * loading the last published version until the owner publishes — and quietly making an * uploaded file live would be a bigger step than the game's own save takes. Left on by * default all the same: someone uploading a blob here is restoring a room, and a restore * nobody can see isn't one. */ function BlobUpload({ roomId, subRoomId, onRoomChange, }: { roomId: number subRoomId: number onRoomChange: (room: OwnedRoom) => void }) { const [file, setFile] = useState(null) const [description, setDescription] = useState('') const [publish, setPublish] = useState(true) // Whether to convert the file for this build before storing it. Picking a file sets it // from the extension — on for a `.binpb`, off for a `.room` — and the owner can overrule // that either way: a `.binpb` that already loads shouldn't lose its circuits for nothing. const [downgrade, setDowngrade] = useState(false) const [fileError, setFileError] = useState('') // The file input is uncontrolled — React can't set its value — so clearing the picked // file after a save takes a handle on the element itself. const input = useRef(null) const { pending, error, done, run } = useAction() return (
    { e.preventDefault() if (!file) return void run(async () => { const blob = await prepareRoomBlob(file, downgrade) const [filename, hash] = await Promise.all([uploadRoomBlob(blob), blobHash(blob)]) onRoomChange( await saveSubRoomBlob(roomId, subRoomId, { filename, hash, description: description.trim(), autoPublish: publish, }) ) setFile(null) setDowngrade(false) setDescription('') if (input.current) input.current.value = '' const uploaded = downgrade ? 'Converted for this build, uploaded' : 'Uploaded' return publish ? `${uploaded} and published — players load this scene now.` : `${uploaded} and staged. Publish it in game to make it live.` }) }} > {/* Said out loud, on the control itself: this is the newest thing on the site and the only one that overwrites what players load. Someone about to hand us a file they can't get back should read that before the file picker, not after. */}

    Replace scene data Beta

    New and lightly tested. Nothing here checks the file — the server stores whatever it is and the game finds out on load. This server runs the {CLIENT_BUILD_DATE} build, so scene data from a room built on anything newer may not load at all. Converting a file for this build before it is stored gets around that, but removes its circuits. Download the save above and keep it before replacing it.

    {fileError &&

    {fileError}

    } {error &&

    {error}

    } {done &&

    {done}

    }
    ) } /** * Picture types the room thumbnail accepts. The `img` worker decodes whatever it serves * with Photon to resize it, and these two are the ones the game itself produces (a photo * is a JPEG; a PNG keeps its alpha through the resize). Checked on the type the browser * reports rather than the extension, and the server's own check still stands behind it. */ const IMAGE_TYPES = new Set(['image/jpeg', 'image/png']) /** * A picture that is also the control that replaces it: clicking it opens the file * picker, and the picked file is handed to `onFile`. The room thumbnail and the profile * avatar are both this, with different pictures and different workers behind them. * * Just the picture and its input — the upload itself, and where its result is shown, * belong to the caller: the frame is exactly the picture's size (a circle for the * avatar), so a message can't live inside it without stretching it. */ function ImageUpload({ src, className, imgClassName, hint, pending, onFile, }: { src: string /** Goes on the label, which is the clickable frame — the shape (card, circle) is its. */ className?: string imgClassName: string /** What the overlay says at rest; while uploading it says so instead. */ hint: string pending: boolean onFile: (file: File) => void }) { return ( // A label, so the whole picture is the file input's click target. The input is // hidden visually rather than with `display: none`, which keeps it in the tab // order — the focus ring is drawn on the label around it. ) } /** Refuse anything but the two image types the server resizes (see IMAGE_TYPES). */ function requireImage(file: File): void { if (!IMAGE_TYPES.has(file.type)) throw new Error('Choose a JPEG or PNG image.') } /** * The room's thumbnail, as an {@link ImageUpload}, with the upload's result under it. * * Same two steps as the scene-data upload, with a different `FileType`: the bytes go to * `storage` under the Image type, and the key it hands back is put to the room's * `…/image` route as `imageName`. In game the only way to set this is to take a photo in * the room, so this is how an owner gets a picture they made elsewhere onto the room. * * There is no submit step because there is no publish step: the image is live the moment * the route answers, for everyone browsing rooms. The picture redraws from the new * `ImageName` straight away — `img` caches by key and the key is new, so nothing stale * can be served. */ function RoomImageUpload({ roomId, src, onImageChange, }: { roomId: number src: string onImageChange: (imageName: string) => void }) { const { pending, error, done, run } = useAction() return (
    void run(async () => { requireImage(file) const imageName = await uploadToStorage(file, FILE_TYPE_IMAGE) await setRoomImage(roomId, imageName) onImageChange(imageName) return 'Image replaced — it shows in game now.' }) } /> {error &&

    {error}

    } {done &&

    {done}

    }
    ) } /** * The player's avatar, as an {@link ImageUpload}: the same two calls the game makes when * a player changes their profile picture, uploaded as a saved image of the profile type * and then set on the account. The game's own picker only offers photos taken in game; * this one takes any JPEG or PNG. * * A fragment, laid out by the identity card it sits in: the avatar takes its place beside * the name, and a failure wraps onto a row of its own below both (`identity-status`) * rather than under the avatar, where it would widen the circle to the text's width. * Success says nothing: the new picture appearing is the confirmation. */ function ProfileImageUpload({ account, onChange, }: { account: SelfAccount onChange: (a: SelfAccount) => void }) { const { pending, error, run } = useAction() return ( <> void run(async () => { requireImage(file) const imageName = await uploadProfileImage(file) await setProfileImage(imageName) onChange({ ...account, profileImage: imageName }) return '' }) } /> {error &&

    {error}

    } ) } /** * A download filename built from player-supplied names, with everything that isn't a * word character, dot or dash flattened to a dash — a subroom can be called anything, * and that string is about to become a path on someone's disk. */ const safeFilename = (...parts: string[]): string => `${parts.join('-').replace(/[^\w.-]+/g, '-')}.bin` /** * One scene-data blob: the key, and a link that downloads it from `cdn`. * * `href` is the real CDN URL, so open-in-new-tab and right-click → Save As work like any * other link. The click is intercepted only to give the file a NAME: blobs are stored * under a date-foldered UUID, so three downloads otherwise land as three * indistinguishable extensionless files. The `download` attribute can't do that on its * own — browsers ignore it cross-origin, and `cdn` is always a different origin from the * website — hence fetching the bytes and saving them through an object URL. */ function BlobDownload({ label, blobKey, filename, cdnHost, }: { label: string blobKey: string filename: string cdnHost: string }) { // Room build data is served under `room/` — the same prefix the storage worker // uploads it to, and the one the game downloads it from. const url = `${cdnHost}/room/${blobKey}` const [pending, setPending] = useState(false) const [error, setError] = useState('') const download = async () => { setPending(true) setError('') try { const res = await fetch(url) // The blob key is stored on the subroom, so a miss here means the object is gone // from the bucket — worth saying, rather than saving a file of the 404 body. if (!res.ok) throw new Error(`the CDN answered ${res.status}`) const href = URL.createObjectURL(await res.blob()) const link = document.createElement('a') link.href = href link.download = filename link.click() // The click is dispatched synchronously but the save reads the URL after this // frame, so the revoke waits a tick rather than pulling it out from under. setTimeout(() => URL.revokeObjectURL(href), 0) } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setPending(false) } } return (
    {label} { e.preventDefault() void download() }} > {blobKey} {/* Only rendered when it has something to say — an empty span would still take a gap from the flex row, leaving the key trailed by a stray space. */} {pending ? ( Downloading… ) : error ? ( Couldn’t download — {error}. ) : null}
    ) } /** How a room or subroom's `Accessibility` reads on screen. */ const accessibilityLabel = (accessibility: number): string => // Unknown ordinals shouldn't happen, but this label is the only thing telling an owner // whether a room is visible — so show the raw value rather than nothing at all. ACCESSIBILITY_LABEL[accessibility] ?? `Accessibility ${accessibility}` /** * The visibility pill. Public gets the same green "healthy" reading as the server * status; every other value stays neutral, since Private is a choice, not a fault. */ function VisibilityBadge({ accessibility }: { accessibility: number }) { return ( {accessibilityLabel(accessibility)} ) } /** * Turnstile's browser API, as much of it as the signup widget uses. Loaded from * Cloudflare at runtime (see loadTurnstile) rather than bundled, so it isn't in * node_modules and has no types of its own. */ interface TurnstileApi { render: ( el: HTMLElement, opts: { sitekey: string action?: string callback?: (token: string) => void 'expired-callback'?: () => void } ) => string | undefined reset: (widgetId?: string) => void remove: (widgetId?: string) => void } declare global { interface Window { turnstile?: TurnstileApi } } /** * Load Turnstile's script, once per page, resolving when `window.turnstile` is ready. * `render=explicit` stops it scanning the document for widgets: this is a SPA, so the * container mounts and unmounts with the form and we render into it ourselves. * * The promise is cached at module scope, so switching tabs back and forth reuses the * loaded script instead of appending another tag. A rejection is cached too — the retry * is a page reload, which is what the error message asks for. */ let turnstileScript: Promise | null = null function loadTurnstile(): Promise { turnstileScript ??= new Promise((resolve, reject) => { const el = document.createElement('script') el.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit' el.async = true el.defer = true el.onload = () => resolve() el.onerror = () => reject(new Error('load failed')) document.head.appendChild(el) }) return turnstileScript } /** * Mount a Turnstile widget and hand back the token it produces. No token means no * submit: the BFF refuses a signup without one, so the form gates its button on it * rather than letting the request fail. * * `reset` re-arms the widget for another attempt — a token is single-use, so a rejected * signup can't be retried with the same one. */ function useTurnstile(siteKey: string) { const container = useRef(null) const widgetId = useRef(undefined) const [token, setToken] = useState('') const [error, setError] = useState('') useEffect(() => { let live = true loadTurnstile() .then(() => { // StrictMode mounts twice, and the cleanup below removes the first widget; bail // if this effect is the stale one so we don't render into a detached container. if (!live || !container.current || !window.turnstile) return widgetId.current = window.turnstile.render(container.current, { sitekey: siteKey, // Marker Cloudflare uses to segment Turnstile integrations; carries no user data. action: 'turnstile-spin-v1', callback: (t) => setToken(t), // Tokens expire after a few minutes; drop ours so the button locks again and // Turnstile can hand us a fresh one. 'expired-callback': () => setToken(''), }) }) .catch(() => { if (live) setError("Couldn't load the bot check — reload the page to try again.") }) return () => { live = false if (widgetId.current) window.turnstile?.remove(widgetId.current) widgetId.current = undefined } }, [siteKey]) const reset = useCallback(() => { setToken('') if (widgetId.current) window.turnstile?.reset(widgetId.current) }, []) return { container, token, error, reset } } /** * Create an account from the website: a password, plus a Turnstile token proving a human * filled the form. The username comes back auto-assigned from `auth` (players don't pick * one), and the session is live on success — so this lands on the account page, where the * username is shown. */ function SignupForm({ siteKey, onAuthed, }: { siteKey: string onAuthed: (a: SelfAccount) => void }) { const [password, setPassword] = useState('') const [email, setEmail] = useState('') const { container, token: widgetToken, error: widgetError, reset } = useTurnstile(siteKey) const { pending, error, run } = useAction() return (
    { e.preventDefault() void run(async () => { const wanted = email.trim() try { await signUp(password, widgetToken) } catch (err) { // The widget token is spent either way, so re-arm before they retry. Only // a failed signup gets here — past this point the account exists, and a // retry would spend another slot against auth's per-IP cap. reset() throw err } // Saved with the new session's own token: `create_account` takes no email, // `accounts` owns the field. Deliberately not fatal — the account exists and // the session is live, and the same field is one call away on the account // page. if (wanted !== '') await saveEmail(wanted).catch(() => {}) // The session is already stored, so a failure here isn't one they can act on // by retrying: a reload finds them signed in. const me = await fetchMe().catch(() => { throw new Error( 'Your account was created, but loading it failed. Reload the page — you are already signed in.' ) }) onAuthed(me) return '' }) }} > {/* Optional, and the button doesn't wait on it — but it's the only contact detail an account has, so the hint says plainly what it's for rather than leaving it to be guessed. `type="email"` gets the right keyboard on mobile and a free format check; the worker re-checks it before the account is created. */}
    {widgetError &&

    {widgetError}

    } {error &&

    {error}

    } ) } function LoginForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) { const [username, setUsername] = useState('') const [password, setPassword] = useState('') const { pending, error, run } = useAction() return (
    { e.preventDefault() void run(async () => { await signIn(username, password) onAuthed(await fetchMe()) return '' }) }} > {error &&

    {error}

    }
    ) } function Dashboard({ account, config, navigate, onChange, }: { account: SelfAccount config: SiteConfig | undefined navigate: Navigate onChange: (a: SelfAccount) => void }) { // The dashboard sections, shown one at a time via the left tab rail. Admin-only // sections are appended when the session carries an admin role. const sections = [ // First, so a player who just signed in lands on what they made rather than on a // settings form they opened the page to avoid. { id: 'rooms', label: 'My rooms', render: () => }, { id: 'photos', label: 'My photos', render: () => }, { id: 'username', label: 'Username', render: () => , }, { id: 'email', label: 'Email', render: () => , }, { id: 'password', label: 'Password', render: () => }, // Only when the operator has Discord configured — otherwise the panel has nothing to // offer and the tab is a promise the server can't keep. The claim also still lives at // /claim, because that URL is Discord's registered redirect and has to keep working. ...(config?.benefitsEnabled ? [ { id: 'benefits', label: 'Claim benefits', render: () => , }, ] : []), ...(isAdmin() ? [ { id: 'maintenance', label: 'Server maintenance', render: () => }, { id: 'coach', label: 'Coach message', render: () => }, ] : []), // Narrower than the two above: the drop mints tokens, and www's route is developer-only. ...(isDeveloper() ? [{ id: 'tokens', label: 'Token drop', render: () => }] : []), ] const [active, setActive] = useState(sections[0].id) const current = sections.find((s) => s.id === active) ?? sections[0] return ( <>
    {/* The same picture the public profile draws (see PlayerPage), and here the way to change it. Dashboard only renders once `/account/me` has answered, and that call went through `where()`, so the img host is known. */}
    Signed in as
    {account.displayName || account.username}
    @{account.username} · #{account.accountId} · {account.email ?? 'no email set'}
    {current.render()}
    ) } /** * The rooms the signed-in player owns. * * Read-only on purpose: rooms are made and edited in game, and there is nothing here a * player could change that the game doesn't already own. What the web is better at is * the overview — everything you've made in one place, including the rooms you never * published, which are invisible everywhere else. */ function MyRooms({ navigate }: { navigate: Navigate }) { const [rooms, setRooms] = useState(null) const [error, setError] = useState('') useEffect(() => { void fetchMyRooms() .then(setRooms) .catch((e) => setError(e instanceof Error ? e.message : String(e))) }, []) return (

    My rooms

    Every room you've made, newest first — unpublished ones included. Your dorm isn't here: it was made for you rather than by you.

    {error ? (

    {error}

    ) : rooms === null ? (

    Loading…

    ) : rooms.length === 0 ? (

    You haven't made a room yet. Rooms are created in game — clone one you like, or start from a blank one in the Rec Center.

    ) : ( // `where()` THROWS when the config never landed, and a throw in render takes the // page down (see useSlideshow). It can't here: this branch is only reached once // the fetch above resolved, and that fetch went through `where()` itself.
      {rooms.map((room) => ( ))}
    )}
    ) } /** * The photos the signed-in player has taken — the same grid their profile shows, private * ones included. * * It reads the same list the profile does (`/api/images/v4/player/:id`); what makes it the * owner's view is the session token going with the request, which `api` matches against * the id (see fetchPlayerPhotos). The private ones are marked in the grid; there is no * way to change a photo's privacy here yet — that's still done in game. */ function MyPhotos({ accountId }: { accountId: number }) { const [photos, setPhotos] = useState(null) const [error, setError] = useState('') useEffect(() => { void fetchPlayerPhotos(accountId) .then(setPhotos) .catch((e) => setError(e instanceof Error ? e.message : String(e))) }, [accountId]) return (

    My photos

    The photos you've taken in game, newest first. Ones you keep private are marked; only you can see them here.

    {error ? (

    {error}

    ) : photos === null ? (

    Loading…

    ) : photos.length === 0 ? (

    No photos yet. Photos you take in game show up here.

    ) : ( )}
    ) } /** * One room in the list: its thumbnail, what it's called in game (`^Name`), and how it's * doing. The whole row links to the room's own page. * * The thumbnail is asked for at 256px wide — one of the img worker's four allowed sizes, * so it's a cached variant rather than the full-size upload. A room with no image of its * own still answers 200 there (the worker serves its fallback), so there's no broken * frame to handle. */ function RoomCard({ room, imgHost, navigate, }: { room: OwnedRoom imgHost: string navigate: Navigate }) { const created = new Date(room.CreatedAt) return (
  • {/* A real `` (see Link), not a click handler on the row: it has to be reachable by keyboard, and openable in a new tab like any other link. */}
    {/* The caret is how the game writes a room name, so it reads as the thing you type to get there rather than as a title someone wrote. */} ^{room.Name}
    {room.Description &&

    {room.Description}

    }

    {room.Stats.VisitCount.toLocaleString()} visit {room.Stats.VisitCount === 1 ? '' : 's'} · {room.Stats.FavoriteCount.toLocaleString()}{' '} favourite {room.Stats.FavoriteCount === 1 ? '' : 's'} · {room.Stats.CheerCount.toLocaleString()}{' '} cheer{room.Stats.CheerCount === 1 ? '' : 's'} {!Number.isNaN(created.getTime()) && ` · made ${created.toLocaleDateString()}`}

  • ) } /** * Admin-only: send a coach/system message, either to one player by `@username` or to * everyone online. * * The two go to different endpoints because they behave differently, not just in reach: * the broadcast is online-only (nothing holds a message with no addressee), while a named * recipient's message is queued by the hub and delivered whenever they next connect. The * recipient box therefore says which of those the operator is about to do. */ function CoachMessageForm() { const [recipient, setRecipient] = useState('') const [message, setMessage] = useState('') const { pending, error, done, run } = useAction() // The `@` is how the name is written, not part of it — accepted either way, shown back // with it, and sent without it. const handle = recipient.trim().replace(/^@/, '') const toOne = handle !== '' return (

    Coach message

    Send a message from the Coach to one player, or leave the recipient blank to send it to every connected player. A broadcast reaches only who is online right now; a message to one player waits for them if they aren't.

    { e.preventDefault() void run(async () => { const content = message.trim() if (!toOne) { const { sent } = await coachMessageAll(content) setMessage('') return `Sent to ${sent ?? 0} online player${sent === 1 ? '' : 's'}.` } // Resolved before sending: the workers address players by id, and a name that // matches nobody should be a refusal rather than a message into the void. const { queued } = await coachMessage(await accountIdForUsername(handle), content) setMessage('') return queued === true ? `@${handle} is offline — it will arrive when they next connect.` : `Sent to @${handle}.` }) }} >