Live video on the AT Protocol

Merge pull request #396 from streamplace/natb/system-messages

chat: add system messages to New Player

authored by natalie and committed by GitHub ad1f501f df731e8a

+223 -13
+2 -1
js/components/src/components/chat/chat-message.tsx
··· 185 185 (prevProps, nextProps) => { 186 186 return ( 187 187 prevProps.item.author.handle === nextProps.item.author.handle && 188 - prevProps.item.record.text === nextProps.item.record.text 188 + prevProps.item.record.text === nextProps.item.record.text && 189 + prevProps.item.uri === nextProps.item.uri 189 190 ); 190 191 }, 191 192 );
+16
js/components/src/components/chat/chat.tsx
··· 10 10 } from "react-native-reanimated"; 11 11 import { ChatMessageViewHydrated } from "streamplace"; 12 12 import { 13 + SystemMessage, 13 14 Text, 14 15 useChat, 15 16 usePlayerStore, ··· 173 174 } 174 175 }; 175 176 }, []); 177 + 178 + // Check if this is a system message 179 + const isSystemMessage = item.author.did === "did:sys:system"; 180 + 181 + // For system messages, render without swipe gestures 182 + if (isSystemMessage) { 183 + return ( 184 + <View style={[py[1]]}> 185 + <SystemMessage 186 + title={item.record.text} 187 + timestamp={new Date(item.record.createdAt)} 188 + /> 189 + </View> 190 + ); 191 + } 176 192 177 193 if (isMouseDriven) { 178 194 return (
+38
js/components/src/components/chat/system-message.tsx
··· 1 + import { View } from "react-native"; 2 + import { flex, gap, layout, ml, p, pl, w } from "../../ui"; 3 + import { atoms } from "../ui"; 4 + import { Code, Text } from "../ui/text"; 5 + 6 + interface SystemMessageProps { 7 + title: string; 8 + timestamp: Date; 9 + } 10 + 11 + export function SystemMessage({ title, timestamp }: SystemMessageProps) { 12 + return ( 13 + <View style={[w.percent[100], p[2]]}> 14 + <Code color="muted" tracking="widest" style={[pl[12], ml[1]]}> 15 + SYSTEM MESSAGE 16 + </Code> 17 + <View style={[gap.all[2], layout.flex.row]}> 18 + <Text 19 + style={{ 20 + fontVariant: ["tabular-nums"], 21 + color: atoms.colors.gray[300], 22 + }} 23 + > 24 + {timestamp.toLocaleTimeString([], { 25 + hour: "2-digit", 26 + minute: "2-digit", 27 + hour12: false, 28 + })} 29 + </Text> 30 + <Text weight="bold" color="default" style={[flex.shrink[1]]}> 31 + {title} 32 + </Text> 33 + </View> 34 + </View> 35 + ); 36 + } 37 + 38 + export default SystemMessage;
+2
js/components/src/index.tsx
··· 27 27 28 28 export * from "./components/chat/chat"; 29 29 export * from "./components/chat/chat-box"; 30 + export * from "./components/chat/system-message"; 31 + export * from "./lib/system-messages";
+135
js/components/src/lib/system-messages.ts
··· 1 + import { ChatMessageViewHydrated } from "streamplace"; 2 + 3 + export enum SystemMessageType { 4 + stream_start = "stream_start", 5 + stream_end = "stream_end", 6 + notification = "notification", 7 + } 8 + 9 + export interface SystemMessageMetadata { 10 + username?: string; 11 + action?: string; 12 + count?: number; 13 + duration?: string; 14 + reason?: string; 15 + streamerName?: string; 16 + } 17 + 18 + /** 19 + * Creates a system message with the proper structure 20 + * @param type The type of system message 21 + * @param text The message text 22 + * @param metadata Optional metadata for the message 23 + * @returns A properly formatted ChatMessageViewHydrated object 24 + */ 25 + export const createSystemMessage = ( 26 + type: SystemMessageType, 27 + text: string, 28 + metadata?: SystemMessageMetadata, 29 + date: Date = new Date(), 30 + ): ChatMessageViewHydrated => { 31 + const now = date; 32 + 33 + return { 34 + uri: `at://did:sys:system/place.stream.chat.message/${now.getTime()}`, 35 + cid: `system-${now.getTime()}`, 36 + author: { 37 + did: "did:sys:system", 38 + handle: type, // Use handle to specify the type of system message 39 + }, 40 + record: { 41 + text, 42 + createdAt: now.toISOString(), 43 + streamer: "system", 44 + $type: "place.stream.chat.message", 45 + }, 46 + indexedAt: now.toISOString(), 47 + chatProfile: { 48 + color: { red: 128, green: 128, blue: 128 }, // Gray color for system messages 49 + }, 50 + }; 51 + }; 52 + 53 + /** 54 + * System message factory functions for common scenarios 55 + */ 56 + export const SystemMessages = { 57 + streamStart: (streamerName: string): ChatMessageViewHydrated => 58 + createSystemMessage( 59 + SystemMessageType.stream_start, 60 + `Now streaming - ${streamerName}`, 61 + { 62 + streamerName, 63 + }, 64 + ), 65 + 66 + // technically, streams can't 'end' on Streamplace 67 + // possibly we could use deleting or editing streams (`endedAt` param) for this? 68 + streamEnd: (duration?: string): ChatMessageViewHydrated => 69 + createSystemMessage( 70 + SystemMessageType.stream_end, 71 + duration ? `Stream has ended. Duration: ${duration}` : "Stream has ended", 72 + { duration }, 73 + ), 74 + 75 + notification: (message: string): ChatMessageViewHydrated => 76 + createSystemMessage(SystemMessageType.notification, message), 77 + }; 78 + 79 + /** 80 + * Checks if a message is a system message 81 + * @param message The message to check 82 + * @returns True if the message is a system message 83 + */ 84 + export const isSystemMessage = (message: ChatMessageViewHydrated): boolean => { 85 + return message.author.did === "did:sys:system"; 86 + }; 87 + 88 + /** 89 + * Gets the system message type from a message 90 + * @param message The message to check 91 + * @returns The system message type or null if not a system message 92 + */ 93 + export const getSystemMessageType = ( 94 + message: ChatMessageViewHydrated, 95 + ): SystemMessageType | null => { 96 + if (!isSystemMessage(message)) { 97 + return null; 98 + } 99 + return message.author.handle as SystemMessageType; 100 + }; 101 + 102 + /** 103 + * Parses metadata from a system message based on its type 104 + * @param message The system message to parse 105 + * @returns The parsed metadata 106 + */ 107 + export const parseSystemMessageMetadata = ( 108 + message: ChatMessageViewHydrated, 109 + ): SystemMessageMetadata => { 110 + const metadata: SystemMessageMetadata = {}; 111 + const type = getSystemMessageType(message); 112 + const text = message.record.text; 113 + 114 + if (!type) return metadata; 115 + 116 + switch (type) { 117 + case "stream_end": { 118 + const durationMatch = text.match(/Duration:\s*(\d+:\d+(?::\d+)?)/); 119 + if (durationMatch) { 120 + metadata.duration = durationMatch[1]; 121 + } 122 + break; 123 + } 124 + 125 + case "stream_start": { 126 + const streamerMatch = text.match(/^(.+?)\s+is now live!/); 127 + if (streamerMatch) { 128 + metadata.streamerName = streamerMatch[1]; 129 + } 130 + break; 131 + } 132 + } 133 + 134 + return metadata; 135 + };
+14 -11
js/components/src/livestream-store/chat.tsx
··· 214 214 215 215 if (parentMsgKey) { 216 216 const parentMsg = newChatIndex[parentMsgKey]; 217 - processedMessage = { 218 - ...message, 219 - replyTo: { 220 - cid: parentMsg.cid, 221 - uri: parentMsg.uri, 222 - author: parentMsg.author, 223 - record: parentMsg.record, 224 - chatProfile: parentMsg.chatProfile, 225 - indexedAt: parentMsg.indexedAt, 226 - }, 227 - }; 217 + // Don't allow replies to system messages 218 + if (parentMsg.author.did !== "did:sys:system") { 219 + processedMessage = { 220 + ...message, 221 + replyTo: { 222 + cid: parentMsg.cid, 223 + uri: parentMsg.uri, 224 + author: parentMsg.author, 225 + record: parentMsg.record, 226 + chatProfile: parentMsg.chatProfile, 227 + indexedAt: parentMsg.indexedAt, 228 + }, 229 + }; 230 + } 228 231 } 229 232 } 230 233 }
+16 -1
js/components/src/livestream-store/websocket-consumer.tsx
··· 8 8 PlaceStreamLivestream, 9 9 PlaceStreamSegment, 10 10 } from "streamplace"; 11 + import { SystemMessages } from "../lib/system-messages"; 11 12 import { reduceChat } from "./chat"; 12 13 import { LivestreamState } from "./livestream-state"; 13 14 ··· 17 18 ): LivestreamState => { 18 19 for (const message of messages) { 19 20 if (PlaceStreamLivestream.isLivestreamView(message)) { 21 + const newLivestream = message as LivestreamViewHydrated; 22 + const oldLivestream = state.livestream; 23 + 24 + // check if this is actually new 25 + if (!oldLivestream || oldLivestream.uri !== newLivestream.uri) { 26 + const streamTitle = newLivestream.record.title || "something cool!"; 27 + const systemMessage = SystemMessages.streamStart(streamTitle); 28 + // set proper times 29 + systemMessage.indexedAt = newLivestream.indexedAt; 30 + systemMessage.record.createdAt = newLivestream.record.createdAt; 31 + 32 + state = reduceChat(state, [systemMessage], []); 33 + } 34 + 20 35 state = { 21 36 ...state, 22 - livestream: message as LivestreamViewHydrated, 37 + livestream: newLivestream, 23 38 }; 24 39 } else if (PlaceStreamLivestream.isViewerCount(message)) { 25 40 state = {