mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
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:
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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)],
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user