Live video on the AT Protocol
79
fork

Configure Feed

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

at eli/linking-fixes 669 lines 19 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 { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; 24import Animated, { 25 SharedValue, 26 useAnimatedStyle, 27} from "react-native-reanimated"; 28import { ChatMessageViewHydrated } from "streamplace"; 29import { Button, ScrollView, Sheet, Text, useMedia, View } from "tamagui"; 30import { RichtextSegment, segmentize } from "../../utils/facet"; 31 32export default function Chat({ 33 isChatVisible, 34 setIsChatVisible: _setIsChatVisible, 35}: { 36 isChatVisible: boolean; 37 setIsChatVisible: (visible: boolean) => void; 38}) { 39 const [open, setOpen] = useState(false); 40 const [modMessage, setMessage] = useState<ChatMessageViewHydrated | null>( 41 null, 42 ); 43 const [isAtBottom, setIsAtBottom] = useState(true); 44 let chat = useChat(); 45 const scrollRef = useRef<ScrollView>(null); 46 const streamerProfile = useProfile(); 47 const userProfile = useAppSelector(selectUserProfile); 48 const myStream = !!( 49 userProfile && 50 userProfile.did && 51 userProfile.did === streamerProfile?.did 52 ); 53 54 const handleScroll = (event: any) => { 55 const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent; 56 const paddingToBottom = 100; 57 const isAtBottom = 58 layoutMeasurement.height + contentOffset.y >= 59 contentSize.height - paddingToBottom; 60 setIsAtBottom(isAtBottom); 61 }; 62 63 useEffect(() => { 64 if (chat && isAtBottom) { 65 scrollRef.current?.scrollToEnd({ animated: true }); 66 } 67 }, [chat, isAtBottom]); 68 69 if (!chat) { 70 return <></>; 71 } 72 73 chat = [...chat].reverse(); 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 <View 278 position="absolute" 279 flexDirection="row" 280 right={0} 281 top="$-3" 282 alignItems="stretch" 283 justifyContent="flex-end" 284 gap="$1" 285 px="$1" 286 backgroundColor="rgba(255,255,255,0.5)" 287 borderRadius="$2" 288 > 289 {isWeb && ( 290 <TouchableOpacity 291 style={{ 292 display: hover ? "flex" : "none", 293 alignItems: "center", 294 justifyContent: "center", 295 padding: 4, 296 }} 297 onPress={handleReply} 298 > 299 <Reply size={16} /> 300 </TouchableOpacity> 301 )} 302 {isWeb && myStream && ( 303 <TouchableOpacity 304 style={{ 305 display: hover ? "flex" : "none", 306 alignItems: "center", 307 justifyContent: "center", 308 padding: 4, 309 }} 310 onPress={moderateMessage} 311 > 312 <Settings size={16} /> 313 </TouchableOpacity> 314 )} 315 </View> 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 onLongPress={() => { 344 if (!isWeb) { 345 handleReply(); 346 } 347 }} 348 onPress={() => { 349 if (!isWeb) { 350 moderateMessage(); 351 } 352 }} 353 overflow="visible" 354 > 355 {hasReply && ( 356 <View 357 position="absolute" 358 left={6} 359 top={-8} 360 width={2} 361 height={16} 362 opacity={0.7} 363 /> 364 )} 365 <View flexDirection="column" gap="$1" flex={1} overflow="visible"> 366 {/* Reply section */} 367 {hasReply && ( 368 <View 369 flexDirection="column" 370 marginBottom="$2" 371 paddingLeft="$3" 372 position="relative" 373 > 374 {/* Vertical reply line */} 375 <View 376 position="absolute" 377 left={6} 378 top={0} 379 bottom={0} 380 width={2} 381 borderRadius={2} 382 backgroundColor="$accentColor" 383 opacity={0.5} 384 /> 385 {/* Reply preview */} 386 <View 387 flexDirection="row" 388 alignItems="center" 389 gap="$1" 390 paddingVertical="$1" 391 paddingHorizontal="$2" 392 borderRadius="$2" 393 marginLeft="-$1" 394 > 395 <Text fontSize={12} color={replyToColor} fontWeight="bold"> 396 {replyToHandle ? `@${replyToHandle}` : ""} 397 </Text> 398 <Text 399 fontSize={12} 400 color="$color" 401 opacity={0.7} 402 numberOfLines={1} 403 flex={1} 404 > 405 {replyToText || ""} 406 </Text> 407 </View> 408 </View> 409 )} 410 {/* Message content */} 411 <View 412 flexDirection="row" 413 alignItems="flex-start" 414 justifyContent="flex-start" 415 gap="$2" 416 > 417 <Text 418 color="$gray10" 419 fontSize="$2" 420 mt="$0.5" 421 style={{ fontVariantNumeric: "tabular-nums" }} 422 > 423 {new Date(message.record.createdAt).toLocaleString(undefined, { 424 hour: "2-digit", 425 minute: "2-digit", 426 hour12: false, 427 })} 428 </Text> 429 <ChatMessageText message={message} chat={chat} /> 430 </View> 431 </View> 432 </View> 433 {/* </ReanimatedSwipeable> */} 434 </View> 435 ); 436} 437 438function byteOffsetToCharIndex(text: string, byteOffset: number): number { 439 const encoder = new TextEncoder(); 440 let bytesCount = 0; 441 for (let i = 0; i < text.length; i++) { 442 const charBytes = encoder.encode(text[i]); 443 if (bytesCount + charBytes.length > byteOffset) { 444 return i; 445 } 446 bytesCount += charBytes.length; 447 } 448 return text.length; 449} 450 451const ChatMessageText = ({ 452 message, 453 chat = [], 454}: { 455 message: ChatMessageViewHydrated; 456 chat?: ChatMessageViewHydrated[]; 457}) => { 458 const rt = new RichText({ text: message.record.text }); 459 rt.detectFacetsWithoutResolution(); 460 461 // Process facets to add DID information for mentions 462 rt.facets?.forEach((facet) => { 463 facet.features.forEach((feature) => { 464 if (isMention(feature)) { 465 const startCharIndex = byteOffsetToCharIndex( 466 message.record.text, 467 facet.index.byteStart, 468 ); 469 const endCharIndex = byteOffsetToCharIndex( 470 message.record.text, 471 facet.index.byteEnd, 472 ); 473 const mentionText = message.record.text.slice( 474 startCharIndex, 475 endCharIndex, 476 ); 477 // Find the mentioned user by their handle (removing the @ symbol) 478 const mentionedUser = chat.find( 479 (msg) => msg.author.handle === mentionText.slice(1), 480 ); 481 if (mentionedUser) { 482 feature.did = mentionedUser.author.did; 483 } 484 } 485 }); 486 }); 487 488 return ( 489 <Text fontSize={13}> 490 <Text 491 color={getRgbColor(message.chatProfile?.color)} 492 cursor="pointer" 493 onPress={() => 494 Linking.openURL(`https://bsky.app/profile/${message.author.did}`) 495 } 496 > 497 {message.author.handle 498 ? `@${message.author.handle}` 499 : message.author.did} 500 : 501 </Text> 502 <Text> </Text> 503 <RichTextMessage 504 text={message.record.text} 505 facets={rt.facets as Facet[]} 506 chat={chat} 507 /> 508 </Text> 509 ); 510}; 511 512interface Facet { 513 index: { 514 byteStart: number; 515 byteEnd: number; 516 }; 517 features: Array<{ 518 $type: string; 519 uri?: string; 520 did?: string; 521 }>; 522} 523 524const getRgbColor = (color?: { red: number; green: number; blue: number }) => 525 color ? `rgb(${color.red}, ${color.green}, ${color.blue})` : "$accentColor"; 526 527const segmentedObject = ( 528 obj: RichtextSegment, 529 chat: ChatMessageViewHydrated[], 530 index: number, 531) => { 532 if (obj.features && obj.features.length > 0) { 533 let ftr = obj.features[0]; 534 // afaik there shouldn't be a case where facets overlap, at least currently 535 if (ftr.$type === "app.bsky.richtext.facet#link") { 536 let linkftr = ftr as $Typed<Link>; 537 return ( 538 <Text 539 key={`mention-${index}`} 540 color="#9090f0" 541 cursor="pointer" 542 onPress={() => Linking.openURL(linkftr.uri || "")} 543 > 544 {obj.text} 545 </Text> 546 ); 547 } else if (ftr.$type === "app.bsky.richtext.facet#mention") { 548 let mtnftr = ftr as $Typed<Mention>; 549 const mentionedUserMessage = chat.find( 550 (msg) => msg.author.did === mtnftr.did, 551 ); 552 return ( 553 <Text 554 key={`mention-${index}`} 555 color={getRgbColor(mentionedUserMessage?.chatProfile?.color)} 556 cursor="pointer" 557 onPress={() => 558 Linking.openURL(`https://bsky.app/profile/${mtnftr.did || ""}`) 559 } 560 > 561 {obj.text} 562 </Text> 563 ); 564 } 565 } else { 566 return <Text key={`text-${index}`}>{obj.text}</Text>; 567 } 568}; 569 570const RichTextMessage = ({ 571 text, 572 facets, 573 chat = [], 574}: { 575 text: string; 576 facets: Facet[]; 577 chat?: ChatMessageViewHydrated[]; 578}) => { 579 if (!facets?.length) return <Text>{text}</Text>; 580 581 let segs = segmentize(text, facets); 582 583 return segs.map((seg, i) => segmentedObject(seg, chat, i)); 584}; 585 586export function timeAgo(time: Date | number | string): string { 587 let timestamp: number; 588 589 if (typeof time === "number") { 590 timestamp = time; 591 } else if (typeof time === "string") { 592 timestamp = new Date(time).getTime(); 593 } else if (time instanceof Date) { 594 timestamp = time.getTime(); 595 } else { 596 timestamp = Date.now(); 597 } 598 599 const now = Date.now(); 600 let seconds = (now - timestamp) / 1000; 601 let token = "ago"; 602 let listChoice = 1; 603 604 if (seconds === 0) { 605 return "Just now"; 606 } 607 608 if (seconds < 0) { 609 seconds = Math.abs(seconds); 610 token = "from now"; 611 listChoice = 2; 612 } 613 614 // Show time for > 1 hour difference 615 if (seconds > 3600) { 616 const date = new Date(timestamp); 617 // More than 1 day ago/from now: show shortened date + time 618 if (seconds > 86400) { 619 // Example format: "Apr 27, 14:35" 620 return date.toLocaleString(undefined, { 621 month: "short", 622 day: "numeric", 623 hour: "2-digit", 624 minute: "2-digit", 625 }); 626 } 627 // Otherwise show only time for > 1 hour 628 return date.toLocaleTimeString(undefined, { 629 hour: "2-digit", 630 minute: "2-digit", 631 }); 632 } 633 634 const timeFormats: [number, string, number | string][] = [ 635 [60, "seconds", 1], // 60 636 [120, "1 minute ago", "1 minute from now"], // 60*2 637 [3600, "minutes", 60], // 60*60, 60 638 [7200, "1 hour ago", "1 hour from now"], // 60*60*2 639 [86400, "hours", 3600], // 60*60*24, 60*60 640 [172800, "Yesterday", "Tomorrow"], // 60*60*24*2 641 [604800, "days", 86400], // 60*60*24*7, 60*60*24 642 [1209600, "Last week", "Next week"], // 60*60*24*7*2 643 [2419200, "weeks", 604800], // 60*60*24*7*4, 60*60*24*7 644 [4838400, "Last month", "Next month"], // 60*60*24*7*4*2 645 [29030400, "months", 2419200], // 60*60*24*7*4*12, 60*60*24*7*4 646 [58060800, "Last year", "Next year"], // 60*60*24*7*4*12*2 647 [2903040000, "years", 29030400], // 60*60*24*7*4*12*100, 60*60*24*7*4*12 648 [5806080000, "Last century", "Next century"], // 60*60*24*7*4*12*100*2 649 [58060800000, "centuries", 2903040000], // 60*60*24*7*4*12*100*20, 60*60*24*7*4*12*100 650 ]; 651 652 for (const format of timeFormats) { 653 if (seconds < format[0]) { 654 if (typeof format[2] === "string") { 655 return format[listChoice] as string; 656 } else { 657 return ( 658 Math.floor(seconds / (format[2] as number)) + 659 " " + 660 format[1] + 661 " " + 662 token 663 ); 664 } 665 } 666 } 667 668 return new Date(timestamp).toString(); 669}