import { createFileRoute } from "@tanstack/react-router"; import { useEffect, useMemo, useRef, useState, useCallback } from "react"; import { useAuth } from "@/lib/auth"; import { supabase } from "@/integrations/supabase/client"; import { AppShell, PageContainer, PageHeader } from "@/components/app-shell"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogTrigger, } from "@/components/ui/dialog"; import { Badge } from "@/components/ui/badge"; import { toast } from "sonner"; import { Plus, Send, Paperclip, X, Hash, Users, AtSign, FileText, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; export const Route = createFileRoute("/messages/")({ component: MessagesPage, }); interface Profile { id: string; full_name: string; email: string; } interface Conversation { id: string; type: "dm" | "channel"; name: string | null; updated_at: string; } interface MemberRow { conversation_id: string; user_id: string; last_read_at: string; } interface MessageRow { id: string; conversation_id: string; sender_id: string; body: string; edited_at: string | null; created_at: string; } interface AttachmentRow { id: string; message_id: string; storage_path: string; name: string; mime_type: string | null; size_bytes: number | null; } function initials(name: string) { return name .split(" ") .map((p) => p[0]) .filter(Boolean) .slice(0, 2) .join("") .toUpperCase(); } function MessagesPage() { const { user } = useAuth(); const uid = user?.id; const [profiles, setProfiles] = useState([]); const [conversations, setConversations] = useState([]); const [members, setMembers] = useState([]); const [activeId, setActiveId] = useState(null); const [unreadByConv, setUnreadByConv] = useState>({}); const [mentionUnreadByConv, setMentionUnreadByConv] = useState>({}); const profilesById = useMemo(() => { const m: Record = {}; for (const p of profiles) m[p.id] = p; return m; }, [profiles]); // Load profiles + my conversations const loadAll = useCallback(async () => { if (!uid) return; const [{ data: pr }, { data: myMems }] = await Promise.all([ supabase.from("profiles").select("id, full_name, email"), supabase.from("conversation_members").select("conversation_id, user_id, last_read_at").eq("user_id", uid), ]); setProfiles((pr ?? []) as Profile[]); const convIds = (myMems ?? []).map((m) => m.conversation_id); if (convIds.length === 0) { setConversations([]); setMembers([]); return; } const [{ data: convs }, { data: allMems }] = await Promise.all([ supabase.from("conversations").select("id, type, name, updated_at").in("id", convIds).order("updated_at", { ascending: false }), supabase.from("conversation_members").select("conversation_id, user_id, last_read_at").in("conversation_id", convIds), ]); setConversations((convs ?? []) as Conversation[]); setMembers((allMems ?? []) as MemberRow[]); }, [uid]); // Compute unread counts based on last_read_at per conversation const refreshUnread = useCallback(async () => { if (!uid) return; const myMems = members.filter((m) => m.user_id === uid); if (myMems.length === 0) return; const counts: Record = {}; const mentions: Record = {}; await Promise.all( myMems.map(async (mm) => { const { count } = await supabase .from("messages") .select("id", { count: "exact", head: true }) .eq("conversation_id", mm.conversation_id) .gt("created_at", mm.last_read_at) .neq("sender_id", uid); counts[mm.conversation_id] = count ?? 0; const { count: mc } = await supabase .from("message_mentions") .select("id", { count: "exact", head: true }) .eq("conversation_id", mm.conversation_id) .eq("mentioned_user_id", uid) .gt("created_at", mm.last_read_at); mentions[mm.conversation_id] = mc ?? 0; }), ); setUnreadByConv(counts); setMentionUnreadByConv(mentions); }, [uid, members]); useEffect(() => { loadAll(); }, [loadAll]); useEffect(() => { refreshUnread(); }, [refreshUnread]); // Realtime: any new message touches conv list and unread useEffect(() => { if (!uid) return; const ch = supabase .channel(`messages-global-${Math.random().toString(36).slice(2)}`) .on("postgres_changes", { event: "INSERT", schema: "public", table: "messages" }, () => { loadAll(); refreshUnread(); }) .on("postgres_changes", { event: "INSERT", schema: "public", table: "conversation_members", filter: `user_id=eq.${uid}` }, () => { loadAll(); }) .subscribe(); return () => { supabase.removeChannel(ch); }; }, [uid, loadAll, refreshUnread]); const labelFor = (c: Conversation) => { if (c.type === "channel") return c.name || "Channel"; const others = members .filter((m) => m.conversation_id === c.id && m.user_id !== uid) .map((m) => profilesById[m.user_id]?.full_name || profilesById[m.user_id]?.email || "User"); return others.join(", ") || "Direct message"; }; const onActivate = async (id: string) => { setActiveId(id); if (uid) { await supabase .from("conversation_members") .update({ last_read_at: new Date().toISOString() }) .eq("conversation_id", id) .eq("user_id", uid); refreshUnread(); } }; return ( p.id !== uid)} onCreated={async (id) => { await loadAll(); onActivate(id); }} /> } />
{/* Sidebar */}
Conversations
{conversations.length === 0 && (
No conversations yet. Start one with the + button.
)} {conversations.map((c) => { const unread = unreadByConv[c.id] ?? 0; const mentions = mentionUnreadByConv[c.id] ?? 0; const active = activeId === c.id; return ( ); })}
{/* Chat panel */}
{activeId ? ( c.id === activeId)!} members={members.filter((m) => m.conversation_id === activeId)} profilesById={profilesById} profiles={profiles} onMessagesRead={refreshUnread} /> ) : (
Select a conversation
)}
); } /* -------- New conversation dialog -------- */ function NewConversationDialog({ profiles, onCreated }: { profiles: Profile[]; onCreated: (id: string) => void }) { const { user } = useAuth(); const [open, setOpen] = useState(false); const [type, setType] = useState<"dm" | "channel">("dm"); const [name, setName] = useState(""); const [selected, setSelected] = useState>(new Set()); const [search, setSearch] = useState(""); const [busy, setBusy] = useState(false); const filtered = profiles.filter((p) => (p.full_name || p.email).toLowerCase().includes(search.toLowerCase()), ); const reset = () => { setType("dm"); setName(""); setSelected(new Set()); setSearch(""); }; const create = async () => { if (!user) return; if (selected.size === 0) { toast.error("Pick at least one teammate"); return; } if (type === "channel" && !name.trim()) { toast.error("Channel needs a name"); return; } setBusy(true); try { const convId = crypto.randomUUID(); const { error } = await supabase .from("conversations") .insert({ id: convId, type, name: type === "channel" ? name.trim() : null, created_by: user.id }); if (error) throw error; const memberRows = [user.id, ...Array.from(selected)].map((uid) => ({ conversation_id: convId, user_id: uid, })); const { error: mErr } = await supabase.from("conversation_members").insert(memberRows); if (mErr) throw mErr; toast.success("Conversation created"); setOpen(false); reset(); onCreated(convId); } catch (e: any) { toast.error(e.message || "Failed to create conversation"); } finally { setBusy(false); } }; return ( { setOpen(o); if (!o) reset(); }} > Start a conversation
{type === "channel" && ( setName(e.target.value)} /> )} setSearch(e.target.value)} />
{filtered.map((p) => { const isSel = selected.has(p.id); return ( ); })} {filtered.length === 0 &&
No matches
}
); } /* -------- Chat panel -------- */ function ChatPanel({ conversationId, conversation, members, profilesById, profiles, onMessagesRead, }: { conversationId: string; conversation: Conversation; members: MemberRow[]; profilesById: Record; profiles: Profile[]; onMessagesRead: () => void; }) { const { user } = useAuth(); const uid = user?.id; const [messages, setMessages] = useState([]); const [attachments, setAttachments] = useState>({}); const [body, setBody] = useState(""); const [pendingFiles, setPendingFiles] = useState([]); const [sending, setSending] = useState(false); const [mentionOpen, setMentionOpen] = useState(false); const [mentionQuery, setMentionQuery] = useState(""); const [mentionedIds, setMentionedIds] = useState>(new Set()); const scrollRef = useRef(null); const taRef = useRef(null); const memberProfiles = members.map((m) => profilesById[m.user_id]).filter(Boolean) as Profile[]; const headerTitle = conversation.type === "channel" ? `# ${conversation.name}` : memberProfiles .filter((p) => p.id !== uid) .map((p) => p.full_name || p.email) .join(", ") || "Direct message"; const loadMessages = useCallback(async () => { const { data } = await supabase .from("messages") .select("id, conversation_id, sender_id, body, edited_at, created_at") .eq("conversation_id", conversationId) .order("created_at", { ascending: true }) .limit(500); const msgs = (data ?? []) as MessageRow[]; setMessages(msgs); if (msgs.length > 0) { const ids = msgs.map((m) => m.id); const { data: atts } = await supabase .from("message_attachments") .select("id, message_id, storage_path, name, mime_type, size_bytes") .in("message_id", ids); const grouped: Record = {}; for (const a of (atts ?? []) as AttachmentRow[]) { (grouped[a.message_id] ||= []).push(a); } setAttachments(grouped); } else { setAttachments({}); } }, [conversationId]); useEffect(() => { loadMessages(); }, [loadMessages]); // Mark read on open and when new messages arrive const markRead = useCallback(async () => { if (!uid) return; await supabase .from("conversation_members") .update({ last_read_at: new Date().toISOString() }) .eq("conversation_id", conversationId) .eq("user_id", uid); onMessagesRead(); }, [conversationId, uid, onMessagesRead]); // Realtime subscription scoped to this conversation useEffect(() => { const ch = supabase .channel(`conv-${conversationId}-${Math.random().toString(36).slice(2)}`) .on( "postgres_changes", { event: "*", schema: "public", table: "messages", filter: `conversation_id=eq.${conversationId}` }, () => { loadMessages().then(() => markRead()); }, ) .on( "postgres_changes", { event: "INSERT", schema: "public", table: "message_attachments" }, () => loadMessages(), ) .subscribe(); return () => { supabase.removeChannel(ch); }; }, [conversationId, loadMessages, markRead]); // Auto-scroll on new messages useEffect(() => { const el = scrollRef.current; if (el) el.scrollTop = el.scrollHeight; }, [messages.length]); // Mention parser: find @text near cursor const handleBodyChange = (val: string) => { setBody(val); const ta = taRef.current; if (!ta) return; const cursor = ta.selectionStart ?? val.length; const upToCursor = val.slice(0, cursor); const match = upToCursor.match(/@([\w]*)$/); if (match) { setMentionOpen(true); setMentionQuery(match[1]); } else { setMentionOpen(false); } }; const insertMention = (p: Profile) => { const ta = taRef.current; if (!ta) return; const cursor = ta.selectionStart ?? body.length; const before = body.slice(0, cursor).replace(/@([\w]*)$/, `@${(p.full_name || p.email).replace(/\s+/g, "_")} `); const after = body.slice(cursor); const newBody = before + after; setBody(newBody); const ns = new Set(mentionedIds); ns.add(p.id); setMentionedIds(ns); setMentionOpen(false); setTimeout(() => { ta.focus(); const pos = before.length; ta.setSelectionRange(pos, pos); }, 0); }; const send = async () => { if (!uid) return; if (!body.trim() && pendingFiles.length === 0) return; setSending(true); try { const { data: msg, error } = await supabase .from("messages") .insert({ conversation_id: conversationId, sender_id: uid, body: body.trim() }) .select("id") .single(); if (error || !msg) throw error; // Upload attachments if (pendingFiles.length > 0) { const attachRows: any[] = []; for (const f of pendingFiles) { const path = `${uid}/${msg.id}/${Date.now()}-${f.name}`; const { error: upErr } = await supabase.storage.from("message-attachments").upload(path, f); if (upErr) { toast.error(`Upload failed: ${f.name}`); continue; } attachRows.push({ message_id: msg.id, storage_path: path, name: f.name, mime_type: f.type || null, size_bytes: f.size, }); } if (attachRows.length > 0) { await supabase.from("message_attachments").insert(attachRows); } } // Mentions: only those whose handle still appears in the body const stillMentioned: string[] = []; for (const id of mentionedIds) { const p = profilesById[id]; if (!p) continue; const handle = (p.full_name || p.email).replace(/\s+/g, "_"); if (body.includes(`@${handle}`)) stillMentioned.push(id); } if (stillMentioned.length > 0) { await supabase.from("message_mentions").insert( stillMentioned.map((mid) => ({ message_id: msg.id, conversation_id: conversationId, mentioned_user_id: mid, })), ); } setBody(""); setPendingFiles([]); setMentionedIds(new Set()); // Realtime will reload messages } catch (e: any) { toast.error(e.message || "Failed to send"); } finally { setSending(false); } }; const handleFiles = (files: FileList | null) => { if (!files) return; setPendingFiles((prev) => [...prev, ...Array.from(files)]); }; const mentionMatches = profiles .filter((p) => p.id !== uid) .filter((p) => (p.full_name || p.email).toLowerCase().includes(mentionQuery.toLowerCase())) .slice(0, 6); return (
{conversation.type === "channel" ? : } {headerTitle}
{memberProfiles.length} member{memberProfiles.length !== 1 ? "s" : ""}
{messages.length === 0 && (
No messages yet. Say hi 👋
)} {messages.map((m, i) => { const sender = profilesById[m.sender_id]; const prev = messages[i - 1]; const showHeader = !prev || prev.sender_id !== m.sender_id || new Date(m.created_at).getTime() - new Date(prev.created_at).getTime() > 5 * 60 * 1000; return (
{showHeader && ( {initials(sender?.full_name || sender?.email || "?")} )}
{showHeader && (
{sender?.full_name || sender?.email || "Unknown"} {new Date(m.created_at).toLocaleString([], { hour: "numeric", minute: "2-digit", month: "short", day: "numeric" })}
)} {m.body &&
{renderBody(m.body)}
} {(attachments[m.id] || []).map((a) => ( ))}
); })}
{/* Composer */}
{pendingFiles.length > 0 && (
{pendingFiles.map((f, i) => (
{f.name}
))}
)} {mentionOpen && mentionMatches.length > 0 && (
{mentionMatches.map((p) => ( ))}
)}