Messaging: whole-board from Direct New Message + always-visible delete

- Whole board where users actually look: the Direct "New Message" dialog now
  lists "Whole Board — <Association>" 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 19:38:15 -04:00
parent 6a6e2306ea
commit 9c87777046
4 changed files with 96 additions and 13 deletions
+51 -7
View File
@@ -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 }:
<PenSquare className="h-4 w-4" />
</Button>
)}
<NewConversationDialog onSelectPartner={onSelectPartner} />
<NewConversationDialog onSelectPartner={onSelectPartner} onMessageWholeBoard={onMessageWholeBoard} />
</div>
)}
</div>
@@ -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"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
@@ -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<RecipientUser[]>([]);
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<string, { id: string; name: string; memberIds: string[] }>();
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<string>();
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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
@@ -404,6 +423,31 @@ function NewConversationDialog({ onSelectPartner }: { onSelectPartner: (id: stri
</div>
) : (
<div className="space-y-1">
{visibleGroups.map((g) => (
<button
key={`board-${g.id}`}
onClick={() => {
onMessageWholeBoard?.(g.memberIds, g.name);
setOpen(false);
}}
className="w-full flex items-center gap-3 px-3 py-2 rounded-md hover:bg-accent transition-colors text-left"
>
<Avatar className="h-8 w-8">
<AvatarFallback className="text-xs bg-primary/15 text-primary">
<Users className="h-4 w-4" />
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-medium truncate">Whole Board {g.name}</span>
<Badge variant="outline" className="text-[10px] shrink-0">Board</Badge>
</div>
<p className="text-[11px] text-muted-foreground truncate">
{g.memberIds.length} member{g.memberIds.length === 1 ? "" : "s"} · starts a group topic
</p>
</div>
</button>
))}
{filtered.map((u) => (
<button
key={u.user_id}
@@ -432,7 +476,7 @@ function NewConversationDialog({ onSelectPartner }: { onSelectPartner: (id: stri
</div>
</button>
))}
{filtered.length === 0 && !loading && (
{filtered.length === 0 && visibleGroups.length === 0 && !loading && (
<p className="text-sm text-muted-foreground text-center py-4">No recipients found</p>
)}
</div>
@@ -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<Recipient[]>([]);
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 (
<Dialog open={open} onOpenChange={(o) => { setOpen(o); if (!o) reset(); }}>
<DialogTrigger asChild>
<Button size="sm" className="gap-1"><Plus className="h-4 w-4" /> New Topic</Button>
</DialogTrigger>
{!hideTrigger && (
<DialogTrigger asChild>
<Button size="sm" className="gap-1"><Plus className="h-4 w-4" /> New Topic</Button>
</DialogTrigger>
)}
<DialogContent className="max-w-lg overflow-hidden">
<DialogHeader><DialogTitle>New Topic</DialogTitle></DialogHeader>
<div className="space-y-4 min-w-0">
+1 -1
View File
@@ -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"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
+21
View File
@@ -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<string | null>(null);
const [searchParams, setSearchParams] = useSearchParams();
const [tab, setTab] = useState<"direct" | "topics">("direct");
const [composerOpen, setComposerOpen] = useState(false);
const [boardPrefill, setBoardPrefill] = useState<string[] | undefined>(undefined);
// Deep-link from a topic notification (/dashboard/messages?thread=<id>).
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 (
<>
<Tabs value={tab} onValueChange={(v) => setTab(v as "direct" | "topics")} className="space-y-3">
<TabsList>
<TabsTrigger value="direct">Direct</TabsTrigger>
@@ -48,6 +59,7 @@ export default function MessagesPage() {
<ConversationList
selectedPartnerId={selectedPartnerId}
onSelectPartner={handleSelectPartner}
onMessageWholeBoard={handleMessageWholeBoard}
/>
</div>
<ChatView partnerId={selectedPartnerId} partnerName={partnerName} />
@@ -63,5 +75,14 @@ export default function MessagesPage() {
</div>
</TabsContent>
</Tabs>
<NewTopicThreadDialog
hideTrigger
open={composerOpen}
onOpenChange={setComposerOpen}
initialSelected={boardPrefill}
onCreated={(id) => { setTab("topics"); handleSelectThread(id); }}
/>
</>
);
}