Files
janichat/frontend/src/pages/MainLayout.tsx
Ai 2a4b7d5abc fix: open private chat from sidebar search
- openOrCreatePrivate: load chat by ID directly (skip stale list lookup)
- GET /api/chats/🆔 resolve title/avatar/privateUserId for private chats
  (was returning null title, causing chat area to behave unexpectedly)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 12:32:06 +03:00

270 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react';
import { Search, Edit, Settings, Shield, Wifi, WifiOff, MessageCircle } from 'lucide-react';
import { useStore } from '../store';
import { useNavigate } from 'react-router-dom';
import { wsClient } from '../api/ws';
import api from '../api/client';
import Avatar from '../components/Avatar';
import ChatListItem from '../components/ChatListItem';
import MessageList from '../components/MessageList';
import ChatHeader from '../components/ChatHeader';
import ChatInfoPanel from '../components/ChatInfoPanel';
import NewChatModal from '../components/NewChatModal';
import AdminPanel from '../components/admin/AdminPanel';
import ProfileModal from '../components/ProfileModal';
import { usePushNotifications } from '../hooks/usePushNotifications';
import { Chat } from '../types';
export default function MainLayout() {
const { user, chats, activeChat, setActiveChat, connected, onlineUsers, updateChat, setChats } = useStore();
const navigate = useNavigate();
const [search, setSearch] = useState('');
const [searchUsers, setSearchUsers] = useState<any[]>([]);
const [showNew, setShowNew] = useState(false);
const [showAdmin, setShowAdmin] = useState(false);
const [showInfo, setShowInfo] = useState(false);
const [showProfile, setShowProfile] = useState(false);
const [mobileChatOpen, setMobileChatOpen] = useState(false);
const [fullChat, setFullChat] = useState<Chat | null>(null);
usePushNotifications(!!user);
// Handle URL param ?chat=xxx
useEffect(() => {
const params = new URLSearchParams(location.search);
const chatId = params.get('chat');
if (chatId) {
const chat = chats.find(c => c.id === chatId);
if (chat) openChat(chat);
}
}, [chats]);
// Handle SW messages
useEffect(() => {
if (!('serviceWorker' in navigator)) return;
const handler = (event: MessageEvent) => {
if (event.data?.type === 'open_chat') {
const chat = chats.find(c => c.id === event.data.chatId);
if (chat) openChat(chat);
}
};
navigator.serviceWorker.addEventListener('message', handler);
return () => navigator.serviceWorker.removeEventListener('message', handler);
}, [chats]);
async function openChat(chat: Chat) {
// Load full chat info
try {
const { data } = await api.get(`/api/chats/${chat.id}`);
const merged = { ...chat, ...data };
setActiveChat(merged);
setFullChat(merged);
updateChat({ id: chat.id, unreadCount: 0 });
wsClient.send('read_messages', { chatId: chat.id });
} catch {
setActiveChat(chat);
setFullChat(chat);
}
setMobileChatOpen(true);
setShowInfo(false);
}
// Global search: fetch users when query changes
useEffect(() => {
if (search.trim().length < 1) { setSearchUsers([]); return; }
const timer = setTimeout(async () => {
try {
const { data } = await api.get('/api/users');
const q = search.toLowerCase();
setSearchUsers(data.filter((u: any) =>
(u.displayName || '').toLowerCase().includes(q) ||
(u.username || '').toLowerCase().includes(q) ||
(u.phone || '').includes(q)
));
} catch {}
}, 200);
return () => clearTimeout(timer);
}, [search]);
async function openOrCreatePrivate(userId: string) {
try {
const { data: chatData } = await api.post(`/api/chats/private/${userId}`);
setSearch('');
// Load full chat detail directly by ID — don't rely on list lookup
const { data: fullChatData } = await api.get(`/api/chats/${chatData.id}`);
// Also refresh the sidebar chat list
api.get('/api/chats').then(r => setChats(r.data)).catch(() => {});
// Build chat object to open
const chatToOpen = { ...fullChatData, id: chatData.id };
setActiveChat(chatToOpen);
setFullChat(chatToOpen);
setMobileChatOpen(true);
setShowInfo(false);
} catch (e) {
console.error('openOrCreatePrivate:', e);
}
}
const filtered = chats.filter(c =>
c.title?.toLowerCase().includes(search.toLowerCase())
);
const canSend = (() => {
if (!activeChat) return false;
if (activeChat.type === 'private') return true;
if (activeChat.type === 'group') return activeChat.canSendMessages !== false;
if (activeChat.type === 'channel') return activeChat.myRole === 'owner' || activeChat.myRole === 'admin' || !!user?.isAdmin;
return false;
})();
return (
<div className="flex h-full bg-gray-100">
{/* Sidebar */}
<div className={`w-full md:w-80 lg:w-96 bg-white flex flex-col border-r border-gray-100 ${mobileChatOpen ? 'hidden md:flex' : 'flex'}`}>
{/* Sidebar Header */}
<div className="px-4 py-3 border-b border-gray-100">
<div className="flex items-center gap-3 mb-3">
<Avatar name={user?.displayName || ''} color={user?.avatarColor || '#3b82f6'} avatar={user?.avatar} size="sm"
onClick={() => setShowProfile(true)} className="cursor-pointer" />
<div className="flex-1 min-w-0">
<div className="font-semibold text-gray-900 text-sm truncate">{user?.displayName}</div>
<div className="flex items-center gap-1 text-xs text-gray-400">
{connected
? <><Wifi className="w-3 h-3 text-green-400" /> <span>Online</span></>
: <><WifiOff className="w-3 h-3 text-red-400" /> <span>Offline</span></>
}
</div>
</div>
<div className="flex items-center gap-1">
{user?.isAdmin && (
<button onClick={() => setShowAdmin(true)}
className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-600" title="Панель администратора">
<Shield className="w-4 h-4" />
</button>
)}
<button onClick={() => setShowProfile(true)}
className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-600" title="Профиль">
<Settings className="w-4 h-4" />
</button>
<button onClick={() => setShowNew(true)}
className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-600" title="Новый чат">
<Edit className="w-4 h-4" />
</button>
</div>
</div>
{/* Search */}
<div className="flex items-center gap-2 bg-gray-100 rounded-xl px-3 py-2">
<Search className="w-4 h-4 text-gray-400 flex-shrink-0" />
<input
value={search} onChange={e => setSearch(e.target.value)}
placeholder="Поиск чатов..."
className="flex-1 bg-transparent text-sm outline-none text-gray-700 placeholder-gray-400"
/>
</div>
</div>
{/* Chat List */}
<div className="flex-1 overflow-y-auto py-2 px-2 space-y-0.5">
{filtered.map(chat => (
<ChatListItem
key={chat.id}
chat={chat}
active={activeChat?.id === chat.id}
online={chat.type === 'private' && chat.privateUserId ? onlineUsers.has(chat.privateUserId) : undefined}
onClick={() => { openChat(chat); setSearch(''); }}
/>
))}
{/* Global user search results */}
{search && searchUsers.length > 0 && (
<>
{filtered.length > 0 && <div className="px-2 pt-2 pb-1 text-xs font-semibold text-gray-400 uppercase tracking-wide">Пользователи</div>}
{searchUsers.map(u => (
<button key={u.id} onClick={() => openOrCreatePrivate(u.id)}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-gray-50 text-left">
<Avatar name={u.displayName} color={u.avatarColor} avatar={u.avatar} size="sm"
online={onlineUsers.has(u.id)} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium text-gray-900 truncate">{u.displayName}</span>
{u.position && <span className="text-xs bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded-full shrink-0">{u.position}</span>}
</div>
<div className="text-xs text-gray-400">@{u.username}</div>
</div>
<MessageCircle className="w-4 h-4 text-gray-300 shrink-0" />
</button>
))}
</>
)}
{search && filtered.length === 0 && searchUsers.length === 0 && (
<div className="text-center text-gray-400 text-sm py-8">Ничего не найдено</div>
)}
{!search && filtered.length === 0 && (
<div className="text-center text-gray-400 text-sm py-8">Нет чатов. Создайте новый!</div>
)}
</div>
</div>
{/* Main Chat Area */}
<div className={`flex-1 flex flex-col ${!mobileChatOpen ? 'hidden md:flex' : 'flex'}`}>
{activeChat && fullChat ? (
<>
<div className="flex flex-1 overflow-hidden">
<div className="flex-1 flex flex-col min-w-0">
<ChatHeader
chat={fullChat}
onBack={() => { setMobileChatOpen(false); setActiveChat(null); }}
onRefresh={() => openChat(activeChat)}
onShowInfo={() => setShowInfo(!showInfo)}
/>
<div className="flex-1 overflow-hidden bg-gray-50">
<MessageList
chatId={activeChat.id}
isGroup={activeChat.type !== 'private'}
canSend={canSend}
/>
</div>
</div>
{/* Info Panel */}
{showInfo && (
<ChatInfoPanel
chat={fullChat}
onClose={() => setShowInfo(false)}
onRefresh={() => openChat(activeChat)}
/>
)}
</div>
</>
) : (
<div className="hidden md:flex flex-1 items-center justify-center text-gray-400 flex-col gap-4">
<div className="w-24 h-24 bg-blue-100 rounded-full flex items-center justify-center">
<svg className="w-12 h-12 text-blue-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/>
</svg>
</div>
<div className="text-center">
<p className="text-lg font-medium text-gray-600">JaniChat</p>
<p className="text-sm text-gray-400 mt-1">Выберите чат или создайте новый</p>
</div>
</div>
)}
</div>
{/* Modals */}
{showNew && <NewChatModal onClose={() => setShowNew(false)} onOpen={async (id) => {
try {
const { data: allChats } = await api.get('/api/chats');
setChats(allChats);
const found = allChats.find((c: any) => c.id === id);
if (found) openChat(found);
} catch {}
}} />}
{showAdmin && <AdminPanel onClose={() => setShowAdmin(false)} />}
{showProfile && <ProfileModal onClose={() => setShowProfile(false)} />}
</div>
);
}