From 46dda740db9745547fe171879fad70adbb123b81 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 14 Aug 2025 19:16:52 -0700 Subject: [PATCH 1/4] OMG --- api/package.json | 3 +- api/src/account.ts | 13 +- api/src/auth.ts | 2 +- api/src/room.ts | 47 +++++- api/src/shared.ts | 9 + app/package.json | 1 - app/src/controls.tsx | 40 +++-- app/src/room/broadcast.ts | 14 +- app/src/room/index.ts | 291 ++------------------------------ app/src/room/local.ts | 334 +++++++++++++++++++++++++++++++++++++ app/src/room/space.ts | 7 +- app/src/room/video.ts | 2 +- app/src/settings.tsx | 12 ++ app/src/sup.tsx | 341 ++++++++++++-------------------------- moq | 2 +- pnpm-lock.yaml | 6 +- 16 files changed, 557 insertions(+), 567 deletions(-) create mode 100644 app/src/room/local.ts diff --git a/api/package.json b/api/package.json index 2c6fbb99..fc459afa 100644 --- a/api/package.json +++ b/api/package.json @@ -22,7 +22,8 @@ "jose": "^6.0.11", "nanoid": "^5.1.5", "uuid": "^11.1.0", - "zod": "^4.0.5" + "zod": "^4.0.5", + "unique-names-generator": "^4.7.1" }, "devDependencies": { "@types/uuid": "^10.0.0", diff --git a/api/src/account.ts b/api/src/account.ts index 27be517b..aedd1404 100644 --- a/api/src/account.ts +++ b/api/src/account.ts @@ -13,13 +13,11 @@ export const idSchema = Auth.accountIdSchema; export type Id = Auth.AccountId; // Account schemas -export const infoSchema = z.object({ - // Defined in jwt to avoid circular dependency - id: idSchema, - name: z.string(), - email: z.string().check(z.email()), - avatar: z.string(), -}); +export interface Info { + id: Id; + name: string; + avatar: string; +} export const createSchema = z.object({ name: z.string().check(z.minLength(4), z.maxLength(100)), @@ -27,7 +25,6 @@ export const createSchema = z.object({ avatar: z.optional(z.string()), }); -export type Info = z.infer; export type Create = z.infer; export const table = sqliteTable("accounts", { diff --git a/api/src/auth.ts b/api/src/auth.ts index f0278c81..1aeff5d4 100644 --- a/api/src/auth.ts +++ b/api/src/auth.ts @@ -4,7 +4,7 @@ import { z } from "zod"; import { Account } from "."; import RootContext from "./context"; -export const accountIdSchema = z.uuidv4().brand("AccountId"); +export const accountIdSchema = z.string().brand("AccountId"); export type AccountId = z.infer; // Use ZOD to validate the payload diff --git a/api/src/room.ts b/api/src/room.ts index 278f243f..f4bd1659 100644 --- a/api/src/room.ts +++ b/api/src/room.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import * as Account from "./account"; import * as Auth from "./auth"; import * as rpc from "./rpc"; +import { randomAvatar, randomName } from "./shared"; export const nameSchema = z.string().check(z.minLength(1), z.maxLength(100)); export type Name = z.infer; @@ -20,7 +21,7 @@ export class Context { } // Returns the URL to join the room - async sign(room: Name, account: Account.Id): Promise { + async sign(room: Name, account: string): Promise { const root = `${this.#env.RELAY_PREFIX}/${room}`; // TODO add a field to force publishing, preventing someone from lurking. const token = await Token.sign(this.#key, { root, get: "", put: account }); @@ -45,13 +46,41 @@ export const joinSchema = z.object({ export const router = rpc .router() - .post("/:name/join", rpc.withParam(z.object({ name: nameSchema })), Auth.optional, async (c) => { - const ctx = c.var.ctx; - const room = c.req.valid("param").name; + .post( + "/:room/join", + rpc.withParam(z.object({ room: nameSchema, guest: Auth.accountIdSchema.optional() })), + Auth.optional, + async (c) => { + const ctx = c.var.ctx; + const room = c.req.valid("param").room; - // Generate a random account ID if not authenticated - const account = c.var.account_id ?? Account.idSchema.parse(Uuid.v4()); + let info: Account.Info; + if (c.var.account_id) { + const row = await ctx.account.get(c.var.account_id); + if (!row) { + throw new Error("Account not found"); + } - const url = await ctx.room.sign(room, account); - return c.json({ url, account }); - }); + info = { + id: c.var.account_id, + name: row.name, + avatar: row.avatar, + }; + } else { + // Let the client provide it's own ID but only if it starts with "guest/" + let id: Account.Id; + + const guest = c.req.valid("param").guest; + if (guest?.startsWith("guest/")) { + id = guest; + } else { + id = Account.idSchema.parse(`guest/${Uuid.v4()}`); + } + + info = { id, name: randomName(), avatar: randomAvatar() }; + } + + const url = await ctx.room.sign(room, info.id); + return c.json({ url, info }); + }, + ); diff --git a/api/src/shared.ts b/api/src/shared.ts index c2541caf..c46c196c 100644 --- a/api/src/shared.ts +++ b/api/src/shared.ts @@ -1,3 +1,4 @@ +import { adjectives, animals, uniqueNamesGenerator } from "unique-names-generator"; import { z } from "zod"; // This could be an RPC endpoint in the future. @@ -9,6 +10,14 @@ export function randomAvatar(): string { return `/avatar/${index}.svg`; } +export function randomName(): string { + return uniqueNamesGenerator({ + dictionaries: [adjectives, animals], + separator: " ", + style: "capital", + }); +} + export const oauthStateSchema = z.object({ // A random string to prevent CSRF attacks. // The client should validate that they generated this string themselves. diff --git a/app/package.json b/app/package.json index 8401f39b..d4391520 100644 --- a/app/package.json +++ b/app/package.json @@ -28,7 +28,6 @@ "solid-js": "^1.9.7", "tailwindcss": "^4.1.11", "tauri-plugin-web-transport": "^0.1.0", - "unique-names-generator": "^4.7.1", "zod": "^4.0.5" }, "devDependencies": { diff --git a/app/src/controls.tsx b/app/src/controls.tsx index 3f7eaabf..9c320782 100644 --- a/app/src/controls.tsx +++ b/app/src/controls.tsx @@ -17,26 +17,22 @@ import IconVolumeMute from "~icons/mdi/volume-mute"; import Tooltip from "./components/tooltip"; import type { Room } from "./room"; import type { Canvas } from "./room/canvas"; +import { Local } from "./room/local"; import Settings, { Modal } from "./settings"; -export function Controls(props: { - room: Room; - camera: Publish.Broadcast; - screen: Publish.Broadcast; - canvas: Canvas; -}): JSX.Element { +export function Controls(props: { room: Room; local: Local; canvas: Canvas }): JSX.Element { return ( @@ -147,30 +95,11 @@ function Preview(props: { {/* Right Column: Avatar/Name Preview */}
- Loading...
}> - {(account) => ( - - - - } - > -
- -
-
+ Loading...}> + {(info) => ( +
+ +
)}
@@ -181,8 +110,6 @@ function Preview(props: { - - {/* */} @@ -190,28 +117,25 @@ function Preview(props: { ); } -function AnonymousPreview(props: { - api: Api.Client; - room: string; - setInfo: (info: Info) => void; - account: Api.Account.Id; -}): JSX.Element { - const [avatar, setAvatar] = createSignal(Api.randomAvatar()); - const [name, setName] = createSignal(randomName()); +function PreviewIcon(props: { api: Api.Client; room: string; local: Local; info: Api.Account.Info }): JSX.Element { + const [info, setInfo] = createSignal(props.info); + const [avatarClicks, setAvatarClicks] = createSignal(0); const [nameClicks, setNameClicks] = createSignal(0); - createEffect(() => { - props.setInfo({ name: name(), avatar: avatar(), guest: true, account: props.account }); - }); + const canvas = document.createElement("canvas"); + canvas.classList.add("w-full", "h-full"); + + const local = new LocalPreview(canvas, props.local.camera); + onCleanup(() => local.close()); const handleRandomAvatar = () => { setAvatarClicks((prev) => prev + 1); - const oldAvatar = avatar(); + const oldAvatar = info().avatar; while (true) { const newAvatar = Api.randomAvatar(); if (newAvatar !== oldAvatar) { - setAvatar(newAvatar); + setInfo((prev) => ({ ...prev, avatar: newAvatar })); break; } } @@ -219,11 +143,11 @@ function AnonymousPreview(props: { const handleRandomName = () => { setNameClicks((prev) => prev + 1); - const oldName = name(); + const oldName = info().name; while (true) { - const newName = randomName(); + const newName = Api.randomName(); if (newName !== oldName) { - setName(newName); + setInfo((prev) => ({ ...prev, name: newName })); break; } } @@ -231,149 +155,100 @@ function AnonymousPreview(props: { return ( <> -

Guest Profile

+

Your Profile

- {/* Avatar Preview */} -
+ {/* Avatar/Video Preview */} +
-
- Avatar Preview -
+
{canvas}
+
- {/* Display Name Overlay */} -
-
- {name()} + +
+
+ +
-
-
- {/* Random Buttons */} -
-
- - +
+ + +
+ +
- -
+ + + +
); } +/* function AuthenticatedPreview(props: { api: Api.Client; room: string; - setInfo: (info: Info) => void; - account: Api.Account.Id; + local: Local; + info: Api.Room.JoinInfo; }): JSX.Element { - const [info, setInfo] = createSignal(undefined); - const [error, setError] = createSignal(undefined); - - createEffect(() => { - const i = info(); - if (i) props.setInfo({ name: i.name, avatar: i.avatar, guest: false, account: props.account }); - }); + const canvas = document.createElement("canvas"); + canvas.classList.add("w-full", "h-full"); - onMount(async () => { - try { - const response = await props.api.routes.account.info.$get(); - if (response.ok) { - setInfo(await response.json()); - } else { - setError(response.statusText); - } - } catch (e) { - setError(`Failed to load account info: ${e}`); - } - }); + const local = new LocalPreview(canvas, props.local.camera); + onCleanup(() => local.close()); return ( - - -
- Error: {error()} -
-
- - {(userInfo) => ( - <> -

Your Profile

- - {/* Avatar Preview */} -
-
-
- } - > - Avatar Preview - -
+ <> +

Preview

- {/* Display Name Overlay */} -
-
- {userInfo().name} -
-
-
-
+
+
+
{canvas}
- {/* Account Link */} -
- - - Edit - +
+
+ {props.info.name}
- - )} - - -
Loading...
-
- +
+
+
+ ); } +*/ /* function MicrophoneControl(): JSX.Element { diff --git a/moq b/moq index 0c243506..e3462016 160000 --- a/moq +++ b/moq @@ -1 +1 @@ -Subproject commit 0c243506cdbda9218c63c36831cd88d1c2b41ff2 +Subproject commit e3462016dd3497e61fe6bc4de9dff15b5a8fe5c8 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71d5ac26..6d846162 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: nanoid: specifier: ^5.1.5 version: 5.1.5 + unique-names-generator: + specifier: ^4.7.1 + version: 4.7.1 uuid: specifier: ^11.1.0 version: 11.1.0 @@ -129,9 +132,6 @@ importers: tauri-plugin-web-transport: specifier: ^0.1.0 version: 0.1.0 - unique-names-generator: - specifier: ^4.7.1 - version: 4.7.1 zod: specifier: ^4.0.5 version: 4.0.5 From ccd6ac6c3a008757461ad850703a4207e2865063 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 14 Aug 2025 19:24:51 -0700 Subject: [PATCH 2/4] So good. --- app/src/sup.tsx | 154 +++++++++--------------------------------------- 1 file changed, 27 insertions(+), 127 deletions(-) diff --git a/app/src/sup.tsx b/app/src/sup.tsx index 316aa084..fcdccd5e 100644 --- a/app/src/sup.tsx +++ b/app/src/sup.tsx @@ -96,17 +96,15 @@ function Preview(props: { connection: Connection; api: Api.Client; room: string; {/* Right Column: Avatar/Name Preview */}
Loading...
}> - {(info) => ( -
- -
- )} +
+ +
{/* Login Options - only show for guests */}
-
...or login to customize your profile
+
...or login to customize your profile
@@ -117,8 +115,8 @@ function Preview(props: { connection: Connection; api: Api.Client; room: string; ); } -function PreviewIcon(props: { api: Api.Client; room: string; local: Local; info: Api.Account.Info }): JSX.Element { - const [info, setInfo] = createSignal(props.info); +function PreviewIcon(props: { api: Api.Client; room: string; local: Local }): JSX.Element { + const info = solid(props.local.info); const [avatarClicks, setAvatarClicks] = createSignal(0); const [nameClicks, setNameClicks] = createSignal(0); @@ -130,12 +128,14 @@ function PreviewIcon(props: { api: Api.Client; room: string; local: Local; info: onCleanup(() => local.close()); const handleRandomAvatar = () => { + const i = info(); + if (!i) return; // not possible, just for typescript + setAvatarClicks((prev) => prev + 1); - const oldAvatar = info().avatar; while (true) { const newAvatar = Api.randomAvatar(); - if (newAvatar !== oldAvatar) { - setInfo((prev) => ({ ...prev, avatar: newAvatar })); + if (newAvatar !== i.avatar) { + props.local.info.set({ ...i, avatar: newAvatar }); break; } } @@ -143,11 +143,13 @@ function PreviewIcon(props: { api: Api.Client; room: string; local: Local; info: const handleRandomName = () => { setNameClicks((prev) => prev + 1); - const oldName = info().name; + const i = info(); + if (!i) return; // not possible, just for typescript + while (true) { const newName = Api.randomName(); - if (newName !== oldName) { - setInfo((prev) => ({ ...prev, name: newName })); + if (newName !== i.name) { + props.local.info.set({ ...i, name: newName }); break; } } @@ -155,7 +157,17 @@ function PreviewIcon(props: { api: Api.Client; room: string; local: Local; info: return ( <> -

Your Profile

+

+ Your Profile{" "} + + + + + +

{/* Avatar/Video Preview */}
@@ -210,115 +222,3 @@ function PreviewIcon(props: { api: Api.Client; room: string; local: Local; info: ); } - -/* -function AuthenticatedPreview(props: { - api: Api.Client; - room: string; - local: Local; - info: Api.Room.JoinInfo; -}): JSX.Element { - const canvas = document.createElement("canvas"); - canvas.classList.add("w-full", "h-full"); - - const local = new LocalPreview(canvas, props.local.camera); - onCleanup(() => local.close()); - - return ( - <> -

Preview

- -
-
-
{canvas}
- -
-
- {props.info.name} -
-
-
-
- - ); -} -*/ - -/* -function MicrophoneControl(): JSX.Element { - const [micEnabled, setMicEnabled] = createSignal(false); - const [hasPermission, setHasPermission] = createSignal(undefined); - - const requestMicPermission = async () => { - try { - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - setHasPermission(true); - setMicEnabled(true); - // Stop the stream since we just wanted permission - stream.getTracks().forEach((track) => track.stop()); - } catch (error) { - setHasPermission(false); - console.error("Microphone permission denied:", error); - } - }; - - const toggleMic = () => { - if (hasPermission()) { - setMicEnabled(!micEnabled()); - } else { - requestMicPermission(); - } - }; - - createEffect(() => { - // Check if we already have microphone permission - navigator.permissions?.query({ name: "microphone" as PermissionName }).then((result) => { - setHasPermission(result.state === "granted"); - if (result.state === "granted") { - setMicEnabled(true); - } - }); - }); - - return ( -
-

Audio Settings

-
- -

- - Microphone access was denied. Please allow microphone access in your browser settings. - -

-
-
- ); -} -*/ From 15d40c44016a7b74827b201fc24ea2de32f00d46 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 14 Aug 2025 19:52:35 -0700 Subject: [PATCH 3/4] ez --- app/src/preview.tsx | 2 +- app/src/room/broadcast.ts | 4 +++- app/src/room/local.ts | 38 ++++++-------------------------------- app/src/room/space.ts | 4 ++++ app/src/sup.tsx | 16 ++++++++-------- 5 files changed, 22 insertions(+), 42 deletions(-) diff --git a/app/src/preview.tsx b/app/src/preview.tsx index 558a5ddb..14cb723d 100644 --- a/app/src/preview.tsx +++ b/app/src/preview.tsx @@ -123,7 +123,7 @@ export function PreviewRoom(props: { connection: Connection; path?: string; api: fallback={

No one's here yet!

-

Be the first to join

+

be the first, you trailblazer

} > diff --git a/app/src/room/broadcast.ts b/app/src/room/broadcast.ts index d160f0a7..790ba2d7 100644 --- a/app/src/room/broadcast.ts +++ b/app/src/room/broadcast.ts @@ -593,9 +593,11 @@ export class Broadcast { close() { this.signals.close(); - this.source.close(); this.audio.close(); this.chat.close(); this.captions.close(); + + // NOTE: Don't close the source broadcast; we need it for the local preview. + // this.source.close(); } } diff --git a/app/src/room/local.ts b/app/src/room/local.ts index 9e166d6d..f607df2e 100644 --- a/app/src/room/local.ts +++ b/app/src/room/local.ts @@ -15,8 +15,6 @@ export class Local { camera: Publish.Broadcast; screen: Publish.Broadcast; - publish = new Signal(false); - // For notifications, created here just because it's more convenient. sound: Sound; @@ -42,7 +40,7 @@ export class Local { // Create the camera broadcast this.camera = new Publish.Broadcast(connection, { - enabled: false, // Don't connect until join + enabled: false, device: "camera", video: { enabled: Settings.cameraEnabled.peek(), @@ -76,7 +74,7 @@ export class Local { // Create the screen broadcast this.screen = new Publish.Broadcast(connection, { - enabled: false, // Don't connect until join + enabled: false, device: "screen", audio: { enabled: false, @@ -226,9 +224,6 @@ export class Local { // Enable the screen when a media device is selected. this.screen.signals.effect((effect) => { - const publish = effect.get(this.publish); - if (!publish) return; - const active = !!effect.get(this.screen.video.media) || !!effect.get(this.screen.audio.media); if (!active) return; @@ -248,20 +243,6 @@ export class Local { effect.set(this.camera.preview.info, info); effect.set(this.screen.preview.info, { ...info, name: `${info.name} (Screen)` }); }); - - // Enable the camera when publishing is enabled. - this.screen.signals.effect((effect) => { - const publish = effect.get(this.publish); - effect.set(this.camera.enabled, publish, false); - }); - } - - /** - * Enable the broadcasts to start publishing - */ - enable() { - this.camera.enabled.set(true); - // Screen is enabled separately when screen sharing is started } close() { @@ -309,26 +290,19 @@ export class LocalPreview { const viewport = this.canvas.viewport.peek(); const targetSize = this.broadcast.video.targetSize; - const scale = Math.min(viewport.x / targetSize.x, viewport.y / targetSize.y) * 0.9; + const scale = Math.min(viewport.x / targetSize.x, viewport.y / targetSize.y) * 0.8; // Update broadcast physics (simplified for preview) this.broadcast.tick(scale); - // Render audio visualization if active - const audioMedia = this.broadcast.source.audio.media.peek(); - if (audioMedia) { - this.broadcast.audio.renderBackground(ctx); - this.broadcast.audio.render(ctx); - } - - // Render the video/avatar + this.broadcast.audio.renderBackground(ctx); + this.broadcast.audio.render(ctx); this.broadcast.video.render(now, ctx, { hovering: true }); } close() { - this.broadcast.close(); this.canvas.close(); this.sound.close(); - // Note: Don't close the canvas as it might be managed externally + this.broadcast.close(); // NOTE: Doesn't close the source broadcast. } } diff --git a/app/src/room/space.ts b/app/src/room/space.ts index 12b3d085..12b0cde7 100644 --- a/app/src/room/space.ts +++ b/app/src/room/space.ts @@ -477,6 +477,7 @@ export class Space { // Don't close local broadcasts, we keep them open and toggle instead. if (!(broadcast.source instanceof Publish.Broadcast)) { broadcast.close(); + broadcast.source.close(); } }, 1000); } @@ -485,6 +486,7 @@ export class Space { for (const broadcast of this.ordered.peek()) { if (!(broadcast.source instanceof Publish.Broadcast)) { broadcast.close(); + broadcast.source.close(); } } @@ -644,10 +646,12 @@ export class Space { for (const broadcast of this.ordered.peek()) { broadcast.close(); + broadcast.source.close(); } for (const broadcast of this.#rip) { broadcast.close(); + broadcast.source.close(); } this.#rip = []; diff --git a/app/src/sup.tsx b/app/src/sup.tsx index fcdccd5e..da1808f1 100644 --- a/app/src/sup.tsx +++ b/app/src/sup.tsx @@ -26,7 +26,7 @@ export function Sup(props: { canvas: Canvas; api: Api.Client; room: string }): J const local = new Local(connection, props.api, props.room); onCleanup(() => local.close()); - const publish = solid(local.publish); + const publish = solid(local.camera.enabled); return ( props.local.publish.set(true)} + onClick={() => props.local.camera.enabled.set(true)} style={{ background: Gradient(), "text-shadow": "0 0 2px rgba(0, 0, 0, 0.8)", @@ -170,13 +170,9 @@ function PreviewIcon(props: { api: Api.Client; room: string; local: Local }): JS {/* Avatar/Video Preview */} -
-
-
{canvas}
-
- +
-
+
+ +
+
{canvas}
+
{/* Media Controls */} From dbf77b14f4e0177f5bfbc2d4d803cfedef20c886 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 15 Aug 2025 09:28:41 -0700 Subject: [PATCH 4/4] Add better a local preview to the join page. - Can enable microphone/webcam. - Actually performs the same rendering. - Saves the guest name/avatar/id for rejoining. --- api/src/account.ts | 14 ++++++++------ api/src/room.ts | 21 +++++++++++---------- app/src/room/local.ts | 12 +++++++++++- app/src/settings.tsx | 17 +++++++++++++++++ 4 files changed, 47 insertions(+), 17 deletions(-) diff --git a/api/src/account.ts b/api/src/account.ts index aedd1404..ceb889b9 100644 --- a/api/src/account.ts +++ b/api/src/account.ts @@ -9,15 +9,17 @@ import * as OAuth from "./oauth"; import * as rpc from "./rpc"; import * as Storage from "./storage"; +// Account schemas export const idSchema = Auth.accountIdSchema; export type Id = Auth.AccountId; -// Account schemas -export interface Info { - id: Id; - name: string; - avatar: string; -} +export const infoSchema = z.object({ + id: idSchema, + name: z.string().check(z.minLength(4), z.maxLength(100)), + avatar: z.string(), +}); + +export type Info = z.infer; export const createSchema = z.object({ name: z.string().check(z.minLength(4), z.maxLength(100)), diff --git a/api/src/room.ts b/api/src/room.ts index f4bd1659..b5f62a92 100644 --- a/api/src/room.ts +++ b/api/src/room.ts @@ -48,7 +48,8 @@ export const router = rpc .router() .post( "/:room/join", - rpc.withParam(z.object({ room: nameSchema, guest: Auth.accountIdSchema.optional() })), + rpc.withParam(z.object({ room: nameSchema })), + rpc.withJson(z.object({ guest: z.lazy(() => Account.infoSchema).optional() })), Auth.optional, async (c) => { const ctx = c.var.ctx; @@ -67,17 +68,17 @@ export const router = rpc avatar: row.avatar, }; } else { - // Let the client provide it's own ID but only if it starts with "guest/" - let id: Account.Id; - - const guest = c.req.valid("param").guest; - if (guest?.startsWith("guest/")) { - id = guest; + // Let the client provide it's own info but only if the ID starts with "guest/" + const guest = c.req.valid("json").guest; + if (guest?.id.startsWith("guest/")) { + info = guest; } else { - id = Account.idSchema.parse(`guest/${Uuid.v4()}`); + info = { + id: Account.idSchema.parse(`guest/${Uuid.v4()}`), + name: randomName(), + avatar: randomAvatar(), + }; } - - info = { id, name: randomName(), avatar: randomAvatar() }; } const url = await ctx.room.sign(room, info.id); diff --git a/app/src/room/local.ts b/app/src/room/local.ts index f607df2e..cade40be 100644 --- a/app/src/room/local.ts +++ b/app/src/room/local.ts @@ -27,7 +27,9 @@ export class Local { this.sound = new Sound(); this.#signals.spawn(async () => { - const response = await api.routes.room[":room"].join.$post({ param: { room } }); + const guest = Settings.guest.peek(); + + const response = await api.routes.room[":room"].join.$post({ param: { room }, json: { guest } }); if (!response.ok) { throw new Error(`Failed to join room: ${response.statusText}`); } @@ -243,6 +245,14 @@ export class Local { effect.set(this.camera.preview.info, info); effect.set(this.screen.preview.info, { ...info, name: `${info.name} (Screen)` }); }); + + // Save the guest account settings + this.#signals.effect((effect) => { + const info = effect.get(this.info); + if (!info) return; + + Settings.guest.set(info); + }); } close() { diff --git a/app/src/settings.tsx b/app/src/settings.tsx index c00d1f4c..7c9cb1a0 100644 --- a/app/src/settings.tsx +++ b/app/src/settings.tsx @@ -1,3 +1,4 @@ +import * as Api from "@hang/api"; import { Effect, Signal } from "@kixelated/signals"; import solid from "@kixelated/signals/solid"; import type { JSX } from "solid-js/jsx-runtime"; @@ -24,8 +25,20 @@ const Settings = { // Device states that persist across sessions microphoneEnabled: new Signal(localStorage.getItem("settings.microphone.enabled") === "true"), cameraEnabled: new Signal(localStorage.getItem("settings.camera.enabled") === "true"), + + // Guest account settings + guest: new Signal(undefined), }; +const guestRaw = localStorage.getItem("settings.guest"); +if (guestRaw) { + try { + Settings.guest.set(Api.Account.infoSchema.safeParse(JSON.parse(guestRaw)).data); + } catch (error) { + console.error("Failed to parse guest settings", error); + } +} + const effect = new Effect(); effect.subscribe(Settings.draggable, (draggable) => { @@ -77,6 +90,10 @@ effect.subscribe(Settings.cameraEnabled, (enabled) => { localStorage.setItem("settings.camera.enabled", enabled.toString()); }); +effect.subscribe(Settings.guest, (guest) => { + localStorage.setItem("settings.guest", JSON.stringify(guest)); +}); + // Mostly just to avoid console warnings about signals not being closed document.addEventListener("unload", () => { effect.close();