A simple, clean, fast browser for the AtmosphereConf(2026) VODs
0
fork

Configure Feed

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

feat: surface atmosphere discussion signals in UI

jack 6f1ceaeb 4c962c27

+193 -12
+57 -6
src/lib/ionosphere.ts
··· 206 206 })) 207 207 } 208 208 209 + function normalizeTranscriptText(value: string | undefined): string { 210 + if (!value) { 211 + return '' 212 + } 213 + 214 + return value 215 + .replace(/\s+/g, ' ') 216 + .trim() 217 + } 218 + 219 + function buildTranscriptPreview(text: string): string[] { 220 + if (!text) { 221 + return [] 222 + } 223 + 224 + const sentences = text 225 + .split(/(?<=[.!?])\s+/) 226 + .map((sentence) => sentence.trim()) 227 + .filter(Boolean) 228 + 229 + if (sentences.length > 0) { 230 + return sentences.slice(0, 4) 231 + } 232 + 233 + const words = text.split(/\s+/).filter(Boolean) 234 + if (words.length === 0) { 235 + return [] 236 + } 237 + 238 + return [words.slice(0, 30).join(' ')] 239 + } 240 + 209 241 async function buildEnrichment(talks: AppTalk[]): Promise<IonosphereEnrichmentResult> { 210 242 const [talksRaw, conceptsRaw, annotationsRaw, speakersRaw, transcriptsRaw] = await Promise.all([ 211 243 fetchCollection('tv.ionosphere.talk'), ··· 234 266 ) 235 267 236 268 const topicsByTalkUri = new Map<string, string[]>() 269 + const topicMentionCountByTalkUri = new Map<string, Map<string, number>>() 237 270 for (const annotation of asIonosphereAnnotations(annotationsRaw)) { 238 271 const talkUri = annotation.value.talkUri 239 272 const conceptUri = annotation.value.conceptUri ··· 249 282 list.push(conceptName) 250 283 } 251 284 topicsByTalkUri.set(talkUri, list) 285 + 286 + const mentionMap = topicMentionCountByTalkUri.get(talkUri) ?? new Map<string, number>() 287 + mentionMap.set(conceptName, (mentionMap.get(conceptName) ?? 0) + 1) 288 + topicMentionCountByTalkUri.set(talkUri, mentionMap) 252 289 } 253 290 254 291 const speakerByUri = new Map(asIonosphereSpeakers(speakersRaw).map((record) => [record.uri, record])) 255 - const transcriptByTalkUri = new Map( 256 - asIonosphereTranscripts(transcriptsRaw) 257 - .map((record) => [record.value.talkUri, record.value.text ?? record.value.transcript ?? ''] as const) 258 - .filter((entry) => Boolean(entry[0] && entry[1])), 259 - ) 292 + const transcriptByTalkUri = new Map<string, string>() 293 + for (const transcript of asIonosphereTranscripts(transcriptsRaw)) { 294 + const talkUri = transcript.value.talkUri 295 + const normalized = normalizeTranscriptText(transcript.value.text ?? transcript.value.transcript) 296 + if (!talkUri || !normalized) { 297 + continue 298 + } 299 + 300 + const existing = transcriptByTalkUri.get(talkUri) 301 + transcriptByTalkUri.set(talkUri, existing ? `${existing} ${normalized}` : normalized) 302 + } 260 303 const byVodUri = new Map<string, IonosphereEnrichment>() 261 304 const allTopics = new Set<string>() 262 305 ··· 269 312 } 270 313 271 314 const topics = (topicsByTalkUri.get(match.uri) ?? []).slice(0, 10) 315 + const topicMentions = [...(topicMentionCountByTalkUri.get(match.uri)?.entries() ?? [])] 316 + .map(([topic, mentions]) => ({ topic, mentions })) 317 + .sort((left, right) => right.mentions - left.mentions || left.topic.localeCompare(right.topic)) 318 + .slice(0, 8) 272 319 for (const topic of topics) { 273 320 allTopics.add(topic) 274 321 } 322 + 323 + const transcriptText = transcriptByTalkUri.get(match.uri) 275 324 276 325 let speakerName: string | undefined 277 326 let speakerHandle: string | undefined ··· 296 345 scheduledAt: match.value.startsAt, 297 346 track: match.value.track || match.value.category, 298 347 topics, 299 - transcriptText: transcriptByTalkUri.get(match.uri), 348 + topicMentions, 349 + transcriptText, 350 + transcriptPreview: buildTranscriptPreview(transcriptText ?? ''), 300 351 speakerName, 301 352 speakerHandle, 302 353 speakerAvatar,
+5
src/lib/types.ts
··· 127 127 scheduledAt?: string 128 128 track?: string 129 129 topics: string[] 130 + topicMentions: Array<{ 131 + topic: string 132 + mentions: number 133 + }> 130 134 transcriptText?: string 135 + transcriptPreview: string[] 131 136 speakerName?: string 132 137 speakerHandle?: string 133 138 speakerAvatar?: string
+14
src/pages/atmosphereconf-page.tsx
··· 202 202 ))} 203 203 </div> 204 204 ) : null} 205 + {(enrichment.byVodUri.get(featuredTalk.uri)?.transcriptPreview ?? []).length > 0 ? ( 206 + <section className="mt-3 space-y-2"> 207 + <h3 className="text-[11px] font-medium uppercase tracking-wide text-muted">Transcript highlights</h3> 208 + <div className="space-y-2"> 209 + {(enrichment.byVodUri.get(featuredTalk.uri)?.transcriptPreview ?? []) 210 + .slice(0, 2) 211 + .map((line, index) => ( 212 + <p key={`${index}-${line.slice(0, 16)}`} className="text-xs leading-relaxed text-muted"> 213 + {line} 214 + </p> 215 + ))} 216 + </div> 217 + </section> 218 + ) : null} 205 219 </article> 206 220 ) : null} 207 221 </section>
+39 -3
src/pages/search-page.tsx
··· 5 5 import { ErrorPanel } from '@/components/error-panel' 6 6 import { TalkCard } from '@/components/talk-card' 7 7 import { TalkGridSkeleton } from '@/components/talk-grid-skeleton' 8 + import { isAtmosphereTalk } from '@/lib/api' 8 9 import { hapticTap } from '@/lib/haptics' 10 + import { fetchAtmosphereIonosphereEnrichment } from '@/lib/ionosphere' 9 11 import { searchTalkUris } from '@/lib/semantic-search' 10 12 import { toTagPath } from '@/lib/routes' 11 13 import { getTalkTaxonomyTokens, scoreTalkForQuery } from '@/lib/taxonomy' 14 + import type { IonosphereEnrichmentResult } from '@/lib/types' 12 15 import { useKeyboard } from '@/lib/use-keyboard' 13 16 import { useVideos } from '@/state/videos-context' 14 17 ··· 24 27 const [remoteError, setRemoteError] = useState<string | null>(null) 25 28 const [remoteLoading, setRemoteLoading] = useState<boolean>(false) 26 29 const [selectedIndex, setSelectedIndex] = useState(0) 30 + const [enrichment, setEnrichment] = useState<IonosphereEnrichmentResult>({ 31 + byVodUri: new Map(), 32 + allTopics: [], 33 + }) 27 34 const { talks, loading, error, refresh } = useVideos() 28 35 const trimmedQuery = query.trim() 29 36 const prefersReducedMotion = ··· 58 65 } 59 66 60 67 useEffect(() => { 68 + const atmosphereTalks = talks.filter((talk) => isAtmosphereTalk(talk)) 69 + if (atmosphereTalks.length === 0) { 70 + return 71 + } 72 + 73 + let active = true 74 + fetchAtmosphereIonosphereEnrichment(atmosphereTalks) 75 + .then((result) => { 76 + if (!active) { 77 + return 78 + } 79 + setEnrichment(result) 80 + }) 81 + .catch(() => { 82 + if (active) { 83 + setEnrichment({ byVodUri: new Map(), allTopics: [] }) 84 + } 85 + }) 86 + 87 + return () => { 88 + active = false 89 + } 90 + }, [talks]) 91 + 92 + useEffect(() => { 61 93 if (!trimmedQuery || talks.length === 0) { 62 94 return 63 95 } ··· 132 164 return talks 133 165 .map((talk) => ({ 134 166 talk, 135 - score: scoreTalkForQuery(talk, trimmedQuery), 167 + score: 168 + scoreTalkForQuery(talk, trimmedQuery) + 169 + ((enrichment.byVodUri.get(talk.uri)?.transcriptText ?? '').toLowerCase().includes(trimmedQuery.toLowerCase()) 170 + ? 1 171 + : 0), 136 172 })) 137 173 .filter((entry) => entry.score > 0) 138 174 .sort((a, b) => b.score - a.score) 139 175 .map((entry) => entry.talk) 140 - }, [trimmedQuery, talks, remoteQuery, remoteUris]) 176 + }, [trimmedQuery, talks, remoteQuery, remoteUris, enrichment.byVodUri]) 141 177 142 178 const selectedTalkIndex = 143 179 filteredTalks.length > 0 ? Math.min(selectedIndex, filteredTalks.length - 1) : 0 ··· 261 297 {!loading && !error && trimmedQuery ? ( 262 298 <section className="rounded-lg border border-line/45 bg-surface/80 p-4 text-xs text-muted"> 263 299 <p> 264 - Results combine semantic ranking for all Streamplace VODs with richer AtmosphereConf metadata. 300 + Results combine semantic ranking for all Streamplace VODs with richer AtmosphereConf discussion data. 265 301 </p> 266 302 {remoteLoading ? <p className="mt-2">Ranking results...</p> : null} 267 303 {!remoteLoading && remoteMode ? (
+78 -3
src/pages/video-page.tsx
··· 13 13 14 14 import { ErrorPanel } from '@/components/error-panel' 15 15 import { Button } from '@/components/ui/button' 16 - import { fetchTalkByUri, fetchVideoPlaylist, getArchiveBlobUrl } from '@/lib/api' 17 - import { formatDate, formatDuration, truncateDid } from '@/lib/format' 16 + import { fetchTalkByUri, fetchVideoPlaylist, getArchiveBlobUrl, isAtmosphereTalk } from '@/lib/api' 17 + import { formatDate, formatDateTime, formatDuration, truncateDid } from '@/lib/format' 18 18 import { 19 19 hapticBack, 20 20 hapticError, ··· 22 22 hapticSeek, 23 23 hapticSuccess, 24 24 } from '@/lib/haptics' 25 + import { fetchAtmosphereIonosphereEnrichment } from '@/lib/ionosphere' 25 26 import { toTagPath, toVideoUriFromParams } from '@/lib/routes' 26 27 import { getTalkTaxonomyTokens } from '@/lib/taxonomy' 27 28 import { useKeyboard } from '@/lib/use-keyboard' 28 - import type { AppTalk } from '@/lib/types' 29 + import type { AppTalk, IonosphereEnrichment } from '@/lib/types' 29 30 import { useVideos } from '@/state/videos-context' 30 31 31 32 type PlaybackStatus = 'idle' | 'loading' | 'ready' | 'error' ··· 51 52 const [playlistUrl, setPlaylistUrl] = useState<string | null>(null) 52 53 const [resolvedTalk, setResolvedTalk] = useState<AppTalk | null>(null) 53 54 const [metadataLoading, setMetadataLoading] = useState<boolean>(false) 55 + const [ionosphere, setIonosphere] = useState<IonosphereEnrichment | null>(null) 54 56 55 57 const resolvedUri = useMemo( 56 58 () => (didParam && rkeyParam ? toVideoUriFromParams(didParam, rkeyParam) : undefined), ··· 194 196 setPlaylistUrl(null) 195 197 } 196 198 }, [resolvedUri, reloadToken]) 199 + 200 + useEffect(() => { 201 + if (!talk || !isAtmosphereTalk(talk)) { 202 + setIonosphere(null) 203 + return 204 + } 205 + 206 + let active = true 207 + fetchAtmosphereIonosphereEnrichment([talk]) 208 + .then((result) => { 209 + if (!active) { 210 + return 211 + } 212 + setIonosphere(result.byVodUri.get(talk.uri) ?? null) 213 + }) 214 + .catch(() => { 215 + if (active) { 216 + setIonosphere(null) 217 + } 218 + }) 219 + 220 + return () => { 221 + active = false 222 + } 223 + }, [talk]) 197 224 198 225 const onRetryPlayback = useCallback(() => { 199 226 hapticPlay() ··· 458 485 <p className="mt-2 text-sm text-muted"> 459 486 Duration: {formatDuration(talk.durationNs)} • Published {formatDate(talk.createdAt)} 460 487 </p> 488 + 489 + {ionosphere ? ( 490 + <article className="mt-4 space-y-3 rounded-lg border border-line/45 bg-surface/80 p-4"> 491 + <h2 className="text-sm font-medium text-text">Discussion</h2> 492 + <p className="text-xs text-muted"> 493 + {ionosphere.room ?? 'Room TBD'} • {ionosphere.track ?? 'Track TBD'} 494 + {ionosphere.scheduledAt ? ` • ${formatDateTime(ionosphere.scheduledAt)}` : ''} 495 + </p> 496 + 497 + {ionosphere.speakerName ? ( 498 + <p className="text-xs text-muted"> 499 + Featured speaker: {ionosphere.speakerName} 500 + {ionosphere.speakerHandle ? ` (@${ionosphere.speakerHandle})` : ''} 501 + </p> 502 + ) : null} 503 + 504 + {ionosphere.topicMentions.length > 0 ? ( 505 + <section className="space-y-2"> 506 + <h3 className="text-xs font-medium uppercase tracking-wide text-muted">Reactions</h3> 507 + <div className="flex flex-wrap gap-2"> 508 + {ionosphere.topicMentions.map((entry) => ( 509 + <Link 510 + key={entry.topic} 511 + to={toTagPath(entry.topic)} 512 + className="inline-flex min-h-11 items-center gap-1 rounded-md border border-line/45 bg-surface/70 px-3 text-xs text-muted transition hover:border-line/60 hover:text-text" 513 + > 514 + <span>{entry.topic}</span> 515 + <span className="text-[11px] text-muted/90">×{entry.mentions}</span> 516 + </Link> 517 + ))} 518 + </div> 519 + </section> 520 + ) : null} 521 + 522 + {ionosphere.transcriptPreview.length > 0 ? ( 523 + <section className="space-y-2"> 524 + <h3 className="text-xs font-medium uppercase tracking-wide text-muted">Transcript highlights</h3> 525 + <div className="space-y-2"> 526 + {ionosphere.transcriptPreview.map((line, index) => ( 527 + <p key={`${index}-${line.slice(0, 16)}`} className="text-sm leading-relaxed text-muted"> 528 + {line} 529 + </p> 530 + ))} 531 + </div> 532 + </section> 533 + ) : null} 534 + </article> 535 + ) : null} 461 536 </section> 462 537 ) : null} 463 538 </div>