mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
Reports: clicking a conversation sometimes left the message thread
stuck on "No messages yet" until the user hit browser refresh.
Root cause — a three-way race kicked off by any click on a conversation
in the list:
1. handleSelectConversation runs:
setActiveConversation(B)
setMessages([])
router.replace("/inbox?c=B")
2. The URL change flips `deepLinkConvId` (from useSearchParams).
handleConversationsLoaded is a useCallback with
[deepLinkConvId], so its identity changes. That cascades into
ConversationList, whose fetchConversations useCallback has
[onConversationsLoaded], whose useEffect has [fetchConversations] —
so the list refetches conversations purely because the URL changed.
3. When the refetch lands, handleConversationsLoaded runs its deep-link
auto-select branch. autoSelectedForDeepLinkRef still points at its
previous value (null, or the previous deep-link), so the guard
`ref !== deepLinkConvId` passes and the branch re-applies B:
setActiveConversation(B) // no-op, same id
setMessages([]) // BLOWS AWAY the messages
// MessageThread just fetched
4. MessageThread's fetch effect only re-runs when conversationId
changes. B is still B. It does not refetch. Messages stuck at [].
A hard reload unsticks things because state starts from scratch and
the initial deep-link flow is the only path that runs the auto-select.
Three defense-in-depth fixes:
- handleSelectConversation now writes the clicked id into
autoSelectedForDeepLinkRef BEFORE the router.replace, so by the
time any downstream refetch lands, the auto-select branch sees
ref === deepLinkConvId and skips.
- handleConversationsLoaded early-returns if the deep-linked
conversation is already the active one. This is redundant with
the ref update above but protects against any other code path
we don't foresee triggering a refetch.
- ConversationList's fetch is now driven by a plain useEffect with
[] deps, using a ref for the latest onConversationsLoaded
callback. URL changes no longer cause a conversations refetch.
Matches the same stabilisation pattern used in
src/hooks/use-auth.tsx and src/components/inbox/message-thread.tsx.
Verified: typecheck passes; lint count unchanged (37 pre-existing
problems, zero added). Couldn't exercise the rendered /inbox from the
preview sandbox — it's auth-gated and no test credentials are
available. Please confirm in the authenticated inbox:
- Click back and forth between two conversations with different
message histories; messages should populate every time.
- Open /inbox?c=<id> directly; thread opens with messages.
- Switch conversations after a deep-link landing; still works.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
277 lines
8.8 KiB
TypeScript
277 lines
8.8 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback, useMemo, useRef } 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);
|
|
|
|
// Keep the latest callback in a ref so the fetch effect below can
|
|
// have a stable, empty-dep identity. Previously the fetch useCallback
|
|
// depended on `onConversationsLoaded`, which depends on the parent's
|
|
// `deepLinkConvId` — so every URL change (including one the parent
|
|
// triggered via router.replace after a click) caused a fresh
|
|
// conversations fetch. That extra refetch was the trigger for the
|
|
// deep-link auto-select running a second time and wiping the active
|
|
// thread's messages.
|
|
const onConversationsLoadedRef = useRef(onConversationsLoaded);
|
|
onConversationsLoadedRef.current = onConversationsLoaded;
|
|
|
|
useEffect(() => {
|
|
const supabase = createClient();
|
|
let cancelled = false;
|
|
|
|
(async () => {
|
|
const { data, error } = await supabase
|
|
.from("conversations")
|
|
.select("*, contact:contacts(*)")
|
|
.order("last_message_at", { ascending: false });
|
|
|
|
if (cancelled) return;
|
|
|
|
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;
|
|
}
|
|
|
|
onConversationsLoadedRef.current(data ?? []);
|
|
setLoading(false);
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
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 (
|
|
// w-full on mobile so the list occupies the whole viewport when it's
|
|
// the single pane showing; fixed 320px on desktop where it shares the
|
|
// row with the thread + contact sidebar.
|
|
<div className="flex h-full w-full flex-col border-r border-slate-800 bg-slate-900 lg:w-80">
|
|
{/* 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>
|
|
);
|
|
}
|