Удалены чувствительные данные
This commit is contained in:
+11
-7
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
}));
|
||||
Reference in New Issue
Block a user