From bf2489fa192317314765b2278ce776b3eb6664d5 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 02:27:31 +0000 Subject: [PATCH 1/4] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/integrations/supabase/types.ts | 175 ++++++++++++++++++ ...8_ba6920ef-8f66-491b-afe3-b0227823de19.sql | 145 +++++++++++++++ 2 files changed, 320 insertions(+) create mode 100644 supabase/migrations/20260417022728_ba6920ef-8f66-491b-afe3-b0227823de19.sql 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/supabase/migrations/20260417022728_ba6920ef-8f66-491b-afe3-b0227823de19.sql b/supabase/migrations/20260417022728_ba6920ef-8f66-491b-afe3-b0227823de19.sql new file mode 100644 index 0000000..e41e62f --- /dev/null +++ b/supabase/migrations/20260417022728_ba6920ef-8f66-491b-afe3-b0227823de19.sql @@ -0,0 +1,145 @@ +-- Conversations +CREATE TABLE public.conversations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + type text NOT NULL CHECK (type IN ('dm','channel')), + name text, + created_by uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +ALTER TABLE public.conversations ENABLE ROW LEVEL SECURITY; + +-- Conversation members +CREATE TABLE public.conversation_members ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + conversation_id uuid NOT NULL REFERENCES public.conversations(id) ON DELETE CASCADE, + user_id uuid NOT NULL, + last_read_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (conversation_id, user_id) +); +ALTER TABLE public.conversation_members ENABLE ROW LEVEL SECURITY; +CREATE INDEX idx_cm_conv ON public.conversation_members(conversation_id); +CREATE INDEX idx_cm_user ON public.conversation_members(user_id); + +-- Security definer to avoid recursion +CREATE OR REPLACE FUNCTION public.is_conversation_member(_conv_id uuid, _user_id uuid) +RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$ + SELECT EXISTS (SELECT 1 FROM public.conversation_members WHERE conversation_id = _conv_id AND user_id = _user_id) +$$; + +-- Messages +CREATE TABLE public.messages ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + conversation_id uuid NOT NULL REFERENCES public.conversations(id) ON DELETE CASCADE, + sender_id uuid NOT NULL, + body text NOT NULL DEFAULT '', + edited_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); +ALTER TABLE public.messages ENABLE ROW LEVEL SECURITY; +CREATE INDEX idx_msg_conv ON public.messages(conversation_id, created_at DESC); + +-- Attachments +CREATE TABLE public.message_attachments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + message_id uuid NOT NULL REFERENCES public.messages(id) ON DELETE CASCADE, + storage_path text NOT NULL, + name text NOT NULL, + mime_type text, + size_bytes bigint, + created_at timestamptz NOT NULL DEFAULT now() +); +ALTER TABLE public.message_attachments ENABLE ROW LEVEL SECURITY; +CREATE INDEX idx_att_msg ON public.message_attachments(message_id); + +-- Mentions +CREATE TABLE public.message_mentions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + message_id uuid NOT NULL REFERENCES public.messages(id) ON DELETE CASCADE, + conversation_id uuid NOT NULL REFERENCES public.conversations(id) ON DELETE CASCADE, + mentioned_user_id uuid NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +ALTER TABLE public.message_mentions ENABLE ROW LEVEL SECURITY; +CREATE INDEX idx_mention_user ON public.message_mentions(mentioned_user_id); +CREATE INDEX idx_mention_msg ON public.message_mentions(message_id); + +-- Trigger to update conversations.updated_at on new message +CREATE OR REPLACE FUNCTION public.tg_touch_conversation() +RETURNS trigger LANGUAGE plpgsql SET search_path = public AS $$ +BEGIN + UPDATE public.conversations SET updated_at = now() WHERE id = NEW.conversation_id; + RETURN NEW; +END; +$$; +CREATE TRIGGER trg_touch_conv AFTER INSERT ON public.messages +FOR EACH ROW EXECUTE FUNCTION public.tg_touch_conversation(); + +CREATE TRIGGER trg_conv_updated BEFORE UPDATE ON public.conversations +FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at(); + +-- RLS POLICIES +-- Conversations +CREATE POLICY conv_select ON public.conversations FOR SELECT TO authenticated +USING (public.is_conversation_member(id, auth.uid())); +CREATE POLICY conv_insert ON public.conversations FOR INSERT TO authenticated +WITH CHECK (auth.uid() IS NOT NULL); +CREATE POLICY conv_update ON public.conversations FOR UPDATE TO authenticated +USING (public.is_conversation_member(id, auth.uid())); +CREATE POLICY conv_delete ON public.conversations FOR DELETE TO authenticated +USING (created_by = auth.uid() OR public.is_admin(auth.uid())); + +-- Conversation members +CREATE POLICY cm_select ON public.conversation_members FOR SELECT TO authenticated +USING (public.is_conversation_member(conversation_id, auth.uid())); +CREATE POLICY cm_insert ON public.conversation_members FOR INSERT TO authenticated +WITH CHECK (auth.uid() IS NOT NULL); +CREATE POLICY cm_update_self ON public.conversation_members FOR UPDATE TO authenticated +USING (user_id = auth.uid()); +CREATE POLICY cm_delete ON public.conversation_members FOR DELETE TO authenticated +USING (user_id = auth.uid() OR public.is_admin(auth.uid())); + +-- Messages +CREATE POLICY msg_select ON public.messages FOR SELECT TO authenticated +USING (public.is_conversation_member(conversation_id, auth.uid())); +CREATE POLICY msg_insert ON public.messages FOR INSERT TO authenticated +WITH CHECK (sender_id = auth.uid() AND public.is_conversation_member(conversation_id, auth.uid())); +CREATE POLICY msg_update_own ON public.messages FOR UPDATE TO authenticated +USING (sender_id = auth.uid()); +CREATE POLICY msg_delete_own ON public.messages FOR DELETE TO authenticated +USING (sender_id = auth.uid() OR public.is_admin(auth.uid())); + +-- Attachments +CREATE POLICY att_select ON public.message_attachments FOR SELECT TO authenticated +USING (EXISTS (SELECT 1 FROM public.messages m WHERE m.id = message_id AND public.is_conversation_member(m.conversation_id, auth.uid()))); +CREATE POLICY att_insert ON public.message_attachments FOR INSERT TO authenticated +WITH CHECK (EXISTS (SELECT 1 FROM public.messages m WHERE m.id = message_id AND m.sender_id = auth.uid())); +CREATE POLICY att_delete ON public.message_attachments FOR DELETE TO authenticated +USING (EXISTS (SELECT 1 FROM public.messages m WHERE m.id = message_id AND (m.sender_id = auth.uid() OR public.is_admin(auth.uid())))); + +-- Mentions +CREATE POLICY mention_select ON public.message_mentions FOR SELECT TO authenticated +USING (mentioned_user_id = auth.uid() OR public.is_conversation_member(conversation_id, auth.uid())); +CREATE POLICY mention_insert ON public.message_mentions FOR INSERT TO authenticated +WITH CHECK (public.is_conversation_member(conversation_id, auth.uid())); +CREATE POLICY mention_delete ON public.message_mentions FOR DELETE TO authenticated +USING (EXISTS (SELECT 1 FROM public.messages m WHERE m.id = message_id AND m.sender_id = auth.uid())); + +-- Realtime +ALTER PUBLICATION supabase_realtime ADD TABLE public.messages; +ALTER PUBLICATION supabase_realtime ADD TABLE public.conversation_members; +ALTER PUBLICATION supabase_realtime ADD TABLE public.message_mentions; +ALTER PUBLICATION supabase_realtime ADD TABLE public.conversations; + +-- Storage bucket for attachments +INSERT INTO storage.buckets (id, name, public) VALUES ('message-attachments', 'message-attachments', false) +ON CONFLICT (id) DO NOTHING; + +-- Storage policies: any authenticated user can upload/read (we gate via signed URLs and DB-level access) +CREATE POLICY "msg_attach_select" ON storage.objects FOR SELECT TO authenticated +USING (bucket_id = 'message-attachments'); +CREATE POLICY "msg_attach_insert" ON storage.objects FOR INSERT TO authenticated +WITH CHECK (bucket_id = 'message-attachments' AND auth.uid()::text = (storage.foldername(name))[1]); +CREATE POLICY "msg_attach_delete" ON storage.objects FOR DELETE TO authenticated +USING (bucket_id = 'message-attachments' AND auth.uid()::text = (storage.foldername(name))[1]); \ No newline at end of file From 36ff95274464de5fd0ee5dae469be4b308bee178 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 02:29:08 +0000 Subject: [PATCH 2/4] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routeTree.gen.ts | 30 ++ src/routes/messages.index.tsx | 769 ++++++++++++++++++++++++++++++++++ 2 files changed, 799 insertions(+) create mode 100644 src/routes/messages.index.tsx diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index f3da2a6..8365021 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, @@ -553,3 +574,12 @@ const rootRouteChildren: RootRouteChildren = { export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} 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) => ( + + ))} +
+ )} +
+