Live video on the AT Protocol
79
fork

Configure Feed

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

at build-3 487 lines 14 kB view raw
1import { 2 useChat, 3 useProfile, 4 useSetReplyToMessage, 5} from "@streamplace/components"; 6import { Reply, Settings, X } from "@tamagui/lucide-icons"; 7import { 8 createBlockRecord, 9 selectUserProfile, 10} from "features/bluesky/blueskySlice"; 11import { MessageViewHydrated } from "features/player/playerSlice"; 12import usePlatform from "hooks/usePlatform"; 13import { useEffect, useRef, useState } from "react"; 14import { Linking, TouchableOpacity } from "react-native"; 15import { useAppDispatch, useAppSelector } from "store/hooks"; 16 17import { $Typed, RichText } from "@atproto/api"; 18import { 19 isMention, 20 Link, 21 Mention, 22} from "@atproto/api/dist/client/types/app/bsky/richtext/facet"; 23import { ChatMessageViewHydrated } from "streamplace"; 24import { Button, ScrollView, Sheet, Text, useMedia, View } from "tamagui"; 25import { RichtextSegment, segmentize } from "../../utils/facet"; 26 27export default function Chat({ 28 isChatVisible, 29 setIsChatVisible: _setIsChatVisible, 30}: { 31 isChatVisible: boolean; 32 setIsChatVisible: (visible: boolean) => void; 33}) { 34 const [open, setOpen] = useState(false); 35 const [modMessage, setMessage] = useState<ChatMessageViewHydrated | null>( 36 null, 37 ); 38 const [isAtBottom, setIsAtBottom] = useState(true); 39 const chat = useChat(); 40 const scrollRef = useRef<ScrollView>(null); 41 const streamerProfile = useProfile(); 42 const userProfile = useAppSelector(selectUserProfile); 43 const myStream = !!( 44 userProfile && 45 userProfile.did && 46 userProfile.did === streamerProfile?.did 47 ); 48 49 const handleScroll = (event: any) => { 50 const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent; 51 const paddingToBottom = 100; 52 const isAtBottom = 53 layoutMeasurement.height + contentOffset.y >= 54 contentSize.height - paddingToBottom; 55 setIsAtBottom(isAtBottom); 56 }; 57 58 useEffect(() => { 59 if (chat && isAtBottom) { 60 scrollRef.current?.scrollToEnd({ animated: true }); 61 } 62 }, [chat, isAtBottom]); 63 64 if (!chat) { 65 return <></>; 66 } 67 68 const m = useMedia(); 69 const dispatch = useAppDispatch(); 70 71 return ( 72 <View f={1} position="relative"> 73 {isChatVisible && ( 74 <> 75 <Sheet 76 // forceRemoveScrollEnabled={open} 77 open={open} 78 modal={!m.gtXs} 79 onOpenChange={setOpen} 80 // snapPoints={snapPoints} 81 // snapPointsMode={snapPointsMode} 82 dismissOnSnapToBottom 83 // position={position} 84 // onPositionChange={setPosition} 85 zIndex={100_000} 86 animation="medium" 87 > 88 <Sheet.Overlay 89 animation="lazy" 90 backgroundColor="$shadow6" 91 enterStyle={{ opacity: 0 }} 92 exitStyle={{ opacity: 0 }} 93 /> 94 <Sheet.Frame> 95 <View 96 f={1} 97 alignItems="center" 98 justifyContent="center" 99 padding="$4" 100 gap="$5" 101 backgroundColor="$accentBackground" 102 > 103 <Button 104 position="absolute" 105 top="$0" 106 right="$0" 107 onPress={(e) => { 108 e.stopPropagation(); 109 setOpen(false); 110 }} 111 marginRight={-15} 112 marginTop={-5} 113 backgroundColor="transparent" 114 > 115 <X /> 116 </Button> 117 {modMessage && ( 118 <> 119 <ChatMessageText message={modMessage} chat={chat || []} /> 120 {modMessage.author.did !== userProfile?.did && ( 121 <Button 122 width="100%" 123 onPress={() => { 124 setOpen(false); 125 dispatch( 126 createBlockRecord({ 127 subjectDID: modMessage.author.did, 128 }), 129 ); 130 }} 131 > 132 <Text>Block @{modMessage.author.handle}</Text> 133 </Button> 134 )} 135 {modMessage.author.did === userProfile?.did && ( 136 <> 137 <Button width="100%" disabled={true}> 138 <Text>(You can't block yourself!)</Text> 139 </Button> 140 </> 141 )} 142 </> 143 )} 144 </View> 145 </Sheet.Frame> 146 </Sheet> 147 <ScrollView 148 paddingHorizontal="$4" 149 invertStickyHeaders={true} 150 ref={scrollRef} 151 onContentSizeChange={() => { 152 if (isAtBottom) { 153 scrollRef.current?.scrollToEnd({ animated: true }); 154 } 155 }} 156 onLayout={() => { 157 if (isAtBottom) { 158 scrollRef.current?.scrollToEnd({ animated: true }); 159 } 160 }} 161 onScroll={handleScroll} 162 scrollEventThrottle={16} 163 > 164 {chat.map((message) => ( 165 <ChatMessageRow 166 key={message.cid} 167 message={message} 168 setOpen={setOpen} 169 setMessage={setMessage} 170 myStream={myStream} 171 replyHandle={undefined} 172 chat={chat} 173 /> 174 ))} 175 </ScrollView> 176 </> 177 )} 178 </View> 179 ); 180} 181 182function ChatMessageRow({ 183 message, 184 setMessage, 185 setOpen, 186 myStream, 187 replyHandle: _replyHandle, 188 chat, 189}: { 190 message: ChatMessageViewHydrated; 191 setOpen: (open: boolean) => void; 192 setMessage: (message: ChatMessageViewHydrated) => void; 193 myStream: boolean; 194 replyHandle?: string; 195 chat: ChatMessageViewHydrated[]; 196}): JSX.Element { 197 const [hover, setHover] = useState(false); 198 const setReplyToMessage = useSetReplyToMessage(); 199 const { isWeb } = usePlatform(); 200 201 const moderateMessage = () => { 202 if (!myStream) { 203 return; 204 } 205 setOpen(true); 206 setMessage(message); 207 }; 208 209 const handleReply = () => { 210 setReplyToMessage(message); 211 }; 212 213 const replyTo = message.replyTo as ChatMessageViewHydrated | undefined; 214 const hasReply = !!replyTo; 215 const replyToHandle = replyTo?.author?.handle; 216 const replyToText = replyTo?.record?.text; 217 const replyToColor = replyTo?.chatProfile?.color 218 ? `rgb(${replyTo.chatProfile.color.red}, ${replyTo.chatProfile.color.green}, ${replyTo.chatProfile.color.blue})` 219 : "$accentColor"; 220 221 return ( 222 <View 223 flexDirection="row" 224 display="block" 225 paddingVertical={isWeb ? 6 : 4} // Adjust padding for web 226 position="relative" 227 onMouseEnter={() => setHover(true)} 228 onMouseLeave={() => setHover(false)} 229 hoverStyle={{ backgroundColor: "rgba(255,255,255,0.1)" }} 230 onPress={() => { 231 if (!isWeb) { 232 moderateMessage(); 233 } 234 }} 235 onLongPress={handleReply} 236 > 237 {hasReply && ( 238 <View 239 position="absolute" 240 left={6} 241 top={-8} 242 width={2} 243 height={16} 244 opacity={0.7} 245 /> 246 )} 247 <View flexDirection="column" gap="$1" flex={1}> 248 {/* Reply section */} 249 {hasReply && ( 250 <View 251 flexDirection="column" 252 marginBottom="$2" 253 paddingLeft="$3" 254 position="relative" 255 > 256 {/* Vertical reply line */} 257 <View 258 position="absolute" 259 left={6} 260 top={0} 261 bottom={0} 262 width={2} 263 borderRadius={2} 264 backgroundColor="$accentColor" 265 opacity={0.5} 266 /> 267 {/* Reply preview */} 268 <View 269 flexDirection="row" 270 alignItems="center" 271 gap="$1" 272 paddingVertical="$1" 273 paddingHorizontal="$2" 274 borderRadius="$2" 275 marginLeft="-$1" 276 > 277 <Text fontSize={12} color={replyToColor} fontWeight="bold"> 278 {replyToHandle ? `@${replyToHandle}` : ""} 279 </Text> 280 <Text 281 fontSize={12} 282 color="$color" 283 opacity={0.7} 284 numberOfLines={1} 285 flex={1} 286 > 287 {replyToText || ""} 288 </Text> 289 </View> 290 </View> 291 )} 292 293 {/* Message content */} 294 <View flexDirection="row" alignItems="flex-start" gap="$2"> 295 <ChatMessageText message={message} chat={chat} /> 296 </View> 297 </View> 298 <View 299 position="absolute" 300 flexDirection="row" 301 right={0} 302 top={0} 303 bottom={0} 304 alignItems="stretch" 305 justifyContent="flex-end" 306 gap="$2" 307 > 308 {isWeb && ( 309 <TouchableOpacity 310 style={{ 311 display: hover ? "flex" : "none", 312 alignItems: "center", 313 justifyContent: "center", 314 padding: 4, 315 }} 316 onPress={handleReply} 317 > 318 <Reply size={16} /> 319 </TouchableOpacity> 320 )} 321 {isWeb && myStream && ( 322 <TouchableOpacity 323 style={{ 324 display: hover ? "flex" : "none", 325 alignItems: "center", 326 justifyContent: "center", 327 padding: 4, 328 }} 329 onPress={moderateMessage} 330 > 331 <Settings size={16} /> 332 </TouchableOpacity> 333 )} 334 </View> 335 </View> 336 ); 337} 338 339function byteOffsetToCharIndex(text: string, byteOffset: number): number { 340 const encoder = new TextEncoder(); 341 let bytesCount = 0; 342 for (let i = 0; i < text.length; i++) { 343 const charBytes = encoder.encode(text[i]); 344 if (bytesCount + charBytes.length > byteOffset) { 345 return i; 346 } 347 bytesCount += charBytes.length; 348 } 349 return text.length; 350} 351 352const ChatMessageText = ({ 353 message, 354 chat = [], 355}: { 356 message: ChatMessageViewHydrated; 357 chat?: ChatMessageViewHydrated[]; 358}) => { 359 const rt = new RichText({ text: message.record.text }); 360 rt.detectFacetsWithoutResolution(); 361 362 // Process facets to add DID information for mentions 363 rt.facets?.forEach((facet) => { 364 facet.features.forEach((feature) => { 365 if (isMention(feature)) { 366 const startCharIndex = byteOffsetToCharIndex( 367 message.record.text, 368 facet.index.byteStart, 369 ); 370 const endCharIndex = byteOffsetToCharIndex( 371 message.record.text, 372 facet.index.byteEnd, 373 ); 374 const mentionText = message.record.text.slice( 375 startCharIndex, 376 endCharIndex, 377 ); 378 // Find the mentioned user by their handle (removing the @ symbol) 379 const mentionedUser = chat.find( 380 (msg) => msg.author.handle === mentionText.slice(1), 381 ); 382 if (mentionedUser) { 383 feature.did = mentionedUser.author.did; 384 } 385 } 386 }); 387 }); 388 389 return ( 390 <Text fontSize={13}> 391 <Text 392 color={getRgbColor(message.chatProfile?.color)} 393 cursor="pointer" 394 onPress={() => 395 Linking.openURL(`https://bsky.app/profile/${message.author.did}`) 396 } 397 > 398 {message.author.handle 399 ? `@${message.author.handle}` 400 : message.author.did} 401 : 402 </Text> 403 <Text> </Text> 404 <RichTextMessage 405 text={message.record.text} 406 facets={rt.facets as Facet[]} 407 chat={chat} 408 /> 409 </Text> 410 ); 411}; 412 413interface Facet { 414 index: { 415 byteStart: number; 416 byteEnd: number; 417 }; 418 features: Array<{ 419 $type: string; 420 uri?: string; 421 did?: string; 422 }>; 423} 424 425const getRgbColor = (color?: { red: number; green: number; blue: number }) => 426 color ? `rgb(${color.red}, ${color.green}, ${color.blue})` : "$accentColor"; 427 428const segmentedObject = ( 429 obj: RichtextSegment, 430 chat: MessageViewHydrated[], 431 index: number, 432) => { 433 if (obj.features && obj.features.length > 0) { 434 let ftr = obj.features[0]; 435 // afaik there shouldn't be a case where facets overlap, at least currently 436 if (ftr.$type === "app.bsky.richtext.facet#link") { 437 let linkftr = ftr as $Typed<Link>; 438 return ( 439 <Text 440 key={`mention-${index}`} 441 color="#9090f0" 442 cursor="pointer" 443 onPress={() => Linking.openURL(linkftr.uri || "")} 444 > 445 {obj.text} 446 </Text> 447 ); 448 } else if (ftr.$type === "app.bsky.richtext.facet#mention") { 449 let mtnftr = ftr as $Typed<Mention>; 450 const mentionedUserMessage = chat.find( 451 (msg) => msg.author.did === mtnftr.did, 452 ); 453 return ( 454 <Text 455 key={`mention-${index}`} 456 color={getRgbColor(mentionedUserMessage?.chatProfile?.color)} 457 cursor="pointer" 458 onPress={() => 459 Linking.openURL(`https://bsky.app/profile/${mtnftr.did || ""}`) 460 } 461 > 462 {obj.text} 463 </Text> 464 ); 465 } 466 } else { 467 return <Text key={`text-${index}`}>{obj.text}</Text>; 468 } 469}; 470 471const RichTextMessage = ({ 472 text, 473 facets, 474 chat = [], 475}: { 476 text: string; 477 facets: Facet[]; 478 chat?: ChatMessageViewHydrated[]; 479}) => { 480 if (!facets?.length) return <Text>{text}</Text>; 481 482 let segs = segmentize(text, facets); 483 484 return segs.map((seg, i) => 485 segmentedObject(seg, chat as MessageViewHydrated[], i), 486 ); 487};