feat(meetings): Discord-style staff voice/video calls under Messages

Backend: meeting_rooms table + /api/meetings (list/create/delete, org-scoped),
and WebRTC mesh signaling over the existing authed Socket.io (call:join with a
≤4 cap and org authorization, call:signal relay, peer-joined/left, disconnect
cleanup). Migration 0024.

Frontend: Messages nav is now expandable (Inbox + Meetings); new
/messages/meetings page with a room list and a Discord-style call UI — a
useWebRtcMesh hook (camera/mic, screen share via replaceTrack, ≤4 mesh) and a
tile grid with a mic/camera/screen/leave control bar. New lib/meetings + i18n.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-19 20:48:26 +03:00
parent 0fa2802723
commit c88b674196
16 changed files with 4854 additions and 1 deletions
+11
View File
@@ -0,0 +1,11 @@
CREATE TABLE "meeting_rooms" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" text NOT NULL,
"name" text NOT NULL,
"created_by" text,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "meeting_rooms" ADD CONSTRAINT "meeting_rooms_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "meeting_rooms" ADD CONSTRAINT "meeting_rooms_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "meeting_rooms_org_idx" ON "meeting_rooms" USING btree ("organization_id");
File diff suppressed because it is too large Load Diff
+7
View File
@@ -169,6 +169,13 @@
"when": 1781889806890,
"tag": "0023_panoramic_punisher",
"breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1781891060177,
"tag": "0024_skinny_iron_fist",
"breakpoints": true
}
]
}
+1
View File
@@ -17,3 +17,4 @@ export * from "./org-ai-policy.js";
export * from "./attachments.js";
export * from "./integrations.js";
export * from "./staff-profile.js";
export * from "./meetings.js";
+23
View File
@@ -0,0 +1,23 @@
import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { organization, user } from "./auth.js";
// A persistent staff meeting room (Discord-style voice/video channel), scoped to
// a clinic. Rooms are long-lived "channels"; the live call (participants, media)
// is ephemeral and lives only in the realtime layer — nothing about an in-call
// session is persisted here.
export const meetingRooms = pgTable(
"meeting_rooms",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
name: text("name").notNull(),
createdBy: text("created_by").references(() => user.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => [index("meeting_rooms_org_idx").on(table.organizationId)],
);
+2
View File
@@ -19,6 +19,7 @@ import { dispensesRouter } from "./routes/dispenses.js";
import { integrationsRouter } from "./routes/integrations.js";
import { inventoryRouter } from "./routes/inventory.js";
import { invoicesRouter } from "./routes/invoices.js";
import { meetingsRouter } from "./routes/meetings.js";
import { notesRouter } from "./routes/notes.js";
import { notificationsRouter } from "./routes/notifications.js";
import { patientsRouter } from "./routes/patients.js";
@@ -77,6 +78,7 @@ app.use("/api/staff", staffRouter);
app.use("/api/activity", activityRouter);
app.use("/api/analytics", analyticsRouter);
app.use("/api/conversations", conversationsRouter);
app.use("/api/meetings", meetingsRouter);
app.use("/api/notifications", notificationsRouter);
app.use("/api/settings", settingsRouter);
app.use("/api/ai", aiRouter);
+86
View File
@@ -5,6 +5,7 @@ import { Server, type Socket } from "socket.io";
import { auth } from "./auth.js";
import { env } from "./env.js";
import * as meetings from "./services/meetings.js";
import * as messaging from "./services/messaging.js";
import { createNotification } from "./services/notifications.js";
import type { MessageAttachment } from "./types/messaging.js";
@@ -13,6 +14,16 @@ let io: Server | null = null;
const userRoom = (userId: string) => `user:${userId}`;
const convRoom = (conversationId: string) => `conv:${conversationId}`;
const callRoom = (roomId: string) => `call:${roomId}`;
// Mesh WebRTC tops out around four peers (each sends its stream to every other);
// past that the room is closed to new joiners.
const MAX_CALL_PEERS = 4;
// Live call participants per room: roomId -> (socketId -> peer info). Ephemeral —
// nothing about an in-call session is persisted.
type CallPeer = { socketId: string; userId: string; userName: string };
const callParticipants = new Map<string, Map<string, CallPeer>>();
// Push helpers other modules can call without importing socket.io directly.
export function emitToUser(userId: string, event: string, data: unknown): void {
@@ -133,6 +144,81 @@ export function initRealtime(httpServer: HttpServer): Server {
await messaging.markRead(orgId, userId, conversationId).catch(() => {});
}
});
// --- Staff calls (WebRTC mesh signaling) -------------------------------
// The server only relays SDP/ICE between peers and tracks who's in a room;
// media flows peer-to-peer and never touches the server. Rooms are
// org-scoped: a join is authorized against the clinic's meeting_rooms.
const joinedCallRooms = new Set<string>();
const leaveCall = (roomId: string) => {
if (!joinedCallRooms.has(roomId)) return;
joinedCallRooms.delete(roomId);
socket.leave(callRoom(roomId));
callParticipants.get(roomId)?.delete(socket.id);
if (callParticipants.get(roomId)?.size === 0) {
callParticipants.delete(roomId);
}
socket.to(callRoom(roomId)).emit("call:peer-left", { socketId: socket.id });
};
socket.on("call:join", async (roomId: unknown, ack?: Ack) => {
try {
const id = String(roomId ?? "");
if (!(id && orgId && (await meetings.roomExists(orgId, id)))) {
ack?.({ ok: false, reason: "not_found" });
return;
}
const peers = callParticipants.get(id) ?? new Map<string, CallPeer>();
if (!peers.has(socket.id) && peers.size >= MAX_CALL_PEERS) {
ack?.({ ok: false, reason: "full" });
return;
}
socket.join(callRoom(id));
joinedCallRooms.add(id);
const me: CallPeer = { socketId: socket.id, userId, userName };
peers.set(socket.id, me);
callParticipants.set(id, peers);
// Tell existing peers a newcomer arrived; the newcomer initiates offers.
socket.to(callRoom(id)).emit("call:peer-joined", me);
// Reply with the peers already present (excluding self).
ack?.({
ok: true,
peers: [...peers.values()].filter((p) => p.socketId !== socket.id),
});
} catch {
ack?.({ ok: false, reason: "error" });
}
});
// Relay an SDP offer/answer or ICE candidate to a specific peer socket.
socket.on(
"call:signal",
(payload: { to?: string; signal?: unknown }) => {
const to = String(payload?.to ?? "");
if (!to) return;
io?.to(to).emit("call:signal", {
from: socket.id,
signal: payload.signal,
});
},
);
socket.on("call:leave", (roomId: unknown) => {
leaveCall(String(roomId ?? ""));
});
socket.on("disconnect", () => {
for (const roomId of joinedCallRooms) {
callParticipants.get(roomId)?.delete(socket.id);
if (callParticipants.get(roomId)?.size === 0) {
callParticipants.delete(roomId);
}
socket
.to(callRoom(roomId))
.emit("call:peer-left", { socketId: socket.id });
}
});
});
return io;
+53
View File
@@ -0,0 +1,53 @@
import { Router } from "express";
import { z } from "zod";
import { HttpError } from "../lib/http-error.js";
import { requireAuth, requireOrg } from "../middleware/auth.js";
import * as meetings from "../services/meetings.js";
export const meetingsRouter = Router();
// Staff meeting rooms (Discord-style voice/video channels), scoped to the active
// clinic. Any clinic member can list, create, and join rooms — calls are
// staff-to-staff. The live call (media + participants) is handled over Socket.io
// (see src/realtime.ts); these endpoints only manage the persistent room list.
meetingsRouter.use(requireAuth, requireOrg);
meetingsRouter.get("/", async (req, res, next) => {
try {
res.json(await meetings.listRooms(req.organizationId!));
} catch (err) {
next(err);
}
});
const createSchema = z.object({
name: z.string().trim().min(1).max(80),
});
meetingsRouter.post("/", async (req, res, next) => {
try {
const { name } = createSchema.parse(req.body);
const room = await meetings.createRoom(
req.organizationId!,
name,
req.user!.id,
);
res.status(201).json(room);
} catch (err) {
next(err);
}
});
meetingsRouter.delete("/:id", async (req, res, next) => {
try {
const ok = await meetings.deleteRoom(
req.organizationId!,
String(req.params.id ?? ""),
);
if (!ok) throw new HttpError(404, "Room not found.");
res.status(204).end();
} catch (err) {
next(err);
}
});
+69
View File
@@ -0,0 +1,69 @@
import { and, asc, eq } from "drizzle-orm";
import { db } from "../db/index.js";
import { meetingRooms } from "../db/schema/meetings.js";
export type MeetingRoom = {
id: string;
name: string;
createdAt: string;
};
export async function listRooms(orgId: string): Promise<MeetingRoom[]> {
const rows = await db
.select({
id: meetingRooms.id,
name: meetingRooms.name,
createdAt: meetingRooms.createdAt,
})
.from(meetingRooms)
.where(eq(meetingRooms.organizationId, orgId))
.orderBy(asc(meetingRooms.createdAt));
return rows.map((r) => ({
id: r.id,
name: r.name,
createdAt: r.createdAt.toISOString(),
}));
}
export async function createRoom(
orgId: string,
name: string,
createdBy: string,
): Promise<MeetingRoom> {
const [row] = await db
.insert(meetingRooms)
.values({ organizationId: orgId, name, createdBy })
.returning({
id: meetingRooms.id,
name: meetingRooms.name,
createdAt: meetingRooms.createdAt,
});
return {
id: row!.id,
name: row!.name,
createdAt: row!.createdAt.toISOString(),
};
}
export async function deleteRoom(orgId: string, roomId: string): Promise<boolean> {
const deleted = await db
.delete(meetingRooms)
.where(
and(eq(meetingRooms.organizationId, orgId), eq(meetingRooms.id, roomId)),
)
.returning({ id: meetingRooms.id });
return deleted.length > 0;
}
// Whether a room exists within the given clinic — used by the realtime layer to
// authorize a call:join before relaying any signaling.
export async function roomExists(orgId: string, roomId: string): Promise<boolean> {
const [row] = await db
.select({ id: meetingRooms.id })
.from(meetingRooms)
.where(
and(eq(meetingRooms.organizationId, orgId), eq(meetingRooms.id, roomId)),
);
return Boolean(row);
}
@@ -0,0 +1,10 @@
import { MeetingsView } from "@/components/meetings/meetings-view";
import { SidebarInset } from "@/components/ui/sidebar";
export default function MeetingsPage() {
return (
<SidebarInset className="flex flex-1 flex-col overflow-hidden">
<MeetingsView />
</SidebarInset>
);
}
@@ -0,0 +1,192 @@
"use client";
import {
Mic,
MicOff,
MonitorUp,
PhoneOff,
Video as VideoIcon,
VideoOff,
} from "lucide-react";
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useWebRtcMesh } from "@/components/meetings/use-webrtc-mesh";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase();
return (parts[0]![0]! + parts.at(-1)![0]!).toUpperCase();
}
// One participant tile: shows their video, or an avatar when the camera is off.
function VideoTile({
stream,
label,
muted,
showVideo,
}: {
stream: MediaStream | null;
label: string;
muted?: boolean;
showVideo: boolean;
}) {
const ref = useRef<HTMLVideoElement>(null);
useEffect(() => {
if (ref.current && ref.current.srcObject !== stream) {
ref.current.srcObject = stream;
}
}, [stream]);
return (
<div className="relative aspect-video overflow-hidden rounded-2xl border bg-muted">
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
autoPlay
className={cn("size-full object-cover", !showVideo && "invisible")}
muted={muted}
playsInline
ref={ref}
/>
{!showVideo && (
<div className="absolute inset-0 flex items-center justify-center">
<Avatar className="size-16">
<AvatarFallback className="text-lg">{initials(label)}</AvatarFallback>
</Avatar>
</div>
)}
<span className="absolute bottom-2 left-2 rounded-full bg-background/70 px-2 py-0.5 text-foreground text-xs backdrop-blur">
{label}
</span>
</div>
);
}
export function MeetingRoom({
roomId,
roomName,
selfName,
onLeave,
}: {
roomId: string;
roomName: string;
selfName: string;
onLeave: () => void;
}) {
const { t } = useTranslation();
const {
localStream,
peers,
joinState,
micOn,
camOn,
screenOn,
toggleMic,
toggleCam,
toggleScreen,
maxPeers,
} = useWebRtcMesh(roomId);
const leave = () => {
onLeave();
};
return (
<div className="flex h-full flex-col gap-4">
<div className="flex items-center justify-between gap-3 rounded-2xl border bg-card/30 px-4 py-3">
<div className="flex min-w-0 flex-col">
<span className="truncate font-medium text-foreground text-sm">
{roomName}
</span>
<span className="text-muted-foreground text-xs">
{joinState === "joined"
? t("meetings.inCall", { count: peers.length + 1 })
: joinState === "joining"
? t("meetings.connecting")
: joinState === "full"
? t("meetings.roomFull", { max: maxPeers })
: joinState === "error"
? t("meetings.callError")
: ""}
</span>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border bg-card/30 p-4">
{joinState === "full" ? (
<div className="flex h-full items-center justify-center text-center text-muted-foreground text-sm">
{t("meetings.roomFull", { max: maxPeers })}
</div>
) : joinState === "error" ? (
<div className="flex h-full items-center justify-center text-center text-muted-foreground text-sm">
{t("meetings.callError")}
</div>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<VideoTile
label={t("meetings.you")}
muted
showVideo={camOn && Boolean(localStream)}
stream={localStream}
/>
{peers.map((p) => (
<VideoTile
key={p.socketId}
label={p.userName || selfName}
showVideo={Boolean(p.stream)}
stream={p.stream}
/>
))}
</div>
)}
</div>
{/* Discord-style control bar */}
<div className="flex items-center justify-center gap-2 rounded-full border bg-card/60 p-2 backdrop-blur">
<Button
aria-label={micOn ? t("meetings.muteMic") : t("meetings.unmuteMic")}
className="size-11 rounded-full"
onClick={toggleMic}
size="icon"
variant={micOn ? "secondary" : "outline"}
>
{micOn ? <Mic className="size-5" /> : <MicOff className="size-5" />}
</Button>
<Button
aria-label={camOn ? t("meetings.stopVideo") : t("meetings.startVideo")}
className="size-11 rounded-full"
onClick={toggleCam}
size="icon"
variant={camOn ? "secondary" : "outline"}
>
{camOn ? (
<VideoIcon className="size-5" />
) : (
<VideoOff className="size-5" />
)}
</Button>
<Button
aria-label={t("meetings.shareScreen")}
className="size-11 rounded-full"
onClick={() => void toggleScreen()}
size="icon"
variant={screenOn ? "default" : "secondary"}
>
<MonitorUp className="size-5" />
</Button>
<Button
aria-label={t("meetings.leave")}
className="size-11 rounded-full"
onClick={leave}
size="icon"
variant="destructive"
>
<PhoneOff className="size-5" />
</Button>
</div>
</div>
);
}
@@ -0,0 +1,179 @@
"use client";
import { Plus, Video } from "lucide-react";
import { type FormEvent, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { MeetingRoom } from "@/components/meetings/meeting-room";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
} from "@/components/ui/dialog";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import {
createMeetingRoom,
listMeetingRooms,
type MeetingRoom as Room,
} from "@/lib/meetings";
import { notify } from "@/lib/toast";
import { cn } from "@/lib/utils";
export function MeetingsView() {
const { t } = useTranslation();
const { data: session } = authClient.useSession();
const selfName = session?.user?.name ?? "";
const [rooms, setRooms] = useState<Room[]>([]);
const [activeRoom, setActiveRoom] = useState<Room | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [newName, setNewName] = useState("");
const [creating, setCreating] = useState(false);
useEffect(() => {
listMeetingRooms()
.then(setRooms)
.catch(() => {
/* api-client redirects on 401 */
});
}, []);
const createRoom = async (event: FormEvent) => {
event.preventDefault();
const name = newName.trim();
if (!name || creating) return;
setCreating(true);
try {
const room = await createMeetingRoom(name);
setRooms((prev) => [...prev, room]);
setCreateOpen(false);
setNewName("");
setActiveRoom(room);
} catch {
notify.error(t("meetings.createFailedTitle"), t("meetings.createFailedBody"));
} finally {
setCreating(false);
}
};
return (
<div className="flex h-full w-full gap-4 p-4">
{/* Left: room (channel) list */}
<aside className="flex w-64 shrink-0 flex-col overflow-hidden rounded-2xl border bg-card/30">
<div className="flex items-center justify-between gap-2 border-border border-b px-4 py-3">
<h1 className="font-semibold text-base tracking-tight">
{t("meetings.rooms")}
</h1>
<Button
aria-label={t("meetings.newRoom")}
onClick={() => setCreateOpen(true)}
size="icon-sm"
type="button"
variant="ghost"
>
<Plus className="size-4" />
</Button>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-2">
{rooms.length === 0 ? (
<p className="px-2 py-1.5 text-muted-foreground text-sm">
{t("meetings.noRooms")}
</p>
) : (
rooms.map((room) => (
<button
className={cn(
"flex w-full items-center gap-2.5 rounded-lg px-2 py-2 text-left transition-colors hover:bg-accent/50",
activeRoom?.id === room.id && "bg-accent hover:bg-accent",
)}
key={room.id}
onClick={() => setActiveRoom(room)}
type="button"
>
<Video className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-foreground text-sm">
{room.name}
</span>
</button>
))
)}
</div>
</aside>
{/* Right: the live call, or a lobby */}
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{activeRoom ? (
<MeetingRoom
key={activeRoom.id}
onLeave={() => setActiveRoom(null)}
roomId={activeRoom.id}
roomName={activeRoom.name}
selfName={selfName}
/>
) : (
<div className="flex flex-1 items-center justify-center rounded-2xl border bg-card/30">
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<Video />
</EmptyMedia>
<EmptyTitle>{t("meetings.emptyTitle")}</EmptyTitle>
<EmptyDescription>
{t("meetings.emptyDescription")}
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button onClick={() => setCreateOpen(true)} type="button" variant="outline">
<Plus className="size-4" />
{t("meetings.newRoom")}
</Button>
</EmptyContent>
</Empty>
</div>
)}
</div>
<Dialog onOpenChange={setCreateOpen} open={createOpen}>
<DialogPopup className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("meetings.newRoom")}</DialogTitle>
<DialogDescription>{t("meetings.newRoomDescription")}</DialogDescription>
</DialogHeader>
<form className="contents" onSubmit={createRoom}>
<DialogPanel>
<Input
aria-label={t("meetings.roomNameLabel")}
onChange={(e) => setNewName(e.target.value)}
placeholder={t("meetings.roomNamePlaceholder")}
value={newName}
/>
</DialogPanel>
<DialogFooter>
<DialogClose render={<Button type="button" variant="outline" />}>
{t("meetings.cancel")}
</DialogClose>
<Button disabled={!newName.trim() || creating} type="submit">
{creating ? t("meetings.creating") : t("meetings.create")}
</Button>
</DialogFooter>
</form>
</DialogPopup>
</Dialog>
</div>
);
}
@@ -0,0 +1,251 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { type CallPeer, MAX_CALL_PEERS } from "@/lib/meetings";
import { getSocket } from "@/lib/socket";
// Public STUN keeps this dependency-free for clinics on the same network or with
// simple NATs. Cross-network/symmetric-NAT calls would also need a TURN server —
// a deployment add-on, out of scope for the mesh MVP.
const ICE_SERVERS: RTCIceServer[] = [
{ urls: "stun:stun.l.google.com:19302" },
];
export type RemotePeer = {
socketId: string;
userName: string;
stream: MediaStream | null;
};
export type JoinState = "idle" | "joining" | "joined" | "full" | "error";
type Signal = {
sdp?: RTCSessionDescriptionInit;
candidate?: RTCIceCandidateInit;
};
// A peer-to-peer mesh call over the shared Socket.io connection: each participant
// holds one RTCPeerConnection per other participant. The newcomer initiates
// offers to everyone already in the room; existing peers answer. Media never
// touches the server — it only relays SDP/ICE.
export function useWebRtcMesh(roomId: string | null) {
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
const [peers, setPeers] = useState<RemotePeer[]>([]);
const [joinState, setJoinState] = useState<JoinState>("idle");
const [micOn, setMicOn] = useState(true);
const [camOn, setCamOn] = useState(true);
const [screenOn, setScreenOn] = useState(false);
const pcsRef = useRef(new Map<string, RTCPeerConnection>());
const localStreamRef = useRef<MediaStream | null>(null);
const cameraTrackRef = useRef<MediaStreamTrack | null>(null);
const screenStreamRef = useRef<MediaStream | null>(null);
const upsertPeer = useCallback((peer: Partial<RemotePeer> & { socketId: string }) => {
setPeers((prev) => {
const existing = prev.find((p) => p.socketId === peer.socketId);
if (existing) {
return prev.map((p) =>
p.socketId === peer.socketId ? { ...p, ...peer } : p,
);
}
return [
...prev,
{ socketId: peer.socketId, userName: peer.userName ?? "", stream: peer.stream ?? null },
];
});
}, []);
const removePeer = useCallback((socketId: string) => {
pcsRef.current.get(socketId)?.close();
pcsRef.current.delete(socketId);
setPeers((prev) => prev.filter((p) => p.socketId !== socketId));
}, []);
useEffect(() => {
if (!roomId) return;
const socket = getSocket();
const pcs = pcsRef.current;
let cancelled = false;
const createPc = (peerId: string, userName: string): RTCPeerConnection => {
const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });
for (const track of localStreamRef.current?.getTracks() ?? []) {
pc.addTrack(track, localStreamRef.current!);
}
pc.onicecandidate = (e) => {
if (e.candidate) {
socket.emit("call:signal", {
to: peerId,
signal: { candidate: e.candidate.toJSON() },
});
}
};
pc.ontrack = (e) => {
upsertPeer({ socketId: peerId, userName, stream: e.streams[0] ?? null });
};
pcs.set(peerId, pc);
upsertPeer({ socketId: peerId, userName });
return pc;
};
const onPeerJoined = (peer: CallPeer) => {
// The newcomer offers to us; just record their name for now.
upsertPeer({ socketId: peer.socketId, userName: peer.userName });
};
const onSignal = async ({ from, signal }: { from: string; signal: Signal }) => {
let pc = pcs.get(from);
try {
if (signal.sdp) {
if (!pc) pc = createPc(from, "");
await pc.setRemoteDescription(signal.sdp);
if (signal.sdp.type === "offer") {
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
socket.emit("call:signal", { to: from, signal: { sdp: answer } });
}
} else if (signal.candidate && pc) {
await pc.addIceCandidate(signal.candidate).catch(() => {});
}
} catch {
/* a failed negotiation drops just this peer link */
}
};
const onPeerLeft = ({ socketId }: { socketId: string }) => removePeer(socketId);
const start = async () => {
setJoinState("joining");
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: true,
});
if (cancelled) {
for (const t of stream.getTracks()) t.stop();
return;
}
localStreamRef.current = stream;
cameraTrackRef.current = stream.getVideoTracks()[0] ?? null;
setLocalStream(stream);
socket.on("call:peer-joined", onPeerJoined);
socket.on("call:signal", onSignal);
socket.on("call:peer-left", onPeerLeft);
socket.emit(
"call:join",
roomId,
(res: { ok: boolean; reason?: string; peers?: CallPeer[] }) => {
if (cancelled) return;
if (!res?.ok) {
setJoinState(res?.reason === "full" ? "full" : "error");
return;
}
setJoinState("joined");
// We're the newcomer: initiate an offer to each existing peer.
for (const peer of res.peers ?? []) {
const pc = createPc(peer.socketId, peer.userName);
pc.createOffer()
.then((offer) => pc.setLocalDescription(offer).then(() => offer))
.then((offer) =>
socket.emit("call:signal", {
to: peer.socketId,
signal: { sdp: offer },
}),
)
.catch(() => {});
}
},
);
} catch {
if (!cancelled) setJoinState("error");
}
};
void start();
return () => {
cancelled = true;
socket.emit("call:leave", roomId);
socket.off("call:peer-joined", onPeerJoined);
socket.off("call:signal", onSignal);
socket.off("call:peer-left", onPeerLeft);
for (const pc of pcs.values()) pc.close();
pcs.clear();
for (const t of localStreamRef.current?.getTracks() ?? []) t.stop();
for (const t of screenStreamRef.current?.getTracks() ?? []) t.stop();
localStreamRef.current = null;
screenStreamRef.current = null;
setPeers([]);
setLocalStream(null);
setJoinState("idle");
};
}, [roomId, upsertPeer, removePeer]);
const toggleMic = useCallback(() => {
const track = localStreamRef.current?.getAudioTracks()[0];
if (!track) return;
track.enabled = !track.enabled;
setMicOn(track.enabled);
}, []);
const toggleCam = useCallback(() => {
const track = localStreamRef.current?.getVideoTracks()[0];
if (!track) return;
track.enabled = !track.enabled;
setCamOn(track.enabled);
}, []);
// Swap the outgoing video track on every peer connection between camera and
// screen share via sender.replaceTrack (no renegotiation needed).
const replaceVideoTrack = useCallback((track: MediaStreamTrack) => {
for (const pc of pcsRef.current.values()) {
const sender = pc.getSenders().find((s) => s.track?.kind === "video");
void sender?.replaceTrack(track);
}
}, []);
const toggleScreen = useCallback(async () => {
if (screenOn) {
const cam = cameraTrackRef.current;
if (cam) replaceVideoTrack(cam);
for (const t of screenStreamRef.current?.getTracks() ?? []) t.stop();
screenStreamRef.current = null;
setScreenOn(false);
return;
}
try {
const display = await navigator.mediaDevices.getDisplayMedia({ video: true });
const screenTrack = display.getVideoTracks()[0];
if (!screenTrack) return;
screenStreamRef.current = display;
replaceVideoTrack(screenTrack);
setScreenOn(true);
// Revert to camera when the user stops sharing from the browser UI.
screenTrack.onended = () => {
const cam = cameraTrackRef.current;
if (cam) replaceVideoTrack(cam);
screenStreamRef.current = null;
setScreenOn(false);
};
} catch {
/* user dismissed the picker */
}
}, [screenOn, replaceVideoTrack]);
return {
localStream,
peers,
joinState,
micOn,
camOn,
screenOn,
toggleMic,
toggleCam,
toggleScreen,
maxPeers: MAX_CALL_PEERS,
};
}
@@ -713,6 +713,32 @@
"updateFailedBody": "Please try again."
}
},
"meetings": {
"rooms": "Rooms",
"newRoom": "New room",
"newRoomDescription": "Create a voice/video room your team can join.",
"roomNameLabel": "Room name",
"roomNamePlaceholder": "e.g. Morning huddle",
"noRooms": "No rooms yet. Create one to start a call.",
"create": "Create",
"creating": "Creating…",
"cancel": "Cancel",
"createFailedTitle": "Couldn't create room",
"createFailedBody": "Please try again.",
"emptyTitle": "No room selected",
"emptyDescription": "Pick a room to join the call, or create a new one.",
"you": "You",
"inCall": "{{count}} in call",
"connecting": "Connecting…",
"roomFull": "Room is full (max {{max}}).",
"callError": "Couldn't start the call. Check camera/mic permissions.",
"muteMic": "Mute microphone",
"unmuteMic": "Unmute microphone",
"startVideo": "Start video",
"stopVideo": "Stop video",
"shareScreen": "Share screen",
"leave": "Leave call"
},
"messages": {
"inbox": "Inbox",
"unread": "Unread · {{count}}",
+37
View File
@@ -0,0 +1,37 @@
import { apiFetch } from "@/lib/api-client";
// A persistent staff meeting room (Discord-style voice/video channel). The live
// call (participants/media) is ephemeral and runs over the socket; this is just
// the room list.
export type MeetingRoom = {
id: string;
name: string;
createdAt: string;
};
export function listMeetingRooms(): Promise<MeetingRoom[]> {
return apiFetch<MeetingRoom[]>("/api/meetings");
}
export function createMeetingRoom(name: string): Promise<MeetingRoom> {
return apiFetch<MeetingRoom>("/api/meetings", {
method: "POST",
body: JSON.stringify({ name }),
});
}
export function deleteMeetingRoom(id: string): Promise<void> {
return apiFetch<void>(`/api/meetings/${encodeURIComponent(id)}`, {
method: "DELETE",
});
}
// Max peers in a mesh call — mirrors the backend cap (mesh degrades past ~4).
export const MAX_CALL_PEERS = 4;
// A remote peer in a live call.
export type CallPeer = {
socketId: string;
userId: string;
userName: string;
};
+16 -1
View File
@@ -14,6 +14,7 @@ import {
Receipt,
Settings,
Users,
Video,
} from "lucide-react";
// Access areas gate routes/nav by role capability (probed against the Better
@@ -120,7 +121,21 @@ export const navItems: NavItem[] = [
link: "/lab",
access: "lab",
},
{ id: "messages", labelKey: "nav.messages", icon: Mail, link: "/messages" },
{
id: "messages",
labelKey: "nav.messages",
icon: Mail,
link: "/messages",
subs: [
{ id: "messages-inbox", labelKey: "nav.inbox", link: "/messages" },
{
id: "meetings",
labelKey: "nav.meetings",
icon: Video,
link: "/messages/meetings",
},
],
},
{
id: "notes",
labelKey: "nav.notes",