Last active
June 6, 2026 15:13
-
-
Save pixeline/11db8076caf89279bb26fa0351cc24c0 to your computer and use it in GitHub Desktop.
xbar plugin: AIM-style Bluesky buddy list in your macOS menu bar — live presence from the public Jetstream firehose, no login, no token. Note: xbar is not maintained so i forked and did some maintenance on it https://github.com/pixeline/xbar/releases/tag/v2.1.9
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/sh | |
| ':' //; for d in $(ls -1 "$HOME/.nvm/versions/node" 2>/dev/null | sort -rV); do c="$HOME/.nvm/versions/node/$d/bin/node"; [ -x "$c" ] && M=$("$c" -p process.versions.node 2>/dev/null | cut -d. -f1) && [ "${M:-0}" -ge 22 ] 2>/dev/null && exec "$c" "$0" "$@"; done; for c in "$HOME/.volta/bin/node" "$HOME/.asdf/shims/node" /opt/homebrew/bin/node /usr/local/bin/node /usr/bin/node; do [ -x "$c" ] && M=$("$c" -p process.versions.node 2>/dev/null | cut -d. -f1) && [ "${M:-0}" -ge 22 ] 2>/dev/null && exec "$c" "$0" "$@"; done; exec "$(command -v node)" "$0" "$@" | |
| // | |
| // Line 1-2 above are a sh/node polyglot: xbar execs the file, /bin/sh picks the | |
| // FIRST node >=22 it can find (nvm highest-version-first, then volta/asdf/ | |
| // homebrew/usr), re-execs it, and node reads line 2 as a comment. Node >=22 is | |
| // required for the global WebSocket that powers live presence; older nodes lack | |
| // it and the plugin falls back to "last-seen only". Falls back to any node if | |
| // none >=22 is found. (Previously used `ls -t | head -1`, which picked the | |
| // most-recently-modified nvm version — often an old one without WebSocket.) | |
| // | |
| // <xbar.title>Bluesky Buddy List (AIM)</xbar.title> | |
| // <xbar.version>v1.0</xbar.version> | |
| // <xbar.author>pixeline</xbar.author> | |
| // <xbar.author.github>pixeline</xbar.author.github> | |
| // <xbar.desc>AIM-style buddy list for Bluesky in your menu bar: shows which of your mutuals are online right now, with a door chime when someone signs on. Presence is behavioral, read from the public Jetstream firehose. No login, no token.</xbar.desc> | |
| // <xbar.dependencies>node</xbar.dependencies> | |
| // <xbar.abouturl>https://tangled.org/cee.wtf/aim</xbar.abouturl> | |
| // | |
| // Variables (xbar will prompt for these on install): | |
| // <xbar.var>string(VAR_BSKY_HANDLE=""): Your Bluesky handle, e.g. pixeline.be</xbar.var> | |
| // <xbar.var>boolean(VAR_DOOR_CHIME="true"): Play a sound when a buddy comes online</xbar.var> | |
| // <xbar.var>number(VAR_ONLINE_WINDOW="10"): Minutes of recent activity that count as "online"</xbar.var> | |
| // <xbar.var>boolean(VAR_LAST_SEEN="true"): Backfill "last seen" times for quiet buddies</xbar.var> | |
| // <xbar.var>boolean(VAR_AVATARS="true"): Show buddy avatar thumbnails in the list</xbar.var> | |
| // | |
| // --------------------------------------------------------------------------- | |
| // HOW IT WORKS | |
| // This one file plays two roles: | |
| // 1. Reader (default) - what xbar runs every 30s. Reads a state snapshot the | |
| // daemon writes, prints the menu, exits. Always fast. | |
| // 2. Daemon (--daemon) - a long-lived background process the reader spawns. It | |
| // resolves your mutuals, holds ONE filtered Jetstream | |
| // websocket, classifies presence, plays the door chime, | |
| // and writes state.json whenever anything changes. | |
| // The daemon's lifetime is tied to xbar polling it: the reader touches a | |
| // heartbeat file each refresh, and the daemon exits if the heartbeat goes stale | |
| // (i.e. you removed/disabled the plugin). No orphaned processes. | |
| // | |
| // Concepts ported from cee.wtf/aim (MIT) — the browser original. Notably: | |
| // - hundreds of DIDs must go in a hello message, NOT the URL, or the WS | |
| // handshake is refused. Connect with ?requireHello=true then send an | |
| // options_update with wantedDids on open. | |
| // - presence is behavioral: posted/reposted = online, only liked/followed = | |
| // idle (lurking), quiet = offline. | |
| // - door chimes are an original system sound, not AIM's copyrighted samples. | |
| // --------------------------------------------------------------------------- | |
| 'use strict'; | |
| const fs = require('fs'); | |
| const os = require('os'); | |
| const path = require('path'); | |
| const { spawn, execFile } = require('child_process'); | |
| // ---- config (from xbar vars, with plain-env / constant fallbacks) ---------- | |
| const HANDLE = (process.env.VAR_BSKY_HANDLE || process.env.BSKY_HANDLE || '').trim().replace(/^@/, ''); | |
| const DOOR_CHIME = (process.env.VAR_DOOR_CHIME || 'true') !== 'false'; | |
| const ONLINE_WINDOW_MIN = Number(process.env.VAR_ONLINE_WINDOW || 10) || 10; | |
| const LAST_SEEN = (process.env.VAR_LAST_SEEN || 'true') !== 'false'; | |
| const AVATARS = (process.env.VAR_AVATARS || 'true') !== 'false'; | |
| const APPVIEW = 'https://public.api.bsky.app'; | |
| const JETSTREAM = 'wss://jetstream2.us-east.bsky.network/subscribe'; | |
| const COLLECTIONS = [ | |
| 'app.bsky.feed.post', | |
| 'app.bsky.feed.repost', | |
| 'app.bsky.feed.like', | |
| 'app.bsky.graph.follow', | |
| ]; | |
| const DIR = path.join(os.tmpdir(), 'bluesky-aim'); | |
| const STATE_FILE = path.join(DIR, 'state.json'); | |
| const PID_FILE = path.join(DIR, 'daemon.pid'); | |
| const BEAT_FILE = path.join(DIR, 'heartbeat'); | |
| const CHIME_SOUND = '/System/Library/Sounds/Glass.aiff'; | |
| const ONLINE_WINDOW_MS = ONLINE_WINDOW_MIN * 60 * 1000; | |
| const IDLE_WINDOW_MS = 30 * 60 * 1000; // a "lurking" event keeps you yellow this long | |
| try { fs.mkdirSync(DIR, { recursive: true }); } catch (_) { } | |
| // =========================================================================== | |
| // ROLE SELECTION | |
| // =========================================================================== | |
| // `--restart` (menu action): kill the daemon + clear state, then fall through | |
| // to the reader, which respawns a fresh daemon and shows "connecting…". | |
| if (process.argv.includes('--restart')) { | |
| try { | |
| const pid = Number(fs.readFileSync(PID_FILE, 'utf8')); | |
| if (pid) process.kill(pid, 'SIGTERM'); | |
| } catch (_) { } | |
| try { fs.unlinkSync(PID_FILE); } catch (_) { } | |
| try { fs.unlinkSync(STATE_FILE); } catch (_) { } | |
| } | |
| if (process.argv.includes('--daemon')) { | |
| runDaemon(); | |
| } else { | |
| runReader(); | |
| } | |
| // =========================================================================== | |
| // READER — what xbar executes on its 30s schedule | |
| // =========================================================================== | |
| function runReader() { | |
| // tell the daemon we're still alive | |
| try { fs.writeFileSync(BEAT_FILE, String(Date.now())); } catch (_) { } | |
| if (!HANDLE) { | |
| console.log('Bluesky ⚙️'); | |
| console.log('---'); | |
| console.log('Set your handle in this plugin\'s settings | color=#ff4136'); | |
| console.log('xbar → Bluesky Buddy List → ⋯ → Open plugin… | refresh=true'); | |
| return; | |
| } | |
| ensureDaemon(); | |
| let state = null; | |
| try { | |
| state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); | |
| } catch (_) { | |
| // daemon hasn't produced a snapshot yet | |
| } | |
| if (!state) { | |
| console.log('Bluesky …'); | |
| console.log('---'); | |
| console.log('Connecting to the firehose… | color=#aaaaaa'); | |
| console.log('Refresh | refresh=true'); | |
| return; | |
| } | |
| if (state.error) { | |
| console.log('Bluesky ⚠️'); | |
| console.log('---'); | |
| console.log(`Couldn't load buddies: ${state.error} | color=#ff4136`); | |
| console.log(`Check your handle (@${state.handle || HANDLE}) and connection | size=11 color=#888888`); | |
| console.log('Retry | refresh=true'); | |
| return; | |
| } | |
| renderMenu(state); | |
| } | |
| function renderMenu(state) { | |
| const buddies = state.buddies || []; | |
| const online = buddies.filter((b) => b.status === 'online').sort(byRecent); | |
| const idle = buddies.filter((b) => b.status === 'idle').sort(byRecent); | |
| const offline = buddies.filter((b) => b.status === 'offline').sort(bySeen); | |
| const stale = Date.now() - (state.updatedAt || 0) > 90 * 1000; | |
| const live = !state.notice; | |
| // ---- menu bar ---- | |
| if (live) console.log(`🟢 ${online.length}${stale ? ' ⏳' : ''}`); | |
| else console.log(`👥 ${buddies.length}`); | |
| // ---- dropdown ---- | |
| console.log('---'); | |
| console.log(`Bluesky Buddies — @${state.handle} | href=https://bsky.app/profile/${state.handle} size=13`); | |
| console.log(`${buddies.length} mutuals · ${online.length} online · ${idle.length} idle | size=11 color=#8a8a8a`); | |
| if (state.notice) console.log(`⚠️ ${state.notice} | size=11 color=#ff851b`); | |
| console.log('---'); | |
| if (online.length) { | |
| console.log(`ONLINE — ${online.length} | size=10 color=#2ecc40`); | |
| online.forEach((b) => console.log(buddyRow(b))); | |
| } else if (live) { | |
| console.log(`No buddies online right now | size=12 color=#8a8a8a`); | |
| } | |
| if (idle.length) { | |
| console.log('---'); | |
| console.log(`IDLE — ${idle.length} | size=10 color=#d4a017`); | |
| idle.forEach((b) => console.log(buddyRow(b))); | |
| } | |
| if (offline.length) { | |
| console.log('---'); | |
| // a flat 100+ list is a wall; bucket by recency into submenus instead | |
| const now = Date.now(); | |
| const DAY = 86400 * 1000; | |
| const groups = [[], [], [], []]; | |
| const labels = ['Seen today', 'Seen this week', 'Seen this month', 'Dormant / unknown']; | |
| for (const b of offline) { | |
| const age = b.lastSeen ? now - b.lastSeen : Infinity; | |
| groups[age < DAY ? 0 : age < 7 * DAY ? 1 : age < 30 * DAY ? 2 : 3].push(b); | |
| } | |
| labels.forEach((label, i) => { | |
| if (!groups[i].length) return; | |
| console.log(`⚪ ${label} — ${groups[i].length}`); | |
| groups[i].forEach((b) => console.log('--' + buddyRow(b, true))); | |
| }); | |
| } | |
| console.log('---'); | |
| console.log(`Door chime: ${DOOR_CHIME ? 'on' : 'off'} | size=11 color=#8a8a8a`); | |
| console.log(`Reconnect firehose | bash="${process.argv[0]}" param1="${__filename}" param2="--restart" terminal=false refresh=true`); | |
| console.log('Refresh | refresh=true'); | |
| console.log(`aim.cee.wtf | href=https://aim.cee.wtf size=11 color=#8a8a8a`); | |
| } | |
| // One menu line per buddy. Name-primary (handles are long and clutter the list); | |
| // the @handle is shown for handle-only accounts and is always the click target. | |
| // No forced text color, so macOS uses the adaptive label color — legible in both | |
| // light and dark menus. | |
| function buddyRow(b, isOffline) { | |
| const display = (b.displayName || '').trim(); | |
| let label = display || ('@' + b.handle); | |
| if (isOffline && b.lastSeen) label += ' · ' + relTime(b.lastSeen); | |
| label = label.replace(/\|/g, '∣'); // the pipe is reserved by xbar | |
| let params = `href=https://bsky.app/profile/${b.handle}`; | |
| if (b.avatar) params += ` image=${b.avatar}`; | |
| return `${label} | ${params}`; | |
| } | |
| function byRecent(a, b) { return (b.lastActivity || 0) - (a.lastActivity || 0); } | |
| function bySeen(a, b) { return (b.lastSeen || 0) - (a.lastSeen || 0); } | |
| // ---- daemon supervision ----------------------------------------------------- | |
| function ensureDaemon() { | |
| if (daemonAlive()) return; | |
| // handle a one-off "--restart" action invoked from the menu | |
| const child = spawn(process.argv[0], [__filename, '--daemon'], { | |
| detached: true, | |
| stdio: 'ignore', | |
| env: process.env, | |
| }); | |
| try { fs.writeFileSync(PID_FILE, String(child.pid)); } catch (_) { } | |
| child.unref(); | |
| } | |
| function daemonAlive() { | |
| let pid; | |
| try { pid = Number(fs.readFileSync(PID_FILE, 'utf8')); } catch (_) { return false; } | |
| if (!pid) return false; | |
| try { process.kill(pid, 0); return true; } catch (_) { return false; } | |
| } | |
| // =========================================================================== | |
| // DAEMON — long-lived: holds the firehose, classifies presence, writes state | |
| // =========================================================================== | |
| async function runDaemon() { | |
| // single-instance guard | |
| process.title = 'bluesky-aim-daemon'; | |
| const startMs = Date.now(); | |
| const activity = new Map(); // did -> { post: ts, lurk: ts } | |
| const lastSeenMap = new Map(); // did -> ts (newest known public activity) | |
| const avatarB64 = new Map(); // did -> base64 png (16-36px), cached for daemon lifetime | |
| const chimedAt = new Map(); // did -> ts (debounce chimes) | |
| let prevStatus = new Map(); // did -> 'online'|'idle'|'offline' (for chime edge-detect) | |
| let profiles = new Map(); // did -> { handle, displayName } | |
| let mutuals = []; // [did] | |
| let handle = HANDLE; | |
| let did = null; | |
| let lastCursor = (startMs - ONLINE_WINDOW_MS) * 1000; // µs, seed a little replay | |
| // exit if xbar stops polling us (plugin disabled/removed) | |
| setInterval(() => { | |
| let beat = 0; | |
| try { beat = Number(fs.readFileSync(BEAT_FILE, 'utf8')); } catch (_) { } | |
| if (Date.now() - beat > 150 * 1000) cleanExit(0); | |
| }, 30 * 1000).unref(); | |
| function cleanExit(code) { | |
| try { fs.unlinkSync(PID_FILE); } catch (_) { } | |
| process.exit(code); | |
| } | |
| process.on('SIGTERM', () => cleanExit(0)); | |
| process.on('SIGINT', () => cleanExit(0)); | |
| // ---- resolve identity + mutuals -------------------------------------- | |
| try { | |
| did = await resolveHandle(handle); | |
| const [follows, followers] = await Promise.all([ | |
| listGraph('app.bsky.graph.getFollows', did, 'follows'), | |
| listGraph('app.bsky.graph.getFollowers', did, 'followers'), | |
| ]); | |
| const followerSet = new Set(followers); | |
| mutuals = follows.filter((d) => followerSet.has(d)); | |
| profiles = await fetchProfiles(mutuals); | |
| handle = (profiles.get(did) || {}).handle || handle; | |
| } catch (err) { | |
| writeState({ error: String(err && err.message || err), handle }); | |
| // exit; the reader respawns us on its next tick (≤30s). Avoids stacking | |
| // intervals/handlers on repeated retries. | |
| return cleanExit(1); | |
| } | |
| if (!mutuals.length) { | |
| writeState({ handle, buddies: [] }); | |
| } | |
| // optional: seed "last seen" for everyone, throttled | |
| if (LAST_SEEN) backfillLastSeen(mutuals).catch(() => { }); | |
| // optional: fetch + downscale avatars (macOS `sips`), throttled, best-effort | |
| if (AVATARS) backfillAvatars(mutuals).catch(() => { }); | |
| // ---- presence recompute loop ----------------------------------------- | |
| function statusOf(d) { | |
| const a = activity.get(d); | |
| if (!a) return 'offline'; | |
| const now = Date.now(); | |
| if (a.post && now - a.post < ONLINE_WINDOW_MS) return 'online'; | |
| if (a.lurk && now - a.lurk < IDLE_WINDOW_MS) return 'idle'; | |
| return 'offline'; | |
| } | |
| function snapshot() { | |
| const buddies = mutuals.map((d) => { | |
| const p = profiles.get(d) || {}; | |
| const a = activity.get(d) || {}; | |
| return { | |
| did: d, | |
| handle: p.handle || d, | |
| displayName: p.displayName || '', | |
| status: statusOf(d), | |
| lastActivity: Math.max(a.post || 0, a.lurk || 0) || 0, | |
| lastSeen: lastSeenMap.get(d) || 0, | |
| avatar: avatarB64.get(d) || '', | |
| }; | |
| }); | |
| return { | |
| handle, did, updatedAt: Date.now(), buddies, | |
| notice: wsAvailable() ? undefined : | |
| 'Live presence needs Node ≥22 or the "ws" package (npm i -g ws). Showing last-seen only.', | |
| }; | |
| } | |
| function publish() { | |
| const snap = snapshot(); | |
| // chime on offline/idle -> online transitions (live events only) | |
| if (DOOR_CHIME) { | |
| for (const b of snap.buddies) { | |
| const was = prevStatus.get(b.did) || 'offline'; | |
| const justOnline = b.status === 'online' && was !== 'online'; | |
| const isLive = b.lastActivity > startMs; // not a replayed event | |
| const cooled = Date.now() - (chimedAt.get(b.did) || 0) > 5 * 60 * 1000; | |
| if (justOnline && isLive && cooled) { | |
| chimedAt.set(b.did, Date.now()); | |
| chime(); | |
| } | |
| } | |
| } | |
| prevStatus = new Map(snap.buddies.map((b) => [b.did, b.status])); | |
| writeState(snap); | |
| } | |
| // recompute every 15s so people decay to idle/offline without new events | |
| setInterval(publish, 15 * 1000).unref(); | |
| // ---- firehose -------------------------------------------------------- | |
| // ---- firehose (only if this Node has a WebSocket; else last-seen only) - | |
| if (wsAvailable()) { | |
| connectFirehose(); | |
| } else { | |
| // No WebSocket on this Node. The recompute loop + last-seen backfill still | |
| // produce a useful buddy list; presence dots just won't go live. | |
| publish(); | |
| } | |
| function connectFirehose() { | |
| const url = `${JETSTREAM}?requireHello=true&cursor=${Math.floor(lastCursor)}`; | |
| let ws; | |
| try { | |
| ws = new WS(url); | |
| } catch (err) { | |
| return setTimeout(connectFirehose, 5000); | |
| } | |
| ws.addEventListener('open', () => { | |
| // THE gotcha: wantedDids go here, not in the URL. | |
| ws.send(JSON.stringify({ | |
| type: 'options_update', | |
| payload: { wantedCollections: COLLECTIONS, wantedDids: mutuals }, | |
| })); | |
| }); | |
| ws.addEventListener('message', (ev) => { | |
| let msg; | |
| try { msg = JSON.parse(ev.data); } catch (_) { return; } | |
| if (msg.time_us) lastCursor = msg.time_us; | |
| if (msg.kind !== 'commit' || !msg.commit) return; | |
| const op = msg.commit.operation; | |
| if (op !== 'create' && op !== 'update') return; | |
| const d = msg.did; | |
| const when = msg.time_us ? Math.floor(msg.time_us / 1000) : Date.now(); | |
| const a = activity.get(d) || {}; | |
| if (msg.commit.collection === 'app.bsky.feed.post' || | |
| msg.commit.collection === 'app.bsky.feed.repost') { | |
| a.post = Math.max(a.post || 0, when); | |
| } else { | |
| a.lurk = Math.max(a.lurk || 0, when); | |
| } | |
| activity.set(d, a); | |
| if (when > (lastSeenMap.get(d) || 0)) lastSeenMap.set(d, when); | |
| publish(); | |
| }); | |
| const reconnect = () => setTimeout(connectFirehose, 3000); | |
| ws.addEventListener('close', reconnect); | |
| ws.addEventListener('error', () => { try { ws.close(); } catch (_) { } }); | |
| } | |
| // ---- last-seen backfill (throttled, best effort) --------------------- | |
| async function backfillLastSeen(dids) { | |
| const queue = dids.slice(); | |
| const workers = Array.from({ length: 4 }, async () => { | |
| while (queue.length) { | |
| const d = queue.shift(); | |
| if (activity.get(d)) continue; // already have live data | |
| try { | |
| const r = await xrpc('app.bsky.feed.getAuthorFeed', { | |
| actor: d, limit: 1, filter: 'posts_and_author_threads', | |
| }); | |
| const item = r.feed && r.feed[0]; | |
| const t = item && item.post && (item.post.indexedAt || item.post.record?.createdAt); | |
| if (t) lastSeenMap.set(d, Date.parse(t)); | |
| } catch (_) { } | |
| } | |
| }); | |
| await Promise.all(workers); | |
| publish(); | |
| } | |
| // ---- avatar backfill: fetch CDN avatar, downscale with macOS `sips` ---- | |
| // Best-effort and fully guarded: any failure just means no thumbnail for that | |
| // buddy (the text row still renders). Cached for the daemon's lifetime. | |
| async function backfillAvatars(dids) { | |
| const queue = dids.filter((d) => (profiles.get(d) || {}).avatar); | |
| let done = 0; | |
| const workers = Array.from({ length: 4 }, async () => { | |
| while (queue.length) { | |
| const d = queue.shift(); | |
| const b64 = await makeAvatar(d, (profiles.get(d) || {}).avatar); | |
| if (b64) avatarB64.set(d, b64); | |
| if (++done % 16 === 0) publish(); // stream them in as they arrive | |
| } | |
| }); | |
| await Promise.all(workers); | |
| publish(); | |
| } | |
| async function makeAvatar(did, url) { | |
| const safe = did.replace(/[^a-z0-9]/gi, '_'); | |
| const src = path.join(DIR, `av_${safe}.src`); | |
| const out = path.join(DIR, `av_${safe}.png`); | |
| try { | |
| const res = await fetch(url); | |
| if (!res.ok) return null; | |
| fs.writeFileSync(src, Buffer.from(await res.arrayBuffer())); | |
| // -Z 36 = fit within 36px (retina-friendly for a ~18px menu row) | |
| await execFileP('sips', ['-s', 'format', 'png', '-Z', '36', src, '--out', out]); | |
| return fs.readFileSync(out).toString('base64'); | |
| } catch (_) { | |
| return null; | |
| } finally { | |
| try { fs.unlinkSync(src); } catch (_) { } | |
| try { fs.unlinkSync(out); } catch (_) { } | |
| } | |
| } | |
| function chime() { | |
| try { | |
| if (fs.existsSync(CHIME_SOUND)) execFile('afplay', [CHIME_SOUND]); | |
| } catch (_) { } | |
| } | |
| function writeState(obj) { | |
| const tmp = STATE_FILE + '.tmp'; | |
| try { | |
| fs.writeFileSync(tmp, JSON.stringify(obj)); | |
| fs.renameSync(tmp, STATE_FILE); | |
| } catch (_) { } | |
| } | |
| } | |
| // =========================================================================== | |
| // atproto helpers (public, unauthenticated, CORS-friendly endpoints) | |
| // =========================================================================== | |
| async function xrpc(method, params) { | |
| const qs = new URLSearchParams(); | |
| for (const [k, v] of Object.entries(params || {})) { | |
| if (Array.isArray(v)) v.forEach((x) => qs.append(k, x)); | |
| else qs.append(k, v); | |
| } | |
| const res = await fetch(`${APPVIEW}/xrpc/${method}?${qs.toString()}`, { | |
| headers: { accept: 'application/json' }, | |
| }); | |
| if (!res.ok) throw new Error(`${method} → HTTP ${res.status}`); | |
| return res.json(); | |
| } | |
| async function resolveHandle(handle) { | |
| const r = await xrpc('com.atproto.identity.resolveHandle', { handle }); | |
| if (!r.did) throw new Error(`could not resolve @${handle}`); | |
| return r.did; | |
| } | |
| async function listGraph(method, actor, key) { | |
| const out = []; | |
| let cursor; | |
| do { | |
| const r = await xrpc(method, cursor ? { actor, limit: 100, cursor } : { actor, limit: 100 }); | |
| for (const item of r[key] || []) out.push(item.did); | |
| cursor = r.cursor; | |
| } while (cursor); | |
| return out; | |
| } | |
| async function fetchProfiles(dids) { | |
| const map = new Map(); | |
| for (let i = 0; i < dids.length; i += 25) { | |
| const batch = dids.slice(i, i + 25); | |
| try { | |
| const r = await xrpc('app.bsky.actor.getProfiles', { actors: batch }); | |
| for (const p of r.profiles || []) { | |
| map.set(p.did, { handle: p.handle, displayName: p.displayName || '', avatar: p.avatar || '' }); | |
| } | |
| } catch (_) { } | |
| } | |
| return map; | |
| } | |
| // ---- WebSocket: prefer Node 22+ global, fall back to the `ws` package ------- | |
| function wsAvailable() { | |
| if (typeof WebSocket === 'function') return true; | |
| try { require.resolve('ws'); return true; } catch (_) { return false; } | |
| } | |
| function WS(url) { | |
| if (typeof WebSocket === 'function') return new WebSocket(url); | |
| try { | |
| const WsLib = require('ws'); | |
| const sock = new WsLib(url); | |
| // normalize to addEventListener/.data shape used above | |
| sock.addEventListener = (type, fn) => | |
| sock.on(type, (arg) => fn(type === 'message' ? { data: arg.toString() } : arg)); | |
| return sock; | |
| } catch (_) { | |
| throw new Error('No WebSocket. Use Node ≥22, or run: npm i -g ws'); | |
| } | |
| } | |
| // ---- tiny time formatter ---------------------------------------------------- | |
| function execFileP(cmd, args) { | |
| return new Promise((resolve, reject) => { | |
| execFile(cmd, args, { timeout: 8000 }, (err, stdout) => (err ? reject(err) : resolve(stdout))); | |
| }); | |
| } | |
| function relTime(ms) { | |
| if (!ms) return ''; | |
| const s = Math.max(0, (Date.now() - ms) / 1000); | |
| if (s < 90) return 'just now'; | |
| const m = s / 60; | |
| if (m < 90) return `${Math.round(m)}m ago`; | |
| const h = m / 60; | |
| if (h < 36) return `${Math.round(h)}h ago`; | |
| return `${Math.round(h / 24)}d ago`; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment