Ionosphere.tv
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

docs: conference mentions design spec and exploration scripts

Explored Bluesky search API to find conference mentions during
ATmosphereConf talks. Found 1,947 unique posts across 117 speakers,
with 708 mentions time-aligned to specific talk windows.

Design spec covers: SQLite storage, paginated fetch with thread
following, byte-position alignment with transcripts, right sidebar
UI with scroll-synced mention cards.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+589
+1
.gitignore
··· 17 17 *.DS_Store 18 18 .sprite 19 19 .claude/worktrees 20 + .superpowers/
+160
docs/superpowers/specs/2026-04-12-conference-mentions-design.md
··· 1 + # Conference Mentions Integration 2 + 3 + Surface Bluesky mentions of speakers during (and after) their talks, time-aligned with the transcript in the ionosphere.tv UI. 4 + 5 + ## Data Model 6 + 7 + ### `mentions` table (SQLite) 8 + 9 + ```sql 10 + CREATE TABLE mentions ( 11 + uri TEXT PRIMARY KEY, -- at:// URI of the Bluesky post 12 + talk_uri TEXT, -- talk this aligns to (null for unaligned buzz) 13 + author_did TEXT NOT NULL, 14 + author_handle TEXT, 15 + text TEXT, 16 + created_at TEXT NOT NULL, 17 + talk_offset_ms INTEGER, -- ms into the talk when posted 18 + byte_position INTEGER, -- transcript byte position (from offset) 19 + likes INTEGER DEFAULT 0, 20 + reposts INTEGER DEFAULT 0, 21 + replies INTEGER DEFAULT 0, 22 + parent_uri TEXT, -- non-null for thread replies 23 + mention_type TEXT DEFAULT 'during_talk', -- 'during_talk' | 'post_conference' 24 + indexed_at TEXT NOT NULL 25 + ); 26 + 27 + CREATE INDEX idx_mentions_talk ON mentions(talk_uri, talk_offset_ms); 28 + CREATE INDEX idx_mentions_parent ON mentions(parent_uri); 29 + ``` 30 + 31 + Thread replies share the parent's `talk_uri` and `byte_position`. 32 + 33 + Author profiles reuse the existing `profiles` table (already caches handle, display_name, avatar_url from the Bluesky public API). 34 + 35 + ## Fetch Script: `scripts/fetch-mentions.mjs` 36 + 37 + Enhanced version of the exploration scripts already built. Runs as a batch job, not a live service. 38 + 39 + ### During-talk mentions 40 + 41 + For each talk with a schedule (`starts_at`, `ends_at`): 42 + 1. Search `app.bsky.feed.searchPosts` with `mentions=<speaker_handle>`, `since=starts_at - 5min`, `until=ends_at + 30min` 43 + 2. Paginate with cursors until exhausted (current scripts cap at 100) 44 + 3. Compute `talk_offset_ms = mention.createdAt - talk.starts_at` 45 + 4. Map offset to `byte_position` using transcript word-level timings 46 + 5. For each mention with replies, fetch thread via `app.bsky.feed.getPostThread` (depth 1-2) 47 + 6. Upsert into `mentions` table 48 + 49 + ### Post-conference mentions 50 + 51 + Wider searches with no `until` bound: 52 + - `domain=ionosphere.tv` — posts linking to talk pages 53 + - `domain=stream.place` — posts linking to VODs 54 + - `mentions=<speaker_handle>` + `q=atmosphere OR atmosphereconf` with `since=2026-03-30` 55 + 56 + These get `mention_type='post_conference'` and align to a talk by matching the speaker. 57 + 58 + ### Byte position mapping 59 + 60 + The transcript stores word-level timings as a compact array (positive = word duration ms, negative = silence gap ms). To map a `talk_offset_ms` to a byte position: 61 + 62 + 1. Walk the timings array, accumulating elapsed time 63 + 2. When elapsed >= talk_offset_ms, return the current byte offset 64 + 3. If the mention falls outside transcript range, use the nearest boundary 65 + 66 + This is done at fetch time and stored, not computed on every request. 67 + 68 + ## API Endpoint 69 + 70 + ### `tv.ionosphere.getMentions` 71 + 72 + ``` 73 + GET /xrpc/tv.ionosphere.getMentions?talkRkey=<rkey> 74 + ``` 75 + 76 + Response: 77 + ```json 78 + { 79 + "mentions": [ 80 + { 81 + "uri": "at://did:plc:.../app.bsky.feed.post/...", 82 + "author_did": "did:plc:...", 83 + "author_handle": "faineg.bsky.social", 84 + "author_display_name": "Faine G", 85 + "author_avatar_url": "https://...", 86 + "text": "as @kissane notes...", 87 + "created_at": "2026-03-28T21:32:15.000Z", 88 + "talk_offset_ms": 872000, 89 + "byte_position": 4521, 90 + "likes": 137, 91 + "reposts": 12, 92 + "replies": 3, 93 + "parent_uri": null, 94 + "mention_type": "during_talk", 95 + "thread": [ 96 + { 97 + "uri": "at://...", 98 + "author_handle": "...", 99 + "author_display_name": "...", 100 + "author_avatar_url": "...", 101 + "text": "reply text...", 102 + "created_at": "...", 103 + "likes": 5 104 + } 105 + ] 106 + } 107 + ], 108 + "total": 51 109 + } 110 + ``` 111 + 112 + Backend query joins `mentions` with `profiles` for author enrichment. Thread replies are nested under their parent. Sorted by `talk_offset_ms` (during-talk first, post-conference after). 113 + 114 + ## Frontend 115 + 116 + ### Right sidebar tabs 117 + 118 + Add a "Mentions" tab alongside existing "Concepts" tab in `TalkContent.tsx`: 119 + 120 + ``` 121 + [Concepts] [Mentions (51)] 122 + ``` 123 + 124 + Tab count comes from the API response `total`. 125 + 126 + ### `MentionsSidebar` component 127 + 128 + Renders mention cards in a scrollable column with pretext spacers for vertical alignment with the transcript. 129 + 130 + **Scroll sync:** Listens to `TimestampProvider` context. Uses the same scroll-position logic as `TranscriptView` — maps current playback nanoseconds to a byte position, then scrolls to keep the matching mention near the viewport center. 131 + 132 + **Pretext spacers:** Each mention card is positioned using top-padding calculated from its `byte_position` relative to the previous mention's position. When a thread is expanded/collapsed, spacers below are recalculated to maintain alignment. 133 + 134 + **Mention card contents:** 135 + - Author avatar (18px circle) + handle + like count 136 + - Post text (truncated to ~120 chars, expandable) 137 + - "↳ N replies" link for threads 138 + - Click anywhere on card → seek video to `talk_offset_ms` 139 + 140 + **Thread expansion:** 141 + - Clicking "↳ N replies" expands replies inline below the parent card 142 + - Reply cards are indented and slightly smaller 143 + - Spacers below recalculate on expand/collapse 144 + - Each reply is also clickable to open the full post on Bluesky (external link) 145 + 146 + **Post-conference section:** 147 + - After all during-talk mentions, a divider: "After the conference" 148 + - Post-conference mentions listed chronologically, no time alignment 149 + - These don't scroll-sync with playback 150 + 151 + ### Mobile 152 + 153 + Right sidebar is hidden on mobile (existing behavior). Mentions accessible via a tab/accordion below the transcript, same as concepts. 154 + 155 + ## Not in scope 156 + 157 + - Real-time mention streaming or webhooks 158 + - Composing/replying to mentions from within ionosphere 159 + - Full-text search within mentions 160 + - Mentions of non-speaker topics (conference hashtags without speaker tags)
+199
scripts/align-mentions-to-talks.mjs
··· 1 + /** 2 + * Align Bluesky mentions to specific talks by time window. 3 + * 4 + * For each talk, finds posts that: 5 + * 1. @-mention one of the talk's speakers 6 + * 2. Were posted during the talk or within a buffer window after 7 + * 8 + * Also searches for posts mentioning the talk title/topic during the window. 9 + */ 10 + 11 + import { createRequire } from 'module'; 12 + const require = createRequire( 13 + new URL('../apps/ionosphere-appview/package.json', import.meta.url).pathname 14 + ); 15 + const { BskyAgent } = require('@atproto/api'); 16 + const Database = require('better-sqlite3'); 17 + 18 + import { fileURLToPath } from 'url'; 19 + import { dirname, join } from 'path'; 20 + import { readFileSync, writeFileSync } from 'fs'; 21 + 22 + const __dirname = dirname(fileURLToPath(import.meta.url)); 23 + const DB_PATH = join(__dirname, '..', 'apps', 'data', 'ionosphere.sqlite'); 24 + 25 + // Buffer: include posts up to 30 min after talk ends (people post after) 26 + const POST_BUFFER_MS = 30 * 60 * 1000; 27 + // Also include posts starting 5 min before (anticipation) 28 + const PRE_BUFFER_MS = 5 * 60 * 1000; 29 + 30 + const agent = new BskyAgent({ service: 'https://bsky.social' }); 31 + 32 + function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } 33 + 34 + async function searchMentions(handle, since, until) { 35 + try { 36 + const data = await agent.app.bsky.feed.searchPosts({ 37 + q: '*', 38 + mentions: handle, 39 + since, 40 + until, 41 + sort: 'latest', 42 + limit: 100, 43 + }); 44 + return data.data?.posts || []; 45 + } catch (e) { 46 + // Fallback: broader query 47 + try { 48 + const data = await agent.app.bsky.feed.searchPosts({ 49 + q: 'atmosphere OR atproto', 50 + mentions: handle, 51 + since, 52 + until, 53 + sort: 'latest', 54 + limit: 100, 55 + }); 56 + return data.data?.posts || []; 57 + } catch { 58 + return []; 59 + } 60 + } 61 + } 62 + 63 + function getTalksWithSpeakers() { 64 + const db = new Database(DB_PATH, { readonly: true }); 65 + const rows = db.prepare(` 66 + SELECT DISTINCT t.uri, t.title, t.starts_at, t.ends_at, t.room, 67 + s.name as speaker_name, s.handle as speaker_handle 68 + FROM talks t 69 + JOIN talk_speakers ts ON ts.talk_uri = t.uri 70 + JOIN speakers s ON s.uri = ts.speaker_uri 71 + WHERE t.starts_at IS NOT NULL AND t.ends_at IS NOT NULL 72 + ORDER BY t.starts_at 73 + `).all(); 74 + db.close(); 75 + 76 + // Group by talk 77 + const talks = new Map(); 78 + for (const r of rows) { 79 + if (!talks.has(r.uri)) { 80 + talks.set(r.uri, { 81 + uri: r.uri, 82 + title: r.title, 83 + starts_at: r.starts_at, 84 + ends_at: r.ends_at, 85 + room: r.room, 86 + speakers: [], 87 + }); 88 + } 89 + const t = talks.get(r.uri); 90 + if (!t.speakers.find(s => s.handle === r.speaker_handle)) { 91 + t.speakers.push({ name: r.speaker_name, handle: r.speaker_handle }); 92 + } 93 + } 94 + 95 + // Deduplicate by title + start time 96 + const seen = new Set(); 97 + return [...talks.values()].filter(t => { 98 + const key = `${t.title}|${t.starts_at}`; 99 + if (seen.has(key)) return false; 100 + seen.add(key); 101 + return true; 102 + }); 103 + } 104 + 105 + async function main() { 106 + console.log('=== Aligning Mentions to Talks ===\n'); 107 + 108 + await agent.login({ 109 + identifier: 'ionosphere.tv', 110 + password: process.env.BOT_PASSWORD, 111 + }); 112 + console.log('Authenticated\n'); 113 + 114 + const talks = getTalksWithSpeakers(); 115 + console.log(`${talks.length} talks with scheduled times\n`); 116 + 117 + const results = []; 118 + 119 + for (let i = 0; i < talks.length; i++) { 120 + const talk = talks[i]; 121 + const talkStart = new Date(talk.starts_at); 122 + const talkEnd = new Date(talk.ends_at); 123 + 124 + // Search window: 5min before to 30min after 125 + const since = new Date(talkStart.getTime() - PRE_BUFFER_MS).toISOString(); 126 + const until = new Date(talkEnd.getTime() + POST_BUFFER_MS).toISOString(); 127 + 128 + const allPosts = new Map(); 129 + 130 + // Search mentions for each speaker 131 + for (const speaker of talk.speakers) { 132 + if (!speaker.handle) continue; 133 + const posts = await searchMentions(speaker.handle, since, until); 134 + for (const p of posts) { 135 + allPosts.set(p.uri, { 136 + uri: p.uri, 137 + author: p.author.handle, 138 + authorName: p.author.displayName, 139 + text: p.record?.text, 140 + createdAt: p.record?.createdAt, 141 + likes: p.likeCount || 0, 142 + reposts: p.repostCount || 0, 143 + replies: p.replyCount || 0, 144 + mentionedSpeaker: speaker.handle, 145 + }); 146 + } 147 + await sleep(150); 148 + } 149 + 150 + const posts = [...allPosts.values()].sort((a, b) => 151 + new Date(a.createdAt) - new Date(b.createdAt) 152 + ); 153 + 154 + const entry = { 155 + title: talk.title, 156 + room: talk.room, 157 + starts_at: talk.starts_at, 158 + ends_at: talk.ends_at, 159 + speakers: talk.speakers.map(s => `${s.name} (@${s.handle})`), 160 + mentionCount: posts.length, 161 + posts, 162 + }; 163 + results.push(entry); 164 + 165 + if (posts.length > 0) { 166 + console.log(`[${i + 1}/${talks.length}] "${talk.title}" — ${posts.length} mentions during talk`); 167 + // Show top post 168 + const top = posts.sort((a, b) => b.likes - a.likes)[0]; 169 + if (top) { 170 + const snippet = top.text?.substring(0, 100).replace(/\n/g, ' '); 171 + console.log(` Top: @${top.author} (${top.likes} likes): ${snippet}`); 172 + } 173 + } else { 174 + console.log(`[${i + 1}/${talks.length}] "${talk.title}" — no mentions`); 175 + } 176 + } 177 + 178 + // Summary 179 + const withMentions = results.filter(r => r.mentionCount > 0); 180 + console.log('\n=== SUMMARY ==='); 181 + console.log(`Talks with mentions during their timeslot: ${withMentions.length}/${results.length}`); 182 + console.log(`Total aligned mentions: ${results.reduce((s, r) => s + r.mentionCount, 0)}`); 183 + 184 + console.log('\n--- Most Buzzed Talks ---'); 185 + results 186 + .filter(r => r.mentionCount > 0) 187 + .sort((a, b) => b.mentionCount - a.mentionCount) 188 + .slice(0, 25) 189 + .forEach(r => { 190 + console.log(` ${r.mentionCount.toString().padStart(3)} mentions: "${r.title}" (${r.speakers.join(', ')})`); 191 + }); 192 + 193 + // Save 194 + const outPath = join(__dirname, '..', 'apps', 'data', 'talk-aligned-mentions.json'); 195 + writeFileSync(outPath, JSON.stringify(results, null, 2)); 196 + console.log(`\nData saved to ${outPath}`); 197 + } 198 + 199 + main().catch(console.error);
+229
scripts/explore-mentions.mjs
··· 1 + /** 2 + * Prototype: Explore Bluesky mentions of ATmosphereConf speakers 3 + * 4 + * Searches for: 5 + * 1. @mentions of each speaker during the conference (March 26-29, 2026) 6 + * 2. Conference-related hashtags and keywords 7 + */ 8 + 9 + import { createRequire } from 'module'; 10 + const require = createRequire( 11 + new URL('../apps/ionosphere-appview/package.json', import.meta.url).pathname 12 + ); 13 + const { BskyAgent } = require('@atproto/api'); 14 + const Database = require('better-sqlite3'); 15 + 16 + import { fileURLToPath } from 'url'; 17 + import { dirname, join } from 'path'; 18 + 19 + const __dirname = dirname(fileURLToPath(import.meta.url)); 20 + const DB_PATH = join(__dirname, '..', 'apps', 'data', 'ionosphere.sqlite'); 21 + 22 + const CONF_SINCE = '2026-03-25T00:00:00Z'; // day before for travel chatter 23 + const CONF_UNTIL = '2026-03-31T00:00:00Z'; // day after for wrap-up 24 + 25 + const agent = new BskyAgent({ service: 'https://bsky.social' }); 26 + 27 + // Rate limit helper 28 + function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } 29 + 30 + async function searchPosts(params) { 31 + const res = await agent.app.bsky.feed.searchPosts(params); 32 + return res.data; 33 + } 34 + 35 + // Get speakers from DB 36 + function getSpeakers() { 37 + const db = new Database(DB_PATH, { readonly: true }); 38 + const rows = db.prepare(` 39 + SELECT DISTINCT name, handle FROM speakers 40 + WHERE handle IS NOT NULL AND handle != '' AND name != 'Test Speaker' 41 + ORDER BY name 42 + `).all(); 43 + db.close(); 44 + return rows; 45 + } 46 + 47 + // Search for mentions of a specific speaker handle 48 + async function searchMentionsOf(handle) { 49 + try { 50 + const data = await searchPosts({ 51 + q: '*', 52 + mentions: handle, 53 + since: CONF_SINCE, 54 + until: CONF_UNTIL, 55 + sort: 'latest', 56 + limit: 100, 57 + }); 58 + return data.posts || []; 59 + } catch (e) { 60 + // Try without wildcard 61 + try { 62 + const data = await searchPosts({ 63 + q: 'atmosphere OR atproto OR bluesky', 64 + mentions: handle, 65 + since: CONF_SINCE, 66 + until: CONF_UNTIL, 67 + sort: 'latest', 68 + limit: 100, 69 + }); 70 + return data.posts || []; 71 + } catch (e2) { 72 + console.error(` Error searching mentions of ${handle}: ${e2.message}`); 73 + return []; 74 + } 75 + } 76 + } 77 + 78 + // Search for general conference buzz 79 + async function searchConferenceBuzz() { 80 + const queries = [ 81 + { q: 'atmosphere conf', label: '"atmosphere conf"' }, 82 + { q: 'atmosphereconf', label: '"atmosphereconf"' }, 83 + { q: '#atmosphere', label: '#atmosphere hashtag' }, 84 + { q: 'ATmosphere', label: '"ATmosphere"' }, 85 + { q: 'ionosphere.tv', label: '"ionosphere.tv"' }, 86 + ]; 87 + 88 + const results = {}; 89 + for (const { q, label } of queries) { 90 + try { 91 + const data = await searchPosts({ 92 + q, 93 + since: CONF_SINCE, 94 + until: CONF_UNTIL, 95 + sort: 'latest', 96 + limit: 100, 97 + }); 98 + results[label] = data.posts || []; 99 + console.log(` "${label}": ${results[label].length} posts (hitsTotal: ${data.hitsTotal || '?'})`); 100 + await sleep(200); 101 + } catch (e) { 102 + console.error(` Error searching "${label}": ${e.message}`); 103 + results[label] = []; 104 + } 105 + } 106 + return results; 107 + } 108 + 109 + async function main() { 110 + console.log('=== ATmosphereConf Bluesky Mentions Explorer ===\n'); 111 + console.log(`Conference window: ${CONF_SINCE} to ${CONF_UNTIL}\n`); 112 + 113 + // Authenticate 114 + console.log('Logging in...'); 115 + await agent.login({ 116 + identifier: 'ionosphere.tv', 117 + password: process.env.BOT_PASSWORD, 118 + }); 119 + console.log('Authenticated as ionosphere.tv\n'); 120 + 121 + // Phase 1: General conference buzz 122 + console.log('--- Phase 1: Conference Buzz ---'); 123 + const buzz = await searchConferenceBuzz(); 124 + 125 + // Collect unique posts from buzz 126 + const buzzPosts = new Map(); 127 + for (const posts of Object.values(buzz)) { 128 + for (const post of posts) { 129 + buzzPosts.set(post.uri, post); 130 + } 131 + } 132 + console.log(`\nTotal unique conference buzz posts: ${buzzPosts.size}\n`); 133 + 134 + // Show top buzz posts 135 + if (buzzPosts.size > 0) { 136 + console.log('--- Sample Conference Posts ---'); 137 + const sorted = [...buzzPosts.values()] 138 + .sort((a, b) => (b.likeCount || 0) - (a.likeCount || 0)) 139 + .slice(0, 20); 140 + for (const post of sorted) { 141 + const text = post.record?.text?.substring(0, 120).replace(/\n/g, ' '); 142 + console.log(` @${post.author.handle} (${post.likeCount || 0} likes): ${text}`); 143 + } 144 + console.log(); 145 + } 146 + 147 + // Phase 2: Speaker mentions 148 + console.log('--- Phase 2: Speaker Mentions ---'); 149 + const speakers = getSpeakers(); 150 + console.log(`Searching mentions for ${speakers.length} speakers...\n`); 151 + 152 + const speakerMentions = []; 153 + let searched = 0; 154 + 155 + for (const speaker of speakers) { 156 + const posts = await searchMentionsOf(speaker.handle); 157 + if (posts.length > 0) { 158 + speakerMentions.push({ ...speaker, posts, count: posts.length }); 159 + console.log(` ✓ ${speaker.name} (@${speaker.handle}): ${posts.length} mentions`); 160 + } 161 + searched++; 162 + if (searched % 20 === 0) { 163 + console.log(` ... searched ${searched}/${speakers.length} speakers`); 164 + } 165 + await sleep(150); // be nice to the API 166 + } 167 + 168 + // Summary 169 + console.log('\n=== SUMMARY ==='); 170 + console.log(`Conference buzz posts: ${buzzPosts.size}`); 171 + console.log(`Speakers with mentions: ${speakerMentions.length}/${speakers.length}`); 172 + console.log(`Total speaker mention posts: ${speakerMentions.reduce((s, m) => s + m.count, 0)}`); 173 + 174 + if (speakerMentions.length > 0) { 175 + console.log('\n--- Most Mentioned Speakers ---'); 176 + speakerMentions 177 + .sort((a, b) => b.count - a.count) 178 + .slice(0, 20) 179 + .forEach(s => console.log(` ${s.count.toString().padStart(3)} mentions: ${s.name} (@${s.handle})`)); 180 + } 181 + 182 + // Collect all unique posts across everything 183 + const allPosts = new Map(buzzPosts); 184 + for (const s of speakerMentions) { 185 + for (const p of s.posts) { 186 + allPosts.set(p.uri, p); 187 + } 188 + } 189 + console.log(`\nTotal unique posts found: ${allPosts.size}`); 190 + 191 + // Save raw data for further analysis 192 + const output = { 193 + searchWindow: { since: CONF_SINCE, until: CONF_UNTIL }, 194 + buzzQueries: Object.fromEntries( 195 + Object.entries(buzz).map(([k, posts]) => [k, posts.length]) 196 + ), 197 + speakerMentions: speakerMentions.map(s => ({ 198 + name: s.name, 199 + handle: s.handle, 200 + mentionCount: s.count, 201 + posts: s.posts.map(p => ({ 202 + uri: p.uri, 203 + author: p.author.handle, 204 + text: p.record?.text, 205 + createdAt: p.record?.createdAt, 206 + likes: p.likeCount || 0, 207 + reposts: p.repostCount || 0, 208 + replies: p.replyCount || 0, 209 + })), 210 + })), 211 + buzzPosts: [...buzzPosts.values()].map(p => ({ 212 + uri: p.uri, 213 + author: p.author.handle, 214 + text: p.record?.text, 215 + createdAt: p.record?.createdAt, 216 + likes: p.likeCount || 0, 217 + reposts: p.repostCount || 0, 218 + replies: p.replyCount || 0, 219 + })), 220 + totalUniquePosts: allPosts.size, 221 + }; 222 + 223 + const fs = await import('fs'); 224 + const outPath = join(__dirname, '..', 'apps', 'data', 'conference-mentions.json'); 225 + fs.writeFileSync(outPath, JSON.stringify(output, null, 2)); 226 + console.log(`\nRaw data saved to ${outPath}`); 227 + } 228 + 229 + main().catch(console.error);