Live video on the AT Protocol
79
fork

Configure Feed

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

at natb/mobile-backgrounding 645 lines 18 kB view raw
1import { 2 useChat, 3 useProfile, 4 useReplyToMessage, 5 useSetReplyToMessage, 6} from "@streamplace/components"; 7import { Reply, ReplyAll, Settings, X } from "@tamagui/lucide-icons"; 8import { 9 createBlockRecord, 10 selectUserProfile, 11} from "features/bluesky/blueskySlice"; 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 ReanimatedSwipeable, { 24 SwipeableMethods, 25} from "react-native-gesture-handler/ReanimatedSwipeable"; 26import Animated, { 27 SharedValue, 28 useAnimatedStyle, 29} from "react-native-reanimated"; 30import { ChatMessageViewHydrated } from "streamplace"; 31import { Button, ScrollView, Sheet, Text, useMedia, View } from "tamagui"; 32import { RichtextSegment, segmentize } from "../../utils/facet"; 33 34export default function Chat({ 35 isChatVisible, 36 setIsChatVisible: _setIsChatVisible, 37}: { 38 isChatVisible: boolean; 39 setIsChatVisible: (visible: boolean) => void; 40}) { 41 const [open, setOpen] = useState(false); 42 const [modMessage, setMessage] = useState<ChatMessageViewHydrated | null>( 43 null, 44 ); 45 const [isAtBottom, setIsAtBottom] = useState(true); 46 const chat = useChat(); 47 const scrollRef = useRef<ScrollView>(null); 48 const streamerProfile = useProfile(); 49 const userProfile = useAppSelector(selectUserProfile); 50 const myStream = !!( 51 userProfile && 52 userProfile.did && 53 userProfile.did === streamerProfile?.did 54 ); 55 56 const handleScroll = (event: any) => { 57 const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent; 58 const paddingToBottom = 100; 59 const isAtBottom = 60 layoutMeasurement.height + contentOffset.y >= 61 contentSize.height - paddingToBottom; 62 setIsAtBottom(isAtBottom); 63 }; 64 65 useEffect(() => { 66 if (chat && isAtBottom) { 67 scrollRef.current?.scrollToEnd({ animated: true }); 68 } 69 }, [chat, isAtBottom]); 70 71 if (!chat) { 72 return <></>; 73 } 74 75 const m = useMedia(); 76 const dispatch = useAppDispatch(); 77 78 return ( 79 <View f={1} position="relative"> 80 {isChatVisible && ( 81 <> 82 <Sheet 83 // forceRemoveScrollEnabled={open} 84 open={open} 85 modal={!m.gtXs} 86 onOpenChange={setOpen} 87 // snapPoints={snapPoints} 88 // snapPointsMode={snapPointsMode} 89 dismissOnSnapToBottom 90 // position={position} 91 // onPositionChange={setPosition} 92 zIndex={100_000} 93 animation="medium" 94 > 95 <Sheet.Overlay 96 animation="lazy" 97 backgroundColor="$shadow6" 98 enterStyle={{ opacity: 0 }} 99 exitStyle={{ opacity: 0 }} 100 /> 101 <Sheet.Frame> 102 <View 103 f={1} 104 alignItems="center" 105 justifyContent="center" 106 padding="$4" 107 gap="$5" 108 backgroundColor="$accentBackground" 109 > 110 <Button 111 position="absolute" 112 top="$0" 113 right="$0" 114 onPress={(e) => { 115 e.stopPropagation(); 116 setOpen(false); 117 }} 118 marginRight={-15} 119 marginTop={-5} 120 backgroundColor="transparent" 121 > 122 <X /> 123 </Button> 124 {modMessage && ( 125 <> 126 <ChatMessageText message={modMessage} chat={chat || []} /> 127 {modMessage.author.did !== userProfile?.did && ( 128 <Button 129 width="100%" 130 onPress={() => { 131 setOpen(false); 132 dispatch( 133 createBlockRecord({ 134 subjectDID: modMessage.author.did, 135 }), 136 ); 137 }} 138 > 139 <Text>Block @{modMessage.author.handle}</Text> 140 </Button> 141 )} 142 {modMessage.author.did === userProfile?.did && ( 143 <> 144 <Button width="100%" disabled={true}> 145 <Text>(You can't block yourself!)</Text> 146 </Button> 147 </> 148 )} 149 </> 150 )} 151 </View> 152 </Sheet.Frame> 153 </Sheet> 154 <ScrollView 155 marginHorizontal="$2" 156 invertStickyHeaders={true} 157 ref={scrollRef} 158 onContentSizeChange={() => { 159 if (isAtBottom) { 160 scrollRef.current?.scrollToEnd({ animated: true }); 161 } 162 }} 163 onLayout={() => { 164 if (isAtBottom) { 165 scrollRef.current?.scrollToEnd({ animated: true }); 166 } 167 }} 168 onScroll={handleScroll} 169 scrollEventThrottle={16} 170 > 171 {chat.map((message, index) => ( 172 <ChatMessageRow 173 key={message.cid + index} 174 message={message} 175 setOpen={setOpen} 176 setMessage={setMessage} 177 myStream={myStream} 178 replyHandle={undefined} 179 chat={chat} 180 /> 181 ))} 182 </ScrollView> 183 </> 184 )} 185 </View> 186 ); 187} 188 189const RightAction = ( 190 _progress: SharedValue<number>, 191 drag: SharedValue<number>, 192) => { 193 const styleAnimation = useAnimatedStyle(() => { 194 return { 195 transform: [ 196 { 197 translateX: drag.value + 50, 198 }, 199 ], 200 }; 201 }); 202 203 return ( 204 <Animated.View style={[styleAnimation, { height: "auto" }]}> 205 <Text 206 width="$4" 207 backgroundColor="rgba(255,255,255,0.5)" 208 borderRadius="$2" 209 pt={"$1"} 210 px={"$1"} 211 > 212 <ReplyAll /> 213 </Text> 214 </Animated.View> 215 ); 216}; 217 218function ChatMessageRow({ 219 message, 220 setMessage, 221 setOpen, 222 myStream, 223 replyHandle: _replyHandle, 224 chat, 225}: { 226 message: ChatMessageViewHydrated; 227 setOpen: (open: boolean) => void; 228 setMessage: (message: ChatMessageViewHydrated) => void; 229 myStream: boolean; 230 replyHandle?: string; 231 chat: ChatMessageViewHydrated[]; 232}): JSX.Element { 233 const [hover, setHover] = useState(false); 234 const setReplyToMessage = useSetReplyToMessage(); 235 const { isWeb } = usePlatform(); 236 237 const swipeableRef = useRef<SwipeableMethods>(null); 238 const close = () => { 239 let current: any = swipeableRef.current; 240 if (current) { 241 current.close(); 242 } 243 }; 244 245 const currentReplyTo = useReplyToMessage(); 246 247 const moderateMessage = () => { 248 if (!myStream) { 249 return; 250 } 251 setOpen(true); 252 setMessage(message); 253 }; 254 255 const handleReply = () => { 256 setReplyToMessage(message); 257 }; 258 259 const replyTo = message.replyTo as ChatMessageViewHydrated | undefined; 260 const hasReply = !!replyTo; 261 const replyToHandle = replyTo?.author?.handle; 262 const replyToText = replyTo?.record?.text; 263 const replyToColor = replyTo?.chatProfile?.color 264 ? `rgb(${replyTo.chatProfile.color.red}, ${replyTo.chatProfile.color.green}, ${replyTo.chatProfile.color.blue})` 265 : "$accentColor"; 266 267 return ( 268 <View 269 onMouseEnter={() => setHover(true)} 270 onMouseLeave={() => setHover(false)} 271 onPress={() => { 272 if (!isWeb) { 273 moderateMessage(); 274 } 275 }} 276 > 277 {isWeb && ( 278 <View 279 position="absolute" 280 flexDirection="row" 281 right={0} 282 top="$-3" 283 alignItems="center" 284 justifyContent="center" 285 gap="$2" 286 pl="$2" 287 pr="$1" 288 backgroundColor="rgba(64,64,64,1)" 289 borderRadius="$2" 290 style={{ 291 display: hover ? "flex" : "none", 292 alignItems: "center", 293 justifyContent: "center", 294 }} 295 zi={32} 296 > 297 <Text fontSize="$2">{timeAgo(message.record.createdAt)}</Text> 298 <TouchableOpacity onPress={handleReply}> 299 <Reply size={16} /> 300 </TouchableOpacity> 301 {isWeb && myStream && ( 302 <TouchableOpacity 303 style={{ 304 display: hover ? "flex" : "none", 305 alignItems: "center", 306 justifyContent: "center", 307 padding: 4, 308 }} 309 onPress={moderateMessage} 310 > 311 <Settings size={16} /> 312 </TouchableOpacity> 313 )} 314 </View> 315 )} 316 <ReanimatedSwipeable 317 ref={swipeableRef} 318 renderRightActions={RightAction} 319 overshootRight={false} 320 friction={2} 321 enableTrackpadTwoFingerGesture 322 rightThreshold={40} 323 onSwipeableOpen={(r) => { 324 if (r === "right") { 325 handleReply(); 326 } 327 close(); 328 }} 329 > 330 <View 331 flexDirection="row" 332 display="block" 333 paddingVertical={isWeb ? 6 : 4} // Adjust padding for web 334 paddingHorizontal={isWeb ? 6 : 4} // Adjust padding for web 335 position="relative" 336 hoverStyle={{ backgroundColor: "rgba(255,255,255,0.1)" }} 337 backgroundColor={ 338 currentReplyTo?.cid === message.cid 339 ? "rgba(180,180,255,0.1)" 340 : "transparent" 341 } 342 borderRadius={isWeb ? 4 : 4} 343 onPress={() => { 344 if (!isWeb) { 345 moderateMessage(); 346 } 347 }} 348 overflow="visible" 349 > 350 {hasReply && ( 351 <View 352 position="absolute" 353 left={6} 354 top={-8} 355 width={2} 356 height={16} 357 opacity={0.7} 358 /> 359 )} 360 <View flexDirection="column" gap="$1" flex={1} overflow="visible"> 361 {/* Reply section */} 362 {hasReply && ( 363 <View 364 flexDirection="column" 365 marginBottom="$2" 366 paddingLeft="$3" 367 position="relative" 368 > 369 {/* Vertical reply line */} 370 <View 371 position="absolute" 372 left={6} 373 top={0} 374 bottom={0} 375 width={2} 376 borderRadius={2} 377 backgroundColor="$accentColor" 378 opacity={0.5} 379 /> 380 {/* Reply preview */} 381 <View 382 flexDirection="row" 383 alignItems="center" 384 gap="$1" 385 paddingVertical="$1" 386 paddingHorizontal="$2" 387 borderRadius="$2" 388 marginLeft="-$1" 389 > 390 <Text fontSize={12} color={replyToColor} fontWeight="bold"> 391 {replyToHandle ? `@${replyToHandle}` : ""} 392 </Text> 393 <Text 394 fontSize={12} 395 color="$color" 396 opacity={0.7} 397 numberOfLines={1} 398 flex={1} 399 > 400 {replyToText || ""} 401 </Text> 402 </View> 403 </View> 404 )} 405 </View> 406 </View> 407 </ReanimatedSwipeable> 408 </View> 409 ); 410} 411 412function byteOffsetToCharIndex(text: string, byteOffset: number): number { 413 const encoder = new TextEncoder(); 414 let bytesCount = 0; 415 for (let i = 0; i < text.length; i++) { 416 const charBytes = encoder.encode(text[i]); 417 if (bytesCount + charBytes.length > byteOffset) { 418 return i; 419 } 420 bytesCount += charBytes.length; 421 } 422 return text.length; 423} 424 425const ChatMessageText = ({ 426 message, 427 chat = [], 428}: { 429 message: ChatMessageViewHydrated; 430 chat?: ChatMessageViewHydrated[]; 431}) => { 432 const rt = new RichText({ text: message.record.text }); 433 rt.detectFacetsWithoutResolution(); 434 435 // Process facets to add DID information for mentions 436 rt.facets?.forEach((facet) => { 437 facet.features.forEach((feature) => { 438 if (isMention(feature)) { 439 const startCharIndex = byteOffsetToCharIndex( 440 message.record.text, 441 facet.index.byteStart, 442 ); 443 const endCharIndex = byteOffsetToCharIndex( 444 message.record.text, 445 facet.index.byteEnd, 446 ); 447 const mentionText = message.record.text.slice( 448 startCharIndex, 449 endCharIndex, 450 ); 451 // Find the mentioned user by their handle (removing the @ symbol) 452 const mentionedUser = chat.find( 453 (msg) => msg.author.handle === mentionText.slice(1), 454 ); 455 if (mentionedUser) { 456 feature.did = mentionedUser.author.did; 457 } 458 } 459 }); 460 }); 461 462 return ( 463 <Text fontSize={13}> 464 <Text 465 color={getRgbColor(message.chatProfile?.color)} 466 cursor="pointer" 467 onPress={() => 468 Linking.openURL(`https://bsky.app/profile/${message.author.did}`) 469 } 470 > 471 {message.author.handle 472 ? `@${message.author.handle}` 473 : message.author.did} 474 : 475 </Text> 476 <Text> </Text> 477 <RichTextMessage 478 text={message.record.text} 479 facets={rt.facets as Facet[]} 480 chat={chat} 481 /> 482 </Text> 483 ); 484}; 485 486interface Facet { 487 index: { 488 byteStart: number; 489 byteEnd: number; 490 }; 491 features: Array<{ 492 $type: string; 493 uri?: string; 494 did?: string; 495 }>; 496} 497 498const getRgbColor = (color?: { red: number; green: number; blue: number }) => 499 color ? `rgb(${color.red}, ${color.green}, ${color.blue})` : "$accentColor"; 500 501const segmentedObject = ( 502 obj: RichtextSegment, 503 chat: ChatMessageViewHydrated[], 504 index: number, 505) => { 506 if (obj.features && obj.features.length > 0) { 507 let ftr = obj.features[0]; 508 // afaik there shouldn't be a case where facets overlap, at least currently 509 if (ftr.$type === "app.bsky.richtext.facet#link") { 510 let linkftr = ftr as $Typed<Link>; 511 return ( 512 <Text 513 key={`mention-${index}`} 514 color="#9090f0" 515 cursor="pointer" 516 onPress={() => Linking.openURL(linkftr.uri || "")} 517 > 518 {obj.text} 519 </Text> 520 ); 521 } else if (ftr.$type === "app.bsky.richtext.facet#mention") { 522 let mtnftr = ftr as $Typed<Mention>; 523 const mentionedUserMessage = chat.find( 524 (msg) => msg.author.did === mtnftr.did, 525 ); 526 return ( 527 <Text 528 key={`mention-${index}`} 529 color={getRgbColor(mentionedUserMessage?.chatProfile?.color)} 530 cursor="pointer" 531 onPress={() => 532 Linking.openURL(`https://bsky.app/profile/${mtnftr.did || ""}`) 533 } 534 > 535 {obj.text} 536 </Text> 537 ); 538 } 539 } else { 540 return <Text key={`text-${index}`}>{obj.text}</Text>; 541 } 542}; 543 544const RichTextMessage = ({ 545 text, 546 facets, 547 chat = [], 548}: { 549 text: string; 550 facets: Facet[]; 551 chat?: ChatMessageViewHydrated[]; 552}) => { 553 if (!facets?.length) return <Text>{text}</Text>; 554 555 let segs = segmentize(text, facets); 556 557 return segs.map((seg, i) => 558 segmentedObject(seg, chat as ChatMessageViewHydrated[], i), 559 ); 560}; 561 562export function timeAgo(time: Date | number | string): string { 563 let timestamp: number; 564 565 if (typeof time === "number") { 566 timestamp = time; 567 } else if (typeof time === "string") { 568 timestamp = new Date(time).getTime(); 569 } else if (time instanceof Date) { 570 timestamp = time.getTime(); 571 } else { 572 timestamp = Date.now(); 573 } 574 575 const now = Date.now(); 576 let seconds = (now - timestamp) / 1000; 577 let token = "ago"; 578 let listChoice = 1; 579 580 if (seconds === 0) { 581 return "Just now"; 582 } 583 584 if (seconds < 0) { 585 seconds = Math.abs(seconds); 586 token = "from now"; 587 listChoice = 2; 588 } 589 590 // Show time for > 1 hour difference 591 if (seconds > 3600) { 592 const date = new Date(timestamp); 593 // More than 1 day ago/from now: show shortened date + time 594 if (seconds > 86400) { 595 // Example format: "Apr 27, 14:35" 596 return date.toLocaleString(undefined, { 597 month: "short", 598 day: "numeric", 599 hour: "2-digit", 600 minute: "2-digit", 601 }); 602 } 603 // Otherwise show only time for > 1 hour 604 return date.toLocaleTimeString(undefined, { 605 hour: "2-digit", 606 minute: "2-digit", 607 }); 608 } 609 610 const timeFormats: [number, string, number | string][] = [ 611 [60, "seconds", 1], // 60 612 [120, "1 minute ago", "1 minute from now"], // 60*2 613 [3600, "minutes", 60], // 60*60, 60 614 [7200, "1 hour ago", "1 hour from now"], // 60*60*2 615 [86400, "hours", 3600], // 60*60*24, 60*60 616 [172800, "Yesterday", "Tomorrow"], // 60*60*24*2 617 [604800, "days", 86400], // 60*60*24*7, 60*60*24 618 [1209600, "Last week", "Next week"], // 60*60*24*7*2 619 [2419200, "weeks", 604800], // 60*60*24*7*4, 60*60*24*7 620 [4838400, "Last month", "Next month"], // 60*60*24*7*4*2 621 [29030400, "months", 2419200], // 60*60*24*7*4*12, 60*60*24*7*4 622 [58060800, "Last year", "Next year"], // 60*60*24*7*4*12*2 623 [2903040000, "years", 29030400], // 60*60*24*7*4*12*100, 60*60*24*7*4*12 624 [5806080000, "Last century", "Next century"], // 60*60*24*7*4*12*100*2 625 [58060800000, "centuries", 2903040000], // 60*60*24*7*4*12*100*20, 60*60*24*7*4*12*100 626 ]; 627 628 for (const format of timeFormats) { 629 if (seconds < format[0]) { 630 if (typeof format[2] === "string") { 631 return format[listChoice] as string; 632 } else { 633 return ( 634 Math.floor(seconds / (format[2] as number)) + 635 " " + 636 format[1] + 637 " " + 638 token 639 ); 640 } 641 } 642 } 643 644 return new Date(timestamp).toString(); 645}