diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index 2ef2859..349da7f 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -1,8 +1,10 @@ import { Link, useLocation, useNavigate } from "@tanstack/react-router"; import { useAuth } from "@/lib/auth"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { HeaderTimer } from "@/components/timer/header-timer"; import { QuickAddTime, QuickAddExpense } from "@/components/quick-add/quick-add"; +import { supabase } from "@/integrations/supabase/client"; import { Briefcase, Users, @@ -17,9 +19,10 @@ import { FolderArchive, ClipboardList, Contact, + MessageSquare, } from "lucide-react"; import { cn } from "@/lib/utils"; -import type { ReactNode } from "react"; +import { useEffect, useState, useCallback, type ReactNode } from "react"; interface NavItem { to: string; @@ -33,6 +36,7 @@ const NAV: NavItem[] = [ { to: "/clients", label: "Clients", icon: Users }, { to: "/cases", label: "Cases", icon: Briefcase }, { to: "/contacts", label: "Contacts", icon: Contact }, + { to: "/messages", label: "Messages", icon: MessageSquare }, { to: "/status", label: "Status Updates", icon: ClipboardList }, { to: "/collections", label: "Collections", icon: Wallet }, { to: "/documents", label: "Documents", icon: FileSignature }, @@ -42,10 +46,60 @@ const NAV: NavItem[] = [ { to: "/settings", label: "Settings", icon: Settings, adminOnly: true }, ]; +function useUnreadMessagesCount() { + const { user } = useAuth(); + const uid = user?.id; + const [count, setCount] = useState(0); + + const refresh = useCallback(async () => { + if (!uid) { + setCount(0); + return; + } + const { data: mems } = await supabase + .from("conversation_members") + .select("conversation_id, last_read_at") + .eq("user_id", uid); + if (!mems || mems.length === 0) { + setCount(0); + return; + } + let total = 0; + await Promise.all( + mems.map(async (m) => { + const { count: c } = await supabase + .from("messages") + .select("id", { count: "exact", head: true }) + .eq("conversation_id", m.conversation_id) + .gt("created_at", m.last_read_at) + .neq("sender_id", uid); + total += c ?? 0; + }), + ); + setCount(total); + }, [uid]); + + useEffect(() => { + refresh(); + if (!uid) return; + const ch = supabase + .channel("sidebar-unread") + .on("postgres_changes", { event: "*", schema: "public", table: "messages" }, () => refresh()) + .on("postgres_changes", { event: "UPDATE", schema: "public", table: "conversation_members", filter: `user_id=eq.${uid}` }, () => refresh()) + .subscribe(); + return () => { + supabase.removeChannel(ch); + }; + }, [uid, refresh]); + + return count; +} + export function AppShell({ children }: { children: ReactNode }) { const { user, signOut, isAdmin, roles } = useAuth(); const location = useLocation(); const navigate = useNavigate(); + const unreadMessages = useUnreadMessagesCount(); const handleSignOut = async () => { await signOut(); @@ -75,6 +129,7 @@ export function AppShell({ children }: { children: ReactNode }) { ? location.pathname === "/" : location.pathname.startsWith(item.to); const Icon = item.icon; + const showBadge = item.to === "/messages" && unreadMessages > 0; return ( - {item.label} + {item.label} + {showBadge && ( + + {unreadMessages} + + )} ); })} diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 77e48a7..98fc144 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -665,6 +665,65 @@ export type Database = { } Relationships: [] } + conversation_members: { + Row: { + conversation_id: string + created_at: string + id: string + last_read_at: string + user_id: string + } + Insert: { + conversation_id: string + created_at?: string + id?: string + last_read_at?: string + user_id: string + } + Update: { + conversation_id?: string + created_at?: string + id?: string + last_read_at?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "conversation_members_conversation_id_fkey" + columns: ["conversation_id"] + isOneToOne: false + referencedRelation: "conversations" + referencedColumns: ["id"] + }, + ] + } + conversations: { + Row: { + created_at: string + created_by: string | null + id: string + name: string | null + type: string + updated_at: string + } + Insert: { + created_at?: string + created_by?: string | null + id?: string + name?: string | null + type: string + updated_at?: string + } + Update: { + created_at?: string + created_by?: string | null + id?: string + name?: string | null + type?: string + updated_at?: string + } + Relationships: [] + } document_folders: { Row: { case_id: string @@ -1123,6 +1182,118 @@ export type Database = { }, ] } + message_attachments: { + Row: { + created_at: string + id: string + message_id: string + mime_type: string | null + name: string + size_bytes: number | null + storage_path: string + } + Insert: { + created_at?: string + id?: string + message_id: string + mime_type?: string | null + name: string + size_bytes?: number | null + storage_path: string + } + Update: { + created_at?: string + id?: string + message_id?: string + mime_type?: string | null + name?: string + size_bytes?: number | null + storage_path?: string + } + Relationships: [ + { + foreignKeyName: "message_attachments_message_id_fkey" + columns: ["message_id"] + isOneToOne: false + referencedRelation: "messages" + referencedColumns: ["id"] + }, + ] + } + message_mentions: { + Row: { + conversation_id: string + created_at: string + id: string + mentioned_user_id: string + message_id: string + } + Insert: { + conversation_id: string + created_at?: string + id?: string + mentioned_user_id: string + message_id: string + } + Update: { + conversation_id?: string + created_at?: string + id?: string + mentioned_user_id?: string + message_id?: string + } + Relationships: [ + { + foreignKeyName: "message_mentions_conversation_id_fkey" + columns: ["conversation_id"] + isOneToOne: false + referencedRelation: "conversations" + referencedColumns: ["id"] + }, + { + foreignKeyName: "message_mentions_message_id_fkey" + columns: ["message_id"] + isOneToOne: false + referencedRelation: "messages" + referencedColumns: ["id"] + }, + ] + } + messages: { + Row: { + body: string + conversation_id: string + created_at: string + edited_at: string | null + id: string + sender_id: string + } + Insert: { + body?: string + conversation_id: string + created_at?: string + edited_at?: string | null + id?: string + sender_id: string + } + Update: { + body?: string + conversation_id?: string + created_at?: string + edited_at?: string | null + id?: string + sender_id?: string + } + Relationships: [ + { + foreignKeyName: "messages_conversation_id_fkey" + columns: ["conversation_id"] + isOneToOne: false + referencedRelation: "conversations" + referencedColumns: ["id"] + }, + ] + } profiles: { Row: { created_at: string @@ -1283,6 +1454,10 @@ export type Database = { Returns: boolean } is_admin: { Args: { _user_id: string }; Returns: boolean } + is_conversation_member: { + Args: { _conv_id: string; _user_id: string } + Returns: boolean + } } Enums: { app_role: "admin" | "attorney" | "staff" diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index f3da2a6..54b4d36 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as LoginRouteImport } from './routes/login' import { Route as IndexRouteImport } from './routes/index' import { Route as StatusIndexRouteImport } from './routes/status.index' import { Route as SettingsIndexRouteImport } from './routes/settings.index' +import { Route as MessagesIndexRouteImport } from './routes/messages.index' import { Route as FilesIndexRouteImport } from './routes/files.index' import { Route as DocumentsIndexRouteImport } from './routes/documents.index' import { Route as ContactsIndexRouteImport } from './routes/contacts.index' @@ -64,6 +65,11 @@ const SettingsIndexRoute = SettingsIndexRouteImport.update({ path: '/', getParentRoute: () => SettingsRoute, } as any) +const MessagesIndexRoute = MessagesIndexRouteImport.update({ + id: '/messages/', + path: '/messages/', + getParentRoute: () => rootRouteImport, +} as any) const FilesIndexRoute = FilesIndexRouteImport.update({ id: '/files/', path: '/files/', @@ -175,6 +181,7 @@ export interface FileRoutesByFullPath { '/contacts/': typeof ContactsIndexRoute '/documents/': typeof DocumentsIndexRoute '/files/': typeof FilesIndexRoute + '/messages/': typeof MessagesIndexRoute '/settings/': typeof SettingsIndexRoute '/status/': typeof StatusIndexRoute '/documents/pleading/new': typeof DocumentsPleadingNewRoute @@ -200,6 +207,7 @@ export interface FileRoutesByTo { '/contacts': typeof ContactsIndexRoute '/documents': typeof DocumentsIndexRoute '/files': typeof FilesIndexRoute + '/messages': typeof MessagesIndexRoute '/settings': typeof SettingsIndexRoute '/status': typeof StatusIndexRoute '/documents/pleading/new': typeof DocumentsPleadingNewRoute @@ -227,6 +235,7 @@ export interface FileRoutesById { '/contacts/': typeof ContactsIndexRoute '/documents/': typeof DocumentsIndexRoute '/files/': typeof FilesIndexRoute + '/messages/': typeof MessagesIndexRoute '/settings/': typeof SettingsIndexRoute '/status/': typeof StatusIndexRoute '/documents/pleading/new': typeof DocumentsPleadingNewRoute @@ -255,6 +264,7 @@ export interface FileRouteTypes { | '/contacts/' | '/documents/' | '/files/' + | '/messages/' | '/settings/' | '/status/' | '/documents/pleading/new' @@ -280,6 +290,7 @@ export interface FileRouteTypes { | '/contacts' | '/documents' | '/files' + | '/messages' | '/settings' | '/status' | '/documents/pleading/new' @@ -306,6 +317,7 @@ export interface FileRouteTypes { | '/contacts/' | '/documents/' | '/files/' + | '/messages/' | '/settings/' | '/status/' | '/documents/pleading/new' @@ -331,6 +343,7 @@ export interface RootRouteChildren { ContactsIndexRoute: typeof ContactsIndexRoute DocumentsIndexRoute: typeof DocumentsIndexRoute FilesIndexRoute: typeof FilesIndexRoute + MessagesIndexRoute: typeof MessagesIndexRoute StatusIndexRoute: typeof StatusIndexRoute DocumentsPleadingNewRoute: typeof DocumentsPleadingNewRoute DocumentsTemplatesTemplateIdRoute: typeof DocumentsTemplatesTemplateIdRoute @@ -382,6 +395,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsIndexRouteImport parentRoute: typeof SettingsRoute } + '/messages/': { + id: '/messages/' + path: '/messages' + fullPath: '/messages/' + preLoaderRoute: typeof MessagesIndexRouteImport + parentRoute: typeof rootRouteImport + } '/files/': { id: '/files/' path: '/files' @@ -544,6 +564,7 @@ const rootRouteChildren: RootRouteChildren = { ContactsIndexRoute: ContactsIndexRoute, DocumentsIndexRoute: DocumentsIndexRoute, FilesIndexRoute: FilesIndexRoute, + MessagesIndexRoute: MessagesIndexRoute, StatusIndexRoute: StatusIndexRoute, DocumentsPleadingNewRoute: DocumentsPleadingNewRoute, DocumentsTemplatesTemplateIdRoute: DocumentsTemplatesTemplateIdRoute, diff --git a/src/routes/messages.index.tsx b/src/routes/messages.index.tsx new file mode 100644 index 0000000..abb7d26 --- /dev/null +++ b/src/routes/messages.index.tsx @@ -0,0 +1,769 @@ +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") + .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 { data: conv, error } = await supabase + .from("conversations") + .insert({ type, name: type === "channel" ? name.trim() : null, created_by: user.id }) + .select("id") + .single(); + if (error || !conv) throw error; + const memberRows = [user.id, ...Array.from(selected)].map((uid) => ({ + conversation_id: conv.id, + 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(conv.id); + } 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}`) + .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) => ( + + ))} +
+ )} +
+