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