import { configureOAuth, createAuthorizationUrl, finalizeAuthorization, OAuthUserAgent, getSession, deleteStoredSession, } from '@atcute/oauth-browser-client'; import { Client } from '@atcute/client'; import { LocalActorResolver, CompositeDidDocumentResolver, PlcDidDocumentResolver, WebDidDocumentResolver, XrpcHandleResolver, } from '@atcute/identity-resolver'; import { getAllFollows, getSuggestedFollows, enrichWithFollowerCounts, enrichWithTopPosts, followUser, unfollowUser, getProfile } from './api.js'; import { sample } from './utils.js'; import { showLoading, showDiscover, showLoginError, showError, setStatus, updateFollowCounter, setUndoVisible, setBackVisible, setForwardVisible, setRefreshSpinnerVisible, setGridDimmed, highlightCard, renderGrid, renderProgressMap, setLoginLoading, setSmallOnlyChecked, bindEvents, secretBtns, iconHtml } from './ui.js'; // ── Config ───────────────────────────────────────────────────────────────── const SCOPE = 'atproto repo:app.bsky.graph.follow?action=create repo:app.bsky.graph.follow?action=delete'; const IS_LOCAL = location.hostname === 'localhost' || location.hostname === '127.0.0.1'; const PROD_ORIGIN = 'https://evil.tngl.io'; const REDIRECT_URI = IS_LOCAL ? location.origin.replace('localhost', '127.0.0.1') + '/' : PROD_ORIGIN + '/'; const CLIENT_ID = IS_LOCAL ? `http://localhost?redirect_uri=${encodeURIComponent(REDIRECT_URI)}&scope=${encodeURIComponent(SCOPE)}` : `${PROD_ORIGIN}/client-metadata.json`; configureOAuth({ metadata: { client_id: CLIENT_ID, redirect_uri: REDIRECT_URI }, identityResolver: new LocalActorResolver({ handleResolver: new XrpcHandleResolver({ serviceUrl: 'https://public.api.bsky.app' }), didDocumentResolver: new CompositeDidDocumentResolver({ methods: { plc: new PlcDidDocumentResolver(), web: new WebDidDocumentResolver(), }, }), }), }); // ── State ────────────────────────────────────────────────────────────────── let rpc = null; let myDid = null; let myAvatar = null; let busy = false; // Discovery state let step = 1; let currentPool = []; let currentCandidates = []; let followingSet = new Set(); let seenSet = new Set(); let rootFollowing = []; let followHistory = []; let timelineEvents = []; let viewHistory = []; let viewHistoryIndex = -1; let smallOnly = false; let deadEnd = false; // ── Startup & UI Binding ─────────────────────────────────────────────────── bindEvents({ onLogin: async (handle) => { if (!handle) return; setLoginLoading(true); try { const url = await createAuthorizationUrl({ target: { type: 'account', identifier: handle }, scope: SCOPE }); await new Promise(r => setTimeout(r, 100)); // let browser persist localStorage location.href = url.toString(); } catch (e) { showLoginError(e.message); setLoginLoading(false); } }, onLogout: async () => { try { const session = await getSession(myDid, { allowStale: true }); await new OAuthUserAgent(session).signOut(); } catch { deleteStoredSession(myDid); } localStorage.removeItem('did'); location.reload(); }, onKeySelect: (index) => { if (busy) return; if (index < currentCandidates.length) select(index); }, onRefresh: async () => { if (busy) return; busy = true; showError(''); setGridDimmed(true); const spinnerTimer = setTimeout(() => setRefreshSpinnerVisible(true), 600); try { const ok = await refreshCurrentCandidates(); if (ok) { timelineEvents.push({ type: 'refresh' }); const lastMsg = viewHistory[viewHistoryIndex].msg; saveView(lastMsg); } else { handleDeadEnd(); } } catch (e) { showError(e.message); } finally { clearTimeout(spinnerTimer); setRefreshSpinnerVisible(false); busy = false; setGridDimmed(false); } }, onUndo: async () => { if (busy || followHistory.length === 0) return; busy = true; showError(''); setGridDimmed(true); try { const lastFollow = followHistory.pop(); followingSet.delete(lastFollow.did); updateFollowCounter(followingSet.size); const rkey = lastFollow.res?.data?.uri?.split('/').pop(); if (!rkey) throw new Error("Could not parse URI: " + JSON.stringify(lastFollow.res)); await unfollowUser(rpc, myDid, rkey); timelineEvents.push({ type: 'unfollow', handle: lastFollow.handle, avatar: lastFollow.avatar }); saveView(`${iconHtml('unfollow')} unfollowed @${lastFollow.handle} ${secretBtns}`); setUndoVisible(followHistory.length > 0); } catch (e) { showError(e.message); } finally { busy = false; setGridDimmed(false); } }, onSmallOnlyToggle: async (checked) => { smallOnly = checked; if (busy) return; busy = true; showError(''); setGridDimmed(true); try { const ok = await refreshCurrentCandidates(); if (ok) { const lastMsg = viewHistory[viewHistoryIndex]?.msg ?? ''; saveView(lastMsg); } else { handleDeadEnd(); } } catch (e) { showError(e.message); } finally { busy = false; setGridDimmed(false); } }, onBack: async () => { if (busy || viewHistoryIndex <= 0) return; busy = true; showError(''); viewHistoryIndex--; restoreView(); busy = false; }, onForward: async () => { if (deadEnd) { startNewSeed(); return; } if (busy || viewHistoryIndex >= viewHistory.length - 1) return; busy = true; showError(''); viewHistoryIndex++; restoreView(); busy = false; } }); (async () => { const params = new URLSearchParams(decodeURIComponent(location.hash.slice(1))); if (params.has('state') && (params.has('code') || params.has('error'))) { history.replaceState(null, '', location.pathname); try { const { session } = await finalizeAuthorization(params); await initSession(session); } catch (e) { showLoginError('OAuth failed: ' + e.message); } return; } // Resume from stored session const storedDid = localStorage.getItem('did'); if (storedDid) { try { const session = await getSession(storedDid, { allowStale: true }); await initSession(session); return; } catch { localStorage.removeItem('did'); } } })(); async function initSession(session) { myDid = session.info.sub; localStorage.setItem('did', myDid); rpc = new Client({ handler: new OAuthUserAgent(session) }); showLoading('loading your follows…'); const [ownProfile, follows] = await Promise.all([getProfile(myDid), getAllFollows(myDid)]); myAvatar = ownProfile.avatar ?? null; rootFollowing = follows; followingSet = new Set(rootFollowing.map(u => u.did)); updateFollowCounter(followingSet.size); currentPool = rootFollowing.filter(u => u.did !== myDid); timelineEvents = []; step = 1; smallOnly = document.getElementById('small-only-toggle')?.checked ?? false; const ok = await refreshCurrentCandidates(); showDiscover(); if (ok) saveView(`choose a path (no follow yet) ${secretBtns}`); } // ── Discovery ────────────────────────────────────────────────────────────── const eligible = (u, ignoreSeen = false) => !followingSet.has(u.did) && (ignoreSeen || !seenSet.has(u.did)) && u.did !== myDid && u.description; async function refreshCurrentCandidates() { if (!currentPool.length) return false; const unseen = currentPool.filter(u => !seenSet.has(u.did) && !followingSet.has(u.did) && u.did !== myDid); const poolToSample = unseen.length >= 4 ? unseen : currentPool; const oversample = sample(poolToSample, 25); await Promise.all([ enrichWithFollowerCounts(oversample), enrichWithTopPosts(oversample), ]); const activeCutoff = Date.now() - 90 * 24 * 60 * 60 * 1000; const hasPosts = u => u.topPosts?.length > 0; const isActive = u => u.lastPostAt && Date.parse(u.lastPostAt) >= activeCutoff; const isSmall = u => (u.followersCount ?? 0) <= 5000; const hasEnoughFollowers = u => (u.followersCount ?? 0) >= 80; // Seed step requires posts (no bio requirement); later steps already require bios via eligible(). let eligiblePool = step === 1 ? oversample.filter(hasPosts) : oversample; eligiblePool = eligiblePool.filter(hasEnoughFollowers); if (smallOnly) eligiblePool = eligiblePool.filter(isSmall); const active = eligiblePool.filter(isActive); const inactive = eligiblePool.filter(u => !isActive(u)); currentCandidates = [...active, ...inactive].slice(0, 4); if (!currentCandidates.length) return false; currentCandidates.forEach(u => seenSet.add(u.did)); return true; } function saveView(statusMsg) { viewHistory.push({ candidates: [...currentCandidates], step, pool: [...currentPool], msg: statusMsg, smallOnly }); viewHistoryIndex = viewHistory.length - 1; rerender(); } function restoreView() { deadEnd = false; const state = viewHistory[viewHistoryIndex]; currentCandidates = [...state.candidates]; step = state.step; currentPool = [...state.pool]; smallOnly = state.smallOnly ?? false; setSmallOnlyChecked(smallOnly); rerender(); } function handleDeadEnd() { deadEnd = true; currentCandidates = []; renderGrid([], null, null); setStatus(`dead end — [n] new seed`); setBackVisible(viewHistoryIndex > 0); setForwardVisible(false); } async function startNewSeed() { if (busy) return; deadEnd = false; busy = true; showError(''); setGridDimmed(true); currentPool = rootFollowing.filter(u => u.did !== myDid); step = 1; try { const ok = await refreshCurrentCandidates(); if (ok) saveView(`choose a path (no follow yet) ${secretBtns}`); else handleDeadEnd(); } finally { busy = false; setGridDimmed(false); } } function rerender() { const followedDids = new Set(followHistory.map(f => f.did)); setStatus(viewHistory[viewHistoryIndex].msg); setBackVisible(viewHistoryIndex > 0); setForwardVisible(viewHistoryIndex < viewHistory.length - 1); renderGrid(currentCandidates, select, step > 1 ? followedDids : null); renderProgressMap(timelineEvents, myAvatar, viewHistoryIndex); } async function select(index) { if (busy) return; const selected = currentCandidates[index]; if (!selected) return; const followedEntry = step > 1 ? followHistory.find(f => f.did === selected.did) : null; if (followedEntry) return unfollowFromGrid(selected, followedEntry); busy = true; showError(''); const clearHighlight = highlightCard(index); await new Promise(r => setTimeout(r, 300)); setGridDimmed(true); try { const event = { handle: selected.handle, avatar: selected.avatar ?? null }; if (step === 1) { timelineEvents.push({ type: 'seed', ...event }); } else { const res = await followUser(rpc, myDid, selected.did); followHistory.push({ res, did: selected.did, handle: selected.handle, avatar: selected.avatar ?? null }); timelineEvents.push({ type: 'follow', ...event }); followingSet.add(selected.did); updateFollowCounter(followingSet.size); } const { suggestions, isFallback } = await getSuggestedFollows(selected.did); let nextPool; if (suggestions.length >= 4 && !isFallback) { nextPool = suggestions; } else { const follows = await getAllFollows(selected.did); nextPool = [...suggestions, ...follows]; } let filtered = nextPool.filter(u => eligible(u)); const noFollows = nextPool.length === 0; if (filtered.length < 4) { let fallback = rootFollowing.filter(u => eligible(u)); if (!fallback.length) fallback = rootFollowing.filter(u => eligible(u, true)); filtered = [...filtered, ...fallback]; } currentPool = filtered; const ok = await refreshCurrentCandidates(); if (!ok) { handleDeadEnd(); return; } step++; const actionMsg = step > 2 ? `${iconHtml('follow')} followed @${selected.handle}` : `${iconHtml('seed')} picked @${selected.handle}`; const fallbackMsg = noFollows ? ` (they follow nobody, showing alternatives)` : ''; saveView(`${actionMsg}${fallbackMsg} ${secretBtns}`); setUndoVisible(followHistory.length > 0); } catch (e) { showError(e.message); } finally { clearHighlight(); busy = false; setGridDimmed(false); } } async function unfollowFromGrid(selected, entry) { if (busy) return; busy = true; showError(''); setGridDimmed(true); try { const rkey = entry.res?.data?.uri?.split('/').pop(); if (!rkey) throw new Error("Could not parse URI: " + JSON.stringify(entry.res)); await unfollowUser(rpc, myDid, rkey); followHistory.splice(followHistory.indexOf(entry), 1); followingSet.delete(selected.did); updateFollowCounter(followingSet.size); timelineEvents.push({ type: 'unfollow', handle: selected.handle, avatar: selected.avatar ?? null }); saveView(`${iconHtml('unfollow')} unfollowed @${selected.handle} ${secretBtns}`); setUndoVisible(followHistory.length > 0); } catch (e) { showError(e.message); } finally { busy = false; setGridDimmed(false); } }