minimal streamplace frontend
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}