Reorganize web folder structurally

This commit is contained in:
Халимов Рустам
2026-03-19 22:24:21 +03:00
parent 11ebbc853b
commit 4384233aa5
62 changed files with 0 additions and 0 deletions
@@ -0,0 +1,469 @@
import { useEffect, useRef, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useChatStore } from '../application/chatStore';
import { useAuthStore } from '../../auth/application/authStore';
import { getSocket, disconnectSocket } from '../../../core/infrastructure/socket';
import { ChatApi } from '../infrastructure/chatApi';
import { playNotificationSound, isChatMuted, playCallRingtone, stopCallRingtone } from '../../../core/utils/sounds';
import { useLang } from '../../../core/infrastructure/i18n';
import type { Message, UserBasic, CallInfo } from '../../../core/domain/types';
import { Send, Check, Phone, PhoneOff } from 'lucide-react';
import Sidebar from '../../../core/presentation/layouts/Sidebar';
import ChatView from './components/ChatView';
import CallModal from '../../calls/presentation/components/CallModal';
import GroupCallModal from '../../calls/presentation/components/GroupCallModal';
export default function ChatPage() {
const {
loadChats,
addMessage,
updateMessage,
removeMessage,
removeMessages,
hideMessages,
addReaction,
removeReaction,
markRead,
addTypingUser,
removeTypingUser,
updateUserOnlineStatus,
setPinnedMessage,
removePinnedMessage,
clearStore,
addChat,
} = 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 [incomingGroupCall, setIncomingGroupCall] = useState<{ chatId: string; from: string; callerInfo: any; callType: string; chatName: string } | null>(null);
const groupCallOpenRef = useRef(false);
const groupCallChatIdRef = useRef('');
const { t } = useLang();
useEffect(() => {
groupCallOpenRef.current = groupCallOpen;
groupCallChatIdRef.current = groupCallChatId;
}, [groupCallOpen, groupCallChatId]);
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 ChatApi.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 && !message.storyId && !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 ChatApi.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('new_chat', (chat: any) => {
addChat(chat);
socket.emit('join_chat', chat.id);
});
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 }) => {
console.log('[Socket] reaction_added received:', data);
addReaction(data.messageId, data.chatId, data.userId, data.username, data.emoji);
});
socket.on('reaction_removed', (data: { messageId: string; chatId: string; userId: string; emoji: string }) => {
console.log('[Socket] reaction_removed received:', data);
removeReaction(data.messageId, data.chatId, data.userId, data.emoji);
});
socket.on('messages_read', (data: any) => {
markRead(data.chatId || data.ChatId, data.userId || data.UserId, data.messageIds || 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);
});
// Story events - registered globally so they work even when StoryViewer is closed
socket.on('story_viewed', (data: { storyId: string; userId: string; username: string; displayName: string; avatar: string | null; viewedAt: string; viewCount: number; ownerId: string }) => {
console.log('[Socket] story_viewed received:', data);
// Only process if this user is the owner
if (data.ownerId === user?.id) {
console.log('[Socket] This is my story, updating view count');
}
});
socket.on('story_reply', (data: { storyId: string; userId: string; username: string; displayName: string; avatar: string | null; content: string; createdAt: string; ownerId: string }) => {
console.log('[Socket] story_reply received:', data);
// Only process if this user is the owner
if (data.ownerId === user?.id) {
console.log('[Socket] This is my story, got reply:', data.content);
}
});
socket.on('story_reaction', (data: { storyId: string; userId: string; username: string; displayName: string; avatar: string | null; emoji: string; createdAt: string; ownerId: string }) => {
console.log('[Socket] story_reaction received:', data);
// Only process if this user is the owner
if (data.ownerId === user?.id) {
console.log('[Socket] This is my story, got reaction:', data.emoji);
}
});
socket.on('group_call_incoming', (data: { chatId: string; from: string; callerInfo: any; callType: string }) => {
if (data.from === user?.id) return;
if (groupCallOpenRef.current && groupCallChatIdRef.current === data.chatId) return;
const { chats } = useChatStore.getState();
const chat = chats.find(c => c.id === data.chatId);
if (!chat) return;
playCallRingtone();
setIncomingGroupCall({
chatId: data.chatId,
from: data.from,
callerInfo: data.callerInfo,
callType: data.callType,
chatName: chat.name || 'Group',
});
// Auto-dismiss after 15 seconds if ignored
setTimeout(() => {
setIncomingGroupCall(prev => {
if (prev?.chatId === data.chatId) {
stopCallRingtone();
return null;
}
return prev;
});
}, 15000);
});
socket.on('group_call_ended', (data: { chatId: string }) => {
setIncomingGroupCall(prev => {
if (prev?.chatId === data.chatId) {
stopCallRingtone();
return null;
}
return prev;
});
});
return () => {
socket.off('new_message');
socket.off('scheduled_delivered');
socket.off('message_edited');
socket.off('new_chat');
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');
socket.off('story_viewed');
socket.off('story_reply');
socket.off('story_reaction');
socket.off('group_call_incoming');
socket.off('group_call_ended');
};
}, [user?.id]);
const handleStartCall = (targetUser: UserBasic, type: 'voice' | 'video') => {
setCallTarget(targetUser);
setCallType(type);
setIncomingCall(null);
setCallSessionId(id => id + 1);
setCallOpen(true);
};
useEffect(() => {
const handleCustomCallEvent = ((e: CustomEvent) => {
if (e.detail?.targetUser && e.detail?.type) {
handleStartCall(e.detail.targetUser, e.detail.type);
}
}) as EventListener;
window.addEventListener('START_CALL', handleCustomCallEvent);
return () => window.removeEventListener('START_CALL', handleCustomCallEvent);
}, []);
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);
};
const activeChat = useChatStore((state) => state.activeChat);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="h-full flex bg-surface sm:p-3 sm:gap-3 overflow-hidden"
>
<div className={`${activeChat ? 'hidden sm:block' : 'block'} w-full sm:w-[340px] flex-shrink-0`}>
<Sidebar />
</div>
<div className={`${activeChat ? 'block' : 'hidden sm:block'} flex-1 h-full min-w-0`}>
<ChatView onStartCall={handleStartCall} onStartGroupCall={handleStartGroupCall} />
</div>
<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>
{/* Incoming Group Call Overlay */}
<AnimatePresence>
{incomingGroupCall && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/80 backdrop-blur-sm"
>
<motion.div
initial={{ scale: 0.9, y: 20 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.9, y: 20 }}
className="bg-zinc-900 border border-white/10 p-8 rounded-3xl w-full max-w-sm flex flex-col items-center shadow-2xl"
>
<div className="relative mb-6">
<div className="absolute inset-0 rounded-full bg-emerald-500/20 animate-call-wave" />
<div className="relative w-24 h-24 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-4xl font-bold text-white uppercase overflow-hidden">
{incomingGroupCall.callerInfo?.avatar ? (
<img src={incomingGroupCall.callerInfo.avatar} className="w-full h-full object-cover" />
) : (
<>{incomingGroupCall.chatName.charAt(0)}</>
)}
</div>
</div>
<h2 className="text-2xl text-white font-semibold mb-2 text-center break-words w-full max-w-full">
{incomingGroupCall.chatName}
</h2>
<p className="text-emerald-400 font-medium mb-1 truncate w-full text-center">
{incomingGroupCall.callerInfo?.displayName || incomingGroupCall.callerInfo?.username || 'User'} {t('calling' as any) || 'звонит...'}
</p>
<p className="text-zinc-400 text-sm mb-8 bg-white/5 px-3 py-1 rounded-full border border-white/5">
{t('group' as any) || 'Групповой звонок'}
</p>
<div className="flex items-center gap-8 w-full justify-center">
<button
onClick={() => {
stopCallRingtone();
setIncomingGroupCall(null);
}}
className="w-16 h-16 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center text-white transition-colors shadow-lg shadow-red-500/20"
>
<PhoneOff size={28} />
</button>
<button
onClick={() => {
stopCallRingtone();
handleStartGroupCall(incomingGroupCall.chatId, incomingGroupCall.chatName, incomingGroupCall.callType as any);
setIncomingGroupCall(null);
}}
className="w-16 h-16 rounded-full bg-emerald-500 hover:bg-emerald-600 flex items-center justify-center text-white transition-colors animate-pulse shadow-lg shadow-emerald-500/20"
>
<Phone size={28} className="animate-wiggle" />
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}