wacrm/src/components/inbox/conversation-list.tsx
Arnas Donauskas 4de6b7719e
fix: make Supabase browser client a singleton
Fixes two runtime errors:

1. "Lock 'lock:sb-xxx-auth-token' was released because another request
   stole it" — caused by multiple Supabase client instances competing
   for the same auth storage lock.

2. "Failed to fetch conversations: {}" — related to #1. When the auth
   lock is stolen mid-request, the session becomes temporarily
   unavailable and queries fail.

The codebase had 65 call sites invoking createClient() — each creating
a new browser client with its own auth listener. This change makes
createClient() cache and return a single shared instance.

Also improved error logging in conversation-list.tsx to show Supabase
error fields (message/details/hint/code) instead of an empty {}.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 19:39:24 +03:00

257 lines
8.0 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import { createClient } from "@/lib/supabase/client";
import { cn } from "@/lib/utils";
import type { Conversation, ConversationStatus } from "@/types";
import { Search, ChevronDown } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
interface ConversationListProps {
activeConversationId: string | null;
onSelect: (conversation: Conversation) => void;
conversations: Conversation[];
onConversationsLoaded: (conversations: Conversation[]) => void;
}
const STATUS_COLORS: Record<ConversationStatus, string> = {
open: "bg-emerald-500",
pending: "bg-amber-500",
closed: "bg-slate-500",
};
const FILTER_OPTIONS: { label: string; value: ConversationStatus | "all" }[] = [
{ label: "All", value: "all" },
{ label: "Open", value: "open" },
{ label: "Pending", value: "pending" },
{ label: "Closed", value: "closed" },
];
export function ConversationList({
activeConversationId,
onSelect,
conversations,
onConversationsLoaded,
}: ConversationListProps) {
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<ConversationStatus | "all">("all");
const [loading, setLoading] = useState(true);
const fetchConversations = useCallback(async () => {
const supabase = createClient();
const { data, error } = await supabase
.from("conversations")
.select("*, contact:contacts(*)")
.order("last_message_at", { ascending: false });
if (error) {
// Supabase errors have non-enumerable properties — log fields explicitly
console.error("Failed to fetch conversations:", {
message: error.message,
details: error.details,
hint: error.hint,
code: error.code,
});
setLoading(false);
return;
}
onConversationsLoaded(data ?? []);
setLoading(false);
}, [onConversationsLoaded]);
useEffect(() => {
fetchConversations();
}, [fetchConversations]);
const filtered = useMemo(() => {
let result = conversations;
if (filter !== "all") {
result = result.filter((c) => c.status === filter);
}
if (search.trim()) {
const q = search.toLowerCase();
result = result.filter((c) => {
const name = c.contact?.name?.toLowerCase() ?? "";
const phone = c.contact?.phone?.toLowerCase() ?? "";
const lastMsg = c.last_message_text?.toLowerCase() ?? "";
return name.includes(q) || phone.includes(q) || lastMsg.includes(q);
});
}
return result;
}, [conversations, filter, search]);
const handleSearchChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value);
},
[]
);
const handleSelect = useCallback(
(conv: Conversation) => {
onSelect(conv);
},
[onSelect]
);
const activeFilter = FILTER_OPTIONS.find((o) => o.value === filter);
return (
<div className="flex h-full w-80 flex-col border-r border-slate-800 bg-slate-900">
{/* Search + Filter */}
<div className="space-y-2 border-b border-slate-800 p-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" />
<Input
value={search}
onChange={handleSearchChange}
placeholder="Search conversations..."
className="border-slate-700 bg-slate-800 pl-9 text-sm text-white placeholder-slate-500 focus:border-emerald-500/50"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex items-center justify-center h-7 gap-1 px-2 text-xs text-slate-400 hover:text-white rounded-md hover:bg-slate-800">
{activeFilter?.label ?? "All"}
<ChevronDown className="h-3 w-3" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="border-slate-700 bg-slate-800"
>
{FILTER_OPTIONS.map((opt) => (
<DropdownMenuItem
key={opt.value}
onClick={() => setFilter(opt.value)}
className={cn(
"text-sm",
filter === opt.value
? "text-emerald-400"
: "text-slate-300"
)}
>
{opt.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Conversation Items */}
<ScrollArea className="flex-1">
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
</div>
) : filtered.length === 0 ? (
<div className="px-4 py-12 text-center">
<p className="text-sm text-slate-500">No conversations found</p>
</div>
) : (
<div className="flex flex-col">
{filtered.map((conv) => (
<ConversationItem
key={conv.id}
conversation={conv}
isActive={conv.id === activeConversationId}
onSelect={handleSelect}
/>
))}
</div>
)}
</ScrollArea>
</div>
);
}
interface ConversationItemProps {
conversation: Conversation;
isActive: boolean;
onSelect: (conversation: Conversation) => void;
}
function ConversationItem({
conversation,
isActive,
onSelect,
}: ConversationItemProps) {
const contact = conversation.contact;
const displayName = contact?.name || contact?.phone || "Unknown";
const initials = displayName.charAt(0).toUpperCase();
const handleClick = useCallback(() => {
onSelect(conversation);
}, [onSelect, conversation]);
const timeAgo = conversation.last_message_at
? formatDistanceToNow(new Date(conversation.last_message_at), {
addSuffix: false,
})
: "";
return (
<button
onClick={handleClick}
className={cn(
"flex w-full items-start gap-3 px-3 py-3 text-left transition-colors hover:bg-slate-800/50",
isActive && "border-l-2 border-emerald-500 bg-slate-800/70"
)}
>
{/* Avatar */}
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-slate-700 text-sm font-medium text-white">
{contact?.avatar_url ? (
<img
src={contact.avatar_url}
alt={displayName}
className="h-10 w-10 rounded-full object-cover"
/>
) : (
initials
)}
</div>
{/* Content */}
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium text-white">
{displayName}
</span>
<span className="shrink-0 text-[10px] text-slate-500">{timeAgo}</span>
</div>
<div className="mt-0.5 flex items-center justify-between gap-2">
<p className="truncate text-xs text-slate-400">
{conversation.last_message_text || "No messages yet"}
</p>
<div className="flex shrink-0 items-center gap-1.5">
{conversation.unread_count > 0 && (
<span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-emerald-500 px-1 text-[10px] font-bold text-white">
{conversation.unread_count}
</span>
)}
<span
className={cn(
"h-2 w-2 rounded-full",
STATUS_COLORS[conversation.status]
)}
title={conversation.status}
/>
</div>
</div>
</div>
</button>
);
}