Original
This commit is contained in:
226
apps/web/src/pages/AuthPage.tsx
Normal file
226
apps/web/src/pages/AuthPage.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { useLang } from '../lib/i18n';
|
||||
import { Eye, EyeOff, ArrowRight, UserPlus, LogIn } from 'lucide-react';
|
||||
|
||||
export default function AuthPage() {
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
const [username, setUsername] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [bio, setBio] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { login, register } = useAuthStore();
|
||||
const { t } = useLang();
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
if (isLogin) {
|
||||
await login(username, password);
|
||||
} else {
|
||||
await register(username, displayName || username, password, bio);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Ошибка');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="h-full flex items-center justify-center relative overflow-hidden bg-surface"
|
||||
>
|
||||
{/* Анимированный фон */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] opacity-20">
|
||||
<div className="absolute inset-0 rounded-full bg-gradient-to-r from-vortex-600/30 to-purple-600/30 blur-[120px] animate-pulse" />
|
||||
</div>
|
||||
<div className="absolute top-20 left-20 w-72 h-72 bg-vortex-500/10 rounded-full blur-[100px]" />
|
||||
<div className="absolute bottom-20 right-20 w-96 h-96 bg-purple-500/10 rounded-full blur-[100px]" />
|
||||
</div>
|
||||
|
||||
{/* Карточка авторизации */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, y: 20 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: 'easeOut' }}
|
||||
className="relative z-10 w-full max-w-md mx-4"
|
||||
>
|
||||
<div className="glass-strong rounded-3xl p-8 shadow-2xl shadow-vortex-500/5">
|
||||
{/* Логотип */}
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<motion.div
|
||||
initial={{ rotate: -180, scale: 0 }}
|
||||
animate={{ rotate: 0, scale: 1 }}
|
||||
transition={{ duration: 0.6, type: 'spring', bounce: 0.4 }}
|
||||
>
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="Vortex"
|
||||
className="w-20 h-20 rounded-2xl shadow-lg shadow-vortex-500/30 object-cover"
|
||||
/>
|
||||
</motion.div>
|
||||
<h1 className="text-2xl font-bold gradient-text mt-4">Vortex</h1>
|
||||
<p className="text-zinc-500 text-sm mt-1">{t('modernMessengerShort')}</p>
|
||||
</div>
|
||||
|
||||
{/* Переключатель Вход/Регистрация */}
|
||||
<div className="flex rounded-xl bg-white/5 p-1 mb-6">
|
||||
<button
|
||||
onClick={() => { setIsLogin(true); setError(''); setPassword(''); }}
|
||||
className={`flex-1 py-2.5 px-4 rounded-lg text-sm font-medium transition-all duration-200 flex items-center justify-center gap-2 ${
|
||||
isLogin
|
||||
? 'bg-gradient-to-r from-vortex-500 to-purple-600 text-white shadow-lg shadow-vortex-500/25'
|
||||
: 'text-zinc-400 hover:text-zinc-200'
|
||||
}`}
|
||||
aria-pressed={isLogin}
|
||||
>
|
||||
<LogIn size={16} />
|
||||
{t('login')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setIsLogin(false); setError(''); setPassword(''); }}
|
||||
className={`flex-1 py-2.5 px-4 rounded-lg text-sm font-medium transition-all duration-200 flex items-center justify-center gap-2 ${
|
||||
!isLogin
|
||||
? 'bg-gradient-to-r from-vortex-500 to-purple-600 text-white shadow-lg shadow-vortex-500/25'
|
||||
: 'text-zinc-400 hover:text-zinc-200'
|
||||
}`}
|
||||
aria-pressed={!isLogin}
|
||||
>
|
||||
<UserPlus size={16} />
|
||||
{t('register')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Ошибка */}
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="mb-4 p-3 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Форма */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4" autoComplete="off">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1.5">
|
||||
Username {!isLogin && <span className="text-zinc-600">{t('latinOnly')}</span>}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value.replace(/[^a-zA-Z0-9_]/g, ''))}
|
||||
placeholder="username"
|
||||
className="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 text-white placeholder-zinc-600 focus:border-vortex-500/50 focus:ring-1 focus:ring-vortex-500/25 transition-all"
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{!isLogin && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1.5">
|
||||
{t('displayNameLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder={t('displayNamePlaceholder')}
|
||||
className="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 text-white placeholder-zinc-600 focus:border-vortex-500/50 focus:ring-1 focus:ring-vortex-500/25 transition-all"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1.5">{t('password')}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t('passwordPlaceholder')}
|
||||
className="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 text-white placeholder-zinc-600 focus:border-vortex-500/50 focus:ring-1 focus:ring-vortex-500/25 transition-all pr-12"
|
||||
required
|
||||
autoComplete={isLogin ? 'current-password' : 'new-password'}
|
||||
minLength={6}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{!isLogin && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1.5">{t('aboutMe')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bio}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
placeholder={t('bioPlaceholder')}
|
||||
className="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 text-white placeholder-zinc-600 focus:border-vortex-500/50 focus:ring-1 focus:ring-vortex-500/25 transition-all"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
disabled={isSubmitting}
|
||||
type="submit"
|
||||
className="w-full py-3 px-4 rounded-xl bg-gradient-to-r from-vortex-500 to-purple-600 text-white font-medium shadow-lg shadow-vortex-500/25 hover:shadow-vortex-500/40 transition-all duration-200 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
{isLogin ? t('loginBtn') : t('createAccount')}
|
||||
<ArrowRight size={18} />
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
304
apps/web/src/pages/ChatPage.tsx
Normal file
304
apps/web/src/pages/ChatPage.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useChatStore } from '../stores/chatStore';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { getSocket, disconnectSocket } from '../lib/socket';
|
||||
import { api } from '../lib/api';
|
||||
import { playNotificationSound, isChatMuted } from '../lib/sounds';
|
||||
import { useLang } from '../lib/i18n';
|
||||
import type { Message, UserBasic, CallInfo } from '../lib/types';
|
||||
import { Send, Check } from 'lucide-react';
|
||||
import Sidebar from '../components/Sidebar';
|
||||
import ChatView from '../components/ChatView';
|
||||
import CallModal from '../components/CallModal';
|
||||
import GroupCallModal from '../components/GroupCallModal';
|
||||
|
||||
export default function ChatPage() {
|
||||
const {
|
||||
loadChats,
|
||||
addMessage,
|
||||
updateMessage,
|
||||
removeMessage,
|
||||
removeMessages,
|
||||
hideMessages,
|
||||
addReaction,
|
||||
removeReaction,
|
||||
markRead,
|
||||
addTypingUser,
|
||||
removeTypingUser,
|
||||
updateUserOnlineStatus,
|
||||
setPinnedMessage,
|
||||
removePinnedMessage,
|
||||
clearStore,
|
||||
} = useChatStore();
|
||||
const { user } = useAuthStore();
|
||||
const initialized = useRef(false);
|
||||
|
||||
// Call state
|
||||
const [callOpen, setCallOpen] = useState(false);
|
||||
const [callTarget, setCallTarget] = useState<UserBasic | null>(null);
|
||||
const [callType, setCallType] = useState<'voice' | 'video'>('voice');
|
||||
const [incomingCall, setIncomingCall] = useState<CallInfo | null>(null);
|
||||
const [callSessionId, setCallSessionId] = useState(0);
|
||||
const [deliveryNotification, setDeliveryNotification] = useState<string | null>(null);
|
||||
const deliveryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Group call state
|
||||
const [groupCallOpen, setGroupCallOpen] = useState(false);
|
||||
const [groupCallChatId, setGroupCallChatId] = useState('');
|
||||
const [groupCallChatName, setGroupCallChatName] = useState('');
|
||||
const [groupCallType, setGroupCallType] = useState<'voice' | 'video'>('voice');
|
||||
const [groupCallSessionId, setGroupCallSessionId] = useState(0);
|
||||
|
||||
const { t } = useLang();
|
||||
|
||||
useEffect(() => {
|
||||
if (initialized.current) return;
|
||||
initialized.current = true;
|
||||
loadChats();
|
||||
}, [loadChats]);
|
||||
|
||||
// Обработка закрытия вкладки — отправить disconnect
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = () => {
|
||||
const socket = getSocket();
|
||||
if (socket) {
|
||||
socket.disconnect();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = getSocket();
|
||||
if (!socket) return;
|
||||
|
||||
socket.on('new_message', async (message: Message) => {
|
||||
// If this chat isn't in our store yet (e.g. someone just created it and sent a message),
|
||||
// fetch chats so the new chat appears in the sidebar immediately
|
||||
const { chats } = useChatStore.getState();
|
||||
if (!chats.some(c => c.id === message.chatId)) {
|
||||
try {
|
||||
const allChats = await api.getChats();
|
||||
const newChat = allChats.find(c => c.id === message.chatId);
|
||||
if (newChat) {
|
||||
// Reset unreadCount to 0 because addMessage below will increment it by 1
|
||||
useChatStore.getState().addChat({ ...newChat, unreadCount: 0 });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch new chat:', e);
|
||||
}
|
||||
}
|
||||
addMessage(message);
|
||||
// Play notification sound for messages from others
|
||||
if (message.senderId !== user?.id && !isChatMuted(message.chatId)) {
|
||||
playNotificationSound();
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('scheduled_delivered', async (message: Message & { _recipientName?: string; _deliveredAt?: string }) => {
|
||||
// If chat unknown, fetch it first
|
||||
const { chats } = useChatStore.getState();
|
||||
if (!chats.some(c => c.id === message.chatId)) {
|
||||
try {
|
||||
const allChats = await api.getChats();
|
||||
const newChat = allChats.find(c => c.id === message.chatId);
|
||||
if (newChat) useChatStore.getState().addChat(newChat);
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
// A scheduled message was delivered: update it in store (remove scheduledAt)
|
||||
updateMessage({ ...message, scheduledAt: null });
|
||||
|
||||
// Show delivery notification to the sender
|
||||
if (message.senderId === user?.id && message._recipientName) {
|
||||
const time = message._deliveredAt
|
||||
? new Date(message._deliveredAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
: '';
|
||||
const notifText = `${useLang.getState().t('scheduledDelivered')} ${message._recipientName} ${useLang.getState().t('scheduledDeliveredAt')} ${time}`;
|
||||
setDeliveryNotification(notifText);
|
||||
if (deliveryTimerRef.current) clearTimeout(deliveryTimerRef.current);
|
||||
deliveryTimerRef.current = setTimeout(() => setDeliveryNotification(null), 5000);
|
||||
}
|
||||
|
||||
// Notify others with sound
|
||||
if (message.senderId !== user?.id && !isChatMuted(message.chatId)) {
|
||||
playNotificationSound();
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('message_edited', (message: Message) => {
|
||||
updateMessage(message);
|
||||
});
|
||||
|
||||
socket.on('message_deleted', (data: { messageId: string; chatId: string }) => {
|
||||
removeMessage(data.messageId, data.chatId);
|
||||
});
|
||||
|
||||
socket.on('messages_deleted', (data: { messageIds: string[]; chatId: string }) => {
|
||||
removeMessages(data.messageIds, data.chatId);
|
||||
});
|
||||
|
||||
socket.on('messages_hidden', (data: { messageIds: string[]; chatId: string }) => {
|
||||
hideMessages(data.messageIds, data.chatId);
|
||||
});
|
||||
|
||||
socket.on('reaction_added', (data: { messageId: string; chatId: string; userId: string; username: string; emoji: string }) => {
|
||||
addReaction(data.messageId, data.chatId, data.userId, data.username, data.emoji);
|
||||
});
|
||||
|
||||
socket.on('reaction_removed', (data: { messageId: string; chatId: string; userId: string; emoji: string }) => {
|
||||
removeReaction(data.messageId, data.chatId, data.userId, data.emoji);
|
||||
});
|
||||
|
||||
socket.on('messages_read', (data: { chatId: string; userId: string; messageIds: string[] }) => {
|
||||
markRead(data.chatId, data.userId, data.messageIds);
|
||||
});
|
||||
|
||||
socket.on('user_typing', (data: { chatId: string; userId: string }) => {
|
||||
if (data.userId !== user?.id) {
|
||||
addTypingUser(data.chatId, data.userId);
|
||||
setTimeout(() => removeTypingUser(data.chatId, data.userId), 3000);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('user_stopped_typing', (data: { chatId: string; userId: string }) => {
|
||||
removeTypingUser(data.chatId, data.userId);
|
||||
});
|
||||
|
||||
socket.on('user_online', (data: { userId: string }) => {
|
||||
updateUserOnlineStatus(data.userId, true);
|
||||
});
|
||||
|
||||
socket.on('user_offline', (data: { userId: string; lastSeen?: string }) => {
|
||||
updateUserOnlineStatus(data.userId, false, data.lastSeen);
|
||||
});
|
||||
|
||||
socket.on('message_pinned', (data: { chatId: string; message: Message }) => {
|
||||
setPinnedMessage(data.chatId, data.message);
|
||||
});
|
||||
|
||||
socket.on('message_unpinned', (data: { chatId: string; messageId: string; newPinnedMessage: Message | null }) => {
|
||||
removePinnedMessage(data.chatId, data.messageId, data.newPinnedMessage);
|
||||
});
|
||||
|
||||
socket.on('call_incoming', async (data: CallInfo) => {
|
||||
// Use callerInfo from server if available, otherwise look up from chats
|
||||
let callerInfo: UserBasic | null = data.callerInfo || null;
|
||||
if (!callerInfo) {
|
||||
const { chats } = useChatStore.getState();
|
||||
for (const chat of chats) {
|
||||
const member = chat.members.find((m) => m.user.id === data.from);
|
||||
if (member) {
|
||||
callerInfo = member.user;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
setCallTarget(null); // Clear any previous outgoing target
|
||||
setIncomingCall({
|
||||
from: data.from,
|
||||
offer: data.offer,
|
||||
callType: data.callType,
|
||||
chatId: data.chatId,
|
||||
callerInfo,
|
||||
});
|
||||
setCallType(data.callType);
|
||||
setCallSessionId(id => id + 1);
|
||||
setCallOpen(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.off('new_message');
|
||||
socket.off('scheduled_delivered');
|
||||
socket.off('message_edited');
|
||||
socket.off('message_deleted');
|
||||
socket.off('messages_deleted');
|
||||
socket.off('messages_hidden');
|
||||
socket.off('reaction_added');
|
||||
socket.off('reaction_removed');
|
||||
socket.off('messages_read');
|
||||
socket.off('user_typing');
|
||||
socket.off('user_stopped_typing');
|
||||
socket.off('user_online');
|
||||
socket.off('user_offline');
|
||||
socket.off('message_pinned');
|
||||
socket.off('message_unpinned');
|
||||
socket.off('call_incoming');
|
||||
};
|
||||
}, [user?.id]);
|
||||
|
||||
const handleStartCall = (targetUser: UserBasic, type: 'voice' | 'video') => {
|
||||
setCallTarget(targetUser);
|
||||
setCallType(type);
|
||||
setIncomingCall(null);
|
||||
setCallSessionId(id => id + 1);
|
||||
setCallOpen(true);
|
||||
};
|
||||
|
||||
const handleStartGroupCall = (chatId: string, chatName: string, type: 'voice' | 'video') => {
|
||||
setGroupCallChatId(chatId);
|
||||
setGroupCallChatName(chatName);
|
||||
setGroupCallType(type);
|
||||
setGroupCallSessionId(id => id + 1);
|
||||
setGroupCallOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseCall = () => {
|
||||
setCallOpen(false);
|
||||
setCallTarget(null);
|
||||
setIncomingCall(null);
|
||||
};
|
||||
|
||||
const handleCloseGroupCall = () => {
|
||||
setGroupCallOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="h-full flex bg-surface p-3 gap-3 overflow-hidden"
|
||||
>
|
||||
<Sidebar />
|
||||
<ChatView onStartCall={handleStartCall} onStartGroupCall={handleStartGroupCall} />
|
||||
<CallModal
|
||||
key={callSessionId}
|
||||
isOpen={callOpen}
|
||||
onClose={handleCloseCall}
|
||||
targetUser={callTarget}
|
||||
callType={callType}
|
||||
incoming={incomingCall}
|
||||
/>
|
||||
<GroupCallModal
|
||||
key={`gc-${groupCallSessionId}`}
|
||||
isOpen={groupCallOpen}
|
||||
onClose={handleCloseGroupCall}
|
||||
chatId={groupCallChatId}
|
||||
chatName={groupCallChatName}
|
||||
callType={groupCallType}
|
||||
/>
|
||||
|
||||
{/* Scheduled message delivery notification */}
|
||||
<AnimatePresence>
|
||||
{deliveryNotification && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -20, scale: 0.95 }}
|
||||
className="fixed top-6 left-1/2 -translate-x-1/2 z-[9999] px-5 py-3 rounded-2xl bg-surface-secondary shadow-2xl border border-border flex items-center gap-3"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-emerald-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<Send size={14} className="text-emerald-400" />
|
||||
</div>
|
||||
<span className="text-sm text-zinc-200">{deliveryNotification}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user