Something went wrong. Try again.
FlockOff is a mass-block tool for the Atmosphere flockoff.app
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247import { clsx, type ClassValue } from "clsx"import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs))}
// ─── AT Protocol URL / URI Utilities ──────────────────────────────────────────//// Post and list inputs are resolved in two tiers, so links from any Atmosphere// client work — not just bsky.app://// 1. `at://` URI extraction — accepts raw AT URIs, and finds URIs embedded in// client URLs (Bluepy puts them in the path; Pinkleap/Klearsky/Skythread// pass them URL-encoded in query params).// 2. Path-shape parsing — accepts ANY hostname whose path matches the// social-app route shape `/profile/<handle-or-did>/post/<rkey>` (or// `/lists/<rkey>`). Verified against bsky.app, deer.social, witchsky.app,// blacksky.community, anisota.net, and reddwarf.app. Safe on unknown// domains: we only extract identifiers, never fetch the pasted host.//// Segment validation follows the atproto identity specs so junk paths on// random domains don't slip through to the AppView.
// Record key: https://atproto.com/specs/record-key ("." and ".." excluded)const RKEY_RE = /^[A-Za-z0-9._:~-]{1,512}$/
// Handle: dot-separated DNS labels, TLD starts with a letterconst HANDLE_RE = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
// DID: method + method-specific id. The id char class includes ':' and '%' so// did:web path components (did:web:example.com:user:alice) and %-encoded ports// (did:web:example.com%3A8443) validate.const DID_RE = /^did:[a-z0-9]+:[A-Za-z0-9._:%-]*[A-Za-z0-9._-]$/
function isValidRkey(rkey: string): boolean { return RKEY_RE.test(rkey) && rkey !== '.' && rkey !== '..'}
/** True if the string is a plausible atproto actor: a handle or a DID. */export function isValidActor(actor: string): boolean { return actor.startsWith('did:') ? DID_RE.test(actor) : HANDLE_RE.test(actor)}
export interface RecordRef { /** Handle or DID of the repo owner */ actor: string collection: string rkey: string}
export interface ParsedInputRef { /** Handle or DID of the repo owner */ actor: string rkey: string /** Which tier matched: an embedded at:// URI, or a /profile/… shaped URL */ via: 'atUri' | 'url'}
/** * Find and parse an `at://` URI anywhere inside the input string. * Checks the raw input first, then a URL-decoded copy (for links that carry * the URI in a query param, e.g. `?uri=at%3A%2F%2F...`). Every occurrence is * considered, so one malformed or unrelated URI can't mask a valid one later * in the string. Pass `collection` to skip URIs of other record types. */export function extractAtUri(input: string, collection?: string): RecordRef | null { const candidates = [input.trim()] try { const decoded = decodeURIComponent(input.trim()) if (decoded !== candidates[0]) candidates.push(decoded) } catch { // Malformed %-encoding — raw input is still worth scanning }
for (const text of candidates) { for ( let start = text.indexOf('at://'); start !== -1; start = text.indexOf('at://', start + 1) ) { // The URI runs until a character that can't belong to it const rest = text.slice(start + 'at://'.length) const end = rest.search(/[\s&#?"'<>]/) const body = (end === -1 ? rest : rest.slice(0, end)).replace(/\/+$/, '')
const [actor, coll, rkey, ...extra] = body.split('/') if (!actor || !coll || !rkey || extra.length > 0) continue if (!isValidActor(actor) || !isValidRkey(rkey)) continue // Collections are NSIDs — cheap sanity check, not a full grammar if (!/^[a-zA-Z][a-zA-Z0-9.-]*\.[a-zA-Z][a-zA-Z0-9]*$/.test(coll)) continue if (collection && coll !== collection) continue
return { actor, collection: coll, rkey } } }
return null}
/** * Parse a post URL from any Atmosphere client into its actor and rkey. * Accepts: https://<any-host>/profile/<handle-or-did>/post/<rkey> */export function parsePostUrl(url: string): { handle: string; rkey: string } | null { return parseProfileRouteUrl(url, 'post')}
/** * Resolve a pasted post input — an `at://` URI (raw or embedded in a client * link) or a `/profile/<id>/post/<rkey>`-shaped URL — to its actor and rkey. * An embedded at:// URI of some other record type doesn't block the URL tier. */export function extractPostRef(input: string): ParsedInputRef | null { const atUri = extractAtUri(input, 'app.bsky.feed.post') if (atUri) return { actor: atUri.actor, rkey: atUri.rkey, via: 'atUri' } const parsed = parsePostUrl(input) return parsed ? { actor: parsed.handle, rkey: parsed.rkey, via: 'url' } : null}
/** * Resolve a pasted list input — an `at://` URI or a * `/profile/<id>/lists/<rkey>`-shaped URL — to its actor and rkey. */export function extractListRef(input: string): ParsedInputRef | null { const atUri = extractAtUri(input, 'app.bsky.graph.list') if (atUri) return { actor: atUri.actor, rkey: atUri.rkey, via: 'atUri' } const parsed = parseListUrl(input) return parsed ? { actor: parsed.handle, rkey: parsed.rkey, via: 'url' } : null}
// Verified Atmosphere clients whose links may be persisted as display URLs// (sourcePostUrl — rendered later and embedded in public list descriptions).// This gates ONLY what we vouch for in stored links; parsing accepts any host.// Exact hostname match only — no subdomain suffixing, so a lookalike host or a// user-controlled subdomain of a listed client can never be persisted. Links// from unlisted clients still work; they just display as canonical bsky.app// URLs until the client is added here.const VERIFIED_DISPLAY_HOSTS = new Set([ 'bsky.app', 'deer.social', 'witchsky.app', 'blacksky.community', 'anisota.net', 'reddwarf.app',])
/** * If the pasted URL is https on a verified client host, return it normalized * to origin-less `https://<host><pathname>` (query/hash junk dropped, port * stripped). Otherwise null — callers substitute a canonical bsky.app URL. */export function verifiedDisplayUrl(input: string): string | null { try { const parsed = new URL(input.trim()) if (parsed.protocol !== 'https:') return null const host = parsed.hostname.toLowerCase().replace(/\.$/, '') if (!VERIFIED_DISPLAY_HOSTS.has(host)) return null return `https://${host}${parsed.pathname}` } catch { return null }}
// Social-app subroutes under a post that still identify the post itself.// Accepting these keeps previously working pastes (e.g. a liked-by page URL)// working, without opening up arbitrary extra path segments.const POST_SUBROUTES = new Set(['liked-by', 'reposted-by', 'quotes'])
function parseProfileRouteUrl( url: string, segment: 'post' | 'lists'): { handle: string; rkey: string } | null { try { const parsed = new URL(url.trim()) if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return null const parts = parsed.pathname.split('/').filter(Boolean) // /profile/<handle-or-did>/<segment>/<rkey>, with an optional known // subroute for posts. Exact length otherwise — with any-domain acceptance, // loose prefix matching would false-positive on unrelated sites. if (parts.length < 4 || parts[0] !== 'profile' || parts[2] !== segment) return null if (parts.length === 5) { if (segment !== 'post' || !POST_SUBROUTES.has(parts[4])) return null } else if (parts.length !== 4) { return null } const actor = decodeURIComponent(parts[1]) const rkey = parts[3] if (!isValidActor(actor) || !isValidRkey(rkey)) return null return { handle: actor, rkey } } catch { return null }}
/** * Extract the rkey from an AT URI. * at://did:plc:xxx/app.bsky.graph.block/rkey → rkey */export function extractRkey(uri: string): string { return uri.split('/').pop() ?? ''}
/** * Build a bsky.app post URL from a handle and rkey. */export function buildBskyUrl(handle: string, rkey: string): string { return `https://bsky.app/profile/${handle}/post/${rkey}`}
/** * Build a bsky.app profile URL. */export function buildProfileUrl(handle: string): string { return `https://bsky.app/profile/${handle}`}
/** * Parse a list URL from any Atmosphere client into its actor and rkey. * Accepts: https://<any-host>/profile/<handle-or-did>/lists/<rkey> */export function parseListUrl(url: string): { handle: string; rkey: string } | null { return parseProfileRouteUrl(url, 'lists')}
/** * Build a bsky.app list URL from a handle/DID and rkey. */export function buildBskyListUrl(handleOrDid: string, rkey: string): string { return `https://bsky.app/profile/${handleOrDid}/lists/${rkey}`}
// ─── Misc ─────────────────────────────────────────────────────────────────────
export function sleep(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms))}
export function generateId(): string { return crypto.randomUUID()}
export function pluralize(count: number, singular: string, plural?: string): string { return count === 1 ? singular : (plural ?? `${singular}s`)}