mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
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:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user