Files
forkmessager/client-web/src/modules/chats/presentation/components/MessageInput.tsx
2026-04-01 17:42:29 +03:00

1219 lines
50 KiB
TypeScript

import { useState, useRef, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Send,
Paperclip,
Smile,
Mic,
X,
Reply,
Pencil,
Image as ImageIcon,
FileText,
Music,
Clock,
ChevronLeft,
ChevronRight,
Calendar,
Check,
} from 'lucide-react';
import { useChatStore } from '../../application/chatStore';
import { useAuthStore } from '../../../auth/application/authStore';
import { ChatApi } from '../../infrastructure/chatApi';
import { getSocket } from '../../../../core/infrastructure/socket';
import { useLang } from '../../../../core/infrastructure/i18n';
import { AUDIO_EXTENSIONS, MAX_FILE_SIZE, type ChatMember } from '../../../../core/domain/types';
import { useNotificationStore } from '../../../../core/application/stores/notificationStore';
import EmojiPicker from './EmojiPicker';
interface Attachment {
file: File;
preview?: string;
type: 'image' | 'video' | 'file' | 'audio';
}
interface MessageInputProps {
chatId: string;
}
export default function MessageInput({ chatId }: MessageInputProps) {
const { user } = useAuthStore();
const { t } = useLang();
const { replyTo, editingMessage, setReplyTo, setEditingMessage, getDraft, setDraft, chats } = useChatStore();
const [text, setText] = useState(() => getDraft(chatId));
// Get current chat members for @mentions
const chat = chats.find(c => c.id === chatId);
const isGroup = chat?.type === 'group';
const chatMembers = (chat?.members || []).filter((m) => m.user.id !== user?.id);
const [showEmoji, setShowEmoji] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [recordingTime, setRecordingTime] = useState(0);
const [showAttachMenu, setShowAttachMenu] = useState(false);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [isSending, setIsSending] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [formatMenu, setFormatMenu] = useState<{ show: boolean; x: number; y: number }>({ show: false, x: 0, y: 0 });
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
const [mentionIndex, setMentionIndex] = useState(0);
const [showSchedule, setShowSchedule] = useState(false);
const [scheduleDate, setScheduleDate] = useState('');
const [scheduleStep, setScheduleStep] = useState<'presets' | 'custom'>('presets');
const [scheduleHour, setScheduleHour] = useState('12');
const [scheduleMinute, setScheduleMinute] = useState('00');
const [scheduleCalDate, setScheduleCalDate] = useState(''); // YYYY-MM-DD
const [scheduleCalMonth, setScheduleCalMonth] = useState(new Date().getMonth());
const [scheduleCalYear, setScheduleCalYear] = useState(new Date().getFullYear());
const [scheduleToast, setScheduleToast] = useState<string | null>(null);
// Filtered members for @mention
const filteredMembers = mentionQuery !== null && isGroup
? chatMembers.filter((m) => {
const q = mentionQuery.toLowerCase();
return (m.user.displayName || '').toLowerCase().includes(q)
|| (m.user.userName || '').toLowerCase().includes(q)
|| (m.user.username || '').toLowerCase().includes(q);
}).slice(0, 6)
: [];
const insertMention = (member: ChatMember) => {
const el = inputRef.current;
if (!el) return;
const username = member.user.userName || member.user.username;
if (!username) return;
const cursorPos = el.selectionStart;
const before = text.substring(0, cursorPos);
const after = text.substring(cursorPos);
// Find the @ that started this mention
const atIdx = before.lastIndexOf('@');
if (atIdx === -1) return;
const newText = before.substring(0, atIdx) + `@${username} ` + after;
setText(newText);
setDraft(chatId, newText);
setMentionQuery(null);
setMentionIndex(0);
setTimeout(() => {
el.focus();
const newPos = atIdx + username.length + 2;
el.setSelectionRange(newPos, newPos);
}, 0);
};
const inputRef = useRef<HTMLTextAreaElement>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const timerRef = useRef<ReturnType<typeof setInterval>>(undefined);
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const fileInputRef = useRef<HTMLInputElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const animFrameRef = useRef<number>(0);
const recordingTimeRef = useRef<number>(0);
const streamRef = useRef<MediaStream | null>(null);
const [liveBars, setLiveBars] = useState<number[]>(() => Array(32).fill(5));
// Cleanup recording resources on unmount
useEffect(() => {
return () => {
if (timerRef.current) clearInterval(timerRef.current);
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
if (audioContextRef.current) audioContextRef.current.close().catch(() => {});
if (streamRef.current) streamRef.current.getTracks().forEach(t => t.stop());
if (mediaRecorderRef.current?.state === 'recording') {
mediaRecorderRef.current.stop();
}
};
}, []);
// Автоподгон высоты textarea
useEffect(() => {
const el = inputRef.current;
if (el) {
el.style.height = '22px'; // Reset/Min height
if (text) {
el.style.height = Math.min(el.scrollHeight, 150) + 'px';
}
}
}, [text]);
// При редактировании — заполнить текст
useEffect(() => {
if (editingMessage?.content) {
setText(editingMessage.content);
inputRef.current?.focus();
}
}, [editingMessage]);
// При ответе - фокус на поле ввода
useEffect(() => {
if (replyTo) {
inputRef.current?.focus();
}
}, [replyTo]);
// Load draft when switching chats
useEffect(() => {
if (!editingMessage) {
setText(getDraft(chatId));
}
}, [chatId]);
// Cleanup preview URLs
useEffect(() => {
(window as any).hasUnsavedAttachments = attachments.length > 0;
return () => {
attachments.forEach(a => {
if (a.preview) URL.revokeObjectURL(a.preview);
});
(window as any).hasUnsavedAttachments = false;
};
}, [attachments]);
// Typing events
const emitTyping = useCallback(() => {
const socket = getSocket();
if (!socket) return;
socket.emit('typing_start', chatId);
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
typingTimeoutRef.current = setTimeout(() => {
socket.emit('typing_stop', chatId);
}, 2000);
}, [chatId]);
const handleSend = async (scheduledAt?: string) => {
const trimmed = text.trim();
const hasAttachments = attachments.length > 0;
if (!trimmed && !hasAttachments) return;
if (isSending) return;
const socket = getSocket();
if (!socket) return;
// Остановить typing
socket.emit('typing_stop', chatId);
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
if (editingMessage) {
socket.emit('edit_message', {
messageId: editingMessage.id,
content: trimmed,
chatId,
});
setEditingMessage(null);
setText('');
setDraft(chatId, '');
return;
}
if (hasAttachments) {
setIsSending(true);
try {
const uploadPromises = attachments.map(a => ChatApi.uploadFile(a.file));
const results = await Promise.all(uploadPromises);
const socketAttachments = results.map((res, i) => ({
type: attachments[i].type,
url: res.url,
fileName: res.filename,
fileSize: res.size
}));
socket.emit('send_message', {
chatId,
content: trimmed || null,
type: attachments.length > 0 ? (attachments.every(a => a.type === 'image') ? 'image' : 'file') : 'text',
attachments: socketAttachments,
replyToId: replyTo?.id || null,
quote: replyTo?.quote || null,
...(scheduledAt ? { scheduledAt } : {}),
});
setReplyTo(null);
clearAttachments();
} catch (e) {
console.error('Ошибка загрузки файла:', e);
const { addNotification } = useNotificationStore.getState();
addNotification('error', t('uploadError') || 'Ошибка загрузки файла');
} finally {
setIsSending(false);
}
} else {
socket.emit('send_message', {
chatId,
content: trimmed,
type: 'text',
replyToId: replyTo?.id || null,
quote: replyTo?.quote || null,
...(scheduledAt ? { scheduledAt } : {}),
});
setReplyTo(null);
}
setText('');
setDraft(chatId, '');
};
const handleKeyDown = (e: React.KeyboardEvent) => {
// Handle @mention navigation
if (mentionQuery !== null && filteredMembers.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setMentionIndex(i => (i + 1) % filteredMembers.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setMentionIndex(i => (i - 1 + filteredMembers.length) % filteredMembers.length);
return;
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
insertMention(filteredMembers[mentionIndex]);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
setMentionQuery(null);
return;
}
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const clearAttachments = () => {
attachments.forEach(a => {
if (a.preview) URL.revokeObjectURL(a.preview);
});
setAttachments([]);
};
const removeAttachment = (index: number) => {
setAttachments(prev => {
const newArr = [...prev];
if (newArr[index].preview) URL.revokeObjectURL(newArr[index].preview!);
newArr.splice(index, 1);
return newArr;
});
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length > 0) {
const { addNotification } = useNotificationStore.getState();
const newAttachments: Attachment[] = [];
let tooLarge = false;
let limitExceeded = false;
for (const file of files) {
if (attachments.length + newAttachments.length >= 20) {
limitExceeded = true;
break;
}
if (file.size > MAX_FILE_SIZE) {
tooLarge = true;
continue;
}
const isAudio = file.type.startsWith('audio/') || AUDIO_EXTENSIONS.some(ext => file.name.toLowerCase().endsWith(ext));
newAttachments.push({ file, type: isAudio ? 'audio' : 'file' });
}
if (tooLarge) addNotification('warning', t('fileTooLarge') || 'Некоторые файлы слишком большие');
if (limitExceeded) addNotification('warning', 'Максимум 20 файлов');
setAttachments(prev => [...prev, ...newAttachments]);
inputRef.current?.focus();
}
e.target.value = '';
setShowAttachMenu(false);
};
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length > 0) {
const { addNotification } = useNotificationStore.getState();
const newAttachments: Attachment[] = [];
let limitExceeded = false;
for (const file of files) {
if (attachments.length + newAttachments.length >= 20) {
limitExceeded = true;
break;
}
const isVideo = file.type.startsWith('video/');
const preview = file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined;
newAttachments.push({ file, preview, type: isVideo ? 'video' : 'image' });
}
if (limitExceeded) addNotification('warning', 'Максимум 20 файлов');
setAttachments(prev => [...prev, ...newAttachments]);
inputRef.current?.focus();
}
e.target.value = '';
setShowAttachMenu(false);
};
// Запись голосового
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
// Use ogg/opus for better compatibility, fallback to webm
const mimeType = MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')
? 'audio/ogg;codecs=opus'
: MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: 'audio/webm';
const ext = mimeType.includes('ogg') ? 'ogg' : 'webm';
const recorder = new MediaRecorder(stream, { mimeType });
mediaRecorderRef.current = recorder;
chunksRef.current = [];
// Set up AnalyserNode for live waveform
const actx = new AudioContext();
const source = actx.createMediaStreamSource(stream);
const analyser = actx.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.6;
source.connect(analyser);
audioContextRef.current = actx;
analyserRef.current = analyser;
const timeDomainData = new Uint8Array(analyser.frequencyBinCount);
const updateBars = () => {
if (!analyserRef.current) return;
analyserRef.current.getByteTimeDomainData(timeDomainData);
// Downsample to 32 bars
const bars: number[] = [];
const step = Math.floor(timeDomainData.length / 32);
for (let i = 0; i < 32; i++) {
let sum = 0;
for (let j = 0; j < step; j++) {
const val = Math.abs(timeDomainData[i * step + j] - 128);
sum += val;
}
const avg = sum / step;
// Map 0-128 to 8-100 with some exaggeration for visibility
bars.push(Math.max(8, Math.min(100, avg * 1.8 + 8)));
}
setLiveBars(bars);
animFrameRef.current = requestAnimationFrame(updateBars);
};
animFrameRef.current = requestAnimationFrame(updateBars);
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
recorder.onstop = async () => {
stream.getTracks().forEach((t) => t.stop());
streamRef.current = null;
const blob = new Blob(chunksRef.current, { type: mimeType });
const file = new File([blob], `voice.${ext}`, { type: mimeType });
try {
const result = await ChatApi.uploadFile(file);
const socket = getSocket();
if (socket) {
socket.emit('send_message', {
chatId,
content: null,
type: 'voice',
attachments: [{
type: 'voice',
url: result.url,
fileName: result.filename,
fileSize: result.size
}],
replyToId: replyTo?.id || null,
quote: replyTo?.quote || null,
});
setReplyTo(null);
}
} catch (e) {
console.error('Ошибка отправки голосового:', e);
}
};
recorder.start();
setIsRecording(true);
setRecordingTime(0);
recordingTimeRef.current = 0;
timerRef.current = setInterval(() => {
recordingTimeRef.current += 1;
setRecordingTime((t) => t + 1);
}, 1000);
} catch (e) {
console.error('Ошибка записи:', e);
}
};
const cleanupAnalyser = () => {
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
analyserRef.current = null;
if (audioContextRef.current) {
audioContextRef.current.close().catch(() => {});
audioContextRef.current = null;
}
setLiveBars(Array(32).fill(5));
};
const stopRecording = () => {
if (mediaRecorderRef.current?.state === 'recording') {
mediaRecorderRef.current.stop();
}
if (timerRef.current) clearInterval(timerRef.current);
cleanupAnalyser();
setIsRecording(false);
setRecordingTime(0);
// recordingTimeRef is consumed in onstop, don't reset here
};
const cancelRecording = () => {
if (mediaRecorderRef.current?.state === 'recording') {
mediaRecorderRef.current.ondataavailable = null;
mediaRecorderRef.current.onstop = null;
mediaRecorderRef.current.stop();
mediaRecorderRef.current.stream?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
}
if (timerRef.current) clearInterval(timerRef.current);
cleanupAnalyser();
setIsRecording(false);
setRecordingTime(0);
recordingTimeRef.current = 0;
};
const formatTime = (sec: number) => {
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
};
const handleInputContextMenu = (e: React.MouseEvent) => {
const el = inputRef.current;
if (el && el.selectionStart !== el.selectionEnd) {
e.preventDefault();
setFormatMenu({ show: true, x: e.clientX, y: e.clientY });
}
};
const applyFormat = (prefix: string, suffix: string) => {
const el = inputRef.current;
if (!el) return;
const start = el.selectionStart;
const end = el.selectionEnd;
const val = el.value;
const selected = val.substring(start, end);
const newVal = val.substring(0, start) + prefix + selected + suffix + val.substring(end);
setText(newVal);
setFormatMenu({ show: false, x: 0, y: 0 });
// refocus and update cursor
setTimeout(() => {
el.focus();
el.setSelectionRange(start + prefix.length, end + prefix.length);
}, 0);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const files = Array.from(e.dataTransfer.files);
const { addNotification } = useNotificationStore.getState();
const newAttachments: Attachment[] = [];
let tooLarge = false;
let limitExceeded = false;
for (const file of files) {
if (attachments.length + newAttachments.length >= 20) {
limitExceeded = true;
break;
}
if (file.size > MAX_FILE_SIZE) {
tooLarge = true;
continue;
}
const isVideo = file.type.startsWith('video/');
const isImage = file.type.startsWith('image/');
const audioExts = ['.mp3', '.wav', '.ogg', '.m4a', '.aac', '.flac', '.wma', '.opus'];
const isAudio = file.type.startsWith('audio/') || audioExts.some(ext => file.name.toLowerCase().endsWith(ext));
const type = isImage ? 'image' : isVideo ? 'video' : isAudio ? 'audio' : 'file';
const preview = isImage ? URL.createObjectURL(file) : undefined;
newAttachments.push({ file, type, preview });
}
if (tooLarge) addNotification('warning', t('fileTooLarge') || 'Некоторые файлы слишком большие');
if (limitExceeded) addNotification('warning', 'Максимум 20 файлов');
setAttachments(prev => [...prev, ...newAttachments]);
inputRef.current?.focus();
}
};
const hasContent = text.trim() || attachments.length > 0;
return (
<div
className="z-10 px-6 pt-2 pb-6 flex-shrink-0 bg-transparent relative"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Drag overlay */}
<AnimatePresence>
{isDragging && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 z-50 rounded-3xl mx-6 mb-6 mt-2 bg-primary/10 border-2 border-dashed border-primary/40 backdrop-blur-sm flex items-center justify-center pointer-events-none"
>
<div className="flex flex-col items-center gap-4 text-primary">
<span className="material-symbols-outlined text-5xl animate-bounce">upload_file</span>
<p className="font-bold text-lg font-headline uppercase tracking-widest">{t('dropFileHere')}</p>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Reply / Edit indicator */}
<AnimatePresence>
{(replyTo || editingMessage) && (
<motion.div
initial={{ height: 0, opacity: 0, scale: 0.98 }}
animate={{ height: 'auto', opacity: 1, scale: 1 }}
exit={{ height: 0, opacity: 0, scale: 0.98 }}
className="mb-2 max-w-4xl mx-auto overflow-hidden"
>
<div className="flex items-center gap-4 px-5 py-3 bg-surface-container-high/80 backdrop-blur-2xl border-none rounded-2xl relative shadow-xl slide-on-ice">
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1.5 h-3/5 bg-primary rounded-r-full" />
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-primary text-[20px]">
{editingMessage ? 'edit' : 'reply'}
</span>
</div>
<div className="flex-1 min-w-0 flex flex-col justify-center">
<p className="text-[10px] font-bold tracking-widest uppercase text-primary mb-0.5">
{editingMessage
? t('editing')
: `${t('replyTo')} ${replyTo?.sender?.displayName || replyTo?.sender?.userName || replyTo?.sender?.username || ''}`}
</p>
<div className="text-[13px] text-[#efeff3] truncate opacity-90 pl-1 border-l border-white/20 ml-0.5">
{replyTo?.quote ? `«${replyTo.quote}»` : (editingMessage || replyTo)?.content || t('media') || 'Медиа'}
</div>
</div>
<button
onClick={() => {
setReplyTo(null);
setEditingMessage(null);
setAttachments([]);
setText('');
}}
className="w-8 h-8 rounded-full flex items-center justify-center text-on-surface-variant/40 hover:text-primary hover:bg-primary/10 transition-all slide-on-ice"
>
<span className="material-symbols-outlined text-[20px]">close</span>
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Attachment previews */}
<AnimatePresence>
{attachments.length > 0 && (
<motion.div
initial={{ height: 0, opacity: 0, y: 10, scale: 0.95 }}
animate={{ height: 'auto', opacity: 1, y: 0, scale: 1 }}
exit={{ height: 0, opacity: 0, y: 10, scale: 0.95 }}
className="mb-2 max-w-3xl mx-auto overflow-hidden px-1.5"
>
<div className="flex flex-col gap-2 p-2 bg-white/[0.04] backdrop-blur-2xl border border-white/10 rounded-2xl shadow-xl">
<div className="flex flex-wrap gap-2">
{attachments.map((att, idx) => (
<motion.div
key={idx}
layout
className="group relative w-20 h-20 rounded-xl overflow-hidden border border-white/10 flex-shrink-0"
>
{att.preview ? (
<img
src={att.preview}
alt=""
className="w-full h-full object-cover"
/>
) : att.type === 'video' ? (
<div className="w-full h-full bg-knot-500/20 flex items-center justify-center">
<ImageIcon size={20} className="text-knot-400" />
</div>
) : att.type === 'audio' ? (
<div className="w-full h-full bg-emerald-500/20 flex items-center justify-center">
<Music size={20} className="text-emerald-400" />
</div>
) : (
<div className="w-full h-full bg-sky-500/20 flex items-center justify-center">
<FileText size={20} className="text-sky-400" />
</div>
)}
<button
onClick={() => removeAttachment(idx)}
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<X size={12} />
</button>
<div className="absolute bottom-0 left-0 right-0 bg-black/40 px-1 py-0.5 pointer-events-none">
<p className="text-[8px] text-white truncate">{att.file.name}</p>
</div>
</motion.div>
))}
</div>
<div className="flex items-center justify-between px-2 py-1 border-t border-white/5 mt-1">
<span className="text-[10px] text-zinc-400">
{attachments.length} {t('files')} ({ (attachments.reduce((acc, a) => acc + a.file.size, 0) / 1024 / 1024).toFixed(2) } MB)
</span>
<button
onClick={clearAttachments}
className="text-[10px] text-zinc-500 hover:text-rose-400 transition-colors"
>
{t('clearAll') || 'Очистить всё'}
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Recording UI */}
{isRecording ? (
<div className="flex items-center gap-4 bg-surface-container-high rounded-[2rem] px-6 py-4 w-full max-w-4xl mx-auto shadow-2xl animate-in slide-in-from-bottom-4 slide-on-ice">
<button
onClick={cancelRecording}
className="w-10 h-10 rounded-full flex items-center justify-center text-error hover:bg-error/10 transition-all slide-on-ice"
>
<span className="material-symbols-outlined">delete</span>
</button>
<div className="flex-1 flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-error animate-pulse shadow-[0_0_8px_rgba(244,67,54,0.5)]" />
<span className="text-sm font-bold font-mono text-on-surface tracking-tighter w-12">{formatTime(recordingTime)}</span>
</div>
<div className="flex-1 flex items-center gap-[3px] h-8 items-center">
{liveBars.map((h, i) => (
<div
key={i}
className="flex-1 bg-primary/40 rounded-full transition-all duration-100"
style={{ height: `${h}%` }}
/>
))}
</div>
</div>
<button
onClick={stopRecording}
className="w-12 h-12 rounded-2xl bg-primary text-on-primary flex items-center justify-center hover:scale-105 active:scale-95 transition-all shadow-lg shadow-primary/20 slide-on-ice"
>
<span className="material-symbols-outlined">send</span>
</button>
</div>
) : (
<div className="flex items-end gap-3 w-full max-w-4xl mx-auto px-4 pb-4">
{/* Attach - Outside */}
<div className="relative flex-shrink-0 self-end mb-1">
<button
onClick={() => setShowAttachMenu(!showAttachMenu)}
className="w-10 h-10 rounded-full text-on-surface-variant/60 hover:text-primary transition-all flex items-center justify-center p-0"
>
<Paperclip size={24} strokeWidth={1.5} />
</button>
<AnimatePresence>
{showAttachMenu && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowAttachMenu(false)} />
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 15 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 15 }}
className="absolute bottom-[calc(100%+12px)] left-0 w-52 rounded-[1.5rem] glass-strong shadow-2xl z-50 p-2 border border-white/10 backdrop-blur-3xl"
>
<button
onClick={() => imageInputRef.current?.click()}
className="flex items-center gap-4 w-full px-3 py-3 rounded-xl text-sm font-medium text-zinc-200 hover:bg-white/5 hover:text-white transition-all group"
>
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-knot-400/20 to-purple-500/20 flex items-center justify-center ring-1 ring-knot-400/30 group-hover:scale-110 transition-transform shadow-inner">
<ImageIcon size={18} className="text-knot-400" />
</div>
{t('photoVideo')}
</button>
<button
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-4 w-full px-3 py-3 rounded-xl text-sm font-medium text-zinc-200 hover:bg-white/5 hover:text-white transition-all group mt-1"
>
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-emerald-400/20 to-teal-500/20 flex items-center justify-center ring-1 ring-emerald-400/30 group-hover:scale-110 transition-transform shadow-inner">
<FileText size={18} className="text-emerald-400" />
</div>
{t('file')}
</button>
</motion.div>
</>
)}
</AnimatePresence>
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileChange} />
<input ref={imageInputRef} type="file" multiple accept="image/*,video/*" className="hidden" onChange={handleImageChange} />
</div>
{/* Main Input Pill */}
<div className="flex-1 flex items-end gap-2 bg-[#2a2a2a] rounded-2xl px-3 py-1.5 transition-all duration-300 focus-within:bg-[#323232] shadow-sm relative min-h-[44px]">
<button
onClick={() => setShowEmoji(!showEmoji)}
className="w-8 h-8 rounded-full text-on-surface-variant/50 hover:text-primary transition-all flex items-center justify-center flex-shrink-0 mb-0.5"
>
<Smile size={20} strokeWidth={1.5} />
</button>
<div className="flex-1 relative self-center">
<textarea
ref={inputRef}
value={text}
onChange={(e) => {
const val = e.target.value;
setText(val);
setDraft(chatId, val);
emitTyping();
if (isGroup) {
const cursorPos = e.target.selectionStart;
const match = val.substring(0, cursorPos).match(/@(\w*)$/);
if (match) { setMentionQuery(match[1]); setMentionIndex(0); }
else setMentionQuery(null);
}
}}
onKeyDown={handleKeyDown}
onContextMenu={handleInputContextMenu}
rows={1}
className="w-full bg-transparent border-none focus:ring-0 text-[#efeff3] placeholder-on-surface-variant/30 text-[16px] leading-[1.3] resize-none max-h-[140px] custom-scrollbar outline-none py-1 px-0"
placeholder={attachments.length > 0 ? t('addCaption') : t('messagePlaceholder') || 'Сообщение...'}
/>
<AnimatePresence>
{mentionQuery !== null && filteredMembers.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
className="absolute bottom-full left-0 right-0 mb-4 rounded-xl glass-strong shadow-2xl border border-white/10 py-1 z-50 max-h-48 overflow-y-auto"
>
{filteredMembers.map((m, i) => (
<button
key={m.user.id}
onClick={() => insertMention(m)}
className={`flex items-center gap-3 w-full px-3 py-2 text-left transition-colors ${
i === mentionIndex ? 'bg-primary/20 text-white' : 'text-zinc-300 hover:bg-white/5'
}`}
>
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white text-[10px] font-bold">
{(m.user.displayName || m.user.userName || m.user.username || '?')[0]?.toUpperCase()}
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{m.user.displayName || m.user.userName || m.user.username || '??'}</p>
</div>
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
<AnimatePresence>
{showEmoji && (
<div className="absolute left-0 bottom-[calc(100%+20px)]">
<EmojiPicker
onSelect={(emoji) => {
setText((prev) => {
const next = prev + emoji;
setDraft(chatId, next);
return next;
});
inputRef.current?.focus();
}}
onSelectGif={(gifUrl) => {
const socket = getSocket();
if (socket) {
socket.emit('send_message', {
chatId,
content: null,
type: 'image',
attachments: [{ type: 'image', url: gifUrl, fileName: 'gif.gif', fileSize: 0 }],
});
}
setShowEmoji(false);
}}
onClose={() => setShowEmoji(false)}
/>
</div>
)}
</AnimatePresence>
</div>
{/* Send / Mic - Circular button outside pill */}
<div className="flex-shrink-0 relative">
{hasContent ? (
<>
<button
onClick={() => handleSend()}
onContextMenu={(e) => { e.preventDefault(); setScheduleStep('presets'); setShowSchedule(true); }}
disabled={isSending}
className="w-11 h-11 flex items-center justify-center rounded-2xl bg-primary text-white hover:brightness-110 active:scale-95 transition-all shadow-[0_4px_15px_rgba(48,150,229,0.3)] disabled:opacity-50"
>
<Send size={20} strokeWidth={2.5} className="translate-x-[1px]" />
</button>
<AnimatePresence>
{showSchedule && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowSchedule(false)} />
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 10 }}
className="absolute bottom-[calc(100%+12px)] right-0 w-72 rounded-2xl glass-strong shadow-2xl z-50 border border-white/10 backdrop-blur-3xl overflow-hidden"
>
<div className="flex items-center gap-2 px-4 pt-4 pb-2">
{scheduleStep === 'custom' && (
<button onClick={() => setScheduleStep('presets')} className="p-1 rounded-lg hover:bg-white/10 text-zinc-400 hover:text-white transition-colors">
<ChevronLeft size={16} />
</button>
)}
<Clock size={16} className="text-knot-400" />
<span className="text-sm font-medium text-zinc-200">{t('scheduleMessage')}</span>
</div>
{scheduleStep === 'presets' ? (
<div className="p-2 space-y-1">
<button
onClick={() => {
const d = new Date(Date.now() + 3600000);
handleSend(d.toISOString());
setShowSchedule(false);
setScheduleToast(d.toLocaleString());
}}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm text-zinc-200 hover:bg-white/10 transition-colors text-left"
>
<Clock size={15} className="text-zinc-400 flex-shrink-0" />
{t('scheduleIn1h')}
</button>
<button
onClick={() => {
const d = new Date(Date.now() + 3 * 3600000);
handleSend(d.toISOString());
setShowSchedule(false);
setScheduleToast(d.toLocaleString());
}}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm text-zinc-200 hover:bg-white/10 transition-colors text-left"
>
<Clock size={15} className="text-zinc-400 flex-shrink-0" />
{t('scheduleIn3h')}
</button>
<button
onClick={() => {
const d = new Date(); d.setDate(d.getDate() + 1); d.setHours(9, 0, 0, 0);
handleSend(d.toISOString());
setShowSchedule(false);
setScheduleToast(d.toLocaleString());
}}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm text-zinc-200 hover:bg-white/10 transition-colors text-left"
>
<Calendar size={15} className="text-zinc-400 flex-shrink-0" />
{t('scheduleTomorrow')}
</button>
<div className="border-t border-white/5 my-1" />
<button
onClick={() => {
const now = new Date();
setScheduleCalYear(now.getFullYear());
setScheduleCalMonth(now.getMonth());
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
setScheduleCalDate(`${now.getFullYear()}-${m}-${d}`);
setScheduleHour(String(Math.min(now.getHours() + 1, 23)).padStart(2, '0'));
setScheduleMinute(String(now.getMinutes()).padStart(2, '0'));
setScheduleStep('custom');
}}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm text-knot-400 hover:bg-white/10 transition-colors text-left"
>
<Calendar size={15} className="flex-shrink-0" />
{t('scheduleCustom')}
</button>
</div>
) : (
<ScheduleCalendar
calDate={scheduleCalDate} setCalDate={setScheduleCalDate}
calMonth={scheduleCalMonth} setCalMonth={setScheduleCalMonth}
calYear={scheduleCalYear} setCalYear={setScheduleCalYear}
hour={scheduleHour} setHour={setScheduleHour}
minute={scheduleMinute} setMinute={setScheduleMinute}
onSend={(iso) => { handleSend(iso); setShowSchedule(false); setScheduleToast(new Date(iso).toLocaleString()); }}
t={t}
/>
)}
</motion.div>
</>
)}
</AnimatePresence>
</>
) : (
<button
onClick={startRecording}
className="w-11 h-11 flex items-center justify-center rounded-2xl bg-primary text-white hover:brightness-110 active:scale-95 transition-all shadow-[0_4px_15px_rgba(48,150,229,0.3)]"
>
<Mic size={22} strokeWidth={1.5} />
</button>
)}
</div>
</div>
)}
{/* Formatting Context Menu */}
<AnimatePresence>
{formatMenu.show && (
<>
<div className="fixed inset-0 z-50 cursor-pointer" onClick={() => setFormatMenu({ ...formatMenu, show: false })} onContextMenu={(e) => { e.preventDefault(); setFormatMenu({ ...formatMenu, show: false }); }} />
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
className="fixed z-[9999] w-48 rounded-2xl glass-strong shadow-2xl py-1"
style={{ left: formatMenu.x, top: formatMenu.y - 180 }}
>
<button
onClick={() => applyFormat('**', '**')}
className="flex items-center gap-3 w-full px-4 py-2 text-sm text-zinc-200 hover:bg-white/10 transition-colors"
>
<b className="font-bold">{t('formatBold')}</b> <span className="text-xs text-zinc-500 ml-auto">{t('formatBoldHint')}</span>
</button>
<button
onClick={() => applyFormat('_', '_')}
className="flex items-center gap-3 w-full px-4 py-2 text-sm text-zinc-200 hover:bg-white/10 transition-colors"
>
<em className="italic">{t('formatItalic')}</em> <span className="text-xs text-zinc-500 ml-auto">{t('formatItalicHint')}</span>
</button>
<button
onClick={() => applyFormat('~', '~')}
className="flex items-center gap-3 w-full px-4 py-2 text-sm text-zinc-200 hover:bg-white/10 transition-colors"
>
<del className="line-through">{t('formatStrike')}</del> <span className="text-xs text-zinc-500 ml-auto">{t('formatStrikeHint')}</span>
</button>
<button
onClick={() => applyFormat('`', '`')}
className="flex items-center gap-3 w-full px-4 py-2 text-sm text-zinc-200 hover:bg-white/10 transition-colors"
>
<code className="bg-black/20 rounded px-1 font-mono">{t('formatMono')}</code> <span className="text-xs text-zinc-500 ml-auto">{t('formatMonoHint')}</span>
</button>
</motion.div>
</>
)}
</AnimatePresence>
{/* Schedule toast notification */}
<AnimatePresence>
{scheduleToast && (
<motion.div
initial={{ opacity: 0, y: -10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.95 }}
onAnimationComplete={() => {
setTimeout(() => setScheduleToast(null), 3500);
}}
className="absolute bottom-[calc(100%+8px)] left-1/2 -translate-x-1/2 z-[9999] px-4 py-2.5 rounded-xl bg-surface shadow-2xl border border-border flex items-center gap-2 whitespace-nowrap"
>
<Check size={16} className="text-emerald-400 flex-shrink-0" />
<span className="text-sm text-zinc-200">{t('messageScheduled')}</span>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
/* =================== Schedule Mini Calendar =================== */
function ScheduleCalendar({
calDate, setCalDate, calMonth, setCalMonth, calYear, setCalYear,
hour, setHour, minute, setMinute, onSend, t,
}: {
calDate: string;
setCalDate: (v: string) => void;
calMonth: number;
setCalMonth: (v: number) => void;
calYear: number;
setCalYear: (v: number) => void;
hour: string;
setHour: (v: string) => void;
minute: string;
setMinute: (v: string) => void;
onSend: (iso: string) => void;
t: (k: any) => any;
}) {
const today = new Date();
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const daysInMonth = new Date(calYear, calMonth + 1, 0).getDate();
const firstDayRaw = new Date(calYear, calMonth, 1).getDay();
const firstDay = firstDayRaw === 0 ? 6 : firstDayRaw - 1;
const cells: (number | null)[] = [];
for (let i = 0; i < firstDay; i++) cells.push(null);
for (let d2 = 1; d2 <= daysInMonth; d2++) cells.push(d2);
const weekDays = t('weekDays') as string[];
const months = t('months') as string[];
const prevMonth = () => {
if (calMonth === 0) { setCalMonth(11); setCalYear(calYear - 1); }
else setCalMonth(calMonth - 1);
};
const nextMonth = () => {
if (calMonth === 11) { setCalMonth(0); setCalYear(calYear + 1); }
else setCalMonth(calMonth + 1);
};
const selectDay = (day: number) => {
const m = String(calMonth + 1).padStart(2, '0');
const d = String(day).padStart(2, '0');
setCalDate(`${calYear}-${m}-${d}`);
};
const isSelected = (day: number) => {
const m = String(calMonth + 1).padStart(2, '0');
const d = String(day).padStart(2, '0');
return calDate === `${calYear}-${m}-${d}`;
};
const isToday = (day: number) => {
return today.getFullYear() === calYear && today.getMonth() === calMonth && today.getDate() === day;
};
const isPast = (day: number) => {
const m = String(calMonth + 1).padStart(2, '0');
const d = String(day).padStart(2, '0');
return `${calYear}-${m}-${d}` < todayStr;
};
const canSend = (() => {
if (!calDate) return false;
const dt = new Date(`${calDate}T${hour}:${minute}:00`);
return dt.getTime() > Date.now();
})();
const handleSend = () => {
if (!canSend) return;
const dt = new Date(`${calDate}T${hour}:${minute}:00`);
onSend(dt.toISOString());
};
return (
<div>
{/* Mini calendar header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-white/5">
<button onClick={prevMonth} className="p-1 rounded-lg hover:bg-white/10 text-zinc-400 hover:text-white transition-colors">
<ChevronLeft size={16} />
</button>
<span className="text-xs font-medium text-zinc-300">{months[calMonth]} {calYear}</span>
<button onClick={nextMonth} className="p-1 rounded-lg hover:bg-white/10 text-zinc-400 hover:text-white transition-colors">
<ChevronRight size={16} />
</button>
</div>
{/* Weekday headers */}
<div className="grid grid-cols-7 px-2 pt-1">
{weekDays.map((d) => (
<div key={d} className="text-center text-[10px] text-zinc-500 font-medium py-0.5">{d}</div>
))}
</div>
{/* Days grid */}
<div className="grid grid-cols-7 px-2 pb-2">
{cells.map((day, i) => (
<div key={i} className="flex items-center justify-center">
{day ? (
<button
onClick={() => !isPast(day) && selectDay(day)}
disabled={isPast(day)}
className={`w-7 h-7 rounded-full text-xs flex items-center justify-center transition-all ${
isSelected(day)
? 'bg-accent text-white font-semibold shadow-lg shadow-accent/30'
: isPast(day)
? 'text-zinc-600 cursor-not-allowed'
: isToday(day)
? 'text-knot-400 font-semibold ring-1 ring-knot-500/50'
: 'text-zinc-300 hover:bg-white/10'
}`}
>
{day}
</button>
) : (
<span className="w-7 h-7" />
)}
</div>
))}
</div>
{/* Time picker */}
<div className="px-3 pb-2">
<label className="text-[11px] text-zinc-500 mb-1 block">{t('scheduleTime')}</label>
<div className="flex items-center gap-2">
<select
value={hour}
onChange={(e) => setHour(e.target.value)}
className="flex-1 bg-white/5 border border-white/10 rounded-lg px-2 py-1.5 text-sm text-zinc-200 focus:outline-none focus:border-knot-500/50 appearance-none text-center"
>
{Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0')).map((h) => (
<option key={h} value={h} className="bg-zinc-800">{h}</option>
))}
</select>
<span className="text-zinc-400 font-bold">:</span>
<select
value={minute}
onChange={(e) => setMinute(e.target.value)}
className="flex-1 bg-white/5 border border-white/10 rounded-lg px-2 py-1.5 text-sm text-zinc-200 focus:outline-none focus:border-knot-500/50 appearance-none text-center"
>
{Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0')).map((m) => (
<option key={m} value={m} className="bg-zinc-800">{m}</option>
))}
</select>
</div>
</div>
{/* Send button */}
<div className="px-3 pb-3">
<button
onClick={handleSend}
disabled={!canSend}
className="w-full py-2 rounded-xl bg-accent hover:bg-accent-hover disabled:bg-zinc-700 disabled:text-zinc-500 text-white text-sm font-medium transition-colors"
>
{t('scheduleSend')}
</button>
</div>
</div>
);
}