feat: sincronização de desenvolvimento local da VPS 4 para o Gitea

This commit is contained in:
VPS 4 Deploy Agent
2026-05-10 07:38:54 +02:00
parent 621fbfba64
commit 3396fa0598
402 changed files with 234697 additions and 0 deletions
@@ -0,0 +1,287 @@
'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
)}
</>
);
}