461 lines
26 KiB
TypeScript
461 lines
26 KiB
TypeScript
'use client';
|
||
|
||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||
import {
|
||
Search,
|
||
MoreVertical,
|
||
User,
|
||
MessageSquare,
|
||
ChevronDown,
|
||
RefreshCw,
|
||
MessageSquarePlus,
|
||
RotateCw,
|
||
ArrowLeft,
|
||
} from 'lucide-react';
|
||
import { useRouter } from 'next/router';
|
||
import { motion, AnimatePresence } from 'framer-motion';
|
||
import { format } from 'date-fns';
|
||
import { ptBR } from 'date-fns/locale';
|
||
import type { NewWhatsChat } from '../../types/inboxTypes';
|
||
import { getAvatarProxyUrl, formatPhone } from '../../utils/inboxUtils';
|
||
import { useNewInboxBridge } from '../../hooks/inbox/useNewInboxBridge';
|
||
import { useInstanceSelector } from '../../hooks/inbox/useInstanceSelector';
|
||
import { useChatFilter } from '../../hooks/inbox/useChatFilter';
|
||
import { useChatWindow } from '../../hooks/inbox/useChatWindow';
|
||
import { useMessageInput } from '../../hooks/inbox/useMessageInput';
|
||
import { messageApi } from '../../services/chatApiService';
|
||
import { useProtocolPanel } from '../../hooks/useProtocolPanel';
|
||
import ChatSidebar from '../../components/ChatSidebar';
|
||
import ThinSidebar, { TabType } from '../../components/ThinSidebar';
|
||
import InboxHeader from '../../components/InboxHeader';
|
||
import MessageArea from '../../components/MessageArea';
|
||
import InputBar from '../../components/InputBar';
|
||
import NewConversationModal from '../../components/NewConversationModal';
|
||
import ContactProfile from '../../components/ContactProfile';
|
||
import ProtocolPanel from '../../components/ProtocolPanel';
|
||
|
||
type Chat = NewWhatsChat;
|
||
|
||
export default function WhatsAppInboxPage() {
|
||
const router = useRouter()
|
||
|
||
// ── Seleção de instância (hook dedicado) ─────────────────────────────────
|
||
const {
|
||
instances,
|
||
activeInstance,
|
||
mappedActiveInstance,
|
||
connectedInstances,
|
||
isConnected,
|
||
dropdownRef,
|
||
selectInstance,
|
||
refreshInstances,
|
||
handleConnect: handleConnectInstance,
|
||
} = useInstanceSelector()
|
||
|
||
// ── Inbox bridge (chats + mensagens) ──────────────────────────────────────
|
||
const {
|
||
chats: chatsStore,
|
||
selectedChat,
|
||
messages,
|
||
loadingChats: loading,
|
||
loadingMessages,
|
||
presenceByJid,
|
||
handleSelectChat: handleSelectChatBridge,
|
||
handleSendText,
|
||
handleLoadMoreHistory,
|
||
handleMenuAction,
|
||
} = useNewInboxBridge()
|
||
|
||
// ── Filtros de chat (hook dedicado) ───────────────────────────────────────
|
||
const {
|
||
searchTerm,
|
||
activeFilter,
|
||
chatScope,
|
||
chatsByScope,
|
||
isFavoriteChat,
|
||
setSearchTerm,
|
||
setActiveFilter,
|
||
setChatScope,
|
||
toggleFavorite,
|
||
} = useChatFilter(chatsStore)
|
||
|
||
// ── Chat window (hook dedicado) ───────────────────────────────────────────
|
||
const {
|
||
scrollRef,
|
||
isTyping,
|
||
replyingTo,
|
||
reactionPickerFor,
|
||
hasMoreHistory,
|
||
loadingMore,
|
||
isProfileOpen,
|
||
isSyncingAvatar,
|
||
isChatMenuOpen,
|
||
setReplyingTo,
|
||
setIsProfileOpen,
|
||
setIsChatMenuOpen,
|
||
handleTyping,
|
||
handleScroll,
|
||
handleSelectChat: onChatWindowSelect,
|
||
handleReactToMessage,
|
||
handleSendMedia,
|
||
handleSendAudio,
|
||
handleSyncAvatar,
|
||
toggleReactionPicker,
|
||
} = useChatWindow({
|
||
selectedChat,
|
||
activeInstanceId: activeInstance?.instance ?? null,
|
||
onLoadMoreHistory: handleLoadMoreHistory,
|
||
})
|
||
|
||
// ── Mensagem de entrada (hook dedicado) ───────────────────────────────────
|
||
const { newMessage, setNewMessage, mentions, setMentions, handleSendMessage } = useMessageInput({
|
||
selectedChat,
|
||
replyingTo,
|
||
onClearReply: () => setReplyingTo(null),
|
||
onSendText: handleSendText,
|
||
})
|
||
|
||
// ── Painel de Protocolo ───────────────────────────────────────────────────
|
||
const [isProtocolOpen, setIsProtocolOpen] = useState(false)
|
||
const protocolPanel = useProtocolPanel(selectedChat ? String(selectedChat.id) : null)
|
||
|
||
// ── Estado local de UI ────────────────────────────────────────────────────
|
||
const [activeTab, setActiveTab] = useState<TabType>('chats')
|
||
const [isInstanceDropdownOpen, setIsInstanceDropdownOpen] = useState(false)
|
||
const [isOptionsMenuOpen, setIsOptionsMenuOpen] = useState(false)
|
||
const [drafts] = useState<Record<string, string>>({})
|
||
const [isMobile, setIsMobile] = useState(false)
|
||
const [isNewConvoModalOpen, setIsNewConvoModalOpen] = useState(false)
|
||
|
||
// Preloader: começa visível e some após o primeiro ciclo loading true→false
|
||
// O delay de 400ms dá tempo ao browser iniciar o carregamento dos avatares
|
||
// antes do overlay sumir, evitando o flash de skeletons pulsando.
|
||
const [pageReady, setPageReady] = useState(false)
|
||
const prevLoadingRef = useRef(false)
|
||
const readyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||
useEffect(() => {
|
||
if (prevLoadingRef.current && !loading) {
|
||
readyTimerRef.current = setTimeout(() => setPageReady(true), 400)
|
||
}
|
||
prevLoadingRef.current = loading
|
||
return () => { if (readyTimerRef.current) clearTimeout(readyTimerRef.current) }
|
||
}, [loading])
|
||
|
||
const optionsRef = useRef<HTMLDivElement>(null)
|
||
const initialized = useRef(false)
|
||
|
||
// ── Setup mobile + click-outside ─────────────────────────────────────────
|
||
useEffect(() => {
|
||
if (initialized.current) return
|
||
initialized.current = true
|
||
const resize = () => setIsMobile(window.innerWidth < 768)
|
||
window.addEventListener('resize', resize)
|
||
resize()
|
||
const click = (e: MouseEvent) => {
|
||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) setIsInstanceDropdownOpen(false)
|
||
if (optionsRef.current && !optionsRef.current.contains(e.target as Node)) setIsOptionsMenuOpen(false)
|
||
}
|
||
document.addEventListener('mousedown', click)
|
||
return () => { window.removeEventListener('resize', resize); document.removeEventListener('mousedown', click) }
|
||
}, [])
|
||
|
||
// ── Handlers ─────────────────────────────────────────────────────────────
|
||
|
||
const handleSelectChat = useCallback((chat: Chat | null) => {
|
||
handleSelectChatBridge(chat)
|
||
onChatWindowSelect()
|
||
setIsProtocolOpen(false)
|
||
}, [handleSelectChatBridge, onChatWindowSelect])
|
||
|
||
const handleChatMenuAction = useCallback((action: string, chat: Chat) => {
|
||
handleMenuAction(action, chat, toggleFavorite)
|
||
}, [handleMenuAction, toggleFavorite])
|
||
|
||
|
||
const handleFetchChats = useCallback(async () => {
|
||
await refreshInstances()
|
||
setIsOptionsMenuOpen(false)
|
||
}, [refreshInstances])
|
||
|
||
// Voltar: se há chat aberto, fecha-o; senão, página anterior
|
||
const handleBack = useCallback(() => {
|
||
if (selectedChat) {
|
||
handleSelectChat(null)
|
||
} else {
|
||
router.back()
|
||
}
|
||
}, [selectedChat, handleSelectChat, router])
|
||
|
||
// ESC fecha chat ou vai para página anterior
|
||
useEffect(() => {
|
||
const onKeyDown = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') handleBack()
|
||
}
|
||
document.addEventListener('keydown', onKeyDown)
|
||
return () => document.removeEventListener('keydown', onKeyDown)
|
||
}, [handleBack])
|
||
|
||
// ── Render ────────────────────────────────────────────────────────────────
|
||
return (
|
||
<div className="flex h-screen w-full bg-[#f0f2f5] overflow-hidden select-none font-sans text-[#111b21]">
|
||
{/* Preloader global – visível até o primeiro carregamento de chats completar */}
|
||
<AnimatePresence>
|
||
{!pageReady && (
|
||
<motion.div
|
||
key="inbox-preloader"
|
||
exit={{ opacity: 0 }}
|
||
transition={{ duration: 0.4 }}
|
||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-[#070b14]"
|
||
>
|
||
<div className="flex flex-col items-center gap-5">
|
||
<motion.div
|
||
animate={{ opacity: [1, 0.2, 1] }}
|
||
transition={{ duration: 1.6, repeat: Infinity, ease: 'easeInOut' }}
|
||
>
|
||
<svg viewBox="0 0 175.216 175.552" xmlns="http://www.w3.org/2000/svg" className="w-20 h-20">
|
||
<defs>
|
||
<linearGradient id="wpp-grad" x1="85.915" y1="173.376" x2="85.915" y2="5.765" gradientUnits="userSpaceOnUse">
|
||
<stop offset="0" stopColor="#20b038"/>
|
||
<stop offset="1" stopColor="#60d66a"/>
|
||
</linearGradient>
|
||
</defs>
|
||
<path d="M87.184 25.227c-33.733 0-61.166 27.423-61.178 61.13a60.98 60.98 0 0 0 8.232 30.673L26 149.344l33.79-8.854a61.22 61.22 0 0 0 29.376 7.483h.023c33.73 0 61.17-27.423 61.18-61.13a60.75 60.75 0 0 0-17.91-43.263 60.77 60.77 0 0 0-43.27-17.353z" fill="url(#wpp-grad)"/>
|
||
<path d="M122.08 106.07c-1.96-1.02-11.6-5.72-13.4-6.38-1.8-.66-3.11-.98-4.42.98-1.31 1.96-5.06 6.38-6.21 7.69s-2.29 1.47-4.25.49c-11.6-5.8-19.21-10.36-26.83-23.5-2.03-3.49.43-3.25 1.16-5.42.6-1.77.3-3.27-.17-4.25-.49-.98-4.42-10.65-6.05-14.58-1.59-3.83-3.22-3.3-4.42-3.36-1.14-.06-2.45-.07-3.76-.07s-3.44.49-5.24 2.45c-1.8 1.96-6.87 6.71-6.87 16.37s7.04 18.99 8.02 20.3c.98 1.31 13.85 21.14 33.57 29.67 12.47 5.38 17.35 5.83 23.59 4.91 3.79-.57 11.6-4.74 13.24-9.33 1.63-4.59 1.63-8.52 1.14-9.33-.47-.81-1.78-1.31-3.74-2.31z" fill="#fff"/>
|
||
</svg>
|
||
</motion.div>
|
||
<span className="text-slate-500 text-sm">Carregando conversas…</span>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
<div className="flex flex-1 overflow-hidden h-full">
|
||
<ThinSidebar activeTab={activeTab} setActiveTab={setActiveTab} activeInstance={activeInstance} />
|
||
|
||
{activeTab === 'chats' && (
|
||
<div className={`${isMobile ? (selectedChat ? 'hidden' : 'w-full') : 'w-[400px]'} flex flex-col bg-white border-r border-[#d1d7db] shrink-0 z-20`}>
|
||
{/* Header da sidebar */}
|
||
<div className="h-[60px] px-4 flex items-center justify-between bg-[#f0f2f5] border-b border-[#d1d7db] shrink-0">
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
onClick={handleBack}
|
||
title={selectedChat ? 'Voltar aos chats' : 'Voltar'}
|
||
className="p-2 hover:bg-black/5 rounded-full text-[#54656f] shrink-0"
|
||
>
|
||
<ArrowLeft className="w-5 h-5" />
|
||
</button>
|
||
<div className="relative" ref={dropdownRef}>
|
||
<div onClick={() => setIsInstanceDropdownOpen(!isInstanceDropdownOpen)} className="flex items-center gap-3 cursor-pointer p-1">
|
||
<div className="w-10 h-10 rounded-full bg-white border border-black/5 overflow-hidden flex items-center justify-center">
|
||
{activeInstance?.instance
|
||
? <img
|
||
src={getAvatarProxyUrl({
|
||
instance_name: activeInstance.instance,
|
||
remote_jid: activeInstance.phone ? `${activeInstance.phone}@s.whatsapp.net` : null,
|
||
}) || activeInstance.avatar || ''}
|
||
className="w-full h-full object-cover" alt=""
|
||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none' }}
|
||
/>
|
||
: <User className="text-gray-400" />}
|
||
</div>
|
||
<span className="font-bold text-sm truncate max-w-[120px]">{activeInstance?.nome || 'Instância'}</span>
|
||
<ChevronDown className={`w-4 h-4 transition-all ${isInstanceDropdownOpen ? 'rotate-180' : ''}`} />
|
||
</div>
|
||
<AnimatePresence>
|
||
{isInstanceDropdownOpen && (
|
||
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }}
|
||
className="absolute left-0 top-full bg-white shadow-2xl rounded-xl border border-gray-100 w-72 z-50 py-2">
|
||
{instances.map(i => (
|
||
<button key={i.instance} onClick={() => { selectInstance(i); setIsInstanceDropdownOpen(false) }}
|
||
className="w-full p-3 hover:bg-gray-50 flex items-center gap-3">
|
||
<div className="w-8 h-8 rounded-full bg-gray-100 overflow-hidden">
|
||
<img
|
||
src={getAvatarProxyUrl({
|
||
instance_name: i.instance,
|
||
remote_jid: i.phone ? `${i.phone}@s.whatsapp.net` : null,
|
||
}) || i.avatar || ''}
|
||
alt="" className="w-full h-full object-cover"
|
||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none' }}
|
||
/>
|
||
</div>
|
||
<div className="text-left">
|
||
<span className="text-sm font-medium block">{i.nome}</span>
|
||
<span className={`text-xs ${i.status === 'connected' ? 'text-green-500' : 'text-gray-400'}`}>{i.status}</span>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
</div>{/* end left flex */}
|
||
<div className="flex items-center gap-1">
|
||
<div className="relative" ref={optionsRef}>
|
||
<button onClick={() => setIsOptionsMenuOpen(!isOptionsMenuOpen)} className="p-2 hover:bg-black/5 rounded-full">
|
||
<MoreVertical className="w-5 h-5 text-[#54656f]" />
|
||
</button>
|
||
<AnimatePresence>
|
||
{isOptionsMenuOpen && (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: -10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: -10 }}
|
||
className="absolute right-0 top-full mt-1 bg-white shadow-2xl rounded-xl border border-gray-100 w-56 z-50 py-2"
|
||
>
|
||
<button
|
||
onClick={handleFetchChats}
|
||
disabled={loading}
|
||
className="w-full flex items-center gap-3 px-4 py-2.5 text-[14px] text-[#111b21] hover:bg-[#f5f6f6] transition-colors disabled:opacity-50"
|
||
>
|
||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||
Atualizar chats
|
||
</button>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Busca */}
|
||
<div className="px-4 py-2 flex items-center gap-2">
|
||
<div className="relative flex-1">
|
||
<Search className="absolute left-3 top-2.5 w-4 h-4 text-gray-400" />
|
||
<input type="text" placeholder="Pesquisar..."
|
||
className="w-full bg-[#f0f2f5] h-[35px] pl-10 rounded-lg text-sm border-none focus:ring-0"
|
||
value={searchTerm} onChange={e => setSearchTerm(e.target.value)} />
|
||
</div>
|
||
<button onClick={() => setIsNewConvoModalOpen(true)}
|
||
className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-black/5">
|
||
<MessageSquarePlus className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Lista de chats */}
|
||
<ChatSidebar
|
||
chats={chatsByScope}
|
||
loading={loading}
|
||
selectedChat={selectedChat}
|
||
searchTerm={searchTerm}
|
||
onSelectChat={handleSelectChat}
|
||
onMenuAction={handleChatMenuAction}
|
||
activeFilter={activeFilter}
|
||
selectedLabelFilter={null}
|
||
chatScope={chatScope}
|
||
activeCustomListName={null}
|
||
presenceByJid={presenceByJid}
|
||
isFavoriteChat={isFavoriteChat}
|
||
drafts={drafts}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Área principal */}
|
||
<div
|
||
className={`${isMobile ? (selectedChat ? 'w-full' : 'hidden') : 'flex-1'} flex h-full relative overflow-hidden`}
|
||
style={{ backgroundImage: "url('/chat-bg.png')", backgroundRepeat: 'repeat', backgroundColor: '#b5bdb5' }}
|
||
>
|
||
{/* Overlay branqueador sobre o padrão */}
|
||
<div className="absolute inset-0 bg-white/75 pointer-events-none z-0" />
|
||
{selectedChat ? (
|
||
<>
|
||
<div className="flex-1 flex flex-col h-full relative z-[1] overflow-hidden border-r border-[#d1d7db]">
|
||
<InboxHeader
|
||
selectedChat={selectedChat}
|
||
isMobile={isMobile}
|
||
isTyping={isTyping}
|
||
onAvatarClick={() => { setIsProfileOpen(!isProfileOpen); setIsProtocolOpen(false) }}
|
||
onBack={() => handleSelectChat(null)}
|
||
onSyncAvatar={handleSyncAvatar}
|
||
isChatMenuOpen={isChatMenuOpen}
|
||
isSyncingAvatar={isSyncingAvatar}
|
||
isProtocolOpen={isProtocolOpen}
|
||
hasActiveProtocol={protocolPanel.hasActive}
|
||
onSearchClick={() => {}}
|
||
onToggleMenu={() => setIsChatMenuOpen(!isChatMenuOpen)}
|
||
onProtocolClick={() => { setIsProtocolOpen(!isProtocolOpen); setIsProfileOpen(false) }}
|
||
/>
|
||
<MessageArea
|
||
messages={messages as any}
|
||
selectedChat={selectedChat}
|
||
scrollRef={scrollRef}
|
||
msgLoading={loadingMessages}
|
||
onScroll={handleScroll}
|
||
historyLoadingMore={loadingMore}
|
||
hasMoreHistory={hasMoreHistory}
|
||
reactionPickerFor={reactionPickerFor}
|
||
onReply={setReplyingTo}
|
||
onToggleReactionPicker={toggleReactionPicker}
|
||
onReact={handleReactToMessage}
|
||
onForward={() => {}}
|
||
onDelete={() => {}}
|
||
onEdit={() => {}}
|
||
quickReactions={['👍', '❤️', '😂', '😮', '😢', '🙏']}
|
||
getGroupSenderLabel={(msg: any) => {
|
||
if (msg.direction === 'out') return 'Você'
|
||
if (msg.participant) return msg.participant
|
||
if (msg.senderJid) {
|
||
if (msg.senderJid.endsWith('@lid')) return msg.participant || ''
|
||
const phone = msg.senderJid.split('@')[0].split(':')[0]
|
||
return formatPhone(phone, msg.senderJid)
|
||
}
|
||
return ''
|
||
}}
|
||
/>
|
||
<InputBar
|
||
newMessage={newMessage}
|
||
onNewMessageChange={setNewMessage}
|
||
onSend={handleSendMessage}
|
||
onPresence={handleTyping}
|
||
onCancelReply={() => setReplyingTo(null)}
|
||
replyingTo={replyingTo}
|
||
selectedChat={selectedChat}
|
||
isMobile={isMobile}
|
||
onSendMedia={handleSendMedia}
|
||
onSendAudio={handleSendAudio}
|
||
instanceId={activeInstance?.instance ?? null}
|
||
mentions={mentions}
|
||
onMentionsChange={setMentions}
|
||
/>
|
||
</div>
|
||
<ContactProfile
|
||
isOpen={isProfileOpen && !isProtocolOpen && !isMobile}
|
||
chat={selectedChat}
|
||
messages={messages as any}
|
||
onClose={() => setIsProfileOpen(false)}
|
||
/>
|
||
<ProtocolPanel
|
||
isOpen={isProtocolOpen && !isMobile}
|
||
chatId={selectedChat ? String(selectedChat.id) : null}
|
||
onClose={() => setIsProtocolOpen(false)}
|
||
{...protocolPanel}
|
||
/>
|
||
</>
|
||
) : (
|
||
<div className="flex-1 flex flex-col items-center justify-center p-10 bg-[#f0f2f5]">
|
||
<MessageSquare className="w-20 h-20 opacity-10 mb-8" />
|
||
<h1 className="text-2xl font-light text-[#41525d] mb-4">NewWhats</h1>
|
||
<p className="text-sm text-[#667781]">Selecione uma conversa para começar.</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<NewConversationModal
|
||
isOpen={isNewConvoModalOpen}
|
||
onClose={() => setIsNewConvoModalOpen(false)}
|
||
activeInstance={mappedActiveInstance as any}
|
||
connectedInstances={connectedInstances as any}
|
||
sendMessage={async (instanceId: string, phone: string, text: string) => {
|
||
try {
|
||
const res = await messageApi.send(instanceId, `${phone}@s.whatsapp.net`, text)
|
||
return { ok: true, messageId: res?.messageId }
|
||
} catch {
|
||
return { ok: false }
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|