Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
bf2489fa19
commit
36ff952744
@@ -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<FileRouteTypes>()
|
||||
|
||||
import type { getRouter } from './router.tsx'
|
||||
import type { createStart } from '@tanstack/react-start'
|
||||
declare module '@tanstack/react-start' {
|
||||
interface Register {
|
||||
ssr: true
|
||||
router: Awaited<ReturnType<typeof getRouter>>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user