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..ceb889b9 100644 --- a/api/src/account.ts +++ b/api/src/account.ts @@ -9,25 +9,24 @@ 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 const infoSchema = z.object({ - // Defined in jwt to avoid circular dependency id: idSchema, - name: z.string(), - email: z.string().check(z.email()), + 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)), email: z.string().check(z.email()), 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..b5f62a92 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,42 @@ 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 })), + rpc.withJson(z.object({ guest: z.lazy(() => Account.infoSchema).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 info but only if the ID starts with "guest/" + const guest = c.req.valid("json").guest; + if (guest?.id.startsWith("guest/")) { + info = guest; + } else { + info = { + id: Account.idSchema.parse(`guest/${Uuid.v4()}`), + 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 f3130f3d..f3f0929d 100644 --- a/app/src/controls.tsx +++ b/app/src/controls.tsx @@ -17,42 +17,50 @@ 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,42 +95,19 @@ function Preview(props: { {/* Right Column: Avatar/Name Preview */}
- Loading...
}> - {(account) => ( - - - - } - > -
- -
-
- )} + Loading...}> +
+ +
{/* Login Options - only show for guests */}
-
...or login to customize your profile
+
...or login to customize your profile
- - {/* */} @@ -190,28 +115,27 @@ 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 }): JSX.Element { + const info = solid(props.local.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 = () => { + const i = info(); + if (!i) return; // not possible, just for typescript + setAvatarClicks((prev) => prev + 1); - const oldAvatar = avatar(); while (true) { const newAvatar = Api.randomAvatar(); - if (newAvatar !== oldAvatar) { - setAvatar(newAvatar); + if (newAvatar !== i.avatar) { + props.local.info.set({ ...i, avatar: newAvatar }); break; } } @@ -219,11 +143,13 @@ function AnonymousPreview(props: { const handleRandomName = () => { setNameClicks((prev) => prev + 1); - const oldName = name(); + const i = info(); + if (!i) return; // not possible, just for typescript + while (true) { - const newName = randomName(); - if (newName !== oldName) { - setName(newName); + const newName = Api.randomName(); + if (newName !== i.name) { + props.local.info.set({ ...i, name: newName }); break; } } @@ -231,219 +157,68 @@ function AnonymousPreview(props: { return ( <> -

Guest Profile

- - {/* Avatar Preview */} -
-
-
- Avatar Preview -
- - {/* Display Name Overlay */} -
-
- {name()} +

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

+ + {/* Avatar/Video Preview */} +
+ +
+
+ +
-
-
- {/* Random Buttons */} -
-
- - +
+ + +
+ -
- - -
+
+
{canvas}
- - ); -} - -function AuthenticatedPreview(props: { - api: Api.Client; - room: string; - setInfo: (info: Info) => void; - account: Api.Account.Id; -}): 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 }); - }); - - 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}`); - } - }); - - return ( - - -
- Error: {error()} -
-
- - {(userInfo) => ( - <> -

Your Profile

- - {/* Avatar Preview */} -
-
-
- } - > - Avatar Preview - -
- - {/* Display Name Overlay */} -
-
- {userInfo().name} -
-
-
-
- - {/* Account Link */} - - - )} -
- -
Loading...
-
-
- ); -} - -/* -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 ( - + ); } -*/ 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