Ionosphere.tv
3
fork

Configure Feed

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

feat: MentionsSidebar component + tabbed sidebar in TalkContent

Add MentionsSidebar with during-talk/post-conference grouping, click-to-seek,
active highlighting, auto-scroll, and thread expansion. Replace right sidebar
with Concepts/Mentions tab system.

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

+288 -19
+234
apps/ionosphere/src/app/components/MentionsSidebar.tsx
··· 1 + "use client"; 2 + 3 + import { useState, useEffect, useRef, useMemo } from "react"; 4 + import { useTimestamp } from "@/app/components/TimestampProvider"; 5 + 6 + export interface Mention { 7 + uri: string; 8 + author_handle: string; 9 + author_display_name: string; 10 + author_avatar_url: string; 11 + text: string; 12 + created_at: string; 13 + talk_offset_ms: number | null; 14 + byte_position: number | null; 15 + likes: number; 16 + reposts: number; 17 + replies: number; 18 + mention_type: string; 19 + thread?: Mention[]; 20 + } 21 + 22 + interface MentionsSidebarProps { 23 + mentions: Mention[]; 24 + words: any[]; 25 + } 26 + 27 + function formatOffset(ms: number): string { 28 + const totalSec = Math.floor(ms / 1000); 29 + const min = Math.floor(totalSec / 60); 30 + const sec = totalSec % 60; 31 + return `${min}:${sec.toString().padStart(2, "0")}`; 32 + } 33 + 34 + function MentionCard({ 35 + mention, 36 + isActive, 37 + onSeek, 38 + }: { 39 + mention: Mention; 40 + isActive: boolean; 41 + onSeek: (ms: number) => void; 42 + }) { 43 + const [expanded, setExpanded] = useState(false); 44 + const isDuringTalk = mention.talk_offset_ms != null; 45 + 46 + return ( 47 + <div 48 + className={`p-2 rounded cursor-pointer transition-colors border-l-2 ${ 49 + isActive 50 + ? "bg-blue-500/10 border-l-blue-400" 51 + : "bg-neutral-900/50 border-l-neutral-700 hover:bg-neutral-800/50" 52 + }`} 53 + onClick={() => { 54 + if (isDuringTalk && mention.talk_offset_ms != null) { 55 + onSeek(mention.talk_offset_ms); 56 + } 57 + }} 58 + > 59 + {/* Author row */} 60 + <div className="flex items-center gap-1.5 mb-1"> 61 + {mention.author_avatar_url ? ( 62 + <img 63 + src={mention.author_avatar_url} 64 + alt="" 65 + className="w-4 h-4 rounded-full shrink-0" 66 + /> 67 + ) : ( 68 + <div className="w-4 h-4 rounded-full bg-neutral-700 shrink-0" /> 69 + )} 70 + <span className="text-[10px] text-blue-400 truncate"> 71 + @{mention.author_handle} 72 + </span> 73 + {isDuringTalk && mention.talk_offset_ms != null && ( 74 + <span className="text-[10px] text-neutral-500 ml-auto shrink-0"> 75 + {formatOffset(mention.talk_offset_ms)} 76 + </span> 77 + )} 78 + </div> 79 + 80 + {/* Post text */} 81 + <p className="text-[11px] text-neutral-300 line-clamp-3 leading-relaxed"> 82 + {mention.text} 83 + </p> 84 + 85 + {/* Metrics row */} 86 + <div className="flex items-center gap-3 mt-1"> 87 + {mention.likes > 0 && ( 88 + <span className="text-[10px] text-neutral-500"> 89 + ♥ {mention.likes} 90 + </span> 91 + )} 92 + {mention.reposts > 0 && ( 93 + <span className="text-[10px] text-neutral-500"> 94 + ↻ {mention.reposts} 95 + </span> 96 + )} 97 + {mention.thread && mention.thread.length > 0 && ( 98 + <button 99 + className="text-[10px] text-neutral-500 hover:text-neutral-300 transition-colors" 100 + onClick={(e) => { 101 + e.stopPropagation(); 102 + setExpanded(!expanded); 103 + }} 104 + > 105 + {expanded ? "▾" : "▸"} {mention.thread.length}{" "} 106 + {mention.thread.length === 1 ? "reply" : "replies"} 107 + </button> 108 + )} 109 + </div> 110 + 111 + {/* Thread expansion */} 112 + {expanded && mention.thread && mention.thread.length > 0 && ( 113 + <div className="mt-1.5 pl-3 border-l border-neutral-700 flex flex-col gap-1"> 114 + {mention.thread.map((reply) => ( 115 + <div key={reply.uri} className="p-1.5 rounded bg-neutral-900/30"> 116 + <div className="flex items-center gap-1.5 mb-0.5"> 117 + {reply.author_avatar_url ? ( 118 + <img 119 + src={reply.author_avatar_url} 120 + alt="" 121 + className="w-3 h-3 rounded-full shrink-0" 122 + /> 123 + ) : ( 124 + <div className="w-3 h-3 rounded-full bg-neutral-700 shrink-0" /> 125 + )} 126 + <span className="text-[10px] text-blue-400 truncate"> 127 + @{reply.author_handle} 128 + </span> 129 + </div> 130 + <p className="text-[10px] text-neutral-400 line-clamp-3 leading-relaxed"> 131 + {reply.text} 132 + </p> 133 + </div> 134 + ))} 135 + </div> 136 + )} 137 + </div> 138 + ); 139 + } 140 + 141 + export default function MentionsSidebar({ mentions }: MentionsSidebarProps) { 142 + const { currentTimeNs, seekTo } = useTimestamp(); 143 + const containerRef = useRef<HTMLDivElement>(null); 144 + 145 + const currentTimeMs = currentTimeNs / 1_000_000; 146 + 147 + const { duringTalk, postConference } = useMemo(() => { 148 + const during: Mention[] = []; 149 + const post: Mention[] = []; 150 + for (const m of mentions) { 151 + if (m.talk_offset_ms != null) { 152 + during.push(m); 153 + } else { 154 + post.push(m); 155 + } 156 + } 157 + during.sort((a, b) => (a.talk_offset_ms ?? 0) - (b.talk_offset_ms ?? 0)); 158 + post.sort( 159 + (a, b) => 160 + new Date(a.created_at).getTime() - new Date(b.created_at).getTime() 161 + ); 162 + return { duringTalk: during, postConference: post }; 163 + }, [mentions]); 164 + 165 + // Find active mention — closest during-talk mention at or before current time 166 + const activeUri = useMemo(() => { 167 + if (duringTalk.length === 0) return null; 168 + let closest: Mention | null = null; 169 + for (const m of duringTalk) { 170 + if ((m.talk_offset_ms ?? 0) <= currentTimeMs) { 171 + closest = m; 172 + } 173 + } 174 + return closest?.uri ?? duringTalk[0]?.uri ?? null; 175 + }, [duringTalk, currentTimeMs]); 176 + 177 + // Auto-scroll to active mention 178 + useEffect(() => { 179 + if (!activeUri || !containerRef.current) return; 180 + const el = containerRef.current.querySelector( 181 + `[data-mention-uri="${CSS.escape(activeUri)}"]` 182 + ); 183 + if (el) { 184 + el.scrollIntoView({ block: "nearest", behavior: "smooth" }); 185 + } 186 + }, [activeUri]); 187 + 188 + const handleSeek = (ms: number) => { 189 + seekTo(ms * 1_000_000); 190 + }; 191 + 192 + if (mentions.length === 0) { 193 + return ( 194 + <div className="flex items-center justify-center h-full text-neutral-500 text-xs"> 195 + No mentions found for this talk. 196 + </div> 197 + ); 198 + } 199 + 200 + return ( 201 + <div ref={containerRef} className="flex flex-col gap-1 overflow-y-auto"> 202 + {duringTalk.map((m) => ( 203 + <div key={m.uri} data-mention-uri={m.uri}> 204 + <MentionCard 205 + mention={m} 206 + isActive={m.uri === activeUri} 207 + onSeek={handleSeek} 208 + /> 209 + </div> 210 + ))} 211 + 212 + {postConference.length > 0 && ( 213 + <> 214 + <div className="flex items-center gap-2 my-2"> 215 + <div className="flex-1 h-px bg-neutral-700" /> 216 + <span className="text-[10px] text-neutral-500 shrink-0"> 217 + After the conference 218 + </span> 219 + <div className="flex-1 h-px bg-neutral-700" /> 220 + </div> 221 + {postConference.map((m) => ( 222 + <div key={m.uri} data-mention-uri={m.uri}> 223 + <MentionCard 224 + mention={m} 225 + isActive={false} 226 + onSeek={handleSeek} 227 + /> 228 + </div> 229 + ))} 230 + </> 231 + )} 232 + </div> 233 + ); 234 + }
+54 -19
apps/ionosphere/src/app/talks/[rkey]/TalkContent.tsx
··· 6 6 import TranscriptView from "@/app/components/TranscriptView"; 7 7 import { fetchComments, type CommentData } from "@/lib/comments"; 8 8 import ReactionBar from "@/app/components/ReactionBar"; 9 + import MentionsSidebar from "@/app/components/MentionsSidebar"; 9 10 10 11 interface TalkContentProps { 11 12 talk: any; ··· 24 25 25 26 export default function TalkContent({ talk, speakers, concepts, mentions }: TalkContentProps) { 26 27 const [comments, setComments] = useState<CommentData[]>([]); 28 + const [sidebarTab, setSidebarTab] = useState<"concepts" | "mentions">(mentions.length > 0 ? "mentions" : "concepts"); 27 29 28 30 // Parse video sources 29 31 const videoSources: VideoSource[] = useMemo(() => { ··· 193 195 </div> 194 196 </div> 195 197 196 - {/* Right sidebar — concepts + cross-refs (hidden on mobile, scrollable on desktop) */} 197 - <aside className="hidden lg:flex lg:flex-col lg:w-56 xl:w-64 shrink-0 border-l border-neutral-800 overflow-y-auto p-4 gap-5"> 198 - {concepts.length > 0 && ( 199 - <section> 200 - <h2 className="text-xs font-semibold text-neutral-500 uppercase tracking-wide mb-2">Concepts</h2> 201 - <div className="flex flex-wrap gap-1.5"> 202 - {concepts.map((c: any) => ( 203 - <a 204 - key={c.rkey} 205 - href={`/concepts/${c.rkey}`} 206 - 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" 207 - > 208 - {c.name} 209 - </a> 210 - ))} 211 - </div> 212 - </section> 213 - )} 198 + {/* Right sidebar — tabbed concepts/mentions (hidden on mobile, scrollable on desktop) */} 199 + <aside className="hidden lg:flex lg:flex-col lg:w-56 xl:w-64 shrink-0 border-l border-neutral-800 overflow-hidden"> 200 + {/* Tab bar */} 201 + <div className="flex border-b border-neutral-800 shrink-0"> 202 + <button 203 + onClick={() => setSidebarTab("concepts")} 204 + className={`flex-1 text-[10px] font-semibold uppercase tracking-wide py-2 transition-colors ${ 205 + sidebarTab === "concepts" 206 + ? "text-amber-400 border-b-2 border-amber-400" 207 + : "text-neutral-500 hover:text-neutral-300" 208 + }`} 209 + > 210 + Concepts ({concepts.length}) 211 + </button> 212 + <button 213 + onClick={() => setSidebarTab("mentions")} 214 + className={`flex-1 text-[10px] font-semibold uppercase tracking-wide py-2 transition-colors ${ 215 + sidebarTab === "mentions" 216 + ? "text-blue-400 border-b-2 border-blue-400" 217 + : "text-neutral-500 hover:text-neutral-300" 218 + }`} 219 + > 220 + Mentions ({mentions.length}) 221 + </button> 222 + </div> 223 + 224 + {/* Tab content */} 225 + <div className="flex-1 overflow-y-auto p-4"> 226 + {sidebarTab === "concepts" && ( 227 + <> 228 + {concepts.length > 0 ? ( 229 + <div className="flex flex-wrap gap-1.5"> 230 + {concepts.map((c: any) => ( 231 + <a 232 + key={c.rkey} 233 + href={`/concepts/${c.rkey}`} 234 + 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" 235 + > 236 + {c.name} 237 + </a> 238 + ))} 239 + </div> 240 + ) : ( 241 + <div className="text-xs text-neutral-500">No concepts extracted yet.</div> 242 + )} 243 + </> 244 + )} 245 + {sidebarTab === "mentions" && ( 246 + <MentionsSidebar mentions={mentions} words={[]} /> 247 + )} 248 + </div> 214 249 215 250 {/* Mobile speakers (shown below transcript on small screens) */} 216 - <section className="lg:hidden"> 251 + <section className="lg:hidden p-4"> 217 252 <h2 className="text-xs font-semibold text-neutral-500 uppercase tracking-wide mb-1">Speakers</h2> 218 253 {speakers.map((s: any) => ( 219 254 <a key={s.rkey} href={`/speakers/${s.rkey}`} className="block text-sm text-neutral-200 hover:text-white">