Файлы, правки отображения
This commit is contained in:
@@ -130,8 +130,10 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
useEffect(() => {
|
||||
const el = inputRef.current;
|
||||
if (el) {
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 150) + 'px';
|
||||
el.style.height = '22px'; // Reset/Min height
|
||||
if (text) {
|
||||
el.style.height = Math.min(el.scrollHeight, 150) + 'px';
|
||||
}
|
||||
}
|
||||
}, [text]);
|
||||
|
||||
@@ -616,12 +618,12 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex flex-col justify-center">
|
||||
<p className="text-[11px] font-black tracking-widest uppercase text-primary mb-0.5">
|
||||
<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-on-surface-variant truncate opacity-80 pl-1 border-l border-on-surface-variant/10 ml-0.5">
|
||||
<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>
|
||||
@@ -739,14 +741,14 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-end gap-2 bg-surface-container-high rounded-[2rem] px-4 py-2 w-full max-w-4xl mx-auto shadow-sm transition-all duration-500 focus-within:shadow-xl focus-within:bg-surface-container-highest slide-on-ice">
|
||||
{/* Attach */}
|
||||
<div className="relative mb-0 flex-shrink-0 self-center">
|
||||
<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 hover:text-primary hover:bg-primary/10 transition-all slide-on-ice flex items-center justify-center"
|
||||
className="w-10 h-10 rounded-full text-on-surface-variant/60 hover:text-primary transition-all flex items-center justify-center p-0"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[24px]">add</span>
|
||||
<Paperclip size={24} strokeWidth={1.5} />
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{showAttachMenu && (
|
||||
@@ -780,129 +782,73 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,video/*"
|
||||
className="hidden"
|
||||
onChange={handleImageChange}
|
||||
/>
|
||||
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileChange} />
|
||||
<input ref={imageInputRef} type="file" multiple accept="image/*,video/*" className="hidden" onChange={handleImageChange} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="flex-1 relative align-middle self-center">
|
||||
{/* @Mention popup */}
|
||||
<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-2 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-accent/20 text-white' : 'text-zinc-300 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{m.user.avatarUrl || m.user.avatar ? (
|
||||
<img src={m.user.avatarUrl || m.user.avatar || ''} className="w-7 h-7 rounded-full object-cover" alt="" />
|
||||
) : (
|
||||
<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-xs font-semibold">
|
||||
{/* 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>
|
||||
<p className="text-xs text-zinc-500 truncate">@{m.user.userName || m.user.username || '??'}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setText(val);
|
||||
setDraft(chatId, val);
|
||||
emitTyping();
|
||||
// Detect @mention
|
||||
if (isGroup) {
|
||||
const cursorPos = e.target.selectionStart;
|
||||
const before = val.substring(0, cursorPos);
|
||||
const match = before.match(/@(\w*)$/);
|
||||
if (match) {
|
||||
setMentionQuery(match[1]);
|
||||
setMentionIndex(0);
|
||||
} else {
|
||||
setMentionQuery(null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onPaste={(e) => {
|
||||
if (e.clipboardData.files && e.clipboardData.files.length > 0) {
|
||||
e.preventDefault();
|
||||
const files = Array.from(e.clipboardData.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 isAudio = file.type.startsWith('audio/') || AUDIO_EXTENSIONS.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]);
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onContextMenu={handleInputContextMenu}
|
||||
className="w-full bg-transparent border-none focus:ring-0 text-on-surface placeholder-on-surface-variant/30 text-[15px] leading-relaxed resize-none max-h-[150px] custom-scrollbar scroll-smooth outline-none py-1"
|
||||
placeholder={attachments.length > 0 ? t('addCaption') : t('message')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-1 self-center pb-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setShowEmoji(!showEmoji)}
|
||||
className="p-2 rounded-full text-zinc-400 hover:text-accent hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<Smile size={22} strokeWidth={1.5} />
|
||||
</button>
|
||||
<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 right-0 bottom-[calc(100%+12px)]">
|
||||
<div className="absolute left-0 bottom-[calc(100%+20px)]">
|
||||
<EmojiPicker
|
||||
onSelect={(emoji) => {
|
||||
setText((prev) => {
|
||||
@@ -919,16 +865,8 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
chatId,
|
||||
content: null,
|
||||
type: 'image',
|
||||
attachments: [{
|
||||
type: 'image',
|
||||
url: gifUrl,
|
||||
fileName: 'gif.gif',
|
||||
fileSize: 0,
|
||||
}],
|
||||
replyToId: replyTo?.id || null,
|
||||
quote: replyTo?.quote || null,
|
||||
attachments: [{ type: 'image', url: gifUrl, fileName: 'gif.gif', fileSize: 0 }],
|
||||
});
|
||||
setReplyTo(null);
|
||||
}
|
||||
setShowEmoji(false);
|
||||
}}
|
||||
@@ -939,21 +877,17 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Send / Mic */}
|
||||
<div className="flex-shrink-0 self-center relative flex items-center">
|
||||
{/* Send / Mic - Circular button outside pill */}
|
||||
<div className="flex-shrink-0 relative">
|
||||
{hasContent ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleSend()}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setScheduleStep('presets');
|
||||
setShowSchedule(true);
|
||||
}}
|
||||
onContextMenu={(e) => { e.preventDefault(); setScheduleStep('presets'); setShowSchedule(true); }}
|
||||
disabled={isSending}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-full bg-accent hover:bg-accent-hover transition-colors text-white disabled:opacity-50 shadow-md transform hover:scale-105"
|
||||
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={16} className="translate-x-[1px] translate-y-[1px]" />
|
||||
<Send size={20} strokeWidth={2.5} className="translate-x-[1px]" />
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{showSchedule && (
|
||||
@@ -965,7 +899,6 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
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"
|
||||
>
|
||||
{/* Header */}
|
||||
<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">
|
||||
@@ -978,7 +911,6 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
|
||||
{scheduleStep === 'presets' ? (
|
||||
<div className="p-2 space-y-1">
|
||||
{/* Preset: 1 hour */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const d = new Date(Date.now() + 3600000);
|
||||
@@ -991,7 +923,6 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
<Clock size={15} className="text-zinc-400 flex-shrink-0" />
|
||||
{t('scheduleIn1h')}
|
||||
</button>
|
||||
{/* Preset: 3 hours */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const d = new Date(Date.now() + 3 * 3600000);
|
||||
@@ -1004,12 +935,9 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
<Clock size={15} className="text-zinc-400 flex-shrink-0" />
|
||||
{t('scheduleIn3h')}
|
||||
</button>
|
||||
{/* Preset: Tomorrow 9:00 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 1);
|
||||
d.setHours(9, 0, 0, 0);
|
||||
const d = new Date(); d.setDate(d.getDate() + 1); d.setHours(9, 0, 0, 0);
|
||||
handleSend(d.toISOString());
|
||||
setShowSchedule(false);
|
||||
setScheduleToast(d.toLocaleString());
|
||||
@@ -1020,7 +948,6 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
{t('scheduleTomorrow')}
|
||||
</button>
|
||||
<div className="border-t border-white/5 my-1" />
|
||||
{/* Custom */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const now = new Date();
|
||||
@@ -1041,21 +968,12 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
</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());
|
||||
}}
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
@@ -1067,7 +985,7 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
) : (
|
||||
<button
|
||||
onClick={startRecording}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-full text-zinc-400 hover:text-accent hover:bg-surface-hover transition-colors"
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user