minimal streamplace frontend
8
fork

Configure Feed

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

at main 339 lines 11 kB view raw
1import { CornerDownRight, Reply, Sword, Star, Video, X } from "lucide-solid"; 2import { createSignal, For, onCleanup, onMount, Show } from "solid-js"; 3 4import { setShowLoginModal } from "../auth/login-modal"; 5import { agent, loggedInDid } from "../auth/state"; 6import { resolveHandle } from "../lib/api"; 7import { 8 connectChatWs, 9 segmentRichText, 10 sendChatMessage, 11 type ChatConnection, 12 type ChatMessage, 13 type Facet, 14 type StreamInfo, 15} from "../lib/chat"; 16 17export interface ChatProps { 18 handle: string; 19 streamerDid?: string; 20 onStreamInfo?: (info: StreamInfo) => void; 21 onViewerCount?: (count: number) => void; 22 class?: string; 23} 24 25const MAX_MESSAGES = 200; 26 27function getAuthorColor(msg: ChatMessage): string { 28 const color = msg.chatProfile?.color; 29 if (color && color.red !== undefined) { 30 return `rgb(${color.red}, ${color.green}, ${color.blue})`; 31 } 32 return "#4ade80"; 33} 34 35function ChatBadges(props: { badges?: ChatMessage["badges"] }) { 36 if (!props.badges || props.badges.length === 0) return null; 37 38 return ( 39 <> 40 <For each={props.badges}> 41 {(badge) => { 42 const type = badge.badgeType; 43 if (type === "place.stream.badge.defs#mod") { 44 return ( 45 <span class="mr-0.5 inline-flex align-middle" title="Moderator"> 46 <Sword size={12} class="text-blue-400" /> 47 </span> 48 ); 49 } 50 if (type === "place.stream.badge.defs#streamer") { 51 return ( 52 <span class="mr-0.5 inline-flex align-middle" title="Streamer"> 53 <Video size={12} class="text-sp-red" /> 54 </span> 55 ); 56 } 57 if (type === "place.stream.badge.defs#vip") { 58 return ( 59 <span class="mr-0.5 inline-flex align-middle" title="VIP"> 60 <Star size={12} class="text-yellow-400" /> 61 </span> 62 ); 63 } 64 return null; 65 }} 66 </For> 67 </> 68 ); 69} 70 71function FacetSegment(props: { text: string; facet?: Facet }) { 72 if (!props.facet) return <>{props.text}</>; 73 74 for (const feature of props.facet.features) { 75 if (feature.$type === "app.bsky.richtext.facet#link") { 76 return ( 77 <a 78 href={feature.uri} 79 target="_blank" 80 rel="noopener noreferrer" 81 class="text-sp-accent decoration-sp-accent/40 hover:decoration-sp-accent underline" 82 > 83 {props.text} 84 </a> 85 ); 86 } 87 if (feature.$type === "app.bsky.richtext.facet#mention") { 88 return ( 89 <a 90 href={`https://bsky.app/profile/${feature.did}`} 91 target="_blank" 92 rel="noopener noreferrer" 93 class="text-sp-accent font-medium" 94 > 95 {props.text} 96 </a> 97 ); 98 } 99 } 100 101 return <>{props.text}</>; 102} 103 104export function Chat(props: ChatProps) { 105 let messagesEl!: HTMLDivElement; 106 let ws: ChatConnection | undefined; 107 let following = true; 108 let seenMessages = new Set<string>(); 109 110 const [messages, setMessages] = createSignal<ChatMessage[]>([]); 111 const [connected, setConnected] = createSignal(false); 112 const [inputText, setInputText] = createSignal(""); 113 114 const [replyingTo, setReplyingTo] = createSignal<ChatMessage | undefined>(); 115 let inputEl!: HTMLInputElement; 116 117 const addMessage = (msg: ChatMessage) => { 118 if (seenMessages.has(msg.cid)) return; 119 seenMessages.add(msg.cid); 120 121 setMessages((prev) => { 122 const msgTime = new Date(msg.indexedAt).getTime(); 123 const lastTime = prev.length > 0 ? new Date(prev[prev.length - 1].indexedAt).getTime() : 0; 124 125 // Fast path: message is newest (common case for live messages) 126 if (msgTime >= lastTime) { 127 if (prev.length >= MAX_MESSAGES) { 128 seenMessages.delete(prev[0].cid); 129 return [...prev.slice(1), msg]; 130 } 131 132 return [...prev, msg]; 133 } 134 135 // Slow path: backfill arriving out of order 136 let i = prev.length; 137 while (i > 0 && new Date(prev[i - 1].indexedAt).getTime() > msgTime) { 138 i--; 139 } 140 const next = [...prev.slice(0, i), msg, ...prev.slice(i)]; 141 if (next.length > MAX_MESSAGES) { 142 seenMessages.delete(next[0].cid); 143 return next.slice(1); 144 } 145 return next; 146 }); 147 148 // auto-scroll to bottom 149 requestAnimationFrame(() => { 150 if (messagesEl && following) { 151 messagesEl.scrollTop = messagesEl.scrollHeight; 152 } 153 }); 154 }; 155 156 const connect = () => { 157 ws = connectChatWs(props.handle, { 158 onMessage: addMessage, 159 onStreamInfo: (info) => props.onStreamInfo?.(info), 160 onViewerCount: (count) => props.onViewerCount?.(count), 161 onOpen: () => setConnected(true), 162 onClose: () => setConnected(false), 163 }); 164 }; 165 166 const send = async () => { 167 const text = inputText().trim(); 168 if (!text) return; 169 170 const currentAgent = agent(); 171 const did = loggedInDid(); 172 const streamerDid = props.streamerDid; 173 if (!currentAgent || !did || !streamerDid) return; 174 175 const replyMsg = replyingTo(); 176 const reply = replyMsg 177 ? { 178 root: { 179 uri: replyMsg.record.reply?.root?.uri ?? replyMsg.uri, 180 cid: replyMsg.record.reply?.root?.cid ?? replyMsg.cid, 181 }, 182 parent: { uri: replyMsg.uri, cid: replyMsg.cid }, 183 } 184 : undefined; 185 186 setInputText(""); 187 setReplyingTo(undefined); 188 try { 189 await sendChatMessage(currentAgent, did, streamerDid, text, resolveHandle, reply); 190 } catch (err) { 191 console.error("Failed to send chat:", err); 192 } 193 }; 194 195 const handleKeyDown = (e: KeyboardEvent) => { 196 if (e.key === "Enter" && !e.shiftKey) { 197 e.preventDefault(); 198 send(); 199 } 200 if (e.key === "Escape") { 201 setReplyingTo(undefined); 202 } 203 }; 204 205 // keep chat pinned to bottom on new messages and resize, unless user has scrolled up 206 const setupScrollFollowing = () => { 207 const onScroll = () => { 208 following = messagesEl.scrollTop + messagesEl.clientHeight >= messagesEl.scrollHeight - 5; 209 }; 210 messagesEl.addEventListener("scroll", onScroll, { passive: true }); 211 const ro = new ResizeObserver(() => { 212 if (following) messagesEl.scrollTop = messagesEl.scrollHeight; 213 }); 214 ro.observe(messagesEl); 215 onCleanup(() => { 216 messagesEl.removeEventListener("scroll", onScroll); 217 ro.disconnect(); 218 }); 219 }; 220 221 onMount(() => { 222 connect(); 223 setupScrollFollowing(); 224 }); 225 226 onCleanup(() => { 227 if (ws) { 228 ws.close(); 229 ws = undefined; 230 } 231 }); 232 233 return ( 234 <div 235 class={`border-sp-border bg-sp-surface flex min-h-0 flex-col border-l ${props.class ?? ""}`} 236 > 237 {/* Messages */} 238 <div ref={messagesEl} class="min-h-0 flex-1 overflow-y-auto pt-2"> 239 <Show 240 when={messages().length > 0} 241 fallback={ 242 <Show when={!connected()}> 243 <div class="text-sp-dim flex h-full items-center justify-center text-sm"> 244 Connecting... 245 </div> 246 </Show> 247 } 248 > 249 <div class="space-y-1"> 250 <For each={messages()}> 251 {(msg) => ( 252 <div class="group/msg hover:bg-sp-hover relative px-3 text-sm leading-relaxed"> 253 <Show when={agent()}> 254 <button 255 class="text-sp-dim hover:text-sp-accent bg-sp-bg border-sp-border absolute -top-3 right-1 hidden rounded border p-1 shadow-sm transition-colors group-hover/msg:inline-flex" 256 title="Reply" 257 onClick={() => { 258 setReplyingTo(msg); 259 inputEl?.focus(); 260 }} 261 > 262 <Reply size={16} /> 263 </button> 264 </Show> 265 <Show when={msg.replyTo}> 266 {(parent) => ( 267 <div class="text-sp-dim flex items-center gap-1 text-[11px]"> 268 <CornerDownRight size={10} class="shrink-0" /> 269 <span class="font-medium" style={{ color: getAuthorColor(parent()) }}> 270 {parent().author.handle} 271 </span> 272 <span class="truncate">{parent().record.text}</span> 273 </div> 274 )} 275 </Show> 276 <ChatBadges badges={msg.badges} /> 277 <span class="font-medium" style={{ color: getAuthorColor(msg) }}> 278 {msg.author.handle} 279 </span> 280 <span class="text-sp-dim">: </span> 281 <span class="wrap-break-word"> 282 <For each={segmentRichText(msg.record.text, msg.record.facets)}> 283 {(seg) => <FacetSegment text={seg.text} facet={seg.facet} />} 284 </For> 285 </span> 286 </div> 287 )} 288 </For> 289 </div> 290 </Show> 291 </div> 292 293 {/* Input */} 294 <Show 295 when={agent()} 296 fallback={ 297 <button 298 class="text-sp-dim hover:text-sp-accent border-sp-border hover:bg-sp-hover mt-2 w-full border-t py-4 text-center text-xs italic transition-colors" 299 onClick={() => setShowLoginModal(true)} 300 > 301 Sign in to chat 302 </button> 303 } 304 > 305 <div class="px-2 py-3"> 306 <Show when={replyingTo()}> 307 {(msg) => ( 308 <div class="bg-sp-bg text-sp-dim mb-1.5 flex items-center gap-1.5 rounded px-2 py-1 text-xs"> 309 <CornerDownRight size={10} class="shrink-0" /> 310 <span class="font-medium" style={{ color: getAuthorColor(msg()) }}> 311 {msg().author.handle} 312 </span> 313 <span class="min-w-0 flex-1 truncate">{msg().record.text}</span> 314 <button 315 class="hover:text-sp-text shrink-0 rounded p-0.5 transition-colors" 316 onClick={() => setReplyingTo(undefined)} 317 > 318 <X size={12} /> 319 </button> 320 </div> 321 )} 322 </Show> 323 <input 324 ref={inputEl} 325 type="text" 326 placeholder={ 327 replyingTo() ? `Reply to ${replyingTo()!.author.handle}...` : "Send a message..." 328 } 329 class="border-sp-border bg-sp-bg text-sp-text placeholder:text-sp-dim focus:border-sp-accent w-full rounded-sm border px-3 py-2.5 text-sm focus:outline-none" 330 value={inputText()} 331 onInput={(e) => setInputText(e.currentTarget.value)} 332 onKeyDown={handleKeyDown} 333 disabled={!props.streamerDid} 334 /> 335 </div> 336 </Show> 337 </div> 338 ); 339}