Compare commits
4
Commits
63fc0e197b
...
72325f48e5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72325f48e5 | ||
|
|
2b51375fbf | ||
|
|
c92289f074 | ||
|
|
33ea792941 |
@@ -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" />
|
||||
|
||||
@@ -1377,11 +1377,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 (
|
||||
|
||||
@@ -459,7 +459,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 +476,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)}
|
||||
@@ -612,9 +612,9 @@ function MessageBubble({
|
||||
const isSingleGif = galleryMedia.length === 1 && isMediaGif(galleryMedia[0]);
|
||||
const hasReactions = Object.keys(reactionGroups).length > 0;
|
||||
|
||||
return (
|
||||
<div className={`
|
||||
${needsFrame ? `-mx-[14px] ${message.forwardedFrom || message.replyTo || message.storyId ? 'mt-2' : '-mt-[8px]'} ${message.content ? 'mb-2' : hasReactions ? 'mb-1' : '-mb-[8px]'}` : ''}
|
||||
return (
|
||||
<div className={`
|
||||
${needsFrame ? `-mx-[14px] ${message.forwardedFrom || message.replyTo || message.storyId ? 'mt-2' : '-mt-[8px]'} ${message.content || hasVoice || hasAudio || hasFile || message.type === 'poll' ? 'mb-3' : hasReactions ? 'mb-1' : '-mb-[8px]'}` : ''}
|
||||
${isSingleGif ? 'max-w-[260px]' : ''}
|
||||
overflow-hidden relative rounded-[1.25rem]
|
||||
`}>
|
||||
@@ -703,6 +703,89 @@ function MessageBubble({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Голосовое - Optimized Kinetic Layout */}
|
||||
{hasVoice && (
|
||||
<div className={`flex items-center gap-3 min-w-[200px] py-0.5 ${hasImage || hasVideo || message.forwardedFrom || message.replyTo || message.storyId ? 'mt-3' : ''}`}>
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={media.find((m) => m.type === 'voice')?.url}
|
||||
preload="auto"
|
||||
/>
|
||||
<button
|
||||
onClick={toggleAudio}
|
||||
className={`w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0 ${isMine ? 'bg-white text-primary' : 'bg-primary text-white'} shadow-sm transition-all active:scale-95`}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause size={16} fill="currentColor" />
|
||||
) : (
|
||||
<Play size={16} fill="currentColor" className="ml-0.5" />
|
||||
)}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="flex items-center gap-[2px] h-6 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || !audio.duration) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const pct = (e.clientX - rect.left) / rect.width;
|
||||
audio.currentTime = pct * audio.duration;
|
||||
setAudioProgress(pct * 100);
|
||||
if (!isPlaying) toggleAudio();
|
||||
}}
|
||||
>
|
||||
{(waveformBars || Array(28).fill(0.5)).map((val, i) => {
|
||||
const barHeight = Math.max(10, val * 85);
|
||||
const progress = audioProgress / 100;
|
||||
const barProgress = i / 28;
|
||||
const isActive = barProgress < progress;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex-1 rounded-full transition-all duration-200 ${isActive
|
||||
? isMine ? 'bg-[#000000] opacity-70' : 'bg-primary'
|
||||
: isMine ? 'bg-[#000000] opacity-20' : 'bg-white/30'
|
||||
}`}
|
||||
style={{ height: `${barHeight}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-end mt-0.5">
|
||||
<span className={`text-[10px] font-bold tabular-nums ${isMine ? 'text-[#0a0a0a]/60' : 'text-white/60'}`}>
|
||||
{isPlaying
|
||||
? formatDuration(audioRef.current?.currentTime || 0)
|
||||
: formatDuration(audioDuration || message.media?.find((m) => m.type === 'voice')?.duration || 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Аудио (mp3 файлы) */}
|
||||
{hasAudio && (() => {
|
||||
const audioMedia = media.find(isAudioFile);
|
||||
const formatSize = (bytes?: number | null) => {
|
||||
if (!bytes) return "";
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
||||
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
||||
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + " GB";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`min-w-[220px] ${hasImage || hasVideo || hasVoice || message.forwardedFrom || message.replyTo || message.storyId ? 'mt-3' : ''}`}>
|
||||
{audioMedia?.filename && (
|
||||
<div className="flex items-center gap-2 mb-2 min-w-0">
|
||||
<Volume2 size={14} className={isMine ? 'text-[#0a0a0a]/60' : 'text-zinc-400'} />
|
||||
<span className={`text-[11px] font-bold truncate ${isMine ? 'text-[#0a0a0a]/80' : 'text-zinc-200'}`}>{audioMedia.filename}</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
@@ -778,78 +861,18 @@ function MessageBubble({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-w-[220px]">
|
||||
{audioMedia?.filename && (
|
||||
<div className="flex items-center gap-2 mb-2 min-w-0">
|
||||
<Volume2 size={14} className={isMine ? 'text-[#0a0a0a]/60' : 'text-zinc-400'} />
|
||||
<span className={`text-[11px] font-bold truncate ${isMine ? 'text-[#0a0a0a]/80' : 'text-zinc-200'}`}>{audioMedia.filename}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={getMediaUrl(audioMedia?.url)}
|
||||
preload="auto"
|
||||
/>
|
||||
<button
|
||||
onClick={toggleAudio}
|
||||
className={`w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0 ${isMine ? 'bg-[#0a0a0a]/10 hover:bg-[#0a0a0a]/20 text-[#0a0a0a]' : 'bg-primary text-white shadow-lg'} transition-all active:scale-95`}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause size={16} fill="currentColor" />
|
||||
) : (
|
||||
<Play size={16} fill="currentColor" className="ml-0.5" />
|
||||
)}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-[2px] h-6 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || !audio.duration) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const pct = (e.clientX - rect.left) / rect.width;
|
||||
audio.currentTime = pct * audio.duration;
|
||||
setAudioProgress(pct * 100);
|
||||
}}>
|
||||
{Array.from({ length: 28 }).map((_, i) => {
|
||||
const barHeight = [40, 65, 35, 80, 50, 90, 45, 70, 55, 85, 30, 75, 60, 95, 40, 80, 50, 70, 35, 90, 55, 65, 45, 85, 60, 75, 50, 40][i] || 50;
|
||||
const progress = audioProgress / 100;
|
||||
const barProgress = i / 28;
|
||||
const isActive = barProgress < progress;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex-1 rounded-full transition-all duration-150 ${isActive
|
||||
? isMine ? 'bg-[#0a0a0a]/70' : 'bg-primary'
|
||||
: isMine ? 'bg-[#0a0a0a]/10' : 'bg-white/20'
|
||||
}`}
|
||||
style={{ height: `${barHeight}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-0.5">
|
||||
<span className={`text-[10px] font-bold tabular-nums ${isMine ? 'text-[#0a0a0a]/60' : 'text-zinc-500'}`}>
|
||||
{isPlaying
|
||||
? formatDuration(audioRef.current?.currentTime || 0)
|
||||
: (typeof audioMedia?.duration === 'number'
|
||||
? formatDuration(audioMedia.duration)
|
||||
: (audioMedia?.duration || formatDuration(audioDuration || 0)))}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[10px] font-black uppercase tracking-tighter ${isMine ? 'text-[#0a0a0a]/40' : 'text-zinc-500'}`}>{formatSize(audioMedia?.size)}</span>
|
||||
<a
|
||||
href={getMediaUrl(audioMedia?.url)}
|
||||
download={audioMedia?.filename || 'audio'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`flex items-center justify-center p-1 rounded-md transition-all ${isMine ? 'hover:bg-[#0a0a0a]/10 text-[#0a0a0a]/40 hover:text-[#0a0a0a]' : 'hover:bg-white/10 text-zinc-500 hover:text-white'}`}
|
||||
>
|
||||
<Download size={12} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
key={m.id}
|
||||
href={getMediaUrl(m.url)}
|
||||
download={m.filename || 'file'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`flex items-center gap-3 p-3 rounded-2xl ${isMine ? 'bg-[#0a0a0a]/5 hover:bg-[#0a0a0a]/10' : 'bg-zinc-900/50 hover:bg-zinc-800/80 border border-white/5'
|
||||
} transition-all mb-1 group/file ${hasImage || hasVideo || hasVoice || hasAudio || message.forwardedFrom || message.replyTo || message.storyId ? 'mt-3' : ''}`}
|
||||
>
|
||||
<div className={`w-11 h-11 rounded-xl flex items-center justify-center ${isMine ? 'bg-[#0a0a0a]/10' : 'bg-primary/20'
|
||||
} group-hover/file:scale-110 transition-transform`}>
|
||||
<FileText size={22} className={isMine ? 'text-[#0a0a0a]' : 'text-primary'} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1029,33 +1052,19 @@ function MessageBubble({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Текст */}
|
||||
{message.content && message.type !== 'poll' && (() => {
|
||||
const onlyEmojiRegex = /^[\p{Extended_Pictographic}\s]+$/u;
|
||||
const isOnlyEmojis = onlyEmojiRegex.test(message.content) && message.content.length <= 15;
|
||||
return (
|
||||
<div className="flex items-end gap-2 text-sm w-full">
|
||||
<div className="flex-1 min-w-0 w-full">
|
||||
<p className={`whitespace-pre-wrap break-words leading-relaxed w-full ${isOnlyEmojis ? 'text-5xl my-1' : ''} ${isMine ? 'text-[#0a0a0a] font-normal' : 'text-zinc-200'}`}>
|
||||
{renderFormattedText(message.content)}
|
||||
</p>
|
||||
{firstUrl && !hasImage && !hasVideo && !hasFile && (
|
||||
<div className="w-full mt-1 mb-1 relative overflow-hidden">
|
||||
<LinkPreview url={firstUrl} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-[10px] font-bold flex-shrink-0 flex items-center gap-0.5 self-end float-right leading-none ${isOnlyEmojis ? '-mb-1' : 'mb-0.5'} ${isMine ? 'text-[#0a0a0a]/50' : 'text-on-surface-variant/40'}`}>
|
||||
{message.isEdited && <span className="mr-0.5">{t('edited')}</span>}
|
||||
{message.scheduledAt && <span className="material-symbols-outlined text-[12px] text-amber-400 mr-0.5">schedule</span>}
|
||||
{isPinned && <Pin size={10} className={`rotate-45 ${isMine ? 'text-[#0a0a0a]/60 fill-[#0a0a0a]/20' : 'text-primary fill-primary/20'} mr-0.5`} />}
|
||||
{timeStr}
|
||||
{isMine && !message.scheduledAt && (
|
||||
<span className={`material-symbols-outlined text-[14px] ${isRead ? 'text-[#0a0a0a]/80 fill-1' : 'text-[#0a0a0a]/40'}`} style={{ fontVariationSettings: `'FILL' ${isRead ? 1 : 0}` }}>
|
||||
{isRead ? 'done_all' : 'done'}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{/* Текст */}
|
||||
{message.content && message.type !== 'poll' && (() => {
|
||||
const onlyEmojiRegex = /^[\p{Extended_Pictographic}\s]+$/u;
|
||||
const isOnlyEmojis = onlyEmojiRegex.test(message.content) && message.content.length <= 15;
|
||||
return (
|
||||
<div className={`flex items-end gap-2 text-sm w-full ${hasImage || hasVideo || hasVoice || hasAudio || hasFile || message.forwardedFrom || message.replyTo || message.storyId ? 'mt-3' : ''}`}>
|
||||
<div className="flex-1 min-w-0 w-full">
|
||||
<p className={`whitespace-pre-wrap break-words leading-relaxed w-full ${isOnlyEmojis ? 'text-5xl my-1' : ''} ${isMine ? 'text-[#0a0a0a] font-normal' : 'text-zinc-200'}`}>
|
||||
{renderFormattedText(message.content)}
|
||||
</p>
|
||||
{firstUrl && !hasImage && !hasVideo && !hasFile && (
|
||||
<div className="w-full mt-1 mb-1 relative overflow-hidden">
|
||||
<LinkPreview url={firstUrl} />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
@@ -1087,7 +1096,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,65 +331,70 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (files.length > 0) {
|
||||
const { addNotification } = useNotificationStore.getState();
|
||||
const newAttachments: Attachment[] = [];
|
||||
const processFiles = useCallback((files: File[]) => {
|
||||
const { addNotification } = useNotificationStore.getState();
|
||||
const newAttachments: Attachment[] = [];
|
||||
|
||||
let tooLarge = false;
|
||||
let limitExceeded = false;
|
||||
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' });
|
||||
for (const file of files) {
|
||||
if (attachments.length + newAttachments.length >= 20) {
|
||||
limitExceeded = true;
|
||||
break;
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
tooLarge = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tooLarge) addNotification('warning', t('fileTooLarge') || 'Some files are too large');
|
||||
if (limitExceeded) addNotification('warning', t('maxFilesLimit' as any) || 'Maximum 20 files');
|
||||
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));
|
||||
|
||||
setAttachments(prev => [...prev, ...newAttachments]);
|
||||
inputRef.current?.focus();
|
||||
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();
|
||||
}, [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