Merge branch 'main' into android
This commit is contained in:
@@ -4,6 +4,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Shared.Kernel.Storage;
|
||||
|
||||
@@ -194,7 +194,8 @@ const translations = {
|
||||
clearChatConfirm: 'Очистить историю чата для себя? Собеседник сохранит свою историю.',
|
||||
clearHistory: 'Очистить историю',
|
||||
clearHistoryConfirm: 'Очистить историю?',
|
||||
deleteChatConfirm: 'Удалить чат? Это действие нельзя отменить.',
|
||||
deleteChatConfirm: 'Удалить чат? Это действие нельзя отменить. Чат будет удалён у всех участников.',
|
||||
deleteGroupChatConfirm: 'Удалить чат? Это действие нельзя отменить. Чат будет удалён у всех участников.',
|
||||
pinChat: 'Закрепить чат',
|
||||
unpinChat: 'Открепить чат',
|
||||
chatCleared: 'Очищено',
|
||||
@@ -574,7 +575,8 @@ const translations = {
|
||||
clearChatConfirm: 'Clear chat history for yourself? The other person will keep their history.',
|
||||
clearHistory: 'Clear history',
|
||||
clearHistoryConfirm: 'Clear history?',
|
||||
deleteChatConfirm: 'Delete this chat? This action cannot be undone.',
|
||||
deleteChatConfirm: 'Delete this chat? This action cannot be undone. The chat will be removed for all participants.',
|
||||
deleteGroupChatConfirm: 'Delete this chat? This action cannot be undone. The chat will be removed for all participants.',
|
||||
pinChat: 'Pin chat',
|
||||
unpinChat: 'Unpin chat',
|
||||
chatCleared: 'Chat cleared',
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function GlobalNavBar({ activeTab, onTabChange }: GlobalNavBarPro
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="lg:fixed lg:left-0 lg:top-0 lg:h-[100dvh] lg:w-20 w-full h-16 fixed bottom-0 left-0 bg-surface-container-low border-t lg:border-t-0 lg:border-r border-white/5 flex lg:flex-col flex-row items-center justify-around lg:justify-start lg:py-8 lg:gap-4 z-50 transition-all safe-area-bottom"
|
||||
className="lg:fixed lg:left-0 lg:top-0 lg:h-[100dvh] lg:w-20 w-full h-16 fixed bottom-0 left-0 bg-surface-container-low border-t lg:border-t-0 lg:border-r border-white/5 flex lg:flex-col flex-row items-center justify-around lg:justify-start lg:pt-8 lg:pb-14 lg:gap-4 z-50 transition-all safe-area-bottom"
|
||||
>
|
||||
<div className="hidden lg:flex mb-10 flex-col items-center">
|
||||
<span className="text-2xl font-black text-primary tracking-tighter italic knot-logo-spin">Knot</span>
|
||||
@@ -46,7 +46,7 @@ export default function GlobalNavBar({ activeTab, onTabChange }: GlobalNavBarPro
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="lg:mt-auto group cursor-pointer relative flex items-center justify-center px-4 lg:px-0"
|
||||
className="lg:mt-auto lg:mb-4 group cursor-pointer relative flex items-center justify-center px-4 lg:px-0"
|
||||
onClick={() => onTabChange('settings')}
|
||||
>
|
||||
<div className="absolute -inset-1 bg-primary/20 rounded-2xl opacity-0 group-hover:opacity-100 blur transition-opacity" />
|
||||
|
||||
@@ -65,6 +65,8 @@ export default function ChatPage() {
|
||||
const [activeTab, setActiveTab] = useState('chats');
|
||||
const { t } = useLang();
|
||||
|
||||
const activeChat = useChatStore((state) => state.activeChat);
|
||||
|
||||
useEffect(() => {
|
||||
groupCallOpenRef.current = groupCallOpen;
|
||||
groupCallChatIdRef.current = groupCallChatId;
|
||||
@@ -335,6 +337,16 @@ export default function ChatPage() {
|
||||
};
|
||||
}, [user?.id]);
|
||||
|
||||
// Join chat group when activeChat changes
|
||||
useEffect(() => {
|
||||
if (activeChat) {
|
||||
const socket = getSocket();
|
||||
if (socket) {
|
||||
socket.emit('join_chat', activeChat);
|
||||
}
|
||||
}
|
||||
}, [activeChat]);
|
||||
|
||||
const handleStartCall = (targetUser: UserBasic, type: 'voice' | 'video') => {
|
||||
setCallTarget(targetUser);
|
||||
setCallType(type);
|
||||
@@ -371,8 +383,6 @@ export default function ChatPage() {
|
||||
setGroupCallOpen(false);
|
||||
};
|
||||
|
||||
const activeChat = useChatStore((state) => state.activeChat);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
|
||||
@@ -187,12 +187,31 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
// Refs and logic for tracking session's first unread message to show the divider exactly once per load
|
||||
const sessionUnreadRef = useRef<{ chatId: string, msgId: string | null }>({ chatId: '', msgId: null });
|
||||
|
||||
if (activeChat && activeChat !== sessionUnreadRef.current.chatId && !isLoadingMessages) {
|
||||
// Update sessionUnreadRef when chat changes OR when messages are marked as read
|
||||
useEffect(() => {
|
||||
if (!activeChat || isLoadingMessages) return;
|
||||
|
||||
// Reset on chat change
|
||||
if (activeChat !== sessionUnreadRef.current.chatId) {
|
||||
const firstUnreadMsg = chatMessages.find(
|
||||
(m) => m.senderId !== user?.id && !m.readBy?.some((r) => r.userId === user?.id)
|
||||
);
|
||||
sessionUnreadRef.current = { chatId: activeChat, msgId: firstUnreadMsg ? firstUnreadMsg.id : null };
|
||||
} else {
|
||||
// Update if the first unread message was read (msgId no longer exists in unread list)
|
||||
const firstUnreadMsg = chatMessages.find(
|
||||
(m) => m.senderId !== user?.id && !m.readBy?.some((r) => r.userId === user?.id)
|
||||
);
|
||||
if (sessionUnreadRef.current.msgId && !firstUnreadMsg) {
|
||||
// All messages are now read
|
||||
sessionUnreadRef.current.msgId = null;
|
||||
} else if (firstUnreadMsg && sessionUnreadRef.current.msgId !== firstUnreadMsg.id) {
|
||||
// First unread changed (some messages were read)
|
||||
sessionUnreadRef.current.msgId = firstUnreadMsg.id;
|
||||
}
|
||||
}
|
||||
}, [activeChat, chatMessages, user?.id, isLoadingMessages]);
|
||||
|
||||
const firstUnreadId = activeChat === sessionUnreadRef.current.chatId ? sessionUnreadRef.current.msgId : null;
|
||||
const initialScrollChatId = useRef<string | null>(null);
|
||||
|
||||
@@ -573,6 +592,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
{
|
||||
root: scrollContainerRef.current,
|
||||
threshold: 0.1,
|
||||
rootMargin: '0px',
|
||||
}
|
||||
);
|
||||
|
||||
@@ -584,8 +604,27 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
unreadElements.forEach((el: Element) => {
|
||||
const id = el.getAttribute('data-message-id');
|
||||
if (id && !sentReadIdsRef.current.has(id)) {
|
||||
// Check if element is already visible
|
||||
const rect = el.getBoundingClientRect();
|
||||
const containerRect = scrollContainerRef.current!.getBoundingClientRect();
|
||||
const isVisible = rect.top >= containerRect.top && rect.bottom <= containerRect.bottom;
|
||||
|
||||
if (isVisible) {
|
||||
// Mark as read immediately without waiting for intersection
|
||||
const seqId = parseInt(el.getAttribute('data-sequence-id') || '0', 10);
|
||||
if (seqId > 0) {
|
||||
socket.emit('read_messages', {
|
||||
chatId: activeChat,
|
||||
lastReadMessageId: id,
|
||||
lastReadSequenceId: seqId,
|
||||
});
|
||||
useChatStore.getState().markRead(activeChat, user.id, seqId);
|
||||
sentReadIdsRef.current.add(id);
|
||||
}
|
||||
} else {
|
||||
observer.observe(el);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1377,11 +1416,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
</>
|
||||
)}
|
||||
|
||||
{typingInChat.length > 0 && (
|
||||
<div className="px-4 pb-1">
|
||||
<TypingIndicator />
|
||||
</div>
|
||||
)}
|
||||
{/* Typing indicator is already shown in the header, removed from here to prevent layout jumping */}
|
||||
|
||||
{(() => {
|
||||
return (
|
||||
|
||||
@@ -25,14 +25,13 @@ import {
|
||||
PhoneIncoming,
|
||||
PhoneOutgoing,
|
||||
BarChart2,
|
||||
Music,
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '../../../auth/application/authStore';
|
||||
import { useChatStore } from '../../application/chatStore';
|
||||
import { getSocket } from '../../../../core/infrastructure/socket';
|
||||
import { useLang } from '../../../../core/infrastructure/i18n';
|
||||
import { extractWaveform, getMediaUrl, generateAvatarColor, getInitials } from '../../../../core/utils/utils';
|
||||
import { AUDIO_EXTENSIONS, type Message, type MediaItem, type Reaction, type ChatMember } from '../../../../core/domain/types';
|
||||
import type { Message, MediaItem, Reaction, ChatMember } from '../../../../core/domain/types';
|
||||
import ImageLightbox from '../../../../core/presentation/components/ui/ImageLightbox';
|
||||
import LinkPreview from './LinkPreview';
|
||||
import Avatar from '../../../../core/presentation/components/ui/Avatar';
|
||||
@@ -79,11 +78,7 @@ function MessageBubble({
|
||||
const [quotedText, setQuotedText] = useState<string | null>(null);
|
||||
|
||||
// Прочитано
|
||||
// Для своих сообщений: проверено, есть ли в readBy другие пользователи (получатели)
|
||||
// Для чужих сообщений: проверено, есть ли в readBy текущий пользователь
|
||||
const isRead = isMine
|
||||
? message.readBy?.some((r) => r.userId !== user?.id) // Кто-то кроме меня прочитал
|
||||
: message.readBy?.some((r) => r.userId === user?.id); // Я прочитал
|
||||
const isRead = message.readBy?.some((r) => r.userId !== user?.id);
|
||||
|
||||
const timeStr = new Date(message.createdAt).toLocaleTimeString(lang === 'ru' ? 'ru-RU' : 'en-US', {
|
||||
hour: '2-digit',
|
||||
@@ -294,7 +289,7 @@ function MessageBubble({
|
||||
};
|
||||
}, [showContext]);
|
||||
|
||||
if (message.isDeleted || message.isDeletedForUser) {
|
||||
if (message.isDeleted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -360,13 +355,11 @@ function MessageBubble({
|
||||
return false;
|
||||
};
|
||||
|
||||
const isAudioFile = (m: MediaItem) => m.type === 'audio' || AUDIO_EXTENSIONS.some(ext => m.filename?.toLowerCase().endsWith(ext));
|
||||
|
||||
const hasImage = media.some((m) => m.type === 'image' || isMediaGif(m));
|
||||
const hasVoice = message.type === 'voice' || media.some((m) => m.type === 'voice');
|
||||
const hasAudio = !hasVoice && (message.type === 'audio' || media.some(isAudioFile));
|
||||
const hasAudio = !hasVoice && (message.type === 'audio' || media.some((m) => m.type === 'audio'));
|
||||
const hasVideo = media.some((m) => m.type === 'video' && !isMediaGif(m));
|
||||
const hasFile = media.some((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && !isAudioFile(m) && !isMediaGif(m));
|
||||
const hasFile = media.some((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && m.type !== 'audio' && !isMediaGif(m));
|
||||
|
||||
const reactionGroups: Record<string, { count: number; users: string[]; isMine: boolean; avatars: { url?: string | null, initials: string, colorClass?: string }[] }> = {};
|
||||
(message.reactions || []).forEach((r) => {
|
||||
@@ -459,7 +452,7 @@ function MessageBubble({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isMine && (
|
||||
{!isMine && activeChat?.type !== 'personal' && activeChat?.type !== 'favorites' && (
|
||||
<div className="w-8 flex-shrink-0 mr-2 self-end">
|
||||
{showAvatar ? (
|
||||
<button onClick={() => onViewProfile?.(message.senderId)}>
|
||||
@@ -476,7 +469,7 @@ function MessageBubble({
|
||||
)}
|
||||
|
||||
<div className={`max-[500px]:max-w-[85%] max-w-[75%] lg:max-w-[65%] min-w-0 ${isMine ? 'items-end' : 'items-start'} flex flex-col`}>
|
||||
{!isMine && showAvatar && (
|
||||
{!isMine && showAvatar && activeChat?.type !== 'personal' && activeChat?.type !== 'favorites' && (
|
||||
<button
|
||||
className="text-xs font-medium text-knot-400 ml-3 mb-0.5 hover:underline"
|
||||
onClick={() => onViewProfile?.(message.senderId)}
|
||||
@@ -495,7 +488,8 @@ function MessageBubble({
|
||||
onContextMenu={handleContextMenu}
|
||||
onDoubleClick={handleReply}
|
||||
title={t('reply') ? `${t('reply')} (Double Click)` : 'Double click to reply'}
|
||||
className={`cursor-pointer max-w-full min-w-[60px] transition-all duration-500 overflow-hidden ${!needsFrame
|
||||
className={`cursor-pointer max-w-full min-w-[60px] transition-all duration-500 overflow-hidden ${
|
||||
!needsFrame
|
||||
? 'p-0 shadow-none border-none bg-transparent'
|
||||
: isMine
|
||||
? 'bubble-sent px-4 py-3 hover:brightness-110'
|
||||
@@ -506,7 +500,8 @@ function MessageBubble({
|
||||
{/* Reply */}
|
||||
{message.replyTo && (
|
||||
<div
|
||||
className={`mb-2 pl-3 py-2 cursor-pointer transition-all -mx-1 px-2 rounded-xl ${isMine ? 'bg-[#1a1a1a] border-l-[3px] border-l-primary hover:bg-[#202020]' : 'bg-white/5 border-l-[3px] border-l-primary hover:bg-white/10'
|
||||
className={`mb-2 pl-3 py-2 cursor-pointer transition-all -mx-1 px-2 rounded-xl ${
|
||||
isMine ? 'bg-[#1a1a1a] border-l-[3px] border-l-primary hover:bg-[#202020]' : 'bg-white/5 border-l-[3px] border-l-primary hover:bg-white/10'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -567,7 +562,8 @@ function MessageBubble({
|
||||
{/* Story Reply Quote */}
|
||||
{message.storyId && (
|
||||
<div
|
||||
className={`mb-1.5 pl-2.5 py-0.5 border-l-[3px] transition-colors -mx-1 px-1 rounded-sm ${isMine ? 'border-l-white/80 hover:bg-white/10' : 'border-l-knot-500 hover:bg-knot-500/10'
|
||||
className={`mb-1.5 pl-2.5 py-0.5 border-l-[3px] transition-colors -mx-1 px-1 rounded-sm ${
|
||||
isMine ? 'border-l-white/80 hover:bg-white/10' : 'border-l-knot-500 hover:bg-knot-500/10'
|
||||
}`}
|
||||
>
|
||||
<p className={`text-[11px] font-bold uppercase tracking-wider mb-1 ${isMine ? 'text-white/80' : 'text-knot-500/80'}`}>
|
||||
@@ -618,7 +614,8 @@ function MessageBubble({
|
||||
${isSingleGif ? 'max-w-[260px]' : ''}
|
||||
overflow-hidden relative rounded-[1.25rem]
|
||||
`}>
|
||||
<div className={`grid gap-[2px] ${galleryMedia.length > 1 ? 'w-[80vw] sm:w-[380px] md:w-[450px]' : 'w-full'} ${galleryMedia.length === 1 ? 'grid-cols-1' : 'grid-cols-6'
|
||||
<div className={`grid gap-[2px] ${galleryMedia.length > 1 ? 'w-[80vw] sm:w-[380px] md:w-[450px]' : 'w-full'} ${
|
||||
galleryMedia.length === 1 ? 'grid-cols-1' : 'grid-cols-6'
|
||||
}`}>
|
||||
{galleryMedia.map((m, idx) => {
|
||||
const gif = isMediaGif(m);
|
||||
@@ -768,7 +765,7 @@ function MessageBubble({
|
||||
|
||||
{/* Аудио (mp3 файлы) */}
|
||||
{hasAudio && (() => {
|
||||
const audioMedia = media.find(isAudioFile);
|
||||
const audioMedia = media.find((m) => m.type === 'audio');
|
||||
const formatSize = (bytes?: number | null) => {
|
||||
if (!bytes) return "";
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
@@ -858,7 +855,7 @@ function MessageBubble({
|
||||
{/* Файлы */}
|
||||
{hasFile &&
|
||||
media
|
||||
.filter((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && !isAudioFile(m) && m.type !== 'gif')
|
||||
.filter((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && m.type !== 'audio' && m.type !== 'gif')
|
||||
.map((m) => {
|
||||
const formatSize = (bytes?: number | null) => {
|
||||
if (!bytes) return "";
|
||||
@@ -1071,7 +1068,8 @@ function MessageBubble({
|
||||
<button
|
||||
key={emoji}
|
||||
onClick={(e) => { e.stopPropagation(); handleReaction(emoji); }}
|
||||
className={`flex items-center gap-2 px-2.5 py-1.5 rounded-[10px] transition-all border ${data.isMine
|
||||
className={`flex items-center gap-2 px-2.5 py-1.5 rounded-[10px] transition-all border ${
|
||||
data.isMine
|
||||
? 'bg-primary/20 border-primary text-white shadow-lg'
|
||||
: 'bg-[#201F1F] border-white/5 text-zinc-300 hover:bg-[#2a2a2a]'
|
||||
} shadow-md group/react`}
|
||||
@@ -1087,7 +1085,7 @@ function MessageBubble({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isMine && (
|
||||
{isMine && activeChat?.type !== 'personal' && activeChat?.type !== 'favorites' && (
|
||||
<div className="w-8 flex-shrink-0 ml-2 self-end">
|
||||
{showAvatar ? (
|
||||
<button onClick={() => onViewProfile?.(message.senderId)}>
|
||||
|
||||
@@ -331,9 +331,7 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (files.length > 0) {
|
||||
const processFiles = useCallback((files: File[]) => {
|
||||
const { addNotification } = useNotificationStore.getState();
|
||||
const newAttachments: Attachment[] = [];
|
||||
|
||||
@@ -349,8 +347,15 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
tooLarge = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const isAudio = file.type.startsWith('audio/') || AUDIO_EXTENSIONS.some(ext => file.name.toLowerCase().endsWith(ext));
|
||||
newAttachments.push({ file, type: isAudio ? 'audio' : 'file' });
|
||||
|
||||
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') || 'Some files are too large');
|
||||
@@ -358,38 +363,38 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
|
||||
setAttachments(prev => [...prev, ...newAttachments]);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [attachments, t]);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (files.length > 0) processFiles(files);
|
||||
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', t('maxFilesLimit' as any) || 'Maximum 20 files');
|
||||
|
||||
setAttachments(prev => [...prev, ...newAttachments]);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
if (files.length > 0) processFiles(files);
|
||||
e.target.value = '';
|
||||
setShowAttachMenu(false);
|
||||
};
|
||||
|
||||
const handlePaste = (e: React.ClipboardEvent) => {
|
||||
const items = Array.from(e.clipboardData.items);
|
||||
const files = items
|
||||
.filter(item => item.kind === 'file')
|
||||
.map(item => item.getAsFile())
|
||||
.filter((f): f is File => f !== null);
|
||||
|
||||
if (files.length > 0) {
|
||||
processFiles(files);
|
||||
// If we only pasted files, don't paste the filename/text representation in the textarea
|
||||
if (items.every(item => item.kind === 'file')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Запись голосового
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
@@ -569,41 +574,11 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
|
||||
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') || 'Some files are too large');
|
||||
if (limitExceeded) addNotification('warning', t('maxFilesLimit' as any) || 'Maximum 20 files');
|
||||
|
||||
setAttachments(prev => [...prev, ...newAttachments]);
|
||||
inputRef.current?.focus();
|
||||
processFiles(files);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const hasContent = text.trim() || attachments.length > 0;
|
||||
|
||||
return (
|
||||
@@ -856,6 +831,7 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onContextMenu={handleInputContextMenu}
|
||||
onPaste={handlePaste}
|
||||
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') || 'Сообщение...'}
|
||||
|
||||
Reference in New Issue
Block a user