Live video on the AT Protocol
79
fork

Configure Feed

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

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