1015 lines
41 KiB
TypeScript
1015 lines
41 KiB
TypeScript
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,
|
|
ArrowLeft,
|
|
MessageSquare,
|
|
} 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, config } = 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 allChatMessages = activeChat ? messages[activeChat] || [] : [];
|
|
// Filter out deleted messages to prevent layout shifts
|
|
const chatMessages = allChatMessages.filter(m => !m.isDeleted);
|
|
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);
|
|
|
|
// Refs and logic for tracking session's first unread message to show the divider exactly once per load
|
|
const sessionUnreadRef = useRef<{ chatId: string, msgId: string | null }>({ chatId: '', msgId: null });
|
|
|
|
if (activeChat && activeChat !== sessionUnreadRef.current.chatId && !isLoadingMessages) {
|
|
const firstUnreadMsg = chatMessages.find(
|
|
(m) => m.senderId !== user?.id && !m.readBy?.some((r) => r.userId === user?.id)
|
|
);
|
|
sessionUnreadRef.current = { chatId: activeChat, msgId: firstUnreadMsg ? firstUnreadMsg.id : null };
|
|
}
|
|
const firstUnreadId = activeChat === sessionUnreadRef.current.chatId ? sessionUnreadRef.current.msgId : null;
|
|
const lastObservedMessageIdRef = useRef<string | null>(null);
|
|
|
|
// Load muted state
|
|
useEffect(() => {
|
|
if (activeChat) {
|
|
setMuted(isChatMuted(activeChat));
|
|
setScrollReady(false);
|
|
setActiveGroupCallParticipants([]);
|
|
lastObservedMessageIdRef.current = null;
|
|
}
|
|
}, [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('get_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) {
|
|
const container = messagesContainerRef.current;
|
|
const unreadId = sessionUnreadRef.current.msgId;
|
|
|
|
if (unreadId && activeChat === sessionUnreadRef.current.chatId) {
|
|
const dividerEl = document.getElementById('unread-divider');
|
|
const unreadEl = document.getElementById(`msg-${unreadId}`);
|
|
const targetEl = dividerEl || unreadEl;
|
|
|
|
if (targetEl) {
|
|
// Вычитаем отступ сверху, чтобы начало непрочитанных было под шапкой
|
|
container.scrollTop = targetEl.offsetTop - 60;
|
|
} else {
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
} else {
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
setScrollReady(true);
|
|
}
|
|
}, [activeChat, isLoadingMessages]);
|
|
|
|
// Scroll on new message arrivals
|
|
useEffect(() => {
|
|
if (chatMessages.length > 0) {
|
|
const lastMsg = chatMessages[chatMessages.length - 1];
|
|
const prevId = lastObservedMessageIdRef.current;
|
|
lastObservedMessageIdRef.current = lastMsg.id;
|
|
|
|
// Scroll ONLY if a genuinely new message was added (not during initial chat load)
|
|
if (prevId && prevId !== lastMsg.id) {
|
|
if (lastMsg.senderId === user?.id) {
|
|
setTimeout(() => scrollToBottom(true), 50);
|
|
} else {
|
|
// Если пользователь внизу — прокрутить
|
|
const container = messagesContainerRef.current;
|
|
if (container) {
|
|
const isNearBottom =
|
|
container.scrollHeight - container.scrollTop - container.clientHeight < 300;
|
|
if (isNearBottom) setTimeout(() => scrollToBottom(true), 50);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
lastObservedMessageIdRef.current = null;
|
|
}
|
|
}, [chatMessages.length, user?.id, scrollToBottom]);
|
|
|
|
// Read receipts using IntersectionObserver
|
|
const sentReadIdsRef = useRef<Set<string>>(new Set());
|
|
const observerRef = useRef<IntersectionObserver | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!activeChat || !user?.id) return;
|
|
|
|
// Cleanup previous observer
|
|
if (observerRef.current) observerRef.current.disconnect();
|
|
sentReadIdsRef.current.clear();
|
|
|
|
const socket = getSocket();
|
|
if (!socket) return;
|
|
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
const newlyReadIds: string[] = [];
|
|
entries.forEach((entry) => {
|
|
if (entry.isIntersecting) {
|
|
const msgId = entry.target.getAttribute('data-message-id');
|
|
if (msgId && !sentReadIdsRef.current.has(msgId)) {
|
|
newlyReadIds.push(msgId);
|
|
sentReadIdsRef.current.add(msgId);
|
|
// Stop observing once read
|
|
observer.unobserve(entry.target);
|
|
}
|
|
}
|
|
});
|
|
|
|
if (newlyReadIds.length > 0) {
|
|
console.log('[IntersectionObserver] Marking as read:', newlyReadIds);
|
|
socket.emit('read_messages', {
|
|
chatId: activeChat,
|
|
messageIds: newlyReadIds,
|
|
});
|
|
// Update local store immediately for current user
|
|
useChatStore.getState().markRead(activeChat, user.id, newlyReadIds);
|
|
}
|
|
},
|
|
{
|
|
root: messagesContainerRef.current,
|
|
threshold: 0.1, // Message must be 10% visible
|
|
}
|
|
);
|
|
|
|
observerRef.current = observer;
|
|
|
|
// Initial observation of unread messages
|
|
const observeUnread = () => {
|
|
if (!messagesContainerRef.current) return;
|
|
const unreadElements = messagesContainerRef.current.querySelectorAll('.unread-detector');
|
|
unreadElements.forEach((el) => {
|
|
const id = el.getAttribute('data-message-id');
|
|
if (id && !sentReadIdsRef.current.has(id)) {
|
|
observer.observe(el);
|
|
}
|
|
});
|
|
};
|
|
|
|
observeUnread();
|
|
|
|
return () => observer.disconnect();
|
|
}, [activeChat, user?.id]);
|
|
|
|
// Re-run observation when messages change (to catch new arrivals)
|
|
useEffect(() => {
|
|
if (observerRef.current && !isLoadingMessages) {
|
|
if (!messagesContainerRef.current) return;
|
|
const unreadElements = messagesContainerRef.current.querySelectorAll('.unread-detector');
|
|
unreadElements.forEach((el) => {
|
|
const id = el.getAttribute('data-message-id');
|
|
if (id && !sentReadIdsRef.current.has(id)) {
|
|
observerRef.current?.observe(el);
|
|
}
|
|
});
|
|
}
|
|
}, [chatMessages.length, isLoadingMessages]);
|
|
|
|
// Scroll detection
|
|
const checkScrollPosition = useCallback(() => {
|
|
const container = messagesContainerRef.current;
|
|
if (!container) return;
|
|
const isNearBottom =
|
|
container.scrollHeight - container.scrollTop - container.clientHeight < 300;
|
|
setShowScrollDown(!isNearBottom);
|
|
}, []);
|
|
|
|
const handleScroll = () => {
|
|
checkScrollPosition();
|
|
};
|
|
|
|
useEffect(() => {
|
|
// Check scroll position when messages change or scroll ready state changes
|
|
if (scrollReady) {
|
|
checkScrollPosition();
|
|
}
|
|
}, [chatMessages.length, scrollReady, checkScrollPosition]);
|
|
|
|
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 h-full 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">
|
|
<div className="text-center relative z-10 w-full max-w-sm px-6 mx-auto">
|
|
<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-24 h-24 mx-auto mb-6 rounded-full bg-surface-hover flex items-center justify-center border border-border/40 backdrop-blur-md relative"
|
|
>
|
|
<MessageSquare className="w-10 h-10 text-zinc-500 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-2xl font-semibold text-white mb-2 tracking-tight"
|
|
>
|
|
Knot 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 text-zinc-400 bg-surface-hover backdrop-blur-lg py-2 px-4 rounded-full inline-flex border border-border/50"
|
|
>
|
|
{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?.forwardedFromId || msg?.sender.id,
|
|
attachments: msg?.media?.map(m => ({
|
|
type: m.type,
|
|
url: m.url,
|
|
fileName: m.filename,
|
|
fileSize: m.size
|
|
})) || [],
|
|
});
|
|
});
|
|
|
|
setSelectionMode(false);
|
|
setSelectedMessages(new Set());
|
|
setShowForwardModal(false);
|
|
setActiveChat(targetChatId);
|
|
useChatStore.getState().loadMessages(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 sm:rounded-[2rem] overflow-hidden shadow-[0_0_120px_-20px_rgba(0,0,0,0.5)] border-x sm: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-4 sm: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-2 sm: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-3 sm:px-6 border-b border-border/40 bg-surface-secondary/80 backdrop-blur-xl z-20 flex-shrink-0 gap-2">
|
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
|
<button
|
|
onClick={() => setActiveChat(null)}
|
|
className="sm:hidden p-2 -ml-1 rounded-xl hover:bg-surface-hover text-zinc-400 transition-colors"
|
|
>
|
|
<ArrowLeft size={22} />
|
|
</button>
|
|
<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>
|
|
|
|
<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 && config?.enableCalls && (
|
|
<>
|
|
<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('highlight-message');
|
|
setTimeout(() => el.classList.remove('highlight-message'), 3000);
|
|
}
|
|
setShowSearch(false);
|
|
setSearchText('');
|
|
setSearchResults([]);
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-2 mb-0.5">
|
|
<span className="text-xs font-medium text-knot-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' && config?.enableCalls && 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('highlight-message');
|
|
setTimeout(() => el.classList.remove('highlight-message'), 3000);
|
|
}
|
|
}}
|
|
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-knot-400 flex-shrink-0 rotate-45" />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-xs font-medium text-knot-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-knot-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();
|
|
|
|
const isFirstUnread = firstUnreadId === msg.id;
|
|
|
|
return (
|
|
<div
|
|
key={msg.id}
|
|
data-message-id={msg.id}
|
|
className={`transition-colors duration-500 ${msg.senderId !== user?.id && !msg.readBy?.some(r => r.userId === user?.id) ? 'unread-detector' : ''}`}
|
|
>
|
|
{isFirstUnread && (
|
|
<div id="unread-divider" className="flex items-center justify-center my-4 opacity-80 select-none">
|
|
<div className="flex-1 h-px bg-border/50"></div>
|
|
<span className="px-4 text-[11px] font-semibold tracking-wider uppercase text-zinc-400">
|
|
{t('unreadMessages')}
|
|
</span>
|
|
<div className="flex-1 h-px bg-border/50"></div>
|
|
</div>
|
|
)}
|
|
{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}
|
|
onForward={(id) => {
|
|
setSelectedMessages(new Set([id]));
|
|
setShowForwardModal(true);
|
|
}}
|
|
/>
|
|
</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-5 w-12 h-12 rounded-full bg-surface-secondary/95 backdrop-blur-md border border-border shadow-xl flex items-center justify-center text-zinc-400 hover:text-white hover:bg-surface-hover hover:scale-105 transition-all z-10"
|
|
>
|
|
<ArrowDown size={22} className="text-accent hover:text-accent-light transition-colors" />
|
|
{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>
|
|
)}
|
|
|
|
{/* Ввод сообщения */}
|
|
{activeChat && <MessageInput chatId={activeChat} />}
|
|
|
|
{/* Профиль пользователя */}
|
|
<AnimatePresence>
|
|
{profileUserId && (
|
|
<UserProfile
|
|
userId={profileUserId}
|
|
chatId={activeChat || undefined}
|
|
onClose={() => setProfileUserId(null)}
|
|
onGoToMessage={(msgId) => {
|
|
const el = document.getElementById(`msg-${msgId}`);
|
|
if (el) {
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
el.classList.add('highlight-message');
|
|
setTimeout(() => el.classList.remove('highlight-message'), 3000);
|
|
setProfileUserId(null);
|
|
}
|
|
}}
|
|
isSelf={profileUserId === user?.id}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Настройки группы */}
|
|
<AnimatePresence>
|
|
{showGroupSettings && chat && chat.type === 'group' && (
|
|
<GroupSettings
|
|
chat={chat}
|
|
onClose={() => setShowGroupSettings(false)}
|
|
onGoToMessage={(msgId) => {
|
|
const el = document.getElementById(`msg-${msgId}`);
|
|
if (el) {
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
el.classList.add('highlight-message');
|
|
setTimeout(() => el.classList.remove('highlight-message'), 3000);
|
|
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>
|
|
);
|
|
}
|