Files
janichat/frontend/src/components/MessageInput.tsx
Ai 87c6d7f82f fix: message sending fallback + iOS zoom on input + openOrCreatePrivate
- 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>
2026-05-22 12:44:42 +03:00

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>
);
}