"use client"; import { CalendarClock, Download, FileText, KeyRound, Mail, Paperclip, Plus, Search, SendHorizonal, ShieldAlert, Video, X, } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import { type ChangeEvent, type FormEvent, Fragment, useEffect, useMemo, useRef, useState, } from "react"; import { useTranslation } from "react-i18next"; import { AppointmentDetailDialog } from "@/components/messages/appointment-detail-dialog"; import { Attachment, AttachmentAction, AttachmentActions, AttachmentContent, AttachmentDescription, AttachmentMedia, AttachmentTitle, AttachmentTrigger, } from "@/components/ui/attachment"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Bubble, BubbleContent } from "@/components/ui/bubble"; import { Button } from "@/components/ui/button"; import { Message, MessageAvatar, MessageContent, MessageFooter, MessageHeader, } from "@/components/ui/message"; import { Dialog, DialogDescription, 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 { type Appointment, listAppointments } from "@/lib/appointments"; import { authClient } from "@/lib/auth-client"; import { type ConversationMessage, type ConversationSummary, type MessageAttachment, type Participant, createConversation, downloadAttachment, getMessages, listClinicMembers, listConversations, uploadAttachment, } from "@/lib/messages"; import { getSocket } from "@/lib/socket"; import { notify } from "@/lib/toast"; import { cn } from "@/lib/utils"; const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024; // Up to two-letter initials from a display name. 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(); } // ISO timestamp -> "10:24" (24h). function formatTime(iso: string): string { return new Date(iso).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false, }); } function sameDay(a: string, b: string): boolean { return new Date(a).toDateString() === new Date(b).toDateString(); } // Consecutive messages from one sender within this window render as a group: // one sender label, one timestamp, tighter spacing. const GROUP_WINDOW_MS = 5 * 60 * 1000; // One sent attachment rendered in the thread, built on the Attachment primitive: // a downloadable file, a shared-appointment card, or a password-reset notice. // Alignment (left/right) is inherited from the parent MessageContent. function SentAttachment({ att }: { att: MessageAttachment }) { const { t } = useTranslation(); const router = useRouter(); const [apptOpen, setApptOpen] = useState(false); if (att.kind === "passwordReset") { return ( router.push( `/settings?tab=careTeam&member=${encodeURIComponent(att.userId)}`, ) } /> {t("messages.system.passwordResetTitle")} {t("messages.system.passwordResetBody", { name: att.userName })} ); } if (att.kind === "file") { return ( {att.fileName} { void downloadAttachment(att.attachmentId, att.fileName).catch( () => { /* ignore — surfaced by the browser */ }, ); }} > ); } const a = att.appointment; return ( <> setApptOpen(true)} /> {a.name} {[a.date, a.time, a.type, a.provider].filter(Boolean).join(" · ")} > ); } export function MessagesView() { const { t } = useTranslation(); const { data: session } = authClient.useSession(); const myId = session?.user?.id ?? ""; const myInitials = initials(session?.user?.name ?? ""); const router = useRouter(); // Deep link from a notification: /messages?conversation=. const searchParams = useSearchParams(); const deepLinkConversation = searchParams.get("conversation"); const openedDeepLink = useRef(null); // "Today" / "Yesterday" / "Jun 9, 2026" for the thread's day separators. const formatDay = (iso: string): string => { const date = new Date(iso); const now = new Date(); const yesterday = new Date(now); yesterday.setDate(now.getDate() - 1); if (date.toDateString() === now.toDateString()) return t("messages.today"); if (date.toDateString() === yesterday.toDateString()) { return t("messages.yesterday"); } return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", }); }; const [conversations, setConversations] = useState([]); const [selectedId, setSelectedId] = useState(null); const [messages, setMessages] = useState([]); const [showUnreadOnly, setShowUnreadOnly] = useState(false); const [inboxQuery, setInboxQuery] = useState(""); const [draft, setDraft] = useState(""); const [composeOpen, setComposeOpen] = useState(false); const [members, setMembers] = useState([]); const [memberQuery, setMemberQuery] = useState(""); // Pending attachments staged for the next message + the attach UI. const [pending, setPending] = useState([]); const [uploading, setUploading] = useState(false); const fileInputRef = useRef(null); const [apptOpen, setApptOpen] = useState(false); const [appts, setAppts] = useState([]); const [apptQuery, setApptQuery] = useState(""); // Refs so the socket handler (registered once) reads current values. const selectedIdRef = useRef(null); const myIdRef = useRef(""); myIdRef.current = myId; const scrollRef = useRef(null); // Initial conversation load. useEffect(() => { let active = true; listConversations() .then((data) => { if (active) setConversations(data); }) .catch(() => { /* api-client redirects on 401 */ }); return () => { active = false; }; }, []); // Realtime: append to the open thread and keep the inbox fresh. useEffect(() => { const socket = getSocket(); const onMessageNew = (msg: ConversationMessage) => { setConversations((prev) => { const existing = prev.find((c) => c.id === msg.conversationId); if (!existing) { listConversations().then(setConversations).catch(() => {}); return prev; } const isSelected = selectedIdRef.current === msg.conversationId; const incoming = msg.senderId !== myIdRef.current && !isSelected; const updated: ConversationSummary = { ...existing, lastMessage: msg, updatedAt: msg.createdAt, unread: incoming, unreadCount: incoming ? existing.unreadCount + 1 : 0, }; return [updated, ...prev.filter((c) => c.id !== msg.conversationId)]; }); if (selectedIdRef.current === msg.conversationId) { setMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg], ); socket.emit("message:read", msg.conversationId); } }; socket.on("message:new", onMessageNew); return () => { socket.off("message:new", onMessageNew); }; }, []); // Auto-scroll the open thread to the latest message. useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); }, [messages]); const unreadCount = conversations.filter((c) => c.unread).length; const selected = conversations.find((c) => c.id === selectedId) ?? null; const visible = useMemo(() => { const q = inboxQuery.trim().toLowerCase(); return conversations.filter((c) => { if (showUnreadOnly && !c.unread) return false; if (!q) return true; return ( c.name.toLowerCase().includes(q) || (c.lastMessage?.body.toLowerCase().includes(q) ?? false) ); }); }, [conversations, showUnreadOnly, inboxQuery]); const visibleMembers = useMemo(() => { const q = memberQuery.trim().toLowerCase(); return q ? members.filter((m) => m.name.toLowerCase().includes(q)) : members; }, [members, memberQuery]); const open = (id: string) => { setSelectedId(id); selectedIdRef.current = id; setDraft(""); setPending([]); setMessages([]); getMessages(id) .then(setMessages) .catch(() => {}); const socket = getSocket(); socket.emit("conversation:join", id); socket.emit("message:read", id); setConversations((prev) => prev.map((c) => c.id === id ? { ...c, unread: false, unreadCount: 0 } : c, ), ); }; // Once the inbox has loaded, auto-open a conversation deep-linked from a // notification. Guarded so it only fires once per target id. useEffect(() => { if (!deepLinkConversation || conversations.length === 0) return; if (openedDeepLink.current === deepLinkConversation) return; openedDeepLink.current = deepLinkConversation; open(deepLinkConversation); // `open` is stable enough for this one-shot; deps intentionally minimal. // eslint-disable-next-line react-hooks/exhaustive-deps }, [deepLinkConversation, conversations]); const send = (event: FormEvent) => { event.preventDefault(); const text = draft.trim(); if (!((text || pending.length > 0) && selected)) return; getSocket().emit("message:send", { conversationId: selected.id, body: text, attachments: pending.length > 0 ? pending : undefined, }); setDraft(""); setPending([]); }; // Open the file picker; on select, upload each and stage it. const onPickFiles = async (event: ChangeEvent) => { // Snapshot before resetting: `event.target.files` is a live FileList that // `event.target.value = ""` empties, so reading it afterwards (or in a later // tick) yields nothing. const picked = Array.from(event.target.files ?? []); event.target.value = ""; if (picked.length === 0) return; setUploading(true); try { for (const file of picked) { if (file.size > MAX_ATTACHMENT_BYTES) { notify.error( t("messages.attach.tooLargeTitle"), t("messages.attach.tooLargeBody"), ); continue; } const att = await uploadAttachment(file); setPending((prev) => [...prev, att]); } } catch { notify.error( t("messages.attach.uploadFailedTitle"), t("messages.attach.uploadFailedBody"), ); } finally { setUploading(false); } }; const openApptPicker = () => { setApptQuery(""); setApptOpen(true); listAppointments() .then(setAppts) .catch(() => setAppts([])); }; const attachAppointment = (a: Appointment) => { setPending((prev) => [ ...prev, { kind: "appointment", appointment: { fileNumber: a.fileNumber, name: a.name, date: a.date, time: a.time, type: a.type, provider: a.provider, status: a.status, }, }, ]); setApptOpen(false); }; const removePending = (index: number) => setPending((prev) => prev.filter((_, i) => i !== index)); const visibleAppts = useMemo(() => { const q = apptQuery.trim().toLowerCase(); return q ? appts.filter((a) => a.name.toLowerCase().includes(q)) : appts; }, [appts, apptQuery]); const openCompose = () => { setComposeOpen(true); setMemberQuery(""); listClinicMembers() .then(setMembers) .catch(() => setMembers([])); }; const startConversation = async (memberId: string) => { try { const conv = await createConversation({ participantIds: [memberId] }); setConversations((prev) => prev.some((c) => c.id === conv.id) ? prev : [conv, ...prev], ); setComposeOpen(false); open(conv.id); } catch { notify.error( t("messages.startFailedTitle"), t("messages.startFailedBody"), ); } }; return ( {/* Left: conversation list */} {/* Right: conversation timeline or empty state */} {selected ? ( {selected.isSystem ? ( ) : ( initials(selected.name) )} {selected.name} {selected.isSystem ? t("messages.system.label") : selected.isGroup ? t("messages.peopleCount", { count: selected.participants.length, }) : t("messages.directMessage")} {messages.map((m, i) => { const prev = messages[i - 1]; const next = messages[i + 1]; const out = m.senderId === myId; const newDay = !prev || !sameDay(prev.createdAt, m.createdAt); // A bubble starts/ends a group at sender changes, day breaks // or >5 min gaps; the group shares one label + timestamp. const startsGroup = newDay || !prev || prev.senderId !== m.senderId || +new Date(m.createdAt) - +new Date(prev.createdAt) > GROUP_WINDOW_MS; const endsGroup = !next || next.senderId !== m.senderId || !sameDay(m.createdAt, next.createdAt) || +new Date(next.createdAt) - +new Date(m.createdAt) > GROUP_WINDOW_MS; return ( {newDay && ( 0 && "mt-5", )} > {formatDay(m.createdAt)} )} {/* Avatar at the bottom of each run (messenger-style); a spacer keeps stacked bubbles aligned otherwise. */} {endsGroup ? ( {out ? myInitials : initials(m.senderName)} ) : ( )} {selected.isGroup && !out && startsGroup && ( {m.senderName} )} {m.body && ( {m.body} )} {m.attachments?.map((att, ai) => ( ))} {endsGroup && ( {formatTime(m.createdAt)} )} ); })} {selected.isSystem ? ( {t("messages.system.readOnly")} ) : ( {pending.length > 0 && ( {pending.map((att, i) => ( {att.kind === "file" ? ( ) : ( )} {att.kind === "file" ? att.fileName : att.kind === "appointment" ? att.appointment.name : ""} removePending(i)} type="button" > ))} )} setDraft(e.target.value)} onKeyDown={(e) => { // Enter sends; Shift+Enter inserts a newline. if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); e.currentTarget.form?.requestSubmit(); } }} placeholder={ uploading ? t("messages.attach.uploading") : t("messages.messagePlaceholder", { name: selected.name }) } rows={1} value={draft} /> {/* The attach control is a native wrapping the file input, so the browser opens the picker on a trusted click. A programmatic `inputRef.click()` (the old menu item) gets dropped by user-activation gating once the menu closes — which is why attaching silently failed and no chip appeared. */} )} ) : ( {t("messages.emptyTitle")} {t("messages.emptyDescription")} {t("messages.startConversation")} )} {/* Compose: pick a clinic member to message */} {t("messages.compose.title")} {t("messages.compose.description")} setMemberQuery(e.target.value)} placeholder={t("messages.compose.searchPlaceholder")} size="sm" value={memberQuery} /> {members.length === 0 ? ( {t("messages.compose.noMembers")} ) : visibleMembers.length === 0 ? ( {t("messages.compose.noMatches")} ) : ( visibleMembers.map((m) => ( startConversation(m.id)} type="button" > {initials(m.name)} {m.name} )) )} {/* Share an appointment: search by patient, pick one to attach */} {t("messages.attach.apptDialogTitle")} {t("messages.attach.apptDialogDescription")} setApptQuery(e.target.value)} placeholder={t("messages.attach.apptSearchPlaceholder")} size="sm" value={apptQuery} /> {appts.length === 0 ? ( {t("messages.attach.apptEmpty")} ) : visibleAppts.length === 0 ? ( {t("messages.attach.apptNoMatches")} ) : ( visibleAppts.map((a) => ( attachAppointment(a)} type="button" > {a.name} {[a.date, a.time, a.type, a.provider] .filter(Boolean) .join(" · ")} )) )} ); }
{t("messages.compose.noMembers")}
{t("messages.compose.noMatches")}
{t("messages.attach.apptEmpty")}
{t("messages.attach.apptNoMatches")}