Messaging: whole-board topics, delete conversations/threads, dialog overflow + board names

- Whole-board messaging: staff starting a Topic can add an entire association's
  board in one click ("Whole Board — <Association>") 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 15:44:39 -04:00
parent bfc758f1f2
commit 6a6e2306ea
3 changed files with 176 additions and 29 deletions
+60 -7
View File
@@ -7,12 +7,17 @@ import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge"; 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 { formatDistanceToNow } from "date-fns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useToast } from "@/hooks/use-toast";
import { import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface Props { interface Props {
selectedPartnerId: string | null; selectedPartnerId: string | null;
@@ -24,9 +29,11 @@ function getInitials(name: string) {
} }
export default function ConversationList({ selectedPartnerId, onSelectPartner }: Props) { 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 { conversations, loading } = useConversations();
const { toast } = useToast();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [pendingDelete, setPendingDelete] = useState<{ id: string; name: string } | null>(null);
const useStaffConversation = isHomeowner || isRvBoatLot; const useStaffConversation = isHomeowner || isRvBoatLot;
const isBoardOnly = isBoardMember && !isStaff; const isBoardOnly = isBoardMember && !isStaff;
@@ -34,6 +41,23 @@ export default function ConversationList({ selectedPartnerId, onSelectPartner }:
c.partner_name.toLowerCase().includes(search.toLowerCase()) 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 ( return (
<div className="flex flex-col h-full border-r border-border"> <div className="flex flex-col h-full border-r border-border">
<div className="p-3 border-b border-border space-y-2"> <div className="p-3 border-b border-border space-y-2">
@@ -107,8 +131,8 @@ export default function ConversationList({ selectedPartnerId, onSelectPartner }:
) : ( ) : (
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{filtered.map((conv) => ( {filtered.map((conv) => (
<div key={conv.partner_id} className="group relative">
<button <button
key={conv.partner_id}
onClick={() => onSelectPartner(conv.partner_id, conv.partner_name)} onClick={() => onSelectPartner(conv.partner_id, conv.partner_name)}
className={cn( className={cn(
"w-full flex items-start gap-3 px-3 py-3 text-left hover:bg-accent transition-colors", "w-full flex items-start gap-3 px-3 py-3 text-left hover:bg-accent transition-colors",
@@ -145,10 +169,39 @@ export default function ConversationList({ selectedPartnerId, onSelectPartner }:
</div> </div>
</div> </div>
</button> </button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); setPendingDelete({ id: conv.partner_id, name: conv.partner_name }); }}
title="Delete conversation"
className="absolute right-2 top-2 hidden group-hover:flex h-6 w-6 items-center justify-center rounded-md bg-background/80 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))} ))}
</div> </div>
)} )}
</ScrollArea> </ScrollArea>
<AlertDialog open={!!pendingDelete} onOpenChange={(o) => { if (!o) setPendingDelete(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this conversation?</AlertDialogTitle>
<AlertDialogDescription>
This permanently deletes the conversation with {pendingDelete?.name} for everyone. This can't be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => pendingDelete && deleteConversation(pendingDelete.id)}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
} }
@@ -205,7 +258,7 @@ function NewConversationDialog({ onSelectPartner }: { onSelectPartner: (id: stri
.neq("status", "archived"), .neq("status", "archived"),
(supabase as any) (supabase as any)
.from("board_members") .from("board_members")
.select("user_id, role, associations(name)") .select("user_id, role, member_name, associations(name)")
.not("user_id", "is", null), .not("user_id", "is", null),
supabase.from("user_roles").select("user_id, role").in("role", ["admin", "manager"]), 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); userIds.add(b.user_id);
recipients.push({ recipients.push({
user_id: b.user_id, user_id: b.user_id,
full_name: null, full_name: b.member_name || null,
avatar_url: null, avatar_url: null,
category: "board", category: "board",
context: `Board • ${b.role || "Member"}${b.associations?.name ? ` (${b.associations.name})` : ""}`, context: `Board • ${b.role || "Member"}${b.associations?.name ? ` (${b.associations.name})` : ""}`,
@@ -313,11 +366,11 @@ function NewConversationDialog({ onSelectPartner }: { onSelectPartner: (id: stri
<PenSquare className="h-4 w-4" /> <PenSquare className="h-4 w-4" />
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md overflow-hidden">
<DialogHeader> <DialogHeader>
<DialogTitle>New Message</DialogTitle> <DialogTitle>New Message</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-3"> <div className="space-y-3 min-w-0">
<Input <Input
placeholder="Search recipients..." placeholder="Search recipients..."
value={search} value={search}
@@ -21,6 +21,7 @@ export default function NewTopicThreadDialog({ onCreated }: { onCreated: (thread
const { toast } = useToast(); const { toast } = useToast();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [recipients, setRecipients] = useState<Recipient[]>([]); const [recipients, setRecipients] = useState<Recipient[]>([]);
const [boardGroups, setBoardGroups] = useState<{ id: string; name: string; memberIds: string[] }[]>([]);
const [loadingRecipients, setLoadingRecipients] = useState(false); const [loadingRecipients, setLoadingRecipients] = useState(false);
const [topic, setTopic] = useState<string>(""); const [topic, setTopic] = useState<string>("");
const [subject, setSubject] = useState(""); const [subject, setSubject] = useState("");
@@ -53,13 +54,30 @@ export default function NewTopicThreadDialog({ onCreated }: { onCreated: (thread
const list: Recipient[] = []; const list: Recipient[] = [];
const seen = new Set<string>(); 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; if (isStaff) {
seen.add(b.user_id); // Staff message whole boards: group board members by association so an
list.push({ // entire board can be added in one click.
user_id: b.user_id, name: "", category: "Board", const groups = new Map<string, { id: string; name: string; memberIds: string[] }>();
context: `${b.role || "Board Member"}${b.associations?.name ? ` · ${b.associations.name}` : ""}`, 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) { for (const id of mgmtIds) {
if (seen.has(id)) continue; 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 () => { const submit = async () => {
if (!topic) return toast({ title: "Pick a topic", variant: "destructive" }); if (!topic) return toast({ title: "Pick a topic", variant: "destructive" });
if (!subject.trim()) return toast({ title: "Add a subject", variant: "destructive" }); if (!subject.trim()) return toast({ title: "Add a subject", variant: "destructive" });
@@ -132,9 +159,9 @@ export default function NewTopicThreadDialog({ onCreated }: { onCreated: (thread
<DialogTrigger asChild> <DialogTrigger asChild>
<Button size="sm" className="gap-1"><Plus className="h-4 w-4" /> New Topic</Button> <Button size="sm" className="gap-1"><Plus className="h-4 w-4" /> New Topic</Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-lg"> <DialogContent className="max-w-lg overflow-hidden">
<DialogHeader><DialogTitle>New Topic</DialogTitle></DialogHeader> <DialogHeader><DialogTitle>New Topic</DialogTitle></DialogHeader>
<div className="space-y-4"> <div className="space-y-4 min-w-0">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Topic</Label> <Label>Topic</Label>
<Select value={topic} onValueChange={setTopic}> <Select value={topic} onValueChange={setTopic}>
@@ -153,17 +180,33 @@ export default function NewTopicThreadDialog({ onCreated }: { onCreated: (thread
<div className="max-h-44 overflow-y-auto rounded-md border border-border divide-y"> <div className="max-h-44 overflow-y-auto rounded-md border border-border divide-y">
{loadingRecipients ? ( {loadingRecipients ? (
<div className="flex justify-center py-6"><Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /></div> <div className="flex justify-center py-6"><Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /></div>
) : recipients.length === 0 ? ( ) : (boardGroups.length === 0 && recipients.length === 0) ? (
<p className="text-sm text-muted-foreground p-3">No contacts available.</p> <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)} /> {boardGroups.map((g) => {
<div className="min-w-0"> const allOn = g.memberIds.every((id) => selected.has(id));
<div className="text-sm font-medium truncate">{r.name}</div> return (
<div className="text-[11px] text-muted-foreground truncate">{r.context}</div> <label key={`board-${g.id}`} className="flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-accent">
</div> <Checkbox checked={allOn} onCheckedChange={() => toggleGroup(g.memberIds)} />
</label> <div className="min-w-0">
))} <div className="text-sm font-medium truncate">Whole Board {g.name}</div>
<div className="text-[11px] text-muted-foreground truncate">{g.memberIds.length} board member{g.memberIds.length === 1 ? "" : "s"}</div>
</div>
</label>
);
})}
{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> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
+54 -3
View File
@@ -1,10 +1,18 @@
import { useState } from "react";
import { useMessageThreads } from "@/hooks/useMessageThreads"; import { useMessageThreads } from "@/hooks/useMessageThreads";
import { useAuth } from "@/contexts/AuthContext";
import { supabase } from "@/integrations/supabase/client";
import { useToast } from "@/hooks/use-toast";
import { topicBadgeClass } from "@/lib/messageTopics"; import { topicBadgeClass } from "@/lib/messageTopics";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Loader2, MessagesSquare } from "lucide-react"; import { Loader2, MessagesSquare, Trash2 } from "lucide-react";
import { format, isToday } from "date-fns"; import { format, isToday } from "date-fns";
import NewTopicThreadDialog from "./NewTopicThreadDialog"; import NewTopicThreadDialog from "./NewTopicThreadDialog";
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from "@/components/ui/alert-dialog";
function when(dateStr: string | null) { function when(dateStr: string | null) {
if (!dateStr) return ""; if (!dateStr) return "";
@@ -16,6 +24,18 @@ export default function TopicThreadList({
selectedThreadId, onSelect, selectedThreadId, onSelect,
}: { selectedThreadId: string | null; onSelect: (id: string) => void }) { }: { selectedThreadId: string | null; onSelect: (id: string) => void }) {
const { threads, loading } = useMessageThreads(); const { threads, loading } = useMessageThreads();
const { user, isStaff } = useAuth();
const { toast } = useToast();
const [pendingDelete, setPendingDelete] = useState<{ id: string; subject: string } | null>(null);
// Delete-for-everyone: removes the thread (RLS allows creator or admin/manager);
// cascade removes participants + messages, and realtime refetches the list.
const deleteThread = async (id: string) => {
const { error } = await (supabase as any).from("message_threads").delete().eq("id", id);
if (error) toast({ title: "Couldn't delete topic", description: error.message, variant: "destructive" });
else toast({ title: "Topic deleted" });
setPendingDelete(null);
};
return ( return (
<div className="flex h-full flex-col border-r border-border"> <div className="flex h-full flex-col border-r border-border">
@@ -33,11 +53,11 @@ export default function TopicThreadList({
</div> </div>
) : ( ) : (
threads.map((t) => ( threads.map((t) => (
<div key={t.id} className="group relative border-b border-border">
<button <button
key={t.id}
onClick={() => onSelect(t.id)} onClick={() => onSelect(t.id)}
className={cn( className={cn(
"w-full text-left px-4 py-3 border-b border-border hover:bg-accent transition-colors", "w-full text-left px-4 py-3 hover:bg-accent transition-colors",
selectedThreadId === t.id && "bg-accent", selectedThreadId === t.id && "bg-accent",
)} )}
> >
@@ -54,9 +74,40 @@ export default function TopicThreadList({
<p className="text-[11px] text-muted-foreground/80 truncate mt-0.5">{t.participant_names.join(", ")}</p> <p className="text-[11px] text-muted-foreground/80 truncate mt-0.5">{t.participant_names.join(", ")}</p>
)} )}
</button> </button>
{(t.created_by === user?.id || isStaff) && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); setPendingDelete({ id: t.id, subject: t.subject }); }}
title="Delete topic"
className="absolute right-2 top-2 hidden group-hover:flex h-6 w-6 items-center justify-center rounded-md bg-background/80 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
)) ))
)} )}
</div> </div>
<AlertDialog open={!!pendingDelete} onOpenChange={(o) => { if (!o) setPendingDelete(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this topic?</AlertDialogTitle>
<AlertDialogDescription>
This permanently deletes {pendingDelete?.subject} and all its messages for everyone. This can't be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => pendingDelete && deleteThread(pendingDelete.id)}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
} }