"use client"; import { Mail, Plus, Search, SendHorizonal } from "lucide-react"; import { type FormEvent, 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, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from "@/components/ui/empty"; import { Input } from "@/components/ui/input"; import { authClient } from "@/lib/auth-client"; import { type ConversationMessage, type ConversationSummary, type Participant, createConversation, getMessages, listClinicMembers, listConversations, } from "@/lib/messages"; import { getSocket } from "@/lib/socket"; import { notify } from "@/lib/toast"; import { cn } from "@/lib/utils"; // 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, }); } export function MessagesView() { const { t } = useTranslation(); const { data: session } = authClient.useSession(); const myId = session?.user?.id ?? ""; 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(""); // 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 updated: ConversationSummary = { ...existing, lastMessage: msg, updatedAt: msg.createdAt, unread: msg.senderId !== myIdRef.current && !isSelected, }; 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(""); 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 } : c)), ); }; const send = (event: FormEvent) => { event.preventDefault(); const text = draft.trim(); if (!(text && selected)) return; getSocket().emit("message:send", { conversationId: selected.id, body: text, }); setDraft(""); }; 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) => { const out = m.senderId === myId; return (
{selected.isGroup && !out && ( {m.senderName} )}
{m.body}
{formatTime(m.createdAt)}
); })}
setDraft(e.target.value)} placeholder={t("messages.messagePlaceholder", { name: selected.name, })} value={draft} />
) : (
{t("messages.emptyTitle")} {t("messages.emptyDescription")}
)}
{/* 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) => ( )) )}
); }