This commit is contained in:
Халимов Рустам
2026-03-10 21:11:59 +03:00
parent a3c0d35263
commit b4b4b8e0d3
80 changed files with 23310 additions and 0 deletions

50
apps/web/src/App.tsx Normal file
View File

@@ -0,0 +1,50 @@
import { useEffect } from 'react';
import { AnimatePresence } from 'framer-motion';
import { useAuthStore } from './stores/authStore';
import AuthPage from './pages/AuthPage';
import ChatPage from './pages/ChatPage';
export default function App() {
const { token, user, checkAuth, isLoading } = useAuthStore();
useEffect(() => {
checkAuth();
}, [checkAuth]);
if (isLoading) {
return (
<div className="h-full flex items-center justify-center bg-surface">
<div className="flex flex-col items-center gap-4">
<VortexLoader />
<p className="text-zinc-500 text-sm">Загрузка...</p>
</div>
</div>
);
}
return (
<AnimatePresence mode="wait">
{token && user ? (
<ChatPage key="chat" />
) : (
<AuthPage key="auth" />
)}
</AnimatePresence>
);
}
function VortexLoader() {
return (
<div className="relative w-12 h-12">
<div className="absolute inset-0 rounded-full border-2 border-transparent border-t-vortex-500 animate-spin" />
<div
className="absolute inset-1 rounded-full border-2 border-transparent border-t-vortex-400 animate-spin"
style={{ animationDuration: '0.8s', animationDirection: 'reverse' }}
/>
<div
className="absolute inset-2 rounded-full border-2 border-transparent border-t-vortex-300 animate-spin"
style={{ animationDuration: '0.6s' }}
/>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { memo } from 'react';
import { getInitials, generateAvatarColor } from '../lib/utils';
interface AvatarProps {
src?: string | null;
name: string;
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
className?: string;
online?: boolean;
}
const sizeClasses = {
xs: 'w-6 h-6 text-[10px]',
sm: 'w-8 h-8 text-xs',
md: 'w-10 h-10 text-sm',
lg: 'w-12 h-12 text-base',
xl: 'w-20 h-20 text-xl',
} as const;
const onlineDotSize = {
xs: 'w-1.5 h-1.5 border',
sm: 'w-2 h-2 border',
md: 'w-2.5 h-2.5 border-2',
lg: 'w-3 h-3 border-2',
xl: 'w-4 h-4 border-2',
} as const;
function AvatarInner({ src, name, size = 'md', className = '', online }: AvatarProps) {
const sizeClass = sizeClasses[size];
const initials = getInitials(name || '?');
const gradientClass = generateAvatarColor(name || '');
return (
<div className={`relative shrink-0 ${className}`}>
{src ? (
<img
src={src}
alt={name}
className={`${sizeClass} rounded-full object-cover`}
/>
) : (
<div
className={`${sizeClass} rounded-full bg-gradient-to-br ${gradientClass} flex items-center justify-center text-white font-medium`}
>
{initials}
</div>
)}
{online !== undefined && (
<div
className={`absolute bottom-0 right-0 ${onlineDotSize[size]} rounded-full border-surface ${
online ? 'bg-emerald-500' : 'bg-zinc-500'
}`}
/>
)}
</div>
);
}
const Avatar = memo(AvatarInner);
export default Avatar;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,214 @@
import { useState, useRef, useEffect, memo } from 'react';
import { formatDistanceToNow } from 'date-fns';
import { ru, enUS } from 'date-fns/locale';
import { Check, CheckCheck, Image, FileText, Mic, Video, Pin, Trash2, Bookmark } from 'lucide-react';
import { useAuthStore } from '../stores/authStore';
import { useChatStore } from '../stores/chatStore';
import { useLang } from '../lib/i18n';
import { stripMarkdown } from '../lib/utils';
import { api } from '../lib/api';
import ConfirmModal from './ConfirmModal';
import Avatar from './Avatar';
import type { Chat } from '../lib/types';
interface ChatListItemProps {
chat: Chat;
isActive: boolean;
}
function ChatListItem({ chat, isActive }: ChatListItemProps) {
const { user } = useAuthStore();
const { setActiveChat, loadMessages, typingUsers, drafts, loadChats } = useChatStore();
const { t, lang } = useLang();
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const ctxRef = useRef<HTMLDivElement>(null);
const myMember = chat.members.find((m) => m.user.id === user?.id);
const isPinned = myMember?.isPinned ?? false;
const draft = drafts[chat.id] || '';
const otherMember = chat.members.find((m) => m.user.id !== user?.id);
const isFavorites = chat.type === 'favorites';
const chatName = isFavorites
? t('favorites')
: chat.type === 'personal'
? otherMember?.user.displayName || otherMember?.user.username || t('chat')
: chat.name || t('group');
const chatAvatar = isFavorites
? null
: chat.type === 'personal'
? otherMember?.user.avatar
: chat.avatar;
const isOnline = chat.type === 'personal' && otherMember?.user.isOnline;
// Check if someone is typing in this chat
const typingInChat = typingUsers.filter((t) => t.chatId === chat.id && t.userId !== user?.id);
const isTyping = typingInChat.length > 0;
const lastMessage = chat.messages?.[0];
const lastMessageText = lastMessage
? lastMessage.isDeleted
? t('messageDeleted')
: lastMessage.type === 'voice'
? t('voice')
: lastMessage.type === 'file' || lastMessage.type === 'image' || lastMessage.type === 'video'
? lastMessage.media?.[0]?.type === 'image'
? t('photo')
: lastMessage.media?.[0]?.type === 'video'
? t('video')
: t('file')
: lastMessage.content || ''
: '';
const previewText = stripMarkdown(lastMessageText);
const isMine = lastMessage?.senderId === user?.id;
// Галочки прочтения
const isRead = lastMessage?.readBy?.some((r) => r.userId !== user?.id);
const timeStr = lastMessage
? formatDistanceToNow(new Date(lastMessage.createdAt), { addSuffix: false, locale: lang === 'ru' ? ru : enUS })
: '';
const handleClick = () => {
setActiveChat(chat.id);
loadMessages(chat.id);
};
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
setCtxMenu({ x: e.clientX, y: e.clientY });
};
useEffect(() => {
if (!ctxMenu) return;
const close = (e: MouseEvent) => {
if (ctxRef.current && !ctxRef.current.contains(e.target as Node)) setCtxMenu(null);
};
document.addEventListener('mousedown', close);
return () => document.removeEventListener('mousedown', close);
}, [ctxMenu]);
const handlePin = async () => {
setCtxMenu(null);
try {
await api.togglePinChat(chat.id);
loadChats();
} catch (e) { console.error(e); }
};
const handleDelete = async () => {
setCtxMenu(null);
setShowDeleteConfirm(true);
};
const confirmDelete = async () => {
setShowDeleteConfirm(false);
try {
await api.deleteChat(chat.id);
useChatStore.getState().removeChat(chat.id);
} catch (e) { console.error(e); }
};
const initials = chatName
.split(' ')
.map((w: string) => w[0])
.join('')
.slice(0, 2)
.toUpperCase();
return (
<div className="relative">
<button
onClick={handleClick}
onContextMenu={handleContextMenu}
className={`w-full flex items-center gap-3 px-3 py-3 transition-colors text-left ${
isActive ? 'bg-accent/15 border-r-2 border-accent' : 'hover:bg-surface-hover'
}`}
>
{/* Аватар */}
<div className="relative flex-shrink-0">
{isFavorites ? (
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center shadow-lg">
<Bookmark size={22} className="text-white" />
</div>
) : (
<Avatar src={chatAvatar} name={chatName} size="lg" online={isOnline ? true : undefined} />
)}
</div>
{/* Инфо */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 min-w-0">
{isPinned && <Pin size={12} className="text-vortex-400 flex-shrink-0 rotate-45" />}
<span className="text-sm font-medium text-white truncate">{chatName}</span>
</div>
{timeStr && <span className="text-xs text-zinc-500 flex-shrink-0 ml-2">{timeStr}</span>}
</div>
<div className="flex items-center justify-between mt-0.5">
<div className="flex items-center gap-1 min-w-0 flex-1">
{isMine && lastMessage && !lastMessage.isDeleted && (
<span className="flex-shrink-0">
{isRead ? (
<CheckCheck size={14} className="text-vortex-400" />
) : (
<Check size={14} className="text-zinc-500" />
)}
</span>
)}
<p className={`text-xs truncate ${isTyping ? 'text-vortex-400 font-medium' : draft ? 'text-red-400' : 'text-zinc-400'}`}>
{isTyping ? t('typing') : draft ? <><span className="font-medium">{t('draft')} </span>{stripMarkdown(draft)}</> : previewText}
</p>
</div>
{chat.unreadCount > 0 && !isActive && (
<span className="ml-2 flex-shrink-0 min-w-[20px] h-5 px-1.5 rounded-full bg-accent flex items-center justify-center text-[11px] text-white font-medium">
{chat.unreadCount}
</span>
)}
</div>
</div>
</button>
{/* Context Menu */}
{ctxMenu && (
<div
ref={ctxRef}
className="fixed z-[9999] min-w-[180px] py-1 rounded-xl bg-surface-secondary border border-border shadow-xl animate-in fade-in zoom-in-95 duration-100"
style={{ top: ctxMenu.y, left: ctxMenu.x }}
>
<button
onClick={handlePin}
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"
>
<Pin size={16} className={isPinned ? 'rotate-45' : ''} />
{isPinned ? t('unpinChat') : t('pinChat')}
</button>
<div className="border-t border-border my-1" />
<button
onClick={handleDelete}
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-red-400 hover:bg-red-500/10 transition-colors"
>
<Trash2 size={16} />
{t('deleteChat')}
</button>
</div>
)}
<ConfirmModal
open={showDeleteConfirm}
message={t('deleteChatConfirm')}
onConfirm={confirmDelete}
onCancel={() => setShowDeleteConfirm(false)}
/>
</div>
);
}
export default memo(ChatListItem);

View File

@@ -0,0 +1,877 @@
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Phone,
Video,
MoreVertical,
Search,
X,
ArrowDown,
Trash2,
UserPlus,
Bell,
BellOff,
Settings,
Eraser,
Pin,
Forward,
Bookmark,
} from 'lucide-react';
import { useChatStore } from '../stores/chatStore';
import { useAuthStore } from '../stores/authStore';
import { api } from '../lib/api';
import { getSocket } from '../lib/socket';
import { isChatMuted, toggleMuteChat } from '../lib/sounds';
import { useLang } from '../lib/i18n';
import { formatLastSeen } from '../lib/utils';
import type { UserBasic, Message } from '../lib/types';
import MessageBubble from './MessageBubble';
import MessageInput from './MessageInput';
import TypingIndicator from './TypingIndicator';
import UserProfile from './UserProfile';
import GroupSettings from './GroupSettings';
import ForwardModal from './ForwardModal';
import ConfirmModal from './ConfirmModal';
import Avatar from './Avatar';
import { useThemeStore } from '../stores/themeStore';
export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCall?: (targetUser: UserBasic, type: 'voice' | 'video') => void; onStartGroupCall?: (chatId: string, chatName: string, type: 'voice' | 'video') => void }) {
const { user } = useAuthStore();
const { t, lang } = useLang();
const { chatTheme } = useThemeStore();
const {
activeChat,
chats,
messages,
typingUsers,
pinnedMessages,
isLoadingMessages,
setActiveChat,
} = useChatStore();
const [showTopMenu, setShowTopMenu] = useState(false);
const [showSearch, setShowSearch] = useState(false);
const [searchText, setSearchText] = useState('');
const [searchResults, setSearchResults] = useState<Message[]>([]);
const [profileUserId, setProfileUserId] = useState<string | null>(null);
const [showGroupSettings, setShowGroupSettings] = useState(false);
const [showScrollDown, setShowScrollDown] = useState(false);
const [muted, setMuted] = useState(false);
const [selectionMode, setSelectionMode] = useState(false);
const [selectedMessages, setSelectedMessages] = useState<Set<string>>(new Set());
const [showForwardModal, setShowForwardModal] = useState(false);
const [showDeleteMenu, setShowDeleteMenu] = useState(false);
const [confirmAction, setConfirmAction] = useState<{ message: string; action: () => void } | null>(null);
const [scrollReady, setScrollReady] = useState(false);
const [activeGroupCallParticipants, setActiveGroupCallParticipants] = useState<string[]>([]);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const topMenuRef = useRef<HTMLDivElement>(null);
const deleteMenuRef = useRef<HTMLDivElement>(null);
const chatViewRef = useRef<HTMLDivElement>(null);
const chat = chats.find((c) => c.id === activeChat);
const chatMessages = activeChat ? messages[activeChat] || [] : [];
const pinnedMsg = activeChat ? pinnedMessages[activeChat] : null;
// Количество непрочитанных сообщений (для бейджика)
const unreadCount = chatMessages.filter(
(m) => m.senderId !== user?.id && !m.readBy?.some((r) => r.userId === user?.id)
).length;
const otherMember = chat?.members.find((m) => m.user.id !== user?.id);
const isFavorites = chat?.type === 'favorites';
const chatName = isFavorites
? t('favorites')
: chat?.type === 'personal'
? otherMember?.user.displayName || otherMember?.user.username || t('chat')
: chat?.name || t('group');
const chatAvatar = isFavorites
? null
: chat?.type === 'personal'
? otherMember?.user.avatar
: chat?.avatar;
const isOnline = chat?.type === 'personal' && otherMember?.user.isOnline;
const typingInChat = typingUsers.filter((t) => t.chatId === activeChat && t.userId !== user?.id);
// Load muted state
useEffect(() => {
if (activeChat) {
setMuted(isChatMuted(activeChat));
setScrollReady(false);
setActiveGroupCallParticipants([]);
}
}, [activeChat]);
// Listen for active group calls
useEffect(() => {
const socket = getSocket();
if (!socket) return;
const handler = (data: { chatId: string; participants: string[] }) => {
if (data.chatId === activeChat) {
setActiveGroupCallParticipants(data.participants.filter(p => p !== user?.id));
}
};
socket.on('group_call_active', handler);
// Request current status when opening a group chat
if (activeChat && chat?.type === 'group') {
socket.emit('group_call_status', { chatId: activeChat });
}
return () => { socket.off('group_call_active', handler); };
}, [activeChat, user?.id, chat?.type]);
// Close top menu on click outside
useEffect(() => {
if (!showTopMenu) return;
const handleClick = (e: MouseEvent) => {
if (topMenuRef.current && !topMenuRef.current.contains(e.target as Node)) {
setShowTopMenu(false);
}
};
// Use setTimeout to avoid the same click that opened the menu from closing it
const timer = setTimeout(() => document.addEventListener('click', handleClick), 0);
return () => {
clearTimeout(timer);
document.removeEventListener('click', handleClick);
};
}, [showTopMenu]);
// Close delete menu on click outside
useEffect(() => {
if (!showDeleteMenu) return;
const handleClick = (e: MouseEvent) => {
if (deleteMenuRef.current && !deleteMenuRef.current.contains(e.target as Node)) {
setShowDeleteMenu(false);
}
};
const timer = setTimeout(() => document.addEventListener('click', handleClick), 0);
return () => {
clearTimeout(timer);
document.removeEventListener('click', handleClick);
};
}, [showDeleteMenu]);
// Прокрутка вниз
const scrollToBottom = useCallback((smooth = true) => {
messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'instant', block: 'end' });
}, []);
// Первичная прокрутка при открытии чата или после загрузки (layout effect — до отрисовки)
useLayoutEffect(() => {
if (!isLoadingMessages && messagesContainerRef.current) {
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
setScrollReady(true);
}
}, [activeChat, isLoadingMessages]);
// Scroll on new message arrivals
useEffect(() => {
if (chatMessages.length > 0) {
const lastMsg = chatMessages[chatMessages.length - 1];
if (lastMsg.senderId === user?.id) {
setTimeout(() => scrollToBottom(true), 50);
} else {
// Если пользователь внизу — прокрутить
const container = messagesContainerRef.current;
if (container) {
const isNearBottom =
container.scrollHeight - container.scrollTop - container.clientHeight < 250;
if (isNearBottom) setTimeout(() => scrollToBottom(true), 50);
}
}
}
}, [chatMessages.length, user?.id, scrollToBottom]);
// Read receipts — debounced via ref to avoid excessive emits
const sentReadIdsRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (!activeChat || !user?.id) return;
// Reset tracked IDs when switching chats
sentReadIdsRef.current.clear();
}, [activeChat, user?.id]);
useEffect(() => {
if (!activeChat || !user?.id) return;
const unread = chatMessages.filter(
(m) => m.senderId !== user.id && !m.readBy?.some((r) => r.userId === user.id) && !sentReadIdsRef.current.has(m.id)
);
if (unread.length > 0) {
const ids = unread.map((m) => m.id);
ids.forEach((id) => sentReadIdsRef.current.add(id));
const socket = getSocket();
if (socket) {
socket.emit('read_messages', {
chatId: activeChat,
messageIds: ids,
});
}
// Update local store immediately for current user
useChatStore.getState().markRead(activeChat, user.id, ids);
}
}, [chatMessages.length, activeChat, user?.id]);
// Scroll detection
const handleScroll = () => {
const container = messagesContainerRef.current;
if (!container) return;
const isNearBottom =
container.scrollHeight - container.scrollTop - container.clientHeight < 200;
setShowScrollDown(!isNearBottom);
};
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (!chatViewRef.current) return;
const { left, top } = chatViewRef.current.getBoundingClientRect();
chatViewRef.current.style.setProperty('--mouse-x', `${e.clientX - left}px`);
chatViewRef.current.style.setProperty('--mouse-y', `${e.clientY - top}px`);
};
// Поиск сообщений
useEffect(() => {
if (!searchText.trim() || !activeChat) {
setSearchResults([]);
return;
}
const timer = setTimeout(async () => {
try {
const results = await api.searchMessages(searchText, activeChat);
setSearchResults(results);
} catch (e) {
console.error(e);
}
}, 300);
return () => clearTimeout(timer);
}, [searchText, activeChat]);
const openSearch = () => {
setShowSearch(true);
setShowTopMenu(false);
setTimeout(() => searchInputRef.current?.focus(), 100);
};
if (!activeChat || !chat) {
return (
<div className="flex-1 flex items-center justify-center bg-surface-secondary/50 rounded-[2rem] overflow-hidden border border-white/5 shadow-2xl relative z-0 backdrop-blur-3xl group">
{/* Slowly pulsing purple background as requested */}
<div className="absolute inset-0 pointer-events-none overflow-hidden transition-opacity duration-[10000ms]">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[60vw] h-[60vw] max-w-[800px] max-h-[800px] bg-vortex-600/10 rounded-full blur-[120px] animate-[pulse_8s_ease-in-out_infinite]" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[40vw] h-[40vw] max-w-[500px] max-h-[500px] bg-purple-600/15 rounded-full blur-[100px] animate-[pulse_12s_ease-in-out_infinite_reverse]" />
</div>
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGNpcmNsZSBjeD0iMSIgY3k9IjEiIHI9IjEiIGZpbGw9InJnYmEoMjU1LDI1NSwyNTUsMC4wMSkvPjwvc3ZnPg==')] [mask-image:radial-gradient(ellipse_at_center,black_40%,transparent_100%)] opacity-20 pointer-events-none" />
<div className="text-center relative z-10 w-full max-w-sm px-6">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
className="w-28 h-28 mx-auto mb-8 rounded-[2rem] bg-gradient-to-br from-vortex-500/20 to-purple-600/20 flex items-center justify-center shadow-[0_0_60px_-15px_var(--color-accent)] ring-1 ring-white/10 backdrop-blur-2xl relative"
>
<div className="absolute inset-0 rounded-[2rem] bg-gradient-to-br from-white/[0.05] to-transparent pointer-events-none" />
<img src="/logo.png" alt="Vortex" className="w-16 h-16 rounded-2xl object-cover shadow-2xl transform hover:scale-105 transition-transform" />
</motion.div>
<motion.h2
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.6, delay: 0.1, ease: [0.16, 1, 0.3, 1] }}
className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-vortex-400 via-fuchsia-400 to-indigo-400 mb-4 drop-shadow-lg tracking-tight"
>
Vortex Messenger
</motion.h2>
<motion.p
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.6, delay: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="text-sm font-medium text-zinc-300 bg-white/5 backdrop-blur-lg py-2.5 px-6 rounded-full inline-flex border border-white/10 shadow-lg"
>
{t('selectChat')}
</motion.p>
</div>
</div>
);
}
const initials = chatName
.split(' ')
.map((w: string) => w[0])
.join('')
.slice(0, 2)
.toUpperCase();
const handleToggleSelect = (msgId: string) => {
const newMap = new Set(selectedMessages);
if (newMap.has(msgId)) {
newMap.delete(msgId);
if (newMap.size === 0) setSelectionMode(false);
} else {
newMap.add(msgId);
}
setSelectedMessages(newMap);
};
const handleStartSelection = (msgId: string) => {
setSelectionMode(true);
setSelectedMessages(new Set([msgId]));
};
const handleForward = (targetChatId: string) => {
const socket = getSocket();
if (!socket || !activeChat) return;
const messagesToForward = Array.from(selectedMessages)
.map(id => chatMessages.find(m => m.id === id))
.filter(Boolean)
.sort((a, b) => new Date(a!.createdAt).getTime() - new Date(b!.createdAt).getTime());
messagesToForward.forEach(msg => {
socket.emit('send_message', {
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,
});
});
setSelectionMode(false);
setSelectedMessages(new Set());
setShowForwardModal(false);
setActiveChat(targetChatId);
};
const handleBulkDelete = (deleteForAll: boolean) => {
const socket = getSocket();
if (!socket || !activeChat) return;
const ids = Array.from(selectedMessages);
socket.emit('delete_messages', {
messageIds: ids,
chatId: activeChat,
deleteForAll,
});
// Optimistic local removal
if (!deleteForAll) {
useChatStore.getState().hideMessages(ids, activeChat);
}
setSelectionMode(false);
setSelectedMessages(new Set());
setShowDeleteMenu(false);
};
return (
<div
ref={chatViewRef}
onMouseMove={handleMouseMove}
className={`flex-1 flex flex-col h-full rounded-3xl overflow-hidden shadow-[0_0_120px_-20px_rgba(0,0,0,0.5)] border border-border/50 relative z-0 chat-theme-${chatTheme} transition-colors duration-500`}
>
{/* Шапка чата */}
{selectionMode ? (
<div className="h-[76px] flex items-center justify-between px-6 border-b border-border/40 bg-surface-secondary/80 backdrop-blur-xl z-20 flex-shrink-0 animate-in slide-in-from-top-2">
<div className="flex items-center gap-4 text-white">
<button onClick={() => { setSelectionMode(false); setSelectedMessages(new Set()); }} className="p-2 -ml-2 rounded-full hover:bg-white/10 transition">
<X size={20} className="text-zinc-300" />
</button>
<span className="font-medium text-[15px]">{selectedMessages.size} {t('selected') || 'выбрано'}</span>
</div>
<div className="flex items-center gap-3">
{/* Кнопка удаления с выпадающим меню */}
<div className="relative" ref={deleteMenuRef}>
<button
disabled={selectedMessages.size === 0}
onClick={() => setShowDeleteMenu(!showDeleteMenu)}
className="flex items-center gap-2 px-4 py-2 bg-red-500/90 text-white font-medium rounded-xl hover:bg-red-600 transition-colors disabled:opacity-50"
>
<Trash2 size={18} />
{t('delete')}
</button>
<AnimatePresence>
{showDeleteMenu && (
<motion.div
initial={{ opacity: 0, scale: 0.95, y: -5 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -5 }}
transition={{ duration: 0.15 }}
className="absolute right-0 top-full mt-2 w-56 rounded-2xl bg-surface-secondary/95 backdrop-blur-2xl shadow-2xl z-50 py-1.5 ring-1 ring-border/50 overflow-hidden"
>
<button
onClick={() => handleBulkDelete(false)}
className="flex items-center gap-3 w-full px-4 py-3 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
>
<Trash2 size={16} className="text-zinc-400" />
{t('deleteForMe')}
</button>
<div className="border-t border-border/30 mx-3" />
<button
onClick={() => handleBulkDelete(true)}
className="flex items-center gap-3 w-full px-4 py-3 text-sm text-red-400 hover:bg-red-500/10 hover:text-red-300 transition-colors"
>
<Trash2 size={16} className="text-red-400" />
{t('deleteForAll')}
</button>
</motion.div>
)}
</AnimatePresence>
</div>
<button
disabled={selectedMessages.size === 0}
onClick={() => setShowForwardModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-white text-black font-medium rounded-xl hover:bg-zinc-200 transition-colors disabled:opacity-50"
>
<Forward size={18} />
{t('forward')}
</button>
</div>
</div>
) : (
<div className="h-[76px] flex items-center justify-between px-6 border-b border-border/40 bg-surface-secondary/80 backdrop-blur-xl z-20 flex-shrink-0">
<button
className="flex items-center gap-3 min-w-0 flex-1 group transition-all"
onClick={() => {
if (chat.type === 'personal' && otherMember) {
setProfileUserId(otherMember.user.id);
} else if (chat.type === 'group') {
setShowGroupSettings(true);
}
}}
>
<div className="relative flex-shrink-0 transform transition-transform duration-300 group-hover:scale-105">
{isFavorites ? (
<div className="w-11 h-11 rounded-full bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center shadow-lg ring-2 ring-transparent group-hover:ring-accent/30 transition-all duration-300">
<Bookmark size={20} className="text-white" />
</div>
) : (
<Avatar
src={chatAvatar}
name={chatName}
size="md"
online={isOnline ? true : undefined}
className="ring-2 ring-transparent group-hover:ring-accent/30 transition-all duration-300 rounded-full"
/>
)}
</div>
<div className="min-w-0 text-left">
<h3 className="text-base font-semibold text-white truncate drop-shadow-sm group-hover:text-accent/90 transition-colors">{chatName}</h3>
<p className="text-xs text-zinc-400 truncate">
{isFavorites
? t('favoritesDescription')
: typingInChat.length > 0
? <span className="text-accent font-medium">{t('typing')}</span>
: isOnline
? <span className="text-emerald-400">{t('online')}</span>
: chat.type === 'personal' && otherMember?.user.lastSeen
? `${t('lastSeenAt')} ${formatLastSeen(otherMember.user.lastSeen, lang)}`
: chat.type === 'group'
? `${chat.members.length} ${t('members')}`
: ''}
</p>
</div>
</button>
<div className="flex items-center gap-1.5 ml-4">
{/* Поиск */}
<AnimatePresence>
{showSearch && (
<motion.div
initial={{ width: 0, opacity: 0 }}
animate={{ width: 200, opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
className="overflow-hidden"
>
<input
ref={searchInputRef}
type="text"
placeholder={t('searchMessages')}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-full px-3 py-1.5 rounded-lg bg-surface-tertiary text-sm text-white placeholder-zinc-500 border border-border focus:border-accent"
/>
</motion.div>
)}
</AnimatePresence>
<button
onClick={() => {
if (showSearch) {
setShowSearch(false);
setSearchText('');
setSearchResults([]);
} else {
openSearch();
}
}}
className="p-2 rounded-lg hover:bg-surface-hover transition-colors text-zinc-400 hover:text-white"
>
{showSearch ? <X size={18} /> : <Search size={18} />}
</button>
{!isFavorites && (
<>
<button
onClick={() => {
if (chat.type === 'personal' && otherMember) {
onStartCall?.(otherMember.user, 'voice');
} else if (chat.type === 'group') {
onStartGroupCall?.(chat.id, chat.name || 'Group', 'voice');
}
}}
className="p-2 rounded-lg hover:bg-surface-hover transition-colors text-zinc-400 hover:text-white" title={t('call')}>
<Phone size={18} />
</button>
<button
onClick={() => {
if (chat.type === 'personal' && otherMember) {
onStartCall?.(otherMember.user, 'video');
} else if (chat.type === 'group') {
onStartGroupCall?.(chat.id, chat.name || 'Group', 'video');
}
}}
className="p-2 rounded-lg hover:bg-surface-hover transition-colors text-zinc-400 hover:text-white" title={t('videoCall')}>
<Video size={18} />
</button>
</>
)}
{/* Меню */}
<div className="relative" ref={topMenuRef}>
<button
onClick={() => setShowTopMenu(!showTopMenu)}
className="p-2 rounded-lg hover:bg-surface-hover transition-colors text-zinc-400 hover:text-white"
>
<MoreVertical size={18} />
</button>
<AnimatePresence>
{showTopMenu && (
<motion.div
initial={{ opacity: 0, scale: 0.95, y: -5 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -5 }}
transition={{ duration: 0.15 }}
className="absolute right-0 top-full mt-2 w-56 rounded-2xl glass-strong shadow-2xl z-50 py-1.5 ring-1 ring-border/50 backdrop-blur-2xl"
>
<button
onClick={openSearch}
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"
>
<Search size={16} />
{t('searchMessages')}
</button>
{chat.type === 'personal' && otherMember && (
<button
onClick={() => {
setShowTopMenu(false);
setProfileUserId(otherMember.user.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"
>
<UserPlus size={16} />
{t('userProfile')}
</button>
)}
<button
onClick={() => {
if (activeChat) {
const nowMuted = toggleMuteChat(activeChat);
setMuted(nowMuted);
}
}}
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"
>
{muted ? <Bell size={16} /> : <BellOff size={16} />}
{muted ? t('enableSound') : t('disableSound')}
</button>
{chat.type === 'group' && (
<button
onClick={() => {
setShowTopMenu(false);
setShowGroupSettings(true);
}}
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"
>
<Settings size={16} />
{t('groupSettings')}
</button>
)}
<div className="border-t border-border my-1" />
<button
onClick={() => {
setShowTopMenu(false);
if (activeChat) {
setConfirmAction({
message: t('clearChatConfirm'),
action: async () => {
try {
await api.clearChat(activeChat);
useChatStore.getState().clearMessages(activeChat);
} catch (e) {
console.error(e);
}
},
});
}
}}
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"
>
<Eraser size={16} />
{t('clearChat')}
</button>
<button
onClick={() => {
setShowTopMenu(false);
if (activeChat) {
setConfirmAction({
message: t('deleteChatConfirm'),
action: async () => {
try {
await api.deleteChat(activeChat);
useChatStore.getState().removeChat(activeChat);
} catch (e) {
console.error(e);
}
},
});
}
}}
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-red-400 hover:bg-red-500/10 transition-colors"
>
<Trash2 size={16} />
{t('deleteChat')}
</button>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
)}
{/* Результаты поиска */}
<AnimatePresence>
{showSearch && searchResults.length > 0 && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="absolute top-14 left-0 right-0 z-20 max-h-60 overflow-y-auto glass-strong border-b border-border"
>
{searchResults.map((msg) => (
<div
key={msg.id}
className="px-4 py-2 hover:bg-surface-hover cursor-pointer border-b border-border/50 last:border-0"
onClick={() => {
// Scroll to message
const el = document.getElementById(`msg-${msg.id}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('bg-vortex-500/20');
setTimeout(() => el.classList.remove('bg-vortex-500/20'), 2000);
}
setShowSearch(false);
setSearchText('');
setSearchResults([]);
}}
>
<div className="flex items-center gap-2 mb-0.5">
<span className="text-xs font-medium text-vortex-400">
{msg.sender?.displayName || msg.sender?.username}
</span>
<span className="text-xs text-zinc-600">
{new Date(msg.createdAt).toLocaleDateString(lang === 'ru' ? 'ru' : 'en')}
</span>
</div>
<p className="text-sm text-zinc-300 truncate">{msg.content}</p>
</div>
))}
</motion.div>
)}
</AnimatePresence>
{/* Закреплённое сообщение */}
{/* Active group call banner */}
{chat?.type === 'group' && activeGroupCallParticipants.length > 0 && (
<button
onClick={() => onStartGroupCall?.(chat.id, chat.name || 'Group', 'voice')}
className="flex items-center gap-3 px-4 py-2.5 border-b border-border bg-emerald-500/10 hover:bg-emerald-500/20 transition-colors text-left w-full flex-shrink-0"
>
<div className="w-8 h-8 rounded-full bg-emerald-500/20 flex items-center justify-center">
<Phone size={14} className="text-emerald-400" />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-emerald-400">{t('activeCall')}</p>
<p className="text-sm text-zinc-300">{activeGroupCallParticipants.length} {t('participants')}</p>
</div>
<span className="text-xs text-emerald-400 font-medium px-3 py-1 rounded-full bg-emerald-500/20">{t('joinCall')}</span>
</button>
)}
{pinnedMsg && (
<button
onClick={() => {
const el = document.getElementById(`msg-${pinnedMsg.id}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('bg-vortex-500/20');
setTimeout(() => el.classList.remove('bg-vortex-500/20'), 2000);
}
}}
className="flex items-center gap-3 px-4 py-2 border-b border-border bg-surface-secondary/60 hover:bg-surface-hover transition-colors text-left w-full flex-shrink-0"
>
<Pin size={16} className="text-vortex-400 flex-shrink-0 rotate-45" />
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-vortex-400">{t('pinnedMessage')}</p>
<p className="text-sm text-zinc-300 truncate">
{pinnedMsg.content || (pinnedMsg.media?.length > 0 ? t('media') : '...')}
</p>
</div>
<X
size={16}
className="text-zinc-500 hover:text-white flex-shrink-0 transition-colors"
onClick={(e) => {
e.stopPropagation();
const socket = getSocket();
if (socket && activeChat) {
socket.emit('unpin_message', { messageId: pinnedMsg.id, chatId: activeChat });
}
}}
/>
</button>
)}
{/* Сообщения */}
<div
ref={messagesContainerRef}
onScroll={handleScroll}
className={`flex-1 overflow-y-auto px-6 pt-6 pb-2 relative z-10 ${!scrollReady && !isLoadingMessages && chatMessages.length > 0 ? 'invisible' : ''}`}
>
{isLoadingMessages ? (
<div className="flex justify-center py-8">
<div className="w-6 h-6 border-2 border-vortex-500 border-t-transparent rounded-full animate-spin" />
</div>
) : chatMessages.length === 0 ? (
<div className="flex items-center justify-center h-full">
<p className="text-sm text-zinc-500">{t('noMessages')}</p>
</div>
) : (
<div className="space-y-1 max-w-3xl mx-auto">
{chatMessages.map((msg, i) => {
const prevMsg = i > 0 ? chatMessages[i - 1] : null;
const showAvatar = !prevMsg || prevMsg.senderId !== msg.senderId;
const showDate =
!prevMsg ||
new Date(msg.createdAt).toDateString() !== new Date(prevMsg.createdAt).toDateString();
return (
<div key={msg.id} id={`msg-${msg.id}`} className="transition-colors duration-500">
{showDate && (
<div className="flex justify-center my-4">
<span className="px-3 py-1 rounded-full text-xs text-zinc-400 glass">
{new Date(msg.createdAt).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', {
day: 'numeric',
month: 'long',
})}
</span>
</div>
)}
<MessageBubble
message={msg}
isMine={msg.senderId === user?.id}
showAvatar={showAvatar}
onViewProfile={(userId) => setProfileUserId(userId)}
selectionMode={selectionMode}
isSelected={selectedMessages.has(msg.id)}
onToggleSelect={handleToggleSelect}
onStartSelectionMode={handleStartSelection}
/>
</div>
);
})}
<div ref={messagesEndRef} className="h-4" /> {/* Empty spacer for the bottom scroll boundary */}
</div>
)}
</div>
{/* Кнопка прокрутки вниз */}
<AnimatePresence>
{showScrollDown && (
<motion.button
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
onClick={() => scrollToBottom()}
className="absolute bottom-24 right-6 w-11 h-11 rounded-full bg-surface-tertiary/90 backdrop-blur-md border border-border shadow-2xl flex items-center justify-center text-zinc-400 hover:text-white hover:bg-surface-hover hover:scale-105 transition-all z-10"
>
<ArrowDown size={20} />
{unreadCount > 0 && (
<motion.span
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="absolute -top-1.5 -right-1.5 min-w-[20px] h-5 px-1.5 rounded-full bg-accent text-white text-[11px] font-bold flex items-center justify-center shadow-lg border-2 border-surface-secondary"
>
{unreadCount > 99 ? '99+' : unreadCount}
</motion.span>
)}
</motion.button>
)}
</AnimatePresence>
{/* Typing индикатор */}
{typingInChat.length > 0 && (
<div className="px-4 pb-1">
<TypingIndicator />
</div>
)}
{/* Ввод сообщения */}
<MessageInput chatId={activeChat} />
{/* Профиль пользователя */}
<AnimatePresence>
{profileUserId && (
<UserProfile
userId={profileUserId}
chatId={activeChat || undefined}
onClose={() => setProfileUserId(null)}
isSelf={profileUserId === user?.id}
/>
)}
</AnimatePresence>
{/* Настройки группы */}
<AnimatePresence>
{showGroupSettings && chat && chat.type === 'group' && (
<GroupSettings
chat={chat}
onClose={() => setShowGroupSettings(false)}
/>
)}
</AnimatePresence>
<AnimatePresence>
{showForwardModal && (
<ForwardModal
onClose={() => setShowForwardModal(false)}
onForward={handleForward}
/>
)}
</AnimatePresence>
<ConfirmModal
open={!!confirmAction}
message={confirmAction?.message || ''}
onConfirm={() => {
confirmAction?.action();
setConfirmAction(null);
}}
onCancel={() => setConfirmAction(null)}
/>
</div>
);
}

View File

@@ -0,0 +1,81 @@
import { motion, AnimatePresence } from 'framer-motion';
import { AlertTriangle } from 'lucide-react';
import { useLang } from '../lib/i18n';
interface ConfirmModalProps {
open: boolean;
title?: string;
message: string;
confirmText?: string;
cancelText?: string;
danger?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
export default function ConfirmModal({
open,
title,
message,
confirmText,
cancelText,
danger = true,
onConfirm,
onCancel,
}: ConfirmModalProps) {
const { t } = useLang();
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/60 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
>
<motion.div
initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.9, opacity: 0, y: 20 }}
transition={{ type: 'spring', duration: 0.35, bounce: 0.2 }}
role="dialog"
aria-modal="true"
aria-label={title}
className="w-full max-w-[360px] mx-4 rounded-2xl bg-surface-secondary border border-border/50 shadow-2xl overflow-hidden"
>
<div className="p-5 flex flex-col items-center text-center">
<div className={`w-12 h-12 rounded-full flex items-center justify-center mb-3 ${danger ? 'bg-red-500/15' : 'bg-accent/15'}`}>
<AlertTriangle size={24} className={danger ? 'text-red-400' : 'text-accent'} />
</div>
{title && (
<h3 className="text-white text-base font-semibold mb-1">{title}</h3>
)}
<p className="text-zinc-400 text-sm leading-relaxed">{message}</p>
</div>
<div className="flex border-t border-border/40">
<button
onClick={onCancel}
className="flex-1 py-3 text-sm font-medium text-zinc-400 hover:bg-surface-hover hover:text-white transition-colors"
>
{cancelText || t('cancel')}
</button>
<div className="w-px bg-border/40" />
<button
onClick={onConfirm}
className={`flex-1 py-3 text-sm font-medium transition-colors ${
danger
? 'text-red-400 hover:bg-red-500/10 hover:text-red-300'
: 'text-accent hover:bg-accent/10'
}`}
>
{confirmText || t('confirm')}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,337 @@
import { useState, useRef, useEffect, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronLeft, ChevronRight, Calendar } from 'lucide-react';
import { useLang } from '../lib/i18n';
interface DatePickerProps {
value: string;
onChange: (val: string) => void;
}
type View = 'days' | 'months' | 'years';
export default function DatePicker({ value, onChange }: DatePickerProps) {
const { t, lang } = useLang();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ top: number; left: number; openUp: boolean } | null>(null);
const today = new Date();
const parsed = value ? new Date(value) : null;
const [viewYear, setViewYear] = useState(parsed?.getFullYear() || today.getFullYear());
const [viewMonth, setViewMonth] = useState(parsed?.getMonth() || today.getMonth());
const [view, setView] = useState<View>('days');
const [yearRangeStart, setYearRangeStart] = useState(() => {
const y = parsed?.getFullYear() || today.getFullYear();
return y - (y % 24);
});
useEffect(() => {
if (!open) return;
const handle = (e: MouseEvent) => {
const target = e.target as Node;
if (ref.current && !ref.current.contains(target) && dropdownRef.current && !dropdownRef.current.contains(target)) {
setOpen(false);
}
};
setTimeout(() => document.addEventListener('click', handle), 0);
return () => document.removeEventListener('click', handle);
}, [open]);
// Reset view to days when reopened & compute position for portal
useEffect(() => {
if (open) {
setView('days');
if (parsed) {
setViewYear(parsed.getFullYear());
setViewMonth(parsed.getMonth());
setYearRangeStart(parsed.getFullYear() - (parsed.getFullYear() % 24));
}
// Compute dropdown position
if (ref.current) {
const rect = ref.current.getBoundingClientRect();
const dropdownHeight = 370;
const spaceBelow = window.innerHeight - rect.bottom;
const openUp = spaceBelow < dropdownHeight;
setPos({
top: openUp ? rect.top : rect.bottom + 8,
left: rect.left,
openUp,
});
}
} else {
setPos(null);
}
}, [open]);
const months = t('months');
const weekDays = t('weekDays');
const shortMonths = useMemo(() => months.map(m => m.slice(0, 3)), [months]);
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const firstDayRaw = new Date(viewYear, viewMonth, 1).getDay();
const firstDay = firstDayRaw === 0 ? 6 : firstDayRaw - 1;
const prevMonth = () => {
if (viewMonth === 0) { setViewMonth(11); setViewYear(viewYear - 1); }
else setViewMonth(viewMonth - 1);
};
const nextMonth = () => {
if (viewMonth === 11) { setViewMonth(0); setViewYear(viewYear + 1); }
else setViewMonth(viewMonth + 1);
};
const selectDay = (day: number) => {
const m = String(viewMonth + 1).padStart(2, '0');
const d = String(day).padStart(2, '0');
onChange(`${viewYear}-${m}-${d}`);
setOpen(false);
};
const isSelected = (day: number) => {
if (!parsed) return false;
return parsed.getFullYear() === viewYear && parsed.getMonth() === viewMonth && parsed.getDate() === day;
};
const isToday = (day: number) => {
return today.getFullYear() === viewYear && today.getMonth() === viewMonth && today.getDate() === day;
};
const displayValue = parsed
? parsed.toLocaleDateString(useLang.getState().lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'long', year: 'numeric' })
: '';
const cells: (number | null)[] = [];
for (let i = 0; i < firstDay; i++) cells.push(null);
for (let d = 1; d <= daysInMonth; d++) cells.push(d);
// 24 years per page
const yearCells = useMemo(() => {
const arr: number[] = [];
for (let i = 0; i < 24; i++) arr.push(yearRangeStart + i);
return arr;
}, [yearRangeStart]);
const handleHeaderClick = () => {
if (view === 'days') {
setView('months');
} else if (view === 'months') {
setYearRangeStart(viewYear - (viewYear % 24));
setView('years');
}
};
const selectMonth = (monthIdx: number) => {
setViewMonth(monthIdx);
setView('days');
};
const selectYear = (year: number) => {
setViewYear(year);
setView('months');
};
const headerLabel = view === 'days'
? `${months[viewMonth]} ${viewYear}`
: view === 'months'
? `${viewYear}`
: `${yearRangeStart}${yearRangeStart + 23}`;
const handlePrev = () => {
if (view === 'days') prevMonth();
else if (view === 'months') setViewYear(y => y - 1);
else setYearRangeStart(s => s - 24);
};
const handleNext = () => {
if (view === 'days') nextMonth();
else if (view === 'months') setViewYear(y => y + 1);
else setYearRangeStart(s => s + 24);
};
return (
<div className="relative" ref={ref}>
<button
type="button"
onClick={() => setOpen(!open)}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg bg-surface-tertiary text-sm text-white border border-border hover:border-accent transition-colors text-left"
>
<Calendar size={14} className="text-zinc-500 flex-shrink-0" />
<span className={displayValue ? 'text-white' : 'text-zinc-500'}>
{displayValue || (lang === 'ru' ? 'дд.мм.гггг' : 'mm/dd/yyyy')}
</span>
</button>
{open && createPortal(
<AnimatePresence>
{pos && (
<motion.div
ref={dropdownRef}
initial={{ opacity: 0, y: pos.openUp ? 8 : -8, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: pos.openUp ? 8 : -8, scale: 0.95 }}
transition={{ duration: 0.15 }}
className="fixed w-72 glass-strong rounded-xl shadow-2xl z-[9999] overflow-hidden border border-border"
style={{
left: pos.left,
...(pos.openUp
? { bottom: window.innerHeight - pos.top + 8 }
: { top: pos.top }),
}}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<button type="button" onClick={handlePrev} className="p-1 rounded-lg hover:bg-surface-hover text-zinc-400 hover:text-white transition-colors">
<ChevronLeft size={18} />
</button>
<button
type="button"
onClick={handleHeaderClick}
className={`text-sm font-medium text-white transition-colors ${view !== 'years' ? 'hover:text-accent cursor-pointer' : 'cursor-default'}`}
>
{headerLabel}
</button>
<button type="button" onClick={handleNext} className="p-1 rounded-lg hover:bg-surface-hover text-zinc-400 hover:text-white transition-colors">
<ChevronRight size={18} />
</button>
</div>
<AnimatePresence mode="wait">
{/* ===== DAYS VIEW ===== */}
{view === 'days' && (
<motion.div
key="days"
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.12 }}
>
<div className="grid grid-cols-7 px-3 pt-2">
{weekDays.map((d) => (
<div key={d} className="text-center text-[11px] text-zinc-500 font-medium py-1">{d}</div>
))}
</div>
<div className="grid grid-cols-7 px-3 pb-2">
{cells.map((day, i) => (
<div key={i} className="flex items-center justify-center">
{day ? (
<button
type="button"
onClick={() => selectDay(day)}
className={`w-8 h-8 rounded-full text-sm flex items-center justify-center transition-all ${
isSelected(day)
? 'bg-accent text-white font-semibold shadow-lg shadow-accent/30'
: isToday(day)
? 'text-vortex-400 font-semibold ring-1 ring-vortex-500/50'
: 'text-zinc-300 hover:bg-surface-hover'
}`}
>
{day}
</button>
) : (
<span className="w-8 h-8" />
)}
</div>
))}
</div>
</motion.div>
)}
{/* ===== MONTHS VIEW ===== */}
{view === 'months' && (
<motion.div
key="months"
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.12 }}
className="grid grid-cols-3 gap-1 p-3"
>
{shortMonths.map((m, idx) => {
const isCurrentMonth = viewYear === today.getFullYear() && idx === today.getMonth();
const isSelectedMonth = parsed && viewYear === parsed.getFullYear() && idx === parsed.getMonth();
return (
<button
key={idx}
type="button"
onClick={() => selectMonth(idx)}
className={`py-2.5 rounded-lg text-sm font-medium transition-all ${
isSelectedMonth
? 'bg-accent text-white shadow-lg shadow-accent/30'
: isCurrentMonth
? 'text-vortex-400 ring-1 ring-vortex-500/50'
: 'text-zinc-300 hover:bg-surface-hover'
}`}
>
{m}
</button>
);
})}
</motion.div>
)}
{/* ===== YEARS VIEW ===== */}
{view === 'years' && (
<motion.div
key="years"
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.12 }}
className="grid grid-cols-4 gap-1 p-3"
>
{yearCells.map((yr) => {
const isCurrentYear = yr === today.getFullYear();
const isSelectedYear = parsed && yr === parsed.getFullYear();
return (
<button
key={yr}
type="button"
onClick={() => selectYear(yr)}
className={`py-2 rounded-lg text-sm font-medium transition-all ${
isSelectedYear
? 'bg-accent text-white shadow-lg shadow-accent/30'
: isCurrentYear
? 'text-vortex-400 ring-1 ring-vortex-500/50'
: 'text-zinc-300 hover:bg-surface-hover'
}`}
>
{yr}
</button>
);
})}
</motion.div>
)}
</AnimatePresence>
{/* Footer */}
<div className="flex items-center justify-between px-4 py-2 border-t border-border">
<button
type="button"
onClick={() => { onChange(''); setOpen(false); }}
className="text-xs text-zinc-400 hover:text-zinc-200 transition-colors"
>
{t('clear')}
</button>
<button
type="button"
onClick={() => {
const m = String(today.getMonth() + 1).padStart(2, '0');
const d = String(today.getDate()).padStart(2, '0');
onChange(`${today.getFullYear()}-${m}-${d}`);
setOpen(false);
}}
className="text-xs text-vortex-400 hover:text-vortex-300 transition-colors"
>
{t('today')}
</button>
</div>
</motion.div>
)}
</AnimatePresence>,
document.body)}
</div>
);
}

View File

@@ -0,0 +1,211 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import Picker from '@emoji-mart/react';
import data from '@emoji-mart/data';
import { Search, TrendingUp, Loader2 } from 'lucide-react';
import { useLang } from '../lib/i18n';
interface TenorGif {
id: string;
media_formats?: {
gif?: { url: string };
tinygif?: { url: string };
};
content_description?: string;
}
interface EmojiPickerProps {
onSelect: (emoji: string) => void;
onSelectGif?: (url: string, preview: string) => void;
onClose: () => void;
}
const getTenorKey = () => localStorage.getItem('vortex_tenor_key') || '';
export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPickerProps) {
const { lang, t } = useLang();
const [tab, setTab] = useState<'emoji' | 'gif'>('emoji');
const [gifQuery, setGifQuery] = useState('');
const [gifs, setGifs] = useState<TenorGif[]>([]);
const [gifLoading, setGifLoading] = useState(false);
const [trendingGifs, setTrendingGifs] = useState<TenorGif[]>([]);
const gifSearchRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
// Load trending GIFs
useEffect(() => {
if (tab === 'gif' && getTenorKey() && trendingGifs.length === 0) {
setGifLoading(true);
fetch(`https://tenor.googleapis.com/v2/featured?key=${getTenorKey()}&limit=30&media_filter=gif,tinygif`)
.then(r => r.json())
.then(d => { setTrendingGifs(d.results || []); setGifLoading(false); })
.catch(() => setGifLoading(false));
}
}, [tab]);
const searchGifs = useCallback((q: string) => {
if (!getTenorKey() || !q.trim()) { setGifs([]); return; }
setGifLoading(true);
fetch(`https://tenor.googleapis.com/v2/search?key=${getTenorKey()}&q=${encodeURIComponent(q)}&limit=30&media_filter=gif,tinygif`)
.then(r => r.json())
.then(d => { setGifs(d.results || []); setGifLoading(false); })
.catch(() => setGifLoading(false));
}, []);
const handleGifSearch = (q: string) => {
setGifQuery(q);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => searchGifs(q), 400);
};
const pickGif = (gif: TenorGif) => {
const url = gif.media_formats?.gif?.url || gif.media_formats?.tinygif?.url || '';
const preview = gif.media_formats?.tinygif?.url || url;
if (onSelectGif && url) {
onSelectGif(url, preview);
}
};
const displayGifs = gifQuery.trim() ? gifs : trendingGifs;
const anchorRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
useEffect(() => {
const update = () => {
const el = anchorRef.current?.parentElement;
if (!el) return;
const rect = el.getBoundingClientRect();
const w = tab === 'gif' ? 360 : 352;
let left = rect.right - w;
if (left < 8) left = 8;
setPos({ top: rect.top - 8, left });
};
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, [tab]);
const pickerWidth = tab === 'gif' ? 360 : 352;
return (
<>
<div ref={anchorRef} className="hidden" />
{createPortal(
<>
<div className="fixed inset-0 z-[9990]" onClick={onClose} />
<div
className="fixed z-[9991] rounded-2xl shadow-2xl border border-white/10"
style={{
width: pickerWidth,
bottom: pos ? `${window.innerHeight - pos.top}px` : undefined,
left: pos ? pos.left : undefined,
background: 'rgb(17, 17, 19)',
visibility: pos ? 'visible' : 'hidden',
}}
>
{/* Tabs */}
<div className="flex border-b border-white/10">
<button
onClick={() => setTab('emoji')}
className={`flex-1 py-2.5 text-xs font-semibold tracking-wide transition-colors ${tab === 'emoji' ? 'text-white border-b-2 border-accent' : 'text-zinc-500 hover:text-zinc-300'}`}
>
EMOJI
</button>
{(getTenorKey() || onSelectGif) && (
<button
onClick={() => { setTab('gif'); setTimeout(() => gifSearchRef.current?.focus(), 100); }}
className={`flex-1 py-2.5 text-xs font-semibold tracking-wide transition-colors ${tab === 'gif' ? 'text-white border-b-2 border-accent' : 'text-zinc-500 hover:text-zinc-300'}`}
>
GIF
</button>
)}
</div>
{/* Emoji tab */}
{tab === 'emoji' && (
<Picker
data={data}
onEmojiSelect={(e: { native: string }) => onSelect(e.native)}
theme="dark"
locale={lang === 'ru' ? 'ru' : 'en'}
set="native"
previewPosition="none"
skinTonePosition="search"
perLine={9}
emojiSize={28}
emojiButtonSize={36}
maxFrequentRows={2}
navPosition="bottom"
dynamicWidth={false}
/>
)}
{/* GIF tab */}
{tab === 'gif' && (
<div className="flex flex-col h-[calc(100%-41px)]">
{!getTenorKey() ? (
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center">
<p className="text-sm text-zinc-400 mb-2">{t('tenorKeyRequired')}</p>
<p className="text-xs text-zinc-500 mb-3">{t('openConsoleRun')}</p>
<code className="text-xs bg-black/30 px-3 py-1.5 rounded-lg text-vortex-400">
localStorage.setItem('vortex_tenor_key', 'YOUR_KEY')
</code>
</div>
) : (
<>
<div className="p-2">
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" />
<input
ref={gifSearchRef}
value={gifQuery}
onChange={(e) => handleGifSearch(e.target.value)}
placeholder={t('searchGifs')}
className="w-full pl-8 pr-3 py-2 rounded-lg bg-surface-tertiary/80 text-sm text-white placeholder-zinc-500 border border-border/30 focus:border-accent/50 outline-none transition-colors"
/>
</div>
</div>
{!gifQuery.trim() && !gifLoading && (
<div className="flex items-center gap-1.5 px-3 pb-1">
<TrendingUp size={12} className="text-zinc-500" />
<span className="text-[10px] text-zinc-500 uppercase tracking-wider font-semibold">{t('trending')}</span>
</div>
)}
<div className="flex-1 overflow-y-auto p-1.5">
{gifLoading ? (
<div className="flex items-center justify-center py-10">
<Loader2 size={24} className="text-zinc-500 animate-spin" />
</div>
) : displayGifs.length === 0 ? (
<p className="text-center text-xs text-zinc-500 py-10">{gifQuery ? t('nothingFound') : ''}</p>
) : (
<div className="columns-2 gap-1.5">
{displayGifs.map((gif) => (
<button
key={gif.id}
onClick={() => { pickGif(gif); onClose(); }}
className="w-full mb-1.5 rounded-lg overflow-hidden hover:opacity-80 transition-opacity block"
>
<img
src={gif.media_formats?.tinygif?.url || gif.media_formats?.gif?.url}
alt={gif.content_description || 'GIF'}
className="w-full h-auto rounded-lg"
loading="lazy"
/>
</button>
))}
</div>
)}
</div>
</>
)}
</div>
)}
</div>
</>,
document.body
)}
</>
);
}

View File

@@ -0,0 +1,97 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Search } from 'lucide-react';
import { useChatStore } from '../stores/chatStore';
import { useAuthStore } from '../stores/authStore';
import { useLang } from '../lib/i18n';
import Avatar from './Avatar';
interface ForwardModalProps {
onClose: () => void;
onForward: (chatId: string) => void;
}
export default function ForwardModal({ onClose, onForward }: ForwardModalProps) {
const { chats } = useChatStore();
const { user } = useAuthStore();
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());
});
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
role="dialog"
aria-modal="true"
aria-label={t('forward')}
className="relative w-full max-w-md bg-surface-secondary/90 glass-strong rounded-3xl overflow-hidden shadow-2xl border border-border"
>
<div className="p-4 flex items-center justify-between border-b border-white/5">
<h2 className="text-lg font-semibold text-white">{t('forwardMessage')}</h2>
<button
onClick={onClose}
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-white/10 transition-colors"
>
<X size={20} className="text-zinc-400" />
</button>
</div>
<div className="p-4">
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" size={18} />
<input
type="text"
placeholder={t('searchChats') || 'Поиск чатов'}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-black/20 border border-white/10 rounded-xl py-2.5 pl-10 pr-4 text-white placeholder-zinc-500 focus:outline-none focus:border-vortex-500 transition-colors"
/>
</div>
<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 chatAvatar = chat.type === 'personal'
? otherMember?.user.avatar
: chat.avatar;
return (
<button
key={chat.id}
onClick={() => onForward(chat.id)}
className="w-full flex items-center gap-3 p-2 rounded-xl hover:bg-white/5 transition-colors text-left"
>
<Avatar src={chatAvatar} name={chatName} size="md" />
<span className="text-white font-medium flex-1 truncate">{chatName}</span>
</button>
);
})}
{filteredChats.length === 0 && (
<p className="text-center text-zinc-500 py-4 text-sm">{t('nothingFound')}</p>
)}
</div>
</div>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,909 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Phone, PhoneOff, Video, VideoOff, Mic, MicOff, Monitor, MonitorOff, Minimize2, Volume2, ShieldCheck, ShieldOff, ChevronUp } from 'lucide-react';
import { getSocket } from '../lib/socket';
import { api } from '../lib/api';
import { useLang } from '../lib/i18n';
interface ParticipantInfo {
id: string;
username: string;
displayName?: string;
avatar?: string | null;
}
interface PeerState {
pc: RTCPeerConnection;
remoteStream: MediaStream;
hasVideo: boolean;
}
interface GroupCallModalProps {
isOpen: boolean;
onClose: () => void;
chatId: string;
chatName: string;
callType: 'voice' | 'video';
}
// ICE config reuse
let cachedIceConfig: RTCConfiguration | null = null;
let iceCacheFetchedAt = 0;
const ICE_CACHE_TTL = 3600_000;
const FALLBACK_ICE: RTCConfiguration = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
],
};
async function getIceServers(): Promise<RTCConfiguration> {
if (cachedIceConfig && Date.now() - iceCacheFetchedAt < ICE_CACHE_TTL) return cachedIceConfig;
try {
const data = await api.getIceServers();
if (data.iceServers?.length > 0) {
cachedIceConfig = { iceServers: data.iceServers };
iceCacheFetchedAt = Date.now();
return cachedIceConfig;
}
} catch { /* fallback */ }
return FALLBACK_ICE;
}
export default function GroupCallModal({ isOpen, onClose, chatId, chatName, callType: initialCallType }: GroupCallModalProps) {
const { t } = useLang();
const [isMuted, setIsMuted] = useState(false);
const [isVideoOff, setIsVideoOff] = useState(initialCallType === 'voice');
const [isScreenSharing, setIsScreenSharing] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
const [duration, setDuration] = useState(0);
const [participants, setParticipants] = useState<Map<string, ParticipantInfo>>(new Map());
const [remoteVolume, setRemoteVolume] = useState(1);
const [showVolumeSlider, setShowVolumeSlider] = useState(false);
const [noiseSuppression, setNoiseSuppression] = useState(false);
const [joined, setJoined] = useState(false);
const [microphones, setMicrophones] = useState<MediaDeviceInfo[]>([]);
const [activeMicId, setActiveMicId] = useState<string>('');
const [showMicMenu, setShowMicMenu] = useState(false);
const localStreamRef = useRef<MediaStream | null>(null);
const screenStreamRef = useRef<MediaStream | null>(null);
const peersRef = useRef<Map<string, PeerState>>(new Map());
const localVideoRef = useRef<HTMLVideoElement>(null);
const timerRef = useRef<ReturnType<typeof setInterval>>(undefined);
const joinedRef = useRef(false);
const remoteAudioRefs = useRef<Map<string, HTMLAudioElement>>(new Map());
// Force re-render when peer video state changes
const [, forceUpdate] = useState(0);
// Noise gate refs
const noiseGateCtxRef = useRef<AudioContext | null>(null);
const noiseGateGainRef = useRef<GainNode | null>(null);
const noiseGateRafRef = useRef<number>(0);
const noiseGateTrackRef = useRef<MediaStreamTrack | null>(null);
const cleanup = useCallback(() => {
if (timerRef.current) clearInterval(timerRef.current);
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach(t => t.stop());
localStreamRef.current = null;
}
if (screenStreamRef.current) {
screenStreamRef.current.getTracks().forEach(t => t.stop());
screenStreamRef.current = null;
}
for (const [, peer] of peersRef.current) {
peer.pc.close();
}
peersRef.current.clear();
remoteAudioRefs.current.clear();
// Clean up noise gate
if (noiseGateRafRef.current) cancelAnimationFrame(noiseGateRafRef.current);
if (noiseGateTrackRef.current) { noiseGateTrackRef.current.stop(); noiseGateTrackRef.current = null; }
if (noiseGateCtxRef.current) { noiseGateCtxRef.current.close().catch(() => {}); noiseGateCtxRef.current = null; }
noiseGateGainRef.current = null;
setParticipants(new Map());
setDuration(0);
setIsMuted(false);
setIsVideoOff(initialCallType === 'voice');
setIsScreenSharing(false);
setIsMinimized(false);
setJoined(false);
joinedRef.current = false;
setNoiseSuppression(false);
setShowMicMenu(false);
}, [initialCallType]);
const createPeerConnection = useCallback(async (targetUserId: string, initiator: boolean) => {
const iceConfig = await getIceServers();
const pc = new RTCPeerConnection(iceConfig);
const remoteStream = new MediaStream();
const peerState: PeerState = { pc, remoteStream, hasVideo: false };
peersRef.current.set(targetUserId, peerState);
// Add local tracks
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach(track => pc.addTrack(track, localStreamRef.current!));
}
pc.onicecandidate = (e) => {
if (e.candidate) {
const socket = getSocket();
socket?.emit('group_ice_candidate', { chatId, targetUserId, candidate: e.candidate });
}
};
pc.ontrack = (e) => {
if (!remoteStream.getTracks().includes(e.track)) {
remoteStream.addTrack(e.track);
}
const hasVid = remoteStream.getVideoTracks().some(t => t.readyState === 'live' && t.enabled && !t.muted);
peerState.hasVideo = hasVid;
e.track.onunmute = () => {
peerState.hasVideo = remoteStream.getVideoTracks().some(t => t.readyState === 'live' && t.enabled && !t.muted);
forceUpdate(n => n + 1);
};
e.track.onmute = () => {
peerState.hasVideo = remoteStream.getVideoTracks().some(t => t.readyState === 'live' && t.enabled && !t.muted);
forceUpdate(n => n + 1);
};
// Play audio through audio element
const audioEl = remoteAudioRefs.current.get(targetUserId);
if (audioEl && audioEl.srcObject !== remoteStream) {
audioEl.srcObject = remoteStream;
audioEl.volume = remoteVolume;
audioEl.play().catch(() => {});
}
forceUpdate(n => n + 1);
};
pc.onconnectionstatechange = () => {
if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
console.warn(`[GroupCall] Peer ${targetUserId} connection ${pc.connectionState}`);
}
};
if (initiator) {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const socket = getSocket();
socket?.emit('group_call_offer', { chatId, targetUserId, offer: pc.localDescription });
}
return peerState;
}, [chatId, remoteVolume]);
const joinCall = useCallback(async () => {
if (joinedRef.current) return;
joinedRef.current = true;
try {
const wantVideo = initialCallType === 'video';
const stream = await navigator.mediaDevices.getUserMedia({
audio: { noiseSuppression: true, echoCancellation: true, autoGainControl: true },
video: wantVideo,
}).catch(async () => {
// Fallback to audio only
return navigator.mediaDevices.getUserMedia({ audio: { noiseSuppression: true, echoCancellation: true } });
});
localStreamRef.current = stream;
if (!stream.getVideoTracks().length) setIsVideoOff(true);
const audioSettings = stream.getAudioTracks()[0]?.getSettings();
if (audioSettings?.deviceId) setActiveMicId(audioSettings.deviceId);
refreshMicrophones();
const socket = getSocket();
socket?.emit('group_call_join', { chatId, callType: initialCallType });
setJoined(true);
timerRef.current = setInterval(() => setDuration(d => d + 1), 1000);
} catch (err: any) {
console.error('Error joining group call:', err);
if (err?.name === 'NotAllowedError' || err?.name === 'NotFoundError') {
alert('Разрешите доступ к микрофону в настройках браузера для совершения звонков');
}
joinedRef.current = false;
}
}, [chatId, initialCallType, t]);
const leaveCall = useCallback(() => {
const socket = getSocket();
socket?.emit('group_call_leave', { chatId });
cleanup();
onClose();
}, [chatId, cleanup, onClose]);
// Toggle mic
const toggleMic = useCallback(() => {
if (localStreamRef.current) {
localStreamRef.current.getAudioTracks().forEach(t => { t.enabled = !t.enabled; });
setIsMuted(m => !m);
}
}, []);
// Toggle video
const toggleVideo = useCallback(async () => {
if (!isVideoOff) {
// Turn off video
if (localStreamRef.current) {
localStreamRef.current.getVideoTracks().forEach(t => { t.enabled = false; });
}
setIsVideoOff(true);
} else {
// Turn on video
if (localStreamRef.current?.getVideoTracks().some(t => t.readyState === 'live')) {
localStreamRef.current.getVideoTracks().forEach(t => { t.enabled = true; });
setIsVideoOff(false);
} else {
try {
const camStream = await navigator.mediaDevices.getUserMedia({ video: true });
const videoTrack = camStream.getVideoTracks()[0];
if (videoTrack && localStreamRef.current) {
localStreamRef.current.addTrack(videoTrack);
// Add to all peer connections
for (const [, peer] of peersRef.current) {
peer.pc.addTrack(videoTrack, localStreamRef.current);
const offer = await peer.pc.createOffer();
await peer.pc.setLocalDescription(offer);
}
setIsVideoOff(false);
}
} catch { console.warn('Camera unavailable'); }
}
}
}, [isVideoOff]);
// Toggle screen share
const toggleScreenShare = useCallback(async () => {
if (isScreenSharing) {
if (screenStreamRef.current) {
screenStreamRef.current.getTracks().forEach(t => t.stop());
screenStreamRef.current = null;
}
// Replace screen track with null on all peers
for (const [targetUserId, peer] of peersRef.current) {
const sender = peer.pc.getSenders().find(s => s.track?.kind === 'video');
if (sender) {
await sender.replaceTrack(null);
const transceiver = peer.pc.getTransceivers().find(t => t.sender === sender);
if (transceiver) transceiver.direction = 'recvonly';
const offer = await peer.pc.createOffer();
await peer.pc.setLocalDescription(offer);
const socket = getSocket();
socket?.emit('group_call_renegotiate', { chatId, targetUserId, offer: peer.pc.localDescription });
}
}
setIsScreenSharing(false);
} else {
try {
const screenStream = await navigator.mediaDevices.getDisplayMedia({
video: { width: { ideal: 1920 }, height: { ideal: 1080 }, frameRate: { ideal: 30 } },
audio: false,
});
screenStreamRef.current = screenStream;
const screenTrack = screenStream.getVideoTracks()[0];
for (const [targetUserId, peer] of peersRef.current) {
const sender = peer.pc.getSenders().find(s => s.track?.kind === 'video');
if (sender) {
await sender.replaceTrack(screenTrack);
const transceiver = peer.pc.getTransceivers().find(t => t.sender === sender);
if (transceiver && (transceiver.direction === 'recvonly' || transceiver.direction === 'inactive')) {
transceiver.direction = 'sendrecv';
const offer = await peer.pc.createOffer();
await peer.pc.setLocalDescription(offer);
const socket = getSocket();
socket?.emit('group_call_renegotiate', { chatId, targetUserId, offer: peer.pc.localDescription });
}
} else {
peer.pc.addTrack(screenTrack, localStreamRef.current || screenStream);
const offer = await peer.pc.createOffer();
await peer.pc.setLocalDescription(offer);
const socket = getSocket();
socket?.emit('group_call_renegotiate', { chatId, targetUserId, offer: peer.pc.localDescription });
}
}
screenTrack.onended = () => {
setIsScreenSharing(false);
if (screenStreamRef.current) {
screenStreamRef.current.getTracks().forEach(t => t.stop());
screenStreamRef.current = null;
}
};
setIsScreenSharing(true);
} catch { console.error('Screen share failed'); }
}
}, [isScreenSharing, chatId]);
// Noise gate with look-ahead: analyse undelayed signal, gate delayed signal
const applyNoiseGate = useCallback(async () => {
if (!localStreamRef.current) return;
const rawTrack = localStreamRef.current.getAudioTracks()[0];
if (!rawTrack) return;
try {
const ctx = new AudioContext();
const source = ctx.createMediaStreamSource(new MediaStream([rawTrack]));
// Look-ahead delay: signal is delayed so gate can open before audio arrives
const delayNode = ctx.createDelay(0.2);
delayNode.delayTime.value = 0.05; // 50ms look-ahead
// Analysis path: bandpass on voice fundamentals (undelayed, sees audio early)
const analysisHP = ctx.createBiquadFilter();
analysisHP.type = 'highpass'; analysisHP.frequency.value = 100; analysisHP.Q.value = 0.7;
const analysisLP = ctx.createBiquadFilter();
analysisLP.type = 'lowpass'; analysisLP.frequency.value = 4000; analysisLP.Q.value = 0.7;
const analyser = ctx.createAnalyser();
analyser.fftSize = 2048;
analyser.smoothingTimeConstant = 0.3;
const gainNode = ctx.createGain();
gainNode.gain.value = 0;
const dest = ctx.createMediaStreamDestination();
// Signal path: source → delay → gain → output (full quality, just gated)
source.connect(delayNode);
delayNode.connect(gainNode);
gainNode.connect(dest);
// Analysis path (no delay): source → bandpass → analyser
source.connect(analysisHP);
analysisHP.connect(analysisLP);
analysisLP.connect(analyser);
const dataArray = new Float32Array(analyser.fftSize);
const OPEN_THRESHOLD = -38;
const CLOSE_THRESHOLD = -48;
const CONFIRM_MS = 22;
const HOLD_TIME = 150;
const ATTACK = 0.002;
const RELEASE = 0.02;
// State machine: 0=closed, 1=pending, 2=open
let state = 0;
let pendingSince = 0;
let holdUntil = 0;
const check = () => {
analyser.getFloatTimeDomainData(dataArray);
let sum = 0;
for (let i = 0; i < dataArray.length; i++) sum += dataArray[i] * dataArray[i];
const rms = Math.sqrt(sum / dataArray.length);
const db = rms > 0 ? 20 * Math.log10(rms) : -100;
const now = performance.now();
if (state === 0) {
if (db > OPEN_THRESHOLD) {
state = 1;
pendingSince = now;
}
} else if (state === 1) {
if (db <= CLOSE_THRESHOLD) {
state = 0;
} else if (now - pendingSince >= CONFIRM_MS) {
state = 2;
holdUntil = now + HOLD_TIME;
gainNode.gain.setTargetAtTime(1, ctx.currentTime, ATTACK);
}
} else {
if (db > CLOSE_THRESHOLD) {
holdUntil = now + HOLD_TIME;
}
if (now >= holdUntil) {
state = 0;
gainNode.gain.setTargetAtTime(0, ctx.currentTime, RELEASE);
}
}
noiseGateRafRef.current = requestAnimationFrame(check);
};
check();
const gatedTrack = dest.stream.getAudioTracks()[0];
gatedTrack.enabled = !isMuted;
// Replace on all peers
for (const [, peer] of peersRef.current) {
const sender = peer.pc.getSenders().find(s => s.track?.kind === 'audio');
if (sender) await sender.replaceTrack(gatedTrack);
}
noiseGateCtxRef.current = ctx;
noiseGateGainRef.current = gainNode;
noiseGateTrackRef.current = gatedTrack;
setNoiseSuppression(true);
} catch (err) {
console.error('Failed to apply noise gate:', err);
}
}, [isMuted]);
const removeNoiseGate = useCallback(async () => {
// Restore raw mic track to all peers
if (localStreamRef.current) {
const rawTrack = localStreamRef.current.getAudioTracks()[0];
if (rawTrack) {
for (const [, peer] of peersRef.current) {
const sender = peer.pc.getSenders().find(s => s.track?.kind === 'audio');
if (sender) await sender.replaceTrack(rawTrack);
}
}
}
if (noiseGateRafRef.current) { cancelAnimationFrame(noiseGateRafRef.current); noiseGateRafRef.current = 0; }
if (noiseGateTrackRef.current) { noiseGateTrackRef.current.stop(); noiseGateTrackRef.current = null; }
if (noiseGateCtxRef.current) { noiseGateCtxRef.current.close().catch(() => {}); noiseGateCtxRef.current = null; }
noiseGateGainRef.current = null;
setNoiseSuppression(false);
}, []);
const toggleNoiseSuppression = useCallback(async () => {
if (noiseSuppression) {
await removeNoiseGate();
} else {
await applyNoiseGate();
}
}, [noiseSuppression, applyNoiseGate, removeNoiseGate]);
// Enumerate microphones
const refreshMicrophones = useCallback(async () => {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const audioInputs = devices.filter(d => d.kind === 'audioinput');
setMicrophones(audioInputs);
return audioInputs;
} catch { return []; }
}, []);
// Switch microphone
const switchMicrophone = useCallback(async (deviceId: string) => {
setShowMicMenu(false);
try {
const newStream = await navigator.mediaDevices.getUserMedia({
audio: { deviceId: { exact: deviceId }, echoCancellation: true, noiseSuppression: true, autoGainControl: true },
});
const newTrack = newStream.getAudioTracks()[0];
if (!newTrack) return;
newTrack.enabled = !isMuted;
const wasGated = noiseSuppression;
if (wasGated) await removeNoiseGate();
if (localStreamRef.current) {
localStreamRef.current.getAudioTracks().forEach(t => {
localStreamRef.current!.removeTrack(t);
t.stop();
});
localStreamRef.current.addTrack(newTrack);
}
for (const [, peer] of peersRef.current) {
const sender = peer.pc.getSenders().find(s => s.track?.kind === 'audio');
if (sender) await sender.replaceTrack(newTrack);
}
setActiveMicId(deviceId);
if (wasGated) await applyNoiseGate();
} catch (err) {
console.error('Switch mic failed:', err);
}
}, [isMuted, noiseSuppression, removeNoiseGate, applyNoiseGate]);
// Volume
const handleVolumeChange = useCallback((vol: number) => {
const v = Math.max(0, Math.min(1, vol));
setRemoteVolume(v);
for (const [, el] of remoteAudioRefs.current) {
el.volume = v;
}
}, []);
// Socket event handlers
useEffect(() => {
const socket = getSocket();
if (!socket || !isOpen) return;
const onParticipants = async (data: { chatId: string; participants: ParticipantInfo[] }) => {
if (data.chatId !== chatId) return;
const newMap = new Map<string, ParticipantInfo>();
for (const p of data.participants) {
newMap.set(p.id, p);
// Create peer connection to each existing participant (we are the initiator)
if (!peersRef.current.has(p.id)) {
await createPeerConnection(p.id, true);
}
}
setParticipants(prev => {
const merged = new Map(prev);
for (const [k, v] of newMap) merged.set(k, v);
return merged;
});
};
const onUserJoined = (data: { chatId: string; userId: string; userInfo: ParticipantInfo }) => {
if (data.chatId !== chatId) return;
setParticipants(prev => {
const next = new Map(prev);
next.set(data.userId, data.userInfo);
return next;
});
// The new joiner will send us an offer — we wait for it
};
const onUserLeft = (data: { chatId: string; userId: string }) => {
if (data.chatId !== chatId) return;
const peer = peersRef.current.get(data.userId);
if (peer) {
peer.pc.close();
peersRef.current.delete(data.userId);
}
remoteAudioRefs.current.delete(data.userId);
setParticipants(prev => {
const next = new Map(prev);
next.delete(data.userId);
return next;
});
forceUpdate(n => n + 1);
};
const onOffer = async (data: { chatId: string; from: string; offer: RTCSessionDescriptionInit }) => {
if (data.chatId !== chatId) return;
let peerState = peersRef.current.get(data.from);
if (!peerState) {
peerState = await createPeerConnection(data.from, false);
}
await peerState.pc.setRemoteDescription(new RTCSessionDescription(data.offer));
const answer = await peerState.pc.createAnswer();
await peerState.pc.setLocalDescription(answer);
socket.emit('group_call_answer', { chatId, targetUserId: data.from, answer: peerState.pc.localDescription });
};
const onAnswer = async (data: { chatId: string; from: string; answer: RTCSessionDescriptionInit }) => {
if (data.chatId !== chatId) return;
const peerState = peersRef.current.get(data.from);
if (peerState) {
await peerState.pc.setRemoteDescription(new RTCSessionDescription(data.answer));
}
};
const onIceCandidate = (data: { chatId: string; from: string; candidate: RTCIceCandidateInit }) => {
if (data.chatId !== chatId) return;
const peerState = peersRef.current.get(data.from);
if (peerState?.pc.remoteDescription) {
peerState.pc.addIceCandidate(new RTCIceCandidate(data.candidate)).catch(console.error);
}
};
const onRenegotiate = async (data: { chatId: string; from: string; offer: RTCSessionDescriptionInit }) => {
if (data.chatId !== chatId) return;
const peerState = peersRef.current.get(data.from);
if (!peerState) return;
await peerState.pc.setRemoteDescription(new RTCSessionDescription(data.offer));
const answer = await peerState.pc.createAnswer();
await peerState.pc.setLocalDescription(answer);
socket.emit('group_call_renegotiate_answer', { chatId, targetUserId: data.from, answer: peerState.pc.localDescription });
};
const onRenegotiateAnswer = async (data: { chatId: string; from: string; answer: RTCSessionDescriptionInit }) => {
if (data.chatId !== chatId) return;
const peerState = peersRef.current.get(data.from);
if (peerState) {
await peerState.pc.setRemoteDescription(new RTCSessionDescription(data.answer));
}
};
socket.on('group_call_participants', onParticipants);
socket.on('group_call_user_joined', onUserJoined);
socket.on('group_call_user_left', onUserLeft);
socket.on('group_call_offer', onOffer);
socket.on('group_call_answer', onAnswer);
socket.on('group_ice_candidate', onIceCandidate);
socket.on('group_call_renegotiate', onRenegotiate);
socket.on('group_call_renegotiate_answer', onRenegotiateAnswer);
return () => {
socket.off('group_call_participants', onParticipants);
socket.off('group_call_user_joined', onUserJoined);
socket.off('group_call_user_left', onUserLeft);
socket.off('group_call_offer', onOffer);
socket.off('group_call_answer', onAnswer);
socket.off('group_ice_candidate', onIceCandidate);
socket.off('group_call_renegotiate', onRenegotiate);
socket.off('group_call_renegotiate_answer', onRenegotiateAnswer);
};
}, [isOpen, chatId, createPeerConnection]);
// Auto-join on open
useEffect(() => {
if (isOpen && !joined) {
joinCall();
}
}, [isOpen, joined, joinCall]);
// Sync local video
useEffect(() => {
if (!localVideoRef.current) return;
const desired = isScreenSharing && screenStreamRef.current ? screenStreamRef.current : localStreamRef.current;
if (desired && localVideoRef.current.srcObject !== desired) {
localVideoRef.current.srcObject = desired;
}
});
// Cleanup on unmount
useEffect(() => {
return () => {
if (joinedRef.current) {
const socket = getSocket();
socket?.emit('group_call_leave', { chatId });
}
cleanup();
};
}, [chatId, cleanup]);
const formatDuration = (sec: number) => {
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
};
if (!isOpen) return null;
const participantList = Array.from(participants.values());
const hasLocalVideo = !!(localStreamRef.current?.getVideoTracks().some(t => t.enabled) || isScreenSharing);
// Grid columns based on participant count
const totalStreams = participantList.length + 1; // +1 for self
const gridCols = totalStreams <= 1 ? 1 : totalStreams <= 4 ? 2 : 3;
return (
<AnimatePresence>
{/* Hidden audio elements for each remote participant */}
{participantList.map(p => (
<audio
key={`audio-${p.id}`}
ref={el => { if (el) remoteAudioRefs.current.set(p.id, el); }}
autoPlay
playsInline
/>
))}
{isMinimized && joined ? (
<motion.div
key="group-call-minimized"
initial={{ opacity: 0, y: 50, scale: 0.8 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 50, scale: 0.8 }}
className="fixed bottom-6 right-6 z-[100] flex items-center gap-3 px-4 py-3 rounded-2xl glass-strong shadow-2xl shadow-black/50 border border-white/10 cursor-pointer select-none"
onClick={() => setIsMinimized(false)}
>
<div className="relative">
<div className="absolute inset-0 rounded-full bg-emerald-500/30 animate-call-wave" />
<div className="relative w-10 h-10 rounded-full bg-gradient-to-br from-emerald-500 to-teal-600 flex items-center justify-center text-white font-bold text-sm">
{participantList.length + 1}
</div>
</div>
<div className="min-w-0">
<p className="text-sm text-white font-medium truncate max-w-[120px]">{chatName}</p>
<p className="text-xs text-zinc-400 font-mono">{formatDuration(duration)}</p>
</div>
<div className="flex items-center gap-2 ml-2" onClick={e => e.stopPropagation()}>
<button
onClick={toggleMic}
className={`w-8 h-8 rounded-full flex items-center justify-center transition-colors ${isMuted ? 'bg-red-500/20 text-red-400' : 'bg-white/10 text-white hover:bg-white/20'}`}
>
{isMuted ? <MicOff size={14} /> : <Mic size={14} />}
</button>
<button
onClick={leaveCall}
className="w-8 h-8 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center text-white transition-colors"
>
<PhoneOff size={14} />
</button>
</div>
</motion.div>
) : (
<motion.div
key="group-call-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100] flex items-center justify-center bg-surface/90 backdrop-blur-xl overflow-hidden"
onClick={() => setShowVolumeSlider(false)}
>
<div className="absolute inset-0 pointer-events-none opacity-40">
<div className="absolute top-[10%] left-[20%] w-[50vh] h-[50vh] bg-emerald-500/30 rounded-full blur-[120px] animate-float" />
<div className="absolute bottom-[10%] right-[20%] w-[50vh] h-[50vh] bg-vortex-500/20 rounded-full blur-[120px] animate-float-delayed" />
</div>
<motion.div
initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="relative w-full max-w-5xl mx-4 rounded-[2.5rem] glass-strong shadow-2xl shadow-black/50 overflow-hidden border border-white/5"
>
{/* Volume slider popup */}
{showVolumeSlider && (
<>
<div className="fixed inset-0 z-[200]" onClick={() => setShowVolumeSlider(false)} />
<div className="fixed z-[201] left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[250px] rounded-xl bg-zinc-800/95 backdrop-blur-md border border-zinc-600 shadow-2xl p-4" onClick={e => e.stopPropagation()}>
<div className="text-xs text-zinc-400 uppercase tracking-wider mb-3">{t('volume')}</div>
<div className="flex items-center gap-3">
<Volume2 size={16} className="text-zinc-400 shrink-0" />
<input
type="range"
min="0"
max="1"
step="0.05"
value={remoteVolume}
onChange={e => handleVolumeChange(parseFloat(e.target.value))}
className="w-full h-1.5 rounded-full appearance-none bg-zinc-600 accent-vortex-500 cursor-pointer"
/>
<span className="text-xs text-zinc-300 w-8 text-right">{Math.round(remoteVolume * 100)}%</span>
</div>
</div>
</>
)}
{/* Mic selector popup */}
{showMicMenu && microphones.length > 0 && (
<>
<div className="fixed inset-0 z-[200]" onClick={() => setShowMicMenu(false)} />
<div className="fixed z-[201] left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[320px] rounded-xl bg-zinc-800/95 backdrop-blur-md border border-zinc-600 shadow-2xl py-2">
<div className="px-3 py-1.5 text-xs text-zinc-400 uppercase tracking-wider border-b border-zinc-700 mb-1">{t('selectMicrophone')}</div>
{microphones.map((mic, i) => (
<button
key={mic.deviceId}
onClick={() => switchMicrophone(mic.deviceId)}
className={`w-full text-left px-4 py-2.5 text-sm transition-colors ${activeMicId === mic.deviceId
? 'text-vortex-400 bg-vortex-500/20 font-medium'
: 'text-zinc-200 hover:bg-zinc-700'
}`}
>
{mic.label || `${t('microphone')} ${i + 1}`}
</button>
))}
</div>
</>
)}
{/* Header */}
<div className="flex items-center justify-between px-8 py-4 border-b border-white/5">
<div>
<h3 className="text-lg font-bold text-white">{chatName}</h3>
<p className="text-xs text-zinc-400">{participantList.length + 1} {t('participants') || 'участников'} · {formatDuration(duration)}</p>
</div>
<button
onClick={() => setIsMinimized(true)}
className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center text-white/70 hover:text-white transition-colors"
title={t('minimize')}
>
<Minimize2 size={16} />
</button>
</div>
{/* Participant grid */}
<div className="p-4" style={{ minHeight: '300px', maxHeight: '60vh', overflowY: 'auto' }}>
<div
className="grid gap-3"
style={{ gridTemplateColumns: `repeat(${gridCols}, 1fr)` }}
>
{/* Self */}
<div className="relative bg-zinc-900 rounded-2xl overflow-hidden aspect-video flex items-center justify-center border border-white/5">
{hasLocalVideo ? (
<video ref={localVideoRef} autoPlay playsInline muted className="w-full h-full object-contain" />
) : (
<div className="flex flex-col items-center">
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white font-bold text-xl mb-2">
{t('you')?.charAt(0).toUpperCase() || 'Я'}
</div>
{isMuted && <MicOff size={14} className="text-red-400" />}
</div>
)}
<div className="absolute bottom-2 left-2 px-2 py-0.5 rounded-full bg-black/60 text-xs text-white">
{t('you')} {isMuted ? '🔇' : ''}
</div>
</div>
{/* Remote participants */}
{participantList.map(p => {
const peer = peersRef.current.get(p.id);
const hasVid = peer?.hasVideo;
const initials = (p.displayName || p.username).split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
return (
<div key={p.id} className="relative bg-zinc-900 rounded-2xl overflow-hidden aspect-video flex items-center justify-center border border-white/5 cursor-pointer" title={t('rightClickVolume')} onContextMenu={(e) => { e.preventDefault(); setShowVolumeSlider(true); }}>
{hasVid ? (
<video
autoPlay
playsInline
muted
ref={el => {
if (el && peer?.remoteStream && el.srcObject !== peer.remoteStream) {
el.srcObject = peer.remoteStream;
}
}}
className="w-full h-full object-contain"
/>
) : (
<div className="flex flex-col items-center">
{p.avatar ? (
<img src={p.avatar} alt="" className="w-16 h-16 rounded-full object-cover mb-2" />
) : (
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white font-bold text-xl mb-2">
{initials}
</div>
)}
</div>
)}
<div className="absolute bottom-2 left-2 px-2 py-0.5 rounded-full bg-black/60 text-xs text-white truncate max-w-[80%]">
{p.displayName || p.username}
</div>
</div>
);
})}
</div>
</div>
{/* Controls */}
<div className="px-8 pb-8 pt-4 flex items-center justify-center gap-4 flex-wrap">
{/* Mic with dropdown */}
<div className="relative">
<button
onClick={toggleMic}
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors ${isMuted ? 'bg-red-500/20 text-red-400' : 'bg-white/10 text-white hover:bg-white/20'}`}
title={isMuted ? t('unmute') : t('mute')}
>
{isMuted ? <MicOff size={18} /> : <Mic size={18} />}
</button>
<button
onClick={async (e) => { e.stopPropagation(); await refreshMicrophones(); setShowMicMenu(!showMicMenu); setShowVolumeSlider(false); }}
className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-zinc-700 hover:bg-zinc-600 flex items-center justify-center text-white/70 hover:text-white transition-colors border border-zinc-600"
>
<ChevronUp size={10} />
</button>
</div>
{/* Camera — only for video calls */}
{initialCallType === 'video' && (
<button
onClick={toggleVideo}
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors ${isVideoOff ? 'bg-red-500/20 text-red-400' : 'bg-white/10 text-white hover:bg-white/20'}`}
>
{isVideoOff ? <VideoOff size={18} /> : <Video size={18} />}
</button>
)}
<button
onClick={toggleScreenShare}
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors ${isScreenSharing ? 'bg-vortex-500/30 text-vortex-400' : 'bg-white/10 text-white hover:bg-white/20'}`}
title={isScreenSharing ? t('stopScreenShare') : t('screenShare')}
>
{isScreenSharing ? <MonitorOff size={18} /> : <Monitor size={18} />}
</button>
<button
onClick={e => { e.stopPropagation(); setShowVolumeSlider(!showVolumeSlider); }}
className="w-11 h-11 rounded-full flex items-center justify-center transition-colors bg-white/10 text-white hover:bg-white/20"
title={t('volume')}
>
<Volume2 size={18} />
</button>
<button
onClick={toggleNoiseSuppression}
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors ${noiseSuppression ? 'bg-emerald-500/20 text-emerald-400' : 'bg-white/10 text-white hover:bg-white/20'}`}
title={noiseSuppression ? t('noiseSuppressionOn') : t('noiseSuppressionOff')}
>
{noiseSuppression ? <ShieldCheck size={18} /> : <ShieldOff size={18} />}
</button>
<button
onClick={leaveCall}
className="w-14 h-14 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center text-white shadow-xl shadow-red-500/30 transition-all hover:scale-105 ml-2"
>
<PhoneOff size={22} />
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,423 @@
import { useState, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
X,
Camera,
Edit3,
Check,
Loader2,
UserPlus,
Trash2,
Search,
Crown,
Users,
} from 'lucide-react';
import { api } from '../lib/api';
import { useAuthStore } from '../stores/authStore';
import { useChatStore } from '../stores/chatStore';
import { useLang } from '../lib/i18n';
import { Chat, UserPresence } from '../lib/types';
import ConfirmModal from './ConfirmModal';
interface GroupSettingsProps {
chat: Chat;
onClose: () => void;
}
export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
const { user } = useAuthStore();
const { updateChat } = useChatStore();
const { t } = useLang();
const currentMember = chat.members.find((m) => m.user.id === user?.id);
const [removeTargetId, setRemoveTargetId] = useState<string | null>(null);
const isAdmin = currentMember?.role === 'admin';
const [isEditingName, setIsEditingName] = useState(false);
const [groupName, setGroupName] = useState(chat.name || '');
const [isSaving, setIsSaving] = useState(false);
const [avatarUploading, setAvatarUploading] = useState(false);
const [showAddMember, setShowAddMember] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<UserPresence[]>([]);
const [isSearching, setIsSearching] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
// Keep local state in sync with chat prop
useEffect(() => {
setGroupName(chat.name || '');
}, [chat.name]);
// Search users to add
useEffect(() => {
if (!searchQuery.trim()) {
setSearchResults([]);
return;
}
const timer = setTimeout(async () => {
try {
setIsSearching(true);
const results = await api.searchUsers(searchQuery);
// Filter out users already in the group
const memberIds = new Set(chat.members.map((m) => m.user.id));
setSearchResults(results.filter((u) => !memberIds.has(u.id)));
} catch (e) {
console.error(e);
} finally {
setIsSearching(false);
}
}, 300);
return () => clearTimeout(timer);
}, [searchQuery, chat.members]);
const handleSaveName = async () => {
if (!groupName.trim()) return;
try {
setIsSaving(true);
const updatedChat = await api.updateGroup(chat.id, { name: groupName.trim() });
updateChat(updatedChat);
setIsEditingName(false);
} catch (e) {
console.error(e);
} finally {
setIsSaving(false);
}
};
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
setAvatarUploading(true);
const updatedChat = await api.uploadGroupAvatar(chat.id, file);
updateChat(updatedChat);
} catch (e) {
console.error(e);
} finally {
setAvatarUploading(false);
e.target.value = '';
}
};
const handleRemoveAvatar = async () => {
try {
setAvatarUploading(true);
const updatedChat = await api.removeGroupAvatar(chat.id);
updateChat(updatedChat);
} catch (e) {
console.error(e);
} finally {
setAvatarUploading(false);
}
};
const handleAddMember = async (userId: string) => {
try {
const updatedChat = await api.addGroupMembers(chat.id, [userId]);
updateChat(updatedChat);
setSearchQuery('');
setSearchResults([]);
} catch (e) {
console.error(e);
}
};
const handleRemoveMember = async (userId: string) => {
setRemoveTargetId(userId);
};
const confirmRemoveMember = async () => {
if (!removeTargetId) return;
try {
const updatedChat = await api.removeGroupMember(chat.id, removeTargetId);
updateChat(updatedChat);
} catch (e) {
console.error(e);
}
setRemoveTargetId(null);
};
const initials = (chat.name || 'G')
.split(' ')
.map((w: string) => w[0])
.join('')
.slice(0, 2)
.toUpperCase();
return (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/60 z-50"
onClick={onClose}
/>
<motion.div
initial={{ opacity: 0, x: 50, scale: 0.95 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 50, scale: 0.95 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="fixed right-3 top-3 bottom-3 w-[380px] max-w-[calc(100%-24px)] bg-surface-secondary/90 backdrop-blur-3xl shadow-2xl shadow-black/80 border border-white/5 rounded-[2rem] z-50 flex flex-col overflow-hidden ring-1 ring-white/10"
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border/40">
<h2 className="text-lg font-semibold text-white">{t('groupSettings')}</h2>
<button
onClick={onClose}
className="p-1.5 rounded-xl text-zinc-400 hover:text-white hover:bg-surface-hover transition-colors"
>
<X size={18} />
</button>
</div>
<div className="flex-1 overflow-y-auto">
{/* Avatar */}
<div className="flex flex-col items-center py-8 px-6">
<div className="relative group">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-40 h-40 bg-vortex-500/20 rounded-full blur-[40px] pointer-events-none" />
<div className="relative z-10 p-1.5 rounded-full bg-gradient-to-br from-white/10 to-transparent backdrop-blur-md border border-white/10 shadow-2xl">
{chat.avatar ? (
<img
src={chat.avatar}
alt=""
className="w-32 h-32 rounded-full object-cover shadow-inner"
/>
) : (
<div className="w-32 h-32 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white font-bold text-4xl shadow-inner">
{initials}
</div>
)}
</div>
{isAdmin && (
<>
<button
onClick={() => fileInputRef.current?.click()}
disabled={avatarUploading}
className="absolute inset-0 rounded-full bg-black/50 opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity"
>
{avatarUploading ? (
<Loader2 size={24} className="text-white animate-spin" />
) : (
<Camera size={24} className="text-white" />
)}
</button>
{chat.avatar && (
<button
onClick={handleRemoveAvatar}
disabled={avatarUploading}
className="absolute -top-1 -right-1 w-7 h-7 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-lg"
>
<X size={14} className="text-white" />
</button>
)}
</>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleAvatarUpload}
/>
</div>
{/* Group name */}
{isEditingName ? (
<div className="mt-4 flex items-center gap-2 w-full max-w-[260px]">
<input
type="text"
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
className="flex-1 text-lg font-bold text-center text-white bg-transparent border-b border-vortex-500 outline-none px-2 py-1"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveName();
if (e.key === 'Escape') {
setIsEditingName(false);
setGroupName(chat.name || '');
}
}}
/>
<button
onClick={handleSaveName}
disabled={isSaving || !groupName.trim()}
className="p-1.5 rounded-lg text-emerald-400 hover:bg-emerald-500/10 transition-colors"
>
{isSaving ? <Loader2 size={18} className="animate-spin" /> : <Check size={18} />}
</button>
<button
onClick={() => {
setIsEditingName(false);
setGroupName(chat.name || '');
}}
className="p-1.5 rounded-lg text-zinc-400 hover:bg-surface-hover transition-colors"
>
<X size={18} />
</button>
</div>
) : (
<div className="mt-4 flex items-center gap-2">
<h3 className="text-xl font-bold text-white">{chat.name}</h3>
{isAdmin && (
<button
onClick={() => setIsEditingName(true)}
className="p-1 rounded-lg text-zinc-500 hover:text-white hover:bg-surface-hover transition-colors"
>
<Edit3 size={14} />
</button>
)}
</div>
)}
<p className="text-sm text-zinc-400 mt-1 flex items-center gap-1">
<Users size={14} />
{chat.members.length} {t('members')}
</p>
</div>
{/* Members */}
<div className="px-4 pb-4">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-zinc-400 uppercase tracking-wider">
{t('membersCount')}
</h4>
{isAdmin && (
<button
onClick={() => {
setShowAddMember(!showAddMember);
if (!showAddMember) {
setTimeout(() => searchInputRef.current?.focus(), 100);
}
}}
className="flex items-center gap-1 text-xs text-vortex-400 hover:text-vortex-300 transition-colors"
>
<UserPlus size={14} />
{t('addMember')}
</button>
)}
</div>
{/* Add member search */}
<AnimatePresence>
{showAddMember && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden mb-3"
>
<div className="relative mb-2">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" />
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('findUser')}
className="w-full pl-8 pr-3 py-2 rounded-xl bg-surface-tertiary text-sm text-white placeholder-zinc-500 border border-border focus:border-accent transition-colors"
/>
</div>
{isSearching && (
<div className="flex justify-center py-2">
<Loader2 size={16} className="text-zinc-500 animate-spin" />
</div>
)}
{searchResults.map((u) => (
<button
key={u.id}
onClick={() => handleAddMember(u.id)}
className="flex items-center gap-3 w-full px-3 py-2 rounded-xl hover:bg-surface-hover transition-colors"
>
{u.avatar ? (
<img src={u.avatar} alt="" className="w-8 h-8 rounded-full object-cover" />
) : (
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white text-xs font-bold">
{(u.displayName || u.username || '?')[0].toUpperCase()}
</div>
)}
<div className="flex-1 text-left min-w-0">
<p className="text-sm text-white truncate">{u.displayName || u.username}</p>
<p className="text-xs text-zinc-500">@{u.username}</p>
</div>
<UserPlus size={14} className="text-vortex-400 flex-shrink-0" />
</button>
))}
{searchQuery.trim() && !isSearching && searchResults.length === 0 && (
<p className="text-xs text-zinc-500 text-center py-2">{t('usersNotFound')}</p>
)}
</motion.div>
)}
</AnimatePresence>
{/* Member list */}
<div className="space-y-1">
{chat.members
.sort((a, b) => {
if (a.role === 'admin' && b.role !== 'admin') return -1;
if (b.role === 'admin' && a.role !== 'admin') return 1;
return 0;
})
.map((member) => (
<div
key={member.user.id}
className="flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-surface-hover/50 transition-colors group"
>
<div className="relative flex-shrink-0">
{member.user.avatar ? (
<img src={member.user.avatar} alt="" className="w-9 h-9 rounded-full object-cover" />
) : (
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white text-xs font-bold">
{(member.user.displayName || member.user.username || '?')[0].toUpperCase()}
</div>
)}
{member.user.isOnline && (
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 bg-emerald-500 rounded-full border-2 border-surface-secondary" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-white truncate">
{member.user.displayName || member.user.username}
{member.user.id === user?.id && (
<span className="text-zinc-500 ml-1 text-xs">({t('you') || 'вы'})</span>
)}
</p>
{member.role === 'admin' && (
<span className="flex items-center gap-0.5 px-1.5 py-0.5 rounded-md bg-amber-500/10 text-amber-400 text-[10px] font-medium flex-shrink-0">
<Crown size={10} />
{t('adminBadge')}
</span>
)}
</div>
<p className="text-xs text-zinc-500">@{member.user.username}</p>
</div>
{isAdmin && member.user.id !== user?.id && member.role !== 'admin' && (
<button
onClick={() => handleRemoveMember(member.user.id)}
className="p-1.5 rounded-lg text-zinc-500 hover:text-red-400 hover:bg-red-500/10 opacity-0 group-hover:opacity-100 transition-all flex-shrink-0"
title={t('removeMember')}
>
<Trash2 size={14} />
</button>
)}
</div>
))}
</div>
</div>
</div>
</motion.div>
<ConfirmModal
open={!!removeTargetId}
message={t('confirmRemoveMember')}
onConfirm={confirmRemoveMember}
onCancel={() => setRemoveTargetId(null)}
/>
</>
);
}

View File

@@ -0,0 +1,118 @@
import { useState, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Download, ChevronLeft, ChevronRight } from 'lucide-react';
interface ImageLightboxProps {
url?: string;
images?: { url: string; type?: string }[];
initialIndex?: number;
onClose: () => void;
}
export default function ImageLightbox({ url, images, initialIndex = 0, onClose }: ImageLightboxProps) {
const gallery = images && images.length > 0;
const [index, setIndex] = useState(initialIndex);
const currentUrl = gallery ? images![index].url : url!;
const currentType = gallery ? images![index].type : undefined;
const total = gallery ? images!.length : 1;
const goPrev = useCallback(() => {
if (gallery) setIndex((i) => (i > 0 ? i - 1 : total - 1));
}, [gallery, total]);
const goNext = useCallback(() => {
if (gallery) setIndex((i) => (i < total - 1 ? i + 1 : 0));
}, [gallery, total]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === 'ArrowLeft') goPrev();
if (e.key === 'ArrowRight') goNext();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, goPrev, goNext]);
return createPortal(
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[9999] bg-black/90 flex items-center justify-center"
onClick={onClose}
>
{/* Top bar */}
<div className="absolute top-4 right-4 flex items-center gap-2 z-10">
{gallery && total > 1 && (
<span className="text-sm text-white/70 mr-2">{index + 1} / {total}</span>
)}
<a
href={currentUrl}
download
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
>
<Download size={20} />
</a>
<button
onClick={onClose}
className="p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
>
<X size={20} />
</button>
</div>
{/* Left arrow */}
{gallery && total > 1 && (
<button
onClick={(e) => { e.stopPropagation(); goPrev(); }}
className="absolute left-4 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
>
<ChevronLeft size={28} />
</button>
)}
{/* Right arrow */}
{gallery && total > 1 && (
<button
onClick={(e) => { e.stopPropagation(); goNext(); }}
className="absolute right-4 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
>
<ChevronRight size={28} />
</button>
)}
<AnimatePresence mode="wait">
<motion.div
key={currentUrl}
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.8, opacity: 0 }}
transition={{ duration: 0.15 }}
onClick={(e) => e.stopPropagation()}
className="max-w-[90vw] max-h-[90vh] flex items-center justify-center"
>
{currentType === 'video' ? (
<video
src={currentUrl}
controls
autoPlay
className="max-w-[90vw] max-h-[90vh] rounded-lg shadow-2xl"
/>
) : (
<img
src={currentUrl}
alt=""
className="max-w-[90vw] max-h-[90vh] object-contain rounded-lg shadow-2xl"
/>
)}
</motion.div>
</AnimatePresence>
</motion.div>,
document.body
);
}

View File

@@ -0,0 +1,829 @@
import { useState, useRef, useEffect, memo } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import {
Check,
CheckCheck,
Play,
Pause,
Download,
FileText,
Copy,
Pencil,
Trash2,
Reply,
Smile,
MoreHorizontal,
X,
Volume2,
Pin,
Clock,
} from 'lucide-react';
import { useAuthStore } from '../stores/authStore';
import { useChatStore } from '../stores/chatStore';
import { getSocket } from '../lib/socket';
import { useLang } from '../lib/i18n';
import { extractWaveform } from '../lib/utils';
import type { Message, MediaItem, Reaction, ChatMember } from '../lib/types';
import ImageLightbox from './ImageLightbox';
interface MessageBubbleProps {
message: Message;
isMine: boolean;
showAvatar: boolean;
onViewProfile?: (userId: string) => void;
selectionMode?: boolean;
isSelected?: boolean;
onToggleSelect?: (id: string) => void;
onStartSelectionMode?: (id: string) => void;
}
function MessageBubble({
message,
isMine,
showAvatar,
onViewProfile,
selectionMode,
isSelected,
onToggleSelect,
onStartSelectionMode
}: MessageBubbleProps) {
const { user } = useAuthStore();
const { setReplyTo, setEditingMessage, pinnedMessages, chats } = useChatStore();
const { t, lang } = useLang();
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 [isPlaying, setIsPlaying] = useState(false);
const [audioProgress, setAudioProgress] = useState(0);
const [audioDuration, setAudioDuration] = useState(0);
const [waveformBars, setWaveformBars] = useState<number[] | null>(null);
const audioRef = useRef<HTMLAudioElement>(null);
const bubbleRef = useRef<HTMLDivElement>(null);
const [quotedText, setQuotedText] = useState<string | null>(null);
// Прочитано
const isRead = message.readBy?.some((r) => r.userId !== user?.id);
const timeStr = new Date(message.createdAt).toLocaleTimeString(lang === 'ru' ? 'ru-RU' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
});
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation(); // Avoid triggering window listener instantly for other menus
if (selectionMode) {
onToggleSelect?.(message.id);
return;
}
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)) {
setQuotedText(text);
} else {
setQuotedText(null);
}
const menuWidth = 208;
const menuHeight = 350; // estimate
let x = e.clientX;
let y = e.clientY;
if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 8;
if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight - 8;
setContextPos({ x, y });
setShowContext(true);
};
const handleCopy = () => {
if (message.content) {
navigator.clipboard.writeText(message.content);
}
setShowContext(false);
};
const handleReply = () => {
setReplyTo({ ...message, quote: quotedText });
setShowContext(false);
setQuotedText(null);
};
const handleEdit = () => {
setEditingMessage(message);
setShowContext(false);
};
const handleDeleteForAll = () => {
const socket = getSocket();
if (socket) {
socket.emit('delete_messages', {
messageIds: [message.id],
chatId: message.chatId,
deleteForAll: true,
});
}
setShowContext(false);
setDeleteMenuMode(false);
};
const handleDeleteForMe = () => {
const socket = getSocket();
if (socket) {
socket.emit('delete_messages', {
messageIds: [message.id],
chatId: message.chatId,
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
|| chatForDelete.members.find(m => m.user.id !== user?.id)?.user.username
|| ''
: '';
const isPinned = pinnedMessages[message.chatId]?.id === message.id;
const handlePin = () => {
const socket = getSocket();
if (socket) {
if (isPinned) {
socket.emit('unpin_message', { messageId: message.id, chatId: message.chatId });
} else {
socket.emit('pin_message', { messageId: message.id, chatId: message.chatId });
}
}
setShowContext(false);
};
const handleReaction = (emoji: string) => {
const socket = getSocket();
if (socket) {
const existingReaction = message.reactions?.find(
(r) => r.userId === user?.id && r.emoji === emoji
);
if (existingReaction) {
socket.emit('remove_reaction', { messageId: message.id, chatId: message.chatId, emoji });
} else {
socket.emit('add_reaction', { messageId: message.id, chatId: message.chatId, emoji });
}
}
setShowContext(false);
};
// Аудио плеер
const toggleAudio = () => {
const audio = audioRef.current;
if (!audio) return;
if (isPlaying) {
audio.pause();
setIsPlaying(false);
} else {
// Ensure audio is loaded before playing
if (audio.readyState < 2) {
audio.load();
}
audio.play().then(() => {
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);
});
}
};
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const onTimeUpdate = () => {
if (audio.duration) {
setAudioProgress((audio.currentTime / audio.duration) * 100);
}
};
const onLoadedMetadata = () => {
setAudioDuration(audio.duration);
};
const onEnded = () => {
setIsPlaying(false);
setAudioProgress(0);
};
audio.addEventListener('timeupdate', onTimeUpdate);
audio.addEventListener('loadedmetadata', onLoadedMetadata);
audio.addEventListener('ended', onEnded);
return () => {
audio.removeEventListener('timeupdate', onTimeUpdate);
audio.removeEventListener('loadedmetadata', onLoadedMetadata);
audio.removeEventListener('ended', onEnded);
};
}, []);
// Extract real waveform from voice audio
useEffect(() => {
const voiceUrl = message.media?.find((m) => m.type === 'voice')?.url;
if (!voiceUrl) return;
extractWaveform(voiceUrl, 28).then(setWaveformBars);
}, [message.media]);
const formatDuration = (sec: number) => {
if (!sec || isNaN(sec) || !isFinite(sec)) return '0:00';
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
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;
}
setShowContext(false);
setDeleteMenuMode(false);
};
window.addEventListener('click', hideMenu, true);
window.addEventListener('contextmenu', hideMenu, true);
return () => {
window.removeEventListener('click', hideMenu, true);
window.removeEventListener('contextmenu', hideMenu, true);
};
}, [showContext]);
// Deleted message — auto-hide after 5 seconds
const [deletedVisible, setDeletedVisible] = useState(true);
useEffect(() => {
if (message.isDeleted) {
const timer = setTimeout(() => setDeletedVisible(false), 5000);
return () => clearTimeout(timer);
}
}, [message.isDeleted]);
if (message.isDeleted) {
if (!deletedVisible) return null;
return (
<motion.div
initial={{ opacity: 1, height: 'auto' }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, height: 0 }}
className={`flex ${isMine ? 'justify-end' : 'justify-start'} mb-1`}
>
<div className="px-4 py-2 rounded-2xl text-sm italic text-zinc-600 bg-surface-tertiary/50">
{t('messageDeleted')}
</div>
</motion.div>
);
}
const media = message.media || [];
const hasImage = media.some((m) => m.type === 'image');
const hasVoice = message.type === 'voice' || media.some((m) => m.type === 'voice');
const hasAudio = !hasVoice && (message.type === 'audio' || media.some((m) => m.type === 'audio'));
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]) {
reactionGroups[r.emoji] = { count: 0, users: [], isMine: false };
}
reactionGroups[r.emoji].count++;
reactionGroups[r.emoji].users.push(r.user?.displayName || r.user?.username || '');
if (r.userId === user?.id) reactionGroups[r.emoji].isMine = true;
});
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) => {
if (part.startsWith('**') && part.endsWith('**')) return <strong key={i} className="font-bold">{part.slice(2, -2)}</strong>;
if (part.startsWith('_') && part.endsWith('_')) return <em key={i} className="italic">{part.slice(1, -1)}</em>;
if (part.startsWith('*') && part.endsWith('*')) return <em key={i} className="italic">{part.slice(1, -1)}</em>;
if (part.startsWith('~') && part.endsWith('~')) return <del key={i} className="line-through opacity-80">{part.slice(1, -1)}</del>;
if (part.startsWith('`') && part.endsWith('`')) {
return <code key={i} className="font-mono text-[13px] bg-black/20 px-1 py-0.5 rounded-[0.35rem]">{part.slice(1, -1)}</code>;
}
if (part.startsWith('@') && part.length > 1) {
const mentionUsername = part.slice(1);
return (
<span
key={i}
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);
if (found) {
onViewProfile?.(found.user.id);
}
}}
>{part}</span>
);
}
return <span key={i}>{part}</span>;
});
};
return (
<>
<div
ref={bubbleRef}
className={`flex ${isMine ? 'justify-end' : 'justify-start'} group mb-0.5 relative transition-colors duration-200 ${selectionMode ? 'px-4 -mx-4 cursor-pointer hover:bg-white/5 rounded-xl' : ''
} ${isSelected ? 'bg-vortex-500/10 hover:bg-vortex-500/20' : ''}`}
onClick={() => {
if (selectionMode) onToggleSelect?.(message.id);
}}
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">
<Check size={12} className="text-white" />
</div>}
</div>
)}
{/* Аватар (чужие) */}
{!isMine && (
<div className="w-8 flex-shrink-0 mr-2 self-end">
{showAvatar ? (
<button onClick={() => onViewProfile?.(message.senderId)}>
{senderAvatar ? (
<img src={senderAvatar} alt="" className="w-8 h-8 rounded-full object-cover" />
) : (
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white text-xs font-semibold">
{senderName[0]?.toUpperCase() || '?'}
</div>
)}
</button>
) : null}
</div>
)}
<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"
onClick={() => onViewProfile?.(message.senderId)}
>
{senderName}
</button>
)}
{/* 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`}>
<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>
)}
{/* Пузырь */}
<div
onContextMenu={handleContextMenu}
onDoubleClick={handleReply}
title={t('reply') ? `${t('reply')} (Double Click)` : 'Double click to reply'}
className={`cursor-pointer rounded-[1.25rem] overflow-hidden transition-all duration-300 ${
hasImage && !message.content
? 'p-0 shadow-none border-none'
: isMine
? 'bubble-sent text-white shadow-sm px-4 py-2.5 hover:shadow-md hover:brightness-105'
: 'bubble-received text-zinc-100 shadow-sm px-4 py-2.5 hover:shadow-md hover:brightness-105'
}`}
>
{/* Рендер пересланного сообщения */}
{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>
)}
{/* Изображения */}
{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)}
/>
))}
</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]">
<audio
ref={audioRef}
src={media.find((m) => m.type === 'voice')?.url}
preload="auto"
onError={(e) => console.error('Audio load error:', e)}
/>
<button
onClick={toggleAudio}
className={`w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0 ${isMine ? 'bg-white/20 hover:bg-white/30' : 'bg-vortex-500/20 hover:bg-vortex-500/30'
} transition-colors`}
>
{isPlaying ? (
<Pause size={16} className={isMine ? 'text-white' : 'text-vortex-400'} />
) : (
<Play size={16} className={`${isMine ? 'text-white' : 'text-vortex-400'} ml-0.5`} />
)}
</button>
<div className="flex-1 min-w-0">
{/* Waveform visualization */}
<div
className="flex items-end gap-[2px] h-6 cursor-pointer"
onClick={(e) => {
const audio = audioRef.current;
if (!audio || !audio.duration) return;
const rect = e.currentTarget.getBoundingClientRect();
const pct = (e.clientX - rect.left) / rect.width;
audio.currentTime = pct * audio.duration;
setAudioProgress(pct * 100);
if (!isPlaying) toggleAudio();
}}
>
{(waveformBars || Array(28).fill(0.5)).map((val, i) => {
const barHeight = Math.max(10, val * 100);
const progress = audioProgress / 100;
const barProgress = i / 28;
const isActive = barProgress < progress;
return (
<div
key={i}
className={`flex-1 rounded-full transition-colors duration-150 ${isActive
? isMine ? 'bg-white/80' : 'bg-vortex-400'
: isMine ? 'bg-white/20' : 'bg-white/10'
}`}
style={{ height: `${barHeight}%` }}
/>
);
})}
</div>
<span className={`text-xs mt-0.5 block ${isMine ? 'text-white/60' : 'text-zinc-500'}`}>
{isPlaying
? formatDuration(audioRef.current?.currentTime || 0)
: formatDuration(audioDuration || message.media?.find((m) => m.type === 'voice')?.duration || 0)}
</span>
</div>
</div>
)}
{/* Аудио (mp3 файлы) */}
{hasAudio && (() => {
const audioMedia = media.find((m) => m.type === 'audio');
return (
<div className="min-w-[220px]">
{audioMedia?.filename && (
<div className="flex items-center gap-2 mb-2">
<Volume2 size={14} className={isMine ? 'text-white/60' : 'text-vortex-400'} />
<span className={`text-xs truncate ${isMine ? 'text-white/70' : 'text-zinc-400'}`}>{audioMedia.filename}</span>
</div>
)}
<div className="flex items-center gap-3">
<audio
ref={audioRef}
src={audioMedia?.url}
preload="auto"
onError={(e) => console.error('Audio load error:', e)}
/>
<button
onClick={toggleAudio}
className={`w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0 ${isMine ? 'bg-white/20 hover:bg-white/30' : 'bg-vortex-500/20 hover:bg-vortex-500/30'
} transition-colors`}
>
{isPlaying ? (
<Pause size={16} className={isMine ? 'text-white' : 'text-vortex-400'} />
) : (
<Play size={16} className={`${isMine ? 'text-white' : 'text-vortex-400'} ml-0.5`} />
)}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-[2px] h-6">
{Array.from({ length: 28 }).map((_, i) => {
const barHeight = [40, 65, 35, 80, 50, 90, 45, 70, 55, 85, 30, 75, 60, 95, 40, 80, 50, 70, 35, 90, 55, 65, 45, 85, 60, 75, 50, 40][i] || 50;
const progress = audioProgress / 100;
const barProgress = i / 28;
const isActive = barProgress < progress;
return (
<div
key={i}
className={`flex-1 rounded-full transition-colors duration-150 ${isActive
? isMine ? 'bg-white/80' : 'bg-vortex-400'
: isMine ? 'bg-white/20' : 'bg-white/10'
}`}
style={{ height: `${barHeight}%` }}
/>
);
})}
</div>
<span className={`text-xs mt-0.5 block ${isMine ? 'text-white/60' : 'text-zinc-500'}`}>
{isPlaying
? formatDuration(audioRef.current?.currentTime || 0)
: formatDuration(audioDuration || 0)}
</span>
</div>
</div>
</div>
);
})()}
{/* Файлы */}
{hasFile &&
media
.filter((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video')
.map((m) => (
<a
key={m.id}
href={m.url}
download={m.filename || 'file'}
target="_blank"
rel="noopener noreferrer"
className={`flex items-center gap-3 p-2 rounded-xl ${isMine ? 'bg-white/10 hover:bg-white/15' : 'bg-surface-tertiary hover:bg-surface-hover'
} transition-colors mb-1`}
>
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${isMine ? 'bg-white/20' : 'bg-vortex-500/20'
}`}>
<FileText size={20} className={isMine ? 'text-white' : 'text-vortex-400'} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{m.filename || t('fileLabel')}</p>
<p className={`text-xs ${isMine ? 'text-white/50' : 'text-zinc-500'}`}>
{m.size ? `${(m.size / 1024).toFixed(1)} ${t('kb')}` : t('download')}
</p>
</div>
<Download size={16} className={isMine ? 'text-white/50' : 'text-zinc-500'} />
</a>
))}
{/* Текст */}
{message.content && (
<div className="flex items-end gap-2">
<p className="text-sm whitespace-pre-wrap break-words flex-1 leading-relaxed">
{renderFormattedText(message.content)}
</p>
<span className={`text-[10px] flex-shrink-0 flex items-center gap-0.5 self-end ${isMine ? 'text-white/50' : 'text-zinc-500'
}`}>
{message.isEdited && <span>{t('edited')}</span>}
{message.scheduledAt && <Clock size={11} className="text-amber-400 mr-0.5" />}
{timeStr}
{isMine && !message.scheduledAt && (
isRead ? (
<CheckCheck size={13} className="text-sky-300 ml-0.5" />
) : (
<Check size={13} className="ml-0.5" />
)
)}
</span>
</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">
{timeStr}
{isMine && (
isRead ? (
<CheckCheck size={13} className="text-sky-300" />
) : (
<Check size={13} />
)
)}
</span>
</div>
)}
</div>
{/* Реакции */}
{Object.keys(reactionGroups).length > 0 && (
<div className="flex flex-wrap gap-1 mt-1 mx-1">
{Object.entries(reactionGroups).map(([emoji, data]) => (
<button
key={emoji}
onClick={() => handleReaction(emoji)}
className={`flex items-center gap-1 px-2 py-0.5 rounded-full text-xs transition-colors ${data.isMine
? 'bg-vortex-500/30 border border-vortex-500/50'
: 'bg-surface-tertiary border border-border hover:border-zinc-600'
}`}
title={data.users.join(', ')}
>
<span>{emoji}</span>
<span className="text-zinc-400">{data.count}</span>
</button>
))}
</div>
)}
</div>
{/* Аватар (свои) */}
{isMine && (
<div className="w-8 flex-shrink-0 ml-2 self-end">
{showAvatar ? (
<button onClick={() => onViewProfile?.(message.senderId)}>
{senderAvatar ? (
<img src={senderAvatar} alt="" className="w-8 h-8 rounded-full object-cover" />
) : (
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white text-xs font-semibold">
{senderName[0]?.toUpperCase() || '?'}
</div>
)}
</button>
) : null}
</div>
)}
</div>
{/* Контекстное меню */}
{typeof document !== 'undefined' && createPortal(
<AnimatePresence>
{showContext && (
<motion.div
ref={contextMenuRef}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="fixed z-[9999] w-52 rounded-[1.25rem] glass-strong shadow-2xl py-1.5 overflow-hidden border border-white/10"
style={{ left: contextPos.x, top: contextPos.y }}
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{deleteMenuMode ? (
<>
{/* Delete submenu */}
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border">
<button
onClick={() => setDeleteMenuMode(false)}
className="p-1 rounded-lg hover:bg-surface-hover transition-colors text-zinc-400 hover:text-white"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="15 18 9 12 15 6" /></svg>
</button>
<span className="text-sm font-medium text-zinc-300">{t('delete')}</span>
</div>
<button
onClick={handleDeleteForMe}
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"
>
<Trash2 size={16} className="text-zinc-400" />
{t('deleteForMe')}
</button>
<button
onClick={handleDeleteForAll}
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-red-400 hover:bg-red-500/10 hover:text-red-300 transition-colors"
>
<Trash2 size={16} />
{chatForDelete?.type === 'personal' && otherMemberName
? `${t('deleteAlsoFor')} ${otherMemberName}`
: t('deleteForAll')}
</button>
</>
) : (
<>
{/* Быстрые реакции */}
<div className="flex items-center gap-1 px-3 py-2 border-b border-border">
{['👍', '❤️', '😂', '😮', '😢', '🔥'].map((emoji) => (
<button
key={emoji}
onClick={() => handleReaction(emoji)}
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-surface-hover transition-colors text-lg"
>
{emoji}
</button>
))}
</div>
<button
onClick={handleReply}
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"
>
<Reply size={16} />
{quotedText ? t('replyWithQuote') : t('reply')}
</button>
<button
onClick={() => {
setShowContext(false);
onStartSelectionMode?.(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"
>
<CheckCheck size={16} />
{t('select')}
</button>
<button
onClick={handlePin}
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"
>
<Pin size={16} />
{isPinned ? t('unpinMessage') : t('pinMessage')}
</button>
{message.content && (
<button
onClick={handleCopy}
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"
>
<Copy size={16} />
{t('copy')}
</button>
)}
{isMine && message.content && (
<button
onClick={handleEdit}
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"
>
<Pencil size={16} />
{t('edit')}
</button>
)}
<div className="border-t border-border my-1" />
<button
onClick={() => setDeleteMenuMode(true)}
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-red-400 hover:bg-red-500/10 transition-colors"
>
<Trash2 size={16} />
{t('delete')}
</button>
</>
)}
</motion.div>
)}
</AnimatePresence>,
document.body
)}
{/* Lightbox */}
<AnimatePresence>
{lightboxUrl && (
<ImageLightbox url={lightboxUrl} onClose={() => setLightboxUrl(null)} />
)}
</AnimatePresence>
</>
);
}
export default memo(MessageBubble);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,374 @@
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Search, MessageSquare, Users, Check, ArrowLeft, ArrowRight } from 'lucide-react';
import { api } from '../lib/api';
import { useChatStore } from '../stores/chatStore';
import { useAuthStore } from '../stores/authStore';
import { useLang } from '../lib/i18n';
import type { UserPresence, FriendWithId } from '../lib/types';
interface NewChatModalProps {
onClose: () => void;
}
type Mode = 'personal' | 'group-select' | 'group-name';
export default function NewChatModal({ onClose }: NewChatModalProps) {
const { user } = useAuthStore();
const { t } = useLang();
const { addChat, setActiveChat, loadMessages } = useChatStore();
const [mode, setMode] = useState<Mode>('personal');
const [query, setQuery] = useState('');
const [users, setUsers] = useState<UserPresence[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<UserPresence[]>([]);
const [groupName, setGroupName] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [friends, setFriends] = useState<FriendWithId[]>([]);
// Load friends on mount
useEffect(() => {
api.getFriends().then(setFriends).catch(() => {});
}, []);
useEffect(() => {
if (!query.trim() || query.trim().length < 3) {
setUsers([]);
return;
}
const timer = setTimeout(async () => {
try {
setIsLoading(true);
const results = await api.searchUsers(query);
setUsers(results.filter((u) => u.id !== user?.id));
} catch (e) {
console.error(e);
} finally {
setIsLoading(false);
}
}, 300);
return () => clearTimeout(timer);
}, [query, user?.id]);
const handleSelectUser = async (selectedUser: UserPresence) => {
if (mode === 'personal') {
try {
const chat = await api.createPersonalChat(selectedUser.id);
addChat(chat);
setActiveChat(chat.id);
loadMessages(chat.id);
onClose();
} catch (e: unknown) {
console.error(e);
}
} else {
// Toggle selection
setSelectedUsers((prev) => {
const exists = prev.find((u) => u.id === selectedUser.id);
if (exists) return prev.filter((u) => u.id !== selectedUser.id);
return [...prev, selectedUser];
});
}
};
const handleCreateGroup = async () => {
if (!groupName.trim() || selectedUsers.length === 0) return;
setIsCreating(true);
try {
const chat = await api.createGroupChat(
groupName.trim(),
selectedUsers.map((u) => u.id)
);
addChat(chat);
setActiveChat(chat.id);
loadMessages(chat.id);
onClose();
} catch (e) {
console.error(e);
} finally {
setIsCreating(false);
}
};
const isSelected = (userId: string) => selectedUsers.some((u) => u.id === userId);
return (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/60 z-50"
onClick={onClose}
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4"
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div className="w-full max-w-md rounded-2xl glass-strong shadow-2xl overflow-hidden" role="dialog" aria-modal="true" aria-label={t('newChat')}>
{/* Шапка */}
<div className="flex items-center justify-between p-4 border-b border-border">
<div className="flex items-center gap-2">
{mode !== 'personal' && (
<button
onClick={() => {
if (mode === 'group-name') setMode('group-select');
else {
setMode('personal');
setSelectedUsers([]);
}
}}
className="p-1 rounded-lg text-zinc-400 hover:text-white hover:bg-surface-hover transition-colors"
>
<ArrowLeft size={18} />
</button>
)}
<h2 className="text-lg font-semibold text-white">
{mode === 'personal'
? t('newChatTitle')
: mode === 'group-select'
? t('selectMembers')
: t('newGroup')}
</h2>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-zinc-400 hover:text-white hover:bg-surface-hover transition-colors"
>
<X size={18} />
</button>
</div>
{mode === 'group-name' ? (
/* Шаг 2: Назвать группу */
<div className="p-4 space-y-4">
<input
type="text"
placeholder={t('groupNamePlaceholder')}
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
className="w-full px-4 py-2.5 rounded-xl bg-surface-tertiary text-sm text-white placeholder-zinc-500 border border-border focus:border-accent transition-colors"
autoFocus
/>
<div>
<p className="text-xs text-zinc-500 mb-2">
{t('membersCount')} ({selectedUsers.length}):
</p>
<div className="flex flex-wrap gap-2">
{selectedUsers.map((u) => (
<div
key={u.id}
className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-vortex-500/20 border border-vortex-500/30"
>
{u.avatar ? (
<img src={u.avatar} alt="" className="w-5 h-5 rounded-full object-cover" />
) : (
<div className="w-5 h-5 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white text-[9px] font-semibold">
{(u.displayName || u.username)?.[0]?.toUpperCase()}
</div>
)}
<span className="text-xs text-white">{u.displayName || u.username}</span>
<button
onClick={() => setSelectedUsers((prev) => prev.filter((p) => p.id !== u.id))}
className="text-zinc-500 hover:text-zinc-300"
>
<X size={12} />
</button>
</div>
))}
</div>
</div>
<button
onClick={handleCreateGroup}
disabled={!groupName.trim() || isCreating}
className="w-full py-2.5 rounded-xl bg-accent hover:bg-accent-hover text-white text-sm font-medium transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
>
{isCreating ? (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<>
<Users size={16} />
{t('createGroup')}
</>
)}
</button>
</div>
) : (
<>
{/* Переключатель режима + Поиск */}
<div className="p-4 space-y-3">
{mode === 'personal' && (
<button
onClick={() => setMode('group-select')}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl bg-surface-tertiary hover:bg-surface-hover transition-colors border border-border"
>
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center">
<Users size={18} className="text-white" />
</div>
<div className="text-left">
<p className="text-sm font-medium text-white">{t('createGroup')}</p>
<p className="text-xs text-zinc-500">{t('upTo200')}</p>
</div>
</button>
)}
{/* Выбранные (в режиме группы) */}
{mode === 'group-select' && selectedUsers.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
{selectedUsers.map((u) => (
<button
key={u.id}
onClick={() => setSelectedUsers((prev) => prev.filter((p) => p.id !== u.id))}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-vortex-500/20 border border-vortex-500/30 text-xs text-white hover:bg-vortex-500/30 transition-colors"
>
{(u.displayName || u.username)}
<X size={11} />
</button>
))}
</div>
)}
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" />
<input
type="text"
placeholder={
mode === 'personal'
? t('findUser')
: t('addMembers')
}
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 rounded-xl bg-surface-tertiary text-sm text-white placeholder-zinc-500 border border-border focus:border-accent transition-colors"
autoFocus
/>
</div>
</div>
{/* Результаты */}
<div className="max-h-72 overflow-y-auto px-2 pb-4">
{isLoading ? (
<div className="flex justify-center py-8">
<div className="w-5 h-5 border-2 border-vortex-500 border-t-transparent rounded-full animate-spin" />
</div>
) : query.trim().length >= 3 && users.length > 0 ? (
users.map((u) => (
<button
key={u.id}
onClick={() => handleSelectUser(u)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl transition-colors ${
isSelected(u.id)
? 'bg-vortex-500/15 border border-vortex-500/30'
: 'hover:bg-surface-hover border border-transparent'
}`}
>
<div className="relative flex-shrink-0">
{u.avatar ? (
<img src={u.avatar} alt="" className="w-10 h-10 rounded-full object-cover" />
) : (
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white font-semibold text-sm">
{(u.displayName || u.username)?.[0]?.toUpperCase() || '?'}
</div>
)}
{u.isOnline && (
<span className="absolute bottom-0 right-0 w-3 h-3 bg-emerald-500 rounded-full border-2 border-surface-secondary" />
)}
</div>
<div className="min-w-0 text-left flex-1">
<p className="text-sm font-medium text-white truncate">
{u.displayName || u.username}
</p>
<p className="text-xs text-zinc-500 truncate">@{u.username}</p>
</div>
{mode === 'group-select' && (
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0 transition-colors ${
isSelected(u.id)
? 'bg-vortex-500 border-vortex-500'
: 'border-zinc-600'
}`}>
{isSelected(u.id) && <Check size={12} className="text-white" />}
</div>
)}
</button>
))
) : query.trim().length >= 3 && users.length === 0 ? (
<div className="text-center py-8 text-zinc-500">
<p className="text-sm">{t('usersNotFound')}</p>
</div>
) : query.trim().length > 0 && query.trim().length < 3 ? (
<div className="text-center py-6 text-zinc-500">
<p className="text-sm">{t('minCharsHint')}</p>
</div>
) : friends.length > 0 ? (
<>
<p className="text-xs text-zinc-500 uppercase tracking-wider px-2 mb-2 font-semibold">{t('friends')}</p>
{friends.map((u) => (
<button
key={u.id}
onClick={() => handleSelectUser(u)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl transition-colors ${
isSelected(u.id)
? 'bg-vortex-500/15 border border-vortex-500/30'
: 'hover:bg-surface-hover border border-transparent'
}`}
>
<div className="relative flex-shrink-0">
{u.avatar ? (
<img src={u.avatar} alt="" className="w-10 h-10 rounded-full object-cover" />
) : (
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white font-semibold text-sm">
{(u.displayName || u.username)?.[0]?.toUpperCase() || '?'}
</div>
)}
{u.isOnline && (
<span className="absolute bottom-0 right-0 w-3 h-3 bg-emerald-500 rounded-full border-2 border-surface-secondary" />
)}
</div>
<div className="min-w-0 text-left flex-1">
<p className="text-sm font-medium text-white truncate">
{u.displayName || u.username}
</p>
<p className="text-xs text-zinc-500 truncate">@{u.username}</p>
</div>
{mode === 'group-select' && (
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0 transition-colors ${
isSelected(u.id)
? 'bg-vortex-500 border-vortex-500'
: 'border-zinc-600'
}`}>
{isSelected(u.id) && <Check size={12} className="text-white" />}
</div>
)}
</button>
))}
</>
) : (
<div className="flex flex-col items-center gap-2 py-8 text-zinc-500">
<MessageSquare size={32} className="opacity-30" />
<p className="text-sm">{t('enterNameOrUsername')}</p>
</div>
)}
</div>
{/* Кнопка "Далее" для группы */}
{mode === 'group-select' && selectedUsers.length > 0 && (
<div className="p-4 border-t border-border">
<button
onClick={() => setMode('group-name')}
className="w-full py-2.5 rounded-xl bg-accent hover:bg-accent-hover text-white text-sm font-medium transition-colors flex items-center justify-center gap-2"
>
{t('next')}
<ArrowRight size={16} />
</button>
</div>
)}
</>
)}
</div>
</motion.div>
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,212 @@
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Search,
Plus,
Menu,
MessageSquare,
X,
User as UserIcon,
} from 'lucide-react';
import { useAuthStore } from '../stores/authStore';
import { useChatStore } from '../stores/chatStore';
import { useLang } from '../lib/i18n';
import { api } from '../lib/api';
import { getInitials, generateAvatarColor } from '../lib/utils';
import Avatar from './Avatar';
import { StoryGroup } from '../lib/types';
import ChatListItem from './ChatListItem';
import NewChatModal from './NewChatModal';
import UserProfile from './UserProfile';
import SideMenu from './SideMenu';
import StoryViewer, { CreateStoryModal } from './StoryViewer';
const API_URL = import.meta.env.VITE_API_URL || '';
export default function Sidebar() {
const { user, logout } = useAuthStore();
const { chats, activeChat, searchQuery, setSearchQuery, clearStore } = useChatStore();
const { t } = useLang();
const [showNewChat, setShowNewChat] = useState(false);
const [showProfile, setShowProfile] = useState(false);
const [showSideMenu, setShowSideMenu] = useState(false);
const [storyGroups, setStoryGroups] = useState<StoryGroup[]>([]);
const [storyViewerIndex, setStoryViewerIndex] = useState<number | null>(null);
const [showCreateStory, setShowCreateStory] = useState(false);
const loadStories = () => {
api.getStories().then(setStoryGroups).catch(console.error);
};
useEffect(() => {
loadStories();
const interval = setInterval(loadStories, 30000); // refresh every 30s
return () => clearInterval(interval);
}, []);
const filteredChats = chats.filter((chat) => {
if (!searchQuery) return true;
const q = searchQuery.toLowerCase();
if (chat.name?.toLowerCase().includes(q)) return true;
return chat.members.some(
(m) =>
m.user.id !== user?.id &&
(m.user.username.toLowerCase().includes(q) ||
m.user.displayName.toLowerCase().includes(q))
);
}).sort((a, b) => {
// Favorites chat always on top
if (a.type === 'favorites') return -1;
if (b.type === 'favorites') return 1;
return 0;
});
const handleLogout = () => {
clearStore();
logout();
};
return (
<>
<div className="w-[340px] h-full flex flex-col bg-surface-secondary rounded-3xl overflow-hidden border border-border/50 shadow-2xl relative z-10">
{/* Шапка */}
<div className="h-[76px] px-4 flex items-center gap-3 border-b border-border/40 bg-surface-secondary flex-shrink-0">
<button
onClick={() => setShowSideMenu(true)}
className="p-2 rounded-lg hover:bg-surface-hover transition-colors text-zinc-400 hover:text-white"
title={t('menu')}
>
<Menu size={20} />
</button>
<div className="flex items-center gap-2 flex-1 min-w-0">
<img src="/logo.png" alt="Vortex" className="w-8 h-8 rounded-lg object-cover" />
<h1 className="text-lg font-bold gradient-text truncate">Vortex</h1>
</div>
<button
onClick={() => setShowNewChat(true)}
className="p-2 rounded-lg hover:bg-surface-hover transition-colors text-zinc-400 hover:text-white"
title={t('newChat')}
>
<Plus size={20} />
</button>
</div>
{/* Поиск */}
<div className="p-4 bg-surface-secondary/50">
<div className="relative group">
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500 group-focus-within:text-accent transition-colors" />
<input
type="text"
placeholder={t('searchChats')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-11 pr-10 py-3 rounded-2xl bg-surface-tertiary/80 text-[15px] font-medium text-white placeholder-zinc-500 border border-border/30 hover:border-border/60 focus:border-accent transition-all outline-none shadow-inner"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-full bg-surface-hover text-zinc-400 hover:text-white transition-colors"
>
<X size={14} />
</button>
)}
</div>
</div>
{/* Story circles */}
{(storyGroups.length > 0 || true) && (
<div className="flex items-center gap-3 px-4 py-2 overflow-x-auto scrollbar-hide border-b border-border/20 flex-shrink-0">
{/* Add story circle */}
<button
onClick={() => setShowCreateStory(true)}
className="flex flex-col items-center gap-1 flex-shrink-0 group"
>
<div className="w-14 h-14 rounded-full border-2 border-dashed border-zinc-600 flex items-center justify-center group-hover:border-vortex-400 transition-colors">
<Plus size={20} className="text-zinc-400 group-hover:text-vortex-400 transition-colors" />
</div>
<span className="text-[10px] text-zinc-500 truncate w-14 text-center">{t('newStory')}</span>
</button>
{storyGroups.map((group, idx) => {
const avatarUrl = group.user.avatar ? `${API_URL}${group.user.avatar}` : null;
const isMine = group.user.id === user?.id;
return (
<button
key={group.user.id}
onClick={() => setStoryViewerIndex(idx)}
className="flex flex-col items-center gap-1 flex-shrink-0 group"
>
<div className={`w-14 h-14 rounded-full p-[2.5px] transition-transform group-hover:scale-105 ${
group.hasUnviewed
? 'bg-gradient-to-tr from-vortex-400 via-purple-500 to-pink-500 shadow-lg shadow-vortex-500/25'
: isMine
? 'bg-gradient-to-tr from-zinc-500 to-zinc-600'
: 'bg-zinc-700'
}`}>
<div className="w-full h-full rounded-full overflow-hidden border-[2.5px] border-surface-secondary">
<Avatar
src={avatarUrl}
name={group.user.displayName || group.user.username}
size="lg"
className="w-full h-full"
/>
</div>
</div>
<span className="text-[10px] text-zinc-400 truncate w-14 text-center">
{isMine ? t('myStory') : (group.user.displayName || group.user.username).split(' ')[0]}
</span>
</button>
);
})}
</div>
)}
{/* Список чатов */}
<div className="flex-1 overflow-y-auto">
{filteredChats.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-zinc-500 gap-3 px-6">
<MessageSquare size={40} className="opacity-30" />
<p className="text-sm text-center">
{searchQuery ? t('nothingFound') : t('noChats')}
</p>
</div>
) : (
filteredChats.map((chat) => (
<ChatListItem key={chat.id} chat={chat} isActive={chat.id === activeChat} />
))
)}
</div>
</div>
{/* Модалки */}
<AnimatePresence>
{showNewChat && <NewChatModal onClose={() => setShowNewChat(false)} />}
</AnimatePresence>
<AnimatePresence>
{showProfile && <UserProfile userId={user!.id} onClose={() => setShowProfile(false)} isSelf />}
</AnimatePresence>
<SideMenu
isOpen={showSideMenu}
onClose={() => setShowSideMenu(false)}
/>
<AnimatePresence>
{storyViewerIndex !== null && storyGroups.length > 0 && (
<StoryViewer
stories={storyGroups}
initialUserIndex={storyViewerIndex}
onClose={() => { setStoryViewerIndex(null); loadStories(); }}
onRefresh={loadStories}
/>
)}
</AnimatePresence>
<AnimatePresence>
{showCreateStory && (
<CreateStoryModal
onClose={() => setShowCreateStory(false)}
onCreated={loadStories}
/>
)}
</AnimatePresence>
</>
);
}

View File

@@ -0,0 +1,512 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, ChevronLeft, ChevronRight, Eye, Trash2, Plus, ChevronUp } from 'lucide-react';
import { useAuthStore } from '../stores/authStore';
import { api } from '../lib/api';
import { useLang } from '../lib/i18n';
import { getInitials, generateAvatarColor } from '../lib/utils';
import Avatar from './Avatar';
import { StoryGroup } from '../lib/types';
const API_URL = import.meta.env.VITE_API_URL || '';
const STORY_BG_COLORS = [
'#6366f1', '#8b5cf6', '#ec4899', '#f43f5e', '#ef4444',
'#f97316', '#eab308', '#22c55e', '#14b8a6', '#0ea5e9',
'#3b82f6', '#1e1e2e',
];
interface StoryViewerProps {
stories: StoryGroup[];
initialUserIndex: number;
onClose: () => void;
onRefresh: () => void;
}
export default function StoryViewer({ stories, initialUserIndex, onClose, onRefresh }: StoryViewerProps) {
const { user } = useAuthStore();
const { t } = useLang();
const [userIndex, setUserIndex] = useState(initialUserIndex);
const [storyIndex, setStoryIndex] = useState(0);
const [progress, setProgress] = useState(0);
const [paused, setPaused] = useState(false);
const timerRef = useRef<ReturnType<typeof setInterval>>(undefined);
const viewedRef = useRef<Set<string>>(new Set()); // track viewed in this session
const [viewOverrides, setViewOverrides] = useState<Record<string, { viewCount: number; viewed: boolean }>>({});
const STORY_DURATION = 5000; // 5 seconds per story
const TICK = 50;
const [showViewers, setShowViewers] = useState(false);
const [viewers, setViewers] = useState<Array<{ userId: string; username: string; displayName: string; avatar: string | null; viewedAt: string }>>([]);
const [viewersLoading, setViewersLoading] = useState(false);
const currentUser = stories[userIndex];
const rawStory = currentUser?.stories?.[storyIndex];
// Merge prop data with local overrides to avoid mutating props
const currentStory = rawStory ? { ...rawStory, ...viewOverrides[rawStory.id] } : null;
// Reset when viewer opens with different user
useEffect(() => {
setUserIndex(initialUserIndex);
setStoryIndex(0);
setProgress(0);
viewedRef.current.clear();
setViewOverrides({});
}, [initialUserIndex]);
const goNext = useCallback(() => {
if (!currentUser) return;
if (storyIndex < currentUser.stories.length - 1) {
setStoryIndex(s => s + 1);
setProgress(0);
} else if (userIndex < stories.length - 1) {
setUserIndex(u => u + 1);
setStoryIndex(0);
setProgress(0);
} else {
onClose();
}
}, [storyIndex, userIndex, currentUser, stories.length, onClose]);
const goPrev = useCallback(() => {
if (storyIndex > 0) {
setStoryIndex(s => s - 1);
setProgress(0);
} else if (userIndex > 0) {
setUserIndex(u => u - 1);
const prevUser = stories[userIndex - 1];
setStoryIndex(prevUser.stories.length - 1);
setProgress(0);
}
}, [storyIndex, userIndex, stories]);
const canGoPrev = storyIndex > 0 || userIndex > 0;
const canGoNext = (currentUser && storyIndex < currentUser.stories.length - 1) || userIndex < stories.length - 1;
// Mark viewed
useEffect(() => {
if (!currentStory || !currentStory.id) return;
if (currentUser.user.id === user?.id) return;
if (currentStory.viewed || viewedRef.current.has(currentStory.id)) return;
viewedRef.current.add(currentStory.id);
const storyId = currentStory.id;
const viewCount = currentStory.viewCount || 0;
api.viewStory(storyId).then(() => {
setViewOverrides(prev => ({
...prev,
[storyId]: {
viewCount: viewCount + 1,
viewed: true,
},
}));
}).catch(console.error);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentStory?.id, currentUser?.user?.id, user?.id]);
// Progress timer - use a key to force restart
useEffect(() => {
if (paused || !currentStory) return;
setProgress(0);
const step = (TICK / STORY_DURATION) * 100;
timerRef.current = setInterval(() => {
setProgress(prev => {
if (prev >= 100) {
goNext();
return 0;
}
return prev + step;
});
}, TICK);
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [storyIndex, userIndex, paused, goNext]);
// Keyboard
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === 'ArrowRight') goNext();
if (e.key === 'ArrowLeft') goPrev();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [goNext, goPrev, onClose]);
const handleDelete = async () => {
if (!currentStory) return;
try {
await api.deleteStory(currentStory.id);
onRefresh();
goNext();
} catch (e) {
console.error(e);
}
};
if (!currentUser || !currentStory) return null;
const timeAgo = (date: string) => {
const diff = (Date.now() - new Date(date).getTime()) / 1000;
if (diff < 60) return `${Math.floor(diff)}s`;
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
return `${Math.floor(diff / 3600)}h`;
};
const avatarUrl = currentUser.user.avatar
? `${API_URL}${currentUser.user.avatar}`
: null;
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100] bg-black/95 flex items-center justify-center"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
{/* Story container */}
<div
className="relative w-full max-w-[420px] h-full max-h-[85vh] rounded-2xl overflow-hidden select-none"
onMouseDown={() => setPaused(true)}
onMouseUp={() => setPaused(false)}
onMouseLeave={() => setPaused(false)}
onTouchStart={() => setPaused(true)}
onTouchEnd={() => setPaused(false)}
>
{/* Story content */}
{currentStory.type === 'image' && currentStory.mediaUrl ? (
<div className="w-full h-full bg-black flex items-center justify-center">
<img
src={currentStory.mediaUrl.startsWith('http') ? currentStory.mediaUrl : `${API_URL}${currentStory.mediaUrl}`}
alt="story"
className="w-full h-full object-contain"
draggable={false}
/>
</div>
) : (
<div
className="w-full h-full flex items-center justify-center p-8"
style={{ background: currentStory.bgColor || '#6366f1' }}
>
<p className="text-white text-2xl font-bold text-center leading-relaxed drop-shadow-lg"
style={{ maxWidth: '90%', wordBreak: 'break-word' }}>
{currentStory.content}
</p>
</div>
)}
{/* Progress bars */}
<div className="absolute top-0 left-0 right-0 flex gap-1 p-2 z-10">
{currentUser.stories.map((_, i) => (
<div key={i} className="flex-1 h-[3px] bg-white/30 rounded-full overflow-hidden">
<div
className="h-full bg-white rounded-full transition-none"
style={{
width: i < storyIndex ? '100%' : i === storyIndex ? `${progress}%` : '0%',
}}
/>
</div>
))}
</div>
{/* Header */}
<div className="absolute top-4 left-0 right-0 flex items-center gap-3 px-4 pt-2 z-10">
<Avatar
src={avatarUrl}
name={currentUser.user.displayName || currentUser.user.username}
size="sm"
className="ring-2 ring-white/20 rounded-full"
/>
<div className="flex-1 min-w-0">
<p className="text-white text-sm font-semibold truncate drop-shadow">
{currentUser.user.id === user?.id ? t('myStory') : currentUser.user.displayName || currentUser.user.username}
</p>
<p className="text-white/60 text-xs drop-shadow">{timeAgo(currentStory.createdAt)}</p>
</div>
<div className="flex items-center gap-2">
{currentUser.user.id === user?.id && (
<>
<button
onClick={(e) => {
e.stopPropagation();
if (showViewers) {
setShowViewers(false);
setPaused(false);
} else {
setPaused(true);
setShowViewers(true);
setViewersLoading(true);
api.getStoryViewers(currentStory.id).then(v => {
setViewers(v);
setViewersLoading(false);
}).catch(() => setViewersLoading(false));
}
}}
className="text-white/60 hover:text-white text-xs flex items-center gap-1 transition-colors p-1"
>
<Eye size={12} /> {currentStory.viewCount}
<ChevronUp size={10} className={`transition-transform ${showViewers ? 'rotate-180' : ''}`} />
</button>
<button onClick={handleDelete} className="text-white/60 hover:text-red-400 transition-colors p-1">
<Trash2 size={16} />
</button>
</>
)}
<button onClick={onClose} className="text-white/60 hover:text-white transition-colors p-1">
<X size={20} />
</button>
</div>
</div>
{/* Left/Right click zones */}
<div className="absolute inset-0 flex z-[5]">
<div className="w-1/3 h-full cursor-pointer" onClick={goPrev} />
<div className="w-1/3 h-full" />
<div className="w-1/3 h-full cursor-pointer" onClick={goNext} />
</div>
{/* Navigation arrows */}
{canGoPrev && (
<button
onClick={(e) => { e.stopPropagation(); goPrev(); }}
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-9 h-9 rounded-full bg-white/10 backdrop-blur-sm flex items-center justify-center text-white/70 hover:bg-white/20 hover:text-white transition-all"
>
<ChevronLeft size={20} />
</button>
)}
{canGoNext && (
<button
onClick={(e) => { e.stopPropagation(); goNext(); }}
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-9 h-9 rounded-full bg-white/10 backdrop-blur-sm flex items-center justify-center text-white/70 hover:bg-white/20 hover:text-white transition-all"
>
<ChevronRight size={20} />
</button>
)}
{/* Viewers panel */}
<AnimatePresence>
{showViewers && currentUser.user.id === user?.id && (
<motion.div
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="absolute bottom-0 left-0 right-0 z-20 bg-black/90 backdrop-blur-xl rounded-t-2xl border-t border-white/10 max-h-[50%] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="text-white text-sm font-semibold flex items-center gap-2">
<Eye size={14} /> {t('storyViewers')} ({currentStory.viewCount})
</h4>
<button
onClick={() => { setShowViewers(false); setPaused(false); }}
className="text-white/60 hover:text-white transition-colors"
>
<X size={16} />
</button>
</div>
{viewersLoading ? (
<div className="text-white/40 text-sm text-center py-4">{t('sending')}</div>
) : viewers.length === 0 ? (
<div className="text-white/40 text-sm text-center py-4">{t('noViewers')}</div>
) : (
<div className="space-y-2">
{viewers.map((v) => (
<div key={v.userId} className="flex items-center gap-3 py-1.5">
<Avatar
src={v.avatar ? `${API_URL}${v.avatar}` : null}
name={v.displayName || v.username}
size="sm"
className="rounded-full"
/>
<div className="flex-1 min-w-0">
<p className="text-white text-sm truncate">{v.displayName || v.username}</p>
<p className="text-white/40 text-xs">@{v.username}</p>
</div>
<span className="text-white/30 text-xs">{timeAgo(v.viewedAt)}</span>
</div>
))}
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
);
}
// Story creation modal
interface CreateStoryModalProps {
onClose: () => void;
onCreated: () => void;
}
export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps) {
const { t } = useLang();
const [mode, setMode] = useState<'text' | 'image'>('text');
const [text, setText] = useState('');
const [bgColor, setBgColor] = useState('#6366f1');
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setImageFile(file);
const reader = new FileReader();
reader.onload = () => setImagePreview(reader.result as string);
reader.readAsDataURL(file);
setMode('image');
};
const handleCreate = async () => {
if (mode === 'text' && !text.trim()) return;
if (mode === 'image' && !imageFile) return;
setIsUploading(true);
try {
let mediaUrl: string | undefined;
if (imageFile) {
const result = await api.uploadFile(imageFile);
mediaUrl = result.url;
}
await api.createStory({
type: mode,
content: mode === 'text' ? text.trim() : undefined,
bgColor: mode === 'text' ? bgColor : undefined,
mediaUrl,
});
onCreated();
onClose();
} catch (e) {
console.error('Create story error:', e);
} finally {
setIsUploading(false);
}
};
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100] bg-black/80 flex items-center justify-center"
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="w-full max-w-[400px] rounded-2xl glass-strong border border-white/10 overflow-hidden"
>
<div className="p-4 border-b border-white/10 flex items-center justify-between">
<h3 className="text-lg font-semibold text-white">{t('newStory')}</h3>
<button onClick={onClose} className="text-zinc-400 hover:text-white">
<X size={18} />
</button>
</div>
{/* Mode tabs */}
<div className="flex border-b border-white/10">
<button
onClick={() => setMode('text')}
className={`flex-1 py-2.5 text-sm font-medium transition-colors ${mode === 'text' ? 'text-vortex-400 border-b-2 border-vortex-400' : 'text-zinc-400'}`}
>
{t('textStory')}
</button>
<button
onClick={() => setMode('image')}
className={`flex-1 py-2.5 text-sm font-medium transition-colors ${mode === 'image' ? 'text-vortex-400 border-b-2 border-vortex-400' : 'text-zinc-400'}`}
>
{t('imageStory')}
</button>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleImageSelect}
/>
<div className="p-4">
{mode === 'text' ? (
<>
{/* Preview */}
<div
className="w-full h-48 rounded-xl flex items-center justify-center p-4 mb-4 transition-colors"
style={{ background: bgColor }}
>
<p className="text-white text-lg font-bold text-center break-words max-w-full">
{text || t('typeYourStory')}
</p>
</div>
<textarea
value={text}
onChange={e => setText(e.target.value)}
placeholder={t('typeYourStory')}
maxLength={200}
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-sm text-zinc-200 resize-none h-20 mb-3 focus:outline-none focus:border-vortex-500/50"
/>
{/* Color picker */}
<div className="flex flex-wrap gap-2 mb-3">
{STORY_BG_COLORS.map(c => (
<button
key={c}
onClick={() => setBgColor(c)}
className={`w-7 h-7 rounded-full transition-transform ${bgColor === c ? 'scale-125 ring-2 ring-white/50' : 'hover:scale-110'}`}
style={{ background: c }}
/>
))}
</div>
</>
) : (
<>
{imagePreview ? (
<div className="relative w-full h-48 rounded-xl mb-4 overflow-hidden">
<img src={imagePreview} className="w-full h-full object-cover" alt="preview" />
<button
onClick={() => { setImageFile(null); setImagePreview(null); }}
className="absolute top-2 right-2 w-7 h-7 rounded-full bg-black/50 flex items-center justify-center text-white"
>
<X size={14} />
</button>
</div>
) : (
<button
onClick={() => fileInputRef.current?.click()}
className="w-full h-48 rounded-xl border-2 border-dashed border-white/20 flex items-center justify-center mb-4 text-zinc-400 hover:text-white hover:border-white/40 transition-colors"
>
<Plus size={32} />
</button>
)}
</>
)}
<button
onClick={handleCreate}
disabled={isUploading || (mode === 'text' && !text.trim()) || (mode === 'image' && !imageFile)}
className="w-full py-2.5 rounded-xl bg-accent hover:bg-accent-hover text-white text-sm font-medium transition-colors disabled:opacity-50"
>
{isUploading ? '...' : t('publishStory')}
</button>
</div>
</motion.div>
</motion.div>
);
}

View File

@@ -0,0 +1,25 @@
import { motion } from 'framer-motion';
import { useLang } from '../lib/i18n';
export default function TypingIndicator() {
const { t } = useLang();
return (
<div className="flex items-center gap-1 py-1">
<span className="text-xs text-vortex-400 font-medium">{t('typingText')}</span>
<div className="flex gap-0.5">
{[0, 1, 2].map((i) => (
<motion.span
key={i}
className="w-1 h-1 rounded-full bg-vortex-400"
animate={{ opacity: [0.3, 1, 0.3] }}
transition={{
duration: 1,
repeat: Infinity,
delay: i * 0.2,
}}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,502 @@
import { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Calendar, AtSign, Edit3, Check, Loader2, Image as ImageIcon, FileText, Link as LinkIcon, Download, ExternalLink, Play, UserPlus, UserMinus, UserCheck, Clock } from 'lucide-react';
import { api } from '../lib/api';
import { useAuthStore } from '../stores/authStore';
import { useLang } from '../lib/i18n';
import { User, Message, FriendshipStatus } from '../lib/types';
import ImageLightbox from './ImageLightbox';
import { getSocket } from '../lib/socket';
interface UserProfileProps {
userId: string;
chatId?: string;
onClose: () => void;
isSelf?: boolean;
}
type MediaTab = 'media' | 'files' | 'links';
export default function UserProfile({ userId, chatId, onClose, isSelf }: UserProfileProps) {
const { user: authUser } = useAuthStore();
const { t, lang } = useLang();
const [profile, setProfile] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [activeTab, setActiveTab] = useState<MediaTab>('media');
// Shared media state
const [sharedMedia, setSharedMedia] = useState<Message[]>([]);
const [sharedFiles, setSharedFiles] = useState<Message[]>([]);
const [sharedLinks, setSharedLinks] = useState<Array<Message & { links?: string[] }>>([]);
const [tabLoading, setTabLoading] = useState(false);
const [loadedTabs, setLoadedTabs] = useState<Set<MediaTab>>(new Set());
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
// Friend state
const [friendStatus, setFriendStatus] = useState<FriendshipStatus | null>(null);
const [friendLoading, setFriendLoading] = useState(false);
useEffect(() => {
loadProfile();
if (!isSelf) {
api.getFriendshipStatus(userId).then(setFriendStatus).catch(() => {});
}
}, [userId]);
// Load shared media/files/links when tab changes
const loadTabData = useCallback(async (tab: MediaTab) => {
if (!chatId || loadedTabs.has(tab)) return;
setTabLoading(true);
try {
const data = await api.getSharedMedia(chatId, tab);
if (tab === 'media') setSharedMedia(data);
else if (tab === 'files') setSharedFiles(data);
else setSharedLinks(data);
setLoadedTabs(prev => new Set(prev).add(tab));
} catch (e) {
console.error('Failed to load shared', tab, e);
} finally {
setTabLoading(false);
}
}, [chatId, loadedTabs]);
useEffect(() => {
loadTabData(activeTab);
}, [activeTab, loadTabData]);
const loadProfile = async () => {
try {
setIsLoading(true);
if (isSelf && authUser) {
setProfile(authUser);
} else {
const data = await api.getUser(userId);
setProfile(data);
}
} catch (e) {
console.error(e);
} finally {
setIsLoading(false);
}
};
const handleSendFriendRequest = async () => {
try {
setFriendLoading(true);
const result = await api.sendFriendRequest(userId);
if (result.status === 'accepted') {
setFriendStatus({ status: 'accepted', friendshipId: null });
} else {
setFriendStatus({ status: 'pending', friendshipId: null, direction: 'outgoing' });
}
// Notify via socket
const socket = getSocket();
if (socket) socket.emit('friend_request', { friendId: userId });
} catch (e) {
console.error(e);
} finally {
setFriendLoading(false);
}
};
const handleAcceptFriend = async () => {
if (!friendStatus?.friendshipId) return;
try {
setFriendLoading(true);
await api.acceptFriendRequest(friendStatus.friendshipId);
setFriendStatus({ status: 'accepted', friendshipId: friendStatus.friendshipId });
const socket = getSocket();
if (socket) socket.emit('friend_accepted', { friendId: userId });
} catch (e) {
console.error(e);
} finally {
setFriendLoading(false);
}
};
const handleRemoveFriend = async () => {
if (!friendStatus?.friendshipId) return;
try {
setFriendLoading(true);
await api.removeFriend(friendStatus.friendshipId);
setFriendStatus({ status: 'none', friendshipId: null });
const socket = getSocket();
if (socket) socket.emit('friend_removed', { friendId: userId });
} catch (e) {
console.error(e);
} finally {
setFriendLoading(false);
}
};
const initials = (profile?.displayName || profile?.username || '??')
.split(' ')
.map((w: string) => w[0])
.join('')
.slice(0, 2)
.toUpperCase();
const tabs: { key: MediaTab; label: string; icon: React.ElementType }[] = [
{ key: 'media', label: t('mediaTab'), icon: ImageIcon },
{ key: 'files', label: t('filesTab'), icon: FileText },
{ key: 'links', label: t('linksTab'), icon: LinkIcon },
];
return (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/60 z-50"
onClick={onClose}
/>
<motion.div
initial={{ opacity: 0, x: 50, filter: 'blur(20px)' }}
animate={{ opacity: 1, x: 0, filter: 'blur(0px)' }}
exit={{ opacity: 0, x: 50, filter: 'blur(20px)' }}
transition={{ type: 'spring', damping: 25, stiffness: 300, mass: 0.8 }}
className="fixed right-3 top-3 bottom-3 w-[360px] max-w-[calc(100%-24px)] bg-surface-secondary/80 backdrop-blur-2xl shadow-[0_0_120px_rgba(0,0,0,0.6)] border border-white/5 rounded-[2rem] z-50 flex flex-col overflow-hidden"
>
{/* Шапка */}
<div className="flex items-center justify-between p-5 border-b border-white/5 bg-white/5 relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-r from-vortex-500/20 to-purple-500/10 pointer-events-none" />
<h2 className="text-xl font-bold tracking-tight text-white drop-shadow-sm relative z-10">
{isSelf ? t('myProfile') : t('profileTitle')}
</h2>
<button
onClick={onClose}
className="w-8 h-8 flex items-center justify-center rounded-full bg-black/20 text-zinc-400 hover:text-white hover:bg-white/10 transition-all border border-white/5 relative z-10"
>
<X size={18} />
</button>
</div>
{isLoading ? (
<div className="flex-1 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-vortex-500 border-t-transparent rounded-full animate-spin" />
</div>
) : profile ? (
<div className="flex-1 overflow-y-auto">
{/* Аватар */}
<div className="flex flex-col items-center pt-8 pb-4 px-6 relative overflow-visible">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[240px] h-[240px] bg-vortex-500/10 rounded-full blur-[80px] pointer-events-none" />
<div className="relative group">
{/* Spinning gradient glow ring */}
<div className="absolute -inset-1 bg-gradient-to-r from-accent via-purple-500 to-accent rounded-full opacity-50 blur group-hover:opacity-75 transition duration-500 animate-[spin_4s_linear_infinite]" />
<div className="relative">
{profile.avatar ? (
<img
src={profile.avatar}
alt=""
className="w-32 h-32 rounded-full object-cover ring-4 ring-surface bg-surface"
/>
) : (
<div className="w-32 h-32 rounded-full bg-gradient-to-br from-surface to-surface-secondary flex items-center justify-center text-white font-bold text-4xl ring-4 ring-surface relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-tr from-accent/20 to-purple-500/20" />
<span className="relative z-10 text-transparent bg-clip-text bg-gradient-to-br from-white to-zinc-400 drop-shadow-md">{initials}</span>
</div>
)}
</div>
{profile.isOnline && (
<div className="absolute bottom-3 right-3 flex items-center justify-center">
<div className="absolute w-7 h-7 bg-emerald-500 rounded-full animate-ping opacity-60" />
<div className="w-7 h-7 bg-emerald-500 rounded-full border-[5px] border-surface-secondary shadow-[0_0_15px_rgba(16,185,129,0.8)]" />
</div>
)}
</div>
{/* Имя */}
<h3 className="mt-5 text-[28px] font-bold text-transparent bg-clip-text bg-gradient-to-b from-white to-white/70 tracking-tight text-center px-4">
{profile.displayName || profile.username}
</h3>
{/* Username (неизменяемый) */}
<div className="flex items-center gap-1.5 mt-2.5 bg-vortex-500/10 hover:bg-vortex-500/20 transition-colors px-4 py-1.5 rounded-full border border-vortex-500/20 backdrop-blur-sm cursor-default">
<AtSign size={14} className="text-vortex-400" />
<span className="text-sm font-semibold text-vortex-100">{profile.username}</span>
</div>
{/* Онлайн статус */}
<p className="text-xs font-semibold uppercase tracking-widest mt-4">
{profile.isOnline ? (
<span className="text-emerald-400 drop-shadow-[0_0_8px_rgba(52,211,153,0.8)] flex items-center gap-1.5">
<span className="w-1.5 h-1.5 bg-emerald-400 rounded-full animate-pulse" />
{t('online')}
</span>
) : (
<span className="text-zinc-500 flex items-center gap-1.5">
<span className="w-1.5 h-1.5 bg-zinc-500 rounded-full" />
{t('wasRecently')}
</span>
)}
</p>
{/* Friend button (for other users only) */}
{!isSelf && friendStatus && (
<div className="mt-4">
{friendStatus.status === 'none' && (
<button
onClick={handleSendFriendRequest}
disabled={friendLoading}
className="flex items-center gap-2 px-5 py-2.5 rounded-full bg-vortex-500/20 border border-vortex-500/30 text-vortex-300 hover:bg-vortex-500/30 transition-all text-sm font-medium"
>
{friendLoading ? <Loader2 size={16} className="animate-spin" /> : <UserPlus size={16} />}
{t('addFriend')}
</button>
)}
{friendStatus.status === 'pending' && friendStatus.direction === 'outgoing' && (
<div className="flex items-center gap-2 px-5 py-2.5 rounded-full bg-yellow-500/10 border border-yellow-500/20 text-yellow-400 text-sm font-medium">
<Clock size={16} />
{t('requestSent')}
</div>
)}
{friendStatus.status === 'pending' && friendStatus.direction === 'incoming' && (
<div className="flex gap-2">
<button
onClick={handleAcceptFriend}
disabled={friendLoading}
className="flex items-center gap-2 px-4 py-2.5 rounded-full bg-green-500/20 border border-green-500/30 text-green-400 hover:bg-green-500/30 transition-all text-sm font-medium"
>
{friendLoading ? <Loader2 size={16} className="animate-spin" /> : <UserCheck size={16} />}
{t('accept')}
</button>
<button
onClick={handleRemoveFriend}
disabled={friendLoading}
className="flex items-center gap-2 px-4 py-2.5 rounded-full bg-red-500/10 border border-red-500/20 text-red-400 hover:bg-red-500/20 transition-all text-sm font-medium"
>
{t('decline')}
</button>
</div>
)}
{friendStatus.status === 'accepted' && (
<button
onClick={handleRemoveFriend}
disabled={friendLoading}
className="flex items-center gap-2 px-5 py-2.5 rounded-full bg-red-500/10 border border-red-500/20 text-red-400 hover:bg-red-500/20 transition-all text-sm font-medium"
>
{friendLoading ? <Loader2 size={16} className="animate-spin" /> : <UserMinus size={16} />}
{t('removeFriend')}
</button>
)}
</div>
)}
</div>
{/* Информация */}
<div className="px-5 space-y-3 pb-8 relative z-10">
{/* О себе */}
<div className="bg-black/20 backdrop-blur-xl border border-white/5 rounded-2xl p-4 transition-all hover:bg-black/30 hover:border-white/10 group">
<div className="flex items-center gap-2 mb-2">
<div className="w-6 h-6 rounded-full bg-vortex-500/20 flex items-center justify-center border border-vortex-500/30">
<Edit3 size={12} className="text-vortex-400" />
</div>
<label className="text-xs font-semibold text-vortex-200/50 uppercase tracking-widest">
{t('aboutMe')}
</label>
</div>
<p className="text-sm text-zinc-200 leading-relaxed pl-1">
{profile.bio || (
<span className="text-white/30 italic">{t('notSpecified')}</span>
)}
</p>
</div>
{/* Дата рождения */}
{profile.birthday && (
<div className="bg-black/20 backdrop-blur-xl border border-white/5 rounded-2xl p-4 transition-all hover:bg-black/30 hover:border-white/10 group">
<div className="flex items-center gap-2 mb-2">
<div className="w-6 h-6 rounded-full bg-orange-500/20 flex items-center justify-center border border-orange-500/30">
<Calendar size={12} className="text-orange-400" />
</div>
<label className="text-xs font-semibold text-orange-200/50 uppercase tracking-widest">
{t('birthday')}
</label>
</div>
<p className="text-sm text-zinc-200 pl-1">
{profile.birthday ? (
new Date(profile.birthday).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
) : (
<span className="text-white/30 italic">{t('notSpecified')}</span>
)}
</p>
</div>
)}
{/* Дата регистрации */}
<div className="bg-black/20 backdrop-blur-xl border border-white/5 rounded-2xl p-4 transition-all hover:bg-black/30 hover:border-white/10 group">
<div className="flex items-center gap-2 mb-2">
<div className="w-6 h-6 rounded-full bg-emerald-500/20 flex items-center justify-center border border-emerald-500/30">
<Check size={12} className="text-emerald-400" />
</div>
<label className="text-xs font-semibold text-emerald-200/50 uppercase tracking-widest">
{t('onVortexSince')}
</label>
</div>
<p className="text-sm text-zinc-200 pl-1">
{new Date(profile.createdAt).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</p>
</div>
</div>
{/* Медиа / Файлы / Ссылки */}
<div className="border-t border-white/5 bg-black/10 mt-2 backdrop-blur-md">
<div className="flex px-2 pt-2 gap-1 overflow-x-auto no-scrollbar">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex-1 flex items-center justify-center gap-2 py-3 px-1 text-xs font-bold transition-all rounded-t-xl min-w-[100px] ${activeTab === tab.key
? 'bg-white/10 text-white shadow-[inset_0_2px_10px_rgba(255,255,255,0.05)] border-t border-x border-white/10'
: 'text-zinc-500 hover:text-zinc-300 hover:bg-white/5'
}`}
>
<tab.icon size={14} className={activeTab === tab.key ? 'text-vortex-400' : 'opacity-70'} />
{tab.label}
</button>
))}
</div>
<div className="min-h-[160px] bg-white/[0.02] border-t border-white/5 relative">
{/* Subtle top glow for active tab content */}
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-vortex-500/50 to-transparent" />
{tabLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 size={20} className="animate-spin text-zinc-500" />
</div>
) : activeTab === 'media' ? (
sharedMedia.length > 0 ? (
<div className="grid grid-cols-3 gap-0.5 p-1">
{(() => {
const allMedia = sharedMedia.flatMap((msg) => (msg.media || []));
return allMedia.map((m, idx) => (
<div
key={m.id}
onClick={() => setLightboxIndex(idx)}
className="relative aspect-square bg-zinc-900 overflow-hidden group cursor-pointer"
>
{m.type === 'video' ? (
<>
<img
src={m.thumbnail || m.url}
alt=""
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<Play size={24} className="text-white fill-white" />
</div>
</>
) : (
<img
src={m.url}
alt=""
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
)}
</div>
));
})()}
</div>
) : (
<div className="flex items-center justify-center py-8">
<p className="text-xs text-zinc-600 italic">{t('sharedPhotos')}</p>
</div>
)
) : activeTab === 'files' ? (
sharedFiles.length > 0 ? (
<div className="divide-y divide-border">
{sharedFiles.flatMap((msg) =>
(msg.media || []).map((m) => (
<a
key={m.id}
href={m.url}
download={m.filename || 'file'}
className="flex items-center gap-3 px-4 py-3 hover:bg-white/5 transition-colors group/file"
>
<div className="w-10 h-10 rounded-xl bg-vortex-500/20 flex items-center justify-center flex-shrink-0 border border-vortex-500/30 group-hover/file:scale-105 transition-transform">
<FileText size={18} className="text-vortex-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{m.filename || 'file'}</p>
<p className="text-xs text-zinc-500">
{m.size ? `${(m.size / 1024).toFixed(1)} KB` : ''}
{msg.sender ? ` · ${msg.sender.displayName || msg.sender.username}` : ''}
</p>
</div>
<Download size={16} className="text-zinc-500 flex-shrink-0" />
</a>
))
)}
</div>
) : (
<div className="flex items-center justify-center py-8">
<p className="text-xs text-zinc-600 italic">{t('sharedFiles')}</p>
</div>
)
) : (
sharedLinks.length > 0 ? (
<div className="divide-y divide-border">
{sharedLinks.map((msg) => (
<div key={msg.id} className="px-4 py-3 hover:bg-white/5 transition-colors">
<p className="text-xs text-zinc-500 mb-1.5 font-medium">
{msg.sender?.displayName || msg.sender?.username} · {new Date(msg.createdAt).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US')}
</p>
{(msg.links || []).map((link: string, i: number) => (
<a
key={i}
href={link}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-vortex-400 hover:text-vortex-300 transition-colors truncate"
>
<ExternalLink size={14} className="flex-shrink-0" />
<span className="truncate">{link}</span>
</a>
))}
{msg.content && (
<p className="text-xs text-zinc-400 mt-1 line-clamp-2">{msg.content}</p>
)}
</div>
))}
</div>
) : (
<div className="flex items-center justify-center py-8">
<p className="text-xs text-zinc-600 italic">{t('sharedLinks')}</p>
</div>
)
)}
</div>
</div>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-zinc-500">
{t('profileNotFound')}
</div>
)}
</motion.div>
{/* Media lightbox gallery */}
<AnimatePresence>
{lightboxIndex !== null && (
<ImageLightbox
images={sharedMedia.flatMap((msg) => (msg.media || []).map((m) => ({ url: m.url, type: m.type })))}
initialIndex={lightboxIndex}
onClose={() => setLightboxIndex(null)}
/>
)}
</AnimatePresence>
</>
);
}

308
apps/web/src/index.css Normal file
View File

@@ -0,0 +1,308 @@
@import "tailwindcss";
@theme {
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
--color-vortex-50: #eef2ff;
--color-vortex-100: #e0e7ff;
--color-vortex-200: #c7d2fe;
--color-vortex-300: #a5b4fc;
--color-vortex-400: #818cf8;
--color-vortex-500: #6366f1;
--color-vortex-600: #4f46e5;
--color-vortex-700: #4338ca;
--color-vortex-800: #3730a3;
--color-vortex-900: #312e81;
--color-vortex-950: #1e1b4b;
--color-surface: #09090b;
--color-surface-secondary: #111113;
--color-surface-tertiary: #1a1a1e;
--color-surface-hover: #222226;
--color-border: rgba(255, 255, 255, 0.08);
--color-border-light: rgba(255, 255, 255, 0.12);
--color-accent: #6366f1;
--color-accent-hover: #818cf8;
--color-accent-light: rgba(99, 102, 241, 0.15);
}
* {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
}
*::-webkit-scrollbar {
width: 6px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
*::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.2);
}
html, body, #root {
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background-color: #09090b;
color: #fafafa;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes pulse-soft {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.animate-slide-in {
animation: slideIn 0.2s ease-out;
}
.animate-fade-in {
animation: fadeIn 0.15s ease-out;
}
.glass {
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.glass-strong {
background: rgba(17, 17, 19, 0.85);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.gradient-text {
background: linear-gradient(135deg, #6366f1, #8b5cf6, #a855f7);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.bubble-sent {
background: #3b82f6;
}
.bubble-received {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.bubble-sent ::selection {
background: rgba(255, 255, 255, 0.35);
}
/* ==================== */
/* Animation Keyframes */
/* ==================== */
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-20px); }
}
@keyframes call-wave {
0% { transform: scale(1); opacity: 0.6; }
100% { transform: scale(2.5); opacity: 0; }
}
.animate-float {
animation: float 6s ease-in-out infinite;
}
.animate-float-delayed {
animation: float 6s ease-in-out 3s infinite;
}
.animate-call-wave {
animation: call-wave 2s ease-out infinite;
}
.animate-call-wave-delayed {
animation: call-wave 2s ease-out 1s infinite;
}
/* ==================== */
/* Chat Themes */
/* ==================== */
.chat-theme-midnight {
background-color: #0f0f13;
background-image: url("data:image/svg+xml,%3Csvg width='100' height='100' viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M11 18c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm48 25c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm-43-7c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm63 31c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3z' fill='%23ffffff' fill-opacity='0.02' fill-rule='evenodd'/%3E%3C/svg%3E");
}
.chat-theme-ocean {
background-color: #0b172a;
background-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%233b82f6' fill-opacity='0.05' fill-rule='evenodd'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/svg%3E");
}
.chat-theme-forest {
background-color: #0f1c15;
background-image: url("data:image/svg+xml,%3Csvg width='20' height='20' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%2310b981' fill-opacity='0.04' fill-rule='evenodd'%3E%3Ccircle cx='3' cy='3' r='3'/%3E%3Ccircle cx='13' cy='13' r='3'/%3E%3C/g%3E%3C/svg%3E");
}
.chat-theme-sunset {
background: linear-gradient(#1f111a, #150a0f);
position: relative;
}
.chat-theme-sunset::after {
content: '';
position: absolute;
top: 0; right: 0; bottom: 0; left: 0;
pointer-events: none;
background-image: radial-gradient(circle at 50% 150%, rgba(236, 72, 153, 0.1), transparent 60%);
}
.chat-theme-classic {
background-color: #121215;
}
.chat-theme-neon {
background-color: #0b0f19;
position: relative;
}
.chat-theme-neon::before {
content: '';
position: absolute;
top: 0; right: 0; bottom: 0; left: 0;
pointer-events: none;
background: radial-gradient(circle 400px at var(--mouse-x, 50%) var(--mouse-y, 50%), rgba(139, 92, 246, 0.15), transparent 80%);
transition: background 0.15s ease-out;
}
.chat-theme-aurora {
background: linear-gradient(135deg, #022c22, #064e3b);
position: relative;
}
.chat-theme-aurora::before {
content: '';
position: absolute;
top: 0; right: 0; bottom: 0; left: 0;
pointer-events: none;
background: radial-gradient(circle 600px at var(--mouse-x, 50%) var(--mouse-y, 50%), rgba(16, 185, 129, 0.2), transparent 70%);
transition: background 0.15s ease-out;
}
.chat-theme-cyber {
background-color: #000;
background-image:
linear-gradient(rgba(245, 158, 11, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(245, 158, 11, 0.03) 1px, transparent 1px);
background-size: 20px 20px;
position: relative;
}
.chat-theme-cyber::before {
content: '';
position: absolute;
top: 0; right: 0; bottom: 0; left: 0;
pointer-events: none;
background: radial-gradient(circle 300px at var(--mouse-x, 50%) var(--mouse-y, 50%), rgba(245, 158, 11, 0.15), transparent);
transition: background 0.15s ease-out;
}
.chat-theme-glass {
background-color: #0d1117;
position: relative;
overflow: hidden;
}
.chat-theme-glass::before {
content: '';
position: absolute;
width: 50vw;
height: 50vh;
left: var(--mouse-x, 50%);
top: var(--mouse-y, 50%);
transform: translate(-50%, -50%);
pointer-events: none;
background: radial-gradient(circle, rgba(59, 130, 246, 0.2), transparent 60%);
filter: blur(80px);
transition: left 0.5s cubic-bezier(0.2, 0.8, 0.2, 1), top 0.5s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.chat-theme-void {
background-color: #000;
position: relative;
}
.chat-theme-void::before {
content: '';
position: absolute;
top: 0; right: 0; bottom: 0; left: 0;
pointer-events: none;
background: radial-gradient(circle 120px at var(--mouse-x, 50%) var(--mouse-y, 50%), rgba(255, 255, 255, 0.05), transparent);
transition: background 0.1s;
}
.chat-bg {
background-color: #09090b;
background-image:
radial-gradient(ellipse at 20% 50%, rgba(99, 102, 241, 0.06) 0%, transparent 50%),
radial-gradient(ellipse at 80% 20%, rgba(139, 92, 246, 0.05) 0%, transparent 50%),
radial-gradient(ellipse at 60% 80%, rgba(168, 85, 247, 0.04) 0%, transparent 50%),
url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%236366f1' fill-opacity='0.03'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
}
::selection {
background: rgba(99, 102, 241, 0.3);
text-shadow: 0 0 8px rgba(99, 102, 241, 0.4);
}
input:focus, textarea:focus {
outline: none;
}
/* Focus-visible ring for keyboard navigation accessibility */
:focus-visible {
outline: 2px solid rgba(99, 102, 241, 0.6);
outline-offset: 2px;
}
button:focus:not(:focus-visible),
a:focus:not(:focus-visible) {
outline: none;
}
/* emoji-mart dark theme overrides */
em-emoji-picker {
--em-rgb-background: 17, 17, 19;
--em-rgb-input: 26, 26, 30;
--em-rgb-color: 240, 240, 240;
--border-radius: 0 0 16px 16px;
border: none !important;
}

297
apps/web/src/lib/api.ts Normal file
View File

@@ -0,0 +1,297 @@
import type { User, UserBasic, UserPresence, Chat, Message, MediaItem, StoryGroup, FriendRequest, FriendWithId, FriendshipStatus } from './types';
const API_BASE = '/api';
class ApiClient {
private token: string | null = null;
setToken(token: string | null) {
this.token = token;
}
private async request<T>(endpoint: string, options: RequestInit & { timeout?: number } = {}): Promise<T> {
const { timeout = 30_000, ...fetchOptions } = options;
const controller = new AbortController();
const timer = timeout > 0 ? setTimeout(() => controller.abort(), timeout) : undefined;
const headers: HeadersInit = {
'Content-Type': 'application/json',
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
...fetchOptions.headers,
};
let response: Response;
try {
response = await fetch(`${API_BASE}${endpoint}`, {
...fetchOptions,
headers,
signal: controller.signal,
});
} catch (err) {
clearTimeout(timer);
if (err instanceof DOMException && err.name === 'AbortError') {
throw new Error('Время ожидания запроса истекло');
}
throw err;
}
clearTimeout(timer);
if (!response.ok) {
const error = await response.json().catch(() => ({ error: '\u041e\u0448\u0438\u0431\u043a\u0430 \u0441\u0435\u0440\u0432\u0435\u0440\u0430' }));
throw new Error(error.error || '\u041e\u0448\u0438\u0431\u043a\u0430 \u0437\u0430\u043f\u0440\u043e\u0441\u0430');
}
return response.json();
}
// \u0410\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u044f
async login(username: string, password: string) {
return this.request<{ token: string; user: User }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
}
async register(username: string, displayName: string, password: string, bio?: string) {
return this.request<{ token: string; user: User }>('/auth/register', {
method: 'POST',
body: JSON.stringify({ username, displayName, password, bio }),
});
}
async getMe() {
return this.request<{ user: User }>('/auth/me');
}
// \u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0438
async searchUsers(query: string) {
return this.request<UserPresence[]>(`/users/search?q=${encodeURIComponent(query)}`);
}
async getUser(id: string) {
return this.request<User>(`/users/${id}`);
}
async updateProfile(data: { displayName?: string; bio?: string; birthday?: string }) {
return this.request<User>('/users/profile', {
method: 'PUT',
body: JSON.stringify(data),
});
}
async uploadAvatar(file: File) {
const formData = new FormData();
formData.append('avatar', file);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 120_000);
const response = await fetch(`${API_BASE}/users/avatar`, {
method: 'POST',
headers: {
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
},
body: formData,
signal: controller.signal,
});
clearTimeout(timer);
if (!response.ok) throw new Error('Ошибка загрузки аватара');
return response.json() as Promise<User>;
}
async removeAvatar() {
return this.request<User>('/users/avatar', { method: 'DELETE' });
}
async searchMessages(query: string, chatId?: string) {
const params = new URLSearchParams({ q: query });
if (chatId) params.append('chatId', chatId);
return this.request<Message[]>(`/users/messages/search?${params}`);
}
// \u0427\u0430\u0442\u044b
async getChats() {
return this.request<Chat[]>('/chats');
}
async createPersonalChat(userId: string) {
return this.request<Chat>('/chats/personal', {
method: 'POST',
body: JSON.stringify({ userId }),
});
}
async createGroupChat(name: string, memberIds: string[]) {
return this.request<Chat>('/chats/group', {
method: 'POST',
body: JSON.stringify({ name, memberIds }),
});
}
// \u0421\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f
async getMessages(chatId: string, cursor?: string) {
const params = cursor ? `?cursor=${cursor}` : '';
return this.request<Message[]>(`/messages/chat/${chatId}${params}`);
}
async uploadFile(file: File) {
const formData = new FormData();
formData.append('file', file);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 120_000);
const response = await fetch(`${API_BASE}/messages/upload`, {
method: 'POST',
headers: {
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
},
body: formData,
signal: controller.signal,
});
clearTimeout(timer);
if (!response.ok) throw new Error('\u041e\u0448\u0438\u0431\u043a\u0430 \u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0438 \u0444\u0430\u0439\u043b\u0430');
return response.json() as Promise<{ url: string; filename: string; size: number }>;
}
// \u0413\u0440\u0443\u043f\u043f\u044b
async updateGroup(chatId: string, data: { name?: string }) {
return this.request<Chat>(`/chats/${chatId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async uploadGroupAvatar(chatId: string, file: File) {
const formData = new FormData();
formData.append('avatar', file);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 120_000);
const response = await fetch(`${API_BASE}/chats/${chatId}/avatar`, {
method: 'POST',
headers: {
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
},
body: formData,
signal: controller.signal,
});
clearTimeout(timer);
if (!response.ok) throw new Error('\u041e\u0448\u0438\u0431\u043a\u0430 \u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0438 \u0430\u0432\u0430\u0442\u0430\u0440\u0430');
return response.json() as Promise<Chat>;
}
async removeGroupAvatar(chatId: string) {
return this.request<Chat>(`/chats/${chatId}/avatar`, { method: 'DELETE' });
}
async addGroupMembers(chatId: string, userIds: string[]) {
return this.request<Chat>(`/chats/${chatId}/members`, {
method: 'POST',
body: JSON.stringify({ userIds }),
});
}
async removeGroupMember(chatId: string, userId: string) {
return this.request<Chat>(`/chats/${chatId}/members/${userId}`, {
method: 'DELETE',
});
}
async clearChat(chatId: string) {
return this.request<{ message: string }>(`/chats/${chatId}/clear`, { method: 'POST' });
}
async deleteChat(chatId: string) {
return this.request<{ message: string }>(`/chats/${chatId}`, { method: 'DELETE' });
}
async togglePinChat(chatId: string) {
return this.request<{ isPinned: boolean }>(`/chats/${chatId}/pin`, { method: 'POST' });
}
async getSharedMedia(chatId: string, type: 'media' | 'files' | 'links') {
return this.request<Message[]>(`/messages/chat/${chatId}/shared?type=${type}`);
}
// ICE серверы для WebRTC
async getIceServers() {
return this.request<{ iceServers: RTCIceServer[] }>('/ice-servers');
}
// Stories
async getStories() {
return this.request<StoryGroup[]>('/stories');
}
async createStory(data: { type: string; mediaUrl?: string; content?: string; bgColor?: string }) {
return this.request<{ id: string }>('/stories', {
method: 'POST',
body: JSON.stringify(data),
});
}
async viewStory(storyId: string) {
return this.request<{ message: string }>(`/stories/${storyId}/view`, { method: 'POST' });
}
async deleteStory(storyId: string) {
return this.request<{ message: string }>(`/stories/${storyId}`, { method: 'DELETE' });
}
async getStoryViewers(storyId: string) {
return this.request<Array<{ userId: string; username: string; displayName: string; avatar: string | null; viewedAt: string }>>(`/stories/${storyId}/viewers`);
}
// Favorites chat
async getOrCreateFavorites() {
return this.request<Chat>('/chats/favorites', { method: 'POST' });
}
// User settings
async updateSettings(data: { hideStoryViews?: boolean }) {
return this.request<User>('/users/settings', {
method: 'PUT',
body: JSON.stringify(data),
});
}
// Friends
async getFriends() {
return this.request<FriendWithId[]>('/friends');
}
async getFriendRequests() {
return this.request<FriendRequest[]>('/friends/requests');
}
async getOutgoingRequests() {
return this.request<FriendRequest[]>('/friends/outgoing');
}
async getFriendshipStatus(userId: string) {
return this.request<FriendshipStatus>(`/friends/status/${userId}`);
}
async sendFriendRequest(friendId: string) {
return this.request<{ status: string }>('/friends/request', {
method: 'POST',
body: JSON.stringify({ friendId }),
});
}
async acceptFriendRequest(friendshipId: string) {
return this.request<{ id: string }>(`/friends/${friendshipId}/accept`, { method: 'POST' });
}
async declineFriendRequest(friendshipId: string) {
return this.request<{ success: boolean }>(`/friends/${friendshipId}/decline`, { method: 'POST' });
}
async removeFriend(friendshipId: string) {
return this.request<{ success: boolean }>(`/friends/${friendshipId}`, { method: 'DELETE' });
}
}
export const api = new ApiClient();

59
apps/web/src/lib/hooks.ts Normal file
View File

@@ -0,0 +1,59 @@
import { useState, useEffect, useRef, useCallback } from 'react';
/**
* Debounced value — updates value after the specified delay.
*/
export function useDebouncedValue<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
/**
* Debounced callback — returns a function that delays execution.
*/
export function useDebouncedCallback<T extends (...args: unknown[]) => unknown>(
callback: T,
delay: number,
): (...args: Parameters<T>) => void {
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const callbackRef = useRef(callback);
callbackRef.current = callback;
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
return useCallback((...args: Parameters<T>) => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => callbackRef.current(...args), delay);
}, [delay]);
}
/**
* Returns an AbortController that auto-aborts on unmount or when reset is called.
*/
export function useAbortController() {
const controllerRef = useRef<AbortController | null>(null);
const getSignal = useCallback(() => {
if (controllerRef.current) controllerRef.current.abort();
controllerRef.current = new AbortController();
return controllerRef.current.signal;
}, []);
useEffect(() => {
return () => {
controllerRef.current?.abort();
};
}, []);
return getSignal;
}

513
apps/web/src/lib/i18n.ts Normal file
View File

@@ -0,0 +1,513 @@
import { create } from 'zustand';
export type Lang = 'ru' | 'en';
const LANG_KEY = 'vortex_language';
const translations = {
ru: {
// Side menu
myProfile: 'Мой профиль',
settings: 'Настройки',
aboutApp: 'О приложении',
logout: 'Выйти из аккаунта',
// Profile
name: 'Имя',
username: 'Имя пользователя',
aboutMe: 'О себе',
birthday: 'Дата рождения',
enterName: 'Введите имя',
tellAboutYourself: 'Расскажите о себе',
removePhoto: 'Удалить фото',
// Settings
language: 'Язык / Language',
interfaceLang: 'Язык интерфейса',
theme: 'Оформление',
russian: 'Русский',
english: 'English',
about: 'О приложении',
version: 'Версия',
modernMessenger: 'Современный мессенджер с фокусом',
onPrivacy: 'на приватность и удобство',
modernMessengerShort: 'Современный мессенджер',
// Chat
chat: 'Чат',
group: 'Группа',
searchChats: 'Поиск чатов...',
noChats: 'Нет чатов — создайте первый!',
nothingFound: 'Ничего не найдено',
selectChat: 'Выберите чат для начала общения',
noMessages: 'Нет сообщений — напишите первое!',
typing: 'печатает...',
online: 'в сети',
wasRecently: 'был(а) недавно',
members: 'участников',
messagePlaceholder: 'Сообщение...',
message: 'Сообщение...',
addCaption: 'Добавьте подпись...',
searchMessages: 'Поиск сообщений...',
searchMessagesBtn: 'Поиск сообщений',
userProfile: 'Профиль пользователя',
enableSound: 'Включить звук',
disableSound: 'Выключить звук',
deleteChat: 'Удалить чат',
// Messages
messageDeleted: 'Сообщение удалено',
reply: 'Ответить',
copy: 'Копировать',
edit: 'Редактировать',
delete: 'Удалить',
deleteForMe: 'Удалить у меня',
deleteForAll: 'Удалить у всех',
deleteAlsoFor: 'Удалить также для',
editing: 'Редактирование',
replyTo: 'Ответ',
media: 'Медиа',
download: 'Скачать',
edited: 'ред.',
selected: 'выбрано',
fileLabel: 'Файл',
kb: 'КБ',
voice: '🎤 Голосовое',
photo: '🖼 Фото',
video: '🎬 Видео',
file: 'Файл',
// Call
call: 'Звонок',
videoCall: 'Видеозвонок',
incomingCall: 'Входящий звонок',
incomingVideoCall: 'Входящий видеозвонок',
calling: 'Вызов...',
accept: 'Принять',
decline: 'Отклонить',
endCall: 'Завершить',
callEnded: 'Звонок завершён',
callDeclined: 'Звонок отклонён',
// Photo/video
photoVideo: 'Фото / видео',
fileBtn: 'Файл',
sendError: 'Ошибка отправки',
// New chat
newChat: 'Новый чат',
menu: 'Меню',
// Date picker
months: ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'],
weekDays: ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'],
clear: 'Очистить',
today: 'Сегодня',
// New chat
newChatTitle: 'Новый чат',
selectMembers: 'Выберите участников',
newGroup: 'Новая группа',
groupNamePlaceholder: 'Название группы...',
membersCount: 'Участники',
createGroup: 'Создать группу',
upTo200: 'До 200 участников',
findUser: 'Найти пользователя по имени или @username...',
addMembers: 'Добавить участников...',
usersNotFound: 'Пользователи не найдены',
enterNameOrUsername: 'Введите имя или @username',
next: 'Далее',
pinMessage: 'Закрепить',
unpinMessage: 'Открепить',
pinnedMessage: 'Закреплённое сообщение',
forwardMessage: 'Переслать сообщение',
forward: 'Переслать',
forwardedFrom: 'Переслано от',
replyWithQuote: 'Ответить с цитатой',
select: 'Выбрать',
sending: 'Отправка...',
scheduleMessage: 'Запланировать',
scheduleSend: 'Отправить по расписанию',
scheduled: 'Запланировано',
myStory: 'Моя история',
newStory: 'Новая история',
textStory: 'Текст',
imageStory: 'Фото',
typeYourStory: 'Напишите историю...',
publishStory: 'Опубликовать',
stories: 'Истории',
clearChat: 'Очистить чат',
clearChatConfirm: 'Очистить историю чата для себя? Собеседник сохранит свою историю.',
deleteChatConfirm: 'Удалить чат? Это действие нельзя отменить.',
pinChat: 'Закрепить чат',
unpinChat: 'Открепить чат',
chatCleared: 'Чат очищен',
groupSettings: 'Настройки группы',
editGroupName: 'Изменить название',
addMember: 'Добавить участника',
removeMember: 'Удалить из группы',
leaveGroup: 'Покинуть группу',
adminBadge: 'Админ',
memberBadge: 'Участник',
screenShare: 'Демонстрация экрана',
stopScreenShare: 'Остановить демонстрацию',
switchCamera: 'Выбрать камеру',
minimize: 'Свернуть',
volume: 'Громкость',
mute: 'Выключить микрофон',
unmute: 'Включить микрофон',
noiseSuppressionOn: 'Шумоподавление включено',
noiseSuppressionOff: 'Шумоподавление выключено',
selectMicrophone: 'Выбрать микрофон',
microphone: 'Микрофон',
rightClickVolume: 'ПКМ для регулировки громкости',
participants: 'участников',
joinCall: 'Присоединиться к звонку',
activeCall: 'Активный звонок',
groupNameRequired: 'Введите название группы',
confirmRemoveMember: 'Удалить этого участника из группы?',
yes: 'Да',
no: 'Нет',
you: 'вы',
// User profile
mediaTab: 'Медиа',
filesTab: 'Файлы',
linksTab: 'Ссылки',
profileTitle: 'Профиль',
removeAvatar: 'Удалить аватар',
namePlaceholder: 'Имя',
notSpecified: 'Не указано',
onVortexSince: 'На Vortex с',
cancel: 'Отмена',
confirm: 'Подтвердить',
save: 'Сохранить',
sharedPhotos: 'Общие фото и видео будут здесь',
sharedFiles: 'Общие файлы будут здесь',
sharedLinks: 'Общие ссылки будут здесь',
profileNotFound: 'Профиль не найден',
// Typing
typingText: 'печатает',
// Emoji
emojiFrequent: 'Часто',
emojiGestures: 'Жесты',
emojiEmotions: 'Эмоции',
emojiObjects: 'Предметы',
emojiFood: 'Еда',
emojiNature: 'Природа',
// Auth
login: 'Вход',
register: 'Регистрация',
latinOnly: '(латиница, нельзя изменить)',
displayNameLabel: 'Отображаемое имя',
displayNamePlaceholder: 'Ваше имя (любой язык)',
password: 'Пароль',
passwordPlaceholder: 'Введите пароль',
bioPlaceholder: 'Расскажите о себе (необязательно)',
loginBtn: 'Войти',
createAccount: 'Создать аккаунт',
testAccounts: 'Тестовые аккаунты: evgeniy, anastasia, artem, polina',
passwordForAll: 'Пароль для всех:',
// File/upload
fileTooLarge: 'Файл слишком большой (макс. 50МБ)',
dropFileHere: 'Отпустите файл здесь',
uploading: 'Загрузка...',
changePhoto: 'Сменить фото',
remove: 'Удалить',
// Formatting
formatBold: 'Жирный',
formatBoldHint: '**текст**',
formatItalic: 'Курсив',
formatItalicHint: '_текст_',
formatStrike: 'Зачёркнут',
formatStrikeHint: '~текст~',
formatMono: 'Моноширинный',
formatMonoHint: '`текст`',
// Draft
draft: 'Черновик:',
// Date
datePlaceholder: 'дд.мм.гггг',
// GIF / Emoji
tenorKeyRequired: 'Для GIF нужен Tenor API ключ',
openConsoleRun: 'Откройте консоль и выполните:',
searchGifs: 'Поиск GIF...',
trending: 'Популярные',
// Misc
error: 'Ошибка',
// Friends
friends: 'Друзья',
friendRequests: 'Заявки в друзья',
friendsList: 'Список друзей',
noFriends: 'Пока нет друзей',
addFriend: 'Добавить в друзья',
removeFriend: 'Удалить из друзей',
requestSent: 'Заявка отправлена',
searchFriends: 'Поиск по @username (мин. 3 символа)',
noSearchResults: 'Пользователи не найдены',
minCharsHint: 'Введите минимум 3 символа после @',
// Story viewers
storyViewers: 'Кто просмотрел',
noViewers: 'Пока никто не посмотрел',
// Favorites
favorites: 'Избранное',
favoritesDescription: 'Сохраняйте важные сообщения здесь',
// Privacy
privacy: 'Приватность',
hideStoryViews: 'Скрывать просмотры историй',
hideStoryViewsDesc: 'Другие не увидят, что вы смотрели их историю',
// Scheduled
scheduledFor: 'Запланировано на',
messageScheduled: 'Сообщение запланировано',
scheduledDelivered: 'Сообщение отправлено для',
scheduledDeliveredAt: 'в',
scheduleIn1h: 'Через 1 час',
scheduleIn3h: 'Через 3 часа',
scheduleTomorrow: 'Завтра в 9:00',
scheduleCustom: 'Выбрать дату и время',
scheduleTime: 'Время',
scheduleDate: 'Дата',
// Last seen
lastSeenAt: 'был(а)',
justNow: 'только что',
},
en: {
myProfile: 'My Profile',
settings: 'Settings',
aboutApp: 'About',
logout: 'Log Out',
name: 'Name',
username: 'Username',
aboutMe: 'About me',
birthday: 'Birthday',
enterName: 'Enter name',
tellAboutYourself: 'Tell about yourself',
removePhoto: 'Remove photo',
language: 'Language',
interfaceLang: 'Interface language',
theme: 'Theme',
russian: 'Русский',
english: 'English',
about: 'About',
version: 'Version',
modernMessenger: 'A modern messenger focused',
onPrivacy: 'on privacy and convenience',
modernMessengerShort: 'Modern messenger',
searchChats: 'Search chats...',
chat: 'Chat',
group: 'Group',
noChats: 'No chats — create one!',
nothingFound: 'Nothing found',
selectChat: 'Select a chat to start messaging',
noMessages: 'No messages — write the first one!',
typing: 'typing...',
online: 'online',
wasRecently: 'was recently',
members: 'members',
messagePlaceholder: 'Message...',
message: 'Message...',
addCaption: 'Add a caption...',
searchMessages: 'Search messages...',
searchMessagesBtn: 'Search messages',
userProfile: 'User profile',
enableSound: 'Enable sound',
disableSound: 'Mute',
deleteChat: 'Delete chat',
messageDeleted: 'Message deleted',
reply: 'Reply',
copy: 'Copy',
edit: 'Edit',
delete: 'Delete',
deleteForMe: 'Delete for me',
deleteForAll: 'Delete for everyone',
deleteAlsoFor: 'Delete also for',
editing: 'Editing',
replyTo: 'Reply',
media: 'Media',
download: 'Download',
edited: 'edit.',
selected: 'selected',
fileLabel: 'File',
kb: 'KB',
voice: '🎤 Voice',
photo: '🖼 Photo',
video: '🎬 Video',
file: 'File',
call: 'Call',
videoCall: 'Video call',
incomingCall: 'Incoming call',
incomingVideoCall: 'Incoming video call',
calling: 'Calling...',
accept: 'Accept',
decline: 'Decline',
endCall: 'End call',
callEnded: 'Call ended',
callDeclined: 'Call declined',
photoVideo: 'Photo / video',
fileBtn: 'File',
sendError: 'Send error',
newChat: 'New chat',
menu: 'Menu',
months: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
weekDays: ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'],
clear: 'Clear',
today: 'Today',
newChatTitle: 'New Chat',
selectMembers: 'Select members',
newGroup: 'New Group',
groupNamePlaceholder: 'Group name...',
membersCount: 'Members',
createGroup: 'Create Group',
upTo200: 'Up to 200 members',
findUser: 'Find user by name or @username...',
addMembers: 'Add members...',
usersNotFound: 'Users not found',
enterNameOrUsername: 'Enter name or @username',
next: 'Next',
pinMessage: 'Pin',
unpinMessage: 'Unpin',
pinnedMessage: 'Pinned message',
forwardMessage: 'Forward message',
forward: 'Forward',
forwardedFrom: 'Forwarded from',
replyWithQuote: 'Reply with quote',
select: 'Select',
sending: 'Sending...',
scheduleMessage: 'Schedule',
scheduleSend: 'Send scheduled',
scheduled: 'Scheduled',
myStory: 'My story',
newStory: 'New story',
textStory: 'Text',
imageStory: 'Photo',
typeYourStory: 'Write your story...',
publishStory: 'Publish',
stories: 'Stories',
clearChat: 'Clear chat',
clearChatConfirm: 'Clear chat history for yourself? The other person will keep their history.',
deleteChatConfirm: 'Delete this chat? This action cannot be undone.',
pinChat: 'Pin chat',
unpinChat: 'Unpin chat',
chatCleared: 'Chat cleared',
groupSettings: 'Group settings',
editGroupName: 'Edit name',
addMember: 'Add member',
removeMember: 'Remove from group',
leaveGroup: 'Leave group',
adminBadge: 'Admin',
memberBadge: 'Member',
screenShare: 'Screen share',
stopScreenShare: 'Stop sharing',
switchCamera: 'Switch camera',
minimize: 'Minimize',
volume: 'Volume',
mute: 'Mute',
unmute: 'Unmute',
noiseSuppressionOn: 'Noise suppression on',
noiseSuppressionOff: 'Noise suppression off',
selectMicrophone: 'Select microphone',
microphone: 'Microphone',
rightClickVolume: 'Right-click to adjust volume',
participants: 'participants',
joinCall: 'Join call',
activeCall: 'Active call',
groupNameRequired: 'Enter a group name',
confirmRemoveMember: 'Remove this member from the group?',
yes: 'Yes',
no: 'No',
you: 'you',
mediaTab: 'Media',
filesTab: 'Files',
linksTab: 'Links',
profileTitle: 'Profile',
removeAvatar: 'Remove avatar',
namePlaceholder: 'Name',
notSpecified: 'Not specified',
onVortexSince: 'On Vortex since',
cancel: 'Cancel',
confirm: 'Confirm',
save: 'Save',
sharedPhotos: 'Shared photos and videos will appear here',
sharedFiles: 'Shared files will appear here',
sharedLinks: 'Shared links will appear here',
profileNotFound: 'Profile not found',
typingText: 'typing',
emojiFrequent: 'Frequent',
emojiGestures: 'Gestures',
emojiEmotions: 'Emotions',
emojiObjects: 'Objects',
emojiFood: 'Food',
emojiNature: 'Nature',
login: 'Login',
register: 'Register',
latinOnly: '(latin only, cannot be changed)',
displayNameLabel: 'Display name',
displayNamePlaceholder: 'Your name (any language)',
password: 'Password',
passwordPlaceholder: 'Enter password',
bioPlaceholder: 'Tell about yourself (optional)',
loginBtn: 'Login',
createAccount: 'Create account',
testAccounts: 'Test accounts: evgeniy, anastasia, artem, polina',
passwordForAll: 'Password for all:',
fileTooLarge: 'File too large (max 50MB)',
dropFileHere: 'Drop file here',
uploading: 'Uploading...',
changePhoto: 'Change photo',
remove: 'Remove',
formatBold: 'Bold',
formatBoldHint: '**text**',
formatItalic: 'Italic',
formatItalicHint: '_text_',
formatStrike: 'Strikethrough',
formatStrikeHint: '~text~',
formatMono: 'Monospace',
formatMonoHint: '`text`',
draft: 'Draft:',
datePlaceholder: 'dd.mm.yyyy',
tenorKeyRequired: 'Tenor API key required for GIFs',
openConsoleRun: 'Open console and run:',
searchGifs: 'Search GIFs...',
trending: 'Trending',
error: 'Error',
friends: 'Friends',
friendRequests: 'Friend requests',
friendsList: 'Friends list',
noFriends: 'No friends yet',
addFriend: 'Add friend',
removeFriend: 'Remove friend',
requestSent: 'Request sent',
searchFriends: 'Search by @username (min. 3 chars)',
noSearchResults: 'No users found',
minCharsHint: 'Enter at least 3 characters after @',
storyViewers: 'Who viewed',
noViewers: 'No one viewed yet',
favorites: 'Favorites',
favoritesDescription: 'Save important messages here',
privacy: 'Privacy',
hideStoryViews: 'Hide story views',
hideStoryViewsDesc: 'Others won\'t see that you viewed their story',
scheduledFor: 'Scheduled for',
messageScheduled: 'Message scheduled',
scheduledDelivered: 'Message sent to',
scheduledDeliveredAt: 'at',
scheduleIn1h: 'In 1 hour',
scheduleIn3h: 'In 3 hours',
scheduleTomorrow: 'Tomorrow at 9:00',
scheduleCustom: 'Choose date & time',
scheduleTime: 'Time',
scheduleDate: 'Date',
lastSeenAt: 'was',
justNow: 'just now',
},
} as const;
export type TranslationKey = keyof typeof translations.ru;
type TranslationValue<K extends TranslationKey> = (typeof translations.ru)[K];
interface LangState {
lang: Lang;
setLang: (lang: Lang) => void;
t: <K extends TranslationKey>(key: K) => TranslationValue<K>;
}
export const useLang = create<LangState>((set, get) => ({
lang: (localStorage.getItem(LANG_KEY) as Lang) || 'ru',
setLang: (lang) => {
localStorage.setItem(LANG_KEY, lang);
set({ lang });
},
t: (key) => {
const { lang } = get();
return (translations[lang][key] ?? translations.ru[key] ?? key) as TranslationValue<typeof key>;
},
}));

View File

@@ -0,0 +1,42 @@
import { io, Socket } from 'socket.io-client';
let socket: Socket | null = null;
export function connectSocket(token: string): Socket {
if (socket?.connected) {
return socket;
}
// Clean up old socket instance if it exists but is disconnected
if (socket) {
socket.removeAllListeners();
socket.disconnect();
socket = null;
}
socket = io(window.location.origin, {
auth: { token },
transports: ['websocket', 'polling'],
});
socket.on('connect', () => {
console.log('Socket подключён');
});
socket.on('connect_error', (err) => {
console.error('Ошибка подключения Socket:', err.message);
});
return socket;
}
export function getSocket(): Socket | null {
return socket;
}
export function disconnectSocket() {
if (socket) {
socket.disconnect();
socket = null;
}
}

120
apps/web/src/lib/sounds.ts Normal file
View File

@@ -0,0 +1,120 @@
// Notification sound using Web Audio API — generates a pleasant chime
let audioContext: AudioContext | null = null;
function getAudioContext(): AudioContext {
if (!audioContext) {
audioContext = new AudioContext();
}
return audioContext;
}
export function playNotificationSound() {
try {
const ctx = getAudioContext();
if (ctx.state === 'suspended') {
ctx.resume();
}
const now = ctx.currentTime;
// Soft, warm notification — lower frequencies, triangle waves, gentle volume
// First note — warm mellow tone
const osc1 = ctx.createOscillator();
const gain1 = ctx.createGain();
osc1.type = 'triangle';
osc1.frequency.setValueAtTime(523.25, now); // C5
gain1.gain.setValueAtTime(0.08, now);
gain1.gain.exponentialRampToValueAtTime(0.001, now + 0.3);
osc1.connect(gain1);
gain1.connect(ctx.destination);
osc1.start(now);
osc1.stop(now + 0.3);
// Second note — gentle higher tone
const osc2 = ctx.createOscillator();
const gain2 = ctx.createGain();
osc2.type = 'triangle';
osc2.frequency.setValueAtTime(659.25, now + 0.08); // E5
gain2.gain.setValueAtTime(0, now);
gain2.gain.setValueAtTime(0.06, now + 0.08);
gain2.gain.exponentialRampToValueAtTime(0.001, now + 0.35);
osc2.connect(gain2);
gain2.connect(ctx.destination);
osc2.start(now + 0.08);
osc2.stop(now + 0.35);
} catch (e) {
// Audio context not supported — silent fail
}
}
// Muted chats stored in localStorage
const MUTED_KEY = 'vortex_muted_chats';
export function getMutedChats(): Set<string> {
try {
const stored = localStorage.getItem(MUTED_KEY);
return stored ? new Set(JSON.parse(stored)) : new Set();
} catch {
return new Set();
}
}
export function toggleMuteChat(chatId: string): boolean {
const muted = getMutedChats();
if (muted.has(chatId)) {
muted.delete(chatId);
} else {
muted.add(chatId);
}
localStorage.setItem(MUTED_KEY, JSON.stringify([...muted]));
return muted.has(chatId);
}
export function isChatMuted(chatId: string): boolean {
return getMutedChats().has(chatId);
}
// Call ringtone
let callAudio: HTMLAudioElement | null = null;
export function playCallRingtone() {
try {
if (callAudio) {
callAudio.pause();
callAudio.currentTime = 0;
}
callAudio = new Audio('/sounds/call_sound.mp3');
callAudio.loop = true;
callAudio.volume = 0.5;
callAudio.play().catch(() => {});
} catch (e) {
// silent fail
}
}
export function stopCallRingtone() {
try {
if (callAudio) {
callAudio.pause();
callAudio.currentTime = 0;
callAudio = null;
}
} catch (e) {
// silent fail
}
}
// "Абонент недоступен" sound
export function playUnavailableSound(): Promise<void> {
return new Promise((resolve) => {
try {
const audio = new Audio('/sounds/abonent_nedostupen.mp3');
audio.volume = 0.7;
audio.onended = () => resolve();
audio.onerror = () => resolve();
audio.play().catch(() => resolve());
} catch (e) {
resolve();
}
});
}

175
apps/web/src/lib/types.ts Normal file
View File

@@ -0,0 +1,175 @@
// ─── User types ────────────────────────────────────────────────────────
export interface UserBasic {
id: string;
username: string;
displayName: string;
avatar: string | null;
}
export interface UserPresence extends UserBasic {
isOnline: boolean;
lastSeen: string;
}
export interface User extends UserPresence {
bio: string | null;
birthday: string | null;
createdAt: string;
hideStoryViews?: boolean;
}
// ─── Chat types ────────────────────────────────────────────────────────
export interface ChatMember {
id: string;
userId: string;
role: string;
isPinned?: boolean;
isMuted?: boolean;
isArchived?: boolean;
clearedAt?: string | null;
user: UserPresence;
}
export interface MediaItem {
id: string;
type: string;
url: string;
filename: string | null;
thumbnail: string | null;
size: number | null;
duration: number | null;
width?: number | null;
height?: number | null;
}
export interface Reaction {
id: string;
emoji: string;
userId: string;
user: { id: string; username: string; displayName: string };
}
export interface MessageSender {
id: string;
username: string;
displayName: string;
avatar?: string | null;
}
export interface Message {
id: string;
chatId: string;
senderId: string;
content: string | null;
type: string;
replyToId: string | null;
quote?: string | null;
forwardedFromId?: string | null;
isEdited: boolean;
isDeleted: boolean;
scheduledAt?: string | null;
createdAt: string;
updatedAt?: string;
sender: MessageSender;
replyTo?: {
id: string;
content: string | null;
quote?: string | null;
sender: { id: string; username: string; displayName: string };
} | null;
forwardedFrom?: UserBasic | null;
media: MediaItem[];
reactions: Reaction[];
readBy: Array<{ userId: string }>;
}
export interface Chat {
id: string;
type: string;
name: string | null;
avatar: string | null;
createdAt: string;
members: ChatMember[];
messages: Message[];
unreadCount: number;
pinnedMessages?: Array<{
id: string;
message: Message;
}>;
}
// ─── Socket event types ────────────────────────────────────────────────
export interface TypingUser {
chatId: string;
userId: string;
}
export interface CallInfo {
from: string;
offer: RTCSessionDescriptionInit;
callType: 'voice' | 'video';
chatId: string;
callerInfo?: UserBasic | null;
}
// ─── Story types ───────────────────────────────────────────────────────
export interface Story {
id: string;
type: string;
mediaUrl: string | null;
content: string | null;
bgColor: string | null;
createdAt: string;
expiresAt: string;
viewCount: number;
viewed: boolean;
}
export interface StoryViewer {
userId: string;
username: string;
displayName: string;
avatar: string | null;
viewedAt: string;
}
export interface StoryGroup {
user: UserBasic;
stories: Story[];
hasUnviewed: boolean;
}
// ─── Utility types ─────────────────────────────────────────────────
// ─── Friend types ──────────────────────────────────────────────────
export interface FriendRequest {
id: string;
user: User;
createdAt: string;
}
export interface FriendWithId extends UserPresence {
friendshipId: string;
}
export interface FriendshipStatus {
status: 'none' | 'pending' | 'accepted' | 'declined' | 'self';
friendshipId?: string | null;
direction?: 'incoming' | 'outgoing';
}
// ─── Utility types ─────────────────────────────────────────────────────
/** Audio file extensions recognized by the app. */
export const AUDIO_EXTENSIONS = ['.mp3', '.wav', '.ogg', '.m4a', '.aac', '.flac', '.wma'] as const;
/** Max file size for uploads (50MB). */
export const MAX_FILE_SIZE = 50 * 1024 * 1024;
/** Max avatar size (5MB). */
export const MAX_AVATAR_SIZE = 5 * 1024 * 1024;

139
apps/web/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,139 @@
import { clsx, type ClassValue } from 'clsx';
export function cn(...inputs: ClassValue[]) {
return clsx(inputs);
}
export function formatTime(date: string | Date, lang: string = 'ru'): string {
const d = new Date(date);
return d.toLocaleTimeString(lang === 'ru' ? 'ru-RU' : 'en-US', { hour: '2-digit', minute: '2-digit' });
}
export function formatDate(date: string | Date, lang: string = 'ru'): string {
const d = new Date(date);
const now = new Date();
const diff = now.getTime() - d.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return lang === 'ru' ? 'Сегодня' : 'Today';
if (days === 1) return lang === 'ru' ? 'Вчера' : 'Yesterday';
if (days < 7) {
const weekDaysRu = ['Воскресенье', 'Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота'];
const weekDaysEn = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
return (lang === 'ru' ? weekDaysRu : weekDaysEn)[d.getDay()];
}
return d.toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', {
day: 'numeric',
month: 'long',
year: days > 365 ? 'numeric' : undefined,
});
}
export function formatLastSeen(date: string | Date, lang: string = 'ru'): string {
const d = new Date(date);
const now = new Date();
const diff = now.getTime() - d.getTime();
const minutes = Math.floor(diff / (1000 * 60));
if (minutes < 1) return lang === 'ru' ? 'только что' : 'just now';
if (minutes < 60) return lang === 'ru' ? `${minutes} мин. назад` : `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return lang === 'ru' ? `${hours} ч. назад` : `${hours}h ago`;
const at = lang === 'ru' ? ' в ' : ' at ';
return formatDate(date, lang) + at + formatTime(date, lang);
}
/**
* Strips markdown syntax (**bold**, *italic*, _italic_, ~strikethrough~, `code`)
* and returns plain text for use in previews.
*/
export function stripMarkdown(text: string): string {
if (!text) return text;
return text
.replace(/\*\*([\s\S]*?)\*\*/g, '$1')
.replace(/\*([\s\S]*?)\*/g, '$1')
.replace(/_([\s\S]*?)_/g, '$1')
.replace(/~([\s\S]*?)~/g, '$1')
.replace(/`([\s\S]*?)`/g, '$1');
}
export function getInitials(name: string): string {
return name
.split(' ')
.map((part) => part[0])
.join('')
.toUpperCase()
.slice(0, 2);
}
export function generateAvatarColor(name: string): string {
const colors = [
'from-violet-500 to-purple-600',
'from-blue-500 to-indigo-600',
'from-emerald-500 to-teal-600',
'from-rose-500 to-pink-600',
'from-amber-500 to-orange-600',
'from-cyan-500 to-blue-600',
'from-fuchsia-500 to-purple-600',
'from-lime-500 to-green-600',
];
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
}
// Waveform cache so we don't decode the same audio twice
const waveformCache = new Map<string, number[]>();
/**
* Decodes an audio file from a URL and extracts normalized waveform peak values.
* Returns an array of `bars` values in [0, 1].
*/
export async function extractWaveform(url: string, bars: number = 28): Promise<number[]> {
const cached = waveformCache.get(url);
if (cached) return cached;
let audioCtx: AudioContext | undefined;
try {
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
audioCtx = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
await audioCtx.close();
audioCtx = undefined;
const channelData = audioBuffer.getChannelData(0);
const samplesPerBar = Math.floor(channelData.length / bars);
const peaks: number[] = [];
for (let i = 0; i < bars; i++) {
let peak = 0;
const start = i * samplesPerBar;
// Sample a subset for performance
const step = Math.max(1, Math.floor(samplesPerBar / 200));
for (let j = 0; j < samplesPerBar; j += step) {
const abs = Math.abs(channelData[start + j] || 0);
if (abs > peak) peak = abs;
}
peaks.push(peak);
}
// Normalize to [0, 1]
const max = Math.max(...peaks, 0.01);
const normalized = peaks.map(p => p / max);
waveformCache.set(url, normalized);
return normalized;
} catch {
// Close leaked AudioContext if any
if (audioCtx) audioCtx.close().catch(() => {});
// On error, return uniform bars
return Array(bars).fill(0.5);
}
}

20
apps/web/src/main.tsx Normal file
View File

@@ -0,0 +1,20 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
import './index.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
},
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,100 @@
import { create } from 'zustand';
import { api } from '../lib/api';
import { connectSocket, disconnectSocket } from '../lib/socket';
import type { User } from '../lib/types';
interface AuthState {
token: string | null;
user: User | null;
isLoading: boolean;
error: string | null;
login: (username: string, password: string) => Promise<void>;
register: (username: string, displayName: string, password: string, bio?: string) => Promise<void>;
logout: () => void;
checkAuth: () => Promise<void>;
updateUser: (data: Partial<User>) => void;
}
export const useAuthStore = create<AuthState>((set, get) => ({
token: localStorage.getItem('vortex_token'),
user: null,
isLoading: true,
error: null,
login: async (username, password) => {
try {
set({ error: null, isLoading: true });
const { token, user } = await api.login(username, password);
localStorage.setItem('vortex_token', token);
api.setToken(token);
connectSocket(token);
set({ token, user, isLoading: false });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
set({ error: msg, isLoading: false });
throw err;
}
},
register: async (username, displayName, password, bio) => {
try {
set({ error: null, isLoading: true });
const { token, user } = await api.register(username, displayName, password, bio);
localStorage.setItem('vortex_token', token);
api.setToken(token);
connectSocket(token);
set({ token, user, isLoading: false });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
set({ error: msg, isLoading: false });
throw err;
}
},
logout: () => {
localStorage.removeItem('vortex_token');
api.setToken(null);
disconnectSocket();
set({ token: null, user: null });
},
checkAuth: async () => {
const token = get().token;
if (!token) {
set({ isLoading: false });
return;
}
// Retry up to 3 times in case server is still starting
let lastError: unknown;
for (let attempt = 0; attempt < 3; attempt++) {
try {
api.setToken(token);
const { user } = await api.getMe();
connectSocket(token);
set({ user, isLoading: false });
return;
} catch (err) {
lastError = err;
// Only retry on network/server errors, not on auth errors (401/403)
const msg = err instanceof Error ? err.message : '';
if (msg.includes('Требуется авторизация') || msg.includes('Недействительный токен')) {
break;
}
if (attempt < 2) {
await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
}
}
}
console.warn('checkAuth failed:', lastError);
localStorage.removeItem('vortex_token');
set({ token: null, user: null, isLoading: false });
},
updateUser: (data) => {
const { user } = get();
if (user) {
set({ user: { ...user, ...data } });
}
},
}));

View File

@@ -0,0 +1,440 @@
import { create } from 'zustand';
import { api } from '../lib/api';
import { useAuthStore } from './authStore';
import type { Chat, ChatMember, Message, TypingUser } from '../lib/types';
interface ChatState {
chats: Chat[];
activeChat: string | null;
messages: Record<string, Message[]>;
pinnedMessages: Record<string, Message>;
typingUsers: TypingUser[];
replyTo: Message | null;
editingMessage: Message | null;
isLoadingChats: boolean;
isLoadingMessages: boolean;
searchQuery: string;
drafts: Record<string, string>;
setActiveChat: (chatId: string | null) => void;
setSearchQuery: (query: string) => void;
setDraft: (chatId: string, text: string) => void;
getDraft: (chatId: string) => string;
loadChats: () => Promise<void>;
loadMessages: (chatId: string) => Promise<void>;
addMessage: (message: Message) => void;
updateMessage: (message: Message) => void;
removeMessage: (messageId: string, chatId: string) => void;
removeMessages: (messageIds: string[], chatId: string) => void;
hideMessages: (messageIds: string[], chatId: string) => void;
addReaction: (messageId: string, chatId: string, userId: string, username: string, emoji: string) => void;
removeReaction: (messageId: string, chatId: string, userId: string, emoji: string) => void;
markRead: (chatId: string, userId: string, messageIds: string[]) => void;
addTypingUser: (chatId: string, userId: string) => void;
removeTypingUser: (chatId: string, userId: string) => void;
updateUserOnlineStatus: (userId: string, isOnline: boolean, lastSeen?: string) => void;
setReplyTo: (message: Message | null) => void;
setEditingMessage: (message: Message | null) => void;
addChat: (chat: Chat) => void;
updateChat: (chat: Chat) => void;
removeChat: (chatId: string) => void;
clearMessages: (chatId: string) => void;
setPinnedMessage: (chatId: string, message: Message) => void;
removePinnedMessage: (chatId: string, messageId: string, newPinned: Message | null) => void;
clearStore: () => void;
}
export const useChatStore = create<ChatState>((set, get) => ({
chats: [],
activeChat: null,
messages: {},
pinnedMessages: {},
typingUsers: [],
replyTo: null,
editingMessage: null,
isLoadingChats: false,
isLoadingMessages: false,
searchQuery: '',
drafts: JSON.parse(localStorage.getItem('vortex_drafts') || '{}'),
setActiveChat: (chatId) => set((state) => ({
activeChat: chatId,
replyTo: null,
editingMessage: null,
chats: chatId
? state.chats.map((c) => c.id === chatId ? { ...c, unreadCount: 0 } : c)
: state.chats,
})),
setSearchQuery: (query) => set({ searchQuery: query }),
setDraft: (chatId, text) => {
set((state) => {
const drafts = { ...state.drafts };
if (text.trim()) {
drafts[chatId] = text;
} else {
delete drafts[chatId];
}
localStorage.setItem('vortex_drafts', JSON.stringify(drafts));
return { drafts };
});
},
getDraft: (chatId) => {
return get().drafts[chatId] || '';
},
loadChats: async () => {
try {
set({ isLoadingChats: true });
const chats = await api.getChats();
// Auto-create favorites chat if not present
if (!chats.some((c: any) => c.type === 'favorites')) {
try {
const favChat = await api.getOrCreateFavorites();
chats.unshift(favChat);
} catch {}
}
// Extract pinned messages from chats
const pinnedMessages: Record<string, Message> = {};
for (const chat of chats) {
if (chat.pinnedMessages && chat.pinnedMessages.length > 0) {
pinnedMessages[chat.id] = chat.pinnedMessages[0].message;
}
}
set({ chats, pinnedMessages, isLoadingChats: false });
} catch (error) {
console.error('Load chats error:', error);
set({ isLoadingChats: false });
}
},
loadMessages: async (chatId) => {
try {
set({ isLoadingMessages: true });
const fetched = await api.getMessages(chatId);
set((state) => {
// Merge fetched messages with any that arrived via socket during the fetch
const existing = state.messages[chatId] || [];
const fetchedIds = new Set(fetched.map(m => m.id));
const socketOnly = existing.filter(m => !fetchedIds.has(m.id));
const merged = [...fetched, ...socketOnly].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
return {
messages: { ...state.messages, [chatId]: merged },
isLoadingMessages: false,
};
});
} catch (error) {
console.error('Load messages error:', error);
set({ isLoadingMessages: false });
}
},
addMessage: (message) => {
const userId = useAuthStore.getState().user?.id;
set((state) => {
const chatMessages = state.messages[message.chatId] || [];
if (chatMessages.some((m) => m.id === message.id)) return state;
const updatedMessages = {
...state.messages,
[message.chatId]: [...chatMessages, message],
};
const updatedChats = state.chats.map((chat) => {
if (chat.id === message.chatId) {
return {
...chat,
messages: [message],
unreadCount: chat.id === state.activeChat ? chat.unreadCount : chat.unreadCount + 1,
};
}
return chat;
});
updatedChats.sort((a, b) => {
const aPin = a.members?.find((m) => m.user?.id === userId)?.isPinned ? 1 : 0;
const bPin = b.members?.find((m) => m.user?.id === userId)?.isPinned ? 1 : 0;
if (aPin !== bPin) return bPin - aPin;
const aTime = a.messages[0]?.createdAt || a.createdAt;
const bTime = b.messages[0]?.createdAt || b.createdAt;
return new Date(bTime).getTime() - new Date(aTime).getTime();
});
return { messages: updatedMessages, chats: updatedChats };
});
},
updateMessage: (message) => {
set((state) => {
const chatMessages = state.messages[message.chatId] || [];
return {
messages: {
...state.messages,
[message.chatId]: chatMessages.map((m) => (m.id === message.id ? message : m)),
},
};
});
},
removeMessage: (messageId, chatId) => {
set((state) => {
const chatMessages = state.messages[chatId] || [];
const updatedMessages = chatMessages.map((m) =>
m.id === messageId ? { ...m, isDeleted: true, content: null } : m
);
// Find the latest non-deleted message to show in sidebar
const latestVisible = updatedMessages
.filter(m => !m.isDeleted)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0];
const updatedChats = state.chats.map((chat) => {
if (chat.id === chatId) {
// If the deleted message was the last message shown, replace with previous one
const currentLast = chat.messages?.[0];
if (currentLast?.id === messageId) {
return {
...chat,
messages: latestVisible ? [latestVisible] : [{ ...currentLast, isDeleted: true, content: null }],
};
}
}
return chat;
});
return {
messages: {
...state.messages,
[chatId]: updatedMessages,
},
chats: updatedChats,
};
});
},
removeMessages: (messageIds, chatId) => {
const idsSet = new Set(messageIds);
set((state) => {
const chatMessages = state.messages[chatId] || [];
const updatedMessages = chatMessages.map((m) =>
idsSet.has(m.id) ? { ...m, isDeleted: true, content: null } : m
);
const latestVisible = updatedMessages
.filter(m => !m.isDeleted)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0];
const updatedChats = state.chats.map((chat) => {
if (chat.id === chatId) {
const currentLast = chat.messages?.[0];
if (currentLast && idsSet.has(currentLast.id)) {
return {
...chat,
messages: latestVisible ? [latestVisible] : [{ ...currentLast, isDeleted: true, content: null }],
};
}
}
return chat;
});
return {
messages: { ...state.messages, [chatId]: updatedMessages },
chats: updatedChats,
};
});
},
hideMessages: (messageIds, chatId) => {
const idsSet = new Set(messageIds);
set((state) => {
const chatMessages = state.messages[chatId] || [];
const updatedMessages = chatMessages.filter((m) => !idsSet.has(m.id));
const latestVisible = updatedMessages
.filter(m => !m.isDeleted)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0];
const updatedChats = state.chats.map((chat) => {
if (chat.id === chatId) {
const currentLast = chat.messages?.[0];
if (currentLast && idsSet.has(currentLast.id)) {
return {
...chat,
messages: latestVisible ? [latestVisible] : [],
};
}
}
return chat;
});
return {
messages: { ...state.messages, [chatId]: updatedMessages },
chats: updatedChats,
};
});
},
addReaction: (messageId, chatId, userId, username, emoji) => {
set((state) => {
const chatMessages = state.messages[chatId] || [];
return {
messages: {
...state.messages,
[chatId]: chatMessages.map((m) => {
if (m.id === messageId) {
const exists = m.reactions.some((r) => r.userId === userId && r.emoji === emoji);
if (exists) return m;
return {
...m,
reactions: [
...m.reactions,
{ id: `${messageId}-${userId}-${emoji}`, emoji, userId, user: { id: userId, username, displayName: username } },
],
};
}
return m;
}),
},
};
});
},
removeReaction: (messageId, chatId, userId, emoji) => {
set((state) => {
const chatMessages = state.messages[chatId] || [];
return {
messages: {
...state.messages,
[chatId]: chatMessages.map((m) => {
if (m.id === messageId) {
return {
...m,
reactions: m.reactions.filter((r) => !(r.userId === userId && r.emoji === emoji)),
};
}
return m;
}),
},
};
});
},
markRead: (chatId, userId, messageIds) => {
const currentUserId = useAuthStore.getState().user?.id;
set((state) => {
const chatMessages = state.messages[chatId] || [];
return {
messages: {
...state.messages,
[chatId]: chatMessages.map((m) => {
if (messageIds.includes(m.id)) {
const alreadyRead = m.readBy?.some((r) => r.userId === userId);
if (alreadyRead) return m;
return { ...m, readBy: [...(m.readBy || []), { userId }] };
}
return m;
}),
},
chats: state.chats.map((chat) => {
if (chat.id === chatId && userId === currentUserId) {
return { ...chat, unreadCount: 0 };
}
return chat;
}),
};
});
},
addTypingUser: (chatId, userId) => {
set((state) => {
const exists = state.typingUsers.some((t) => t.chatId === chatId && t.userId === userId);
if (exists) return state;
return { typingUsers: [...state.typingUsers, { chatId, userId }] };
});
},
removeTypingUser: (chatId, userId) => {
set((state) => ({
typingUsers: state.typingUsers.filter((t) => !(t.chatId === chatId && t.userId === userId)),
}));
},
updateUserOnlineStatus: (userId, isOnline, lastSeen) => {
set((state) => ({
chats: state.chats.map((chat) => ({
...chat,
members: chat.members.map((m) =>
m.user.id === userId
? { ...m, user: { ...m.user, isOnline, lastSeen: lastSeen || m.user.lastSeen } }
: m
),
})),
}));
},
setReplyTo: (message) => set({ replyTo: message, editingMessage: null }),
setEditingMessage: (message) => set({ editingMessage: message, replyTo: null }),
addChat: (chat) => {
set((state) => {
if (state.chats.some((c) => c.id === chat.id)) return state;
return { chats: [chat, ...state.chats] };
});
},
updateChat: (chat) => {
set((state) => ({
chats: state.chats.map((c) => (c.id === chat.id ? { ...c, ...chat } : c)),
}));
},
removeChat: (chatId) => {
set((state) => ({
chats: state.chats.filter((c) => c.id !== chatId),
activeChat: state.activeChat === chatId ? null : state.activeChat,
messages: (() => { const m = { ...state.messages }; delete m[chatId]; return m; })(),
}));
},
clearMessages: (chatId) => {
set((state) => ({
messages: { ...state.messages, [chatId]: [] },
chats: state.chats.map((c) =>
c.id === chatId ? { ...c, messages: [] } : c
),
}));
},
setPinnedMessage: (chatId, message) => {
set((state) => ({
pinnedMessages: { ...state.pinnedMessages, [chatId]: message },
}));
},
removePinnedMessage: (chatId, _messageId, newPinned) => {
set((state) => {
const updated = { ...state.pinnedMessages };
if (newPinned) {
updated[chatId] = newPinned;
} else {
delete updated[chatId];
}
return { pinnedMessages: updated };
});
},
clearStore: () => {
set({
chats: [],
activeChat: null,
messages: {},
pinnedMessages: {},
typingUsers: [],
replyTo: null,
editingMessage: null,
});
},
}));

View File

@@ -0,0 +1,21 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type ChatTheme = 'midnight' | 'ocean' | 'forest' | 'sunset' | 'classic' | 'neon' | 'aurora' | 'cyber' | 'glass' | 'void';
interface ThemeState {
chatTheme: ChatTheme;
setChatTheme: (theme: ChatTheme) => void;
}
export const useThemeStore = create<ThemeState>()(
persist(
(set) => ({
chatTheme: 'midnight',
setChatTheme: (theme) => set({ chatTheme: theme }),
}),
{
name: 'vortex-theme-storage',
}
)
);

1
apps/web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />