Skip to main content
The FriendsPanel component provides a slide-out panel for managing social connections, including friends list, pending requests, and user search functionality.

Overview

This panel appears as a fixed sidebar on the right side of the screen, offering three tabs:
  • Friends: View all friends with online status and activity
  • Requests: Manage incoming friend requests
  • Add: Search for and send friend requests to new users
Source: src/components/Social/FriendsPanel.tsx:20-411

Props

PropTypeDescription
isOpenbooleanControls panel visibility
onClose() => voidCallback when panel is closed
onOpenChat(friend: Friend) => voidOpens chat window with friend
onViewProfile(friendId: string) => voidOpens friend profile view

State Management

Core State

const [friends, setFriends] = useState<Friend[]>([]);
const [onlineFriends, setOnlineFriends] = useState<Friend[]>([]);
const [requests, setRequests] = useState<FriendRequest[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [activeTab, setActiveTab] = useState<'friends' | 'requests' | 'add'>('friends');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
Source: src/components/Social/FriendsPanel.tsx:28-36

Tab State

Three tabs control the active view:
  • friends - Display all friends with online status
  • requests - Show pending friend requests
  • add - Search and add new friends

WebSocket Events

The component subscribes to real-time social events:

friend_online

const unsubOnline = onSocialEvent('friend_online', (data) => {
  setOnlineFriends(prev => {
    const friend = friends.find(f => f.id === data.userId);
    if (friend && !prev.some(f => f.id === data.userId)) {
      return [...prev, friend];
    }
    return prev;
  });
});
Source: src/components/Social/FriendsPanel.tsx:47-55

friend_offline

const unsubOffline = onSocialEvent('friend_offline', (data) => {
  setOnlineFriends(prev => prev.filter(f => f.id !== data.userId));
});
Source: src/components/Social/FriendsPanel.tsx:57-59

currently_watching

const unsubWatching = onSocialEvent('currently_watching', (data) => {
  const userId = typeof data.userId === 'string' ? data.userId : '';
  if (!userId) return;
  
  const currentlyWatching = data.content as Friend['currentlyWatching'];
  setOnlineFriends(prev => prev.map(friend => (
    friend.id === userId
      ? { ...friend, currentlyWatching }
      : friend
  )));
});
Source: src/components/Social/FriendsPanel.tsx:61-70

friend_request / friend_accepted

const unsubRequest = onSocialEvent('friend_request', () => {
  loadRequests();
});

const unsubAccepted = onSocialEvent('friend_accepted', () => {
  loadFriends();
});
Source: src/components/Social/FriendsPanel.tsx:72-78

Data Loading

Load Friends and Requests

const loadFriendsAndRequests = async () => {
  try {
    setLoading(true);
    setError(null);
    await Promise.all([loadFriends(), loadRequests()]);
  } catch (err) {
    setError('Failed to load friends data. Please try again later.');
    console.error('Failed to load friends data:', err);
  } finally {
    setLoading(false);
  }
};
Source: src/components/Social/FriendsPanel.tsx:89-100

Load Friends

const loadFriends = async () => {
  try {
    const data = await getFriends();
    setFriends(data.friends);
    setOnlineFriends(data.online);
  } catch (error) {
    console.error('Failed to load friends:', error);
    throw error;
  }
};
Source: src/components/Social/FriendsPanel.tsx:102-111

Load Requests

const loadRequests = async () => {
  try {
    const data = await getPendingRequests();
    setRequests(data);
  } catch (error) {
    console.error('Failed to load requests:', error);
    throw error;
  }
};
Source: src/components/Social/FriendsPanel.tsx:113-121 Search functionality with minimum 2-character query:
const handleSearch = async (query: string) => {
  setSearchQuery(query);
  if (query.length < 2) {
    setSearchResults([]);
    return;
  }

  setIsSearching(true);
  try {
    const results = await searchUsers(query);
    // Filter out existing friends
    setSearchResults(results.filter(r => !friends.some(f => f.id === r.id)));
  } catch (error) {
    console.error('Search failed:', error);
  } finally {
    setIsSearching(false);
  }
};
Source: src/components/Social/FriendsPanel.tsx:123-139

Friend Request Management

Send Request

const handleSendRequest = async (userId: string) => {
  try {
    await sendFriendRequest(userId);
    setSearchResults(prev => prev.filter(r => r.id !== userId));
    loadRequests(); // Reload to show new pending request
  } catch (error) {
    console.error('Failed to send request:', error);
  }
};
Source: src/components/Social/FriendsPanel.tsx:141-150

Accept Request

const handleAcceptRequest = async (fromId: string) => {
  try {
    await acceptFriendRequest(fromId);
    setRequests(prev => prev.filter(r => r.fromId !== fromId));
    loadFriends();
  } catch (error) {
    console.error('Failed to accept request:', error);
  }
};
Source: src/components/Social/FriendsPanel.tsx:152-160

Reject Request

const handleRejectRequest = async (fromId: string) => {
  try {
    await rejectFriendRequest(fromId);
    setRequests(prev => prev.filter(r => r.fromId !== fromId));
  } catch (error) {
    console.error('Failed to reject request:', error);
  }
};
Source: src/components/Social/FriendsPanel.tsx:162-169

UI Layout

Panel Structure

Fixed right sidebar with slide-in animation:
<motion.div
  initial={{ x: 300, opacity: 0 }}
  animate={{ x: 0, opacity: 1 }}
  exit={{ x: 300, opacity: 0 }}
  className="fixed right-0 top-0 h-full w-80 bg-zinc-900 border-l border-zinc-800 z-50 flex flex-col"
>
Source: src/components/Social/FriendsPanel.tsx:179-184 Displays panel title with request badge:
<div className="flex items-center gap-2">
  <Users className="w-5 h-5 text-purple-500" />
  <h2 className="font-semibold">Friends</h2>
  {requests.length > 0 && (
    <span className="bg-purple-500 text-white text-xs px-2 py-0.5 rounded-full">
      {requests.length}
    </span>
  )}
</div>
Source: src/components/Social/FriendsPanel.tsx:187-195

Tab Navigation

Three-tab layout with request count indicator:
<div className="flex border-b border-zinc-800">
  <button onClick={() => setActiveTab('friends')} className={...}>
    Friends ({friends.length})
  </button>
  <button onClick={() => setActiveTab('requests')} className={...}>
    Requests
    {requests.length > 0 && (
      <span className="absolute -top-1 right-4 bg-red-500 ...">
        {requests.length}
      </span>
    )}
  </button>
  <button onClick={() => setActiveTab('add')} className={...}>
    <UserPlus className="w-4 h-4 mx-auto" />
  </button>
</div>
Source: src/components/Social/FriendsPanel.tsx:202-232

Friends Tab

Displays friends with online status separation:

Online Friends Section

{onlineFriends.length > 0 && (
  <div className="mb-4">
    <h3 className="text-xs font-semibold text-zinc-500 uppercase px-2 mb-2">
      Online ({onlineFriends.length})
    </h3>
    {onlineFriends.map(friend => (
      <FriendItem
        key={friend.id}
        friend={friend}
        isOnline={true}
        onChat={() => onOpenChat(friend)}
        onViewProfile={() => onViewProfile(friend.id)}
      />
    ))}
  </div>
)}
Source: src/components/Social/FriendsPanel.tsx:252-266

All Friends Section

<div>
  <h3 className="text-xs font-semibold text-zinc-500 uppercase px-2 mb-2">
    All Friends
  </h3>
  {friends.map(friend => (
    <FriendItem
      key={friend.id}
      friend={friend}
      isOnline={onlineFriends.some(f => f.id === friend.id)}
      onChat={() => onOpenChat(friend)}
      onViewProfile={() => onViewProfile(friend.id)}
    />
  ))}
</div>
Source: src/components/Social/FriendsPanel.tsx:270-293

Requests Tab

Displays pending friend requests with accept/reject buttons:
{requests.map(request => (
  <div key={request.fromId} className="flex items-center gap-3 p-3 rounded-lg hover:bg-zinc-800/50">
    <div className="w-10 h-10 rounded-full bg-zinc-700 overflow-hidden">
      {/* Avatar display */}
    </div>
    <div className="flex-1 min-w-0">
      <p className="font-medium truncate">{request.fromName}</p>
      <p className="text-xs text-zinc-500">{formatRelativeTime(request.sentAt)}</p>
    </div>
    <div className="flex gap-1">
      <Button onClick={() => handleAcceptRequest(request.fromId)} className="text-green-500">
        <Check className="w-4 h-4" />
      </Button>
      <Button onClick={() => handleRejectRequest(request.fromId)} className="text-red-500">
        <X className="w-4 h-4" />
      </Button>
    </div>
  </div>
))}
Source: src/components/Social/FriendsPanel.tsx:307-344

Add Friends Tab

Search interface with user results:

Search Input

<div className="relative mb-4">
  <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
  <Input
    placeholder="Search by name or email..."
    value={searchQuery}
    onChange={(e) => handleSearch(e.target.value)}
    className="pl-10 bg-zinc-800 border-zinc-700"
  />
</div>
Source: src/components/Social/FriendsPanel.tsx:351-359

Search Results

{searchResults.map(user => (
  <div key={user.id} className="flex items-center gap-3 p-3 rounded-lg bg-zinc-800/50">
    <div className="w-10 h-10 rounded-full bg-zinc-700 overflow-hidden">
      {/* Avatar */}
    </div>
    <div className="flex-1 min-w-0">
      <p className="font-medium truncate">{user.displayName}</p>
    </div>
    <Button
      size="sm"
      onClick={() => handleSendRequest(user.id)}
      className="bg-purple-600 hover:bg-purple-700"
    >
      <UserPlus className="w-4 h-4 mr-1" />
      Add
    </Button>
  </div>
))}
Source: src/components/Social/FriendsPanel.tsx:365-390

FriendItem Component

Individual friend list item with online status and activity:
function FriendItem({ friend, isOnline, onChat, onViewProfile }: FriendItemProps) {
  return (
    <div className="flex items-center gap-3 p-2 rounded-lg hover:bg-zinc-800/50 group">
      <div className="relative cursor-pointer" onClick={onViewProfile}>
        <div className="w-10 h-10 rounded-full bg-zinc-700 overflow-hidden">
          {/* Avatar */}
        </div>
        {isOnline && (
          <div className="absolute bottom-0 right-0 w-3 h-3 bg-green-500 rounded-full border-2 border-zinc-900" />
        )}
      </div>

      <div className="flex-1 min-w-0 cursor-pointer" onClick={onViewProfile}>
        <p className="font-medium truncate">{friend.name}</p>
        {friend.currentlyWatching ? (
          <div className="flex items-center gap-1 text-xs text-purple-400">
            {friend.currentlyWatching.contentType === 'movie' ? <Film /> : <Tv />}
            <span className="truncate">Watching {friend.currentlyWatching.title}</span>
          </div>
        ) : isOnline ? (
          <p className="text-xs text-green-500">Online</p>
        ) : (
          <p className="text-xs text-zinc-500">Offline</p>
        )}
      </div>

      <Button
        size="icon"
        variant="ghost"
        className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
        onClick={onChat}
      >
        <MessageCircle className="w-4 h-4" />
      </Button>
    </div>
  );
}
Source: src/components/Social/FriendsPanel.tsx:420-466

Error Handling

Displays error state with retry option:
{error ? (
  <div className="flex flex-col items-center justify-center py-12 text-center p-4">
    <AlertCircle className="w-12 h-12 text-red-500 mb-4" />
    <p className="text-red-400 font-medium mb-2">Failed to load friends</p>
    <p className="text-zinc-500 text-sm mb-4">{error}</p>
    <Button 
      variant="outline" 
      onClick={retryLoad}
      className="border-zinc-700"
    >
      Retry
    </Button>
  </div>
) : (
  // Normal content
)}
Source: src/components/Social/FriendsPanel.tsx:236-248

API Functions

  • getFriends() - Retrieves friends list with online status
  • getPendingRequests() - Retrieves incoming friend requests
  • searchUsers(query) - Searches for users by name or email
  • sendFriendRequest(userId) - Sends friend request to user
  • acceptFriendRequest(fromId) - Accepts incoming friend request
  • rejectFriendRequest(fromId) - Rejects incoming friend request
  • onSocialEvent(eventType, handler) - Subscribes to WebSocket events
  • formatRelativeTime(timestamp) - Formats timestamp as relative time

Build docs developers (and LLMs) love