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: 3 additions & 0 deletions api/.dev.vars
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
AUTH_SECRET="dev-secret-change-in-production-must-be-32-chars-min"
GOOGLE_CLIENT_SECRET="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"
DISCORD_CLIENT_SECRET="uJRHl-pmOFYHvrViHJdcs2slyTCnoJog"

# PEM but without newlines or the header/trailer
APPLE_CLIENT_SECRET="MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgRn72O3lOuG5Op8/2uGxkx3XlRcONRYpggQMGwCQd30KgCgYIKoZIzj0DAQehRANCAASw/36zB3txwM6YM70NwxSTqDVF1kRhFDIlAaI0Q7nKQNzx6kJ4Cgl7+iOApnCw7gbNuLQ4ULynk5QIB3lGUhGQ"

# Set by just after it has been generated.
RELAY_SECRET=""
2 changes: 1 addition & 1 deletion api/justfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ dev:
# Generate SSL certificates for API development
mkdir -p dev
mkcert -cert-file dev/api-cert.pem -key-file dev/api-key.pem localhost api.hang.dev
pnpm wrangler dev --host api.hang.dev --local-protocol https --https-key-path dev/api-key.pem --https-cert-path dev/api-cert.pem
pnpm wrangler dev --var "RELAY_SECRET:$(cat ../moq/rs/dev/root.jwk)" --host api.hang.dev --local-protocol https --https-key-path dev/api-key.pem --https-cert-path dev/api-cert.pem

build:
pnpm i
Expand Down
4 changes: 3 additions & 1 deletion api/src/fave.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ export const router = rpc
created_at: row.created_at,
}));

return c.json({ favorites });
// Generate a token that allows subscribing to all favorited rooms
const token = await ctx.room.signPreview(favorites.map((f) => f.room));
return c.json({ favorites, token });
})
// Check if a room is favorited
.get("/:room", rpc.withParam(z.object({ room: Room.nameSchema })), Auth.required, async (c) => {
Expand Down
20 changes: 14 additions & 6 deletions api/src/room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,30 @@ export type Name = z.infer<typeof nameSchema>;
// TODO: Add proper type for Env.MOQ_JWK in worker-configuration.d.ts if needed

export class Context {
#key?: Token.Key;
#key: Token.Key;
#env: Env;

constructor(env: Env) {
this.#key = env.RELAY_SECRET ? Token.load(env.RELAY_SECRET) : undefined;
this.#key = Token.load(env.RELAY_SECRET);
this.#env = env;
}

// Returns the URL to join the room
async sign(room: Name, account: Account.Id): Promise<URL> {
const root = `${this.#env.RELAY_PREFIX}/${room}`;
if (!this.#key) {
return new URL(root, this.#env.RELAY_URL);
}
// TODO add a field to force publishing, preventing someone from lurking.
const token = await Token.sign(this.#key, { root, get: "", put: account });
return new URL(`${root}/?jwt=${token}`, this.#env.RELAY_URL);
}

const token = await Token.sign(this.#key, { root, sub: "", pub: account });
// Returns a URL to preview a list of rooms
async signPreview(rooms: Name[]): Promise<URL> {
const root = this.#env.RELAY_PREFIX;
const token = await Token.sign(this.#key, {
root,
get: rooms,
// no put permission
});
return new URL(`${root}/?jwt=${token}`, this.#env.RELAY_URL);
}
}
Expand Down
6 changes: 3 additions & 3 deletions api/worker-configuration.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable */
// Generated by Wrangler by running `wrangler types` (hash: 0fe5bdade492aa84f191db1d2d21b3ec)
// Generated by Wrangler by running `wrangler types` (hash: a31fbac4d97e8636af353491409d82e0)
// Runtime types generated with workerd@1.20250709.0 2025-07-09
declare namespace Cloudflare {
interface Env {
Expand All @@ -11,8 +11,8 @@ declare namespace Cloudflare {
APPLE_TEAM_ID: "D7D5SDDB5Z";
APPLE_KEY_ID: "964W676BWT" | "7BQ2ZQY943" | "Q7CN38JH2Z";
R2_PUBLIC_URL: "https://api.hang.dev:3000/public" | "https://public.hang.now" | "https://public.hang.live";
RELAY_URL: "http://localhost:4443" | "https://relay.quic.video";
RELAY_PREFIX: "anon" | "staging" | "live";
RELAY_URL: "http://localhost:4443" | "https://relay.moq.dev";
RELAY_PREFIX: "demo" | "staging" | "live";
AUTH_SECRET: string;
GOOGLE_CLIENT_SECRET: string;
DISCORD_CLIENT_SECRET: string;
Expand Down
8 changes: 4 additions & 4 deletions api/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@
"R2_PUBLIC_URL": "https://api.hang.dev:3000/public",

"RELAY_URL": "http://localhost:4443",
"RELAY_PREFIX": "anon"
// "RELAY_SECRET": <stored in .dev.vars>
"RELAY_PREFIX": "demo"
// "RELAY_SECRET": <stored in .dev.vars, set dynamically>
},

// Staging environment overrides
Expand Down Expand Up @@ -104,7 +104,7 @@

"R2_PUBLIC_URL": "https://public.hang.now",

"RELAY_URL": "https://relay.quic.video",
"RELAY_URL": "https://relay.moq.dev",
"RELAY_PREFIX": "staging"
// "RELAY_SECRET": npx wrangler secret put RELAY_SECRET --env staging
}
Expand Down Expand Up @@ -148,7 +148,7 @@

"R2_PUBLIC_URL": "https://public.hang.live",

"RELAY_URL": "https://relay.quic.video",
"RELAY_URL": "https://relay.moq.dev",
"RELAY_PREFIX": "live"
// "RELAY_SECRET": npx wrangler secret put RELAY_SECRET --env live
}
Expand Down
2 changes: 1 addition & 1 deletion app/justfile
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ fix:
pnpm exec eslint . --fix

# Make sure the JS packages are not vulnerable
pnpm exec pnpm audit --fix
# pnpm exec pnpm audit --fix

# Run any CI tests
test:
Expand Down
2 changes: 1 addition & 1 deletion app/src/about.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ export function About(): JSX.Element {
<div class="p-4 h-128">{canvas}</div>
<p class="px-4">
For the nerds in the audience, this site uses bleeding edge web technologies. We're using{" "}
<a href="https://quic.video">Media over QUIC</a> which is an{" "}
<a href="https://moq.dev">Media over QUIC</a> which is an{" "}
<a href="https://github.com/kixelated/moq">open source</a> WebRTC alternative. This ain't your usual
Zoom clone.
</p>
Expand Down
65 changes: 41 additions & 24 deletions app/src/fave.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as Api from "@hang/api/client";
import { Connection } from "@kixelated/hang";
import { useNavigate } from "@solidjs/router";
import { createMemo, createResource, createSignal, For, Match, onMount, Show, Switch } from "solid-js";
import { createMemo, createResource, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js";
import type { JSX } from "solid-js/jsx-runtime";
import IconDelete from "~icons/mdi/delete";
import IconHeart from "~icons/mdi/heart";
Expand All @@ -12,6 +13,7 @@ import Dialog from "./components/dialog";
import Gradient from "./components/gradient";
import Login from "./components/login";
import Layout from "./layout/web";
import { PreviewRoomCompact } from "./preview";
import * as Random from "./util/random";

export function Fave(props: { api: Api.Client }): JSX.Element {
Expand All @@ -30,13 +32,18 @@ export function Fave(props: { api: Api.Client }): JSX.Element {
return () => clearInterval(interval);
});

// Connection for live previews
const connection = new Connection();
onCleanup(() => connection.close());

const [favorites, { refetch }] = createResource(async () => {
if (!props.api.authenticated()) return null;

try {
const response = await props.api.routes.fave.all.$get();
if (response.ok) {
const data = await response.json();
connection.url.set(new URL(data.token));
return data.favorites;
}
} catch (error) {
Expand Down Expand Up @@ -72,15 +79,6 @@ export function Fave(props: { api: Api.Client }): JSX.Element {
}
};

// Determine how many favorites to show initially
const visibleFavorites = createMemo(() => {
const favs = favorites() || [];
if (showMore() || favs.length <= 6) {
return favs;
}
return favs.slice(0, 6);
});

return (
<Layout>
<div class="max-w-7xl p-4">
Expand Down Expand Up @@ -133,12 +131,14 @@ export function Fave(props: { api: Api.Client }): JSX.Element {
{(favs) => (
<>
<div class="space-y-3">
<For each={visibleFavorites()}>
<For each={favs()}>
{(favorite) => (
<FavoriteRoom
room={favorite.room}
createdAt={favorite.created_at}
onRemove={handleRemove}
connection={connection}
api={props.api}
/>
)}
</For>
Expand Down Expand Up @@ -217,8 +217,15 @@ export function Fave(props: { api: Api.Client }): JSX.Element {
);
}

function FavoriteRoom(props: { room: string; createdAt: number; onRemove: (room: string) => void }): JSX.Element {
function FavoriteRoom(props: {
room: string;
createdAt: number;
onRemove: (room: string) => void;
connection: Connection;
api: Api.Client;
}): JSX.Element {
const [removing, setRemoving] = createSignal(false);
const [memberCount, setMemberCount] = createSignal(0);

const handleRemove = async (e: MouseEvent) => {
e.preventDefault();
Expand All @@ -229,22 +236,32 @@ function FavoriteRoom(props: { room: string; createdAt: number; onRemove: (room:

return (
<div
class="group relative bg-gray-800/30 rounded-xl p-4 hover:bg-gray-800/50 transition-all flex items-center justify-between"
class="group relative bg-gray-800/30 rounded-xl px-5 py-3 hover:bg-gray-800/50 transition-all flex flex-col gap-3"
classList={{
"opacity-50 pointer-events-none": removing(),
}}
>
<a href={`/@${props.room}`} class="flex-1 min-w-0 cursor-pointer">
<h3 class="font-semibold text-lg truncate">{props.room}</h3>
</a>
<button
type="button"
onClick={handleRemove}
class="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-red-500/20 rounded text-gray-400 hover:text-red-400 cursor-pointer ml-2"
title="Remove from favorites"
>
<IconDelete class="w-4 h-4" />
</button>
<div class="flex items-center justify-between">
<a href={`/@${props.room}`} class="flex-1 min-w-0 truncate cursor-pointer">
<h3 class="font-semibold text-lg truncate">{props.room}</h3>
</a>
<div class="relative flex items-center">
<Show when={memberCount() > 0}>
<span class="text-gray-400 font-semibold absolute inset-0 flex items-center justify-center group-hover:opacity-0 transition-opacity">
{memberCount()}
</span>
</Show>
<button
type="button"
onClick={handleRemove}
class="opacity-0 group-hover:opacity-100 relative z-10 transition-all p-1 bg-gray-800 hover:bg-red-500/20 rounded text-gray-400 hover:text-red-400 cursor-pointer"
title="Remove from favorites"
>
<IconDelete class="w-4 h-4" />
</button>
</div>
</div>
<PreviewRoomCompact connection={props.connection} api={props.api} onMemberCountChange={setMemberCount} />
</div>
);
}
2 changes: 1 addition & 1 deletion app/src/layout/web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { Logo } from "./logo";

export default function Web(props: { children: JSX.Element }) {
return (
<div class="p-4 mx-auto w-full flex flex-col max-w-[900px]">
<div class="p-4 mx-auto w-full flex flex-col max-w-[1100px]">
<header class="flex items-center justify-between mb-4">
<Logo />
<div id="support" />
Expand Down
Loading
Loading