Рабочий чат

This commit is contained in:
Халимов Рустам
2026-04-01 23:18:55 +03:00
parent 249c344df8
commit 4ae7dd60ce
42 changed files with 1016 additions and 184 deletions

View File

@@ -524,7 +524,7 @@ export default function AdminPage() {
const [stats, setStats] = useState<Stats | null>(null);
const [config, setConfig] = useState<Conf | null>(null);
const [cleanStats, setCleanStats] = useState<CleanStats | null>(null);
const [authHeader, setAuthHeader] = useState('');
const [authHeader, setAuthHeader] = useState(localStorage.getItem('knot_admin_auth') || '');
const [creds, setCreds] = useState({ user: '', pass: '' });
const [authenticated, setAuthenticated] = useState(false);
@@ -818,17 +818,47 @@ export default function AdminPage() {
try {
await httpClient.request('/admin/dashboard');
setAuthHeader(header);
localStorage.setItem('knot_admin_auth', header);
setAuthenticated(true);
fetchDashboard();
fetchSettings();
fetchTimezones();
searchUsers('');
} catch {
} catch (err: any) {
showToast(t.errorInvalidLogin, 'error');
httpClient.setToken(null);
if (err.status === 401 || err.status === 403) {
httpClient.setToken(null);
localStorage.removeItem('knot_admin_auth');
}
}
};
useEffect(() => {
const initAuth = async () => {
const savedHeader = localStorage.getItem('knot_admin_auth');
if (savedHeader) {
httpClient.setToken(savedHeader);
try {
await httpClient.request('/admin/dashboard');
setAuthenticated(true);
fetchDashboard();
fetchSettings();
fetchTimezones();
searchUsers('');
} catch (err: any) {
console.error('Session verify failed', err);
if (err.status === 401 || err.status === 403 || (err.message && err.message.includes('auth'))) {
localStorage.removeItem('knot_admin_auth');
httpClient.setToken(null);
setAuthenticated(false);
setAuthHeader('');
}
}
}
};
initAuth();
}, []);
useEffect(() => {
if (!authenticated || !authHeader) return;
const interval = setInterval(() => {

View File

@@ -18,7 +18,11 @@ interface AuthState {
}
export const useAuthStore = create<AuthState>((set, get) => ({
token: localStorage.getItem('knot_token'),
token: (() => {
const t = localStorage.getItem('knot_token');
if (t) AuthApi.setToken(t);
return t;
})(),
user: null,
isLoading: true,
error: null,
@@ -83,7 +87,12 @@ export const useAuthStore = create<AuthState>((set, get) => ({
for (let attempt = 0; attempt < 3; attempt++) {
try {
AuthApi.setToken(token);
const { user } = await AuthApi.getMe();
const { user, token: newToken } = await AuthApi.getMe();
if (newToken && newToken.length > 0) {
localStorage.setItem('knot_token', newToken);
AuthApi.setToken(newToken);
set({ token: newToken });
}
connectSocket(token);
set({ user, isLoading: false });
await get().fetchConfig();
@@ -101,8 +110,23 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}
}
console.warn('checkAuth failed:', lastError);
localStorage.removeItem('knot_token');
set({ token: null, user: null, isLoading: false });
// Explicitly check for 401 Unauthorized or 403 Forbidden
const status = (lastError as any)?.status;
const errorMsg = lastError instanceof Error ? lastError.message : String(lastError);
if (
status === 401 ||
status === 403 ||
errorMsg.includes('auth') ||
errorMsg.includes('Недействительный токен') ||
errorMsg.includes('Требуется авторизация')
) {
localStorage.removeItem('knot_token');
set({ token: null, user: null, isLoading: false });
} else {
// Keep the token but stop loading if we're just offline/network error/500
set({ isLoading: false });
}
},
updateUser: (data) => {

View File

@@ -37,7 +37,16 @@ export class AuthApi {
}
static async getMe() {
return httpClient.request<{ user: User }>('/auth/me');
const response = await httpClient.request<{ userId: string; username: string; displayName: string; avatar: string | null; accessToken?: string }>('/auth/me');
return {
user: {
id: response.userId,
username: response.username,
displayName: response.displayName,
avatar: response.avatar
} as User,
token: response.accessToken
};
}
static async getConfig() {

View File

@@ -7,12 +7,13 @@ import { ChatApi } from '../infrastructure/chatApi';
import { playNotificationSound, isChatMuted, playCallRingtone, stopCallRingtone } from '../../../core/utils/sounds';
import { useLang } from '../../../core/infrastructure/i18n';
import type { Message, UserBasic, CallInfo, ChatMember } from '../../../core/domain/types';
import { Send, Check, Phone, PhoneOff } from 'lucide-react';
import { Send, Check, Phone, PhoneOff, Users } from 'lucide-react';
import Sidebar from '../../../core/presentation/layouts/Sidebar';
import GlobalNavBar from '../../../core/presentation/layouts/GlobalNavBar';
import ChatView from './components/ChatView';
import CallModal from '../../calls/presentation/components/CallModal';
import GroupCallModal from '../../calls/presentation/components/GroupCallModal';
import ContactsSidebar from '../../friends/presentation/components/ContactsSidebar';
export default function ChatPage() {
const {
@@ -57,6 +58,7 @@ export default function ChatPage() {
const groupCallOpenRef = useRef(false);
const groupCallChatIdRef = useRef('');
const [activeTab, setActiveTab] = useState('chats');
const { t } = useLang();
useEffect(() => {
@@ -365,22 +367,58 @@ export default function ChatPage() {
exit={{ opacity: 0 }}
className="h-screen w-screen flex bg-surface-dim overflow-hidden antialiased font-body selection:bg-primary/30"
>
<GlobalNavBar activeTab="chats" onTabChange={() => {}} />
<GlobalNavBar activeTab={activeTab} onTabChange={setActiveTab} />
<main className="ml-20 flex-1 flex flex-row relative h-full">
{/* Chat List (Sidebar) */}
<div
className={`${activeChat ? 'hidden lg:block' : 'block'} w-full lg:w-[360px] flex-shrink-0 bg-surface-container-low tonal-transition-no-border h-full`}
>
<Sidebar />
</div>
{activeTab === 'chats' ? (
<>
{/* Chat List (Sidebar) */}
<div
className={`${activeChat ? 'hidden lg:block' : 'block'} w-full lg:w-[360px] flex-shrink-0 bg-surface-container-low h-full overflow-hidden`}
>
<Sidebar />
</div>
{/* Selected Chat View (Main Area) */}
<div
className={`${activeChat ? 'block' : 'hidden lg:block'} flex-1 h-full min-w-0 bg-surface-container-lowest m-4 rounded-3xl overflow-hidden relative group slide-on-ice`}
>
<ChatView onStartCall={handleStartCall} onStartGroupCall={handleStartGroupCall} />
</div>
{/* Selected Chat View (Main Area) */}
<div
className={`${activeChat ? 'block' : 'hidden lg:block'} flex-1 h-full min-w-0 bg-surface-container-lowest m-4 rounded-3xl overflow-hidden relative group slide-on-ice`}
>
<ChatView onStartCall={handleStartCall} onStartGroupCall={handleStartGroupCall} />
</div>
</>
) : activeTab === 'contacts' ? (
<div className="flex-1 flex flex-row h-full overflow-hidden">
{/* Contacts Sidebar List */}
<div className="w-full lg:w-[360px] flex-shrink-0 bg-surface-container-low h-full overflow-hidden antialiased">
<ContactsSidebar onSwitchToChat={() => setActiveTab('chats')} />
</div>
{/* Right side placeholder / Profile detail */}
<div className="hidden lg:flex flex-1 items-center justify-center bg-surface-base m-4 rounded-3xl overflow-hidden border border-white/5 relative slide-on-ice">
<div className="flex flex-col items-center gap-6 max-w-sm text-center">
<div className="w-24 h-24 rounded-3xl bg-primary/10 flex items-center justify-center text-primary shadow-inner">
<Users size={48} className="knot-logo-spin opacity-50" />
</div>
<div>
<h2 className="text-xl font-bold text-white mb-2">{t('contacts')}</h2>
<p className="text-sm text-zinc-500 leading-relaxed max-w-[280px]">
{t('selectContactToChat')}
</p>
</div>
<button
onClick={() => setActiveTab('chats')}
className="px-8 py-3 rounded-2xl bg-primary text-on-primary shadow-lg shadow-primary/20 hover:scale-105 active:scale-95 transition-all text-sm font-bold tracking-tight"
>
{t('backToChats')}
</button>
</div>
</div>
</div>
) : (
<div className="flex-1 flex items-center justify-center">
<p className="text-zinc-500">Coming soon</p>
</div>
)}
</main>
<CallModal
key={callSessionId}

View File

@@ -193,30 +193,34 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
{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"
className="fixed z-[9999] min-w-[200px] py-1.5 rounded-[1.25rem] bg-[#1a1a1a] border border-white/10 shadow-[0_20px_50px_rgba(0,0,0,0.5)] 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" />
{!isFavorites && (
<>
<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')}
{isFavorites ? t('clearHistory') : t('deleteChat')}
</button>
</div>
)}
<ConfirmModal
open={showDeleteConfirm}
message={t('deleteChatConfirm')}
message={isFavorites ? t('clearHistoryConfirm') : t('deleteChatConfirm')}
onConfirm={confirmDelete}
onCancel={() => setShowDeleteConfirm(false)}
/>

View File

@@ -516,7 +516,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
<div className="relative" ref={deleteMenuRef}>
<button
disabled={selectedMessages.size === 0}
onClick={() => setShowDeleteMenu(!showDeleteMenu)}
onClick={() => isFavorites ? handleBulkDelete(false) : 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} />
@@ -529,7 +529,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
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"
className="absolute right-0 top-full mt-2 w-56 rounded-2xl bg-[#1a1a1a] shadow-[0_20px_50px_rgba(0,0,0,0.5)] z-50 py-1.5 ring-1 ring-white/10 overflow-hidden"
>
<button
onClick={() => handleBulkDelete(false)}
@@ -690,7 +690,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
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"
className="absolute right-0 top-full mt-2 w-56 rounded-2xl bg-[#1a1a1a] shadow-[0_20px_50px_rgba(0,0,0,0.5)] z-50 py-1.5 ring-1 ring-white/10"
>
<button
onClick={openSearch}
@@ -699,7 +699,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
<Search size={16} />
{t('searchMessages')}
</button>
{chat.type === 'personal' && otherMember && (
{!isFavorites && chat.type === 'personal' && otherMember && (
<button
onClick={() => {
setShowTopMenu(false);
@@ -711,19 +711,21 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
{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' && (
{!isFavorites && (
<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>
)}
{!isFavorites && chat.type === 'group' && (
<button
onClick={() => {
setShowTopMenu(false);
@@ -735,13 +737,13 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
{t('groupSettings')}
</button>
)}
<div className="border-t border-border my-1" />
<div className="border-t border-white/5 my-1" />
<button
onClick={() => {
setShowTopMenu(false);
if (activeChat) {
setConfirmAction({
message: t('clearChatConfirm'),
message: isFavorites ? t('clearHistoryConfirm') : t('clearChatConfirm'),
action: async () => {
try {
await ChatApi.clearChat(activeChat);
@@ -756,30 +758,32 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
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 ChatApi.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')}
{isFavorites ? t('clearHistory') : t('clearChat')}
</button>
{!isFavorites && (
<button
onClick={() => {
setShowTopMenu(false);
if (activeChat) {
setConfirmAction({
message: t('deleteChatConfirm'),
action: async () => {
try {
await ChatApi.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>

View File

@@ -1,3 +1,4 @@
import { createPortal } from 'react-dom';
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Search } from 'lucide-react';
@@ -32,14 +33,14 @@ export default function ForwardModal({ onClose, onForward }: ForwardModalProps)
return 0;
});
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
return createPortal(
<div className="fixed inset-0 z-[99999] 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"
className="absolute inset-0 bg-black/60 backdrop-blur-md"
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
@@ -48,31 +49,31 @@ export default function ForwardModal({ onClose, onForward }: ForwardModalProps)
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"
className="relative w-full max-w-md bg-[#1a1a1a] rounded-[2rem] overflow-hidden shadow-[0_20px_50px_rgba(0,0,0,0.5)] border border-white/10"
>
<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>
<div className="p-5 flex items-center justify-between border-b border-white/5">
<h2 className="text-xl font-bold font-headline 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"
className="w-10 h-10 flex items-center justify-center rounded-xl 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} />
<div className="p-5">
<div className="relative mb-5">
<Search className="absolute left-4 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-knot-500 transition-colors"
className="w-full bg-black/40 border border-white/10 rounded-2xl py-3 pl-12 pr-4 text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all"
/>
</div>
<div className="max-h-80 overflow-y-auto space-y-1 pr-2 custom-scrollbar">
<div className="max-h-96 overflow-y-auto space-y-1.5 pr-2 custom-scrollbar">
{filteredChats.map((chat) => {
const otherMember = chat.members.find((m) => m.userId !== user?.id);
const chatName =
@@ -88,19 +89,33 @@ export default function ForwardModal({ onClose, onForward }: ForwardModalProps)
<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"
className="w-full flex items-center gap-4 p-3 rounded-2xl hover:bg-white/5 transition-all text-left group"
>
<Avatar src={chatAvatar} name={chatName} size="md" />
<span className="text-white font-medium flex-1 truncate">{chatName}</span>
<div className="relative group-hover:scale-105 transition-transform duration-300">
{chat.type === 'favorites' ? (
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-primary to-primary-container flex items-center justify-center shadow-lg shadow-primary/10 border-2 border-outline-variant/10">
<span className="material-symbols-outlined text-on-primary-container text-[24px]">bookmark</span>
</div>
) : (
<Avatar src={chatAvatar} name={chatName} size="lg" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-white font-bold truncate group-hover:text-primary transition-colors">{chatName}</div>
<div className="text-[11px] text-zinc-500 font-bold uppercase tracking-widest mt-0.5">
{chat.type === 'favorites' ? t('favoritesDescription') : chat.type === 'personal' ? t('chat') : `${chat.members.length} ${t('members')}`}
</div>
</div>
</button>
);
})}
{filteredChats.length === 0 && (
<p className="text-center text-zinc-500 py-4 text-sm">{t('nothingFound')}</p>
<p className="text-center text-zinc-500 py-8 text-sm">{t('nothingFound')}</p>
)}
</div>
</div>
</motion.div>
</div>
</div>,
document.body
);
}

View File

@@ -59,6 +59,9 @@ function MessageBubble({
const [contextPos, setContextPos] = useState({ x: 0, y: 0 });
const [deleteMenuMode, setDeleteMenuMode] = useState(false);
const [lightboxData, setLightboxData] = useState<{ index: number } | null>(null);
const activeChatId = useChatStore(s => s.activeChat);
const activeChat = useChatStore(s => s.chats.find(c => c.id === activeChatId));
const isFavorites = activeChat?.type === 'favorites';
const [isPlaying, setIsPlaying] = useState(false);
const [audioProgress, setAudioProgress] = useState(0);
const [audioDuration, setAudioDuration] = useState(0);
@@ -862,7 +865,7 @@ function MessageBubble({
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] bg-[#1a1a1a]/95 backdrop-blur-2xl shadow-[0_20px_50px_rgba(0,0,0,0.5)] py-1.5 overflow-hidden border border-white/10"
className="fixed z-[9999] w-52 rounded-[1.25rem] bg-[#1a1a1a] shadow-[0_20px_50px_rgba(0,0,0,0.5)] py-1.5 overflow-hidden border border-white/10"
style={{ left: contextPos.x, top: contextPos.y }}
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => {
@@ -972,7 +975,7 @@ function MessageBubble({
<div className="border-t border-border my-1" />
<button
onClick={() => setDeleteMenuMode(true)}
onClick={() => isFavorites ? handleDeleteForMe() : 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} />

View File

@@ -0,0 +1,254 @@
import React, { useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Search,
UserPlus,
UserCheck,
X,
UserMinus,
Loader2,
Users,
User as UserIcon,
MessageCircle,
ArrowRight
} from 'lucide-react';
import { useFriendStore } from '../../application/friendStore';
import { useAuthStore } from '../../../auth/application/authStore';
import { useChatStore } from '../../../../modules/chats/application/chatStore';
import { useLang } from '../../../../core/infrastructure/i18n';
import Avatar from '../../../../core/presentation/components/ui/Avatar';
import { ChatApi } from '../../../../modules/chats/infrastructure/chatApi';
interface ContactsSidebarProps {
onSwitchToChat: () => void;
}
export default function ContactsSidebar({ onSwitchToChat }: ContactsSidebarProps) {
const { user } = useAuthStore();
const { t } = useLang();
const {
friends,
friendRequests,
isLoading,
searchQuery,
searchResults,
isSearching,
setSearchQuery,
loadFriends,
acceptRequest,
declineRequest,
removeFriend,
sendRequest,
searchFriends,
clearSearch,
initializeSocketEvents
} = useFriendStore();
const { addChat, setActiveChat } = useChatStore();
useEffect(() => {
loadFriends();
const cleanup = initializeSocketEvents();
return cleanup;
}, [loadFriends, initializeSocketEvents]);
// Global search effect
useEffect(() => {
const timer = setTimeout(() => {
if (searchQuery.trim().length >= 3) {
searchFriends(searchQuery, user?.id);
}
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, user?.id, searchFriends]);
const handleStartChat = async (friendId: string) => {
try {
// Logic to find or create DM
const allChats = await ChatApi.getChats();
let chat = allChats.find(c =>
c.type === 'personal' && c.members.some(m => m.user.id === friendId)
);
if (!chat) {
// Create new DM
chat = await ChatApi.createPersonalChat(friendId);
addChat(chat);
}
setActiveChat(chat.id);
onSwitchToChat();
} catch (error) {
console.error('Failed to start chat:', error);
}
};
return (
<div className="w-full h-full flex flex-col bg-surface-container-low overflow-hidden border-none relative z-10 slide-on-ice">
{/* Title */}
<div className="px-6 py-8 pb-4">
<h1 className="text-2xl font-bold font-headline text-white tracking-tight leading-none">{t('contacts')}</h1>
</div>
{/* Global Search */}
<div className="px-6 mb-6">
<div className="relative group">
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-on-surface-variant/40 group-focus-within:text-primary transition-colors" />
<input
type="text"
placeholder={t('searchFriends')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-12 pr-4 py-3 rounded-xl bg-surface-container-highest text-sm text-on-surface placeholder-on-surface-variant/30 border-none focus:ring-2 focus:ring-primary/20 hover:bg-surface-bright transition-all outline-none"
/>
{searchQuery && (
<button
onClick={() => { clearSearch(); setSearchQuery(''); }}
className="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-white"
>
<X size={16} />
</button>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto px-4 custom-scrollbar space-y-6 pb-6">
{/* Search Results */}
{searchQuery.trim().length > 0 && (
<div>
<h3 className="px-2 mb-3 text-[11px] font-bold text-zinc-500 uppercase tracking-[0.08em]">{t('searchResults')}</h3>
{isSearching ? (
<div className="flex items-center justify-center py-6">
<Loader2 size={24} className="animate-spin text-primary" />
</div>
) : searchResults.length > 0 ? (
<div className="space-y-1">
{searchResults.map((u) => (
<motion.div
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
key={u.id}
className="flex items-center gap-3 p-3 rounded-2xl bg-surface-container-high/40 border border-white/5 group"
>
<Avatar src={u.avatar} name={u.displayName || u.username || ''} size="md" />
<div className="flex-1 min-w-0">
<p className="text-sm font-bold text-zinc-100 truncate">{u.displayName || u.username || ''}</p>
<p className="text-[11px] text-zinc-500">@{u.username || ''}</p>
</div>
<button
onClick={() => sendRequest(u.id)}
className="p-2.5 rounded-xl bg-primary/10 text-primary hover:bg-primary/20 transition-all active:scale-90"
title={t('addFriend')}
>
<UserPlus size={18} />
</button>
</motion.div>
))}
</div>
) : (
<p className="text-center py-6 text-zinc-600 text-xs">{t('noSearchResults')}</p>
)}
</div>
)}
{/* Friend Requests */}
{friendRequests.length > 0 && !searchQuery && (
<div>
<div className="flex items-center justify-between px-2 mb-3">
<h3 className="text-[11px] font-bold text-primary uppercase tracking-[0.08em]">{t('friendRequests')}</h3>
<span className="w-5 h-5 rounded-full bg-primary text-[10px] font-black text-on-primary flex items-center justify-center animate-pulse">
{friendRequests.filter(r => !r.isOutgoing).length}
</span>
</div>
<div className="space-y-2">
{friendRequests.map((req) => (
<div key={req.id} className={`flex items-center gap-3 p-3 rounded-2xl border ${req.isOutgoing ? 'bg-surface-container-high/40 border-white/5 opacity-80' : 'bg-primary/5 border-primary/10'}`}>
<Avatar src={req.user.avatar} name={req.user.displayName || req.user.username || ''} size="sm" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-xs font-bold text-zinc-100 truncate">{req.user.displayName || req.user.username || ''}</p>
{req.isOutgoing && <span className="text-[10px] px-1.5 py-0.5 rounded-full bg-zinc-800 text-zinc-500 font-medium lowercase">sent</span>}
</div>
<p className="text-[10px] text-zinc-500">@{req.user.username || ''}</p>
</div>
<div className="flex gap-1.5 text-shimmer">
{!req.isOutgoing && (
<button
onClick={() => acceptRequest(req.id)}
className="w-8 h-8 rounded-lg bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20 flex items-center justify-center transition-all"
title={t('accept') || 'Accept'}
>
<UserCheck size={14} />
</button>
)}
<button
onClick={() => declineRequest(req.id)}
className="w-8 h-8 rounded-lg bg-red-500/10 text-red-400 hover:bg-red-500/20 flex items-center justify-center transition-all"
title={req.isOutgoing ? (t('cancel') || 'Cancel') : (t('decline') || 'Decline')}
>
<X size={14} />
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* Contacts List */}
{!searchQuery && (
<div>
<h3 className="px-2 mb-3 text-[11px] font-bold text-zinc-500 uppercase tracking-[0.08em]">{t('friendsList')}</h3>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 size={24} className="animate-spin text-zinc-700" />
</div>
) : friends.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-zinc-600 gap-4">
<Users size={48} className="opacity-10" />
<p className="text-xs max-w-[200px] text-center leading-relaxed">
{t('noFriends') || 'Your contact list is empty. Use search to find people.'}
</p>
</div>
) : (
<div className="space-y-1">
{friends.map((friend) => (
<div key={friend.id} className="group relative">
<button
onClick={() => handleStartChat(friend.id)}
className="w-full flex items-center gap-3 p-3 rounded-2xl hover:bg-surface-container-highest/40 transition-all active:scale-[0.98] group-hover:pr-12"
>
<div className="relative">
<Avatar src={friend.avatar} name={friend.displayName || friend.username || ''} size="md" />
{friend.isOnline && (
<span className="absolute bottom-0 right-0 w-3 h-3 bg-emerald-500 border-2 border-surface-container-low rounded-full shadow-lg" />
)}
</div>
<div className="flex-1 min-w-0 text-left">
<p className="text-sm font-bold text-zinc-100 truncate group-hover:text-primary transition-colors">
{friend.displayName || friend.username || ''}
</p>
<p className="text-[11px] text-zinc-500">
{friend.isOnline ? t('online') : (friend as any).status || `@${friend.username || ''}`}
</p>
</div>
<ArrowRight size={14} className="opacity-0 -translate-x-2 group-hover:opacity-40 group-hover:translate-x-0 transition-all text-zinc-400" />
</button>
<button
onClick={(e) => { e.stopPropagation(); removeFriend(friend.friendshipId); }}
className="absolute right-3 top-1/2 -translate-y-1/2 p-2 rounded-xl text-zinc-600 opacity-0 group-hover:opacity-100 hover:bg-red-500/10 hover:text-red-400 transition-all"
title={t('removeFriend')}
>
<UserMinus size={16} />
</button>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
);
}