/** @param {string|null|undefined} iso @returns {string} */ export function day(iso) { return iso ? iso.slice(0, 10) : ''; } /** @param {string|null|undefined} iso @returns {string} */ export function dateTime(iso) { if (!iso) return ''; const parsed = new Date(iso); if (Number.isNaN(parsed.getTime())) return iso; return parsed.toLocaleString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit', }); } /** @param {string} scope @returns {string[]} */ export function scopeTokens(scope) { return (scope || 'atproto').split(/\s+/).filter(Boolean); } /** @param {number|null|undefined} n @returns {string} */ export function num(n) { return (n ?? 0).toLocaleString('en-US'); } /** Human-readable bytes like "1.5 MB". @param {number|null|undefined} n */ export function bytes(n) { if (n == null) return ''; if (n < 1024) return `${n} B`; const units = ['KB', 'MB', 'GB', 'TB']; let value = n; let unit = 0; do { value /= 1024; unit += 1; } while (value >= 1024 && unit < units.length); return `${value >= 100 ? Math.round(value) : value.toFixed(1)} ${units[unit - 1]}`; } /** A short relative time like "12m ago". @param {string|null} iso */ export function timeAgo(iso) { if (!iso) return ''; const seconds = Math.max( 0, Math.floor((Date.now() - Date.parse(iso)) / 1000), ); if (seconds < 60) return 'just now'; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h ago`; const days = Math.floor(hours / 24); if (days < 30) return `${days}d ago`; const months = Math.floor(days / 30); if (months < 12) return `${months}mo ago`; return `${Math.floor(months / 12)}y ago`; } /** * The registrable-ish domain behind an NSID, for a favicon lookup: the first * two authority segments, reversed. `app.bsky.feed.post` -> `bsky.app`. * @param {string} nsid @returns {string} */ export function nsidDomain(nsid) { const parts = String(nsid).split('.'); return parts.length < 2 ? String(nsid) : `${parts[1]}.${parts[0]}`; } // Tails that say nothing on their own, so the category before them is kept. const GENERIC_TAIL = new Set([ 'item', 'items', 'record', 'records', 'entry', 'entries', 'object', 'objects', 'thing', 'things', 'def', 'definition', 'decl', 'declaration', ]); /** * A plain-language noun for a record type, from its NSID, so a person reads * "a post" rather than "app.bsky.feed.post". Normally the last segment carries * the meaning (`…feed.post` -> "post"); when it is a generic tail the category * before it comes along (`social.grain.gallery.item` -> "gallery item"), unless * that would only reach back into the authority. * @param {string} nsid @returns {string} */ export function recordNoun(nsid) { const parts = String(nsid).split('.'); const humanize = (/** @type {string} */ s) => s.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase(); const last = parts[parts.length - 1] || String(nsid); if (GENERIC_TAIL.has(last.toLowerCase()) && parts.length >= 4) { return `${humanize(parts[parts.length - 2])} ${humanize(last)}`; } return humanize(last); } /** * "a" or "an" to fit the word that follows. * @param {string} word @returns {string} */ export function article(word) { return /^[aeiou]/i.test(word) ? 'an' : 'a'; } /** * Naive English plural for a record noun: "gallery" -> "galleries", * "photo" -> "photos", "gallery item" -> "gallery items". * @param {string} noun @returns {string} */ export function pluralNoun(noun) { if (/[^aeiou]y$/.test(noun)) return `${noun.slice(0, -1)}ies`; if (/(s|x|z|ch|sh)$/.test(noun)) return `${noun}es`; return `${noun}s`; } /** * Pick the variant of a translated string that best fits the reader's * languages: exact locale, then bare language, then any region of the same * language, then the untagged base (English by lexicon convention). * @param {string|null} base * @param {Record} [byLang] * @returns {string|null} */ export function pickLangString(base, byLang = {}) { const locales = typeof navigator === 'undefined' ? [] : navigator.languages || []; for (const locale of locales) { if (byLang[locale]) return byLang[locale]; const lang = locale.split('-')[0]; if (byLang[lang]) return byLang[lang]; // The untagged base is English by lexicon convention, so it is the // English candidate at English's position in the reader's order. Without // this, a translation in any later-preferred language would beat it. if (lang === 'en' && base != null) return base; const sameLang = Object.keys(byLang).find( (key) => key.split('-')[0] === lang, ); if (sameLang) return byLang[sameLang]; } return base; } /** * Fold a repo's collections into app-like buckets keyed by their authority * domain, summing record counts and keeping each collection under its app. * Built straight from /overview's collections. Sorted by record count, most * first, and so are the collections within each bucket. * @param {{name: string, count: number}[]} collections * @returns {{domain: string, count: number, collections: {name: string, count: number}[]}[]} */ export function groupByApp(collections) { /** @type {Map} */ const map = new Map(); for (const c of collections || []) { const domain = nsidDomain(c.name); const group = map.get(domain) || { domain, count: 0, collections: [] }; group.count += c.count; group.collections.push(c); map.set(domain, group); } for (const group of map.values()) { group.collections.sort((a, b) => b.count - a.count); } return [...map.values()].sort((a, b) => b.count - a.count); } /** * Fold an overview's per-collection creation histogram into one series for an * app, by summing every collection under the app's domain. `recordActivity` is * the `recordActivity` block from /overview: `{ buckets, series }`, where each * series is bucket counts oldest to newest across a fixed window the server * fixed. Returns the app's summed counts, or null when it had no records * created in the window (so the row can skip the sparkline entirely). A row * that absorbed other domains' records passes them as `folded` so their * activity counts too. * @param {{buckets?: number, series?: Record}|null|undefined} recordActivity * @param {string} domain * @param {string[]} [folded] * @returns {number[]|null} */ export function appActivitySeries(recordActivity, domain, folded = []) { const series = recordActivity?.series; const buckets = recordActivity?.buckets || 0; if (!series || !buckets) return null; let out = null; for (const [name, counts] of Object.entries(series)) { const owner = nsidDomain(name); if (owner !== domain && !folded.includes(owner)) continue; if (!out) out = new Array(buckets).fill(0); for (let i = 0; i < buckets; i++) out[i] += counts[i] || 0; } return out; } /** The host an OAuth client is identified by, for its name and favicon. */ /** @param {string} clientId @returns {string} */ export function clientHost(clientId) { try { return new URL(clientId).host; } catch { return clientId; } } /** * The NSIDs a session's granular scope tokens name: `repo:sh.tangled.repo` * and `rpc:sh.tangled.ci.triggerPipeline?aud=*` both name an `sh.tangled.*` * NSID, and the query forms `repo?collection=…` and `rpc?lxm=…` count the * same way. A generic grant (`atproto`, `transition:generic`) names none, * and a bare wildcard says nothing about an authority, so neither * contributes. * @param {string} scope @returns {string[]} */ export function scopeNsids(scope) { /** @type {string[]} */ const out = []; const keep = (/** @type {string} */ raw) => { const nsid = raw.replace(/\.\*$/, ''); if (nsid !== '*' && nsid.includes('.')) out.push(nsid); }; for (const token of scopeTokens(scope)) { const path = /^(?:repo|rpc):([^?]+)/.exec(token); if (path) { keep(decodeURIComponent(path[1])); continue; } const query = /^(?:repo|rpc)\?(.+)$/.exec(token); if (!query) continue; const params = new URLSearchParams(query[1]); for (const name of [ ...params.getAll('collection'), ...params.getAll('lxm'), ]) { keep(name); } } return out; } /** * The name an app goes by once its TLD is dropped — `tangled.sh` and * `tangled.org` both read "tangled". Approximate on purpose (no public-suffix * list): the segment left of the last one, which is where registrable app * domains carry their name. * @param {string} domain @returns {string} */ function appLabel(domain) { const parts = String(domain).toLowerCase().split('.'); return parts.length >= 2 ? parts[parts.length - 2] : (parts[0] ?? ''); } /** * Pairs of Apps rows that look like one app split by a rebrand: it signs in * from its new domain while its records keep the NSID authority of the old * one, so one row holds only sessions and the other only records. The * signature is deliberately narrow — that complement plus a shared name * label — so a look-alike domain the user once authorized is never folded * into a trusted row on its own say-so. A session that announces granular * scopes must actually cover the record row's authority, or the pair is * dropped: a third-party client writing another app's lexicons is not that * app. Pairs already combined or turned down are skipped. Each offer folds * `from` into `to`, the name the app is known by: a client that is just a * subdomain of the record authority folds into the apex (api.currents.is * into currents.is), while a rebrand keeps the live client host on top * (tangled.sh folds into tangled.org). * @param {{domain: string, count: number, sessions: {scope?: string}[]}[]} apps * @param {{aliases?: Record, dismissedPairs?: string[]}} [prefs] * @returns {{from: string, to: string}[]} */ export function combineOffers(apps, prefs = {}) { const dismissed = new Set(prefs.dismissedPairs || []); const aliased = new Set(Object.keys(prefs.aliases || {})); const sessionOnly = apps.filter((a) => a.sessions.length > 0 && !a.count); const recordOnly = apps.filter((a) => a.count > 0 && !a.sessions.length); const offers = []; for (const sessionRow of sessionOnly) { for (const recordRow of recordOnly) { if (appLabel(recordRow.domain) !== appLabel(sessionRow.domain)) continue; const named = sessionRow.sessions.flatMap((s) => scopeNsids(s.scope || ''), ); if ( named.length && !named.some((n) => nsidDomain(n) === recordRow.domain) ) { continue; } const offer = sessionRow.domain.endsWith(`.${recordRow.domain}`) ? { from: sessionRow.domain, to: recordRow.domain } : { from: recordRow.domain, to: sessionRow.domain }; if (aliased.has(offer.from)) continue; if (dismissed.has(`${offer.from}>${offer.to}`)) continue; offers.push(offer); } } return offers; } /** * One row per app, keyed by domain, joining what an app has stored (its * collections, from /overview) with the access it holds (its OAuth sessions, * from /apps). One app can hold several sessions at once, one per device or * reauthorization, so they are collected rather than overwritten. An app can * appear with data but no session, or the reverse. Rows sort by when the app * was last active — the newest of its session accesses, its record writes and * its writes to the spaces it manages — and fall back to record count when no * side has a time. * Rows the user combined (`aliases`, from-domain to to-domain) fold together: * the from row's records move under the to row, which remembers the fold in * `folded` so screens can say where its records really live. * @param {{name: string, count: number, lastRecordAt?: number|null}[]} collections * @param {{clientId: string, scope: string, authorizedAt: string|null, lastAccessedAt: string|null, sessionId: string|null}[]} [oauthApps] * @param {Record} [aliases] * @param {{spaceType: string, collections?: {collection: string, lastRecordAt?: number|null}[]}[]} [spaces] * @returns {{domain: string, count: number, collections: {name: string, count: number, lastRecordAt?: number|null}[], sessions: object[], spaces: object[], folded?: string[]}[]} */ export function mergeApps( collections, oauthApps = [], aliases = {}, spaces = [], ) { const map = new Map(); for (const group of groupByApp(collections)) { map.set(group.domain, { ...group, sessions: [] }); } for (const app of oauthApps) { const domain = clientHost(app.clientId); const entry = map.get(domain) || { domain, count: 0, collections: [], sessions: [], }; entry.sessions.push(app); map.set(domain, entry); } for (const [from, to] of Object.entries(aliases || {})) { const source = map.get(from); if (!source || from === to) continue; const target = map.get(to) || { domain: to, count: 0, collections: [], sessions: [], }; target.count += source.count; target.collections = [...target.collections, ...source.collections].sort( (a, b) => b.count - a.count, ); target.sessions = [...target.sessions, ...source.sessions]; target.folded = [...(target.folded || []), from]; map.delete(from); map.set(to, target); } const rows = [...map.values()]; for (const row of rows) { row.spaces = spacesForApp(spaces, [row.domain, ...(row.folded || [])]); } return rows.sort( (a, b) => (Date.parse(lastActiveAt(b) ?? '') || 0) - (Date.parse(lastActiveAt(a) ?? '') || 0) || b.count - a.count, ); } /** * The spaces one app manages, out of every space the account holds: a space * belongs to the app whose lexicon defines its type, or one of its permissioned * collections. * @template {{spaceType: string, collections?: {collection: string}[]}} T * @param {T[]|null|undefined} spaces * @param {string[]} domains - the app's domain, and any folded into it * @returns {T[]} */ export function spacesForApp(spaces, domains) { return (spaces || []).filter( (space) => domains.includes(nsidDomain(space.spaceType)) || (space.collections || []).some((collection) => domains.includes(nsidDomain(collection.collection)), ), ); } /** * The most recent time an app touched the account, as an ISO string, or null * when nothing is known: the newest of its OAuth sessions' access times, its * collections' latest record writes, and the latest writes to the spaces it * manages. So a data-only app reads as active by when it last wrote, not only * apps that hold a session, and an app that keeps its data in a shared space * reads as active by that. Used both to order the Apps list and to label each * row. * @param {{sessions?: {lastAccessedAt?: string|null}[], collections?: {lastRecordAt?: number|null}[], spaces?: {collections?: {lastRecordAt?: number|null}[]}[]}} app * @returns {string|null} */ export function lastActiveAt(app) { let newestMs = 0; for (const session of app.sessions || []) { const at = Date.parse(session.lastAccessedAt ?? ''); if (at > newestMs) newestMs = at; } const collections = [ ...(app.collections || []), ...(app.spaces || []).flatMap((space) => space.collections || []), ]; for (const collection of collections) { const at = collection.lastRecordAt; if (typeof at === 'number' && at > newestMs) newestMs = at; } return newestMs ? new Date(newestMs).toISOString() : null; }