A work-in-progress chat bot for Streamplace with chat overlay functionality
2
fork

Configure Feed

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

EventHandler passes messages to subscribed clients again

+66 -35
+12 -12
islands/ChatMessage.tsx
··· 37 37 setIsFadingOut(true); 38 38 setTimeout(() => { 39 39 if (onExpire) { 40 - onExpire(data.id); 40 + onExpire(data.uri); 41 41 } 42 42 }, FADE_TIME_SECONDS * 1000); 43 43 }, MESSAGE_TIMEOUT_SECONDS * 1000); 44 44 45 45 return () => clearTimeout(timeoutId); 46 - }, [data.id, onExpire]); 46 + }, [data.uri, onExpire]); 47 47 48 48 // Get colors from author profile 49 - const chatColor = data.author.color 50 - ? rgbaToString(data.author.color, 1) 49 + const chatColor = data.chatProfile?.color 50 + ? rgbaToString(data.chatProfile.color, 1) 51 51 : "#ffffff"; 52 - const borderColor = data.author.color 53 - ? rgbaToString(data.author.color, 0.8) 52 + const borderColor = data.chatProfile?.color 53 + ? rgbaToString(data.chatProfile.color, 0.8) 54 54 : MESSAGE_BORDER_COLOR; 55 55 56 56 // Background: use banner if available, otherwise use color or default ··· 58 58 59 59 if (USE_BANNER_BACKGROUND && data.author.bannerUrl) { 60 60 // Use banner as background with overlay 61 - const overlayColor = data.author.color 62 - ? rgbaToString(data.author.color, 0.3) 61 + const overlayColor = data.chatProfile?.color 62 + ? rgbaToString(data.chatProfile?.color, 0.3) 63 63 : MESSAGE_BG_COLOR; 64 64 65 65 backgroundStyle = { ··· 69 69 backgroundPosition: "center", 70 70 backgroundBlendMode: "overlay", 71 71 }; 72 - } else if (data.author.color) { 72 + } else if (data.chatProfile?.color) { 73 73 // Use solid color background 74 74 backgroundStyle = { 75 - backgroundColor: rgbaToString(data.author.color, 0.5), 75 + backgroundColor: rgbaToString(data.chatProfile?.color, 0.5), 76 76 }; 77 77 } else { 78 78 // Use default background ··· 85 85 <div 86 86 ref={messageRef} 87 87 class={`chat-message ${isFadingOut ? "fading-out" : ""}`} 88 - data-message-id={data.id} 88 + data-message-id={data.uri} 89 89 style={{ 90 90 ...backgroundStyle, 91 91 border: `${MESSAGE_BORDER_WIDTH} solid ${borderColor}`, ··· 118 118 </div> 119 119 120 120 <div class="chat-message-text"> 121 - {data.text} 121 + {data.record.text} 122 122 </div> 123 123 </div> 124 124 </div>
+21 -14
islands/ChatOverlay.tsx
··· 22 22 }: ChatBoxProps) { 23 23 const [messages, setMessages] = useState<EnrichedChatMessage[]>([]); 24 24 const [pendingDeletes, setPendingDeletes] = useState< 25 - Array<{ id: string; timestamp: number }> 25 + Array<{ uri: string; timestamp: number }> 26 26 >([]); 27 27 const chatContainerRef = useRef<HTMLDivElement>(null); 28 28 const [connected, setConnected] = useState(false); ··· 31 31 32 32 // Prune old pending deletes (older than 60 seconds) 33 33 const prunePendingDeletes = useCallback( 34 - (list: Array<{ id: string; timestamp: number }>) => { 34 + (list: Array<{ uri: string; timestamp: number }>) => { 35 35 const now = Date.now(); 36 36 return list.filter((item) => now - item.timestamp < 60000); 37 37 }, ··· 44 44 }, []); 45 45 46 46 // Handle message expiration 47 - const handleExpire = useCallback((id: string) => { 48 - setMessages((current) => current.filter((msg) => msg.id !== id)); 47 + const handleExpire = useCallback((uri: string) => { 48 + setMessages((current) => 49 + current.filter((message) => message.uri !== uri) 50 + ); 49 51 }, []); 50 52 51 53 // Memoize handleAddMessage to prevent recreating on every render 52 54 const handleAddMessage = useCallback( 53 - (data: EnrichedChatMessage) => { 55 + (chatMessage: EnrichedChatMessage) => { 54 56 // Filter empty messages 55 - if ( 56 - !data.text || typeof data.text !== "string" || 57 - !data.text.trim() 58 - ) { 57 + if (!chatMessage.record.text || !chatMessage.record.text.trim()) { 59 58 return; 60 59 } 61 60 ··· 63 62 setPendingDeletes((current) => { 64 63 const pruned = prunePendingDeletes(current); 65 64 const pendingIndex = pruned.findIndex((item) => 66 - item.id === data.id 65 + item.uri === chatMessage.uri 67 66 ); 68 67 69 68 if (pendingIndex !== -1) { ··· 78 77 79 78 setMessages((current) => { 80 79 // Check for duplicates 81 - if (current.some((msg) => msg.id === data.id)) { 80 + if ( 81 + current.some((storedMessage) => 82 + storedMessage.uri === chatMessage.uri 83 + ) 84 + ) { 82 85 return current; 83 86 } 84 87 85 88 // Add new message and limit total messages 86 - const updated = [...current, data]; 89 + const updated = [...current, chatMessage]; 87 90 return updated.slice(-maxMessages); 88 91 }); 89 92 }, ··· 163 166 boxShadow, 164 167 }} 165 168 > 166 - {messages.slice().reverse().map((msg) => ( 167 - <ChatMessage key={msg.id} data={msg} onExpire={handleExpire} /> 169 + {messages.slice().reverse().map((message) => ( 170 + <ChatMessage 171 + key={message.uri} 172 + data={message} 173 + onExpire={handleExpire} 174 + /> 168 175 ))} 169 176 </div> 170 177 );
+3 -2
routes/index.tsx
··· 1 1 import { define } from "../utils.ts"; 2 2 3 - export default define.page(function Home(ctx) { 3 + export default define.page(function Home(_ctx) { 4 4 return ( 5 5 <div> 6 - Hello World 6 + This is a placeholder for a future bot settings and record creation 7 + page. 7 8 </div> 8 9 ); 9 10 });
+25 -3
utils/eventHandler.ts
··· 4 4 import { PlaceStreamChatMessage } from "./lexicons/index.ts"; 5 5 6 6 export class EventHandler { 7 + private streamerClients: Map<string, Set<WebSocket>>; 8 + 9 + constructor(streamerClients: Map<string, Set<WebSocket>>) { 10 + this.streamerClients = streamerClients; 11 + } 12 + 7 13 handleEvent(event: JetstreamEvent) { 8 14 if (event.kind === "commit") { 9 15 const commit = event.commit; ··· 44 50 // Send to bot 45 51 const streamplaceBot = botInstances.get(record.streamer); 46 52 streamplaceBot!.processMessage(event); 47 - // Enrich and 48 - //const enrichedChatMessage = await this.enrichMessage(event); 53 + // Enrich and pass to subscribed websocket clients 54 + const subscribedClients = this.streamerClients.get( 55 + record.streamer, 56 + ); 57 + if (subscribedClients && subscribedClients.size > 0) { 58 + const enrichedMessage = await this.enrichMessage(event); 59 + const messageJson = JSON.stringify({ 60 + type: "chat_message", 61 + data: enrichedMessage, 62 + }); 63 + 64 + // Send to all subscribed clients 65 + for (const client of subscribedClients) { 66 + if (client.readyState === WebSocket.OPEN) { 67 + client.send(messageJson); 68 + } 69 + } 70 + } 49 71 } 50 72 } 51 73 } ··· 86 108 cid: commitEvent.commit.cid, //TODO 87 109 deleted: false, 88 110 indexedAt: "", //TODO. 89 - record: commitEvent.commit.record as Record<string, unknown>, //TODO 111 + record: commitEvent.commit.record as PlaceStreamChatMessage.Main, //TODO 90 112 uri: `at://${commitEvent.did}/place.stream.chat.message/${commitEvent.commit.rkey}`, 91 113 }; 92 114 }
+2
utils/globals.d.ts
··· 35 35 avatarUrl?: string; 36 36 bannerUrl?: string; 37 37 }; 38 + // it is not obvious to me why but the official lexicon has type Record<string, unknown> here 39 + record: import("./lexicons/index.ts").PlaceStreamChatMessage.Main; 38 40 }
+3 -4
utils/websocket.ts
··· 13 13 private jetstreamWs: WebSocket | null = null; 14 14 private clients = new Map<WebSocket, Set<string>>(); // client -> subscribed streamers 15 15 private streamerClients = new Map<string, Set<WebSocket>>(); // streamer -> clients 16 - private eventHandler = new EventHandler; 16 + private eventHandler = new EventHandler(this.streamerClients); 17 17 18 18 constructor() {} 19 19 ··· 36 36 37 37 private connectToJetstream() { 38 38 const jetstreamUrl = JETSTREAM_URL; 39 - 39 + 40 40 this.jetstreamWs = new WebSocket(jetstreamUrl); 41 41 42 42 this.jetstreamWs.onopen = () => { ··· 48 48 const jetstreamEvent: JetstreamEvent = JSON.parse( 49 49 event.data, 50 50 ); 51 - 51 + 52 52 this.eventHandler.handleEvent(jetstreamEvent); 53 - 54 53 } catch (error) { 55 54 console.error("Error processing jetstream message:", error); 56 55 }