Рабочий чат
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user