Skip to main content
The Activity Feed provides a real-time stream of friend watch activity, currently watching status, and social engagement features. All activity respects user privacy settings.

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

  1. Lazy Loading: Load activity in pages to reduce initial load time
  2. Real-Time Updates: Use WebSocket events for instant updates
  3. Privacy First: Always respect user privacy settings
  4. Efficient Queries: Filter on backend to minimize data transfer
  5. Caching: Cache activity locally to improve perceived performance

Watch Together

Watch content in sync with friends

Friends System

Manage friends and connections

Build docs developers (and LLMs) love