From 6a6e2306eab1ae7e90d74b3c8d549a128c945b82 Mon Sep 17 00:00:00 2001 From: renee-png Date: Wed, 17 Jun 2026 15:44:39 -0400 Subject: [PATCH] Messaging: whole-board topics, delete conversations/threads, dialog overflow + board names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Whole-board messaging: staff starting a Topic can add an entire association's board in one click ("Whole Board — ") in NewTopicThreadDialog; create_topic_thread already permits staff to add board members. - Delete for everyone: trash control on Direct conversations (deletes the 1:1 both directions) and on Topic threads (deletes the thread for the creator or admin/manager — cascade). Backed by new RLS delete policies on direct_messages and message_threads (migration allow_delete_conversations_and_threads). - Fix New Message dialog overflow for real: the grid child needed min-w-0 (+ overflow-hidden on the card); applied the same guard to the Topic dialog. - Fix board members showing as "Unknown User": fall back to board_members.member_name. Co-Authored-By: Claude Opus 4.8 --- src/components/messaging/ConversationList.tsx | 67 +++++++++++++-- .../messaging/NewTopicThreadDialog.tsx | 81 ++++++++++++++----- src/components/messaging/TopicThreadList.tsx | 57 ++++++++++++- 3 files changed, 176 insertions(+), 29 deletions(-) diff --git a/src/components/messaging/ConversationList.tsx b/src/components/messaging/ConversationList.tsx index 668464c..61182ca 100644 --- a/src/components/messaging/ConversationList.tsx +++ b/src/components/messaging/ConversationList.tsx @@ -7,12 +7,17 @@ import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; -import { Search, PenSquare, Loader2 } from "lucide-react"; +import { Search, PenSquare, Loader2, Trash2 } from "lucide-react"; import { formatDistanceToNow } from "date-fns"; import { cn } from "@/lib/utils"; +import { useToast } from "@/hooks/use-toast"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; +import { + AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, +} from "@/components/ui/alert-dialog"; interface Props { selectedPartnerId: string | null; @@ -24,9 +29,11 @@ function getInitials(name: string) { } export default function ConversationList({ selectedPartnerId, onSelectPartner }: Props) { - const { isHomeowner, isRvBoatLot, isBoardMember, isStaff } = useAuth(); + const { user, isHomeowner, isRvBoatLot, isBoardMember, isStaff } = useAuth(); const { conversations, loading } = useConversations(); + const { toast } = useToast(); const [search, setSearch] = useState(""); + const [pendingDelete, setPendingDelete] = useState<{ id: string; name: string } | null>(null); const useStaffConversation = isHomeowner || isRvBoatLot; const isBoardOnly = isBoardMember && !isStaff; @@ -34,6 +41,23 @@ export default function ConversationList({ selectedPartnerId, onSelectPartner }: c.partner_name.toLowerCase().includes(search.toLowerCase()) ); + // Delete the whole 1:1 conversation for everyone (both directions). The + // realtime subscription in useConversations refetches the list on delete. + const deleteConversation = async (partnerId: string) => { + if (!user) return; + const me = user.id; + const { error } = await supabase + .from("direct_messages") + .delete() + .or(`and(sender_id.eq.${me},recipient_id.eq.${partnerId}),and(sender_id.eq.${partnerId},recipient_id.eq.${me})`); + if (error) { + toast({ title: "Couldn't delete conversation", description: error.message, variant: "destructive" }); + } else { + toast({ title: "Conversation deleted" }); + } + setPendingDelete(null); + }; + return (
@@ -107,8 +131,8 @@ export default function ConversationList({ selectedPartnerId, onSelectPartner }: ) : (
{filtered.map((conv) => ( +
+ +
))}
)} + + { if (!o) setPendingDelete(null); }}> + + + Delete this conversation? + + This permanently deletes the conversation with {pendingDelete?.name} for everyone. This can't be undone. + + + + Cancel + pendingDelete && deleteConversation(pendingDelete.id)} + > + Delete + + + + ); } @@ -205,7 +258,7 @@ function NewConversationDialog({ onSelectPartner }: { onSelectPartner: (id: stri .neq("status", "archived"), (supabase as any) .from("board_members") - .select("user_id, role, associations(name)") + .select("user_id, role, member_name, associations(name)") .not("user_id", "is", null), supabase.from("user_roles").select("user_id, role").in("role", ["admin", "manager"]), ]); @@ -243,7 +296,7 @@ function NewConversationDialog({ onSelectPartner }: { onSelectPartner: (id: stri userIds.add(b.user_id); recipients.push({ user_id: b.user_id, - full_name: null, + full_name: b.member_name || null, avatar_url: null, category: "board", context: `Board • ${b.role || "Member"}${b.associations?.name ? ` (${b.associations.name})` : ""}`, @@ -313,11 +366,11 @@ function NewConversationDialog({ onSelectPartner }: { onSelectPartner: (id: stri - + New Message -
+
([]); + const [boardGroups, setBoardGroups] = useState<{ id: string; name: string; memberIds: string[] }[]>([]); const [loadingRecipients, setLoadingRecipients] = useState(false); const [topic, setTopic] = useState(""); const [subject, setSubject] = useState(""); @@ -53,13 +54,30 @@ export default function NewTopicThreadDialog({ onCreated }: { onCreated: (thread const list: Recipient[] = []; const seen = new Set(); - 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}` : ""}`, - }); + + if (isStaff) { + // Staff message whole boards: group board members by association so an + // entire board can be added in one click. + const groups = new Map(); + for (const b of (board ?? []) as any[]) { + if (!b.user_id || b.user_id === user.id || !b.association_id) continue; + const g = groups.get(b.association_id) + ?? { id: b.association_id, name: b.associations?.name || "Association", memberIds: [] }; + if (!g.memberIds.includes(b.user_id)) g.memberIds.push(b.user_id); + groups.set(b.association_id, g); + } + setBoardGroups(Array.from(groups.values()).filter((g) => g.memberIds.length).sort((a, b) => a.name.localeCompare(b.name))); + } else { + // Board members: their own board peers, listed individually. + setBoardGroups([]); + 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; @@ -90,6 +108,15 @@ export default function NewTopicThreadDialog({ onCreated }: { onCreated: (thread }); }; + const toggleGroup = (memberIds: string[]) => { + setSelected((prev) => { + const next = new Set(prev); + const allOn = memberIds.every((id) => next.has(id)); + memberIds.forEach((id) => (allOn ? 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" }); @@ -132,9 +159,9 @@ export default function NewTopicThreadDialog({ onCreated }: { onCreated: (thread - + New Topic -
+