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.
Friend Search
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
- Token Management: Refresh tokens before expiration
- WebSocket Reconnection: Implement exponential backoff
- Offline Handling: Cache friend list for offline viewing
- Error Boundaries: Wrap social components in error boundaries
- Rate Limiting: Throttle search requests
Related Resources
Watch Together
Invite friends to synchronized watch sessions
Activity Feed
See what friends are watching