Something went wrong. Try again.
FlockOff is a mass-block tool for the Atmosphere flockoff.app
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403import 'server-only'import { Agent } from '@atproto/api'import { extractPostRef, verifiedDisplayUrl } from './utils'import { APPVIEW_URL } from './appview'import type { Profile, EngagementAccount, EngagementComment, EngagementType, RelationshipStatus, PostData, BlockCriteria,} from '@/types'
export const UNRECOGNIZED_POST_INPUT_ERROR = "Couldn't parse that link. Paste the post's URL from any Atmosphere client, or its at:// URI — aturi.to can convert links between clients."
// ─── Profile Mapping ──────────────────────────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-anyfunction mapProfile(view: any): Profile { return { did: view.did, handle: view.handle, displayName: view.displayName, avatar: view.avatar, }}
// ─── Paginated Fetchers ───────────────────────────────────────────────────────
async function fetchAllLikes(agent: Agent, uri: string, cid: string): Promise<Profile[]> { const profiles: Profile[] = [] let cursor: string | undefined do { const res = await agent.getLikes({ uri, cid, limit: 100, cursor }) for (const like of res.data.likes) profiles.push(mapProfile(like.actor)) cursor = res.data.cursor } while (cursor) return profiles}
async function fetchAllReposts(agent: Agent, uri: string, cid: string): Promise<Profile[]> { const profiles: Profile[] = [] let cursor: string | undefined do { const res = await agent.getRepostedBy({ uri, cid, limit: 100, cursor }) for (const profile of res.data.repostedBy) profiles.push(mapProfile(profile)) cursor = res.data.cursor } while (cursor) return profiles}
// Max comment texts sent per account — matches what AccountCard renders.const MAX_COMMENTS_PER_ACCOUNT = 3
// A profile paired with the text of their reply/quote — the thread and quote// responses carry full post records, so the text comes for free.interface CommentedProfile { profile: Profile text: string uri: string}
async function fetchAllQuotes(agent: Agent, uri: string): Promise<CommentedProfile[]> { const entries: CommentedProfile[] = [] let cursor: string | undefined do { // eslint-disable-next-line @typescript-eslint/no-explicit-any const res = await (agent as any).app.bsky.feed.getQuotes({ uri, limit: 100, cursor }) for (const post of res.data.posts) { entries.push({ profile: mapProfile(post.author), text: (post.record as { text?: string })?.text ?? '', uri: post.uri, }) } cursor = res.data.cursor } while (cursor) return entries}
// Walk the thread tree to collect all repliers (excluding OP at depth 0)// eslint-disable-next-line @typescript-eslint/no-explicit-anyfunction extractRepliers(thread: any, depth = 0, maxDepth = 8): CommentedProfile[] { if (depth > maxDepth || !thread) return [] const entries: CommentedProfile[] = [] if (thread.$type === 'app.bsky.feed.defs#threadViewPost') { if (depth > 0 && thread.post?.author) { entries.push({ profile: mapProfile(thread.post.author), text: (thread.post.record as { text?: string })?.text ?? '', uri: thread.post.uri, }) } for (const reply of thread.replies ?? []) { entries.push(...extractRepliers(reply, depth + 1, maxDepth)) } } return entries}
// ─── Relationship Checking ────────────────────────────────────────────────────
async function checkRelationships( agent: Agent, userDid: string, dids: string[]): Promise<Map<string, RelationshipStatus>> { const result = new Map<string, RelationshipStatus>() if (dids.length === 0) return result
// Batch in groups of 30 (API limit) const batches: string[][] = [] for (let i = 0; i < dids.length; i += 30) batches.push(dids.slice(i, i + 30))
await Promise.all( batches.map(async (batch) => { try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const res = await (agent as any).app.bsky.graph.getRelationships({ actor: userDid, others: batch, })
// eslint-disable-next-line @typescript-eslint/no-explicit-any for (const rel of res.data.relationships as any[]) { // Skip notFoundActor entries if (rel.$type === 'app.bsky.graph.defs#notFoundActor') { result.set(rel.did, 'none') continue }
const isFollowing = !!rel.following const isFollowedBy = !!rel.followedBy
let status: RelationshipStatus if (isFollowing && isFollowedBy) status = 'mutual' else if (isFollowing) status = 'following' else if (isFollowedBy) status = 'follower' else status = 'none'
result.set(rel.did, status) } } catch { // Graceful degradation — relationship check failing shouldn't block the feature for (const did of batch) result.set(did, 'none') } }) )
return result}
// ─── Shared Core ──────────────────────────────────────────────────────────────// Fetches engagement from a resolved post URI + thread. Used by both// fetchEngagement (URL input) and fetchEngagementByAtUri (AT URI input).
export interface FetchEngagementResult { post: PostData accounts: EngagementAccount[]}
async function fetchEngagementFromThread( agent: Agent, // eslint-disable-next-line @typescript-eslint/no-explicit-any typedThread: any, originalUrl: string, userDid: string, criteria: BlockCriteria): Promise<FetchEngagementResult> { const postView = typedThread.post const post: PostData = { uri: postView.uri, cid: postView.cid, url: originalUrl, author: mapProfile(postView.author), // eslint-disable-next-line @typescript-eslint/no-explicit-any text: (postView.record as any)?.text ?? '', createdAt: postView.indexedAt, likeCount: postView.likeCount, repostCount: postView.repostCount, replyCount: postView.replyCount, quoteCount: postView.quoteCount, }
const authorDid: string = postView.author.did
// Fetch engagement types in parallel — only fetch what was requested const [likers, reposters, quoters, repliers] = await Promise.all([ criteria.blockLikes ? fetchAllLikes(agent, postView.uri, postView.cid) : Promise.resolve<Profile[]>([]), criteria.blockReposts ? fetchAllReposts(agent, postView.uri, postView.cid) : Promise.resolve<Profile[]>([]), criteria.blockQuotes ? fetchAllQuotes(agent, postView.uri) : Promise.resolve<CommentedProfile[]>([]), criteria.blockReplies ? Promise.resolve(extractRepliers(typedThread)) : Promise.resolve<CommentedProfile[]>([]), ])
// Merge into a single map keyed by DID, accumulating engagement types and // any reply/quote text (one account can comment multiple times) const accountMap = new Map< string, { profile: Profile types: Set<EngagementType> comments: EngagementComment[] commentUris: Set<string> } >()
const getEntry = (profile: Profile) => { if (!accountMap.has(profile.did)) { accountMap.set(profile.did, { profile, types: new Set(), comments: [], commentUris: new Set(), }) } return accountMap.get(profile.did)! }
const addAccounts = (profiles: Profile[], type: EngagementType) => { for (const profile of profiles) getEntry(profile).types.add(type) }
const addCommented = (entries: CommentedProfile[], type: 'quote' | 'reply') => { for (const { profile, text, uri } of entries) { const entry = getEntry(profile) entry.types.add(type) // Image-only posts have no text — the account still counts, no comment // shown. A post that is both a quote of and a reply to the source // arrives from both fetchers — dedupe by URI so it's neither // double-counted nor double-rendered. if (!text || entry.commentUris.has(uri)) continue entry.commentUris.add(uri) // Cap what's shipped to the client — the UI shows at most this many, and // serial repliers on huge threads shouldn't inflate the response payload. // commentUris keeps counting past the cap so "+N more" stays accurate. if (entry.comments.length < MAX_COMMENTS_PER_ACCOUNT) { entry.comments.push({ text, uri, type }) } } }
if (criteria.blockOp) { getEntry(post.author).types.add('op') } addAccounts(likers, 'like') addAccounts(reposters, 'repost') addCommented(quoters, 'quote') addCommented(repliers, 'reply')
// Never include the logged-in user in their own block list accountMap.delete(userDid)
const allDids = [...accountMap.keys()] const relationships = await checkRelationships(agent, userDid, allDids)
const accounts: EngagementAccount[] = allDids.map((did) => { const { profile, types, comments, commentUris } = accountMap.get(did)! return { profile, engagementTypes: [...types], relationship: relationships.get(did) ?? 'none', ...(comments.length > 0 && { comments, commentCount: commentUris.size }), } })
return { post, accounts }}
// ─── Lightweight Post Fetch ───────────────────────────────────────────────────
/** * Fetch only post metadata (author, text, counts) from an AT URI. * Much faster than fetchEngagementByAtUri — no engagement list fetching. * Returns null if the post is deleted or unavailable. */export async function fetchPostData( atUri: string, sourcePostUrl?: string): Promise<PostData | null> { try { const agent = new Agent(APPVIEW_URL) const threadRes = await agent.getPostThread({ uri: atUri, depth: 0 }) const thread = threadRes.data.thread
if (thread.$type !== 'app.bsky.feed.defs#threadViewPost') return null
// eslint-disable-next-line @typescript-eslint/no-explicit-any const pv = (thread as any).post const postRkey = (pv.uri as string).split('/').pop()
return { uri: pv.uri, cid: pv.cid, url: sourcePostUrl ?? `https://bsky.app/profile/${pv.author.did}/post/${postRkey}`, author: { did: pv.author.did, handle: pv.author.handle, displayName: pv.author.displayName, avatar: pv.author.avatar, }, // eslint-disable-next-line @typescript-eslint/no-explicit-any text: (pv.record as any)?.text ?? '', createdAt: pv.indexedAt, likeCount: pv.likeCount, repostCount: pv.repostCount, replyCount: pv.replyCount, quoteCount: pv.quoteCount, } } catch { return null }}
// ─── Main Exports ─────────────────────────────────────────────────────────────
/** * Fetch engagement for a post given a pasted input: a post URL from any * Atmosphere client, or an at:// URI (raw or embedded in a client link). * Resolves actor → DID → AT URI, then fetches engagement. */export async function fetchEngagement( url: string, userDid: string, criteria: BlockCriteria): Promise<FetchEngagementResult> { const parsed = extractPostRef(url) if (!parsed) throw new Error(UNRECOGNIZED_POST_INPUT_ERROR)
const agent = new Agent(APPVIEW_URL)
// Resolve handle → DID so we can build a valid AT URI. Client URLs (and // at:// URIs) may carry either — bsky.app itself produces /profile/did:plc:… // links, so skip resolution when the actor is already a DID. let authorDid: string if (parsed.actor.startsWith('did:')) { authorDid = parsed.actor } else { const handleRes = await agent.com.atproto.identity.resolveHandle({ handle: parsed.actor }) authorDid = handleRes.data.did } const postUri = `at://${authorDid}/app.bsky.feed.post/${parsed.rkey}`
// Fetch the thread (gives us post data + reply tree in one request) const threadRes = await agent.getPostThread({ uri: postUri, depth: 10 }) const thread = threadRes.data.thread
if (thread.$type !== 'app.bsky.feed.defs#threadViewPost') { throw new Error('Post not found or unavailable') }
// The pasted URL is only kept as the display link when it matched the // /profile/…/post/… URL shape AND its host is a verified client — the value // is persisted as sourcePostUrl, rendered later, and embedded in public list // descriptions, so an attacker-chosen host must never reach it. Everything // else (raw at:// pastes, embedded at:// URIs, unverified hosts) gets a // canonical bsky.app URL. const canonicalUrl = `https://bsky.app/profile/${authorDid}/post/${parsed.rkey}` const displayUrl = parsed.via === 'url' ? (verifiedDisplayUrl(url) ?? canonicalUrl) : canonicalUrl
// eslint-disable-next-line @typescript-eslint/no-explicit-any return fetchEngagementFromThread(agent, thread as any, displayUrl, userDid, criteria)}
/** * Fetch engagement for a post given its AT URI (at://did/.../rkey). * Skips handle resolution — use when you already have the AT URI from a stored record. */export async function fetchEngagementByAtUri( atUri: string, userDid: string, criteria: BlockCriteria): Promise<FetchEngagementResult> { const agent = new Agent(APPVIEW_URL)
const threadRes = await agent.getPostThread({ uri: atUri, depth: 10 }) const thread = threadRes.data.thread
if (thread.$type !== 'app.bsky.feed.defs#threadViewPost') { throw new Error('Post not found or unavailable') }
// Build a best-effort bsky.app URL from the AT URI for display purposes // at://did:plc:xxx/app.bsky.feed.post/rkey → use DID as profile identifier const parts = atUri.split('/') const authorDid = parts[2] const postRkey = parts[parts.length - 1] const displayUrl = `https://bsky.app/profile/${authorDid}/post/${postRkey}`
// eslint-disable-next-line @typescript-eslint/no-explicit-any return fetchEngagementFromThread(agent, thread as any, displayUrl, userDid, criteria)}