Something went wrong. Try again.
A Bsky-like frontend using the atprotocol natively.
Something went wrong. Try again.
6.9 kB · 187 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188import { useState, useEffect, useCallback } from 'react';import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';import { faXmark } from '@fortawesome/free-solid-svg-icons';import { atprotoClient } from '../api/client';import { useProfileViewMode } from '../settings/useProfileViewMode';import { useProfileViewer } from './useProfileViewer';import PdsFavicon from './PdsFavicon';import { useNavigate } from 'react-router-dom';import { useMuteBlock } from './muteBlockContext';import './LikesRepostsModal.css';
interface MutualFollowersModalProps { actor: string; onClose: () => void;}
interface FollowProfile { did: string; handle: string; displayName?: string; avatar?: string; followsYou?: boolean;}
export default function MutualFollowersModal({ actor, onClose }: MutualFollowersModalProps) { const [profiles, setProfiles] = useState<FollowProfile[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); const [cursor, setCursor] = useState<string | undefined>(); const [loadingMore, setLoadingMore] = useState(false); const { mode: profileViewMode } = useProfileViewMode(); const { open: openProfile } = useProfileViewer(); const navigate = useNavigate(); const { isBlocked, isMuted } = useMuteBlock();
useEffect(() => { document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = ''; }; }, []);
useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [onClose]);
// Fetch all following of the logged-in user, then filter followers to mutuals useEffect(() => { let cancelled = false;
(async () => { try { // 1. Get the logged-in user's following list (all of them) const myFollowingDids = new Set<string>(); let followingCursor: string | undefined; do { const resp = await atprotoClient.getFollows( atprotoClient.api!.did!, 100, followingCursor, ); for (const f of resp.follows) { if (f.did) myFollowingDids.add(f.did); } followingCursor = resp.cursor; } while (followingCursor && !cancelled);
if (cancelled) return;
// 2. Get the target's followers const resp = await atprotoClient.getFollowers(actor); if (!cancelled) { // Filter to only mutual followers (people you follow) const mutuals = (resp.followers as FollowProfile[]).filter( (f) => myFollowingDids.has(f.did), ); setProfiles(mutuals); // We don't paginate the filtered list — load more fetches next page and filters setCursor(resp.cursor); } } catch (err) { if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load'); } finally { if (!cancelled) setLoading(false); } })();
return () => { cancelled = true; }; }, [actor]);
const loadMore = useCallback(async () => { if (!cursor || loadingMore) return; setLoadingMore(true); try { // Re-fetch the logged-in user's following (cached by the API client usually) const myFollowingDids = new Set<string>(); let followingCursor: string | undefined; do { const resp = await atprotoClient.getFollows( atprotoClient.api!.did!, 100, followingCursor, ); for (const f of resp.follows) { if (f.did) myFollowingDids.add(f.did); } followingCursor = resp.cursor; } while (followingCursor);
const resp = await atprotoClient.getFollowers(actor, 50, cursor); const mutuals = (resp.followers as FollowProfile[]).filter( (f) => myFollowingDids.has(f.did), ); setProfiles((prev) => [...prev, ...mutuals]); setCursor(resp.cursor); } catch (err) { console.error('Failed to load more:', err); } finally { setLoadingMore(false); } }, [actor, cursor, loadingMore]);
const handleProfileClick = useCallback((p: FollowProfile) => { onClose(); const profileRoute = `/profile/${encodeURIComponent(p.handle)}`; switch (profileViewMode) { case 'page': navigate(profileRoute); break; case 'side': window.history.pushState(null, '', profileRoute); openProfile(p.handle); break; } }, [onClose, profileViewMode, navigate, openProfile]);
return ( <div className="lr-modal-overlay" onClick={onClose}> <div className="lr-modal-content" onClick={(e) => e.stopPropagation()}> <div className="lr-modal-header"> <span className="lr-modal-title">Followers you know</span> <button className="lr-modal-close" onClick={onClose}> <FontAwesomeIcon icon={faXmark} /> </button> </div> <div className="lr-modal-body"> {loading && <div className="lr-modal-loading">Loading…</div>} {error && <div className="lr-modal-error">{error}</div>} {!loading && !error && profiles.length === 0 && ( <div className="lr-modal-empty">No followers you know yet</div> )} {profiles.map((p) => ( <button key={p.did} className="lr-modal-user" onClick={() => handleProfileClick(p)} onAuxClick={(e) => { if (e.button === 1) { e.preventDefault(); window.open(`/profile/${encodeURIComponent(p.handle)}`, '_blank'); } }} style={{ cursor: 'pointer', textAlign: 'left', width: '100%', background: 'none', border: 'none', padding: '10px 16px', display: 'flex', alignItems: 'center', gap: '12px' }} > {p.avatar ? ( <img className="lr-modal-user-avatar" src={p.avatar} alt="" /> ) : ( <div className="lr-modal-user-avatar-placeholder"> {(p.displayName || p.handle)[0].toUpperCase()} </div> )} <div className="lr-modal-user-info"> <span className="lr-modal-user-name">{p.displayName || p.handle}</span> <PdsFavicon did={p.did} /> <span className="lr-modal-user-handle">@{p.handle}</span> {isBlocked(p.did) && <span className="blocked-badge">Blocked</span>} {isMuted(p.did) && <span className="muted-badge">Muted</span>} </div> </button> ))} {cursor && ( <button className="lr-modal-load-more" onClick={loadMore} disabled={loadingMore}> {loadingMore ? 'Loading…' : 'Load more'} </button> )} </div> </div> </div> );}