- MessageInput: HTTP fallback when WebSocket not OPEN (prevents silent failures) - WsClient: add trySend() returning bool to detect WS availability - Backend: POST /api/messages/chat/:chatId endpoint for HTTP message send - openOrCreatePrivate: use openChat() for consistency after refreshing chat list - iOS fix: inputs get font-size:16px on mobile to prevent Safari auto-zoom Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
209 lines
6.7 KiB
TypeScript
209 lines
6.7 KiB
TypeScript
import { useState, useRef, useEffect } from 'react';
|
|
import { Send, Paperclip, X, Smile } from 'lucide-react';
|
|
import { wsClient } from '../api/ws';
|
|
import api from '../api/client';
|
|
import { Message } from '../types';
|
|
import { useStore } from '../store';
|
|
|
|
interface Props {
|
|
chatId: string;
|
|
replyTo: Message | null;
|
|
editMsg: Message | null;
|
|
onCancelReply: () => void;
|
|
onCancelEdit: () => void;
|
|
onEditDone: (msg: Message) => void;
|
|
}
|
|
|
|
const EMOJIS = ['😀','😂','😍','🥰','😎','👍','❤️','🔥','✅','👋','🙏','💪','🎉','💯','😊','🤔','😅','🙌','💬','📌'];
|
|
|
|
export default function MessageInput({ chatId, replyTo, editMsg, onCancelReply, onCancelEdit, onEditDone }: Props) {
|
|
const [text, setText] = useState('');
|
|
const [sending, setSending] = useState(false);
|
|
const [showEmoji, setShowEmoji] = useState(false);
|
|
const [uploading, setUploading] = useState(false);
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
const fileRef = useRef<HTMLInputElement>(null);
|
|
const typingTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const { addMessage, user } = useStore();
|
|
|
|
useEffect(() => {
|
|
if (editMsg) {
|
|
setText(editMsg.content);
|
|
textareaRef.current?.focus();
|
|
}
|
|
}, [editMsg]);
|
|
|
|
useEffect(() => {
|
|
if (replyTo) textareaRef.current?.focus();
|
|
}, [replyTo]);
|
|
|
|
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
|
|
setText(e.target.value);
|
|
autoResize();
|
|
|
|
// Typing indicator
|
|
wsClient.send('typing', { chatId, typing: true });
|
|
if (typingTimeout.current) clearTimeout(typingTimeout.current);
|
|
typingTimeout.current = setTimeout(() => {
|
|
wsClient.send('typing', { chatId, typing: false });
|
|
}, 2000);
|
|
}
|
|
|
|
function autoResize() {
|
|
const el = textareaRef.current;
|
|
if (el) {
|
|
el.style.height = 'auto';
|
|
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
|
|
}
|
|
}
|
|
|
|
function handleKeyDown(e: React.KeyboardEvent) {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSend();
|
|
}
|
|
if (e.key === 'Escape') {
|
|
onCancelReply();
|
|
onCancelEdit();
|
|
}
|
|
}
|
|
|
|
async function handleSend() {
|
|
const content = text.trim();
|
|
if (!content || sending) return;
|
|
|
|
setSending(true);
|
|
setText('');
|
|
if (textareaRef.current) textareaRef.current.style.height = 'auto';
|
|
wsClient.send('typing', { chatId, typing: false });
|
|
|
|
try {
|
|
if (editMsg) {
|
|
wsClient.send('edit_message', { messageId: editMsg.id, content });
|
|
onEditDone({ ...editMsg, content, isEdited: true });
|
|
} else {
|
|
const sent = wsClient.trySend('send_message', {
|
|
chatId, content, type: 'text', replyToId: replyTo?.id || null,
|
|
});
|
|
if (!sent) {
|
|
// WS not available — use HTTP fallback
|
|
const { data } = await api.post(`/api/messages/chat/${chatId}`, {
|
|
content, type: 'text', replyToId: replyTo?.id || null,
|
|
});
|
|
addMessage(data);
|
|
}
|
|
onCancelReply();
|
|
}
|
|
} catch {
|
|
// restore text so user doesn't lose it
|
|
setText(content);
|
|
} finally {
|
|
setSending(false);
|
|
}
|
|
}
|
|
|
|
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
e.target.value = '';
|
|
|
|
setUploading(true);
|
|
const form = new FormData();
|
|
form.append('file', file);
|
|
try {
|
|
const { data } = await api.post(`/api/messages/chat/${chatId}/upload`, form, {
|
|
headers: { 'Content-Type': 'multipart/form-data' }
|
|
});
|
|
addMessage(data);
|
|
} catch {
|
|
alert('Ошибка загрузки файла');
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
}
|
|
|
|
function insertEmoji(emoji: string) {
|
|
setText(t => t + emoji);
|
|
setShowEmoji(false);
|
|
textareaRef.current?.focus();
|
|
}
|
|
|
|
const isEdit = !!editMsg;
|
|
const placeholder = isEdit ? 'Редактирование...' : replyTo ? 'Ответить...' : 'Сообщение...';
|
|
|
|
return (
|
|
<div className="border-t border-gray-100 bg-white px-4 pb-safe pt-2">
|
|
{/* Reply/Edit preview */}
|
|
{(replyTo || editMsg) && (
|
|
<div className="flex items-center gap-2 mb-2 pl-3 border-l-2 border-blue-400 bg-blue-50 rounded-r-lg py-1.5 pr-2">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-xs font-medium text-blue-600">
|
|
{isEdit ? 'Редактирование' : `Ответ: ${replyTo?.sender?.displayName}`}
|
|
</div>
|
|
<div className="text-xs text-gray-600 truncate">
|
|
{isEdit ? editMsg?.content : replyTo?.content}
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={isEdit ? onCancelEdit : onCancelReply}
|
|
className="p-0.5 text-gray-400 hover:text-gray-600"
|
|
>
|
|
<X className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-end gap-2">
|
|
{/* Emoji picker */}
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => setShowEmoji(!showEmoji)}
|
|
className="p-2 text-gray-400 hover:text-gray-600 transition-colors rounded-lg hover:bg-gray-100"
|
|
>
|
|
<Smile className="w-5 h-5" />
|
|
</button>
|
|
{showEmoji && (
|
|
<div className="absolute bottom-10 left-0 bg-white rounded-2xl shadow-xl p-3 grid grid-cols-5 gap-1 z-10 border border-gray-100">
|
|
{EMOJIS.map(e => (
|
|
<button key={e} onClick={() => insertEmoji(e)} className="text-xl hover:bg-gray-100 rounded-lg p-1">
|
|
{e}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Textarea */}
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={text}
|
|
onChange={handleChange}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={placeholder}
|
|
rows={1}
|
|
className="flex-1 resize-none bg-gray-100 rounded-2xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 max-h-[120px] leading-relaxed"
|
|
/>
|
|
|
|
{/* File upload */}
|
|
<button
|
|
onClick={() => fileRef.current?.click()}
|
|
disabled={uploading}
|
|
className="p-2 text-gray-400 hover:text-gray-600 transition-colors rounded-lg hover:bg-gray-100 disabled:opacity-50"
|
|
>
|
|
<Paperclip className="w-5 h-5" />
|
|
</button>
|
|
<input ref={fileRef} type="file" className="hidden" onChange={handleFile} />
|
|
|
|
{/* Send */}
|
|
<button
|
|
onClick={handleSend}
|
|
disabled={!text.trim() || sending}
|
|
className="p-2.5 bg-blue-500 hover:bg-blue-600 disabled:opacity-40 text-white rounded-xl transition-colors"
|
|
>
|
|
<Send className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|