| "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> | |
| ); | |
| } | |