mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
Closes #104. Supabase Realtime payloads only carry the row's own columns — the `contact:contacts(*)` join the initial fetch uses is absent. A freshly-inserted conversation arrived in state with `contact: undefined`, displaying "Unknown" and a blank contact sidebar. When the message-INSERT for that conversation arrived before the conv-INSERT event had landed in state, the preview update was a silent no-op and the conversation appeared stuck on "No messages yet" until something else triggered a refetch (typically the user sending a reply, which is why replying made the missing inbound messages "suddenly appear"). Add a deduped `hydrateConversation(id)` helper that fetches the row with the contact joined and merges it into state. Call it from: - conv-INSERT realtime handler — to backfill the contact join - message-INSERT handler when the conv isn't in state yet - conv-UPDATE handler when the row is missing (self-heal for any realtime event ever dropped) Existing fields in state win on merge: a realtime UPDATE may have patched last_message_text / unread_count to fresher values than the single-row read returns, and we don't want to clobber them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
434 lines
16 KiB
TypeScript
434 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useCallback, useEffect, useRef } from "react";
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
import { createClient } from "@/lib/supabase/client";
|
|
import type { Conversation, Message, Contact, ConversationStatus } from "@/types";
|
|
import { useRealtime } from "@/hooks/use-realtime";
|
|
import { ConversationList } from "@/components/inbox/conversation-list";
|
|
import { MessageThread } from "@/components/inbox/message-thread";
|
|
import { ContactSidebar } from "@/components/inbox/contact-sidebar";
|
|
import { toast } from "sonner";
|
|
import { WifiOff } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export default function InboxPage() {
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
/**
|
|
* `?c=<id>` deep-link support. Used when landing here from the
|
|
* dashboard's recent-conversations list so the right thread opens
|
|
* automatically instead of showing the empty center panel.
|
|
*/
|
|
const deepLinkConvId = searchParams.get("c");
|
|
|
|
const [conversations, setConversations] = useState<Conversation[]>([]);
|
|
const [activeConversation, setActiveConversation] =
|
|
useState<Conversation | null>(null);
|
|
const [activeContact, setActiveContact] = useState<Contact | null>(null);
|
|
const [messages, setMessages] = useState<Message[]>([]);
|
|
const [whatsappConnected, setWhatsappConnected] = useState<boolean | null>(
|
|
null
|
|
);
|
|
|
|
// Fire the deep-link auto-select exactly once per URL — subsequent
|
|
// list refreshes (realtime, manual refetch) must not snap the user
|
|
// back to the deep-linked conversation if they've already clicked
|
|
// elsewhere.
|
|
const autoSelectedForDeepLinkRef = useRef<string | null>(null);
|
|
|
|
// Tracks conversations whose hydrate fetch is currently in flight. The
|
|
// conv-INSERT and the first-message-INSERT events both call into
|
|
// hydrateConversation; the dedupe here keeps it at one refetch per
|
|
// new conversation even when both events arrive within milliseconds.
|
|
const hydratingConvIdsRef = useRef<Set<string>>(new Set());
|
|
|
|
// Pull the conversation row with its `contact` joined and merge it
|
|
// into state. Needed because Supabase Realtime payloads only carry the
|
|
// row's own columns — a brand-new conversation arrives without a
|
|
// contact, which surfaced as "Unknown" names, empty avatars, and
|
|
// (when the conv-INSERT event was delayed past the message-INSERT)
|
|
// conversations stuck on "No messages yet" until the user reloaded.
|
|
// Also self-heals if a realtime event was missed: callers can invoke
|
|
// this whenever they reference a conversation id they don't recognise.
|
|
const hydrateConversation = useCallback(async (convId: string) => {
|
|
if (hydratingConvIdsRef.current.has(convId)) return;
|
|
hydratingConvIdsRef.current.add(convId);
|
|
try {
|
|
const supabase = createClient();
|
|
const { data, error } = await supabase
|
|
.from("conversations")
|
|
.select("*, contact:contacts(*)")
|
|
.eq("id", convId)
|
|
.maybeSingle();
|
|
if (error) {
|
|
// Supabase errors have non-enumerable properties — log fields
|
|
// explicitly so the console message isn't just `{}`.
|
|
console.error("Failed to hydrate conversation:", {
|
|
message: error.message,
|
|
details: error.details,
|
|
hint: error.hint,
|
|
code: error.code,
|
|
});
|
|
return;
|
|
}
|
|
if (!data) return;
|
|
const fetched = data as Conversation;
|
|
setConversations((prev) => {
|
|
const existing = prev.find((c) => c.id === fetched.id);
|
|
if (existing) {
|
|
// Already in state — keep its fields (a realtime UPDATE may
|
|
// have landed while the fetch was in flight and patched
|
|
// last_message_text / unread_count to fresher values than
|
|
// the row we just read). Only backfill `contact`, which the
|
|
// realtime payloads never carry.
|
|
return prev.map((c) =>
|
|
c.id === fetched.id
|
|
? { ...c, contact: c.contact ?? fetched.contact }
|
|
: c,
|
|
);
|
|
}
|
|
return [fetched, ...prev];
|
|
});
|
|
} finally {
|
|
hydratingConvIdsRef.current.delete(convId);
|
|
}
|
|
}, []);
|
|
|
|
// Check WhatsApp connection status on mount
|
|
useEffect(() => {
|
|
const checkConnection = async () => {
|
|
const supabase = createClient();
|
|
const {
|
|
data: { session },
|
|
} = await supabase.auth.getSession();
|
|
const user = session?.user;
|
|
|
|
if (!user) return;
|
|
|
|
// Table is `whatsapp_config` (singular) — the previous "whatsapp_configs"
|
|
// query always returned no rows, so the banner always showed "not connected".
|
|
const { data } = await supabase
|
|
.from("whatsapp_config")
|
|
.select("status")
|
|
.eq("user_id", user.id)
|
|
.maybeSingle();
|
|
|
|
setWhatsappConnected(data?.status === "connected");
|
|
};
|
|
|
|
checkConnection();
|
|
}, []);
|
|
|
|
// Handle realtime message events
|
|
const handleMessageEvent = useCallback(
|
|
(event: { eventType: string; new: Message; old: Partial<Message> }) => {
|
|
const newMsg = event.new;
|
|
|
|
if (event.eventType === "INSERT") {
|
|
// Add to messages if it belongs to active conversation
|
|
if (
|
|
activeConversation &&
|
|
newMsg.conversation_id === activeConversation.id
|
|
) {
|
|
setMessages((prev) => {
|
|
// Avoid duplicates
|
|
if (prev.some((m) => m.id === newMsg.id)) return prev;
|
|
// Replace optimistic message if it exists
|
|
const withoutOptimistic = prev.filter(
|
|
(m) => !m.id.startsWith("temp-")
|
|
);
|
|
return [...withoutOptimistic, newMsg];
|
|
});
|
|
}
|
|
|
|
// Update conversation list preview. If the conv isn't in state
|
|
// yet (its INSERT event was missed, delayed, or this is the
|
|
// first message for a conversation created out-of-band) the
|
|
// map() below is a no-op and the preview would never appear —
|
|
// hydrate the row from the DB instead so the inbox self-heals.
|
|
let foundInList = false;
|
|
setConversations((prev) => {
|
|
if (!prev.some((c) => c.id === newMsg.conversation_id)) {
|
|
return prev;
|
|
}
|
|
foundInList = true;
|
|
return prev.map((c) =>
|
|
c.id === newMsg.conversation_id
|
|
? {
|
|
...c,
|
|
last_message_text: newMsg.content_text ?? "",
|
|
last_message_at: newMsg.created_at,
|
|
unread_count:
|
|
activeConversation?.id === newMsg.conversation_id
|
|
? 0
|
|
: c.unread_count + 1,
|
|
}
|
|
: c,
|
|
);
|
|
});
|
|
if (!foundInList) {
|
|
hydrateConversation(newMsg.conversation_id);
|
|
}
|
|
}
|
|
|
|
if (event.eventType === "UPDATE") {
|
|
// Update message status
|
|
setMessages((prev) =>
|
|
prev.map((m) => (m.id === newMsg.id ? { ...m, ...newMsg } : m))
|
|
);
|
|
}
|
|
},
|
|
[activeConversation, hydrateConversation]
|
|
);
|
|
|
|
// Handle realtime conversation events
|
|
const handleConversationEvent = useCallback(
|
|
(event: {
|
|
eventType: string;
|
|
new: Conversation;
|
|
old: Partial<Conversation>;
|
|
}) => {
|
|
const conv = event.new;
|
|
|
|
if (event.eventType === "INSERT") {
|
|
// Prepend immediately for snappy UX, then hydrate to fill in
|
|
// the `contact` join (realtime payloads omit it). The dedupe
|
|
// inside hydrateConversation keeps the result idempotent if
|
|
// the first-message-INSERT handler also triggers a hydrate.
|
|
setConversations((prev) => {
|
|
if (prev.some((c) => c.id === conv.id)) return prev;
|
|
return [conv, ...prev];
|
|
});
|
|
hydrateConversation(conv.id);
|
|
}
|
|
|
|
if (event.eventType === "UPDATE") {
|
|
let foundInList = false;
|
|
setConversations((prev) => {
|
|
if (!prev.some((c) => c.id === conv.id)) return prev;
|
|
foundInList = true;
|
|
return prev.map((c) => (c.id === conv.id ? { ...c, ...conv } : c));
|
|
});
|
|
if (!foundInList) {
|
|
// UPDATE arrived before the INSERT (or after a missed
|
|
// INSERT) — fetch the row so it surfaces with its contact.
|
|
hydrateConversation(conv.id);
|
|
}
|
|
|
|
// Update active conversation if it changed
|
|
if (activeConversation && conv.id === activeConversation.id) {
|
|
setActiveConversation((prev) =>
|
|
prev ? { ...prev, ...conv } : prev
|
|
);
|
|
}
|
|
}
|
|
},
|
|
[activeConversation, hydrateConversation]
|
|
);
|
|
|
|
// Subscribe to realtime
|
|
useRealtime({
|
|
channelName: "inbox-realtime",
|
|
onMessageEvent: handleMessageEvent,
|
|
onConversationEvent: handleConversationEvent,
|
|
enabled: true,
|
|
});
|
|
|
|
const handleConversationsLoaded = useCallback(
|
|
(loaded: Conversation[]) => {
|
|
setConversations(loaded);
|
|
// Resolve a pending deep-link here rather than in an effect — this
|
|
// is an event handler, so the setState calls below are allowed by
|
|
// react-hooks/set-state-in-effect. Runs once per ?c=<id> URL value
|
|
// via the ref, so realtime refreshes of the list can't snap the
|
|
// user back to the deep-linked thread after they've navigated.
|
|
if (
|
|
deepLinkConvId &&
|
|
autoSelectedForDeepLinkRef.current !== deepLinkConvId &&
|
|
loaded.length > 0
|
|
) {
|
|
autoSelectedForDeepLinkRef.current = deepLinkConvId;
|
|
// If the deep-linked conversation is already the active one
|
|
// (e.g. because the user clicked it in the list and we
|
|
// router.replace()'d the URL, which made the ConversationList
|
|
// refetch and land us back here), do NOT re-apply it. Doing so
|
|
// would setMessages([]) on a thread whose messages have
|
|
// already been loaded by MessageThread — and because
|
|
// conversationId didn't change, MessageThread wouldn't
|
|
// refetch. The thread would read "No messages yet" until a
|
|
// full page reload rehydrated state from scratch.
|
|
if (activeConversation?.id === deepLinkConvId) return;
|
|
const match = loaded.find((c) => c.id === deepLinkConvId);
|
|
if (match) {
|
|
setActiveConversation(match);
|
|
setActiveContact(match.contact ?? null);
|
|
setMessages([]);
|
|
}
|
|
}
|
|
},
|
|
[deepLinkConvId, activeConversation?.id]
|
|
);
|
|
|
|
const handleSelectConversation = useCallback(
|
|
(conv: Conversation) => {
|
|
// Re-clicking the already-active conversation would clear the
|
|
// messages array, but the fetch effect in MessageThread only re-runs
|
|
// when conversationId changes — so messages would stay empty until
|
|
// the user navigated away and back. Bail out early instead.
|
|
if (activeConversation?.id === conv.id) return;
|
|
setActiveConversation(conv);
|
|
setActiveContact(conv.contact ?? null);
|
|
setMessages([]);
|
|
// Record the selection on the deep-link ref BEFORE we change the
|
|
// URL. The router.replace below flips `deepLinkConvId`, which can
|
|
// in turn cause ConversationList to refetch and eventually call
|
|
// handleConversationsLoaded again. Without this line, the ref
|
|
// still points at the previous value, the auto-select block
|
|
// sees `ref !== deepLinkConvId`, fires a second time, and
|
|
// clobbers the messages MessageThread just fetched.
|
|
autoSelectedForDeepLinkRef.current = conv.id;
|
|
// Reflect the selection in the URL so a refresh lands the user
|
|
// back in the same thread, and so copy-paste links work. Use
|
|
// replace() to avoid polluting browser history with every click.
|
|
router.replace(`/inbox?c=${conv.id}`, { scroll: false });
|
|
},
|
|
[activeConversation?.id, router]
|
|
);
|
|
|
|
// Mobile "back" — deselect the conversation so the list pane comes
|
|
// back. Also clears the ?c= param so a refresh lands on the list
|
|
// instead of re-opening the thread the user just backed out of.
|
|
const handleCloseConversation = useCallback(() => {
|
|
setActiveConversation(null);
|
|
setActiveContact(null);
|
|
setMessages([]);
|
|
// Clearing the ref lets the deep-link auto-selector fire again if
|
|
// the user later visits /inbox?c=<same-id> — desirable UX.
|
|
autoSelectedForDeepLinkRef.current = null;
|
|
router.replace("/inbox", { scroll: false });
|
|
}, [router]);
|
|
|
|
|
|
const handleMessagesLoaded = useCallback((loaded: Message[]) => {
|
|
setMessages(loaded);
|
|
}, []);
|
|
|
|
const handleNewMessage = useCallback((msg: Message) => {
|
|
setMessages((prev) => {
|
|
if (prev.some((m) => m.id === msg.id)) return prev;
|
|
return [...prev, msg];
|
|
});
|
|
}, []);
|
|
|
|
const handleUpdateMessage = useCallback(
|
|
(id: string, updates: Partial<Message>) => {
|
|
setMessages((prev) =>
|
|
prev.map((m) => (m.id === id ? { ...m, ...updates } : m))
|
|
);
|
|
},
|
|
[]
|
|
);
|
|
|
|
const handleStatusChange = useCallback(
|
|
(conversationId: string, status: ConversationStatus) => {
|
|
setConversations((prev) =>
|
|
prev.map((c) => (c.id === conversationId ? { ...c, status } : c))
|
|
);
|
|
if (activeConversation?.id === conversationId) {
|
|
setActiveConversation((prev) => (prev ? { ...prev, status } : prev));
|
|
}
|
|
},
|
|
[activeConversation]
|
|
);
|
|
|
|
const handleAssignChange = useCallback(
|
|
(conversationId: string, assignedAgentId: string | null) => {
|
|
setConversations((prev) =>
|
|
prev.map((c) =>
|
|
c.id === conversationId
|
|
? { ...c, assigned_agent_id: assignedAgentId ?? undefined }
|
|
: c
|
|
)
|
|
);
|
|
if (activeConversation?.id === conversationId) {
|
|
setActiveConversation((prev) =>
|
|
prev
|
|
? { ...prev, assigned_agent_id: assignedAgentId ?? undefined }
|
|
: prev
|
|
);
|
|
}
|
|
},
|
|
[activeConversation]
|
|
);
|
|
|
|
// On mobile (<lg) we show a SINGLE pane — either the list or the
|
|
// thread — rather than cramming both side-by-side. Selecting a
|
|
// conversation slides the thread in; the thread's back button pops
|
|
// it back to the list. On lg+ both panes render side-by-side as
|
|
// before, unchanged.
|
|
const hasActiveConv = !!activeConversation;
|
|
|
|
return (
|
|
<div className="-m-4 flex h-[calc(100vh-3.5rem)] flex-col overflow-hidden sm:-m-6">
|
|
{/* WhatsApp connection banner — in the flex column, not absolute,
|
|
so it pushes the panels down instead of overlapping them. */}
|
|
{whatsappConnected === false && (
|
|
<div className="flex shrink-0 items-center justify-center gap-2 border-b border-amber-500/20 bg-amber-500/10 px-4 py-2">
|
|
<WifiOff className="h-4 w-4 text-amber-400" />
|
|
<p className="text-xs text-amber-400">
|
|
WhatsApp® is not connected. Go to Settings to connect your account.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-1 overflow-hidden">
|
|
{/* Left panel: Conversation list.
|
|
Hidden on mobile when a conversation is selected so the
|
|
thread can occupy the full width. Always visible on lg+. */}
|
|
<div
|
|
className={cn(
|
|
"flex h-full flex-1 lg:flex-none",
|
|
hasActiveConv ? "hidden lg:flex" : "flex",
|
|
)}
|
|
>
|
|
<ConversationList
|
|
activeConversationId={activeConversation?.id ?? null}
|
|
onSelect={handleSelectConversation}
|
|
conversations={conversations}
|
|
onConversationsLoaded={handleConversationsLoaded}
|
|
/>
|
|
</div>
|
|
|
|
{/* Center panel: Message thread.
|
|
Hidden on mobile when no conversation is selected so the
|
|
list can occupy the full width. Always visible on lg+
|
|
(shows its own empty-state if no thread is picked yet). */}
|
|
<div
|
|
className={cn(
|
|
"flex h-full flex-1 lg:flex",
|
|
hasActiveConv ? "flex" : "hidden lg:flex",
|
|
)}
|
|
>
|
|
<MessageThread
|
|
conversation={activeConversation}
|
|
contact={activeContact}
|
|
messages={messages}
|
|
onMessagesLoaded={handleMessagesLoaded}
|
|
onNewMessage={handleNewMessage}
|
|
onUpdateMessage={handleUpdateMessage}
|
|
onStatusChange={handleStatusChange}
|
|
onAssignChange={handleAssignChange}
|
|
onBack={handleCloseConversation}
|
|
/>
|
|
</div>
|
|
|
|
{/* Right panel: Contact sidebar — desktop only. */}
|
|
<div className="hidden lg:block">
|
|
<ContactSidebar contact={activeContact} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|