288 lines
14 KiB
TypeScript
288 lines
14 KiB
TypeScript
'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,
|
||
} 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">
|
||
{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
|
||
)}
|
||
</>
|
||
);
|
||
}
|