feat(meetings): live invite, scheduling calendar, Discord-style redesign

- Live invite: ring a clinic member into a room (socket call:invite + a bell
  notification); the invitee gets a toast with a Join action. Notifications of
  type "meeting" deep-link to /messages/meetings?room=, which auto-joins.
- Scheduling: new scheduled_meetings table + /api/meetings/events (list mine,
  create, delete); a Calendar tab on the Meetings page with a month picker
  (meeting-day dots), the day's agenda, and a Schedule-meeting dialog
  (title/date/time/participants).
- Redesign: rounded control bar with tooltips (mic/cam/screen/invite + separated
  red Leave), speaking ring on tiles (Web Audio), and live room-occupancy counts
  via call:presence broadcasts. Migration 0025.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-20 02:07:41 +03:00
parent e841cb56e1
commit 913a217e1d
16 changed files with 5046 additions and 133 deletions
+14
View File
@@ -0,0 +1,14 @@
CREATE TABLE "scheduled_meetings" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" text NOT NULL,
"title" text NOT NULL,
"date" text NOT NULL,
"time" text NOT NULL,
"participants" jsonb DEFAULT '[]'::jsonb NOT NULL,
"created_by" text,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "scheduled_meetings" ADD CONSTRAINT "scheduled_meetings_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "scheduled_meetings" ADD CONSTRAINT "scheduled_meetings_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 "scheduled_meetings_org_idx" ON "scheduled_meetings" USING btree ("organization_id");
File diff suppressed because it is too large Load Diff
+7
View File
@@ -176,6 +176,13 @@
"when": 1781891060177,
"tag": "0024_skinny_iron_fist",
"breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1781910033543,
"tag": "0025_nice_paladin",
"breakpoints": true
}
]
}
+30 -1
View File
@@ -1,4 +1,11 @@
import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import {
index,
jsonb,
pgTable,
text,
timestamp,
uuid,
} from "drizzle-orm/pg-core";
import { organization, user } from "./auth.js";
@@ -21,3 +28,25 @@ export const meetingRooms = pgTable(
},
(table) => [index("meeting_rooms_org_idx").on(table.organizationId)],
);
// A scheduled staff meeting (calendar event), scoped to a clinic. `participants`
// holds the invited staff user ids; `date`/`time` are local strings (YYYY-MM-DD /
// HH:mm) like appointments, to avoid timezone drift on the calendar.
export const scheduledMeetings = pgTable(
"scheduled_meetings",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
title: text("title").notNull(),
date: text("date").notNull(),
time: text("time").notNull(),
participants: jsonb("participants").$type<string[]>().notNull().default([]),
createdBy: text("created_by").references(() => user.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => [index("scheduled_meetings_org_idx").on(table.organizationId)],
);
+50 -1
View File
@@ -15,6 +15,7 @@ let io: Server | null = null;
const userRoom = (userId: string) => `user:${userId}`;
const convRoom = (conversationId: string) => `conv:${conversationId}`;
const callRoom = (roomId: string) => `call:${roomId}`;
const orgRoom = (orgId: string) => `org:${orgId}`;
// Mesh WebRTC tops out around four peers (each sends its stream to every other);
// past that the room is closed to new joiners.
@@ -69,8 +70,9 @@ export function initRealtime(httpServer: HttpServer): Server {
const userName: string = socket.data.userName;
const orgId: string | null = socket.data.orgId;
// Personal room for notifications.
// Personal room for notifications; clinic room for call presence broadcasts.
socket.join(userRoom(userId));
if (orgId) socket.join(orgRoom(orgId));
socket.on(
"conversation:join",
@@ -151,6 +153,14 @@ export function initRealtime(httpServer: HttpServer): Server {
// org-scoped: a join is authorized against the clinic's meeting_rooms.
const joinedCallRooms = new Set<string>();
// Broadcast a room's live occupancy to the whole clinic so the meetings
// room list can show "N in call".
const emitPresence = (roomId: string) => {
if (!orgId) return;
const count = callParticipants.get(roomId)?.size ?? 0;
io?.to(orgRoom(orgId)).emit("call:presence", { roomId, count });
};
const leaveCall = (roomId: string) => {
if (!joinedCallRooms.has(roomId)) return;
joinedCallRooms.delete(roomId);
@@ -160,6 +170,7 @@ export function initRealtime(httpServer: HttpServer): Server {
callParticipants.delete(roomId);
}
socket.to(callRoom(roomId)).emit("call:peer-left", { socketId: socket.id });
emitPresence(roomId);
};
socket.on("call:join", async (roomId: unknown, ack?: Ack) => {
@@ -181,6 +192,7 @@ export function initRealtime(httpServer: HttpServer): Server {
callParticipants.set(id, peers);
// Tell existing peers a newcomer arrived; the newcomer initiates offers.
socket.to(callRoom(id)).emit("call:peer-joined", me);
emitPresence(id);
// Reply with the peers already present (excluding self).
ack?.({
ok: true,
@@ -191,6 +203,42 @@ export function initRealtime(httpServer: HttpServer): Server {
}
});
// Ring a clinic member into a room: push a live invite + a bell notification.
socket.on(
"call:invite",
async (payload: { roomId?: string; toUserId?: string }) => {
try {
const roomId = String(payload?.roomId ?? "");
const toUserId = String(payload?.toUserId ?? "");
if (!(roomId && toUserId && orgId)) return;
if (!(await meetings.roomExists(orgId, roomId))) return;
const room = (await meetings.listRooms(orgId)).find(
(r) => r.id === roomId,
);
const roomName = room?.name ?? "";
emitToUser(toUserId, "call:invite", {
roomId,
roomName,
fromName: userName,
});
const notification = await createNotification({
orgId,
userId: toUserId,
type: "meeting",
text: `${userName} invited you to a call`,
entityType: "meeting",
entityId: roomId,
actorName: userName,
});
if (notification) {
emitToUser(toUserId, "notification:new", notification);
}
} catch {
/* best-effort */
}
},
);
// Relay an SDP offer/answer or ICE candidate to a specific peer socket.
socket.on(
"call:signal",
@@ -217,6 +265,7 @@ export function initRealtime(httpServer: HttpServer): Server {
socket
.to(callRoom(roomId))
.emit("call:peer-left", { socketId: socket.id });
emitPresence(roomId);
}
});
});
+45
View File
@@ -39,6 +39,51 @@ meetingsRouter.post("/", async (req, res, next) => {
}
});
// --- Scheduled meetings (calendar) -----------------------------------------
meetingsRouter.get("/events", async (req, res, next) => {
try {
res.json(await meetings.listMeetingEvents(req.organizationId!, req.user!.id));
} catch (err) {
next(err);
}
});
const eventSchema = z.object({
title: z.string().trim().min(1).max(120),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
time: z.string().regex(/^\d{2}:\d{2}$/),
participants: z.array(z.string()).max(50).default([]),
});
meetingsRouter.post("/events", async (req, res, next) => {
try {
const input = eventSchema.parse(req.body);
const event = await meetings.createMeetingEvent(
req.organizationId!,
req.user!.id,
input,
);
res.status(201).json(event);
} catch (err) {
next(err);
}
});
meetingsRouter.delete("/events/:id", async (req, res, next) => {
try {
const ok = await meetings.deleteMeetingEvent(
req.organizationId!,
req.user!.id,
String(req.params.id ?? ""),
);
if (!ok) throw new HttpError(404, "Meeting not found.");
res.status(204).end();
} catch (err) {
next(err);
}
});
meetingsRouter.delete("/:id", async (req, res, next) => {
try {
const ok = await meetings.deleteRoom(
+99 -2
View File
@@ -1,7 +1,8 @@
import { and, asc, eq } from "drizzle-orm";
import { and, asc, eq, inArray, or, sql } from "drizzle-orm";
import { db } from "../db/index.js";
import { meetingRooms } from "../db/schema/meetings.js";
import { user } from "../db/schema/auth.js";
import { meetingRooms, scheduledMeetings } from "../db/schema/meetings.js";
export type MeetingRoom = {
id: string;
@@ -67,3 +68,99 @@ export async function roomExists(orgId: string, roomId: string): Promise<boolean
);
return Boolean(row);
}
// --- Scheduled meetings (calendar) -----------------------------------------
export type ScheduledMeeting = {
id: string;
title: string;
date: string;
time: string;
participants: string[];
participantNames: string[];
createdBy: string | null;
};
// Meetings the user is part of (creator or invited participant), with the
// participants' display names resolved for the calendar.
export async function listMeetingEvents(
orgId: string,
userId: string,
): Promise<ScheduledMeeting[]> {
const rows = await db
.select()
.from(scheduledMeetings)
.where(
and(
eq(scheduledMeetings.organizationId, orgId),
or(
eq(scheduledMeetings.createdBy, userId),
// `participants` is a JSONB array of user ids.
sql`${scheduledMeetings.participants} @> ${JSON.stringify([userId])}::jsonb`,
),
),
)
.orderBy(asc(scheduledMeetings.date), asc(scheduledMeetings.time));
// Resolve participant names in one query.
const ids = [...new Set(rows.flatMap((r) => r.participants))];
const nameById = new Map<string, string>();
if (ids.length > 0) {
const users = await db
.select({ id: user.id, name: user.name })
.from(user)
.where(inArray(user.id, ids));
for (const u of users) nameById.set(u.id, u.name);
}
return rows.map((r) => ({
id: r.id,
title: r.title,
date: r.date,
time: r.time,
participants: r.participants,
participantNames: r.participants.map((id) => nameById.get(id) ?? "—"),
createdBy: r.createdBy,
}));
}
export async function createMeetingEvent(
orgId: string,
createdBy: string,
input: { title: string; date: string; time: string; participants: string[] },
): Promise<ScheduledMeeting> {
// The creator is always a participant.
const participants = [...new Set([createdBy, ...input.participants])];
const [row] = await db
.insert(scheduledMeetings)
.values({
organizationId: orgId,
title: input.title,
date: input.date,
time: input.time,
participants,
createdBy,
})
.returning({ id: scheduledMeetings.id });
const events = await listMeetingEvents(orgId, createdBy);
return events.find((e) => e.id === row!.id)!;
}
export async function deleteMeetingEvent(
orgId: string,
userId: string,
id: string,
): Promise<boolean> {
// Only the creator can delete.
const deleted = await db
.delete(scheduledMeetings)
.where(
and(
eq(scheduledMeetings.organizationId, orgId),
eq(scheduledMeetings.id, id),
eq(scheduledMeetings.createdBy, userId),
),
)
.returning({ id: scheduledMeetings.id });
return deleted.length > 0;
}
+187 -49
View File
@@ -5,15 +5,35 @@ import {
MicOff,
MonitorUp,
PhoneOff,
Search,
UserPlus,
Video as VideoIcon,
VideoOff,
} from "lucide-react";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useSpeaking } from "@/components/meetings/use-audio-level";
import { useWebRtcMesh } from "@/components/meetings/use-webrtc-mesh";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogDescription,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipPopup,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { authClient } from "@/lib/auth-client";
import { listClinicMembers, type Participant } from "@/lib/messages";
import { getSocket } from "@/lib/socket";
import { cn } from "@/lib/utils";
function initials(name: string): string {
@@ -23,7 +43,8 @@ function initials(name: string): string {
return (parts[0]![0]! + parts.at(-1)![0]!).toUpperCase();
}
// One participant tile: shows their video, or an avatar when the camera is off.
// One participant tile: video when the camera is on, an avatar otherwise, with a
// green speaking ring driven by the stream's audio level.
function VideoTile({
stream,
label,
@@ -36,13 +57,21 @@ function VideoTile({
showVideo: boolean;
}) {
const ref = useRef<HTMLVideoElement>(null);
// Analyse the stream's audio for the speaking ring (muting only affects
// playback, so the local tile still gets a ring when you talk).
const speaking = useSpeaking(stream);
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">
<div
className={cn(
"relative aspect-video overflow-hidden rounded-2xl border-2 bg-muted transition-colors",
speaking ? "border-success" : "border-transparent",
)}
>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
autoPlay
@@ -65,6 +94,40 @@ function VideoTile({
);
}
// A round control-bar button with a tooltip label.
function ControlButton({
label,
onClick,
active,
variant = "secondary",
children,
}: {
label: string;
onClick: () => void;
active?: boolean;
variant?: "secondary" | "outline" | "default" | "destructive";
children: React.ReactNode;
}) {
return (
<Tooltip>
<TooltipTrigger
render={
<Button
aria-label={label}
className="size-12 rounded-full"
onClick={onClick}
size="icon"
variant={active === false ? "outline" : variant}
/>
}
>
{children}
</TooltipTrigger>
<TooltipPopup>{label}</TooltipPopup>
</Tooltip>
);
}
export function MeetingRoom({
roomId,
roomName,
@@ -77,6 +140,8 @@ export function MeetingRoom({
onLeave: () => void;
}) {
const { t } = useTranslation();
const { data: session } = authClient.useSession();
const myId = session?.user?.id ?? "";
const {
localStream,
peers,
@@ -90,10 +155,31 @@ export function MeetingRoom({
maxPeers,
} = useWebRtcMesh(roomId);
const leave = () => {
onLeave();
// Invite picker.
const [inviteOpen, setInviteOpen] = useState(false);
const [members, setMembers] = useState<Participant[]>([]);
const [memberQuery, setMemberQuery] = useState("");
const [invited, setInvited] = useState<Set<string>>(new Set());
const openInvite = () => {
setInviteOpen(true);
setMemberQuery("");
listClinicMembers()
.then(setMembers)
.catch(() => setMembers([]));
};
const invite = (userId: string) => {
getSocket().emit("call:invite", { roomId, toUserId: userId });
setInvited((prev) => new Set(prev).add(userId));
};
const visibleMembers = members.filter(
(m) =>
m.id !== myId &&
m.name.toLowerCase().includes(memberQuery.trim().toLowerCase()),
);
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">
@@ -116,13 +202,11 @@ export function MeetingRoom({
</div>
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border bg-card/30 p-4">
{joinState === "full" ? (
{joinState === "full" || joinState === "error" ? (
<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")}
{joinState === "full"
? t("meetings.roomFull", { max: maxPeers })
: t("meetings.callError")}
</div>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
@@ -145,48 +229,102 @@ export function MeetingRoom({
</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"
<div className="flex items-center justify-center gap-3 rounded-full border bg-card/60 px-4 py-2 backdrop-blur">
<div className="flex items-center gap-2">
<ControlButton
active={micOn}
label={micOn ? t("meetings.muteMic") : t("meetings.unmuteMic")}
onClick={toggleMic}
>
{micOn ? <Mic className="size-5" /> : <MicOff className="size-5" />}
</ControlButton>
<ControlButton
active={camOn}
label={camOn ? t("meetings.stopVideo") : t("meetings.startVideo")}
onClick={toggleCam}
>
{camOn ? (
<VideoIcon className="size-5" />
) : (
<VideoOff className="size-5" />
)}
</ControlButton>
<ControlButton
label={t("meetings.shareScreen")}
onClick={() => void toggleScreen()}
variant={screenOn ? "default" : "secondary"}
>
<MonitorUp className="size-5" />
</ControlButton>
<ControlButton label={t("meetings.invite.add")} onClick={openInvite}>
<UserPlus className="size-5" />
</ControlButton>
</div>
<div className="mx-1 h-8 w-px bg-border" />
<ControlButton
label={t("meetings.leave")}
onClick={onLeave}
variant="destructive"
>
<PhoneOff className="size-5" />
</Button>
</ControlButton>
</div>
<Dialog onOpenChange={setInviteOpen} open={inviteOpen}>
<DialogPopup className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("meetings.invite.title")}</DialogTitle>
<DialogDescription>
{t("meetings.invite.description", { room: roomName })}
</DialogDescription>
</DialogHeader>
<DialogPanel className="flex flex-col gap-2">
<div className="relative">
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
<Input
aria-label={t("meetings.invite.search")}
className="pl-9"
onChange={(e) => setMemberQuery(e.target.value)}
placeholder={t("meetings.invite.search")}
size="sm"
value={memberQuery}
/>
</div>
<div className="flex max-h-72 flex-col gap-1 overflow-y-auto">
{visibleMembers.length === 0 ? (
<p className="px-1 py-4 text-center text-muted-foreground text-sm">
{t("meetings.invite.noMembers")}
</p>
) : (
visibleMembers.map((m) => (
<div
className="flex items-center gap-3 rounded-lg px-2 py-2"
key={m.id}
>
<Avatar className="size-8">
<AvatarFallback>{initials(m.name)}</AvatarFallback>
</Avatar>
<span className="min-w-0 flex-1 truncate text-foreground text-sm">
{m.name}
</span>
<Button
disabled={invited.has(m.id)}
onClick={() => invite(m.id)}
size="sm"
type="button"
variant="outline"
>
{invited.has(m.id)
? t("meetings.invite.invited")
: t("meetings.invite.ring")}
</Button>
</div>
))
)}
</div>
</DialogPanel>
</DialogPopup>
</Dialog>
</div>
);
}
+265 -79
View File
@@ -1,11 +1,15 @@
"use client";
import { Plus, Video } from "lucide-react";
import { type FormEvent, useEffect, useState } from "react";
import { CalendarDays, Plus, Users, Video } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { MeetingRoom } from "@/components/meetings/meeting-room";
import { ScheduleMeetingDialog } from "@/components/meetings/schedule-meeting-dialog";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
Dialog,
DialogClose,
@@ -28,31 +32,82 @@ import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import {
createMeetingRoom,
listMeetingEvents,
listMeetingRooms,
type MeetingRoom as Room,
type ScheduledMeeting,
} from "@/lib/meetings";
import { getSocket } from "@/lib/socket";
import { notify } from "@/lib/toast";
import { cn } from "@/lib/utils";
type Tab = "rooms" | "calendar";
// Local YYYY-MM-DD key for a Date (no UTC drift), matching how meetings store date.
function keyOf(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
export function MeetingsView() {
const { t } = useTranslation();
const { data: session } = authClient.useSession();
const selfName = session?.user?.name ?? "";
const searchParams = useSearchParams();
const deepLinkRoom = searchParams.get("room");
const openedDeepLink = useRef<string | null>(null);
const [tab, setTab] = useState<Tab>("rooms");
// Rooms / live calls.
const [rooms, setRooms] = useState<Room[]>([]);
const [activeRoom, setActiveRoom] = useState<Room | null>(null);
const [presence, setPresence] = useState<Record<string, number>>({});
const [createOpen, setCreateOpen] = useState(false);
const [newName, setNewName] = useState("");
const [creating, setCreating] = useState(false);
// Scheduled meetings (calendar).
const [events, setEvents] = useState<ScheduledMeeting[]>([]);
const [selectedDay, setSelectedDay] = useState<Date>(new Date());
const [scheduleOpen, setScheduleOpen] = useState(false);
useEffect(() => {
listMeetingRooms()
.then(setRooms)
.catch(() => {
/* api-client redirects on 401 */
});
.catch(() => {});
}, []);
const loadEvents = () => {
listMeetingEvents()
.then(setEvents)
.catch(() => {});
};
useEffect(loadEvents, []);
// Live room occupancy.
useEffect(() => {
const socket = getSocket();
const onPresence = ({ roomId, count }: { roomId: string; count: number }) => {
setPresence((prev) => ({ ...prev, [roomId]: count }));
};
socket.on("call:presence", onPresence);
return () => {
socket.off("call:presence", onPresence);
};
}, []);
// Auto-join a room deep-linked from an invite (?room=).
useEffect(() => {
if (!deepLinkRoom || rooms.length === 0) return;
if (openedDeepLink.current === deepLinkRoom) return;
const room = rooms.find((r) => r.id === deepLinkRoom);
if (!room) return;
openedDeepLink.current = deepLinkRoom;
setTab("rooms");
setActiveRoom(room);
}, [deepLinkRoom, rooms]);
const createRoom = async (event: FormEvent) => {
event.preventDefault();
const name = newName.trim();
@@ -71,83 +126,207 @@ export function MeetingsView() {
}
};
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>
const meetingDays = useMemo(
() => events.map((e) => new Date(`${e.date}T00:00:00`)),
[events],
);
const dayEvents = useMemo(
() =>
events
.filter((e) => e.date === keyOf(selectedDay))
.sort((a, b) => a.time.localeCompare(b.time)),
[events, selectedDay],
);
{/* 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>
)}
return (
<div className="flex h-full w-full flex-col gap-3 p-4">
{/* Header: Rooms / Calendar tabs */}
<div className="flex items-center gap-1 rounded-full border bg-muted/40 p-1 self-start">
<button
className={cn(
"flex items-center gap-1.5 rounded-full px-3 py-1 font-medium text-sm transition-colors",
tab === "rooms"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
onClick={() => setTab("rooms")}
type="button"
>
<Video className="size-4" />
{t("meetings.rooms")}
</button>
<button
className={cn(
"flex items-center gap-1.5 rounded-full px-3 py-1 font-medium text-sm transition-colors",
tab === "calendar"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
onClick={() => setTab("calendar")}
type="button"
>
<CalendarDays className="size-4" />
{t("meetings.calendar")}
</button>
</div>
{tab === "rooms" ? (
<div className="flex min-h-0 flex-1 gap-4">
{/* 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) => {
const count = presence[room.id] ?? 0;
return (
<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>
{count > 0 && (
<span className="flex items-center gap-1 rounded-full bg-success/15 px-1.5 text-success text-xs">
<Users className="size-3" />
{count}
</span>
)}
</button>
);
})
)}
</div>
</aside>
{/* Live call or 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>
</div>
) : (
// Calendar tab
<div className="flex min-h-0 flex-1 gap-4">
<div className="flex shrink-0 flex-col gap-3 rounded-2xl border bg-card/30 p-3">
<Calendar
mode="single"
modifiers={{ hasMeeting: meetingDays }}
modifiersClassNames={{
hasMeeting:
"relative after:absolute after:bottom-1 after:left-1/2 after:size-1 after:-translate-x-1/2 after:rounded-full after:bg-primary",
}}
onSelect={(d) => d && setSelectedDay(d)}
required
selected={selectedDay}
/>
<Button onClick={() => setScheduleOpen(true)} type="button">
<Plus className="size-4" />
{t("meetings.schedule.cta")}
</Button>
</div>
<div className="flex min-w-0 flex-1 flex-col overflow-hidden rounded-2xl border bg-card/30">
<div className="border-border border-b px-4 py-3">
<h2 className="font-semibold text-base tracking-tight">
{selectedDay.toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
})}
</h2>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-3">
{dayEvents.length === 0 ? (
<p className="px-2 py-6 text-center text-muted-foreground text-sm">
{t("meetings.calendarEmpty")}
</p>
) : (
dayEvents.map((e) => (
<div
className="flex items-start gap-3 rounded-xl border bg-card px-3 py-2.5"
key={e.id}
>
<span className="font-medium text-foreground text-sm tabular-nums">
{e.time}
</span>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate font-medium text-foreground text-sm">
{e.title}
</span>
<span className="truncate text-muted-foreground text-xs">
{e.participantNames.join(", ")}
</span>
</div>
<Avatar className="size-7">
<AvatarFallback className="text-xs">
{e.participantNames.length}
</AvatarFallback>
</Avatar>
</div>
))
)}
</div>
</div>
</div>
)}
{/* Create room dialog */}
<Dialog onOpenChange={setCreateOpen} open={createOpen}>
<DialogPopup className="sm:max-w-md">
<DialogHeader>
@@ -174,6 +353,13 @@ export function MeetingsView() {
</form>
</DialogPopup>
</Dialog>
<ScheduleMeetingDialog
defaultDate={keyOf(selectedDay)}
onCreated={loadEvents}
onOpenChange={setScheduleOpen}
open={scheduleOpen}
/>
</div>
);
}
@@ -0,0 +1,198 @@
"use client";
import { Check } from "lucide-react";
import { type FormEvent, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { createMeetingEvent } from "@/lib/meetings";
import { listClinicMembers, type Participant } from "@/lib/messages";
import { notify } from "@/lib/toast";
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();
}
export function ScheduleMeetingDialog({
open,
onOpenChange,
defaultDate,
onCreated,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
defaultDate?: string; // YYYY-MM-DD
onCreated: () => void;
}) {
const { t } = useTranslation();
const { data: session } = authClient.useSession();
const myId = session?.user?.id ?? "";
const [title, setTitle] = useState("");
const [date, setDate] = useState(defaultDate ?? "");
const [time, setTime] = useState("09:00");
const [members, setMembers] = useState<Participant[]>([]);
const [picked, setPicked] = useState<Set<string>>(new Set());
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
setTitle("");
setDate(defaultDate ?? "");
setTime("09:00");
setPicked(new Set());
listClinicMembers()
.then(setMembers)
.catch(() => setMembers([]));
}, [open, defaultDate]);
const toggle = (id: string) =>
setPicked((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!(title.trim() && date && time) || saving) return;
setSaving(true);
try {
await createMeetingEvent({
title: title.trim(),
date,
time,
participants: [...picked],
});
onCreated();
onOpenChange(false);
} catch {
notify.error(
t("meetings.schedule.failedTitle"),
t("meetings.schedule.failedBody"),
);
} finally {
setSaving(false);
}
};
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogPopup className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("meetings.schedule.title")}</DialogTitle>
<DialogDescription>
{t("meetings.schedule.description")}
</DialogDescription>
</DialogHeader>
<form className="contents" onSubmit={submit}>
<DialogPanel className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<label className="font-medium text-sm" htmlFor="meeting-title">
{t("meetings.schedule.titleLabel")}
</label>
<Input
id="meeting-title"
onChange={(e) => setTitle(e.target.value)}
placeholder={t("meetings.schedule.titlePlaceholder")}
value={title}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<label className="font-medium text-sm" htmlFor="meeting-date">
{t("meetings.schedule.date")}
</label>
<Input
id="meeting-date"
onChange={(e) => setDate(e.target.value)}
type="date"
value={date}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="font-medium text-sm" htmlFor="meeting-time">
{t("meetings.schedule.time")}
</label>
<Input
id="meeting-time"
onChange={(e) => setTime(e.target.value)}
type="time"
value={time}
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<span className="font-medium text-sm">
{t("meetings.schedule.participants")}
</span>
<div className="flex max-h-48 flex-col gap-1 overflow-y-auto rounded-2xl border p-1">
{members.filter((m) => m.id !== myId).length === 0 ? (
<p className="px-2 py-3 text-center text-muted-foreground text-sm">
{t("meetings.invite.noMembers")}
</p>
) : (
members
.filter((m) => m.id !== myId)
.map((m) => {
const on = picked.has(m.id);
return (
<button
className={cn(
"flex items-center gap-3 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-accent/50",
on && "bg-accent",
)}
key={m.id}
onClick={() => toggle(m.id)}
type="button"
>
<Avatar className="size-7">
<AvatarFallback className="text-xs">
{initials(m.name)}
</AvatarFallback>
</Avatar>
<span className="min-w-0 flex-1 truncate text-sm">
{m.name}
</span>
{on && <Check className="size-4 text-primary" />}
</button>
);
})
)}
</div>
</div>
</DialogPanel>
<DialogFooter>
<DialogClose render={<Button type="button" variant="outline" />}>
{t("meetings.cancel")}
</DialogClose>
<Button
disabled={!(title.trim() && date && time) || saving}
type="submit"
>
{saving ? t("meetings.schedule.saving") : t("meetings.schedule.save")}
</Button>
</DialogFooter>
</form>
</DialogPopup>
</Dialog>
);
}
@@ -0,0 +1,46 @@
"use client";
import { useEffect, useState } from "react";
// Returns true while the given stream's audio is above a speaking threshold —
// used to draw a Discord-style "speaking" ring around a participant's tile.
// Degrades silently if the Web Audio API isn't available.
export function useSpeaking(stream: MediaStream | null): boolean {
const [speaking, setSpeaking] = useState(false);
useEffect(() => {
if (!stream || stream.getAudioTracks().length === 0) {
setSpeaking(false);
return;
}
let ctx: AudioContext | null = null;
let raf = 0;
try {
ctx = new AudioContext();
const source = ctx.createMediaStreamSource(stream);
const analyser = ctx.createAnalyser();
analyser.fftSize = 512;
source.connect(analyser);
const data = new Uint8Array(analyser.frequencyBinCount);
const tick = () => {
analyser.getByteTimeDomainData(data);
let sum = 0;
for (let i = 0; i < data.length; i++) {
const v = (data[i]! - 128) / 128;
sum += v * v;
}
setSpeaking(Math.sqrt(sum / data.length) > 0.045);
raf = requestAnimationFrame(tick);
};
tick();
} catch {
/* no Web Audio — no ring */
}
return () => {
cancelAnimationFrame(raf);
ctx?.close().catch(() => {});
};
}, [stream]);
return speaking;
}
@@ -0,0 +1,40 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { toastManager } from "@/components/ui/toast";
import { getSocket } from "@/lib/socket";
type CallInvite = { roomId: string; roomName: string; fromName: string };
// Listens for live "call:invite" events and shows a toast with a Join action
// that drops the user into the room. Mounted once in the app shell (the sidebar)
// so it works from any page.
export function useCallInvites() {
const router = useRouter();
const { t } = useTranslation();
useEffect(() => {
const socket = getSocket();
const onInvite = ({ roomId, roomName, fromName }: CallInvite) => {
toastManager.add({
type: "info",
title: t("meetings.invite.toastTitle", { name: fromName }),
description: roomName,
actionProps: {
children: t("meetings.invite.join"),
onClick: () =>
router.push(
`/messages/meetings?room=${encodeURIComponent(roomId)}`,
),
},
});
};
socket.on("call:invite", onInvite);
return () => {
socket.off("call:invite", onInvite);
};
}, [router, t]);
}
@@ -24,6 +24,7 @@ import DashboardNavigation from "@/components/sidebar-02/nav-main";
import { NavChatHistory } from "@/components/sidebar-02/nav-chat-history";
import { NotificationsPopover } from "@/components/sidebar-02/nav-notifications";
import { NavUser } from "@/components/sidebar-02/nav-user";
import { useCallInvites } from "@/components/meetings/use-call-invites";
export function DashboardSidebar() {
const { state } = useSidebar();
@@ -31,6 +32,8 @@ export function DashboardSidebar() {
const role = useActiveRole();
const { allowed: aiAllowed } = useAiAccess();
const isCollapsed = state === "collapsed";
// Ring staff into calls from anywhere in the app.
useCallInvites();
// Hide clinical nav from non-clinical roles (e.g. reception). See lib/roles.ts.
// Also drop the AI surfaces ("New chat" + "Analysis") when the clinic's AI
+28 -1
View File
@@ -737,7 +737,34 @@
"startVideo": "Start video",
"stopVideo": "Stop video",
"shareScreen": "Share screen",
"leave": "Leave call"
"leave": "Leave call",
"calendar": "Calendar",
"calendarEmpty": "No meetings on this day.",
"invite": {
"add": "Add people",
"title": "Invite to call",
"description": "Ring a team member into {{room}}.",
"search": "Search team…",
"noMembers": "No other members.",
"ring": "Ring",
"invited": "Invited",
"join": "Join",
"toastTitle": "{{name}} invited you to a call"
},
"schedule": {
"cta": "Schedule meeting",
"title": "Schedule a meeting",
"description": "Plan a meeting and invite your team.",
"titleLabel": "Title",
"titlePlaceholder": "e.g. Weekly sync",
"date": "Date",
"time": "Time",
"participants": "Participants",
"save": "Schedule",
"saving": "Scheduling…",
"failedTitle": "Couldn't schedule meeting",
"failedBody": "Please try again."
}
},
"messages": {
"inbox": "Inbox",
+34
View File
@@ -35,3 +35,37 @@ export type CallPeer = {
userId: string;
userName: string;
};
// --- Scheduled meetings (calendar) -----------------------------------------
export type ScheduledMeeting = {
id: string;
title: string;
date: string; // YYYY-MM-DD
time: string; // HH:mm
participants: string[];
participantNames: string[];
createdBy: string | null;
};
export function listMeetingEvents(): Promise<ScheduledMeeting[]> {
return apiFetch<ScheduledMeeting[]>("/api/meetings/events");
}
export function createMeetingEvent(input: {
title: string;
date: string;
time: string;
participants: string[];
}): Promise<ScheduledMeeting> {
return apiFetch<ScheduledMeeting>("/api/meetings/events", {
method: "POST",
body: JSON.stringify(input),
});
}
export function deleteMeetingEvent(id: string): Promise<void> {
return apiFetch<void>(`/api/meetings/events/${encodeURIComponent(id)}`, {
method: "DELETE",
});
}
+2
View File
@@ -39,6 +39,8 @@ export function notificationHref(n: Notification): string | null {
switch (n.entityType) {
case "conversation":
return `/messages?conversation=${encodeURIComponent(n.entityId)}`;
case "meeting":
return `/messages/meetings?room=${encodeURIComponent(n.entityId)}`;
case "patient":
return `/patients?file=${encodeURIComponent(n.entityId)}`;
default: