mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
fe78c25fd1
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>
337 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|