Удалены чувствительные данные

This commit is contained in:
Халимов Рустам
2026-03-12 16:01:41 +03:00
parent d025c98e61
commit 782506ccc3
200 changed files with 484563 additions and 4565 deletions
+11 -7
View File
@@ -3,6 +3,7 @@ import { AnimatePresence } from 'framer-motion';
import { useAuthStore } from './stores/authStore';
import AuthPage from './pages/AuthPage';
import ChatPage from './pages/ChatPage';
import NotificationProvider from './components/NotificationProvider';
export default function App() {
const { token, user, checkAuth, isLoading } = useAuthStore();
@@ -23,13 +24,16 @@ export default function App() {
}
return (
<AnimatePresence mode="wait">
{token && user ? (
<ChatPage key="chat" />
) : (
<AuthPage key="auth" />
)}
</AnimatePresence>
<>
<AnimatePresence mode="wait">
{token && user ? (
<ChatPage key="chat" />
) : (
<AuthPage key="auth" />
)}
</AnimatePresence>
<NotificationProvider />
</>
);
}
+12 -6
View File
@@ -394,11 +394,13 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
chatId: targetChatId,
content: msg?.content,
type: msg?.type,
forwardedFromId: msg?.sender.id,
mediaUrl: msg?.media?.[0]?.url,
mediaType: msg?.media?.[0]?.type,
fileName: msg?.media?.[0]?.filename,
fileSize: msg?.media?.[0]?.size ?? undefined,
forwardedFromId: msg?.forwardedFromId || msg?.sender.id,
attachments: msg?.media?.map(m => ({
type: m.type,
url: m.url,
fileName: m.filename,
fileSize: m.size
})) || [],
});
});
@@ -406,6 +408,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
setSelectedMessages(new Set());
setShowForwardModal(false);
setActiveChat(targetChatId);
useChatStore.getState().loadMessages(targetChatId);
};
const handleBulkDelete = (deleteForAll: boolean) => {
@@ -837,7 +840,6 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
return (
<div
key={msg.id}
id={`msg-${msg.id}`}
data-message-id={msg.id}
className={`transition-colors duration-500 ${msg.senderId !== user?.id && !msg.readBy?.some(r => r.userId === user?.id) ? 'unread-detector' : ''}`}
>
@@ -860,6 +862,10 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
isSelected={selectedMessages.has(msg.id)}
onToggleSelect={handleToggleSelect}
onStartSelectionMode={handleStartSelection}
onForward={(id) => {
setSelectedMessages(new Set([id]));
setShowForwardModal(true);
}}
/>
</div>
);
+19 -10
View File
@@ -17,13 +17,20 @@ export default function ForwardModal({ onClose, onForward }: ForwardModalProps)
const { t } = useLang();
const [search, setSearch] = useState('');
const filteredChats = chats.filter((chat) => {
const otherMember = chat.members.find((m) => m.userId !== user?.id);
const chatName = chat.type === 'personal'
? otherMember?.user.displayName || otherMember?.user.username || t('chat')
: chat.name || t('group');
return chatName.toLowerCase().includes(search.toLowerCase());
});
const filteredChats = chats
.filter((chat) => {
const otherMember = chat.members.find((m) => m.userId !== user?.id);
const chatName = chat.type === 'personal'
? otherMember?.user.displayName || otherMember?.user.username || t('favorites')
: chat.name || t('group');
const finalName = chat.type === 'favorites' ? t('favorites') : chatName;
return finalName.toLowerCase().includes(search.toLowerCase());
})
.sort((a, b) => {
if (a.type === 'favorites') return -1;
if (b.type === 'favorites') return 1;
return 0;
});
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
@@ -68,9 +75,11 @@ export default function ForwardModal({ onClose, onForward }: ForwardModalProps)
<div className="max-h-80 overflow-y-auto space-y-1 pr-2 custom-scrollbar">
{filteredChats.map((chat) => {
const otherMember = chat.members.find((m) => m.userId !== user?.id);
const chatName = chat.type === 'personal'
? otherMember?.user.displayName || otherMember?.user.username || t('chat')
: chat.name || t('group');
const chatName =
chat.type === 'favorites' ? t('favorites') :
chat.type === 'personal'
? otherMember?.user.displayName || otherMember?.user.username || t('chat')
: chat.name || t('group');
const chatAvatar = chat.type === 'personal'
? otherMember?.user.avatar
: chat.avatar;
+103 -64
View File
@@ -18,6 +18,7 @@ import {
Volume2,
Pin,
Clock,
Forward,
} from 'lucide-react';
import { useAuthStore } from '../stores/authStore';
import { useChatStore } from '../stores/chatStore';
@@ -36,6 +37,7 @@ interface MessageBubbleProps {
isSelected?: boolean;
onToggleSelect?: (id: string) => void;
onStartSelectionMode?: (id: string) => void;
onForward?: (id: string) => void;
}
function MessageBubble({
@@ -46,7 +48,8 @@ function MessageBubble({
selectionMode,
isSelected,
onToggleSelect,
onStartSelectionMode
onStartSelectionMode,
onForward
}: MessageBubbleProps) {
const { user } = useAuthStore();
const { setReplyTo, setEditingMessage, pinnedMessages, chats } = useChatStore();
@@ -54,7 +57,7 @@ function MessageBubble({
const [showContext, setShowContext] = useState(false);
const [contextPos, setContextPos] = useState({ x: 0, y: 0 });
const [deleteMenuMode, setDeleteMenuMode] = useState(false);
const [lightboxUrl, setLightboxUrl] = useState<string | null>(null);
const [lightboxData, setLightboxData] = useState<{ index: number } | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [audioProgress, setAudioProgress] = useState(0);
const [audioDuration, setAudioDuration] = useState(0);
@@ -73,7 +76,7 @@ function MessageBubble({
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation(); // Avoid triggering window listener instantly for other menus
e.stopPropagation();
if (selectionMode) {
onToggleSelect?.(message.id);
return;
@@ -81,7 +84,6 @@ function MessageBubble({
const rect = bubbleRef.current?.getBoundingClientRect();
if (!rect) return;
// Check if text is selected inside this bubble
const selection = window.getSelection();
const text = selection?.toString().trim();
if (text && bubbleRef.current?.contains(selection?.anchorNode || null)) {
@@ -91,7 +93,7 @@ function MessageBubble({
}
const menuWidth = 208;
const menuHeight = 350; // estimate
const menuHeight = 350;
let x = e.clientX;
let y = e.clientY;
@@ -142,13 +144,11 @@ function MessageBubble({
deleteForAll: false,
});
}
// Optimistic hide
useChatStore.getState().hideMessages([message.id], message.chatId);
setShowContext(false);
setDeleteMenuMode(false);
};
// Имя собеседника для кнопки «Удалить также для ...»
const chatForDelete = chats.find(c => c.id === message.chatId);
const otherMemberName = chatForDelete?.type === 'personal'
? chatForDelete.members.find(m => m.user.id !== user?.id)?.user.displayName
@@ -185,7 +185,6 @@ function MessageBubble({
setShowContext(false);
};
// Аудио плеер
const toggleAudio = () => {
const audio = audioRef.current;
if (!audio) return;
@@ -194,7 +193,6 @@ function MessageBubble({
audio.pause();
setIsPlaying(false);
} else {
// Ensure audio is loaded before playing
if (audio.readyState < 2) {
audio.load();
}
@@ -202,7 +200,6 @@ function MessageBubble({
setIsPlaying(true);
}).catch((err) => {
console.error('Audio play error:', err);
// Try reloading and playing again
audio.load();
audio.play().then(() => setIsPlaying(true)).catch(console.error);
});
@@ -239,7 +236,6 @@ function MessageBubble({
};
}, []);
// Extract real waveform from voice audio
useEffect(() => {
const voiceUrl = message.media?.find((m) => m.type === 'voice')?.url;
if (!voiceUrl) return;
@@ -253,13 +249,11 @@ function MessageBubble({
return `${m}:${s.toString().padStart(2, '0')}`;
};
// Close context menu logic
const contextMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!showContext) return;
const hideMenu = (e: MouseEvent) => {
// Don't close if clicking inside the context menu
if (contextMenuRef.current?.contains(e.target as Node)) {
return;
}
@@ -274,7 +268,6 @@ function MessageBubble({
};
}, [showContext]);
// Deleted message — auto-hide after 5 seconds
const [deletedVisible, setDeletedVisible] = useState(true);
useEffect(() => {
if (message.isDeleted) {
@@ -306,7 +299,6 @@ function MessageBubble({
const hasFile = media.some((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && m.type !== 'audio');
const hasVideo = media.some((m) => m.type === 'video');
// Группировка реакций
const reactionGroups: Record<string, { count: number; users: string[]; isMine: boolean }> = {};
(message.reactions || []).forEach((r) => {
if (!reactionGroups[r.emoji]) {
@@ -320,10 +312,8 @@ function MessageBubble({
const senderName = message.sender?.displayName || message.sender?.username || '';
const senderAvatar = message.sender?.avatar;
// Simple Markdown formatter
const renderFormattedText = (text: string) => {
if (!text) return text;
// Split by *, _, ~, ` blocks and @mentions while keeping the delimiters
const parts = text.split(/(\*\*[\s\S]*?\*\*|\*[\s\S]*?\*|_[\s\S]*?_|~[\s\S]*?~|`[\s\S]*?`|@\w+)/g);
return parts.map((part, i) => {
@@ -342,7 +332,6 @@ function MessageBubble({
className="font-semibold text-sky-300 cursor-pointer hover:underline"
onClick={(e) => {
e.stopPropagation();
// Find userId by username from chat members in store
const chat = chats.find(c => c.id === message.chatId);
const members = chat?.members || [];
const found = members.find((m) => m.user?.username === mentionUsername);
@@ -368,7 +357,6 @@ function MessageBubble({
}}
onContextMenu={handleContextMenu}
>
{/* Selection Checkbox */}
{selectionMode && (
<div className="absolute left-1 top-1/2 -translate-y-1/2 w-5 h-5 rounded-full border border-white/30 flex items-center justify-center transition-colors">
{isSelected && <div className="w-5 h-5 rounded-full bg-vortex-500 flex items-center justify-center">
@@ -377,7 +365,6 @@ function MessageBubble({
</div>
)}
{/* Аватар (чужие) */}
{!isMine && (
<div className="w-8 flex-shrink-0 mr-2 self-end">
{showAvatar ? (
@@ -395,7 +382,6 @@ function MessageBubble({
)}
<div className={`max-w-[65%] ${isMine ? 'items-end' : 'items-start'} flex flex-col`}>
{/* Имя отправителя (для групп) */}
{!isMine && showAvatar && (
<button
className="text-xs font-medium text-vortex-400 ml-3 mb-0.5 hover:underline"
@@ -407,16 +393,40 @@ function MessageBubble({
{/* Reply */}
{message.replyTo && (
<div className={`mx-3 mb-1 px-3 py-1.5 rounded-lg border-l-2 border-vortex-500 bg-vortex-500/10 max-w-full`}>
<div
className="mx-3 mb-1 px-3 py-1.5 rounded-lg border-l-2 border-vortex-500 bg-vortex-500/10 max-w-full cursor-pointer hover:bg-vortex-500/20 transition-colors"
onClick={() => {
const el = document.getElementById(`msg-${message.replyToId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('highlight-message');
setTimeout(() => el.classList.remove('highlight-message'), 2000);
}
}}
>
<p className="text-xs font-medium text-vortex-400 truncate">
{message.replyTo.sender?.displayName || message.replyTo.sender?.username}
</p>
<p className="text-xs text-zinc-400 truncate">{message.quote || message.replyTo.content || t('media')}</p>
<div className="flex items-center gap-1">
{message.replyTo.media && message.replyTo.media.length > 0 && !message.quote && (
<div className="w-8 h-8 rounded bg-black/20 overflow-hidden flex-shrink-0">
{message.replyTo.media[0].type === 'image' ? (
<img src={message.replyTo.media[0].url} className="w-full h-full object-cover" alt="" />
) : message.replyTo.media[0].type === 'video' ? (
<div className="w-full h-full flex items-center justify-center bg-black/40"><Play size={10} className="text-white" /></div>
) : (
<div className="w-full h-full flex items-center justify-center"><FileText size={10} className="text-zinc-500" /></div>
)}
</div>
)}
<p className="text-xs text-zinc-400 truncate">{message.quote || message.replyTo.content || (message.replyTo.media && message.replyTo.media.length > 0 ? t('media') : '')}</p>
</div>
</div>
)}
{/* Пузырь */}
<div
id={`msg-${message.id}`}
onContextMenu={handleContextMenu}
onDoubleClick={handleReply}
title={t('reply') ? `${t('reply')} (Double Click)` : 'Double click to reply'}
@@ -430,43 +440,64 @@ function MessageBubble({
>
{/* Рендер пересланного сообщения */}
{message.forwardedFrom && (
<div className="mb-2 text-xs opacity-90 border-l-[3px] border-white/30 pl-2">
<span className="font-medium">{t('forwardedFrom')}: </span>
{message.forwardedFrom.displayName || message.forwardedFrom.username}
<div
className="mb-2 text-[13px] opacity-90 border-l-[2px] border-white/40 pl-3 py-0.5 cursor-pointer hover:bg-white/5 transition-colors -mx-1 px-1 rounded-sm"
onClick={() => onViewProfile?.(message.forwardedFromId!)}
>
<div className="text-[11px] font-bold uppercase tracking-wider opacity-70 leading-none mb-1">
{t('forwardedFrom')}
</div>
<div className="font-semibold text-accent-light">
{message.forwardedFrom.displayName || message.forwardedFrom.username}
</div>
</div>
)}
{/* Изображения */}
{hasImage && (
<div className={`${message.content ? 'mb-2 -mx-3 -mt-2' : ''} ${!message.content ? 'rounded-[1.25rem]' : ''} bg-black/40 overflow-hidden`}>
{media
.filter((m) => m.type === 'image')
.map((m) => (
<img
key={m.id}
src={m.url}
alt=""
className="max-w-full max-h-80 object-cover cursor-pointer hover:brightness-90 transition-all"
onClick={() => setLightboxUrl(m.url)}
/>
))}
{/* Изображения и Видео (Галерея) */}
{(hasImage || hasVideo) && (
<div className={`${message.content ? 'mb-2 -mx-4 -mt-2.5' : ''} bg-black/20 overflow-hidden`}>
<div className={`grid gap-[2px] ${
media.filter(m => m.type === 'image' || m.type === 'video').length >= 3
? 'grid-cols-3'
: media.filter(m => m.type === 'image' || m.type === 'video').length === 2
? 'grid-cols-2'
: 'grid-cols-1'
}`}>
{media
.filter((m) => m.type === 'image' || m.type === 'video')
.map((m, idx) => (
m.type === 'image' ? (
<img
key={m.id}
src={m.url}
alt=""
className={`w-full h-full object-cover cursor-pointer hover:brightness-90 transition-all ${
media.filter(i => i.type === 'image' || i.type === 'video').length > 1 ? 'aspect-square' : 'max-h-[500px]'
}`}
onClick={() => setLightboxData({ index: idx })}
/>
) : (
<div
key={m.id}
className={`relative cursor-pointer group/video ${
media.filter(i => i.type === 'image' || i.type === 'video').length > 1 ? 'aspect-square' : ''
}`}
onClick={() => setLightboxData({ index: idx })}
>
<video
src={m.url}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/20 group-hover/video:bg-black/40 transition-colors">
<Play size={media.filter(i => i.type === 'image' || i.type === 'video').length > 1 ? 24 : 48} className="text-white opacity-80" />
</div>
</div>
)
))}
</div>
</div>
)}
{/* Видео */}
{hasVideo &&
media
.filter((m) => m.type === 'video')
.map((m) => (
<div key={m.id} className={`${message.content ? 'mb-2 -mx-3 -mt-2' : ''}`}>
<video
src={m.url}
controls
className="max-w-full max-h-80 rounded-lg"
/>
</div>
))}
{/* Голосовое */}
{hasVoice && (
<div className="flex items-center gap-3 min-w-[200px]">
@@ -488,7 +519,6 @@ function MessageBubble({
)}
</button>
<div className="flex-1 min-w-0">
{/* Waveform visualization */}
<div
className="flex items-end gap-[2px] h-6 cursor-pointer"
onClick={(e) => {
@@ -589,7 +619,7 @@ function MessageBubble({
{/* Файлы */}
{hasFile &&
media
.filter((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video')
.filter((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && m.type !== 'audio')
.map((m) => (
<a
key={m.id}
@@ -636,7 +666,6 @@ function MessageBubble({
</div>
)}
{/* Время для медиа без текста */}
{!message.content && (hasImage || hasVideo) && (
<div className={`flex justify-end px-3 py-1 ${hasImage ? '-mt-8 relative z-10' : ''}`}>
<span className="text-[10px] text-white/70 bg-black/40 px-2 py-0.5 rounded-full flex items-center gap-1 backdrop-blur-sm">
@@ -674,7 +703,6 @@ function MessageBubble({
)}
</div>
{/* Аватар (свои) */}
{isMine && (
<div className="w-8 flex-shrink-0 ml-2 self-end">
{showAvatar ? (
@@ -692,7 +720,6 @@ function MessageBubble({
)}
</div>
{/* Контекстное меню */}
{typeof document !== 'undefined' && createPortal(
<AnimatePresence>
{showContext && (
@@ -711,7 +738,6 @@ function MessageBubble({
>
{deleteMenuMode ? (
<>
{/* Delete submenu */}
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border">
<button
onClick={() => setDeleteMenuMode(false)}
@@ -740,7 +766,6 @@ function MessageBubble({
</>
) : (
<>
{/* Быстрые реакции */}
<div className="flex items-center gap-1 px-3 py-2 border-b border-border">
{['👍', '❤️', '😂', '😮', '😢', '🔥'].map((emoji) => (
<button
@@ -771,6 +796,17 @@ function MessageBubble({
<CheckCheck size={16} />
{t('select')}
</button>
<button
onClick={() => {
setShowContext(false);
onForward?.(message.id);
}}
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
>
<Forward size={16} />
{t('forward')}
</button>
<button
onClick={handlePin}
@@ -816,10 +852,13 @@ function MessageBubble({
document.body
)}
{/* Lightbox */}
<AnimatePresence>
{lightboxUrl && (
<ImageLightbox url={lightboxUrl} onClose={() => setLightboxUrl(null)} />
{lightboxData && (
<ImageLightbox
images={media.filter(m => m.type === 'image' || m.type === 'video').map(m => ({ url: m.url, type: m.type }))}
initialIndex={lightboxData.index}
onClose={() => setLightboxData(null)}
/>
)}
</AnimatePresence>
</>
+171 -75
View File
@@ -23,6 +23,7 @@ import { api } from '../lib/api';
import { getSocket } from '../lib/socket';
import { useLang } from '../lib/i18n';
import { AUDIO_EXTENSIONS, MAX_FILE_SIZE } from '../lib/types';
import { useNotificationStore } from '../stores/notificationStore';
import EmojiPicker from './EmojiPicker';
interface Attachment {
@@ -49,7 +50,7 @@ export default function MessageInput({ chatId }: MessageInputProps) {
const [isRecording, setIsRecording] = useState(false);
const [recordingTime, setRecordingTime] = useState(0);
const [showAttachMenu, setShowAttachMenu] = useState(false);
const [attachment, setAttachment] = useState<Attachment | null>(null);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [isSending, setIsSending] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [formatMenu, setFormatMenu] = useState<{ show: boolean; x: number; y: number }>({ show: false, x: 0, y: 0 });
@@ -148,9 +149,11 @@ export default function MessageInput({ chatId }: MessageInputProps) {
// Cleanup preview URLs
useEffect(() => {
return () => {
if (attachment?.preview) URL.revokeObjectURL(attachment.preview);
attachments.forEach(a => {
if (a.preview) URL.revokeObjectURL(a.preview);
});
};
}, [attachment]);
}, [attachments]);
// Typing events
const emitTyping = useCallback(() => {
@@ -166,9 +169,9 @@ export default function MessageInput({ chatId }: MessageInputProps) {
const handleSend = async (scheduledAt?: string) => {
const trimmed = text.trim();
const hasAttachment = !!attachment;
const hasAttachments = attachments.length > 0;
if (!trimmed && !hasAttachment) return;
if (!trimmed && !hasAttachments) return;
if (isSending) return;
const socket = getSocket();
@@ -190,27 +193,35 @@ export default function MessageInput({ chatId }: MessageInputProps) {
return;
}
if (hasAttachment) {
if (hasAttachments) {
setIsSending(true);
try {
const result = await api.uploadFile(attachment!.file);
const uploadPromises = attachments.map(a => api.uploadFile(a.file));
const results = await Promise.all(uploadPromises);
const socketAttachments = results.map((res, i) => ({
type: attachments[i].type,
url: res.url,
fileName: res.filename,
fileSize: res.size
}));
socket.emit('send_message', {
chatId,
content: trimmed || null,
type: attachment!.type,
mediaUrl: result.url,
mediaType: attachment!.type,
fileName: result.filename,
fileSize: result.size,
type: attachments.length > 0 ? (attachments.every(a => a.type === 'image') ? 'image' : 'file') : 'text',
attachments: socketAttachments,
replyToId: replyTo?.id || null,
quote: replyTo?.quote || null,
...(scheduledAt ? { scheduledAt } : {}),
});
setReplyTo(null);
clearAttachment();
clearAttachments();
} catch (e) {
console.error('Ошибка загрузки файла:', e);
alert(t('uploadError'));
const { addNotification } = useNotificationStore.getState();
addNotification('error', t('uploadError') || 'Ошибка загрузки файла');
} finally {
setIsSending(false);
}
@@ -260,21 +271,48 @@ export default function MessageInput({ chatId }: MessageInputProps) {
}
};
const clearAttachment = () => {
if (attachment?.preview) URL.revokeObjectURL(attachment.preview);
setAttachment(null);
const clearAttachments = () => {
attachments.forEach(a => {
if (a.preview) URL.revokeObjectURL(a.preview);
});
setAttachments([]);
};
const removeAttachment = (index: number) => {
setAttachments(prev => {
const newArr = [...prev];
if (newArr[index].preview) URL.revokeObjectURL(newArr[index].preview!);
newArr.splice(index, 1);
return newArr;
});
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > MAX_FILE_SIZE) {
alert(t('fileTooLarge'));
e.target.value = '';
return;
const files = Array.from(e.target.files || []);
if (files.length > 0) {
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 isAudio = file.type.startsWith('audio/') || AUDIO_EXTENSIONS.some(ext => file.name.toLowerCase().endsWith(ext));
newAttachments.push({ file, type: isAudio ? 'audio' : 'file' });
}
const isAudio = file.type.startsWith('audio/') || AUDIO_EXTENSIONS.some(ext => file.name.toLowerCase().endsWith(ext));
setAttachment({ file, type: isAudio ? 'audio' : 'file' });
if (tooLarge) addNotification('warning', t('fileTooLarge') || 'Некоторые файлы слишком большие');
if (limitExceeded) addNotification('warning', 'Максимум 20 файлов');
setAttachments(prev => [...prev, ...newAttachments]);
inputRef.current?.focus();
}
e.target.value = '';
@@ -282,11 +320,26 @@ export default function MessageInput({ chatId }: MessageInputProps) {
};
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const isVideo = file.type.startsWith('video/');
const preview = file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined;
setAttachment({ file, preview, type: isVideo ? 'video' : 'image' });
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', 'Максимум 20 файлов');
setAttachments(prev => [...prev, ...newAttachments]);
inputRef.current?.focus();
}
e.target.value = '';
@@ -469,21 +522,43 @@ export default function MessageInput({ chatId }: MessageInputProps) {
setIsDragging(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const file = e.dataTransfer.files[0];
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 files = Array.from(e.dataTransfer.files);
const { addNotification } = useNotificationStore.getState();
const newAttachments: Attachment[] = [];
let tooLarge = false;
let limitExceeded = false;
const type = isImage ? 'image' : isVideo ? 'video' : isAudio ? 'audio' : 'file';
const preview = isImage ? URL.createObjectURL(file) : undefined;
for (const file of files) {
if (attachments.length + newAttachments.length >= 20) {
limitExceeded = true;
break;
}
if (file.size > MAX_FILE_SIZE) {
tooLarge = true;
continue;
}
setAttachment({ file, type, preview });
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') || 'Некоторые файлы слишком большие');
if (limitExceeded) addNotification('warning', 'Максимум 20 файлов');
setAttachments(prev => [...prev, ...newAttachments]);
inputRef.current?.focus();
}
};
const hasContent = text.trim() || attachment;
const hasContent = text.trim() || attachments.length > 0;
return (
<div
@@ -541,6 +616,7 @@ export default function MessageInput({ chatId }: MessageInputProps) {
onClick={() => {
setReplyTo(null);
setEditingMessage(null);
setAttachments([]);
setText('');
}}
className="w-7 h-7 rounded-full flex items-center justify-center text-white/40 hover:text-white hover:bg-white/10 transition-colors"
@@ -552,49 +628,67 @@ export default function MessageInput({ chatId }: MessageInputProps) {
)}
</AnimatePresence>
{/* Attachment preview */}
{/* Attachment previews */}
<AnimatePresence>
{attachment && (
{attachments.length > 0 && (
<motion.div
initial={{ height: 0, opacity: 0, y: 10, scale: 0.95 }}
animate={{ height: 'auto', opacity: 1, y: 0, scale: 1 }}
exit={{ height: 0, opacity: 0, y: 10, scale: 0.95 }}
className="mb-2 max-w-3xl mx-auto overflow-hidden px-1.5"
>
<div className="flex items-center gap-3 px-3 py-2.5 bg-white/[0.04] backdrop-blur-2xl border border-white/10 rounded-2xl shadow-xl relative">
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-3/5 bg-gradient-to-b from-sky-400 to-blue-500 rounded-r-md" />
{attachment.preview ? (
<img
src={attachment.preview}
alt=""
className="w-12 h-12 rounded-xl object-cover flex-shrink-0 ring-1 ring-white/10 ml-2"
/>
) : attachment.type === 'video' ? (
<div className="w-12 h-12 rounded-xl bg-vortex-500/20 flex items-center justify-center flex-shrink-0 ring-1 ring-white/10 ml-2">
<ImageIcon size={20} className="text-vortex-400" />
</div>
) : attachment.type === 'audio' ? (
<div className="w-12 h-12 rounded-xl bg-emerald-500/20 flex items-center justify-center flex-shrink-0 ring-1 ring-white/10 ml-2">
<Music size={20} className="text-emerald-400" />
</div>
) : (
<div className="w-12 h-12 rounded-xl bg-sky-500/20 flex items-center justify-center flex-shrink-0 ring-1 ring-white/10 ml-2">
<FileText size={20} className="text-sky-400" />
</div>
)}
<div className="flex-1 min-w-0 flex flex-col justify-center">
<p className="text-sm font-medium text-white truncate tracking-wide">{attachment.file.name}</p>
<p className="text-xs text-zinc-400 font-mono mt-0.5">
{(attachment.file.size / 1024).toFixed(1)} {t('kb')}
{isSending && <span className="ml-2 text-vortex-400 animate-pulse">{t('sending')}</span>}
</p>
<div className="flex flex-col gap-2 p-2 bg-white/[0.04] backdrop-blur-2xl border border-white/10 rounded-2xl shadow-xl">
<div className="flex flex-wrap gap-2">
{attachments.map((att, idx) => (
<motion.div
key={idx}
layout
className="group relative w-20 h-20 rounded-xl overflow-hidden border border-white/10 flex-shrink-0"
>
{att.preview ? (
<img
src={att.preview}
alt=""
className="w-full h-full object-cover"
/>
) : att.type === 'video' ? (
<div className="w-full h-full bg-vortex-500/20 flex items-center justify-center">
<ImageIcon size={20} className="text-vortex-400" />
</div>
) : att.type === 'audio' ? (
<div className="w-full h-full bg-emerald-500/20 flex items-center justify-center">
<Music size={20} className="text-emerald-400" />
</div>
) : (
<div className="w-full h-full bg-sky-500/20 flex items-center justify-center">
<FileText size={20} className="text-sky-400" />
</div>
)}
<button
onClick={() => removeAttachment(idx)}
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<X size={12} />
</button>
<div className="absolute bottom-0 left-0 right-0 bg-black/40 px-1 py-0.5 pointer-events-none">
<p className="text-[8px] text-white truncate">{att.file.name}</p>
</div>
</motion.div>
))}
</div>
<div className="flex items-center justify-between px-2 py-1 border-t border-white/5 mt-1">
<span className="text-[10px] text-zinc-400">
{attachments.length} {t('files')} ({ (attachments.reduce((acc, a) => acc + a.file.size, 0) / 1024 / 1024).toFixed(2) } MB)
</span>
<button
onClick={clearAttachments}
className="text-[10px] text-zinc-500 hover:text-rose-400 transition-colors"
>
{t('clearAll') || 'Очистить всё'}
</button>
</div>
<button
onClick={clearAttachment}
className="w-8 h-8 rounded-full flex items-center justify-center text-white/40 hover:text-white hover:bg-white/10 transition-colors"
>
<X size={16} />
</button>
</div>
</motion.div>
)}
@@ -675,12 +769,14 @@ export default function MessageInput({ chatId }: MessageInputProps) {
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileChange}
/>
<input
ref={imageInputRef}
type="file"
multiple
accept="image/*,video/*"
className="hidden"
onChange={handleImageChange}
@@ -745,7 +841,7 @@ export default function MessageInput({ chatId }: MessageInputProps) {
}}
onKeyDown={handleKeyDown}
onContextMenu={handleInputContextMenu}
placeholder={attachment ? t('addCaption') : t('message')}
placeholder={attachments.length > 0 ? t('addCaption') : t('message')}
rows={1}
className="w-full resize-none bg-transparent text-[15px] text-white placeholder-white/40 leading-relaxed py-2.5 px-2 border-none focus:ring-0 max-h-[150px] outline-none"
/>
@@ -0,0 +1,63 @@
import { motion, AnimatePresence } from 'framer-motion';
import { X, AlertCircle, CheckCircle, AlertTriangle, Info } from 'lucide-react';
import { useNotificationStore, NotificationType } from '../stores/notificationStore';
const icons: Record<NotificationType, React.ReactNode> = {
info: <Info className="text-blue-400" size={20} />,
success: <CheckCircle className="text-emerald-400" size={20} />,
warning: <AlertTriangle className="text-amber-400" size={20} />,
error: <AlertCircle className="text-rose-400" size={20} />,
};
const colors: Record<NotificationType, string> = {
info: 'border-blue-500/30 bg-blue-500/10',
success: 'border-emerald-500/30 bg-emerald-500/10',
warning: 'border-amber-500/30 bg-amber-500/10',
error: 'border-rose-500/30 bg-rose-500/10',
};
export default function NotificationProvider() {
const { notifications, removeNotification } = useNotificationStore();
return (
<div className="fixed top-6 right-6 z-[9999] flex flex-col gap-3 pointer-events-none w-full max-w-sm">
<AnimatePresence mode="popLayout">
{notifications.map((n) => (
<motion.div
key={n.id}
layout
initial={{ opacity: 0, x: 50, scale: 0.9 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.2 } }}
className={`pointer-events-auto relative group overflow-hidden rounded-2xl border backdrop-blur-xl p-4 shadow-2xl ${colors[n.type]}`}
>
<div className="flex gap-3">
<div className="flex-shrink-0 mt-0.5">{icons[n.type]}</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white/90 leading-relaxed">
{n.message}
</p>
</div>
<button
onClick={() => removeNotification(n.id)}
className="flex-shrink-0 -mr-1 -mt-1 p-1 rounded-full text-white/30 hover:text-white hover:bg-white/10 transition-colors"
>
<X size={16} />
</button>
</div>
{/* Progress bar for auto-hide */}
{n.duration && n.duration > 0 && (
<motion.div
initial={{ width: '100%' }}
animate={{ width: 0 }}
transition={{ duration: n.duration / 1000, ease: 'linear' }}
className="absolute bottom-0 left-0 h-0.5 bg-white/20"
/>
)}
</motion.div>
))}
</AnimatePresence>
</div>
);
}
+8 -1
View File
@@ -10,6 +10,7 @@ import {
} from 'lucide-react';
import { useAuthStore } from '../stores/authStore';
import { useChatStore } from '../stores/chatStore';
import { useNotificationStore } from '../stores/notificationStore';
import { useLang } from '../lib/i18n';
import { api } from '../lib/api';
import { getInitials, generateAvatarColor } from '../lib/utils';
@@ -35,7 +36,13 @@ export default function Sidebar() {
const [showCreateStory, setShowCreateStory] = useState(false);
const loadStories = () => {
api.getStories().then(setStoryGroups).catch(console.error);
api.getStories()
.then(setStoryGroups)
.catch((err) => {
console.error(err);
const { addNotification } = useNotificationStore.getState();
addNotification('error', (t('loadStoriesError') || 'Failed to load stories') as string);
});
};
useEffect(() => {
+10
View File
@@ -42,6 +42,8 @@ const translations = {
online: 'в сети',
wasRecently: 'был(а) недавно',
members: 'участников',
files: 'файлов',
clearAll: 'Очистить всё',
messagePlaceholder: 'Сообщение...',
message: 'Сообщение...',
addCaption: 'Добавьте подпись...',
@@ -265,6 +267,9 @@ const translations = {
// Last seen
lastSeenAt: 'был(а)',
justNow: 'только что',
loadStoriesError: 'Ошибка загрузки историй',
loadChatsError: 'Ошибка загрузки чатов',
loadMessagesError: 'Ошибка загрузки сообщений',
},
en: {
myProfile: 'My Profile',
@@ -299,6 +304,8 @@ const translations = {
online: 'online',
wasRecently: 'was recently',
members: 'members',
files: 'files',
clearAll: 'Clear all',
messagePlaceholder: 'Message...',
message: 'Message...',
addCaption: 'Add a caption...',
@@ -500,6 +507,9 @@ const translations = {
scheduleDate: 'Date',
lastSeenAt: 'was',
justNow: 'just now',
loadStoriesError: 'Error loading stories',
loadChatsError: 'Error loading chats',
loadMessagesError: 'Error loading messages',
},
} as const;
+1
View File
@@ -77,6 +77,7 @@ export interface Message {
id: string;
content: string | null;
quote?: string | null;
media?: MediaItem[];
sender: { id: string; username: string; displayName: string };
} | null;
forwardedFrom?: UserBasic | null;
+6 -2
View File
@@ -103,9 +103,11 @@ export const useChatStore = create<ChatState>((set, get) => ({
}
}
set({ chats, pinnedMessages, isLoadingChats: false });
} catch (error) {
} catch (error: any) {
console.error('Load chats error:', error);
set({ isLoadingChats: false });
const { addNotification } = (await import('./notificationStore')).useNotificationStore.getState();
addNotification('error', error.message || 'Failed to load chats');
}
},
@@ -126,9 +128,11 @@ export const useChatStore = create<ChatState>((set, get) => ({
isLoadingMessages: false,
};
});
} catch (error) {
} catch (error: any) {
console.error('Load messages error:', error);
set({ isLoadingMessages: false });
const { addNotification } = (await import('./notificationStore')).useNotificationStore.getState();
addNotification('error', error.message || 'Failed to load messages');
}
},
+38
View File
@@ -0,0 +1,38 @@
import { create } from 'zustand';
export type NotificationType = 'info' | 'success' | 'warning' | 'error';
export interface Notification {
id: string;
type: NotificationType;
message: string;
duration?: number;
}
interface NotificationState {
notifications: Notification[];
addNotification: (type: NotificationType, message: string, duration?: number) => void;
removeNotification: (id: string) => void;
}
export const useNotificationStore = create<NotificationState>((set) => ({
notifications: [],
addNotification: (type, message, duration = 5000) => {
const id = Math.random().toString(36).substring(2, 9);
set((state) => ({
notifications: [...state.notifications, { id, type, message, duration }],
}));
if (duration > 0) {
setTimeout(() => {
set((state) => ({
notifications: state.notifications.filter((n) => n.id !== id),
}));
}, duration);
}
},
removeNotification: (id) =>
set((state) => ({
notifications: state.notifications.filter((n) => n.id !== id),
})),
}));