Messaging: board topic threads with management + board

Add a "Topics" tab to the Messages page so board members can start a
topic-scoped group conversation with management and their own-association
board members. Topic = predefined category + free-text subject; recipients
are multi-selected; replies are visible to all participants.

- New public tables message_threads / message_thread_participants /
  thread_messages (migration board_topic_message_threads), added to the
  realtime publication; RLS scopes reads/writes to thread participants via
  is_thread_participant().
- create_topic_thread() RPC (SECURITY DEFINER) enforces recipient
  eligibility server-side: management (admin/manager) or a board member of
  the caller's own association; rejects anyone else.
- Frontend: messageTopics constant, useMessageThreads/useThreadMessages
  hooks (realtime), TopicThreadList/TopicThreadView/NewTopicThreadDialog,
  and a tabbed MessagesPage. Recipients notified via insert_notification
  with a /dashboard/messages?thread= deep link. Existing 1:1 DMs unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 14:58:50 -04:00
parent 56e63edcd6
commit d5145e2515
7 changed files with 653 additions and 10 deletions
@@ -0,0 +1,183 @@
import { useEffect, useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { supabase } from "@/integrations/supabase/client";
import { useToast } from "@/hooks/use-toast";
import { MESSAGE_TOPICS } from "@/lib/messageTopics";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter,
} from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Plus, Loader2 } from "lucide-react";
type Recipient = { user_id: string; name: string; category: "Management" | "Board"; context?: string };
export default function NewTopicThreadDialog({ onCreated }: { onCreated: (threadId: string) => void }) {
const { user, isStaff } = useAuth();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [recipients, setRecipients] = useState<Recipient[]>([]);
const [loadingRecipients, setLoadingRecipients] = useState(false);
const [topic, setTopic] = useState<string>("");
const [subject, setSubject] = useState("");
const [body, setBody] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!open || !user) return;
setLoadingRecipients(true);
(async () => {
// Management = admins/managers.
const { data: roles } = await supabase
.from("user_roles").select("user_id, role").in("role", ["admin", "manager"]);
const mgmtIds = new Set((roles ?? []).map((r: any) => r.user_id).filter((id: string) => id && id !== user.id));
// Board peers = board members of the caller's own association(s); staff see all.
let boardQuery = (supabase as any)
.from("board_members")
.select("user_id, role, association_id, associations(name)")
.not("user_id", "is", null);
if (!isStaff) {
const { data: mine } = await (supabase as any)
.from("board_members").select("association_id").eq("user_id", user.id);
const assoc = Array.from(new Set((mine ?? []).map((m: any) => m.association_id).filter(Boolean)));
if (assoc.length === 0) { setRecipients([]); setLoadingRecipients(false); return; }
boardQuery = boardQuery.in("association_id", assoc);
}
const { data: board } = await boardQuery;
const list: Recipient[] = [];
const seen = new Set<string>();
for (const b of (board ?? []) as any[]) {
if (!b.user_id || b.user_id === user.id || seen.has(b.user_id)) continue;
seen.add(b.user_id);
list.push({
user_id: b.user_id, name: "", category: "Board",
context: `${b.role || "Board Member"}${b.associations?.name ? ` · ${b.associations.name}` : ""}`,
});
}
for (const id of mgmtIds) {
if (seen.has(id)) continue;
seen.add(id);
list.push({ user_id: id, name: "", category: "Management", context: "Management" });
}
// Hydrate names from profiles.
const ids = list.map((r) => r.user_id);
if (ids.length) {
const { data: profiles } = await supabase.from("profiles").select("user_id, full_name").in("user_id", ids);
const pmap = new Map((profiles ?? []).map((p: any) => [p.user_id, p.full_name]));
for (const r of list) r.name = pmap.get(r.user_id) || "Member";
}
list.sort((a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name));
setRecipients(list);
setLoadingRecipients(false);
})();
}, [open, user, isStaff]);
const reset = () => { setTopic(""); setSubject(""); setBody(""); setSelected(new Set()); };
const toggle = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
const submit = async () => {
if (!topic) return toast({ title: "Pick a topic", variant: "destructive" });
if (!subject.trim()) return toast({ title: "Add a subject", variant: "destructive" });
if (selected.size === 0) return toast({ title: "Select at least one recipient", variant: "destructive" });
if (!body.trim()) return toast({ title: "Write a message", variant: "destructive" });
setSubmitting(true);
try {
const recipientIds = Array.from(selected);
const { data: threadId, error } = await (supabase as any).rpc("create_topic_thread", {
_topic: topic, _subject: subject.trim(), _body: body.trim(), _recipient_ids: recipientIds,
});
if (error) throw error;
// Notify recipients (mirrors notifyMessageRecipients in useDirectMessages).
const senderName = (await supabase.from("profiles").select("full_name").eq("user_id", user!.id).maybeSingle())
.data?.full_name || "A board member";
await Promise.allSettled(recipientIds.map((rid) =>
(supabase as any).rpc("insert_notification", {
p_user_id: rid, p_type: "info",
p_title: `New topic from ${senderName}: ${topic}`,
p_message: subject.trim(),
p_related_item_type: "message_thread",
p_link: `/dashboard/messages?thread=${threadId}`,
})
));
toast({ title: "Topic started" });
reset();
setOpen(false);
onCreated(threadId as string);
} catch (e: any) {
toast({ title: "Could not start topic", description: e.message, variant: "destructive" });
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={(o) => { setOpen(o); if (!o) reset(); }}>
<DialogTrigger asChild>
<Button size="sm" className="gap-1"><Plus className="h-4 w-4" /> New Topic</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader><DialogTitle>New Topic</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label>Topic</Label>
<Select value={topic} onValueChange={setTopic}>
<SelectTrigger><SelectValue placeholder="Select a topic" /></SelectTrigger>
<SelectContent>
{MESSAGE_TOPICS.map((t) => <SelectItem key={t} value={t}>{t}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Subject</Label>
<Input value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Short summary" maxLength={150} />
</div>
<div className="space-y-1.5">
<Label>Recipients</Label>
<div className="max-h-44 overflow-y-auto rounded-md border border-border divide-y">
{loadingRecipients ? (
<div className="flex justify-center py-6"><Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /></div>
) : recipients.length === 0 ? (
<p className="text-sm text-muted-foreground p-3">No contacts available.</p>
) : recipients.map((r) => (
<label key={r.user_id} className="flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-accent">
<Checkbox checked={selected.has(r.user_id)} onCheckedChange={() => toggle(r.user_id)} />
<div className="min-w-0">
<div className="text-sm font-medium truncate">{r.name}</div>
<div className="text-[11px] text-muted-foreground truncate">{r.context}</div>
</div>
</label>
))}
</div>
</div>
<div className="space-y-1.5">
<Label>Message</Label>
<Textarea value={body} onChange={(e) => setBody(e.target.value)} placeholder="Write your message…" rows={4} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
<Button onClick={submit} disabled={submitting}>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : "Send"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,62 @@
import { useMessageThreads } from "@/hooks/useMessageThreads";
import { topicBadgeClass } from "@/lib/messageTopics";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { Loader2, MessagesSquare } from "lucide-react";
import { format, isToday } from "date-fns";
import NewTopicThreadDialog from "./NewTopicThreadDialog";
function when(dateStr: string | null) {
if (!dateStr) return "";
const d = new Date(dateStr);
return isToday(d) ? format(d, "h:mm a") : format(d, "MMM d");
}
export default function TopicThreadList({
selectedThreadId, onSelect,
}: { selectedThreadId: string | null; onSelect: (id: string) => void }) {
const { threads, loading } = useMessageThreads();
return (
<div className="flex h-full flex-col border-r border-border">
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b border-border">
<span className="font-semibold text-sm">Topics</span>
<NewTopicThreadDialog onCreated={onSelect} />
</div>
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="flex justify-center py-8"><Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /></div>
) : threads.length === 0 ? (
<div className="flex flex-col items-center gap-2 text-muted-foreground text-center px-6 py-10">
<MessagesSquare className="h-8 w-8 opacity-30" />
<p className="text-sm">No topics yet. Start one to message management and your board.</p>
</div>
) : (
threads.map((t) => (
<button
key={t.id}
onClick={() => onSelect(t.id)}
className={cn(
"w-full text-left px-4 py-3 border-b border-border hover:bg-accent transition-colors",
selectedThreadId === t.id && "bg-accent",
)}
>
<div className="flex items-center justify-between gap-2 mb-1">
<Badge variant="outline" className={cn("text-[10px] px-1.5 py-0", topicBadgeClass(t.topic))}>{t.topic}</Badge>
<span className="text-[10px] text-muted-foreground shrink-0">{when(t.last_message_at)}</span>
</div>
<div className="flex items-center gap-2">
<span className={cn("text-sm truncate", t.unread ? "font-semibold" : "font-medium")}>{t.subject}</span>
{t.unread && <span className="h-2 w-2 rounded-full bg-primary shrink-0" />}
</div>
{t.last_message && <p className="text-xs text-muted-foreground truncate mt-0.5">{t.last_message}</p>}
{t.participant_names.length > 0 && (
<p className="text-[11px] text-muted-foreground/80 truncate mt-0.5">{t.participant_names.join(", ")}</p>
)}
</button>
))
)}
</div>
</div>
);
}
@@ -0,0 +1,105 @@
import { useEffect, useRef, useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { useThreadMessages } from "@/hooks/useThreadMessages";
import { topicBadgeClass } from "@/lib/messageTopics";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { Loader2, Send, MessagesSquare } from "lucide-react";
import { format, isToday, isYesterday } from "date-fns";
import { useToast } from "@/hooks/use-toast";
function fmt(dateStr: string) {
const d = new Date(dateStr);
if (isToday(d)) return format(d, "h:mm a");
if (isYesterday(d)) return "Yesterday " + format(d, "h:mm a");
return format(d, "MMM d, h:mm a");
}
export default function TopicThreadView({ threadId }: { threadId: string | null }) {
const { user } = useAuth();
const { header, messages, loading, sendReply } = useThreadMessages(threadId);
const { toast } = useToast();
const [draft, setDraft] = useState("");
const [sending, setSending] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}, [messages]);
const send = async () => {
if (!draft.trim() || sending) return;
setSending(true);
try {
await sendReply(draft);
setDraft("");
} catch (e: any) {
toast({ title: "Error sending message", description: e.message, variant: "destructive" });
} finally {
setSending(false);
}
};
if (!threadId || !header) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-3">
<MessagesSquare className="h-12 w-12 opacity-30" />
<p className="text-sm">Select a topic or start a new one</p>
</div>
);
}
return (
<div className="flex-1 flex flex-col min-h-0">
<div className="shrink-0 px-4 py-3 border-b border-border">
<div className="flex items-center gap-2">
<Badge variant="outline" className={cn("text-[10px] px-1.5 py-0", topicBadgeClass(header.topic))}>{header.topic}</Badge>
<span className="font-semibold text-sm truncate">{header.subject}</span>
</div>
<div className="text-[11px] text-muted-foreground truncate mt-1">
{header.participants.map((p) => p.name).join(", ")}
</div>
</div>
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4 space-y-3">
{loading ? (
<div className="flex justify-center py-8"><Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /></div>
) : messages.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">No messages yet.</p>
) : (
messages.map((m) => {
const isMe = m.sender_id === user?.id;
return (
<div key={m.id} className={cn("flex flex-col", isMe ? "items-end" : "items-start")}>
{!isMe && <span className="text-[11px] text-muted-foreground mb-0.5 px-1">{m.sender_name}</span>}
<div className={cn(
"max-w-[75%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap break-words",
isMe ? "bg-primary text-primary-foreground" : "bg-muted",
)}>
{m.body}
</div>
<span className="text-[10px] text-muted-foreground mt-0.5 px-1">{fmt(m.created_at)}</span>
</div>
);
})
)}
</div>
<div className="shrink-0 border-t border-border p-3 flex items-end gap-2">
<Textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } }}
placeholder="Reply to everyone…"
rows={1}
className="resize-none min-h-[40px] max-h-32"
/>
<Button size="icon" onClick={send} disabled={sending || !draft.trim()}>
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</Button>
</div>
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
import { useCallback, useEffect, useState } from "react";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/contexts/AuthContext";
export interface TopicThread {
id: string;
topic: string;
subject: string;
created_by: string;
association_id: string | null;
updated_at: string;
last_message: string | null;
last_message_at: string | null;
last_read_at: string | null;
unread: boolean;
participant_names: string[];
}
// Lists the topic threads the current user participates in, newest activity
// first, with a last-message preview and an unread flag. Mirrors the realtime
// pattern in useDirectMessages (subscribe to the relevant tables, refetch).
export function useMessageThreads() {
const { user } = useAuth();
const [threads, setThreads] = useState<TopicThread[]>([]);
const [loading, setLoading] = useState(true);
const fetchThreads = useCallback(async () => {
if (!user) return;
// Threads I participate in (with my read marker).
const { data: parts, error } = await (supabase as any)
.from("message_thread_participants")
.select("thread_id, last_read_at, message_threads(id, topic, subject, created_by, association_id, updated_at)")
.eq("user_id", user.id);
if (error) {
console.error("Error fetching topic threads:", error);
setLoading(false);
return;
}
const rows = (parts ?? []).filter((p: any) => p.message_threads);
const threadIds = rows.map((p: any) => p.thread_id);
if (threadIds.length === 0) {
setThreads([]);
setLoading(false);
return;
}
// Latest message per thread + all participant ids (for names).
const [{ data: msgs }, { data: allParts }] = await Promise.all([
(supabase as any)
.from("thread_messages")
.select("thread_id, body, created_at, sender_id")
.in("thread_id", threadIds)
.order("created_at", { ascending: false }),
(supabase as any)
.from("message_thread_participants")
.select("thread_id, user_id")
.in("thread_id", threadIds),
]);
const lastByThread = new Map<string, { body: string; created_at: string }>();
for (const m of (msgs ?? []) as any[]) {
if (!lastByThread.has(m.thread_id)) lastByThread.set(m.thread_id, { body: m.body, created_at: m.created_at });
}
const participantIds = Array.from(new Set((allParts ?? []).map((p: any) => p.user_id)));
const nameMap = new Map<string, string>();
if (participantIds.length) {
const { data: profiles } = await supabase
.from("profiles").select("user_id, full_name").in("user_id", participantIds);
for (const p of (profiles ?? []) as any[]) nameMap.set(p.user_id, p.full_name || "Member");
}
const partsByThread = new Map<string, string[]>();
for (const p of (allParts ?? []) as any[]) {
if (p.user_id === user.id) continue;
const arr = partsByThread.get(p.thread_id) ?? [];
arr.push(nameMap.get(p.user_id) || "Member");
partsByThread.set(p.thread_id, arr);
}
const list: TopicThread[] = rows.map((p: any) => {
const t = p.message_threads;
const last = lastByThread.get(p.thread_id) ?? null;
const lastAt = last?.created_at ?? t.updated_at;
const unread = !!last && (!p.last_read_at || new Date(last.created_at) > new Date(p.last_read_at));
return {
id: t.id,
topic: t.topic,
subject: t.subject,
created_by: t.created_by,
association_id: t.association_id,
updated_at: t.updated_at,
last_message: last?.body ?? null,
last_message_at: lastAt,
last_read_at: p.last_read_at,
unread,
participant_names: partsByThread.get(p.thread_id) ?? [],
};
});
list.sort((a, b) => new Date(b.last_message_at ?? 0).getTime() - new Date(a.last_message_at ?? 0).getTime());
setThreads(list);
setLoading(false);
}, [user]);
useEffect(() => {
fetchThreads();
const channel = supabase
.channel("topic-threads")
.on("postgres_changes", { event: "*", schema: "public", table: "thread_messages" }, () => fetchThreads())
.on("postgres_changes", { event: "*", schema: "public", table: "message_thread_participants" }, () => fetchThreads())
.subscribe();
return () => { supabase.removeChannel(channel); };
}, [fetchThreads]);
return { threads, loading, refetch: fetchThreads };
}
+101
View File
@@ -0,0 +1,101 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/contexts/AuthContext";
export interface ThreadMessage {
id: string;
thread_id: string;
sender_id: string;
sender_name: string;
body: string;
created_at: string;
}
export interface ThreadHeader {
id: string;
topic: string;
subject: string;
created_by: string;
participants: { user_id: string; name: string }[];
}
// Messages + header for one topic thread, with send-reply, mark-read and
// realtime (mirrors useChatMessages). Reply inserts are guarded by RLS
// (sender must be a participant).
export function useThreadMessages(threadId: string | null) {
const { user } = useAuth();
const [header, setHeader] = useState<ThreadHeader | null>(null);
const [messages, setMessages] = useState<ThreadMessage[]>([]);
const [loading, setLoading] = useState(false);
const nameCache = useRef<Map<string, string>>(new Map());
const resolveNames = useCallback(async (ids: string[]) => {
const missing = ids.filter((id) => !nameCache.current.has(id));
if (missing.length) {
const { data } = await supabase.from("profiles").select("user_id, full_name").in("user_id", missing);
for (const p of (data ?? []) as any[]) nameCache.current.set(p.user_id, p.full_name || "Member");
}
}, []);
const markRead = useCallback(async () => {
if (!threadId || !user) return;
await (supabase as any)
.from("message_thread_participants")
.update({ last_read_at: new Date().toISOString() })
.eq("thread_id", threadId)
.eq("user_id", user.id);
}, [threadId, user]);
const fetchAll = useCallback(async () => {
if (!threadId) { setHeader(null); setMessages([]); return; }
setLoading(true);
const [{ data: thread }, { data: parts }, { data: msgs }] = await Promise.all([
(supabase as any).from("message_threads").select("id, topic, subject, created_by").eq("id", threadId).maybeSingle(),
(supabase as any).from("message_thread_participants").select("user_id").eq("thread_id", threadId),
(supabase as any).from("thread_messages").select("id, thread_id, sender_id, body, created_at").eq("thread_id", threadId).order("created_at", { ascending: true }),
]);
const ids = Array.from(new Set([
...((parts ?? []).map((p: any) => p.user_id)),
...((msgs ?? []).map((m: any) => m.sender_id)),
]));
await resolveNames(ids);
if (thread) {
setHeader({
id: thread.id, topic: thread.topic, subject: thread.subject, created_by: thread.created_by,
participants: (parts ?? []).map((p: any) => ({ user_id: p.user_id, name: nameCache.current.get(p.user_id) || "Member" })),
});
}
setMessages((msgs ?? []).map((m: any) => ({
...m, sender_name: nameCache.current.get(m.sender_id) || "Member",
})));
setLoading(false);
markRead();
}, [threadId, resolveNames, markRead]);
useEffect(() => {
fetchAll();
if (!threadId) return;
const channel = supabase
.channel(`topic-thread-${threadId}`)
.on("postgres_changes",
{ event: "*", schema: "public", table: "thread_messages", filter: `thread_id=eq.${threadId}` },
() => fetchAll())
.subscribe();
return () => { supabase.removeChannel(channel); };
}, [threadId, fetchAll]);
const sendReply = useCallback(async (body: string) => {
const trimmed = body.trim();
if (!trimmed || !threadId || !user) return;
const { error } = await (supabase as any)
.from("thread_messages")
.insert({ thread_id: threadId, sender_id: user.id, body: trimmed });
if (error) throw error;
await markRead();
}, [threadId, user, markRead]);
return { header, messages, loading, sendReply };
}
+31
View File
@@ -0,0 +1,31 @@
// Predefined topic categories for board ↔ management/board topic threads.
// Kept as a constant for now; can be moved to a DB table later if associations
// need to customize the list. Order here is the order shown in the dropdown.
export const MESSAGE_TOPICS = [
"Maintenance",
"Landscaping",
"Finances / Budget",
"Violations",
"Architectural (ARC)",
"Meetings",
"Vendors",
"Other",
] as const;
export type MessageTopic = (typeof MESSAGE_TOPICS)[number];
// Tailwind classes for the topic badge so each category is visually distinct.
const TOPIC_BADGE_CLASS: Record<string, string> = {
Maintenance: "bg-amber-100 text-amber-800 border-amber-200",
Landscaping: "bg-green-100 text-green-800 border-green-200",
"Finances / Budget": "bg-blue-100 text-blue-800 border-blue-200",
Violations: "bg-red-100 text-red-800 border-red-200",
"Architectural (ARC)": "bg-purple-100 text-purple-800 border-purple-200",
Meetings: "bg-indigo-100 text-indigo-800 border-indigo-200",
Vendors: "bg-teal-100 text-teal-800 border-teal-200",
Other: "bg-gray-100 text-gray-700 border-gray-200",
};
export function topicBadgeClass(topic: string): string {
return TOPIC_BADGE_CLASS[topic] ?? TOPIC_BADGE_CLASS.Other;
}
+52 -10
View File
@@ -1,25 +1,67 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import ConversationList from "@/components/messaging/ConversationList"; import ConversationList from "@/components/messaging/ConversationList";
import ChatView from "@/components/messaging/ChatView"; import ChatView from "@/components/messaging/ChatView";
import TopicThreadList from "@/components/messaging/TopicThreadList";
import TopicThreadView from "@/components/messaging/TopicThreadView";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
export default function MessagesPage() { export default function MessagesPage() {
const [selectedPartnerId, setSelectedPartnerId] = useState<string | null>(null); const [selectedPartnerId, setSelectedPartnerId] = useState<string | null>(null);
const [partnerName, setPartnerName] = useState(""); const [partnerName, setPartnerName] = useState("");
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
const [searchParams, setSearchParams] = useSearchParams();
const [tab, setTab] = useState<"direct" | "topics">("direct");
// Deep-link from a topic notification (/dashboard/messages?thread=<id>).
useEffect(() => {
const threadParam = searchParams.get("thread");
if (threadParam) {
setTab("topics");
setSelectedThreadId(threadParam);
}
}, [searchParams]);
const handleSelectPartner = (id: string, name: string) => { const handleSelectPartner = (id: string, name: string) => {
setSelectedPartnerId(id); setSelectedPartnerId(id);
setPartnerName(name); setPartnerName(name);
}; };
const handleSelectThread = (id: string) => {
setSelectedThreadId(id);
if (searchParams.get("thread") && searchParams.get("thread") !== id) {
searchParams.delete("thread");
setSearchParams(searchParams, { replace: true });
}
};
return ( return (
<div className="h-[calc(100vh-8rem)] flex rounded-lg border border-border bg-card overflow-hidden"> <Tabs value={tab} onValueChange={(v) => setTab(v as "direct" | "topics")} className="space-y-3">
<div className="w-80 shrink-0"> <TabsList>
<ConversationList <TabsTrigger value="direct">Direct</TabsTrigger>
selectedPartnerId={selectedPartnerId} <TabsTrigger value="topics">Topics</TabsTrigger>
onSelectPartner={handleSelectPartner} </TabsList>
/>
</div> <TabsContent value="direct" className="mt-0">
<ChatView partnerId={selectedPartnerId} partnerName={partnerName} /> <div className="h-[calc(100vh-10rem)] flex rounded-lg border border-border bg-card overflow-hidden">
</div> <div className="w-80 shrink-0">
<ConversationList
selectedPartnerId={selectedPartnerId}
onSelectPartner={handleSelectPartner}
/>
</div>
<ChatView partnerId={selectedPartnerId} partnerName={partnerName} />
</div>
</TabsContent>
<TabsContent value="topics" className="mt-0">
<div className="h-[calc(100vh-10rem)] flex rounded-lg border border-border bg-card overflow-hidden">
<div className="w-80 shrink-0">
<TopicThreadList selectedThreadId={selectedThreadId} onSelect={handleSelectThread} />
</div>
<TopicThreadView threadId={selectedThreadId} />
</div>
</TabsContent>
</Tabs>
); );
} }