Added Slack-style messaging

X-Lovable-Edit-ID: edt-8c8522ef-3905-43db-861f-7f997b510e1c
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-17 02:30:25 +00:00
co-authored by renee-png
5 changed files with 1172 additions and 2 deletions
+62 -2
View File
@@ -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 (
<Link
key={item.to}
@@ -87,7 +142,12 @@ export function AppShell({ children }: { children: ReactNode }) {
)}
>
<Icon className="h-4 w-4" />
{item.label}
<span className="flex-1">{item.label}</span>
{showBadge && (
<Badge variant="destructive" className="h-5 px-1.5 text-[10px]">
{unreadMessages}
</Badge>
)}
</Link>
);
})}
+175
View File
@@ -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"
+21
View File
@@ -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,
+769
View File
@@ -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<Profile[]>([]);
const [conversations, setConversations] = useState<Conversation[]>([]);
const [members, setMembers] = useState<MemberRow[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
const [unreadByConv, setUnreadByConv] = useState<Record<string, number>>({});
const [mentionUnreadByConv, setMentionUnreadByConv] = useState<Record<string, number>>({});
const profilesById = useMemo(() => {
const m: Record<string, Profile> = {};
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<string, number> = {};
const mentions: Record<string, number> = {};
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 (
<AppShell>
<PageContainer>
<PageHeader
title="Messages"
description="Real-time chat with your firm"
actions={
<NewConversationDialog
profiles={profiles.filter((p) => p.id !== uid)}
onCreated={async (id) => {
await loadAll();
onActivate(id);
}}
/>
}
/>
<div className="grid grid-cols-1 md:grid-cols-[280px_1fr] gap-4 h-[calc(100vh-220px)] min-h-[500px]">
{/* Sidebar */}
<div className="border rounded-lg bg-card flex flex-col overflow-hidden">
<div className="p-3 border-b">
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Conversations</div>
</div>
<ScrollArea className="flex-1">
<div className="p-2 space-y-1">
{conversations.length === 0 && (
<div className="text-xs text-muted-foreground p-3">No conversations yet. Start one with the + button.</div>
)}
{conversations.map((c) => {
const unread = unreadByConv[c.id] ?? 0;
const mentions = mentionUnreadByConv[c.id] ?? 0;
const active = activeId === c.id;
return (
<button
key={c.id}
onClick={() => onActivate(c.id)}
className={cn(
"w-full text-left px-2.5 py-2 rounded-md text-sm flex items-center gap-2 transition-colors",
active ? "bg-primary text-primary-foreground" : "hover:bg-accent",
)}
>
{c.type === "channel" ? <Hash className="h-3.5 w-3.5 shrink-0" /> : <Users className="h-3.5 w-3.5 shrink-0" />}
<span className={cn("flex-1 truncate", unread > 0 && !active && "font-semibold")}>{labelFor(c)}</span>
{mentions > 0 && (
<Badge variant="destructive" className="h-5 px-1.5 text-[10px]">
@{mentions}
</Badge>
)}
{unread > 0 && mentions === 0 && (
<Badge variant={active ? "secondary" : "default"} className="h-5 px-1.5 text-[10px]">
{unread}
</Badge>
)}
</button>
);
})}
</div>
</ScrollArea>
</div>
{/* Chat panel */}
<div className="border rounded-lg bg-card overflow-hidden">
{activeId ? (
<ChatPanel
key={activeId}
conversationId={activeId}
conversation={conversations.find((c) => c.id === activeId)!}
members={members.filter((m) => m.conversation_id === activeId)}
profilesById={profilesById}
profiles={profiles}
onMessagesRead={refreshUnread}
/>
) : (
<div className="h-full flex items-center justify-center text-sm text-muted-foreground">
Select a conversation
</div>
)}
</div>
</div>
</PageContainer>
</AppShell>
);
}
/* -------- 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<Set<string>>(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 (
<Dialog
open={open}
onOpenChange={(o) => {
setOpen(o);
if (!o) reset();
}}
>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-1" /> New
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Start a conversation</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="flex gap-2">
<Button variant={type === "dm" ? "default" : "outline"} size="sm" onClick={() => setType("dm")}>
<Users className="h-3.5 w-3.5 mr-1" /> Direct / Group DM
</Button>
<Button variant={type === "channel" ? "default" : "outline"} size="sm" onClick={() => setType("channel")}>
<Hash className="h-3.5 w-3.5 mr-1" /> Channel
</Button>
</div>
{type === "channel" && (
<Input placeholder="Channel name" value={name} onChange={(e) => setName(e.target.value)} />
)}
<Input placeholder="Search teammates…" value={search} onChange={(e) => setSearch(e.target.value)} />
<ScrollArea className="h-56 border rounded-md">
<div className="p-1">
{filtered.map((p) => {
const isSel = selected.has(p.id);
return (
<button
key={p.id}
onClick={() => {
const ns = new Set(selected);
if (isSel) ns.delete(p.id);
else ns.add(p.id);
setSelected(ns);
}}
className={cn(
"w-full text-left flex items-center gap-2 px-2 py-1.5 rounded text-sm",
isSel ? "bg-primary text-primary-foreground" : "hover:bg-accent",
)}
>
<Avatar className="h-6 w-6">
<AvatarFallback className="text-[10px]">{initials(p.full_name || p.email)}</AvatarFallback>
</Avatar>
<span className="flex-1 truncate">{p.full_name || p.email}</span>
{isSel && <span className="text-[10px]">✓</span>}
</button>
);
})}
{filtered.length === 0 && <div className="text-xs text-muted-foreground p-3">No matches</div>}
</div>
</ScrollArea>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={create} disabled={busy}>
{busy && <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />}Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/* -------- Chat panel -------- */
function ChatPanel({
conversationId,
conversation,
members,
profilesById,
profiles,
onMessagesRead,
}: {
conversationId: string;
conversation: Conversation;
members: MemberRow[];
profilesById: Record<string, Profile>;
profiles: Profile[];
onMessagesRead: () => void;
}) {
const { user } = useAuth();
const uid = user?.id;
const [messages, setMessages] = useState<MessageRow[]>([]);
const [attachments, setAttachments] = useState<Record<string, AttachmentRow[]>>({});
const [body, setBody] = useState("");
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const [sending, setSending] = useState(false);
const [mentionOpen, setMentionOpen] = useState(false);
const [mentionQuery, setMentionQuery] = useState("");
const [mentionedIds, setMentionedIds] = useState<Set<string>>(new Set());
const scrollRef = useRef<HTMLDivElement>(null);
const taRef = useRef<HTMLTextAreaElement>(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<string, AttachmentRow[]> = {};
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 (
<div className="h-full flex flex-col">
<div className="px-4 py-3 border-b flex items-center justify-between">
<div>
<div className="font-medium text-sm flex items-center gap-2">
{conversation.type === "channel" ? <Hash className="h-3.5 w-3.5" /> : <Users className="h-3.5 w-3.5" />}
{headerTitle}
</div>
<div className="text-[11px] text-muted-foreground">{memberProfiles.length} member{memberProfiles.length !== 1 ? "s" : ""}</div>
</div>
</div>
<div ref={scrollRef} className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.length === 0 && (
<div className="text-center text-xs text-muted-foreground py-8">No messages yet. Say hi 👋</div>
)}
{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 (
<div key={m.id} className={cn("flex gap-2", !showHeader && "pl-10")}>
{showHeader && (
<Avatar className="h-8 w-8 shrink-0">
<AvatarFallback className="text-[10px]">{initials(sender?.full_name || sender?.email || "?")}</AvatarFallback>
</Avatar>
)}
<div className="flex-1 min-w-0">
{showHeader && (
<div className="flex items-baseline gap-2">
<span className="text-sm font-medium">{sender?.full_name || sender?.email || "Unknown"}</span>
<span className="text-[10px] text-muted-foreground">
{new Date(m.created_at).toLocaleString([], { hour: "numeric", minute: "2-digit", month: "short", day: "numeric" })}
</span>
</div>
)}
{m.body && <div className="text-sm whitespace-pre-wrap break-words">{renderBody(m.body)}</div>}
{(attachments[m.id] || []).map((a) => (
<AttachmentItem key={a.id} att={a} />
))}
</div>
</div>
);
})}
</div>
{/* Composer */}
<div className="border-t p-3 space-y-2 relative">
{pendingFiles.length > 0 && (
<div className="flex flex-wrap gap-2">
{pendingFiles.map((f, i) => (
<div key={i} className="flex items-center gap-1.5 text-xs bg-muted rounded px-2 py-1">
<FileText className="h-3 w-3" />
<span className="max-w-[140px] truncate">{f.name}</span>
<button onClick={() => setPendingFiles((p) => p.filter((_, j) => j !== i))}>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
{mentionOpen && mentionMatches.length > 0 && (
<div className="absolute bottom-full left-3 mb-1 w-64 bg-popover border rounded-md shadow-lg z-10 overflow-hidden">
{mentionMatches.map((p) => (
<button
key={p.id}
onClick={() => insertMention(p)}
className="w-full flex items-center gap-2 px-2 py-1.5 hover:bg-accent text-left text-sm"
>
<AtSign className="h-3 w-3 text-muted-foreground" />
<span>{p.full_name || p.email}</span>
</button>
))}
</div>
)}
<div className="flex gap-2 items-end">
<Textarea
ref={taRef}
placeholder="Type a message… use @ to mention"
value={body}
onChange={(e) => handleBodyChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && !mentionOpen) {
e.preventDefault();
send();
}
}}
rows={2}
className="resize-none"
/>
<div className="flex flex-col gap-1">
<label className="cursor-pointer">
<input type="file" multiple className="hidden" onChange={(e) => handleFiles(e.target.files)} />
<Button asChild variant="outline" size="icon">
<span><Paperclip className="h-4 w-4" /></span>
</Button>
</label>
<Button onClick={send} disabled={sending} size="icon">
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</Button>
</div>
</div>
</div>
</div>
);
}
function renderBody(text: string) {
// Highlight @mentions
const parts = text.split(/(@[\w]+)/g);
return parts.map((p, i) =>
p.startsWith("@") ? (
<span key={i} className="bg-primary/10 text-primary rounded px-1">
{p}
</span>
) : (
<span key={i}>{p}</span>
),
);
}
function AttachmentItem({ att }: { att: AttachmentRow }) {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
supabase.storage
.from("message-attachments")
.createSignedUrl(att.storage_path, 3600)
.then(({ data }) => {
if (!cancelled) setUrl(data?.signedUrl ?? null);
});
return () => {
cancelled = true;
};
}, [att.storage_path]);
const isImage = (att.mime_type || "").startsWith("image/");
if (isImage && url) {
return (
<a href={url} target="_blank" rel="noreferrer" className="block mt-1">
<img src={url} alt={att.name} className="max-h-64 rounded border" />
</a>
);
}
return (
<a
href={url || "#"}
target="_blank"
rel="noreferrer"
className="mt-1 inline-flex items-center gap-2 text-xs border rounded px-2 py-1 hover:bg-accent max-w-xs"
>
<FileText className="h-3.5 w-3.5" />
<span className="truncate">{att.name}</span>
{att.size_bytes && <span className="text-muted-foreground">{Math.round(att.size_bytes / 1024)} KB</span>}
</a>
);
}
@@ -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]);