From 9c8777704694722026b672b1d9a13326fd8e4fe3 Mon Sep 17 00:00:00 2001 From: renee-png Date: Wed, 17 Jun 2026 19:38:15 -0400 Subject: [PATCH] Messaging: whole-board from Direct New Message + always-visible delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Whole board where users actually look: the Direct "New Message" dialog now lists "Whole Board โ€” " rows for staff (All/Board tabs). Clicking one opens the group-topic composer prefilled with that board's members (the composer is now reusable: hideTrigger + controlled open + initialSelected, wired through MessagesPage). - Delete discoverability: the trash control on Direct conversations and Topic threads is now always visible (subtle, bottom-right) instead of hover-only, which read as "no delete option." Co-Authored-By: Claude Opus 4.8 --- src/components/messaging/ConversationList.tsx | 58 ++++++++++++++++--- .../messaging/NewTopicThreadDialog.tsx | 28 +++++++-- src/components/messaging/TopicThreadList.tsx | 2 +- src/pages/MessagesPage.tsx | 21 +++++++ 4 files changed, 96 insertions(+), 13 deletions(-) diff --git a/src/components/messaging/ConversationList.tsx b/src/components/messaging/ConversationList.tsx index 61182ca..48e1731 100644 --- a/src/components/messaging/ConversationList.tsx +++ b/src/components/messaging/ConversationList.tsx @@ -7,7 +7,7 @@ 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, Trash2 } from "lucide-react"; +import { Search, PenSquare, Loader2, Trash2, Users } from "lucide-react"; import { formatDistanceToNow } from "date-fns"; import { cn } from "@/lib/utils"; import { useToast } from "@/hooks/use-toast"; @@ -22,13 +22,15 @@ import { interface Props { selectedPartnerId: string | null; onSelectPartner: (id: string, name: string) => void; + // Start a group "topic" message to a whole board (memberIds = the board's users). + onMessageWholeBoard?: (memberIds: string[], label: string) => void; } function getInitials(name: string) { return name.split(" ").map((w) => w[0]).join("").toUpperCase().slice(0, 2); } -export default function ConversationList({ selectedPartnerId, onSelectPartner }: Props) { +export default function ConversationList({ selectedPartnerId, onSelectPartner, onMessageWholeBoard }: Props) { const { user, isHomeowner, isRvBoatLot, isBoardMember, isStaff } = useAuth(); const { conversations, loading } = useConversations(); const { toast } = useToast(); @@ -85,7 +87,7 @@ export default function ConversationList({ selectedPartnerId, onSelectPartner }: )} - + )} @@ -173,7 +175,7 @@ export default function ConversationList({ selectedPartnerId, onSelectPartner }: 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" + className="absolute right-2 bottom-2 flex h-7 w-7 items-center justify-center rounded-md bg-background/70 text-muted-foreground opacity-60 hover:opacity-100 hover:text-destructive hover:bg-background" > @@ -215,10 +217,11 @@ type RecipientUser = { context?: string; }; -function NewConversationDialog({ onSelectPartner }: { onSelectPartner: (id: string, name: string) => void }) { +function NewConversationDialog({ onSelectPartner, onMessageWholeBoard }: { onSelectPartner: (id: string, name: string) => void; onMessageWholeBoard?: (memberIds: string[], label: string) => void }) { const { user, isStaff, isBoardMember } = useAuth(); const [open, setOpen] = useState(false); const [users, setUsers] = useState([]); + const [boardGroups, setBoardGroups] = useState<{ id: string; name: string; memberIds: string[] }[]>([]); const [search, setSearch] = useState(""); const [tab, setTab] = useState<"all" | "homeowner" | "board" | "staff">("all"); const [loading, setLoading] = useState(false); @@ -258,11 +261,22 @@ function NewConversationDialog({ onSelectPartner }: { onSelectPartner: (id: stri .neq("status", "archived"), (supabase as any) .from("board_members") - .select("user_id, role, member_name, associations(name)") + .select("user_id, role, member_name, association_id, associations(name)") .not("user_id", "is", null), supabase.from("user_roles").select("user_id, role").in("role", ["admin", "manager"]), ]); + // Whole-board groups (one per association) so staff can message an entire board. + const groups = new Map(); + for (const b of (boardRes.data ?? []) 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))); + const userIds = new Set(); const recipients: RecipientUser[] = []; @@ -359,6 +373,11 @@ function NewConversationDialog({ onSelectPartner }: { onSelectPartner: (id: stri const showTabs = isStaff; + // Whole-board group rows (staff only) shown on the All / Board tabs. + const visibleGroups = (onMessageWholeBoard && (tab === "all" || tab === "board")) + ? boardGroups.filter((g) => g.name.toLowerCase().includes(search.toLowerCase())) + : []; + return ( @@ -404,6 +423,31 @@ function NewConversationDialog({ onSelectPartner }: { onSelectPartner: (id: stri ) : (
+ {visibleGroups.map((g) => ( + + ))} {filtered.map((u) => ( ))} - {filtered.length === 0 && !loading && ( + {filtered.length === 0 && visibleGroups.length === 0 && !loading && (

No recipients found

)}
diff --git a/src/components/messaging/NewTopicThreadDialog.tsx b/src/components/messaging/NewTopicThreadDialog.tsx index 0954c6b..a117e2d 100644 --- a/src/components/messaging/NewTopicThreadDialog.tsx +++ b/src/components/messaging/NewTopicThreadDialog.tsx @@ -16,10 +16,20 @@ 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 }) { +export default function NewTopicThreadDialog({ + onCreated, hideTrigger = false, open: openProp, onOpenChange, initialSelected, +}: { + onCreated: (threadId: string) => void; + hideTrigger?: boolean; + open?: boolean; + onOpenChange?: (o: boolean) => void; + initialSelected?: string[]; +}) { const { user, isStaff } = useAuth(); const { toast } = useToast(); - const [open, setOpen] = useState(false); + const [openState, setOpenState] = useState(false); + const open = openProp ?? openState; + const setOpen = (o: boolean) => { onOpenChange ? onOpenChange(o) : setOpenState(o); }; const [recipients, setRecipients] = useState([]); const [boardGroups, setBoardGroups] = useState<{ id: string; name: string; memberIds: string[] }[]>([]); const [loadingRecipients, setLoadingRecipients] = useState(false); @@ -98,6 +108,12 @@ export default function NewTopicThreadDialog({ onCreated }: { onCreated: (thread })(); }, [open, user, isStaff]); + // When opened with a prefilled recipient set (e.g. "message whole board" from + // the Direct dialog), preselect those members. + useEffect(() => { + if (open && initialSelected && initialSelected.length) setSelected(new Set(initialSelected)); + }, [open, initialSelected]); + const reset = () => { setTopic(""); setSubject(""); setBody(""); setSelected(new Set()); }; const toggle = (id: string) => { @@ -156,9 +172,11 @@ export default function NewTopicThreadDialog({ onCreated }: { onCreated: (thread return ( { setOpen(o); if (!o) reset(); }}> - - - + {!hideTrigger && ( + + + + )} New Topic
diff --git a/src/components/messaging/TopicThreadList.tsx b/src/components/messaging/TopicThreadList.tsx index 1be34f8..76c41d5 100644 --- a/src/components/messaging/TopicThreadList.tsx +++ b/src/components/messaging/TopicThreadList.tsx @@ -79,7 +79,7 @@ export default function TopicThreadList({ 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" + className="absolute right-2 bottom-2 flex h-7 w-7 items-center justify-center rounded-md bg-background/70 text-muted-foreground opacity-60 hover:opacity-100 hover:text-destructive hover:bg-background" > diff --git a/src/pages/MessagesPage.tsx b/src/pages/MessagesPage.tsx index 5d5f632..6b4753e 100644 --- a/src/pages/MessagesPage.tsx +++ b/src/pages/MessagesPage.tsx @@ -4,6 +4,7 @@ import ConversationList from "@/components/messaging/ConversationList"; import ChatView from "@/components/messaging/ChatView"; import TopicThreadList from "@/components/messaging/TopicThreadList"; import TopicThreadView from "@/components/messaging/TopicThreadView"; +import NewTopicThreadDialog from "@/components/messaging/NewTopicThreadDialog"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; export default function MessagesPage() { @@ -12,6 +13,8 @@ export default function MessagesPage() { const [selectedThreadId, setSelectedThreadId] = useState(null); const [searchParams, setSearchParams] = useSearchParams(); const [tab, setTab] = useState<"direct" | "topics">("direct"); + const [composerOpen, setComposerOpen] = useState(false); + const [boardPrefill, setBoardPrefill] = useState(undefined); // Deep-link from a topic notification (/dashboard/messages?thread=). useEffect(() => { @@ -35,7 +38,15 @@ export default function MessagesPage() { } }; + // "Message whole board" from the Direct dialog โ†’ open the group-topic composer + // prefilled with that board's members. + const handleMessageWholeBoard = (memberIds: string[]) => { + setBoardPrefill(memberIds); + setComposerOpen(true); + }; + return ( + <> setTab(v as "direct" | "topics")} className="space-y-3"> Direct @@ -48,6 +59,7 @@ export default function MessagesPage() {
@@ -63,5 +75,14 @@ export default function MessagesPage() { + + { setTab("topics"); handleSelectThread(id); }} + /> + ); }