Files
clube67_newwhats.local/clube67/newwhats.local/frontend/components/ChatListItem.tsx
T
VPS 4 Deploy Agent 2f8c04a0a7
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)
chore(ops): restore all source files in newwhats.clube67.com
2026-05-18 03:28:29 +02:00

293 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import React, { useMemo, useRef, useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import {
Pin, Check, CheckCheck, Clock, ChevronDown,
VolumeX, Star, StarOff, Archive, ArchiveRestore,
Trash2, MailOpen, Volume2, PinOff,
Mic, FileText, Video, Image as ImageIcon,
AlertCircle, BotOff,
} from 'lucide-react';
import type { NewWhatsChat, PresenceState } from '../types/inboxTypes';
import {
normalizeJid,
} from '../utils/inboxUtils';
import Avatar from './ui/Avatar';
interface ChatListItemProps {
chat: NewWhatsChat;
isActive: boolean;
onSelect: (chat: NewWhatsChat) => void;
onMenuAction: (action: any, chat: NewWhatsChat) => void;
presenceByJid: Record<string, PresenceState>;
isFavorite: boolean;
draft?: string;
}
const renderLastStatus = (chat: NewWhatsChat) => {
if (chat.last_message_from_me !== 1) return null;
switch (chat.last_message_status) {
case 'pending':
case 'sending':
return <Clock className="w-3.5 h-3.5 text-[#8696a0] flex-shrink-0" />;
case 'sent':
return <Check className="w-3.5 h-3.5 text-[#8696a0] flex-shrink-0" />;
case 'delivered':
return <CheckCheck className="w-3.5 h-3.5 text-[#8696a0] flex-shrink-0" />;
case 'read':
return <CheckCheck className="w-3.5 h-3.5 text-[#53bdeb] flex-shrink-0" />;
case 'failed':
return <AlertCircle className="w-3.5 h-3.5 text-red-500 flex-shrink-0" />;
default:
return null;
}
};
const getLastMessageSnippet = (chat: NewWhatsChat, draft?: string): React.ReactNode => {
if (draft) return <span className="text-[#00a884]">Rascunho: {draft}</span>;
const truncate = (value: string, limit = 35) => value.length > limit ? `${value.slice(0, limit - 1)}...` : value;
// Group Sender Prefix — WhatsApp exibe "Você:" ou primeiro nome do remetente
const isGroup = chat.remote_jid.endsWith('@g.us');
const senderPrefix = isGroup
? (chat.last_message_from_me === 1
? 'Você: '
: (chat.last_message_sender ? `${chat.last_message_sender.split(' ')[0]}: ` : ''))
: '';
const IconLabel = ({ icon: Icon, label }: { icon: any, label: string }) => (
<span className="flex items-center gap-1">
<Icon className="w-3.5 h-3.5" />
{senderPrefix}{label}
</span>
);
// Use last_message_type (pipeline do chatStore) para ícones de mídia
const msgType = (chat.last_message_type ?? '').toLowerCase();
if (msgType === 'audio' || msgType === 'ptt') return <IconLabel icon={Mic} label="Áudio" />;
if (msgType === 'sticker') return <IconLabel icon={Star} label="Figurinha" />;
if (msgType === 'image') {
const caption = chat.last_message_text?.trim();
return caption
? <IconLabel icon={ImageIcon} label={truncate(caption)} />
: <IconLabel icon={ImageIcon} label="Imagem" />;
}
if (msgType === 'video') {
const caption = chat.last_message_text?.trim();
return caption
? <IconLabel icon={Video} label={truncate(caption)} />
: <IconLabel icon={Video} label="Vídeo" />;
}
if (msgType === 'document') return <IconLabel icon={FileText} label={chat.last_message_text?.trim() || 'Documento'} />;
// Texto puro (text, extendedtext, etc.)
const text = chat.last_message_text ?? '';
if (text.trim()) return `${senderPrefix}${truncate(text.trim())}`;
return senderPrefix || '';
};
export default function ChatListItem({
chat,
isActive,
onSelect,
onMenuAction,
presenceByJid,
isFavorite,
draft
}: ChatListItemProps) {
const [menuOpen, setMenuOpen] = useState(false);
const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null);
const menuBtnRef = useRef<HTMLButtonElement>(null);
const relativeTime = useMemo(() => {
if (!chat.last_message_time) return '';
const date = new Date(chat.last_message_time);
if (isNaN(date.getTime())) return '';
// Comparação por data de calendário (não por 24h corridas)
const today = new Date(); today.setHours(0, 0, 0, 0);
const msgDay = new Date(date); msgDay.setHours(0, 0, 0, 0);
const diffDays = Math.round((today.getTime() - msgDay.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return date.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
if (diffDays === 1) return 'Ontem';
if (diffDays < 7) {
// "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb", "Dom"
const short = date.toLocaleDateString('pt-BR', { weekday: 'short' });
return short.replace('.', '').replace(/^\w/, (c) => c.toUpperCase());
}
return date.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: '2-digit' });
}, [chat.last_message_time]);
const isMuted = useMemo(() => {
return Boolean(chat.muted_until && new Date(chat.muted_until).getTime() > Date.now());
}, [chat.muted_until]);
const menuItems = useMemo(() => {
const isPinned = Boolean(chat.is_pinned);
const isArchived = Boolean(chat.is_archived);
const isGroup = chat.remote_jid.endsWith('@g.us');
return [
{ key: 'favorite', label: isFavorite ? 'Remover favorito' : 'Favoritar', icon: isFavorite ? <StarOff className="w-4 h-4" /> : <Star className="w-4 h-4" /> },
{ key: 'archive', label: isArchived ? 'Desarquivar' : 'Arquivar', icon: isArchived ? <ArchiveRestore className="w-4 h-4" /> : <Archive className="w-4 h-4" /> },
{ key: 'mute', label: isMuted ? 'Desativar silêncio' : 'Silenciar', icon: isMuted ? <Volume2 className="w-4 h-4" /> : <VolumeX className="w-4 h-4" /> },
{ key: 'pin', label: isPinned ? 'Desafixar' : 'Fixar conversa', icon: isPinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" /> },
{ key: 'mark_read', label: 'Marcar como lida', icon: <MailOpen className="w-4 h-4" /> },
{ key: 'delete', label: 'Apagar conversa', icon: <Trash2 className="w-4 h-4" />, danger: true },
];
}, [chat, isFavorite, isMuted]);
const MENU_H = 252; // 6 itens × ~40px + 12px padding container
const MENU_W = 224; // w-56
const openMenu = (anchorX: number, anchorY: number, anchorBottom?: number) => {
const triggerBottom = anchorBottom ?? anchorY;
const spaceBelow = window.innerHeight - triggerBottom;
// Abre abaixo se couber; senão ancora o BOTTOM do menu no TOP do trigger
const top = spaceBelow >= MENU_H + 8
? triggerBottom + 4
: anchorY - MENU_H;
const left = Math.min(anchorX, window.innerWidth - MENU_W - 8);
setMenuPos({ top: Math.max(8, top), left: Math.max(8, left) });
setMenuOpen(true);
};
return (
<>
{/* Usamos div+role="button" em vez de <button> porque dentro há outro
<button> (o menu chevron) — aninhar botões é inválido em HTML e
dispara validateDOMNesting warning no React. */}
<div
role="button"
tabIndex={0}
onClick={() => onSelect(chat)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect(chat);
}
}}
onContextMenu={(e) => {
e.preventDefault();
// Abre à direita do cursor se couber, senão à esquerda
const spaceRight = window.innerWidth - e.clientX;
const x = spaceRight >= MENU_W + 8 ? e.clientX : e.clientX - MENU_W;
// Passa anchorY = cursor Y, anchorBottom = cursor Y (sem item height)
openMenu(Math.max(8, x), e.clientY, e.clientY);
}}
className={`w-full flex items-center gap-3 px-3 py-2 transition-all text-[#111b21] group border-b border-[#f0f2f5] last:border-none cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-[#00a884]/40 ${isActive ? 'bg-[#f0f2f5]' : 'hover:bg-[#f5f6f6]'}`}
>
{/* Avatar Section */}
<Avatar chat={chat} className="flex-shrink-0" />
{/* Content Section */}
<div className="flex-1 min-w-0 flex flex-col justify-center py-0.5 pr-1 text-left">
<div className="flex items-center justify-between">
<h3 className="text-[16px] leading-[21px] font-normal text-[#111b21] truncate">
{chat.displayName || chat.subject || chat.phone}
</h3>
<span className={`text-[12px] leading-[14px] flex-shrink-0 ml-2 ${chat.unread_count > 0 ? 'text-[#00a884] font-medium' : 'text-[#667781]'}`}>
{relativeTime}
</span>
</div>
<div className="flex items-center justify-between mt-1 h-5">
<div className="flex items-center gap-1 flex-1 min-w-0">
{(() => {
const jid = normalizeJid(chat.remote_jid);
const p = presenceByJid[jid];
const isPresenceLive = p && (Date.now() - p.ts) <= 6000;
if (isPresenceLive && (p.state === 'composing' || p.state === 'recording')) {
return (
<p className="text-[13px] text-[#00a884] font-normal truncate">
{p.state === 'recording' ? 'gravando áudio...' : 'digitando...'}
</p>
);
}
return (
<div className="flex items-center gap-1 min-w-0">
{!draft && renderLastStatus(chat)}
<div className={`text-[13px] leading-[20px] truncate max-w-full ${draft ? 'text-[#00a884]' : 'text-[#667781]'}`}>
{getLastMessageSnippet(chat, draft)}
</div>
</div>
);
})()}
</div>
<div className="flex items-center gap-2 flex-shrink-0 ml-1">
{chat.bot_paused && (
<span title="Bot pausado — aguardando atendente">
<BotOff className="w-4 h-4 text-amber-500" />
</span>
)}
{isMuted && <VolumeX className="w-4 h-4 text-[#8696a0]" />}
{Boolean(chat.is_pinned) && <Pin className="w-4 h-4 text-[#8696a0]" />}
{chat.unread_count > 0 && (
<span className="bg-[#00a884] text-white text-[12px] font-bold px-1.5 py-0.5 rounded-full min-w-[20px] h-5 flex items-center justify-center">
{chat.unread_count > 99 ? '99+' : chat.unread_count}
</span>
)}
{/* Hover Chevron for Menu */}
<div className="relative group-hover:block hidden">
<button
ref={menuBtnRef}
className="p-1 rounded-full hover:bg-black/5"
onClick={(e) => {
e.stopPropagation();
const rect = menuBtnRef.current?.getBoundingClientRect();
if (rect) {
openMenu(rect.right - MENU_W, rect.top, rect.bottom);
}
}}
>
<ChevronDown className="w-4 h-4 text-[#8696a0]" />
</button>
</div>
</div>
</div>
</div>
</div>
{/* Context Menu Portal (fora do item pra não ficar dentro do role=button) */}
{menuOpen && menuPos && typeof window !== 'undefined' && createPortal(
<>
<div className="fixed inset-0 z-[1000]" onClick={(e) => { e.stopPropagation(); setMenuOpen(false); }} />
<div
className="fixed bg-white py-1.5 rounded-lg shadow-xl border border-gray-100 z-[1001] w-56 flex flex-col"
style={{ top: menuPos.top, left: menuPos.left }}
onClick={(e) => e.stopPropagation()}
>
{menuItems.map((item: any) => (
<button
key={item.key}
onClick={() => {
onMenuAction(item.key, chat);
setMenuOpen(false);
}}
className={`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors text-left ${
item.danger ? 'text-red-600 hover:bg-red-50' : 'text-[#3b4a54] hover:bg-[#f5f6f6]'
}`}
>
<span className={item.danger ? 'text-red-500' : 'text-[#8696a0]'}>{item.icon}</span>
{item.label}
</button>
))}
</div>
</>,
document.body
)}
</>
);
}