Skip to main content
The Friends System allows users to connect with each other, manage friend relationships, send messages, and see real-time online status and activity.

Overview

StreamVault’s social features are powered by Google Drive authentication and a backend social service:
  • Google OAuth: Authentication via Google Drive integration
  • Friend Requests: Send and accept friend requests
  • Online Status: Real-time presence tracking
  • Profile Management: Custom display names and avatars
  • Privacy Controls: Granular control over activity sharing

Authentication

Social features require Google Drive authentication.

Connect Google Drive

src/components/Social/SocialView.tsx
const handleConnect = async () => {
  try {
    // Start OAuth flow - opens browser
    const authUrl = await invoke('gdrive_start_auth');
    
    // Wait for OAuth completion
    const accountInfo = await invoke('gdrive_complete_auth');
    
    // Get access token for social features
    const accessToken = await invoke('gdrive_get_access_token');
    
    // Initialize social connection
    await connectSocial(accessToken);
    
    setIsConnected(true);
  } catch (error) {
    console.error('Failed to connect:', error);
  }
};

Backend OAuth Flow

src-tauri/src/gdrive.rs
pub async fn wait_for_oauth_callback() -> Result<TokenResponse, String> {
    // Start local server to receive OAuth callback
    let listener = TcpListener::bind("127.0.0.1:5476")?;
    
    // Wait for callback with tokens
    let (stream, _) = listener.accept()?;
    let mut reader = BufReader::new(stream);
    
    // Parse tokens from query params
    let tokens: TokenResponse = parse_callback_tokens(&mut reader)?;
    
    Ok(tokens)
}
The backend proxy handles token exchange, so client secrets never touch the frontend.
Search for users by display name or email.
src/components/Social/FriendSearch.tsx
const handleSearch = async (value: string) => {
  setQuery(value);
  if (value.trim().length < 2) {
    setSearchResults([]);
    return;
  }
  
  setLoading(true);
  try {
    const data = await searchUsers(value);
    // Filter out already-friends
    setSearchResults(data.filter(u => !excludeIds.includes(u.id)));
  } catch (error) {
    console.error('Search failed:', error);
  } finally {
    setLoading(false);
  }
};

API Implementation

src/services/social.ts
export async function searchUsers(query: string): Promise<SearchResult[]> {
  const response = await fetch(`${API_BASE}/social/users/search?q=${encodeURIComponent(query)}`, {
    headers: {
      'Authorization': `Bearer ${accessToken}`,
    },
  });
  
  if (!response.ok) {
    throw new Error('Search failed');
  }
  
  const data = await response.json();
  return data.users;
}

Friend Requests

Sending Requests

const handleAddFriend = async (userId: string, name: string) => {
  try {
    await sendFriendRequest(userId);
    setPendingRequests(prev => [...prev, userId]);
    toast({
      title: "Request Sent",
      description: `Friend request sent to ${name}`,
    });
  } catch (error) {
    toast({
      title: "Error",
      description: "Failed to send friend request",
      variant: "destructive",
    });
  }
};
export async function sendFriendRequest(userId: string): Promise<void> {
  const response = await fetch(`${API_BASE}/social/friends/request`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${accessToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ targetUserId: userId }),
  });
  
  if (!response.ok) {
    throw new Error('Failed to send friend request');
  }
}

Accepting Requests

src/components/Social/FriendRequests.tsx
const handleAccept = async (requestId: string, fromUserId: string) => {
  try {
    await acceptFriendRequest(requestId);
    setRequests(prev => prev.filter(r => r.id !== requestId));
    toast({
      title: "Friend Added",
      description: "You are now friends!",
    });
    onRequestsChange();
  } catch (error) {
    toast({
      title: "Error",
      description: "Failed to accept request",
      variant: "destructive",
    });
  }
};

Declining Requests

const handleDecline = async (requestId: string) => {
  try {
    await declineFriendRequest(requestId);
    setRequests(prev => prev.filter(r => r.id !== requestId));
  } catch (error) {
    console.error('Failed to decline:', error);
  }
};

Friends List

Display friends with online status and current activity.
src/components/Social/FriendsList.tsx
export function FriendsList({ 
  friends, 
  onlineFriends, 
  onOpenChat, 
  onViewProfile 
}: FriendsListProps) {
  // Sort: Online first, then by name
  const sortedFriends = [...friends].sort((a, b) => {
    const aOnline = onlineFriends.includes(a.id);
    const bOnline = onlineFriends.includes(b.id);
    if (aOnline && !bOnline) return -1;
    if (!aOnline && bOnline) return 1;
    return a.name.localeCompare(b.name);
  });
  
  return (
    <div className="flex flex-col gap-1 p-2">
      {sortedFriends.map((friend) => {
        const isOnline = onlineFriends.includes(friend.id);
        return (
          <div key={friend.id} className="flex items-center gap-3 p-2 rounded-xl hover:bg-zinc-800/50 group">
            {/* Avatar with online indicator */}
            <div className="relative">
              <div className="w-10 h-10 rounded-full bg-zinc-800 overflow-hidden">
                {friend.avatar ? (
                  <img src={friend.avatar} alt={friend.name} />
                ) : (
                  <div className="flex items-center justify-center text-zinc-500">
                    {friend.name.charAt(0).toUpperCase()}
                  </div>
                )}
              </div>
              <div className={cn(
                "absolute bottom-0 right-0 w-3 h-3 rounded-full border-2 border-zinc-900",
                isOnline ? "bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.5)]" : "bg-zinc-600"
              )} />
            </div>
            
            {/* Name and status */}
            <div className="flex-1 min-w-0">
              <p className="font-semibold text-sm truncate">{friend.name}</p>
              {friend.currentlyWatching ? (
                <div className="flex items-center gap-1 text-xs text-purple-400">
                  {friend.currentlyWatching.contentType === 'movie' ? (
                    <Film className="w-3 h-3" />
                  ) : (
                    <Tv className="w-3 h-3" />
                  )}
                  <span>Watching {friend.currentlyWatching.title}</span>
                </div>
              ) : (
                <p className={cn(
                  "text-xs",
                  isOnline ? "text-green-500" : "text-zinc-500"
                )}>
                  {isOnline ? 'Online' : 'Offline'}
                </p>
              )}
            </div>
            
            {/* Chat button */}
            <Button
              size="icon"
              variant="ghost"
              className="opacity-0 group-hover:opacity-100"
              onClick={() => onOpenChat(friend)}
            >
              <MessageCircle className="w-4 h-4" />
            </Button>
          </div>
        );
      })}
    </div>
  );
}

Real-Time Events

WebSocket events for live updates.

Subscribe to Events

export function onSocialEvent(
  eventType: 'friend_request' | 'friend_accepted' | 'friend_online' | 'friend_offline' | 'currently_watching',
  callback: (data: any) => void
): () => void {
  if (!ws || ws.readyState !== WebSocket.OPEN) {
    console.warn('[Social] WebSocket not connected');
    return () => {};
  }
  
  const listeners = eventListeners.get(eventType) || [];
  listeners.push(callback);
  eventListeners.set(eventType, listeners);
  
  return () => {
    const idx = listeners.indexOf(callback);
    if (idx > -1) listeners.splice(idx, 1);
  };
}

Handle Events

useEffect(() => {
  const unsubFriendRequest = onSocialEvent('friend_request', (data) => {
    setRequests(prev => [data.request, ...prev]);
    toast({
      title: "New Friend Request",
      description: `${data.request.fromUserName} wants to be friends`,
    });
  });
  
  const unsubOnline = onSocialEvent('friend_online', (data) => {
    setOnlineFriends(prev => [...prev, data.userId]);
  });
  
  const unsubOffline = onSocialEvent('friend_offline', (data) => {
    setOnlineFriends(prev => prev.filter(id => id !== data.userId));
  });
  
  return () => {
    unsubFriendRequest();
    unsubOnline();
    unsubOffline();
  };
}, []);

Profile Management

View Profile

src/components/Social/UserProfileModal.tsx
export function UserProfileModal({ 
  userId, 
  isOpen, 
  onClose 
}: UserProfileModalProps) {
  const [profile, setProfile] = useState<UserProfile | null>(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    if (isOpen && userId) {
      loadProfile();
    }
  }, [isOpen, userId]);
  
  const loadProfile = async () => {
    try {
      setLoading(true);
      const data = await getUserProfile(userId);
      setProfile(data);
    } catch (error) {
      console.error('Failed to load profile:', error);
    } finally {
      setLoading(false);
    }
  };
  
  return (
    <Dialog open={isOpen} onOpenChange={onClose}>
      <DialogContent>
        {loading ? (
          <div className="flex justify-center py-8">
            <Loader2 className="w-8 h-8 animate-spin" />
          </div>
        ) : profile ? (
          <div className="space-y-4">
            {/* Avatar */}
            <div className="flex justify-center">
              <div className="w-24 h-24 rounded-full bg-zinc-800 overflow-hidden">
                {profile.avatar ? (
                  <img src={profile.avatar} alt={profile.displayName} />
                ) : (
                  <div className="flex items-center justify-center h-full text-3xl">
                    {profile.displayName.charAt(0).toUpperCase()}
                  </div>
                )}
              </div>
            </div>
            
            {/* Info */}
            <div className="text-center">
              <h2 className="text-xl font-bold">{profile.displayName}</h2>
              <p className="text-sm text-zinc-500">{profile.email}</p>
            </div>
            
            {/* Stats */}
            <div className="grid grid-cols-3 gap-4 p-4 bg-zinc-800/50 rounded-lg">
              <div className="text-center">
                <p className="text-2xl font-bold text-purple-400">{profile.stats.totalWatched}</p>
                <p className="text-xs text-zinc-500">Watched</p>
              </div>
              <div className="text-center">
                <p className="text-2xl font-bold text-purple-400">{profile.stats.friends}</p>
                <p className="text-xs text-zinc-500">Friends</p>
              </div>
              <div className="text-center">
                <p className="text-2xl font-bold text-purple-400">{profile.stats.hoursWatched}</p>
                <p className="text-xs text-zinc-500">Hours</p>
              </div>
            </div>
            
            {/* Recent Activity */}
            {profile.recentActivity && profile.recentActivity.length > 0 && (
              <div>
                <h3 className="font-semibold mb-2">Recent Activity</h3>
                <div className="space-y-2">
                  {profile.recentActivity.map((activity) => (
                    <div key={activity.id} className="flex items-center gap-2 p-2 bg-zinc-800/30 rounded">
                      {activity.contentType === 'movie' ? <Film className="w-4 h-4" /> : <Tv className="w-4 h-4" />}
                      <span className="text-sm">{activity.title}</span>
                    </div>
                  ))}
                </div>
              </div>
            )}
          </div>
        ) : (
          <p className="text-center text-zinc-500">Profile not found</p>
        )}
      </DialogContent>
    </Dialog>
  );
}

Edit Profile

src/components/Social/ProfileEditor.tsx
const handleSave = async () => {
  try {
    await updateProfile({
      displayName: displayName.trim(),
      avatarUrl: avatarUrl?.trim() || null,
    });
    
    toast({
      title: "Profile Updated",
      description: "Your profile has been saved",
    });
    
    onSave();
  } catch (error) {
    toast({
      title: "Error",
      description: "Failed to update profile",
      variant: "destructive",
    });
  }
};

Privacy Settings

Control activity visibility and who can send friend requests.
src/components/Social/PrivacySettings.tsx
export function PrivacySettings() {
  const [settings, setSettings] = useState({
    showActivity: true,
    showCurrentlyWatching: true,
    allowFriendRequests: true,
  });
  
  const handleToggle = async (key: keyof typeof settings) => {
    const newValue = !settings[key];
    setSettings(prev => ({ ...prev, [key]: newValue }));
    
    try {
      await updatePrivacySettings({ [key]: newValue });
    } catch (error) {
      // Revert on error
      setSettings(prev => ({ ...prev, [key]: !newValue }));
      toast({
        title: "Error",
        description: "Failed to update privacy settings",
        variant: "destructive",
      });
    }
  };
  
  return (
    <div className="space-y-4">
      <div className="flex items-center justify-between">
        <div>
          <p className="font-medium">Show Watch Activity</p>
          <p className="text-sm text-zinc-500">Let friends see what you've watched</p>
        </div>
        <Switch
          checked={settings.showActivity}
          onCheckedChange={() => handleToggle('showActivity')}
        />
      </div>
      
      <div className="flex items-center justify-between">
        <div>
          <p className="font-medium">Show Currently Watching</p>
          <p className="text-sm text-zinc-500">Show real-time watching status</p>
        </div>
        <Switch
          checked={settings.showCurrentlyWatching}
          onCheckedChange={() => handleToggle('showCurrentlyWatching')}
        />
      </div>
      
      <div className="flex items-center justify-between">
        <div>
          <p className="font-medium">Allow Friend Requests</p>
          <p className="text-sm text-zinc-500">Let others send you friend requests</p>
        </div>
        <Switch
          checked={settings.allowFriendRequests}
          onCheckedChange={() => handleToggle('allowFriendRequests')}
        />
      </div>
    </div>
  );
}

Chat Integration

Direct messaging between friends.
src/components/Social/ChatWindow.tsx
const handleSend = async () => {
  if (!message.trim() || !friend) return;
  
  try {
    await sendChatMessage(friend.id, message.trim());
    setMessages(prev => [...prev, {
      id: Date.now().toString(),
      fromUserId: currentUserId,
      toUserId: friend.id,
      content: message.trim(),
      timestamp: new Date().toISOString(),
      read: false,
    }]);
    setMessage('');
  } catch (error) {
    toast({
      title: "Error",
      description: "Failed to send message",
      variant: "destructive",
    });
  }
};
export async function sendChatMessage(toUserId: string, content: string): Promise<void> {
  const response = await fetch(`${API_BASE}/social/chat/send`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${accessToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ toUserId, content }),
  });
  
  if (!response.ok) {
    throw new Error('Failed to send message');
  }
}

Data Sync

Watch stats are synced to enable social features.

Upload Watch Stats

src-tauri/src/database.rs
pub fn get_watch_stats(&self) -> Result<WatchStatsAggregated> {
    let mut stmt = self.conn.prepare("
        SELECT 
            COUNT(DISTINCT CASE WHEN media_type = 'movie' THEN id END) as movies_watched,
            COUNT(DISTINCT CASE WHEN media_type = 'tvepisode' THEN id END) as episodes_watched,
            COALESCE(SUM(CASE WHEN duration_seconds IS NOT NULL THEN duration_seconds ELSE 0 END), 0) as total_watch_time
        FROM media
        WHERE last_watched IS NOT NULL
    ")?;
    
    let stats = stmt.query_row([], |row| {
        Ok(WatchStatsAggregated {
            movies_watched: row.get(0)?,
            episodes_watched: row.get(1)?,
            total_watch_time: row.get(2)?,
        })
    })?;
    
    Ok(stats)
}

Tauri Command

#[tauri::command]
async fn get_watch_stats(
    state: State<'_, AppState>,
) -> Result<WatchStatsAggregated, String> {
    let db = state.db.lock().map_err(|e| e.to_string())?;
    db.get_watch_stats().map_err(|e| e.to_string())
}

API Reference

Core Functions

export async function connectSocial(accessToken: string): Promise<void>

Best Practices

  1. Token Management: Refresh tokens before expiration
  2. WebSocket Reconnection: Implement exponential backoff
  3. Offline Handling: Cache friend list for offline viewing
  4. Error Boundaries: Wrap social components in error boundaries
  5. Rate Limiting: Throttle search requests

Watch Together

Invite friends to synchronized watch sessions

Activity Feed

See what friends are watching

Build docs developers (and LLMs) love