Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429import { 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 statelet 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 — <span id="new-seed-btn" class="cursor-pointer">[n] new seed</span>`); 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); }}