···11+# Conference Mentions Integration
22+33+Surface Bluesky mentions of speakers during (and after) their talks, time-aligned with the transcript in the ionosphere.tv UI.
44+55+## Data Model
66+77+### `mentions` table (SQLite)
88+99+```sql
1010+CREATE TABLE mentions (
1111+ uri TEXT PRIMARY KEY, -- at:// URI of the Bluesky post
1212+ talk_uri TEXT, -- talk this aligns to (null for unaligned buzz)
1313+ author_did TEXT NOT NULL,
1414+ author_handle TEXT,
1515+ text TEXT,
1616+ created_at TEXT NOT NULL,
1717+ talk_offset_ms INTEGER, -- ms into the talk when posted
1818+ byte_position INTEGER, -- transcript byte position (from offset)
1919+ likes INTEGER DEFAULT 0,
2020+ reposts INTEGER DEFAULT 0,
2121+ replies INTEGER DEFAULT 0,
2222+ parent_uri TEXT, -- non-null for thread replies
2323+ mention_type TEXT DEFAULT 'during_talk', -- 'during_talk' | 'post_conference'
2424+ indexed_at TEXT NOT NULL
2525+);
2626+2727+CREATE INDEX idx_mentions_talk ON mentions(talk_uri, talk_offset_ms);
2828+CREATE INDEX idx_mentions_parent ON mentions(parent_uri);
2929+```
3030+3131+Thread replies share the parent's `talk_uri` and `byte_position`.
3232+3333+Author profiles reuse the existing `profiles` table (already caches handle, display_name, avatar_url from the Bluesky public API).
3434+3535+## Fetch Script: `scripts/fetch-mentions.mjs`
3636+3737+Enhanced version of the exploration scripts already built. Runs as a batch job, not a live service.
3838+3939+### During-talk mentions
4040+4141+For each talk with a schedule (`starts_at`, `ends_at`):
4242+1. Search `app.bsky.feed.searchPosts` with `mentions=<speaker_handle>`, `since=starts_at - 5min`, `until=ends_at + 30min`
4343+2. Paginate with cursors until exhausted (current scripts cap at 100)
4444+3. Compute `talk_offset_ms = mention.createdAt - talk.starts_at`
4545+4. Map offset to `byte_position` using transcript word-level timings
4646+5. For each mention with replies, fetch thread via `app.bsky.feed.getPostThread` (depth 1-2)
4747+6. Upsert into `mentions` table
4848+4949+### Post-conference mentions
5050+5151+Wider searches with no `until` bound:
5252+- `domain=ionosphere.tv` — posts linking to talk pages
5353+- `domain=stream.place` — posts linking to VODs
5454+- `mentions=<speaker_handle>` + `q=atmosphere OR atmosphereconf` with `since=2026-03-30`
5555+5656+These get `mention_type='post_conference'` and align to a talk by matching the speaker.
5757+5858+### Byte position mapping
5959+6060+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:
6161+6262+1. Walk the timings array, accumulating elapsed time
6363+2. When elapsed >= talk_offset_ms, return the current byte offset
6464+3. If the mention falls outside transcript range, use the nearest boundary
6565+6666+This is done at fetch time and stored, not computed on every request.
6767+6868+## API Endpoint
6969+7070+### `tv.ionosphere.getMentions`
7171+7272+```
7373+GET /xrpc/tv.ionosphere.getMentions?talkRkey=<rkey>
7474+```
7575+7676+Response:
7777+```json
7878+{
7979+ "mentions": [
8080+ {
8181+ "uri": "at://did:plc:.../app.bsky.feed.post/...",
8282+ "author_did": "did:plc:...",
8383+ "author_handle": "faineg.bsky.social",
8484+ "author_display_name": "Faine G",
8585+ "author_avatar_url": "https://...",
8686+ "text": "as @kissane notes...",
8787+ "created_at": "2026-03-28T21:32:15.000Z",
8888+ "talk_offset_ms": 872000,
8989+ "byte_position": 4521,
9090+ "likes": 137,
9191+ "reposts": 12,
9292+ "replies": 3,
9393+ "parent_uri": null,
9494+ "mention_type": "during_talk",
9595+ "thread": [
9696+ {
9797+ "uri": "at://...",
9898+ "author_handle": "...",
9999+ "author_display_name": "...",
100100+ "author_avatar_url": "...",
101101+ "text": "reply text...",
102102+ "created_at": "...",
103103+ "likes": 5
104104+ }
105105+ ]
106106+ }
107107+ ],
108108+ "total": 51
109109+}
110110+```
111111+112112+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).
113113+114114+## Frontend
115115+116116+### Right sidebar tabs
117117+118118+Add a "Mentions" tab alongside existing "Concepts" tab in `TalkContent.tsx`:
119119+120120+```
121121+[Concepts] [Mentions (51)]
122122+```
123123+124124+Tab count comes from the API response `total`.
125125+126126+### `MentionsSidebar` component
127127+128128+Renders mention cards in a scrollable column with pretext spacers for vertical alignment with the transcript.
129129+130130+**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.
131131+132132+**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.
133133+134134+**Mention card contents:**
135135+- Author avatar (18px circle) + handle + like count
136136+- Post text (truncated to ~120 chars, expandable)
137137+- "↳ N replies" link for threads
138138+- Click anywhere on card → seek video to `talk_offset_ms`
139139+140140+**Thread expansion:**
141141+- Clicking "↳ N replies" expands replies inline below the parent card
142142+- Reply cards are indented and slightly smaller
143143+- Spacers below recalculate on expand/collapse
144144+- Each reply is also clickable to open the full post on Bluesky (external link)
145145+146146+**Post-conference section:**
147147+- After all during-talk mentions, a divider: "After the conference"
148148+- Post-conference mentions listed chronologically, no time alignment
149149+- These don't scroll-sync with playback
150150+151151+### Mobile
152152+153153+Right sidebar is hidden on mobile (existing behavior). Mentions accessible via a tab/accordion below the transcript, same as concepts.
154154+155155+## Not in scope
156156+157157+- Real-time mention streaming or webhooks
158158+- Composing/replying to mentions from within ionosphere
159159+- Full-text search within mentions
160160+- Mentions of non-speaker topics (conference hashtags without speaker tags)
+199
scripts/align-mentions-to-talks.mjs
···11+/**
22+ * Align Bluesky mentions to specific talks by time window.
33+ *
44+ * For each talk, finds posts that:
55+ * 1. @-mention one of the talk's speakers
66+ * 2. Were posted during the talk or within a buffer window after
77+ *
88+ * Also searches for posts mentioning the talk title/topic during the window.
99+ */
1010+1111+import { createRequire } from 'module';
1212+const require = createRequire(
1313+ new URL('../apps/ionosphere-appview/package.json', import.meta.url).pathname
1414+);
1515+const { BskyAgent } = require('@atproto/api');
1616+const Database = require('better-sqlite3');
1717+1818+import { fileURLToPath } from 'url';
1919+import { dirname, join } from 'path';
2020+import { readFileSync, writeFileSync } from 'fs';
2121+2222+const __dirname = dirname(fileURLToPath(import.meta.url));
2323+const DB_PATH = join(__dirname, '..', 'apps', 'data', 'ionosphere.sqlite');
2424+2525+// Buffer: include posts up to 30 min after talk ends (people post after)
2626+const POST_BUFFER_MS = 30 * 60 * 1000;
2727+// Also include posts starting 5 min before (anticipation)
2828+const PRE_BUFFER_MS = 5 * 60 * 1000;
2929+3030+const agent = new BskyAgent({ service: 'https://bsky.social' });
3131+3232+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
3333+3434+async function searchMentions(handle, since, until) {
3535+ try {
3636+ const data = await agent.app.bsky.feed.searchPosts({
3737+ q: '*',
3838+ mentions: handle,
3939+ since,
4040+ until,
4141+ sort: 'latest',
4242+ limit: 100,
4343+ });
4444+ return data.data?.posts || [];
4545+ } catch (e) {
4646+ // Fallback: broader query
4747+ try {
4848+ const data = await agent.app.bsky.feed.searchPosts({
4949+ q: 'atmosphere OR atproto',
5050+ mentions: handle,
5151+ since,
5252+ until,
5353+ sort: 'latest',
5454+ limit: 100,
5555+ });
5656+ return data.data?.posts || [];
5757+ } catch {
5858+ return [];
5959+ }
6060+ }
6161+}
6262+6363+function getTalksWithSpeakers() {
6464+ const db = new Database(DB_PATH, { readonly: true });
6565+ const rows = db.prepare(`
6666+ SELECT DISTINCT t.uri, t.title, t.starts_at, t.ends_at, t.room,
6767+ s.name as speaker_name, s.handle as speaker_handle
6868+ FROM talks t
6969+ JOIN talk_speakers ts ON ts.talk_uri = t.uri
7070+ JOIN speakers s ON s.uri = ts.speaker_uri
7171+ WHERE t.starts_at IS NOT NULL AND t.ends_at IS NOT NULL
7272+ ORDER BY t.starts_at
7373+ `).all();
7474+ db.close();
7575+7676+ // Group by talk
7777+ const talks = new Map();
7878+ for (const r of rows) {
7979+ if (!talks.has(r.uri)) {
8080+ talks.set(r.uri, {
8181+ uri: r.uri,
8282+ title: r.title,
8383+ starts_at: r.starts_at,
8484+ ends_at: r.ends_at,
8585+ room: r.room,
8686+ speakers: [],
8787+ });
8888+ }
8989+ const t = talks.get(r.uri);
9090+ if (!t.speakers.find(s => s.handle === r.speaker_handle)) {
9191+ t.speakers.push({ name: r.speaker_name, handle: r.speaker_handle });
9292+ }
9393+ }
9494+9595+ // Deduplicate by title + start time
9696+ const seen = new Set();
9797+ return [...talks.values()].filter(t => {
9898+ const key = `${t.title}|${t.starts_at}`;
9999+ if (seen.has(key)) return false;
100100+ seen.add(key);
101101+ return true;
102102+ });
103103+}
104104+105105+async function main() {
106106+ console.log('=== Aligning Mentions to Talks ===\n');
107107+108108+ await agent.login({
109109+ identifier: 'ionosphere.tv',
110110+ password: process.env.BOT_PASSWORD,
111111+ });
112112+ console.log('Authenticated\n');
113113+114114+ const talks = getTalksWithSpeakers();
115115+ console.log(`${talks.length} talks with scheduled times\n`);
116116+117117+ const results = [];
118118+119119+ for (let i = 0; i < talks.length; i++) {
120120+ const talk = talks[i];
121121+ const talkStart = new Date(talk.starts_at);
122122+ const talkEnd = new Date(talk.ends_at);
123123+124124+ // Search window: 5min before to 30min after
125125+ const since = new Date(talkStart.getTime() - PRE_BUFFER_MS).toISOString();
126126+ const until = new Date(talkEnd.getTime() + POST_BUFFER_MS).toISOString();
127127+128128+ const allPosts = new Map();
129129+130130+ // Search mentions for each speaker
131131+ for (const speaker of talk.speakers) {
132132+ if (!speaker.handle) continue;
133133+ const posts = await searchMentions(speaker.handle, since, until);
134134+ for (const p of posts) {
135135+ allPosts.set(p.uri, {
136136+ uri: p.uri,
137137+ author: p.author.handle,
138138+ authorName: p.author.displayName,
139139+ text: p.record?.text,
140140+ createdAt: p.record?.createdAt,
141141+ likes: p.likeCount || 0,
142142+ reposts: p.repostCount || 0,
143143+ replies: p.replyCount || 0,
144144+ mentionedSpeaker: speaker.handle,
145145+ });
146146+ }
147147+ await sleep(150);
148148+ }
149149+150150+ const posts = [...allPosts.values()].sort((a, b) =>
151151+ new Date(a.createdAt) - new Date(b.createdAt)
152152+ );
153153+154154+ const entry = {
155155+ title: talk.title,
156156+ room: talk.room,
157157+ starts_at: talk.starts_at,
158158+ ends_at: talk.ends_at,
159159+ speakers: talk.speakers.map(s => `${s.name} (@${s.handle})`),
160160+ mentionCount: posts.length,
161161+ posts,
162162+ };
163163+ results.push(entry);
164164+165165+ if (posts.length > 0) {
166166+ console.log(`[${i + 1}/${talks.length}] "${talk.title}" — ${posts.length} mentions during talk`);
167167+ // Show top post
168168+ const top = posts.sort((a, b) => b.likes - a.likes)[0];
169169+ if (top) {
170170+ const snippet = top.text?.substring(0, 100).replace(/\n/g, ' ');
171171+ console.log(` Top: @${top.author} (${top.likes} likes): ${snippet}`);
172172+ }
173173+ } else {
174174+ console.log(`[${i + 1}/${talks.length}] "${talk.title}" — no mentions`);
175175+ }
176176+ }
177177+178178+ // Summary
179179+ const withMentions = results.filter(r => r.mentionCount > 0);
180180+ console.log('\n=== SUMMARY ===');
181181+ console.log(`Talks with mentions during their timeslot: ${withMentions.length}/${results.length}`);
182182+ console.log(`Total aligned mentions: ${results.reduce((s, r) => s + r.mentionCount, 0)}`);
183183+184184+ console.log('\n--- Most Buzzed Talks ---');
185185+ results
186186+ .filter(r => r.mentionCount > 0)
187187+ .sort((a, b) => b.mentionCount - a.mentionCount)
188188+ .slice(0, 25)
189189+ .forEach(r => {
190190+ console.log(` ${r.mentionCount.toString().padStart(3)} mentions: "${r.title}" (${r.speakers.join(', ')})`);
191191+ });
192192+193193+ // Save
194194+ const outPath = join(__dirname, '..', 'apps', 'data', 'talk-aligned-mentions.json');
195195+ writeFileSync(outPath, JSON.stringify(results, null, 2));
196196+ console.log(`\nData saved to ${outPath}`);
197197+}
198198+199199+main().catch(console.error);
+229
scripts/explore-mentions.mjs
···11+/**
22+ * Prototype: Explore Bluesky mentions of ATmosphereConf speakers
33+ *
44+ * Searches for:
55+ * 1. @mentions of each speaker during the conference (March 26-29, 2026)
66+ * 2. Conference-related hashtags and keywords
77+ */
88+99+import { createRequire } from 'module';
1010+const require = createRequire(
1111+ new URL('../apps/ionosphere-appview/package.json', import.meta.url).pathname
1212+);
1313+const { BskyAgent } = require('@atproto/api');
1414+const Database = require('better-sqlite3');
1515+1616+import { fileURLToPath } from 'url';
1717+import { dirname, join } from 'path';
1818+1919+const __dirname = dirname(fileURLToPath(import.meta.url));
2020+const DB_PATH = join(__dirname, '..', 'apps', 'data', 'ionosphere.sqlite');
2121+2222+const CONF_SINCE = '2026-03-25T00:00:00Z'; // day before for travel chatter
2323+const CONF_UNTIL = '2026-03-31T00:00:00Z'; // day after for wrap-up
2424+2525+const agent = new BskyAgent({ service: 'https://bsky.social' });
2626+2727+// Rate limit helper
2828+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
2929+3030+async function searchPosts(params) {
3131+ const res = await agent.app.bsky.feed.searchPosts(params);
3232+ return res.data;
3333+}
3434+3535+// Get speakers from DB
3636+function getSpeakers() {
3737+ const db = new Database(DB_PATH, { readonly: true });
3838+ const rows = db.prepare(`
3939+ SELECT DISTINCT name, handle FROM speakers
4040+ WHERE handle IS NOT NULL AND handle != '' AND name != 'Test Speaker'
4141+ ORDER BY name
4242+ `).all();
4343+ db.close();
4444+ return rows;
4545+}
4646+4747+// Search for mentions of a specific speaker handle
4848+async function searchMentionsOf(handle) {
4949+ try {
5050+ const data = await searchPosts({
5151+ q: '*',
5252+ mentions: handle,
5353+ since: CONF_SINCE,
5454+ until: CONF_UNTIL,
5555+ sort: 'latest',
5656+ limit: 100,
5757+ });
5858+ return data.posts || [];
5959+ } catch (e) {
6060+ // Try without wildcard
6161+ try {
6262+ const data = await searchPosts({
6363+ q: 'atmosphere OR atproto OR bluesky',
6464+ mentions: handle,
6565+ since: CONF_SINCE,
6666+ until: CONF_UNTIL,
6767+ sort: 'latest',
6868+ limit: 100,
6969+ });
7070+ return data.posts || [];
7171+ } catch (e2) {
7272+ console.error(` Error searching mentions of ${handle}: ${e2.message}`);
7373+ return [];
7474+ }
7575+ }
7676+}
7777+7878+// Search for general conference buzz
7979+async function searchConferenceBuzz() {
8080+ const queries = [
8181+ { q: 'atmosphere conf', label: '"atmosphere conf"' },
8282+ { q: 'atmosphereconf', label: '"atmosphereconf"' },
8383+ { q: '#atmosphere', label: '#atmosphere hashtag' },
8484+ { q: 'ATmosphere', label: '"ATmosphere"' },
8585+ { q: 'ionosphere.tv', label: '"ionosphere.tv"' },
8686+ ];
8787+8888+ const results = {};
8989+ for (const { q, label } of queries) {
9090+ try {
9191+ const data = await searchPosts({
9292+ q,
9393+ since: CONF_SINCE,
9494+ until: CONF_UNTIL,
9595+ sort: 'latest',
9696+ limit: 100,
9797+ });
9898+ results[label] = data.posts || [];
9999+ console.log(` "${label}": ${results[label].length} posts (hitsTotal: ${data.hitsTotal || '?'})`);
100100+ await sleep(200);
101101+ } catch (e) {
102102+ console.error(` Error searching "${label}": ${e.message}`);
103103+ results[label] = [];
104104+ }
105105+ }
106106+ return results;
107107+}
108108+109109+async function main() {
110110+ console.log('=== ATmosphereConf Bluesky Mentions Explorer ===\n');
111111+ console.log(`Conference window: ${CONF_SINCE} to ${CONF_UNTIL}\n`);
112112+113113+ // Authenticate
114114+ console.log('Logging in...');
115115+ await agent.login({
116116+ identifier: 'ionosphere.tv',
117117+ password: process.env.BOT_PASSWORD,
118118+ });
119119+ console.log('Authenticated as ionosphere.tv\n');
120120+121121+ // Phase 1: General conference buzz
122122+ console.log('--- Phase 1: Conference Buzz ---');
123123+ const buzz = await searchConferenceBuzz();
124124+125125+ // Collect unique posts from buzz
126126+ const buzzPosts = new Map();
127127+ for (const posts of Object.values(buzz)) {
128128+ for (const post of posts) {
129129+ buzzPosts.set(post.uri, post);
130130+ }
131131+ }
132132+ console.log(`\nTotal unique conference buzz posts: ${buzzPosts.size}\n`);
133133+134134+ // Show top buzz posts
135135+ if (buzzPosts.size > 0) {
136136+ console.log('--- Sample Conference Posts ---');
137137+ const sorted = [...buzzPosts.values()]
138138+ .sort((a, b) => (b.likeCount || 0) - (a.likeCount || 0))
139139+ .slice(0, 20);
140140+ for (const post of sorted) {
141141+ const text = post.record?.text?.substring(0, 120).replace(/\n/g, ' ');
142142+ console.log(` @${post.author.handle} (${post.likeCount || 0} likes): ${text}`);
143143+ }
144144+ console.log();
145145+ }
146146+147147+ // Phase 2: Speaker mentions
148148+ console.log('--- Phase 2: Speaker Mentions ---');
149149+ const speakers = getSpeakers();
150150+ console.log(`Searching mentions for ${speakers.length} speakers...\n`);
151151+152152+ const speakerMentions = [];
153153+ let searched = 0;
154154+155155+ for (const speaker of speakers) {
156156+ const posts = await searchMentionsOf(speaker.handle);
157157+ if (posts.length > 0) {
158158+ speakerMentions.push({ ...speaker, posts, count: posts.length });
159159+ console.log(` ✓ ${speaker.name} (@${speaker.handle}): ${posts.length} mentions`);
160160+ }
161161+ searched++;
162162+ if (searched % 20 === 0) {
163163+ console.log(` ... searched ${searched}/${speakers.length} speakers`);
164164+ }
165165+ await sleep(150); // be nice to the API
166166+ }
167167+168168+ // Summary
169169+ console.log('\n=== SUMMARY ===');
170170+ console.log(`Conference buzz posts: ${buzzPosts.size}`);
171171+ console.log(`Speakers with mentions: ${speakerMentions.length}/${speakers.length}`);
172172+ console.log(`Total speaker mention posts: ${speakerMentions.reduce((s, m) => s + m.count, 0)}`);
173173+174174+ if (speakerMentions.length > 0) {
175175+ console.log('\n--- Most Mentioned Speakers ---');
176176+ speakerMentions
177177+ .sort((a, b) => b.count - a.count)
178178+ .slice(0, 20)
179179+ .forEach(s => console.log(` ${s.count.toString().padStart(3)} mentions: ${s.name} (@${s.handle})`));
180180+ }
181181+182182+ // Collect all unique posts across everything
183183+ const allPosts = new Map(buzzPosts);
184184+ for (const s of speakerMentions) {
185185+ for (const p of s.posts) {
186186+ allPosts.set(p.uri, p);
187187+ }
188188+ }
189189+ console.log(`\nTotal unique posts found: ${allPosts.size}`);
190190+191191+ // Save raw data for further analysis
192192+ const output = {
193193+ searchWindow: { since: CONF_SINCE, until: CONF_UNTIL },
194194+ buzzQueries: Object.fromEntries(
195195+ Object.entries(buzz).map(([k, posts]) => [k, posts.length])
196196+ ),
197197+ speakerMentions: speakerMentions.map(s => ({
198198+ name: s.name,
199199+ handle: s.handle,
200200+ mentionCount: s.count,
201201+ posts: s.posts.map(p => ({
202202+ uri: p.uri,
203203+ author: p.author.handle,
204204+ text: p.record?.text,
205205+ createdAt: p.record?.createdAt,
206206+ likes: p.likeCount || 0,
207207+ reposts: p.repostCount || 0,
208208+ replies: p.replyCount || 0,
209209+ })),
210210+ })),
211211+ buzzPosts: [...buzzPosts.values()].map(p => ({
212212+ uri: p.uri,
213213+ author: p.author.handle,
214214+ text: p.record?.text,
215215+ createdAt: p.record?.createdAt,
216216+ likes: p.likeCount || 0,
217217+ reposts: p.repostCount || 0,
218218+ replies: p.replyCount || 0,
219219+ })),
220220+ totalUniquePosts: allPosts.size,
221221+ };
222222+223223+ const fs = await import('fs');
224224+ const outPath = join(__dirname, '..', 'apps', 'data', 'conference-mentions.json');
225225+ fs.writeFileSync(outPath, JSON.stringify(output, null, 2));
226226+ console.log(`\nRaw data saved to ${outPath}`);
227227+}
228228+229229+main().catch(console.error);