"use client";
import {
CalendarClock,
Download,
FileText,
Mail,
Plus,
Search,
SendHorizonal,
X,
} from "lucide-react";
import {
type ChangeEvent,
type FormEvent,
Fragment,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
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 {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { Input } from "@/components/ui/input";
import { Menu, MenuItem, MenuPopup, MenuTrigger } from "@/components/ui/menu";
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: a downloadable file chip or a
// shared-appointment card. Alignment (left/right) comes from the parent column.
function SentAttachment({ att }: { att: MessageAttachment }) {
const { t } = useTranslation();
if (att.kind === "file") {
return (
);
}
const a = att.appointment;
return (
{t("messages.attach.apptCardLabel")}
{a.name}
{[a.date, a.time].filter(Boolean).join(" · ")}
{[a.type, a.provider].filter(Boolean).length > 0 && (
{[a.type, a.provider].filter(Boolean).join(" · ")}
)}
);
}
export function MessagesView() {
const { t } = useTranslation();
const { data: session } = authClient.useSession();
const myId = session?.user?.id ?? "";
// "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,
),
);
};
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 ? (
{initials(selected.name)}
{selected.name}
{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)}
)}
{selected.isGroup && !out && startsGroup && (
{m.senderName}
)}
{m.body && (
{m.body}
)}
{m.attachments?.map((att, ai) => (
))}
{endsGroup && (
{formatTime(m.createdAt)}
)}
);
})}
) : (
{t("messages.emptyTitle")}
{t("messages.emptyDescription")}
)}
{/* Compose: pick a clinic member to message */}
{/* Share an appointment: search by patient, pick one to attach */}
);
}