416 lines
23 KiB
TypeScript
416 lines
23 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)
|
|
|
|
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]">
|
|
<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>
|
|
)
|
|
}
|