Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 4 additions & 5 deletions api/src/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof infoSchema>;

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<typeof infoSchema>;
export type Create = z.infer<typeof createSchema>;

export const table = sqliteTable("accounts", {
Expand Down
2 changes: 1 addition & 1 deletion api/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof accountIdSchema>;

// Use ZOD to validate the payload
Expand Down
48 changes: 39 additions & 9 deletions api/src/room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof nameSchema>;
Expand All @@ -20,7 +21,7 @@ export class Context {
}

// Returns the URL to join the room
async sign(room: Name, account: Account.Id): Promise<URL> {
async sign(room: Name, account: string): Promise<URL> {
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 });
Expand All @@ -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 });
},
);
9 changes: 9 additions & 0 deletions api/src/shared.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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.
Expand Down
1 change: 0 additions & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
54 changes: 31 additions & 23 deletions app/src/controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div
class="fixed bottom-0 left-0 right-0 flex items-end gap-3 p-3 text-shadow-lg text-xl pointer-events-none"
class="fixed bottom-0 left-0 right-0 flex items-end gap-3 p-4 text-shadow-lg text-xl pointer-events-none"
style={{ "z-index": "10" }}
role="toolbar"
aria-label="Media controls"
>
<Microphone audio={props.camera.audio} />
<Camera video={props.camera.video} room={props.room} />
<Screen video={props.screen.video} audio={props.screen.audio} room={props.room} />
<Chat broadcast={props.camera} />
<div style={{ "flex-grow": "1", "pointer-events": "none", "backdrop-filter": "none" }} />
<Volume room={props.room} />
<ClosedCaptions />
<Advanced />
<Fullscreen canvas={props.canvas} />
{/* Left group */}
<div class="flex gap-3">
<Microphone audio={props.local.camera.audio} />
<Camera video={props.local.camera.video} room={props.room} />
<Screen video={props.local.screen.video} audio={props.local.screen.audio} room={props.room} />
</div>

{/* Center group */}
<div class="flex-1 flex justify-center">
<Chat broadcast={props.local.camera} />
</div>

{/* Right group */}
<div class="flex gap-3">
<Volume room={props.room} />
<ClosedCaptions />
<Advanced />
<Fullscreen canvas={props.canvas} />
</div>
</div>
);
}

function Microphone(props: { audio: Publish.Audio }): JSX.Element {
export function Microphone(props: { audio: Publish.Audio; volume?: boolean }): JSX.Element {
const toggle = () => {
props.audio.enabled.set((prev) => !prev);
};
const root = solid(props.audio.root);

const [hover, setHover] = createSignal(false);
const opacity = Opacity(() => hover() && !!root());
const opacity = Opacity(() => {
return props.volume ? hover() && !!root() : false;
});

const volume = solid(props.audio.volume);
Settings.microphoneGain.subscribe((gain) => {
Expand Down Expand Up @@ -107,7 +115,7 @@ function Microphone(props: { audio: Publish.Audio }): JSX.Element {
);
}

function Camera(props: { video: Publish.Video; room: Room }): JSX.Element {
export function Camera(props: { video: Publish.Video; room?: Room }): JSX.Element {
const toggle = () => {
props.video.enabled.set((prev) => !prev);
};
Expand Down Expand Up @@ -164,7 +172,7 @@ function Screen(props: { video: Publish.Video; audio: Publish.Audio; room: Room
}

// Renders a volume meter in the background of an element.
function Visualize(props: { audio: Publish.Audio }): JSX.Element {
export function Visualize(props: { audio: Publish.Audio }): JSX.Element {
const [power, setPower] = createSignal<number | undefined>(undefined);
const [speaking, setSpeaking] = createSignal(false);

Expand Down Expand Up @@ -294,7 +302,7 @@ function Chat(props: { broadcast: Publish.Broadcast }): JSX.Element {
onInput={(e) => setMessage(e.currentTarget.value)}
aria-label="Chat message"
tabIndex={0}
class="w-full pointer-events-auto backdrop-blur-sm bg-transparent rounded py-1 px-2 outline-none"
class="w-full pointer-events-auto backdrop-blur-sm bg-transparent rounded py-1 px-2 outline-none text-center placeholder:text-center"
/>
</form>
);
Expand Down Expand Up @@ -434,7 +442,7 @@ function Advanced(): JSX.Element {
aria-label="Settings"
aria-expanded={showSettings()}
aria-haspopup="dialog"
class="hover:bg-gray-700 transition-all cursor-pointer p-2 backdrop-blur-sm bg-transparent rounded"
class="hover:bg-gray-700 transition-all cursor-pointer p-2 pointer-events-auto backdrop-blur-sm bg-transparent rounded"
>
<IconSettings />
</button>
Expand Down Expand Up @@ -514,4 +522,4 @@ function Opacity(fn: () => boolean): Accessor<number> {
});

return opacity;
}
}
2 changes: 1 addition & 1 deletion app/src/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export function PreviewRoom(props: { connection: Connection; path?: string; api:
fallback={
<div class="text-center py-12">
<h3 class="text-lg font-semibold mb-4">No one's here yet!</h3>
<p class="text-gray-500 text-sm">Be the first to join</p>
<p class="text-gray-500 text-sm">be the first, you trailblazer</p>
</div>
}
>
Expand Down
18 changes: 10 additions & 8 deletions app/src/room/broadcast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export type BroadcastProps = {
camera?: Publish.Broadcast;
screen?: Publish.Broadcast;

online?: boolean;
visible?: boolean;
};

// Catalog.Position but all fields are required.
Expand Down Expand Up @@ -178,7 +178,7 @@ export class Broadcast<T extends BroadcastSource = BroadcastSource> {
//targetPosition = Vector.create(0, 0); // -0.5 to 0.5, sent over the network
//targetScale = 1.0; // 1 is 100%

online: Signal<boolean>;
visible: Signal<boolean>;

// 1 when a video frame is fully rendered, 0 when their avatar is fully rendered.
transition = 0;
Expand All @@ -205,7 +205,7 @@ export class Broadcast<T extends BroadcastSource = BroadcastSource> {
this.source = source;
this.canvas = canvas;
this.sound = sound;
this.online = new Signal(props?.online ?? true);
this.visible = new Signal(props?.visible ?? true);

// Unless provided, start them at the center of the screen with a tiiiiny bit of variance to break ties.
const start = () => (Math.random() - 0.5) / 100;
Expand Down Expand Up @@ -234,7 +234,7 @@ export class Broadcast<T extends BroadcastSource = BroadcastSource> {

// Load the broadcaster's position from the network.
this.signals.effect((effect) => {
if (!effect.get(this.online)) {
if (!effect.get(this.visible)) {
// Change the target position to somewhere outside the screen.
this.targetPosition.set((prev) => {
const offscreen = Vector.create(prev.x, prev.y).normalize().mult(2);
Expand Down Expand Up @@ -286,15 +286,15 @@ export class Broadcast<T extends BroadcastSource = BroadcastSource> {
});

this.signals.effect((effect) => {
if (!effect.get(this.online)) return;
if (!effect.get(this.visible)) return;

const name = effect.get(this.name);
if (!name) return;
this.sound.say(this.#getJoinAnnouncement(name));
});

this.signals.effect((effect) => {
if (effect.get(this.online)) return;
if (effect.get(this.visible)) return;

const name = effect.get(this.name);
if (!name) return;
Expand Down Expand Up @@ -526,7 +526,7 @@ export class Broadcast<T extends BroadcastSource = BroadcastSource> {
renderLocator(now: DOMHighResTimeStamp, ctx: CanvasRenderingContext2D) {
if (!this.source.enabled.peek()) return;

if (!this.online.peek()) {
if (!this.visible.peek()) {
this.#locatorStart = undefined;
return;
}
Expand Down Expand Up @@ -593,9 +593,11 @@ export class Broadcast<T extends BroadcastSource = BroadcastSource> {

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();
}
}
Loading
Loading