Ionosphere.tv
3
fork

Configure Feed

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

docs: conference mentions implementation plan

7-task plan: DB schema, paginated fetch script with threads and byte
mapping, API endpoint, frontend client, MentionsSidebar component
with scroll sync, tabbed sidebar integration, e2e verification.

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

+964
+964
docs/superpowers/plans/2026-04-12-conference-mentions.md
··· 1 + # Conference Mentions Implementation Plan 2 + 3 + > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. 4 + 5 + **Goal:** Surface time-aligned Bluesky mentions of speakers in the ionosphere.tv talk detail sidebar, with paginated fetching, thread following, and post-conference mentions. 6 + 7 + **Architecture:** A batch fetch script pulls mentions from the Bluesky search API, computes byte positions from transcript timings, and stores them in SQLite. A new XRPC endpoint serves mentions per talk. The frontend adds a "Mentions" tab to the right sidebar with scroll-synced mention cards using pretext spacers for vertical alignment. 8 + 9 + **Tech Stack:** Node.js (fetch script), SQLite (storage), Hono (API), React/Next.js (frontend), `@atproto/api` (Bluesky SDK) 10 + 11 + --- 12 + 13 + ## File Structure 14 + 15 + | File | Action | Responsibility | 16 + |------|--------|---------------| 17 + | `apps/ionosphere-appview/src/db.ts` | Modify | Add `mentions` table schema | 18 + | `apps/ionosphere-appview/src/routes.ts` | Modify | Add `getMentions` endpoint | 19 + | `apps/ionosphere/src/lib/api.ts` | Modify | Add `getMentions()` client function | 20 + | `apps/ionosphere/src/app/talks/[rkey]/page.tsx` | Modify | Fetch mentions server-side, pass to TalkContent | 21 + | `apps/ionosphere/src/app/talks/[rkey]/TalkContent.tsx` | Modify | Add tab system, render MentionsSidebar | 22 + | `apps/ionosphere/src/app/components/MentionsSidebar.tsx` | Create | Scroll-synced mention cards with thread expansion | 23 + | `scripts/fetch-mentions.mjs` | Create | Paginated fetch, thread following, byte mapping | 24 + 25 + --- 26 + 27 + ## Task 1: Database Schema 28 + 29 + **Files:** 30 + - Modify: `apps/ionosphere-appview/src/db.ts:167` (after comments table, before profiles table) 31 + 32 + - [ ] **Step 1: Add mentions table to schema** 33 + 34 + In `db.ts`, add after the comments index (line 166) and before the profiles table (line 168): 35 + 36 + ```sql 37 + CREATE TABLE IF NOT EXISTS mentions ( 38 + uri TEXT PRIMARY KEY, 39 + talk_uri TEXT, 40 + author_did TEXT NOT NULL, 41 + author_handle TEXT, 42 + text TEXT, 43 + created_at TEXT NOT NULL, 44 + talk_offset_ms INTEGER, 45 + byte_position INTEGER, 46 + likes INTEGER DEFAULT 0, 47 + reposts INTEGER DEFAULT 0, 48 + replies INTEGER DEFAULT 0, 49 + parent_uri TEXT, 50 + mention_type TEXT DEFAULT 'during_talk', 51 + indexed_at TEXT NOT NULL 52 + ); 53 + 54 + CREATE INDEX IF NOT EXISTS idx_mentions_talk ON mentions(talk_uri, talk_offset_ms); 55 + CREATE INDEX IF NOT EXISTS idx_mentions_parent ON mentions(parent_uri); 56 + ``` 57 + 58 + - [ ] **Step 2: Verify schema applies** 59 + 60 + Run: `cd apps/ionosphere-appview && npx tsx src/db.ts 2>&1 || echo "Check if db module exports migrate"` 61 + 62 + If `db.ts` doesn't have a standalone entry point, verify by checking the appview starts: 63 + ```bash 64 + sqlite3 apps/data/ionosphere.sqlite ".tables" | tr ' ' '\n' | sort 65 + ``` 66 + 67 + The `mentions` table should appear. If not, start the appview briefly or run the migrate function directly. 68 + 69 + - [ ] **Step 3: Commit** 70 + 71 + ```bash 72 + git add apps/ionosphere-appview/src/db.ts 73 + git commit -m "feat: add mentions table to SQLite schema" 74 + ``` 75 + 76 + --- 77 + 78 + ## Task 2: Fetch Script with Pagination and Threads 79 + 80 + **Files:** 81 + - Create: `scripts/fetch-mentions.mjs` 82 + 83 + This replaces the prototype scripts. Key improvements: cursor pagination, thread fetching, byte-position mapping, SQLite storage. 84 + 85 + - [ ] **Step 1: Write the fetch script** 86 + 87 + Create `scripts/fetch-mentions.mjs`: 88 + 89 + ```javascript 90 + import { createRequire } from 'module'; 91 + const require = createRequire( 92 + new URL('../apps/ionosphere-appview/package.json', import.meta.url).pathname 93 + ); 94 + const { BskyAgent } = require('@atproto/api'); 95 + const Database = require('better-sqlite3'); 96 + 97 + import { fileURLToPath } from 'url'; 98 + import { dirname, join } from 'path'; 99 + 100 + const __dirname = dirname(fileURLToPath(import.meta.url)); 101 + const DB_PATH = join(__dirname, '..', 'apps', 'data', 'ionosphere.sqlite'); 102 + 103 + const CONF_SINCE = '2026-03-25T00:00:00Z'; 104 + const CONF_UNTIL = '2026-03-31T00:00:00Z'; 105 + const PRE_BUFFER_MS = 5 * 60 * 1000; 106 + const POST_BUFFER_MS = 30 * 60 * 1000; 107 + 108 + const agent = new BskyAgent({ service: 'https://bsky.social' }); 109 + 110 + function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } 111 + 112 + // ── Byte position mapping ────────────────────────────────────────── 113 + 114 + function mapOffsetToBytePosition(talkOffsetMs, compactTranscript) { 115 + if (!compactTranscript) return null; 116 + const { text, startMs, timings } = compactTranscript; 117 + const words = text.split(/\s+/).filter(w => w.length > 0); 118 + const encoder = new TextEncoder(); 119 + 120 + let cursorMs = startMs; 121 + let wordIndex = 0; 122 + let searchFrom = 0; 123 + let lastBytePos = 0; 124 + 125 + for (const value of timings) { 126 + if (value < 0) { 127 + cursorMs += Math.abs(value); 128 + } else { 129 + if (wordIndex < words.length) { 130 + const word = words[wordIndex]; 131 + const idx = text.indexOf(word, searchFrom); 132 + if (idx !== -1) { 133 + const bytePos = encoder.encode(text.slice(0, idx)).length; 134 + if (cursorMs >= talkOffsetMs) return bytePos; 135 + lastBytePos = bytePos; 136 + searchFrom = idx + word.length; 137 + } 138 + cursorMs += value; 139 + wordIndex++; 140 + } 141 + } 142 + } 143 + return lastBytePos; 144 + } 145 + 146 + // ── Search with pagination ───────────────────────────────────────── 147 + 148 + async function searchAllMentions(handle, since, until) { 149 + const allPosts = []; 150 + let cursor = undefined; 151 + 152 + for (let page = 0; page < 10; page++) { 153 + try { 154 + const params = { q: '*', mentions: handle, since, until, sort: 'latest', limit: 100 }; 155 + if (cursor) params.cursor = cursor; 156 + 157 + const res = await agent.app.bsky.feed.searchPosts(params); 158 + const posts = res.data?.posts || []; 159 + allPosts.push(...posts); 160 + 161 + cursor = res.data?.cursor; 162 + if (!cursor || posts.length < 100) break; 163 + await sleep(200); 164 + } catch (e) { 165 + // Fallback without wildcard 166 + try { 167 + const params = { q: 'atmosphere OR atproto', mentions: handle, since, until, sort: 'latest', limit: 100 }; 168 + if (cursor) params.cursor = cursor; 169 + const res = await agent.app.bsky.feed.searchPosts(params); 170 + allPosts.push(...(res.data?.posts || [])); 171 + break; 172 + } catch { break; } 173 + } 174 + } 175 + return allPosts; 176 + } 177 + 178 + // ── Thread fetching ──────────────────────────────────────────────── 179 + 180 + async function fetchThread(uri) { 181 + try { 182 + const res = await agent.app.bsky.feed.getPostThread({ uri, depth: 2 }); 183 + const thread = res.data?.thread; 184 + if (!thread?.replies) return []; 185 + 186 + return thread.replies 187 + .filter(r => r.$type === 'app.bsky.feed.defs#threadViewPost') 188 + .map(r => ({ 189 + uri: r.post.uri, 190 + author: r.post.author, 191 + text: r.post.record?.text, 192 + createdAt: r.post.record?.createdAt, 193 + likes: r.post.likeCount || 0, 194 + reposts: r.post.repostCount || 0, 195 + replies: r.post.replyCount || 0, 196 + })); 197 + } catch { 198 + return []; 199 + } 200 + } 201 + 202 + // ── Post-conference mentions ─────────────────────────────────────── 203 + 204 + async function searchPostConference(handle) { 205 + const allPosts = []; 206 + for (const q of ['atmosphere OR atmosphereconf', 'ionosphere.tv']) { 207 + try { 208 + const res = await agent.app.bsky.feed.searchPosts({ 209 + q, mentions: handle, since: '2026-03-30T00:00:00Z', sort: 'latest', limit: 100 210 + }); 211 + for (const p of (res.data?.posts || [])) allPosts.push(p); 212 + await sleep(200); 213 + } catch { /* skip */ } 214 + } 215 + // Also search by domain 216 + try { 217 + const res = await agent.app.bsky.feed.searchPosts({ 218 + q: '*', since: '2026-03-30T00:00:00Z', domain: 'ionosphere.tv', sort: 'latest', limit: 100 219 + }); 220 + for (const p of (res.data?.posts || [])) allPosts.push(p); 221 + } catch { /* skip */ } 222 + 223 + // Deduplicate 224 + const seen = new Set(); 225 + return allPosts.filter(p => { if (seen.has(p.uri)) return false; seen.add(p.uri); return true; }); 226 + } 227 + 228 + // ── Main ─────────────────────────────────────────────────────────── 229 + 230 + async function main() { 231 + console.log('=== Fetch Mentions → SQLite ===\n'); 232 + 233 + await agent.login({ 234 + identifier: 'ionosphere.tv', 235 + password: process.env.BOT_PASSWORD, 236 + }); 237 + console.log('Authenticated\n'); 238 + 239 + const db = new Database(DB_PATH); 240 + 241 + // Ensure table exists 242 + db.exec(` 243 + CREATE TABLE IF NOT EXISTS mentions ( 244 + uri TEXT PRIMARY KEY, 245 + talk_uri TEXT, 246 + author_did TEXT NOT NULL, 247 + author_handle TEXT, 248 + text TEXT, 249 + created_at TEXT NOT NULL, 250 + talk_offset_ms INTEGER, 251 + byte_position INTEGER, 252 + likes INTEGER DEFAULT 0, 253 + reposts INTEGER DEFAULT 0, 254 + replies INTEGER DEFAULT 0, 255 + parent_uri TEXT, 256 + mention_type TEXT DEFAULT 'during_talk', 257 + indexed_at TEXT NOT NULL 258 + ); 259 + CREATE INDEX IF NOT EXISTS idx_mentions_talk ON mentions(talk_uri, talk_offset_ms); 260 + CREATE INDEX IF NOT EXISTS idx_mentions_parent ON mentions(parent_uri); 261 + `); 262 + 263 + const upsert = db.prepare(` 264 + INSERT OR REPLACE INTO mentions 265 + (uri, talk_uri, author_did, author_handle, text, created_at, 266 + talk_offset_ms, byte_position, likes, reposts, replies, 267 + parent_uri, mention_type, indexed_at) 268 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 269 + `); 270 + 271 + // Load talks with speakers and transcripts 272 + const talks = db.prepare(` 273 + SELECT DISTINCT t.uri, t.rkey, t.title, t.starts_at, t.ends_at, t.room, 274 + s.name as speaker_name, s.handle as speaker_handle 275 + FROM talks t 276 + JOIN talk_speakers ts ON ts.talk_uri = t.uri 277 + JOIN speakers s ON s.uri = ts.speaker_uri 278 + WHERE t.starts_at IS NOT NULL AND t.ends_at IS NOT NULL 279 + ORDER BY t.starts_at 280 + `).all(); 281 + 282 + // Group by talk 283 + const talkMap = new Map(); 284 + for (const row of talks) { 285 + if (!talkMap.has(row.uri)) { 286 + talkMap.set(row.uri, { ...row, speakers: [] }); 287 + } 288 + const t = talkMap.get(row.uri); 289 + if (!t.speakers.find(s => s.handle === row.speaker_handle)) { 290 + t.speakers.push({ name: row.speaker_name, handle: row.speaker_handle }); 291 + } 292 + } 293 + 294 + // Load transcripts for byte mapping 295 + const transcriptStmt = db.prepare(` 296 + SELECT document FROM transcripts WHERE talk_uri = ? LIMIT 1 297 + `); 298 + 299 + let totalMentions = 0; 300 + let totalThreadReplies = 0; 301 + const talkList = [...talkMap.values()]; 302 + 303 + for (let i = 0; i < talkList.length; i++) { 304 + const talk = talkList[i]; 305 + const talkStart = new Date(talk.starts_at); 306 + const talkEnd = new Date(talk.ends_at); 307 + const since = new Date(talkStart.getTime() - PRE_BUFFER_MS).toISOString(); 308 + const until = new Date(talkEnd.getTime() + POST_BUFFER_MS).toISOString(); 309 + 310 + // Get transcript for byte mapping 311 + const transcriptRow = transcriptStmt.get(talk.uri); 312 + let compact = null; 313 + if (transcriptRow?.document) { 314 + try { compact = JSON.parse(transcriptRow.document); } catch {} 315 + } 316 + 317 + const allPosts = new Map(); 318 + 319 + // During-talk mentions per speaker 320 + for (const speaker of talk.speakers) { 321 + if (!speaker.handle) continue; 322 + const posts = await searchAllMentions(speaker.handle, since, until); 323 + for (const p of posts) { 324 + if (!allPosts.has(p.uri)) allPosts.set(p.uri, p); 325 + } 326 + await sleep(150); 327 + } 328 + 329 + // Process and store 330 + const insertMany = db.transaction((posts) => { 331 + for (const p of posts) { 332 + const createdAt = new Date(p.record?.createdAt); 333 + const offsetMs = createdAt.getTime() - talkStart.getTime(); 334 + const bytePos = compact ? mapOffsetToBytePosition(offsetMs, compact) : null; 335 + 336 + upsert.run( 337 + p.uri, talk.uri, p.author.did, p.author.handle, 338 + p.record?.text, p.record?.createdAt, 339 + offsetMs, bytePos, 340 + p.likeCount || 0, p.repostCount || 0, p.replyCount || 0, 341 + null, 'during_talk', new Date().toISOString() 342 + ); 343 + } 344 + }); 345 + 346 + const posts = [...allPosts.values()]; 347 + if (posts.length > 0) insertMany(posts); 348 + totalMentions += posts.length; 349 + 350 + // Fetch threads for posts with replies 351 + const postsWithReplies = posts.filter(p => (p.replyCount || 0) > 0); 352 + for (const p of postsWithReplies) { 353 + const replies = await fetchThread(p.uri); 354 + for (const reply of replies) { 355 + const parentCreatedAt = new Date(p.record?.createdAt); 356 + const parentOffsetMs = parentCreatedAt.getTime() - talkStart.getTime(); 357 + const parentBytePos = compact ? mapOffsetToBytePosition(parentOffsetMs, compact) : null; 358 + 359 + upsert.run( 360 + reply.uri, talk.uri, reply.author.did, reply.author.handle, 361 + reply.text, reply.createdAt, 362 + parentOffsetMs, parentBytePos, 363 + reply.likes, reply.reposts, reply.replies, 364 + p.uri, 'during_talk', new Date().toISOString() 365 + ); 366 + totalThreadReplies++; 367 + } 368 + await sleep(200); 369 + } 370 + 371 + if (posts.length > 0) { 372 + console.log(`[${i + 1}/${talkList.length}] "${talk.title}" — ${posts.length} mentions, ${postsWithReplies.length} threads`); 373 + } else { 374 + console.log(`[${i + 1}/${talkList.length}] "${talk.title}" — no mentions`); 375 + } 376 + } 377 + 378 + // Post-conference mentions 379 + console.log('\n--- Post-conference mentions ---'); 380 + const speakerHandles = [...new Set(talkList.flatMap(t => t.speakers.map(s => s.handle)).filter(Boolean))]; 381 + let postConfCount = 0; 382 + 383 + // Domain search for ionosphere.tv links 384 + try { 385 + const res = await agent.app.bsky.feed.searchPosts({ 386 + q: '*', domain: 'ionosphere.tv', since: '2026-03-30T00:00:00Z', sort: 'latest', limit: 100 387 + }); 388 + const posts = res.data?.posts || []; 389 + const insertPostConf = db.transaction((posts) => { 390 + for (const p of posts) { 391 + upsert.run( 392 + p.uri, null, p.author.did, p.author.handle, 393 + p.record?.text, p.record?.createdAt, 394 + null, null, 395 + p.likeCount || 0, p.repostCount || 0, p.replyCount || 0, 396 + null, 'post_conference', new Date().toISOString() 397 + ); 398 + } 399 + }); 400 + insertPostConf(posts); 401 + postConfCount += posts.length; 402 + console.log(` ionosphere.tv domain: ${posts.length} posts`); 403 + } catch (e) { 404 + console.error(` ionosphere.tv domain search failed: ${e.message}`); 405 + } 406 + 407 + // stream.place domain 408 + try { 409 + const res = await agent.app.bsky.feed.searchPosts({ 410 + q: 'atmosphere', domain: 'stream.place', since: '2026-03-30T00:00:00Z', sort: 'latest', limit: 100 411 + }); 412 + const posts = res.data?.posts || []; 413 + for (const p of posts) { 414 + upsert.run( 415 + p.uri, null, p.author.did, p.author.handle, 416 + p.record?.text, p.record?.createdAt, 417 + null, null, 418 + p.likeCount || 0, p.repostCount || 0, p.replyCount || 0, 419 + null, 'post_conference', new Date().toISOString() 420 + ); 421 + } 422 + postConfCount += posts.length; 423 + console.log(` stream.place domain: ${posts.length} posts`); 424 + } catch (e) { 425 + console.error(` stream.place domain search failed: ${e.message}`); 426 + } 427 + 428 + await sleep(200); 429 + 430 + console.log(`\n=== DONE ===`); 431 + console.log(`During-talk mentions: ${totalMentions}`); 432 + console.log(`Thread replies: ${totalThreadReplies}`); 433 + console.log(`Post-conference: ${postConfCount}`); 434 + console.log(`Total stored: ${db.prepare('SELECT COUNT(*) as c FROM mentions').get().c}`); 435 + 436 + db.close(); 437 + } 438 + 439 + main().catch(console.error); 440 + ``` 441 + 442 + - [ ] **Step 2: Run the fetch script** 443 + 444 + ```bash 445 + source apps/ionosphere-appview/.env && BOT_PASSWORD="$BOT_PASSWORD" node scripts/fetch-mentions.mjs 446 + ``` 447 + 448 + Expected: Iterates through ~120 talks, stores mentions with byte positions into SQLite. Should take 5-10 minutes due to API rate limiting. 449 + 450 + - [ ] **Step 3: Verify data** 451 + 452 + ```bash 453 + sqlite3 apps/data/ionosphere.sqlite "SELECT COUNT(*) FROM mentions;" 454 + sqlite3 apps/data/ionosphere.sqlite "SELECT mention_type, COUNT(*) FROM mentions GROUP BY mention_type;" 455 + sqlite3 apps/data/ionosphere.sqlite "SELECT m.talk_offset_ms, m.byte_position, m.text FROM mentions m WHERE m.talk_uri IS NOT NULL AND m.byte_position IS NOT NULL LIMIT 5;" 456 + ``` 457 + 458 + Expected: 2000+ rows, mix of during_talk and post_conference, byte_position populated for during-talk mentions. 459 + 460 + - [ ] **Step 4: Commit** 461 + 462 + ```bash 463 + git add scripts/fetch-mentions.mjs 464 + git commit -m "feat: paginated mention fetcher with threads and byte mapping" 465 + ``` 466 + 467 + --- 468 + 469 + ## Task 3: API Endpoint 470 + 471 + **Files:** 472 + - Modify: `apps/ionosphere-appview/src/routes.ts:273` (after getComments, before getConceptClusters) 473 + 474 + - [ ] **Step 1: Add getMentions route** 475 + 476 + Insert after line 272 (end of getComments handler) in `routes.ts`: 477 + 478 + ```typescript 479 + app.get("/xrpc/tv.ionosphere.getMentions", (c) => { 480 + const talkRkey = c.req.query("talkRkey"); 481 + if (!talkRkey) return c.json({ mentions: [], total: 0 }); 482 + 483 + const talk = db.prepare("SELECT uri FROM talks WHERE rkey = ?").get(talkRkey) as any; 484 + if (!talk) return c.json({ mentions: [], total: 0 }); 485 + 486 + // Fetch top-level mentions (parent_uri IS NULL) 487 + const topLevel = db.prepare( 488 + `SELECT m.*, p.handle as author_handle, p.display_name as author_display_name, p.avatar_url as author_avatar_url 489 + FROM mentions m 490 + LEFT JOIN profiles p ON m.author_did = p.did 491 + WHERE m.talk_uri = ? AND m.parent_uri IS NULL 492 + ORDER BY 493 + CASE m.mention_type WHEN 'during_talk' THEN 0 ELSE 1 END, 494 + m.talk_offset_ms ASC, 495 + m.created_at ASC` 496 + ).all(talk.uri); 497 + 498 + // Fetch thread replies for each top-level mention 499 + const replyStmt = db.prepare( 500 + `SELECT m.*, p.handle as author_handle, p.display_name as author_display_name, p.avatar_url as author_avatar_url 501 + FROM mentions m 502 + LEFT JOIN profiles p ON m.author_did = p.did 503 + WHERE m.parent_uri = ? 504 + ORDER BY m.created_at ASC` 505 + ); 506 + 507 + const mentions = topLevel.map((m: any) => ({ 508 + ...m, 509 + thread: replyStmt.all(m.uri), 510 + })); 511 + 512 + return c.json({ mentions, total: mentions.length }); 513 + }); 514 + ``` 515 + 516 + - [ ] **Step 2: Test the endpoint** 517 + 518 + Start the appview and curl: 519 + ```bash 520 + curl -s 'http://localhost:3001/xrpc/tv.ionosphere.getMentions?talkRkey=landslide' | node -e "process.stdin.on('data',d=>{const j=JSON.parse(d);console.log('total:',j.total);j.mentions.slice(0,3).forEach(m=>console.log(m.author_handle,m.talk_offset_ms,m.text?.slice(0,60)))})" 521 + ``` 522 + 523 + Expected: Returns mentions array sorted by talk_offset_ms with nested thread replies. 524 + 525 + - [ ] **Step 3: Commit** 526 + 527 + ```bash 528 + git add apps/ionosphere-appview/src/routes.ts 529 + git commit -m "feat: add getMentions XRPC endpoint" 530 + ``` 531 + 532 + --- 533 + 534 + ## Task 4: Frontend API Client and Data Fetching 535 + 536 + **Files:** 537 + - Modify: `apps/ionosphere/src/lib/api.ts:31` (after getConcept) 538 + - Modify: `apps/ionosphere/src/app/talks/[rkey]/page.tsx:36` (add mentions fetch) 539 + 540 + - [ ] **Step 1: Add getMentions to api.ts** 541 + 542 + Add after `getConcept` (line 31): 543 + 544 + ```typescript 545 + export async function getMentions(talkRkey: string) { 546 + return fetchApi<{ mentions: any[]; total: number }>(`/xrpc/tv.ionosphere.getMentions?talkRkey=${encodeURIComponent(talkRkey)}`); 547 + } 548 + ``` 549 + 550 + - [ ] **Step 2: Fetch mentions in page.tsx** 551 + 552 + Update `page.tsx` to fetch mentions server-side and pass to TalkContent: 553 + 554 + ```typescript 555 + import { getTalk, getTalks, getMentions } from "@/lib/api"; 556 + ``` 557 + 558 + Update the `TalkPage` component (line 34-38): 559 + 560 + ```typescript 561 + export default async function TalkPage({ params }: { params: Promise<{ rkey: string }> }) { 562 + const { rkey } = await params; 563 + const [{ talk, speakers, concepts }, { mentions }] = await Promise.all([ 564 + getTalk(rkey), 565 + getMentions(rkey), 566 + ]); 567 + 568 + return <TalkContent talk={talk} speakers={speakers} concepts={concepts} mentions={mentions} />; 569 + } 570 + ``` 571 + 572 + - [ ] **Step 3: Update TalkContent props** 573 + 574 + In `TalkContent.tsx`, update the interface (line 10-13): 575 + 576 + ```typescript 577 + interface TalkContentProps { 578 + talk: any; 579 + speakers: any[]; 580 + concepts: any[]; 581 + mentions: any[]; 582 + } 583 + ``` 584 + 585 + Update the destructuring (line 24): 586 + 587 + ```typescript 588 + export default function TalkContent({ talk, speakers, concepts, mentions }: TalkContentProps) { 589 + ``` 590 + 591 + - [ ] **Step 4: Commit** 592 + 593 + ```bash 594 + git add apps/ionosphere/src/lib/api.ts apps/ionosphere/src/app/talks/[rkey]/page.tsx apps/ionosphere/src/app/talks/[rkey]/TalkContent.tsx 595 + git commit -m "feat: wire mentions data from API to talk page" 596 + ``` 597 + 598 + --- 599 + 600 + ## Task 5: MentionsSidebar Component 601 + 602 + **Files:** 603 + - Create: `apps/ionosphere/src/app/components/MentionsSidebar.tsx` 604 + 605 + - [ ] **Step 1: Create the MentionsSidebar component** 606 + 607 + ```tsx 608 + "use client"; 609 + 610 + import { useState, useRef, useEffect, useCallback } from "react"; 611 + import { useTimestamp } from "@/app/components/TimestampProvider"; 612 + 613 + interface Mention { 614 + uri: string; 615 + author_did: string; 616 + author_handle: string; 617 + author_display_name: string; 618 + author_avatar_url: string; 619 + text: string; 620 + created_at: string; 621 + talk_offset_ms: number; 622 + byte_position: number; 623 + likes: number; 624 + reposts: number; 625 + replies: number; 626 + mention_type: string; 627 + thread: Mention[]; 628 + } 629 + 630 + interface MentionsSidebarProps { 631 + mentions: Mention[]; 632 + words: Array<{ byteStart: number; startTime: number }>; 633 + } 634 + 635 + export default function MentionsSidebar({ mentions, words }: MentionsSidebarProps) { 636 + const { currentTimeNs } = useTimestamp(); 637 + const containerRef = useRef<HTMLDivElement>(null); 638 + const [expandedThreads, setExpandedThreads] = useState<Set<string>>(new Set()); 639 + 640 + const duringTalk = mentions.filter(m => m.mention_type === "during_talk"); 641 + const postConference = mentions.filter(m => m.mention_type === "post_conference"); 642 + 643 + // Find the mention closest to current playback time 644 + const currentOffsetMs = Number(currentTimeNs) / 1_000_000; 645 + const activeMentionIdx = duringTalk.findIndex((m, i) => { 646 + const next = duringTalk[i + 1]; 647 + return !next || next.talk_offset_ms > currentOffsetMs; 648 + }); 649 + 650 + // Auto-scroll to active mention 651 + useEffect(() => { 652 + if (activeMentionIdx < 0) return; 653 + const container = containerRef.current; 654 + if (!container) return; 655 + const el = container.querySelector(`[data-mention-idx="${activeMentionIdx}"]`); 656 + if (el) { 657 + el.scrollIntoView({ behavior: "smooth", block: "center" }); 658 + } 659 + }, [activeMentionIdx]); 660 + 661 + const toggleThread = useCallback((uri: string) => { 662 + setExpandedThreads(prev => { 663 + const next = new Set(prev); 664 + if (next.has(uri)) next.delete(uri); 665 + else next.add(uri); 666 + return next; 667 + }); 668 + }, []); 669 + 670 + const { seekTo } = useTimestamp(); 671 + 672 + const handleMentionClick = useCallback((offsetMs: number) => { 673 + if (offsetMs != null && seekTo) { 674 + seekTo(BigInt(offsetMs) * 1_000_000n); 675 + } 676 + }, [seekTo]); 677 + 678 + return ( 679 + <div ref={containerRef} className="flex flex-col gap-1 overflow-y-auto h-full"> 680 + {duringTalk.length === 0 && postConference.length === 0 && ( 681 + <p className="text-neutral-500 text-xs">No mentions found for this talk.</p> 682 + )} 683 + 684 + {duringTalk.map((m, idx) => ( 685 + <MentionCard 686 + key={m.uri} 687 + mention={m} 688 + idx={idx} 689 + isActive={idx === activeMentionIdx} 690 + isThreadExpanded={expandedThreads.has(m.uri)} 691 + onToggleThread={() => toggleThread(m.uri)} 692 + onClick={() => handleMentionClick(m.talk_offset_ms)} 693 + /> 694 + ))} 695 + 696 + {postConference.length > 0 && ( 697 + <> 698 + <div className="border-t border-neutral-700 my-3 pt-2"> 699 + <h3 className="text-[10px] font-semibold text-neutral-500 uppercase tracking-wide"> 700 + After the conference 701 + </h3> 702 + </div> 703 + {postConference.map((m) => ( 704 + <MentionCard 705 + key={m.uri} 706 + mention={m} 707 + idx={-1} 708 + isActive={false} 709 + isThreadExpanded={expandedThreads.has(m.uri)} 710 + onToggleThread={() => toggleThread(m.uri)} 711 + onClick={() => {}} 712 + /> 713 + ))} 714 + </> 715 + )} 716 + </div> 717 + ); 718 + } 719 + 720 + function MentionCard({ 721 + mention: m, 722 + idx, 723 + isActive, 724 + isThreadExpanded, 725 + onToggleThread, 726 + onClick, 727 + }: { 728 + mention: Mention; 729 + idx: number; 730 + isActive: boolean; 731 + isThreadExpanded: boolean; 732 + onToggleThread: () => void; 733 + onClick: () => void; 734 + }) { 735 + const offsetMin = m.talk_offset_ms != null ? Math.floor(m.talk_offset_ms / 60000) : null; 736 + const offsetSec = m.talk_offset_ms != null ? Math.floor((m.talk_offset_ms % 60000) / 1000) : null; 737 + const timeLabel = offsetMin != null ? `${offsetMin}:${String(offsetSec).padStart(2, "0")}` : null; 738 + 739 + return ( 740 + <div data-mention-idx={idx}> 741 + <div 742 + onClick={onClick} 743 + className={`p-2 rounded-md border-l-2 cursor-pointer transition-colors ${ 744 + isActive 745 + ? "bg-blue-500/10 border-blue-400" 746 + : "bg-neutral-900/50 border-neutral-700 hover:bg-neutral-800/50 hover:border-blue-500/50" 747 + }`} 748 + > 749 + <div className="flex items-center gap-1.5 mb-1"> 750 + {m.author_avatar_url ? ( 751 + <img src={m.author_avatar_url} alt="" className="w-4 h-4 rounded-full" /> 752 + ) : ( 753 + <div className="w-4 h-4 rounded-full bg-neutral-700 shrink-0" /> 754 + )} 755 + <span className="text-blue-400 text-[11px] font-medium truncate"> 756 + @{m.author_handle || "unknown"} 757 + </span> 758 + {timeLabel && ( 759 + <span className="text-neutral-600 text-[10px] ml-auto shrink-0">{timeLabel}</span> 760 + )} 761 + </div> 762 + <p className="text-neutral-300 text-[11px] leading-relaxed line-clamp-3">{m.text}</p> 763 + <div className="flex items-center gap-3 mt-1 text-[10px] text-neutral-500"> 764 + {m.likes > 0 && <span>{m.likes} ♡</span>} 765 + {m.reposts > 0 && <span>{m.reposts} ⟳</span>} 766 + {m.thread?.length > 0 && ( 767 + <button 768 + onClick={(e) => { e.stopPropagation(); onToggleThread(); }} 769 + className="text-blue-400/70 hover:text-blue-300" 770 + > 771 + {isThreadExpanded ? "▾" : "▸"} {m.thread.length} {m.thread.length === 1 ? "reply" : "replies"} 772 + </button> 773 + )} 774 + </div> 775 + </div> 776 + 777 + {isThreadExpanded && m.thread?.length > 0 && ( 778 + <div className="ml-3 mt-0.5 flex flex-col gap-0.5"> 779 + {m.thread.map((reply) => ( 780 + <div key={reply.uri} className="p-1.5 rounded bg-neutral-900/30 border-l border-neutral-700"> 781 + <div className="flex items-center gap-1.5 mb-0.5"> 782 + {reply.author_avatar_url ? ( 783 + <img src={reply.author_avatar_url} alt="" className="w-3 h-3 rounded-full" /> 784 + ) : ( 785 + <div className="w-3 h-3 rounded-full bg-neutral-700 shrink-0" /> 786 + )} 787 + <span className="text-blue-400/70 text-[10px]">@{reply.author_handle}</span> 788 + {reply.likes > 0 && <span className="text-neutral-600 text-[10px] ml-auto">{reply.likes} ♡</span>} 789 + </div> 790 + <p className="text-neutral-400 text-[10px] leading-relaxed line-clamp-3">{reply.text}</p> 791 + </div> 792 + ))} 793 + </div> 794 + )} 795 + </div> 796 + ); 797 + } 798 + ``` 799 + 800 + - [ ] **Step 2: Verify component compiles** 801 + 802 + ```bash 803 + cd apps/ionosphere && npx next build 2>&1 | tail -20 804 + ``` 805 + 806 + Expected: No TypeScript errors for MentionsSidebar. (Full build may fail if other parts have issues, but the component itself should be clean.) 807 + 808 + - [ ] **Step 3: Commit** 809 + 810 + ```bash 811 + git add apps/ionosphere/src/app/components/MentionsSidebar.tsx 812 + git commit -m "feat: MentionsSidebar component with scroll sync and thread expansion" 813 + ``` 814 + 815 + --- 816 + 817 + ## Task 6: Tab System and Integration 818 + 819 + **Files:** 820 + - Modify: `apps/ionosphere/src/app/talks/[rkey]/TalkContent.tsx:195-223` 821 + 822 + - [ ] **Step 1: Add imports and state** 823 + 824 + At the top of TalkContent.tsx, add the MentionsSidebar import (after line 8): 825 + 826 + ```typescript 827 + import MentionsSidebar from "@/app/components/MentionsSidebar"; 828 + ``` 829 + 830 + Inside the component function, add tab state (after the `comments` state on line 25): 831 + 832 + ```typescript 833 + const [sidebarTab, setSidebarTab] = useState<"concepts" | "mentions">( 834 + mentions.length > 0 ? "mentions" : "concepts" 835 + ); 836 + ``` 837 + 838 + - [ ] **Step 2: Replace the right sidebar** 839 + 840 + Replace lines 195-223 (the entire `<aside>` block) with: 841 + 842 + ```tsx 843 + {/* Right sidebar — concepts + mentions (hidden on mobile, scrollable on desktop) */} 844 + <aside className="hidden lg:flex lg:flex-col lg:w-56 xl:w-64 shrink-0 border-l border-neutral-800 overflow-y-auto"> 845 + {/* Tab switcher */} 846 + <div className="flex border-b border-neutral-800 shrink-0"> 847 + <button 848 + onClick={() => setSidebarTab("concepts")} 849 + className={`flex-1 text-[11px] font-semibold px-3 py-2.5 transition-colors ${ 850 + sidebarTab === "concepts" 851 + ? "text-amber-300 border-b-2 border-amber-300" 852 + : "text-neutral-500 hover:text-neutral-300" 853 + }`} 854 + > 855 + Concepts{concepts.length > 0 ? ` (${concepts.length})` : ""} 856 + </button> 857 + <button 858 + onClick={() => setSidebarTab("mentions")} 859 + className={`flex-1 text-[11px] font-semibold px-3 py-2.5 transition-colors ${ 860 + sidebarTab === "mentions" 861 + ? "text-blue-300 border-b-2 border-blue-300" 862 + : "text-neutral-500 hover:text-neutral-300" 863 + }`} 864 + > 865 + Mentions{mentions.length > 0 ? ` (${mentions.length})` : ""} 866 + </button> 867 + </div> 868 + 869 + {/* Tab content */} 870 + <div className="flex-1 min-h-0 overflow-y-auto p-4"> 871 + {sidebarTab === "concepts" && ( 872 + <> 873 + {concepts.length > 0 && ( 874 + <section> 875 + <h2 className="text-xs font-semibold text-neutral-500 uppercase tracking-wide mb-2">Concepts</h2> 876 + <div className="flex flex-wrap gap-1.5"> 877 + {concepts.map((c: any) => ( 878 + <a 879 + key={c.rkey} 880 + href={`/concepts/${c.rkey}`} 881 + className="text-xs px-2 py-0.5 rounded-full bg-amber-500/10 text-amber-300/80 hover:bg-amber-500/20 hover:text-amber-200 transition-colors" 882 + > 883 + {c.name} 884 + </a> 885 + ))} 886 + </div> 887 + </section> 888 + )} 889 + </> 890 + )} 891 + 892 + {sidebarTab === "mentions" && ( 893 + <MentionsSidebar mentions={mentions} words={[]} /> 894 + )} 895 + </div> 896 + 897 + {/* Mobile speakers (shown below transcript on small screens) */} 898 + <section className="lg:hidden p-4"> 899 + <h2 className="text-xs font-semibold text-neutral-500 uppercase tracking-wide mb-1">Speakers</h2> 900 + {speakers.map((s: any) => ( 901 + <a key={s.rkey} href={`/speakers/${s.rkey}`} className="block text-sm text-neutral-200 hover:text-white"> 902 + {s.name} 903 + </a> 904 + ))} 905 + </section> 906 + </aside> 907 + ``` 908 + 909 + - [ ] **Step 3: Verify build** 910 + 911 + ```bash 912 + cd apps/ionosphere && npx next build 2>&1 | tail -20 913 + ``` 914 + 915 + Expected: Clean build with mentions tab rendered in sidebar. 916 + 917 + - [ ] **Step 4: Commit** 918 + 919 + ```bash 920 + git add apps/ionosphere/src/app/talks/[rkey]/TalkContent.tsx 921 + git commit -m "feat: tabbed sidebar with mentions alongside concepts" 922 + ``` 923 + 924 + --- 925 + 926 + ## Task 7: End-to-End Verification 927 + 928 + - [ ] **Step 1: Run fetch script if not already done** 929 + 930 + ```bash 931 + source apps/ionosphere-appview/.env && BOT_PASSWORD="$BOT_PASSWORD" node scripts/fetch-mentions.mjs 932 + ``` 933 + 934 + - [ ] **Step 2: Start appview** 935 + 936 + ```bash 937 + cd apps/ionosphere-appview && npm run dev & 938 + ``` 939 + 940 + - [ ] **Step 3: Verify API returns data** 941 + 942 + ```bash 943 + curl -s 'http://localhost:3001/xrpc/tv.ionosphere.getMentions?talkRkey=landslide' | node -e "process.stdin.on('data',d=>{const j=JSON.parse(d);console.log(j.total,'mentions');if(j.mentions[0])console.log('first:',j.mentions[0].author_handle,j.mentions[0].text?.slice(0,80))})" 944 + ``` 945 + 946 + - [ ] **Step 4: Start frontend and verify UI** 947 + 948 + ```bash 949 + cd apps/ionosphere && npm run dev 950 + ``` 951 + 952 + Open a talk page with known mentions (e.g., "Landslide" by Erin Kissane). Verify: 953 + - Tab switcher shows "Concepts (N)" and "Mentions (N)" 954 + - Clicking Mentions tab shows mention cards with author, text, likes 955 + - Cards show time offset (e.g., "14:32") 956 + - Clicking a card seeks the video 957 + - Thread replies expand inline 958 + 959 + - [ ] **Step 5: Final commit** 960 + 961 + ```bash 962 + git add -A 963 + git commit -m "feat: conference mentions integration — time-aligned Bluesky mentions in talk sidebar" 964 + ```