feat(newwhats): frontend do plugin (Inbox/Sessions/Secretária) em tela cheia
- /wa-inbox, /wa-sessions e /wa-secretaria rodam sem a sidebar do scoreodonto (isStandaloneView); cada tela tem seu "Voltar" (Secretária ganhou botão na ThinNav). - SessionsView: banner "Você já possui acesso ao WhatsApp" quando há sessão ativa. - avatares: usa a URL do CDN (contactAvatarUrl/instance.avatar) direto; Avatar exibe sem depender de version; self-heal no onError re-busca a foto atual. - deps: framer-motion, immer. Dev override com HMR (docker-compose.dev.yml). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,722 @@
|
||||
'use client';
|
||||
|
||||
import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Smile,
|
||||
Paperclip,
|
||||
Mic,
|
||||
Send,
|
||||
X,
|
||||
StopCircle,
|
||||
Pause,
|
||||
Play,
|
||||
LayoutTemplate,
|
||||
BookOpen,
|
||||
Plus,
|
||||
Sticker,
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
// Satélite: emoji picker simples (evita a dep @emoji-mart, incompatível com react 19).
|
||||
const EMOJI_SET = ['😀','😁','😂','🤣','😊','😍','😘','😎','🤔','😐','😴','😭','😡','👍','👎','🙏','👏','🙌','💪','🔥','🎉','❤️','💔','✅','❌','⚠️','📌','📎','📞','💬','⏰','🥳','😇','🤝','👀','💯'];
|
||||
import { groupApi, type GroupParticipant, type MessageTemplate, type TemplateType } from '../services/chatApiService';
|
||||
import RichMessageComposer from './RichMessageComposer';
|
||||
import TemplateLibrary from './TemplateLibrary';
|
||||
import ScheduleMessageModal from './ScheduleMessageModal';
|
||||
import FilePreviewModal from './FilePreviewModal';
|
||||
import StickerPanel from './StickerPanel';
|
||||
import { mediaApi } from '../services/chatApiService';
|
||||
|
||||
interface Message {
|
||||
id: number;
|
||||
chat_id: number;
|
||||
message_id: string;
|
||||
text: string;
|
||||
direction: 'in' | 'out';
|
||||
}
|
||||
|
||||
interface InputBarProps {
|
||||
newMessage: string;
|
||||
onNewMessageChange: (val: string) => void;
|
||||
onSend: (e?: React.FormEvent) => void;
|
||||
onPresence: () => void;
|
||||
onSendMedia: (file: File, caption?: string) => Promise<void>;
|
||||
onSendAudio: (blob: Blob) => Promise<void>;
|
||||
replyingTo: Message | null;
|
||||
onCancelReply: () => void;
|
||||
isMobile: boolean;
|
||||
selectedChat: any;
|
||||
instanceId?: string | null;
|
||||
mentions?: string[];
|
||||
onMentionsChange?: (mentions: string[]) => void;
|
||||
}
|
||||
|
||||
const InputBar: React.FC<InputBarProps> = ({
|
||||
newMessage,
|
||||
onNewMessageChange,
|
||||
onSend,
|
||||
onPresence,
|
||||
onSendMedia,
|
||||
onSendAudio,
|
||||
replyingTo,
|
||||
onCancelReply,
|
||||
isMobile,
|
||||
selectedChat,
|
||||
instanceId,
|
||||
mentions = [],
|
||||
onMentionsChange,
|
||||
}) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
const [showStickerPanel, setShowStickerPanel] = useState(false);
|
||||
const [showAttachMenu, setShowAttachMenu] = useState(false);
|
||||
const stickerPanelRef = useRef<HTMLDivElement>(null);
|
||||
const [previewFile, setPreviewFile] = useState<File | null>(null);
|
||||
const attachMenuRef = useRef<HTMLDivElement>(null);
|
||||
const [showRichComposer, setShowRichComposer] = useState(false);
|
||||
const [showTemplateLibrary, setShowTemplateLibrary] = useState(false);
|
||||
const [richInitialPayload, setRichInitialPayload] = useState<Record<string, unknown> | undefined>();
|
||||
const [richInitialType, setRichInitialType] = useState<TemplateType | undefined>();
|
||||
const [showScheduleModal, setShowScheduleModal] = useState(false);
|
||||
const [schedulingPayload, setSchedulingPayload] = useState<Record<string, unknown>>({});
|
||||
const [schedulingType, setSchedulingType] = useState<TemplateType>('TEXT');
|
||||
const emojiPickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ── Mention autocomplete state ──────────────────────────────────────
|
||||
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
||||
const [mentionIndex, setMentionIndex] = useState(0);
|
||||
const [participants, setParticipants] = useState<GroupParticipant[]>([]);
|
||||
const participantsCache = useRef<Record<string, GroupParticipant[]>>({});
|
||||
const mentionDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isGroupChat = selectedChat?.remote_jid?.endsWith('@g.us') || selectedChat?.jid?.endsWith('@g.us');
|
||||
const groupJid = selectedChat?.remote_jid || selectedChat?.jid;
|
||||
|
||||
// Fetch group participants when group chat is selected
|
||||
useEffect(() => {
|
||||
if (!isGroupChat || !instanceId || !groupJid) {
|
||||
setParticipants([]);
|
||||
return;
|
||||
}
|
||||
if (participantsCache.current[groupJid]) {
|
||||
setParticipants(participantsCache.current[groupJid]);
|
||||
return;
|
||||
}
|
||||
groupApi.getParticipants(instanceId, groupJid)
|
||||
.then((data) => {
|
||||
participantsCache.current[groupJid] = data.participants;
|
||||
setParticipants(data.participants);
|
||||
})
|
||||
.catch(() => setParticipants([]));
|
||||
}, [isGroupChat, instanceId, groupJid]);
|
||||
|
||||
// Filter participants by mention query
|
||||
const filteredParticipants = useMemo(() => {
|
||||
if (mentionQuery === null || !isGroupChat) return [];
|
||||
const q = mentionQuery.toLowerCase();
|
||||
// Only show participants that have a displayable name or phone
|
||||
return participants.filter((p) => {
|
||||
const label = p.name || p.phone || '';
|
||||
if (!label) return false;
|
||||
return q === '' || label.toLowerCase().includes(q);
|
||||
}).slice(0, 8);
|
||||
}, [mentionQuery, participants, isGroupChat]);
|
||||
|
||||
// Detect @ trigger in text
|
||||
const detectMentionQuery = useCallback((text: string, cursorPos: number) => {
|
||||
if (!isGroupChat) { setMentionQuery(null); return; }
|
||||
const before = text.slice(0, cursorPos);
|
||||
const atMatch = before.match(/@([^\s@]*)$/);
|
||||
if (atMatch) {
|
||||
setMentionQuery(atMatch[1]);
|
||||
setMentionIndex(0);
|
||||
} else {
|
||||
setMentionQuery(null);
|
||||
}
|
||||
}, [isGroupChat]);
|
||||
|
||||
const selectMention = useCallback((participant: GroupParticipant) => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
const cursorPos = textarea.selectionStart;
|
||||
const before = newMessage.slice(0, cursorPos);
|
||||
const after = newMessage.slice(cursorPos);
|
||||
const atIdx = before.lastIndexOf('@');
|
||||
if (atIdx === -1) return;
|
||||
|
||||
const displayName = participant.name || participant.phone;
|
||||
const replacement = `@${displayName} `;
|
||||
const newText = before.slice(0, atIdx) + replacement + after;
|
||||
|
||||
onNewMessageChange(newText);
|
||||
setMentionQuery(null);
|
||||
|
||||
// Add JID to mentions list
|
||||
if (onMentionsChange && !mentions.includes(participant.jid)) {
|
||||
onMentionsChange([...mentions, participant.jid]);
|
||||
}
|
||||
|
||||
// Restore cursor
|
||||
requestAnimationFrame(() => {
|
||||
if (textareaRef.current) {
|
||||
const newCursorPos = atIdx + replacement.length;
|
||||
textareaRef.current.selectionStart = newCursorPos;
|
||||
textareaRef.current.selectionEnd = newCursorPos;
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
});
|
||||
}, [newMessage, onNewMessageChange, mentions, onMentionsChange]);
|
||||
|
||||
// Audio recording states
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
const [micError, setMicError] = useState<string | null>(null);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const audioChunksRef = useRef<Blob[]>([]);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// navigator.mediaDevices só existe em contextos seguros (HTTPS ou localhost)
|
||||
const micAvailable = typeof window !== 'undefined' && !!window.isSecureContext && !!navigator.mediaDevices?.getUserMedia;
|
||||
|
||||
// Auto-expand textarea
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 150)}px`;
|
||||
}
|
||||
}, [newMessage]);
|
||||
|
||||
// Close emoji picker, sticker panel and attach menu on click outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (emojiPickerRef.current && !emojiPickerRef.current.contains(event.target as Node)) {
|
||||
setShowEmojiPicker(false);
|
||||
}
|
||||
if (stickerPanelRef.current && !stickerPanelRef.current.contains(event.target as Node)) {
|
||||
setShowStickerPanel(false);
|
||||
}
|
||||
if (attachMenuRef.current && !attachMenuRef.current.contains(event.target as Node)) {
|
||||
setShowAttachMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSendSticker = useCallback(async (blob: Blob) => {
|
||||
if (!instanceId || !selectedChat?.remote_jid) return;
|
||||
setShowStickerPanel(false);
|
||||
await mediaApi.sendSticker(instanceId, selectedChat.remote_jid, blob);
|
||||
}, [instanceId, selectedChat]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// Mention dropdown navigation
|
||||
if (mentionQuery !== null && filteredParticipants.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setMentionIndex((prev) => (prev + 1) % filteredParticipants.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setMentionIndex((prev) => (prev - 1 + filteredParticipants.length) % filteredParticipants.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
selectMention(filteredParticipants[mentionIndex]);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setMentionQuery(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onSend();
|
||||
}
|
||||
};
|
||||
|
||||
const onEmojiSelect = (emoji: any) => {
|
||||
onNewMessageChange(newMessage + (emoji.native ?? ''));
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setPreviewFile(file);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviewConfirm = async (file: File, caption: string) => {
|
||||
await onSendMedia(file, caption);
|
||||
setPreviewFile(null);
|
||||
};
|
||||
|
||||
const handlePreviewCancel = () => setPreviewFile(null);
|
||||
|
||||
const startRecording = async () => {
|
||||
if (!micAvailable) {
|
||||
setMicError('Áudio requer HTTPS. Acesse via https:// para gravar.');
|
||||
setTimeout(() => setMicError(null), 4000);
|
||||
return;
|
||||
}
|
||||
setMicError(null);
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const mediaRecorder = new MediaRecorder(stream);
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
audioChunksRef.current = [];
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = async () => {
|
||||
const mimeType = mediaRecorder.mimeType || 'audio/webm'
|
||||
const audioBlob = new Blob(audioChunksRef.current, { type: mimeType });
|
||||
await onSendAudio(audioBlob);
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
};
|
||||
|
||||
mediaRecorder.start();
|
||||
setIsRecording(true);
|
||||
setIsPaused(false);
|
||||
setRecordingDuration(0);
|
||||
timerRef.current = setInterval(() => {
|
||||
setRecordingDuration(prev => prev + 1);
|
||||
}, 1000);
|
||||
} catch (err: any) {
|
||||
const denied = err?.name === 'NotAllowedError' || err?.name === 'PermissionDeniedError';
|
||||
const msg = denied
|
||||
? 'Permissão de microfone negada.'
|
||||
: 'Não foi possível acessar o microfone.';
|
||||
setMicError(msg);
|
||||
setTimeout(() => setMicError(null), 4000);
|
||||
}
|
||||
};
|
||||
|
||||
const togglePause = () => {
|
||||
const mr = mediaRecorderRef.current
|
||||
if (!mr) return
|
||||
if (isPaused) {
|
||||
mr.resume()
|
||||
setIsPaused(false)
|
||||
timerRef.current = setInterval(() => setRecordingDuration(prev => prev + 1), 1000)
|
||||
} else {
|
||||
mr.pause()
|
||||
setIsPaused(true)
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
}
|
||||
}
|
||||
|
||||
const stopRecording = () => {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
// Se estava pausado, retoma antes de parar para capturar o último chunk
|
||||
if (isPaused && mediaRecorderRef.current.state === 'paused') {
|
||||
mediaRecorderRef.current.resume()
|
||||
}
|
||||
mediaRecorderRef.current.stop();
|
||||
setIsRecording(false);
|
||||
setIsPaused(false);
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTemplateSelect = (template: MessageTemplate) => {
|
||||
setRichInitialPayload(template.payload)
|
||||
setRichInitialType(template.type)
|
||||
setShowRichComposer(true)
|
||||
}
|
||||
|
||||
const handleScheduleFromComposer = (type: TemplateType, payload: Record<string, unknown>) => {
|
||||
setSchedulingType(type)
|
||||
setSchedulingPayload(payload)
|
||||
setShowRichComposer(false)
|
||||
setShowScheduleModal(true)
|
||||
}
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative z-20">
|
||||
{/* Hidden File Input */}
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
{/* Emoji Picker Popover */}
|
||||
<AnimatePresence>
|
||||
{showEmojiPicker && (
|
||||
<motion.div
|
||||
ref={emojiPickerRef}
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
className="absolute bottom-20 left-4 z-50 shadow-2xl rounded-2xl overflow-hidden border border-gray-100"
|
||||
>
|
||||
<div className="bg-white w-[280px] p-2 grid grid-cols-8 gap-1">
|
||||
{EMOJI_SET.map((e) => (
|
||||
<button
|
||||
key={e}
|
||||
type="button"
|
||||
onClick={() => onEmojiSelect({ native: e })}
|
||||
className="text-xl leading-none p-1 rounded-lg hover:bg-gray-100"
|
||||
>
|
||||
{e}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Sticker Panel Popover */}
|
||||
<AnimatePresence>
|
||||
{showStickerPanel && instanceId && (
|
||||
<motion.div
|
||||
ref={stickerPanelRef}
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
className="absolute bottom-20 left-16 z-50"
|
||||
>
|
||||
<StickerPanel
|
||||
instanceId={instanceId}
|
||||
jid={selectedChat?.remote_jid ?? ''}
|
||||
onSend={handleSendSticker}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Reply Preview */}
|
||||
<AnimatePresence>
|
||||
{replyingTo && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
className="mx-4 mb-2 bg-white/80 backdrop-blur-sm border-l-[4px] border-[#00a884] rounded-lg px-4 py-3 flex items-start justify-between gap-4 shadow-sm"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[12px] font-medium text-[#00a884] mb-1">
|
||||
{replyingTo.direction === 'out' ? 'Respondendo a você' : 'Respondendo ao contato'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 font-medium truncate italic">
|
||||
{replyingTo.text || 'Anexo/Mídia'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="bg-gray-100 hover:bg-gray-200 text-gray-500 p-1 rounded-full transition-colors"
|
||||
onClick={onCancelReply}
|
||||
>
|
||||
<X className="w-3.5 h-3.5 stroke-[3px]" />
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Mic error banner */}
|
||||
<AnimatePresence>
|
||||
{micError && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 6 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className="mx-4 mb-1 px-3 py-2 bg-amber-50 border border-amber-200 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<Mic className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />
|
||||
<span className="text-xs text-amber-700">{micError}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Main Input Bar */}
|
||||
<div className="min-h-[62px] px-4 py-2 bg-transparent flex items-center gap-2 shrink-0">
|
||||
{!isRecording ? (
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Grupo flutuante: + expande Anexar / Templates / Mensagem Rica */}
|
||||
<div className="relative" ref={attachMenuRef}>
|
||||
<button
|
||||
onClick={() => setShowAttachMenu(v => !v)}
|
||||
className={`p-2 rounded-full transition-all active:scale-95 ${showAttachMenu ? 'bg-[#00a884] text-white' : 'hover:bg-black/10 text-[#54656f]'}`}
|
||||
title="Mais opções"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ rotate: showAttachMenu ? 45 : 0 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
>
|
||||
<Plus className="w-[26px] h-[26px]" />
|
||||
</motion.div>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{showAttachMenu && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8, scale: 0.92 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 8, scale: 0.92 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute bottom-full left-0 mb-2 flex flex-col items-center gap-1 bg-white rounded-2xl shadow-2xl border border-gray-100 py-2 px-1.5"
|
||||
>
|
||||
{/* Anexar arquivo */}
|
||||
<button
|
||||
onClick={() => { fileInputRef.current?.click(); setShowAttachMenu(false); }}
|
||||
className="p-2.5 hover:bg-[#f0f2f5] rounded-xl transition-all text-[#54656f] active:scale-95 group relative"
|
||||
title="Anexar arquivo"
|
||||
>
|
||||
<Paperclip className="w-[22px] h-[22px] rotate-45" />
|
||||
<span className="absolute left-full ml-2 whitespace-nowrap text-xs bg-[#111b21] text-white px-2 py-1 rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity">
|
||||
Anexar
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Biblioteca de templates */}
|
||||
<button
|
||||
onClick={() => { setShowTemplateLibrary(true); setShowAttachMenu(false); }}
|
||||
className="p-2.5 hover:bg-[#f0f2f5] rounded-xl transition-all text-[#54656f] active:scale-95 group relative"
|
||||
title="Templates salvos"
|
||||
>
|
||||
<BookOpen className="w-[22px] h-[22px]" />
|
||||
<span className="absolute left-full ml-2 whitespace-nowrap text-xs bg-[#111b21] text-white px-2 py-1 rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity">
|
||||
Templates
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Mensagem rica */}
|
||||
<button
|
||||
onClick={() => { setRichInitialPayload(undefined); setRichInitialType(undefined); setShowRichComposer(true); setShowAttachMenu(false); }}
|
||||
className="p-2.5 hover:bg-[#f0f2f5] rounded-xl transition-all text-[#54656f] active:scale-95 group relative"
|
||||
title="Mensagem rica"
|
||||
>
|
||||
<LayoutTemplate className="w-[22px] h-[22px]" />
|
||||
<span className="absolute left-full ml-2 whitespace-nowrap text-xs bg-[#111b21] text-white px-2 py-1 rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity">
|
||||
Mensagem Rica
|
||||
</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Emoji — mantém no lugar */}
|
||||
<button
|
||||
onClick={() => { setShowEmojiPicker(!showEmojiPicker); setShowStickerPanel(false); }}
|
||||
className={`p-2 hover:bg-black/10 rounded-full transition-all active:scale-95 ${showEmojiPicker ? 'text-[#00a884]' : 'text-[#54656f]'}`}
|
||||
title="Emojis"
|
||||
>
|
||||
<Smile className="w-[26px] h-[26px]" />
|
||||
</button>
|
||||
|
||||
{/* Figurinhas */}
|
||||
{instanceId && (
|
||||
<button
|
||||
onClick={() => { setShowStickerPanel(!showStickerPanel); setShowEmojiPicker(false); }}
|
||||
className={`p-2 hover:bg-black/10 rounded-full transition-all active:scale-95 ${showStickerPanel ? 'text-[#00a884]' : 'text-[#54656f]'}`}
|
||||
title="Figurinhas"
|
||||
>
|
||||
<Sticker className="w-[24px] h-[24px]" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 relative">
|
||||
{/* Mention Autocomplete Dropdown */}
|
||||
<AnimatePresence>
|
||||
{mentionQuery !== null && filteredParticipants.length > 0 && (
|
||||
<motion.div
|
||||
ref={mentionDropdownRef}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 6 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
className="absolute bottom-full left-0 right-0 mb-1 bg-white rounded-lg shadow-xl border border-gray-200 max-h-[240px] overflow-y-auto z-50"
|
||||
>
|
||||
{filteredParticipants.map((p, i) => (
|
||||
<button
|
||||
key={p.jid}
|
||||
type="button"
|
||||
className={`w-full px-3 py-2 flex items-center gap-2 text-left text-sm hover:bg-[#f0f2f5] transition-colors ${
|
||||
i === mentionIndex ? 'bg-[#e9edef]' : ''
|
||||
}`}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
selectMention(p);
|
||||
}}
|
||||
onMouseEnter={() => setMentionIndex(i)}
|
||||
>
|
||||
<div className="w-7 h-7 rounded-full bg-[#dfe5e7] flex items-center justify-center text-xs font-bold text-[#54656f] shrink-0">
|
||||
{(p.name || p.phone || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-[#111b21] truncate">{p.name || p.phone}</div>
|
||||
{p.name && <div className="text-[11px] text-[#667781] truncate">+{p.phone}</div>}
|
||||
</div>
|
||||
{p.admin && (
|
||||
<span className="ml-auto text-[10px] bg-[#e7f8f2] text-[#00a884] px-1.5 py-0.5 rounded font-medium shrink-0">
|
||||
{p.admin === 'superadmin' ? 'Admin' : 'Admin'}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<label htmlFor="message-textarea" className="sr-only">Digite sua mensagem</label>
|
||||
<textarea
|
||||
id="message-textarea"
|
||||
name="message-textarea"
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
placeholder="Digite uma mensagem"
|
||||
className="w-full px-3 py-2.5 bg-white rounded-lg text-[15px] text-[#111b21] border-none focus:ring-0 focus:outline-none placeholder:text-[#667781] resize-none shadow-sm transition-all overflow-hidden leading-[1.4]"
|
||||
value={newMessage}
|
||||
onChange={(e) => {
|
||||
onNewMessageChange(e.target.value);
|
||||
onPresence();
|
||||
detectMentionQuery(e.target.value, e.target.selectionStart);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="transition-all flex items-center">
|
||||
{newMessage.trim() ? (
|
||||
<motion.button
|
||||
initial={{ scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
onClick={() => onSend()}
|
||||
className="p-2 text-[#54656f] hover:bg-black/10 rounded-full transition-all active:scale-90"
|
||||
>
|
||||
<Send className="w-[26px] h-[26px]" />
|
||||
</motion.button>
|
||||
) : (
|
||||
<button
|
||||
onClick={startRecording}
|
||||
title={!micAvailable ? 'Requer HTTPS para gravar áudio' : 'Gravar áudio'}
|
||||
className={`p-2 rounded-full transition-all active:scale-90 ${
|
||||
!micAvailable
|
||||
? 'text-[#aab7bf] cursor-not-allowed opacity-50'
|
||||
: 'hover:bg-black/10 text-[#54656f]'
|
||||
}`}
|
||||
>
|
||||
<Mic className="w-[26px] h-[26px]" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={`flex-1 flex items-center justify-between rounded-2xl px-4 py-2 border shadow-inner ${isPaused ? 'bg-amber-50/70 border-amber-200' : 'bg-red-50/50 border-red-100'}`}>
|
||||
{/* Indicador + tempo */}
|
||||
<div className="flex items-center gap-3">
|
||||
{isPaused ? (
|
||||
<div className="w-3 h-3 bg-amber-400 rounded-full" />
|
||||
) : (
|
||||
<div className="w-3 h-3 bg-red-500 rounded-full animate-ping" />
|
||||
)}
|
||||
<span className={`text-sm font-bold tracking-wider ${isPaused ? 'text-amber-600' : 'text-red-600'}`}>
|
||||
{isPaused ? 'PAUSADO' : 'GRAVANDO'} {formatDuration(recordingDuration)}
|
||||
</span>
|
||||
</div>
|
||||
{/* Controles */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Cancelar */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const mr = mediaRecorderRef.current
|
||||
if (mr) {
|
||||
mr.onstop = null;
|
||||
mr.stream?.getTracks().forEach(t => t.stop());
|
||||
if (mr.state !== 'inactive') mr.stop();
|
||||
setIsRecording(false);
|
||||
setIsPaused(false);
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
}
|
||||
}}
|
||||
className="p-2 hover:bg-red-100 rounded-full text-red-400 transition-colors"
|
||||
title="Cancelar"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
{/* Pause / Resume */}
|
||||
<button
|
||||
onClick={togglePause}
|
||||
className="p-2 hover:bg-black/10 rounded-full text-[#54656f] transition-colors"
|
||||
title={isPaused ? 'Continuar gravação' : 'Pausar gravação'}
|
||||
>
|
||||
{isPaused
|
||||
? <Play className="w-5 h-5 fill-current text-amber-500" />
|
||||
: <Pause className="w-5 h-5 fill-current" />
|
||||
}
|
||||
</button>
|
||||
{/* Parar e Enviar */}
|
||||
<button
|
||||
onClick={stopRecording}
|
||||
className="p-2 bg-red-500 hover:bg-red-600 text-white rounded-full transition-all shadow-md active:scale-90"
|
||||
title="Parar e Enviar"
|
||||
>
|
||||
<StopCircle className="w-5 h-5 fill-current" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Modal de preview de arquivo ─────────────────────── */}
|
||||
<FilePreviewModal
|
||||
file={previewFile}
|
||||
onConfirm={handlePreviewConfirm}
|
||||
onCancel={handlePreviewCancel}
|
||||
/>
|
||||
|
||||
{/* ── Modais de mensagem rica ────────────────────────────── */}
|
||||
<RichMessageComposer
|
||||
isOpen={showRichComposer}
|
||||
onClose={() => setShowRichComposer(false)}
|
||||
instanceId={instanceId ?? null}
|
||||
jid={selectedChat?.remote_jid ?? selectedChat?.jid ?? ''}
|
||||
onSent={() => setShowRichComposer(false)}
|
||||
onSchedule={handleScheduleFromComposer}
|
||||
initialPayload={richInitialPayload}
|
||||
initialType={richInitialType}
|
||||
/>
|
||||
|
||||
<TemplateLibrary
|
||||
isOpen={showTemplateLibrary}
|
||||
onClose={() => setShowTemplateLibrary(false)}
|
||||
onSelect={handleTemplateSelect}
|
||||
/>
|
||||
|
||||
<ScheduleMessageModal
|
||||
isOpen={showScheduleModal}
|
||||
onClose={() => setShowScheduleModal(false)}
|
||||
payload={schedulingPayload}
|
||||
messageType={schedulingType}
|
||||
onSaved={() => setShowScheduleModal(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputBar;
|
||||
Reference in New Issue
Block a user