Files
acmcc/src/components/messaging/ChatView.tsx
T
admin fe78c25fd1 Messaging: add explicit Delete button to the conversation header
The list-row trash is always-visible now, but add an unmissable "Delete"
button in the open-conversation header (ChatView) too. Deletes the whole 1:1
conversation for everyone and clears the selection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 20:04:50 -04:00

337 lines
14 KiB
TypeScript

import { useState, useRef, useEffect } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { useChatMessages, usePartnerInfo, STAFF_GROUP_ID } from "@/hooks/useDirectMessages";
import { supabase } from "@/integrations/supabase/client";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { FilePlus, Send, Loader2, MessageSquare, Trash2 } from "lucide-react";
import { format, isToday, isYesterday } from "date-fns";
import { cn } from "@/lib/utils";
import { useToast } from "@/hooks/use-toast";
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
interface Props {
partnerId: string | null;
partnerName: string;
onDeleted?: () => void;
}
function getInitials(name: string) {
return name.split(" ").map((w) => w[0]).join("").toUpperCase().slice(0, 2);
}
function formatMessageDate(dateStr: string) {
const d = new Date(dateStr);
if (isToday(d)) return format(d, "h:mm a");
if (isYesterday(d)) return "Yesterday " + format(d, "h:mm a");
return format(d, "MMM d, h:mm a");
}
export default function ChatView({ partnerId, partnerName, onDeleted }: Props) {
const { user, isAdmin, isStaff } = useAuth();
const { messages, loading, sendMessage, deleteMessage } = useChatMessages(partnerId);
const partnerInfo = usePartnerInfo(partnerId);
const [draft, setDraft] = useState("");
const [sending, setSending] = useState(false);
const [deleteConvOpen, setDeleteConvOpen] = useState(false);
const canDeleteConversation = !!partnerId && partnerId !== STAFF_GROUP_ID;
const deleteConversation = async () => {
if (!user || !partnerId) return;
const { error } = await supabase
.from("direct_messages")
.delete()
.or(`and(sender_id.eq.${user.id},recipient_id.eq.${partnerId}),and(sender_id.eq.${partnerId},recipient_id.eq.${user.id})`);
setDeleteConvOpen(false);
if (error) { toast({ title: "Couldn't delete conversation", description: error.message, variant: "destructive" }); return; }
toast({ title: "Conversation deleted" });
onDeleted?.();
};
const [creatingRequestId, setCreatingRequestId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const { toast } = useToast();
const canCreateRequests = isAdmin || isStaff;
// Auto-scroll to bottom on new messages
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
const handleSend = async () => {
if (!draft.trim() || sending) return;
setSending(true);
try {
await sendMessage(draft);
setDraft("");
} catch (err: any) {
toast({ title: "Error sending message", description: err.message, variant: "destructive" });
} finally {
setSending(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const createHomeownerRequest = async (messageId: string, message: string, createdAt: string) => {
if (!partnerId || !canCreateRequests) return;
setCreatingRequestId(messageId);
try {
const { data: owner, error: ownerError } = await supabase
.from("owners")
.select("id, association_id, first_name, last_name, property_address, units(address)")
.eq("user_id", partnerId)
.neq("status", "archived")
.limit(1)
.maybeSingle();
if (ownerError) throw ownerError;
if (!owner) throw new Error("No homeowner record is linked to this sender.");
const title = message.replace(/\s+/g, " ").trim().slice(0, 80) || `Message from ${partnerName}`;
const ownerName = `${owner.first_name || ""} ${owner.last_name || ""}`.trim() || partnerName;
const description = [
`Created from homeowner message on ${format(new Date(createdAt), "MMM d, yyyy h:mm a")}.`,
`Homeowner: ${ownerName}`,
owner.property_address || (owner.units as any)?.address ? `Property: ${owner.property_address || (owner.units as any)?.address}` : null,
"",
message,
].filter(Boolean).join("\n");
const { error } = await supabase.from("homeowner_requests").insert({
association_id: owner.association_id,
owner_id: owner.id,
title,
description,
category: "general",
priority: "medium",
status: "open",
});
if (error) throw error;
toast({ title: "Homeowner request created" });
} catch (err: any) {
toast({ title: "Request not created", description: err.message, variant: "destructive" });
} finally {
setCreatingRequestId(null);
}
};
if (!partnerId) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-3">
<MessageSquare className="h-12 w-12 opacity-30" />
<p className="text-sm">Select a conversation or start a new message</p>
</div>
);
}
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Header */}
<div className="shrink-0 px-4 py-3 border-b border-border flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback className="text-xs bg-primary/10 text-primary">
{getInitials(partnerName)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="font-semibold text-sm truncate">{partnerName}</div>
{partnerInfo && (partnerInfo.ownerName || partnerInfo.unit || partnerInfo.associationName) && (
<div className="text-[11px] text-muted-foreground truncate">
{[partnerInfo.ownerName, partnerInfo.unit, partnerInfo.associationName].filter(Boolean).join(" • ")}
</div>
)}
</div>
{canDeleteConversation && (
<Button
variant="ghost"
size="sm"
className="shrink-0 gap-1 text-muted-foreground hover:text-destructive"
onClick={() => setDeleteConvOpen(true)}
>
<Trash2 className="h-4 w-4" /> Delete
</Button>
)}
</div>
<AlertDialog open={deleteConvOpen} onOpenChange={setDeleteConvOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this conversation?</AlertDialogTitle>
<AlertDialogDescription>
This permanently deletes the conversation with {partnerName} 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={deleteConversation}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Messages */}
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4 space-y-3">
{loading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : messages.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No messages yet. Say hello!
</p>
) : (
messages.map((msg) => {
const isMe = msg.sender_id === user?.id;
const canDelete = isMe || isStaff || isAdmin;
return (
<div key={msg.id} className={cn("group flex items-start gap-1", isMe ? "justify-end" : "justify-start")}>
{isMe && canDelete && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive shrink-0 mt-1"
disabled={deletingId === msg.id}
>
{deletingId === msg.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete message?</AlertDialogTitle>
<AlertDialogDescription>This will remove the message permanently.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
setDeletingId(msg.id);
try { await deleteMessage(msg.id); }
catch (e: any) { toast({ title: "Delete failed", description: e.message, variant: "destructive" }); }
finally { setDeletingId(null); }
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
<div
className={cn(
"max-w-[75%] rounded-2xl px-3.5 py-2 text-sm",
isMe
? "bg-primary text-primary-foreground rounded-br-md"
: "bg-muted rounded-bl-md"
)}
>
<p className="whitespace-pre-wrap break-words">{msg.message}</p>
<p
className={cn(
"text-[10px] mt-1",
isMe ? "text-primary-foreground/60" : "text-muted-foreground"
)}
>
{formatMessageDate(msg.created_at)}
</p>
{!isMe && canCreateRequests && (
<Button
type="button"
variant="ghost"
size="sm"
className="mt-2 h-6 px-2 text-[11px]"
onClick={() => createHomeownerRequest(msg.id, msg.message, msg.created_at)}
disabled={creatingRequestId === msg.id}
>
{creatingRequestId === msg.id ? <Loader2 className="mr-1 h-3 w-3 animate-spin" /> : <FilePlus className="mr-1 h-3 w-3" />}
Create Request
</Button>
)}
</div>
{!isMe && canDelete && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive shrink-0 mt-1"
disabled={deletingId === msg.id}
>
{deletingId === msg.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete message?</AlertDialogTitle>
<AlertDialogDescription>This will remove the message permanently.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
setDeletingId(msg.id);
try { await deleteMessage(msg.id); }
catch (e: any) { toast({ title: "Delete failed", description: e.message, variant: "destructive" }); }
finally { setDeletingId(null); }
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
);
})
)}
</div>
{/* Input */}
<div className="shrink-0 p-3 border-t border-border">
<div className="flex items-end gap-2">
<Textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
className="min-h-[40px] max-h-[120px] resize-none text-sm"
rows={1}
/>
<Button
size="icon"
onClick={handleSend}
disabled={sending || !draft.trim()}
className="shrink-0 h-10 w-10"
>
{sending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
);
}