Overview
The activity system tracks and shares:- Currently Watching: Real-time status when friends are watching content
- Completed Content: Watch history shared with friends
- Activity Filters: Filter by content type and genre
- Privacy Controls: Granular control over what’s shared
- Infinite Scroll: Paginated activity loading
Currently Watching
Real-time status updates when friends start watching.Update Status
src/services/social.ts
export async function updateCurrentlyWatching(
contentId: string,
contentType: 'movie' | 'tv',
title: string,
season?: number,
episode?: number
): Promise<void> {
const response = await fetch(`${API_BASE}/social/currently-watching`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
contentId,
contentType,
title,
season,
episode,
}),
});
if (!response.ok) {
throw new Error('Failed to update currently watching');
}
}
Clear Status
export async function clearCurrentlyWatching(): Promise<void> {
const response = await fetch(`${API_BASE}/social/currently-watching`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error('Failed to clear currently watching');
}
}
Get Friends Watching
export async function getFriendsWatching(): Promise<CurrentlyWatching[]> {
const response = await fetch(`${API_BASE}/social/friends/watching`, {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error('Failed to fetch watching status');
}
const data = await response.json();
return data.watching;
}
Activity Feed Component
The main feed with filtering and infinite scroll.src/components/Social/ActivityFeed.tsx
export function ActivityFeed({ onViewProfile, onReconnect }: ActivityFeedProps) {
const [activities, setActivities] = useState<Activity[]>([]);
const [watching, setWatching] = useState<CurrentlyWatching[]>([]);
const [loading, setLoading] = useState(true);
const [contentTypeFilter, setContentTypeFilter] = useState<'all' | 'movie' | 'tv'>('all');
const [genreFilter, setGenreFilter] = useState<string>('all');
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
useEffect(() => {
loadData();
// Listen for real-time updates
const unsubActivity = onSocialEvent('friend_activity', (data) => {
setActivities(prev => [data.activity, ...prev].slice(0, 50));
});
const unsubWatching = onSocialEvent('currently_watching', () => {
loadWatching();
});
return () => {
unsubActivity();
unsubWatching();
};
}, []);
useEffect(() => {
// Reset pagination when filters change
setPage(1);
setHasMore(true);
loadActivities(true);
}, [contentTypeFilter, genreFilter]);
const loadActivities = async (reset = false) => {
try {
const filters: ActivityFilters = {
page: reset ? 1 : page
};
if (contentTypeFilter !== 'all') filters.contentType = contentTypeFilter;
if (genreFilter !== 'all') filters.genre = genreFilter;
const data = await getFriendsActivity(filters);
if (reset) {
setActivities(data);
} else {
setActivities(prev => [...prev, ...data]);
}
setHasMore(data.length > 0);
} catch (error) {
console.error('Failed to load activities:', error);
}
};
return (
<div className="h-full flex flex-col">
{/* Currently Watching Section */}
{watching.length > 0 && (
<div className="p-4 border-b border-zinc-800">
<h3 className="text-sm font-semibold text-zinc-400 mb-3 flex items-center gap-2">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
Friends Watching Now
</h3>
<div className="flex gap-3 overflow-x-auto">
{watching.map((item) => (
<CurrentlyWatchingCard key={item.userId} item={item} onViewProfile={onViewProfile} />
))}
</div>
</div>
)}
{/* Filters */}
<ActivityFilters
contentTypeFilter={contentTypeFilter}
setContentTypeFilter={setContentTypeFilter}
genreFilter={genreFilter}
setGenreFilter={setGenreFilter}
genres={genres}
/>
{/* Activity List */}
<ScrollArea className="flex-1">
<div className="p-4 space-y-3">
{activities.map((activity) => (
<ActivityItem key={activity.id} activity={activity} onViewProfile={onViewProfile} />
))}
{/* Infinite scroll sentinel */}
<div ref={sentinelRef} className="h-1" />
{isLoadingMore && (
<div className="flex justify-center py-4">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
)}
</div>
</ScrollArea>
</div>
);
}
Currently Watching Card
function CurrentlyWatchingCard({ item, onViewProfile }: CurrentlyWatchingCardProps) {
return (
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="flex-shrink-0 w-40 bg-zinc-800/50 rounded-lg p-3 cursor-pointer hover:bg-zinc-800"
onClick={() => onViewProfile(item.userId)}
>
<div className="flex items-center gap-2 mb-2">
<div className="w-6 h-6 rounded-full bg-zinc-700 overflow-hidden">
{item.userAvatar ? (
<img src={item.userAvatar} alt="" className="w-full h-full object-cover" />
) : (
<User className="w-full h-full p-1 text-zinc-500" />
)}
</div>
<span className="text-xs font-medium truncate">{item.userName}</span>
</div>
<div className="flex items-center gap-1 text-xs text-purple-400 mb-1">
{item.contentType === 'movie' ? (
<Film className="w-3 h-3" />
) : (
<Tv className="w-3 h-3" />
)}
<span>Watching</span>
</div>
<p className="text-sm font-medium truncate">{item.title}</p>
{item.season && item.episode && (
<p className="text-xs text-zinc-500">S{item.season} E{item.episode}</p>
)}
</motion.div>
);
}
Activity Item
function ActivityItem({ activity, onViewProfile }: ActivityItemProps) {
const isMovie = activity.contentType === 'movie';
return (
<motion.div
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
className="flex gap-3 p-3 bg-zinc-800/30 rounded-lg hover:bg-zinc-800/50"
>
{/* Poster */}
<div className="w-16 h-24 bg-zinc-800 rounded overflow-hidden">
{activity.posterPath ? (
<img
src={`https://image.tmdb.org/t/p/w92${activity.posterPath}`}
alt=""
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
{isMovie ? <Film className="w-6 h-6 text-zinc-600" /> : <Tv className="w-6 h-6 text-zinc-600" />}
</div>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<div
className="flex items-center gap-1.5 cursor-pointer hover:text-purple-400"
onClick={() => onViewProfile(activity.userId)}
>
<div className="w-5 h-5 rounded-full bg-zinc-700 overflow-hidden">
{activity.userAvatar ? (
<img src={activity.userAvatar} alt="" />
) : (
<User className="w-full h-full p-0.5" />
)}
</div>
<span className="text-sm font-medium">{activity.userName}</span>
</div>
<span className="text-xs text-zinc-500">watched</span>
</div>
<p className="font-medium truncate">{activity.title}</p>
{activity.season && activity.episode && (
<p className="text-sm text-zinc-400">
Season {activity.season}, Episode {activity.episode}
</p>
)}
<div className="flex items-center gap-2 mt-2">
<span className={`text-xs px-2 py-0.5 rounded ${
isMovie ? 'bg-blue-500/20 text-blue-400' : 'bg-purple-500/20 text-purple-400'
}`}>
{isMovie ? 'Movie' : 'TV Show'}
</span>
{activity.genres?.slice(0, 2).map(genre => (
<span key={genre} className="text-xs px-2 py-0.5 rounded bg-zinc-700">
{genre}
</span>
))}
<span className="text-xs text-zinc-500 ml-auto">
{formatRelativeTime(activity.timestamp)}
</span>
</div>
</div>
</motion.div>
);
}
Activity Filters
function ActivityFilters({
contentTypeFilter,
setContentTypeFilter,
genreFilter,
setGenreFilter,
genres
}: ActivityFiltersProps) {
return (
<div className="flex items-center gap-2 p-4 border-b border-zinc-800 flex-wrap">
<Filter className="w-4 h-4 text-zinc-500" />
{/* Content Type */}
<div className="flex rounded-lg bg-zinc-800 p-1">
<button
onClick={() => setContentTypeFilter('all')}
className={`px-3 py-1 text-xs font-medium rounded ${
contentTypeFilter === 'all' ? 'bg-purple-600 text-white' : 'text-zinc-400'
}`}
>
All
</button>
<button
onClick={() => setContentTypeFilter('movie')}
className={`px-3 py-1 text-xs font-medium rounded flex items-center gap-1 ${
contentTypeFilter === 'movie' ? 'bg-purple-600 text-white' : 'text-zinc-400'
}`}
>
<Film className="w-3 h-3" />
Movies
</button>
<button
onClick={() => setContentTypeFilter('tv')}
className={`px-3 py-1 text-xs font-medium rounded flex items-center gap-1 ${
contentTypeFilter === 'tv' ? 'bg-purple-600 text-white' : 'text-zinc-400'
}`}
>
<Tv className="w-3 h-3" />
TV Shows
</button>
</div>
{/* Genre Dropdown */}
{genres.length > 0 && (
<div className="relative group">
<Button variant="outline" size="sm" className="h-8 text-xs">
{genreFilter === 'all' ? 'All Genres' : genreFilter}
</Button>
<div className="absolute top-full left-0 mt-1 bg-zinc-800 border border-zinc-700 rounded-lg hidden group-hover:block z-10 min-w-[140px] max-h-48 overflow-y-auto">
<button
onClick={() => setGenreFilter('all')}
className={`w-full px-3 py-2 text-left text-xs hover:bg-zinc-700 ${
genreFilter === 'all' ? 'text-purple-400' : 'text-zinc-300'
}`}
>
All Genres
</button>
{genres.map(genre => (
<button
key={genre}
onClick={() => setGenreFilter(genre)}
className={`w-full px-3 py-2 text-left text-xs hover:bg-zinc-700 ${
genreFilter === genre ? 'text-purple-400' : 'text-zinc-300'
}`}
>
{genre}
</button>
))}
</div>
</div>
)}
{/* Clear Filters */}
{(contentTypeFilter !== 'all' || genreFilter !== 'all') && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setContentTypeFilter('all');
setGenreFilter('all');
}}
>
<X className="w-3 h-3 mr-1" />
Clear
</Button>
)}
</div>
);
}
Infinite Scroll
Load more activities as user scrolls.const sentinelRef = useRef<HTMLDivElement>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
useEffect(() => {
if (!sentinelRef.current) return;
if (observerRef.current) {
observerRef.current.disconnect();
}
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !isLoadingMore) {
loadMore();
}
},
{ threshold: 1.0 }
);
observer.observe(sentinelRef.current);
observerRef.current = observer;
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, [hasMore, isLoadingMore]);
const loadMore = () => {
if (hasMore && !isLoadingMore) {
setPage(prev => prev + 1);
loadActivities();
}
};
Watch Activity Sync
Sync local watch history to social backend.Get Recent Activities
src-tauri/src/database.rs
pub fn get_recent_watch_activities(
&self,
since_timestamp: &str,
) -> Result<Vec<WatchActivityItem>> {
let mut stmt = self.conn.prepare("
SELECT
id,
title,
media_type,
tmdb_id,
season_number,
episode_number,
poster_path,
last_watched,
duration_seconds,
progress_seconds
FROM media
WHERE last_watched IS NOT NULL
AND last_watched > ?
AND progress_seconds >= duration_seconds * 0.9
ORDER BY last_watched DESC
")?;
let activities = stmt.query_map([since_timestamp], |row| {
Ok(WatchActivityItem {
id: row.get(0)?,
title: row.get(1)?,
media_type: row.get(2)?,
tmdb_id: row.get(3)?,
season_number: row.get(4)?,
episode_number: row.get(5)?,
poster_path: row.get(6)?,
last_watched: row.get(7)?,
duration_seconds: row.get(8)?,
progress_seconds: row.get(9)?,
})
})?.collect::<Result<Vec<_>, _>>()?;
Ok(activities)
}
Tauri Command
#[tauri::command]
async fn get_recent_watch_activities(
state: State<'_, AppState>,
since_timestamp: String,
) -> Result<Vec<WatchActivityItem>, String> {
let db = state.db.lock().map_err(|e| e.to_string())?;
db.get_recent_watch_activities(&since_timestamp)
.map_err(|e| e.to_string())
}
Privacy Controls
Users control what activity is shared.Privacy Settings
export interface PrivacySettings {
showActivity: boolean; // Share watch history
showCurrentlyWatching: boolean; // Share real-time status
allowFriendRequests: boolean; // Accept friend requests
}
export async function updatePrivacySettings(settings: Partial<PrivacySettings>): Promise<void> {
const response = await fetch(`${API_BASE}/social/settings/privacy`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(settings),
});
if (!response.ok) {
throw new Error('Failed to update privacy settings');
}
}
Time Formatting
User-friendly relative timestamps.src/services/social.ts
export function formatRelativeTime(timestamp: string): string {
const now = new Date();
const then = new Date(timestamp);
const diffMs = now.getTime() - then.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return 'just now';
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHour < 24) return `${diffHour}h ago`;
if (diffDay < 7) return `${diffDay}d ago`;
if (diffDay < 30) return `${Math.floor(diffDay / 7)}w ago`;
return then.toLocaleDateString();
}
API Reference
export async function getFriendsActivity(
filters?: ActivityFilters
): Promise<Activity[]>
Error Handling
if (error) {
return (
<div className="flex flex-col items-center justify-center py-12">
<AlertCircle className="w-12 h-12 text-red-500 mb-4" />
<p className="text-red-400 font-medium mb-2">
{error.includes('session has expired') ? 'Session Expired' : 'Failed to load activity'}
</p>
<p className="text-zinc-500 text-sm mb-4">{error}</p>
<div className="flex items-center gap-3">
<Button variant="outline" onClick={retryLoad}>
<RefreshCw className="w-4 h-4 mr-2" />
Retry
</Button>
{onReconnect && (
<Button onClick={onReconnect}>
<LogIn className="w-4 h-4 mr-2" />
Reconnect
</Button>
)}
</div>
</div>
);
}
Best Practices
- Lazy Loading: Load activity in pages to reduce initial load time
- Real-Time Updates: Use WebSocket events for instant updates
- Privacy First: Always respect user privacy settings
- Efficient Queries: Filter on backend to minimize data transfer
- Caching: Cache activity locally to improve perceived performance
Related Resources
Watch Together
Watch content in sync with friends
Friends System
Manage friends and connections