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 { 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 (
<div className="flex flex-col h-full border-r border-border">
<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">
{filtered.map((conv) => (
<div key={conv.partner_id} className="group relative">
<button
key={conv.partner_id}
onClick={() => onSelectPartner(conv.partner_id, conv.partner_name)}
className={cn(
"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>
</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>
)}
</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>
);
}
@@ -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
<PenSquare className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogContent className="sm:max-w-md overflow-hidden">
<DialogHeader>
<DialogTitle>New Message</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-3 min-w-0">
<Input
placeholder="Search recipients..."
value={search}
@@ -21,6 +21,7 @@ export default function NewTopicThreadDialog({ onCreated }: { onCreated: (thread
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [recipients, setRecipients] = useState<Recipient[]>([]);
const [boardGroups, setBoardGroups] = useState<{ id: string; name: string; memberIds: string[] }[]>([]);
const [loadingRecipients, setLoadingRecipients] = useState(false);
const [topic, setTopic] = useState<string>("");
const [subject, setSubject] = useState("");
@@ -53,13 +54,30 @@ export default function NewTopicThreadDialog({ onCreated }: { onCreated: (thread
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}` : ""}`,
});
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<string, { id: string; name: string; memberIds: string[] }>();
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
<DialogTrigger asChild>
<Button size="sm" className="gap-1"><Plus className="h-4 w-4" /> New Topic</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogContent className="max-w-lg overflow-hidden">
<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">
<Label>Topic</Label>
<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">
{loadingRecipients ? (
<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>
) : 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>
))}
) : (
<>
{boardGroups.map((g) => {
const allOn = g.memberIds.every((id) => selected.has(id));
return (
<label key={`board-${g.id}`} className="flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-accent">
<Checkbox checked={allOn} onCheckedChange={() => toggleGroup(g.memberIds)} />
<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 className="space-y-1.5">
+54 -3
View File
@@ -1,10 +1,18 @@
import { useState } from "react";
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 { Badge } from "@/components/ui/badge";
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 NewTopicThreadDialog from "./NewTopicThreadDialog";
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from "@/components/ui/alert-dialog";
function when(dateStr: string | null) {
if (!dateStr) return "";
@@ -16,6 +24,18 @@ export default function TopicThreadList({
selectedThreadId, onSelect,
}: { selectedThreadId: string | null; onSelect: (id: string) => void }) {
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 (
<div className="flex h-full flex-col border-r border-border">
@@ -33,11 +53,11 @@ export default function TopicThreadList({
</div>
) : (
threads.map((t) => (
<div key={t.id} className="group relative border-b border-border">
<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",
"w-full text-left px-4 py-3 hover:bg-accent transition-colors",
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>
)}
</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>
<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>
);
}