import { 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//post/` (or // `/lists/`). 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 letter const 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:///profile//post/ */ 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//post/`-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//lists/`-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://` (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///, 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:///profile//lists/ */ 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 { 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`) }