looood / src /user-list.tsx
looda3131's picture
Clean push without any binary history
cc276cc
"use client";
import { useState, useEffect, useMemo } from 'react';
import { useAuth } from '@/contexts/auth-context';
import { useContacts } from '@/contexts/contacts-context';
import { useGroups } from '@/contexts/groups-context';
import { useSettings } from '@/contexts/settings-context';
import { useFirebase } from '@/contexts/firebase-context';
import { Button } from './ui/button';
import { Cog, LogOut, Users, Sparkles, Globe, UserPlus, Search, X, Loader2, MoreVertical, Ban, Check, Mail, LayoutGrid, Phone, Settings, User } from 'lucide-react';
import type { ChatRecipient, Contact, Group, User as UserType, ChatRequest, GroupInvitation } from '@/lib/types';
import { Input } from './ui/input';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { Badge } from './ui/badge';
import { Separator } from './ui/separator';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './ui/dropdown-menu';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { UserListItem } from './user-list-item';
import { onValue, ref } from 'firebase/database';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
const loodaAssistant: ChatRecipient = {
uid: 'looda-assistant',
displayName: 'Looda',
isBot: true,
photoURL: 'https://api.dicebear.com/8.x/bottts/svg?seed=Looda'
}
const khaloushAssistant: ChatRecipient = {
uid: 'khaloush-assistant',
displayName: 'Khaloush',
isBot: true,
photoURL: 'https://api.dicebear.com/8.x/bottts/svg?seed=Khaloush'
}
const GroupListItem = ({ group, onSelectRecipient }: { group: Group, onSelectRecipient: (recipient: ChatRecipient) => void }) => {
const { playSound, t } = useSettings();
const groupType = group.info.type || 'general';
return (
<button
onClick={() => { playSound('touch'); onSelectRecipient({ ...group.info, uid: group.id, displayName: group.info.name, isGroup: true, publicId: group.id, photoURL: group.info.photoURL }); }}
className="flex items-center gap-3 p-2 rounded-lg cursor-pointer hover:bg-muted w-full text-left"
>
<Avatar className="h-12 w-12 border">
<AvatarImage src={group.info.photoURL} alt={group.info.name} />
<AvatarFallback>{group.info.name.charAt(0)}</AvatarFallback>
</Avatar>
<div className="flex-1 overflow-hidden">
<span className="font-bold text-foreground truncate">{group.info.name}</span>
<p className="text-xs text-muted-foreground truncate capitalize">{t(groupType)}</p>
</div>
</button>
)};
const ChatRequestItem = ({ request, onAccept, onDecline }: {
request: ChatRequest;
onAccept: (request: ChatRequest) => void;
onDecline: (request: ChatRequest, andBlock: boolean) => void;
}) => {
const { playSound, t } = useSettings();
return (
<div className="flex items-center justify-between p-2 rounded-lg hover:bg-muted">
<div className="flex items-center gap-3">
<Avatar className="h-12 w-12 border">
<AvatarImage src={request.photoURL} />
<AvatarFallback>{request.displayName.charAt(0)}</AvatarFallback>
</Avatar>
<div>
<p className="font-bold">{request.displayName}</p>
<p className="text-xs text-muted-foreground">@{request.publicId}</p>
</div>
</div>
<div className="flex items-center gap-1">
<Button size="sm" onClick={() => { playSound('touch'); onAccept(request); }}>{t('accept')}</Button>
<DropdownMenu onOpenChange={() => playSound('touch')}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<X className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onSelect={() => onDecline(request, false)}>{t('decline')}</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onDecline(request, true)} className="text-destructive">
{t('declineAndBlock')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
};
const GroupInvitationItem = ({ invitation, onAccept, onDecline }: {
invitation: GroupInvitation;
onAccept: (invitation: GroupInvitation) => void;
onDecline: (invitation: GroupInvitation) => void;
}) => {
const { playSound, t } = useSettings();
return (
<div className="flex items-center justify-between p-2 rounded-lg hover:bg-muted">
<div className="flex items-center gap-3">
<Avatar className="h-12 w-12 border">
<AvatarImage src={invitation.groupPhotoURL} />
<AvatarFallback>{invitation.groupName.charAt(0)}</AvatarFallback>
</Avatar>
<div>
<p className="font-bold">{invitation.groupName}</p>
<p className="text-xs text-muted-foreground">{t('invitedBy', { name: invitation.invitedBy })}</p>
</div>
</div>
<div className="flex items-center gap-1">
<Button size="icon" className="h-8 w-8" onClick={() => { playSound('touch'); onAccept(invitation); }}><Check className="h-4 w-4" /></Button>
<Button variant="destructive" size="icon" className="h-8 w-8" onClick={() => { playSound('touch'); onDecline(invitation); }}><X className="h-4 w-4" /></Button>
</div>
</div>
);
};
export function UserList({
onSelectRecipient,
onOpenCreateGroup,
onOpenSettings,
onSignOut,
onViewProfile
}: {
onSelectRecipient: (recipient: ChatRecipient) => void,
onOpenCreateGroup: () => void,
onOpenSettings: () => void,
onSignOut: () => void,
onViewProfile: (user: UserType) => void
}) {
const { currentUser } = useAuth();
const { contacts, chatRequests, findUserByPublicId, sendChatRequest, acceptChatRequest, declineChatRequest } = useContacts();
const { groups, groupInvitations, acceptGroupInvitation, declineGroupInvitation } = useGroups();
const { rtdb } = useFirebase();
const { addToast, playSound, t } = useSettings();
const [searchTerm, setSearchTerm] = useState('');
const [searchResult, setSearchResult] = useState<UserType | null | 'not_found'>(null);
const [isSearching, setIsSearching] = useState(false);
const [onlineStatus, setOnlineStatus] = useState<{[key: string]: {isOnline: boolean, lastSeen: number}}>({});
const router = useRouter();
useEffect(() => {
if (!currentUser) return;
const presenceRef = ref(rtdb, 'presence');
const unsubscribe = onValue(presenceRef, (snapshot) => {
const data = snapshot.val() || {};
setOnlineStatus(data);
});
return () => unsubscribe();
}, [rtdb, currentUser]);
const contactsWithStatus = useMemo(() => {
return contacts.map(contact => ({
...contact,
isOnline: onlineStatus[contact.uid]?.isOnline,
}));
}, [contacts, onlineStatus]);
const handleSearch = async (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key !== 'Enter' || !searchTerm.trim()) return;
playSound('touch');
const term = searchTerm.trim().toLowerCase();
setIsSearching(true);
setSearchResult(null);
if (term === currentUser?.publicId) {
addToast(t("youCantAddYourself"), { variant: "destructive" });
setIsSearching(false);
return;
}
const user = await findUserByPublicId(term);
setSearchResult(user || 'not_found');
setIsSearching(false);
};
const handleStartChat = async (user: UserType) => {
playSound('touch');
const isContact = contacts.some(c => c.uid === user.uid);
if (isContact) {
onSelectRecipient({ ...user, isGroup: false, uid: user.uid, displayName: user.displayName });
} else {
await sendChatRequest(user);
}
setSearchTerm('');
setSearchResult(null);
}
if (!currentUser) {
return null;
}
return (
<div className="flex flex-col h-full w-full bg-background overflow-hidden">
<header className="p-4 border-b flex items-center justify-between bg-card/80 backdrop-blur-sm sticky top-0 z-10">
<div className="flex items-center gap-4">
<button
className="flex items-center gap-3 text-left overflow-hidden"
onClick={() => { playSound('touch'); onViewProfile(currentUser); }}
>
<Avatar className="h-10 w-10 border">
<AvatarImage src={currentUser?.photoURL} alt={currentUser?.displayName} />
<AvatarFallback>{currentUser?.displayName?.charAt(0)}</AvatarFallback>
</Avatar>
<div className="overflow-hidden">
<p className="font-bold text-foreground truncate">{currentUser?.displayName}</p>
<p className="text-xs text-muted-foreground font-mono truncate">@{currentUser?.publicId}</p>
</div>
</button>
</div>
<div className="flex items-center gap-1">
<Button onClick={() => router.push('/aether-feed')} variant="ghost" size="icon" title={t('explore')}>
<Globe />
</Button>
<Button onClick={() => router.push('/group-chat')} variant="ghost" size="icon" title="Explore">
<Users />
</Button>
<Button onClick={() => { playSound('touch'); onOpenCreateGroup(); }} variant="ghost" size="icon" title={t('createGroupTitle')}>
<UserPlus />
</Button>
<Button onClick={() => { playSound('touch'); onOpenSettings(); }} variant="ghost" size="icon" title={t('settingsTitle')}>
<Settings />
</Button>
<Button onClick={() => { playSound('touch'); onSignOut(); }} variant="ghost" size="icon" title={t('signOut')} className="text-destructive">
<LogOut />
</Button>
</div>
</header>
<div className="flex-1 flex flex-col min-h-0">
<div className="p-4 border-b">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder={t('searchById')}
className="pl-8 w-full"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
if (!e.target.value) setSearchResult(null);
}}
onKeyDown={handleSearch}
/>
</div>
{isSearching && (
<div className="p-4 flex items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{searchResult && searchResult !== 'not_found' && (
<div className="p-2 mt-2 border rounded-lg">
<p className="text-sm font-semibold mb-2 px-2">{t('searchResult')}</p>
<div className="flex items-center justify-between p-2 rounded-lg">
<button onClick={() => { playSound('touch'); onViewProfile(searchResult); }} className="flex items-center gap-3 text-left">
<Avatar className="h-12 w-12 border">
<AvatarImage src={searchResult.photoURL} />
<AvatarFallback>{searchResult.displayName.charAt(0)}</AvatarFallback>
</Avatar>
<div>
<p className="font-bold">{searchResult.displayName}</p>
<p className="text-xs text-muted-foreground">@{searchResult.publicId}</p>
</div>
</button>
<Button size="sm" onClick={() => handleStartChat(searchResult)}>
<UserPlus className="mr-2 h-4 w-4"/> {t('add')}
</Button>
</div>
</div>
)}
{searchResult === 'not_found' && (
<p className="text-center text-sm text-muted-foreground p-4">{t('userNotFound')}</p>
)}
</div>
<Tabs defaultValue="chats" className="flex-1 flex flex-col overflow-hidden">
<TabsList className="mx-4 mt-4 grid grid-cols-2">
<TabsTrigger value="chats" className="flex-1" onClick={() => playSound('touch')}>{t('chatsTab')}</TabsTrigger>
<TabsTrigger value="requests" className="flex-1 relative" onClick={() => playSound('touch')}>
{t('requestsTab')}
{(chatRequests.length + groupInvitations.length > 0) && <Badge className="absolute -top-1 -right-1 h-5 w-5 justify-center p-0">{chatRequests.length + groupInvitations.length}</Badge>}
</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
<TabsContent value="chats" className="m-0">
<div className="space-y-1">
<h3 className="text-sm font-bold text-muted-foreground px-2 mt-2 flex items-center gap-2">{t('mainSection')}</h3>
<button
onClick={() => { playSound('touch'); onSelectRecipient(loodaAssistant); }}
className="flex items-center gap-3 p-2 rounded-lg cursor-pointer hover:bg-muted w-full text-left"
>
<Avatar className="h-12 w-12 border">
<AvatarImage src={loodaAssistant.photoURL} />
<AvatarFallback>L</AvatarFallback>
</Avatar>
<div className="flex-1 overflow-hidden">
<span className="font-bold text-foreground truncate">{loodaAssistant.displayName}</span>
<p className="text-xs text-muted-foreground truncate">{t('aiAssistant')}</p>
</div>
</button>
<button
onClick={() => { playSound('touch'); onSelectRecipient(khaloushAssistant); }}
className="flex items-center gap-3 p-2 rounded-lg cursor-pointer hover:bg-muted w-full text-left"
>
<Avatar className="h-12 w-12 border">
<AvatarImage src={khaloushAssistant.photoURL} />
<AvatarFallback>K</AvatarFallback>
</Avatar>
<div className="flex-1 overflow-hidden">
<span className="font-bold text-foreground truncate">{khaloushAssistant.displayName}</span>
<p className="text-xs text-muted-foreground truncate">Cloud AI Assistant</p>
</div>
</button>
{groups.length > 0 && (
<>
<h3 className="text-sm font-bold text-muted-foreground px-2 mt-2 flex items-center gap-2">{t('groupsSection')}</h3>
{groups.map(group => <GroupListItem key={group.id} group={group} onSelectRecipient={onSelectRecipient} />)}
</>
)}
<h3 className="text-sm font-bold text-muted-foreground px-2 mt-2 flex items-center gap-2">{t('contactsSection')}</h3>
{contactsWithStatus.map(contact => <UserListItem key={contact.uid} contact={contact} onSelectRecipient={onSelectRecipient} />)}
{contacts.length === 0 && !searchTerm && <p className="px-2 text-sm text-muted-foreground">{t('noContacts')}</p>}
</div>
</TabsContent>
<TabsContent value="requests" className="m-0">
{(chatRequests.length > 0 || groupInvitations.length > 0) ? (
<div className="space-y-4">
{groupInvitations.length > 0 && (
<div>
<h3 className="text-sm font-bold text-muted-foreground px-2 mb-2 flex items-center gap-2"><Users className="h-4 w-4" /> {t('groupInvitations')}</h3>
<div className="space-y-1">
{groupInvitations.map(inv => (
<GroupInvitationItem key={inv.groupId} invitation={inv} onAccept={acceptGroupInvitation} onDecline={declineGroupInvitation} />
))}
</div>
</div>
)}
{chatRequests.length > 0 && (
<div>
<h3 className="text-sm font-bold text-muted-foreground px-2 mb-2 flex items-center gap-2"><Mail className="h-4 w-4" /> {t('chatRequests')}</h3>
<div className="space-y-1">
{chatRequests.map(req => (
<ChatRequestItem key={req.fromUid} request={req} onAccept={acceptChatRequest} onDecline={declineChatRequest} />
))}
</div>
</div>
)}
</div>
) : (
<p className="text-center text-sm text-muted-foreground p-4">{t('noNewRequests')}</p>
)}
</TabsContent>
</div>
</Tabs>
</div>
</div>
);
}