mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React, {memo, useMemo} from 'react' 2import {StyleSheet, View} from 'react-native' 3import { 4 AtUri, 5 AppBskyFeedDefs, 6 AppBskyFeedPost, 7 RichText as RichTextAPI, 8 ModerationDecision, 9} from '@atproto/api' 10import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' 11import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 12import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn' 13import {Link, TextLink} from '../util/Link' 14import {RichText} from '#/components/RichText' 15import {Text} from '../util/text/Text' 16import {PreviewableUserAvatar} from '../util/UserAvatar' 17import {s} from 'lib/styles' 18import {niceDate} from 'lib/strings/time' 19import {sanitizeDisplayName} from 'lib/strings/display-names' 20import {sanitizeHandle} from 'lib/strings/handles' 21import {countLines, pluralize} from 'lib/strings/helpers' 22import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers' 23import {PostMeta} from '../util/PostMeta' 24import {PostEmbeds} from '../util/post-embeds' 25import {PostCtrls} from '../util/post-ctrls/PostCtrls' 26import {PostHider} from '../../../components/moderation/PostHider' 27import {ContentHider} from '../../../components/moderation/ContentHider' 28import {PostAlerts} from '../../../components/moderation/PostAlerts' 29import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe' 30import {ErrorMessage} from '../util/error/ErrorMessage' 31import {usePalette} from 'lib/hooks/usePalette' 32import {formatCount} from '../util/numeric/format' 33import {makeProfileLink} from 'lib/routes/links' 34import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 35import {MAX_POST_LINES} from 'lib/constants' 36import {Trans, msg} from '@lingui/macro' 37import {useLingui} from '@lingui/react' 38import {useLanguagePrefs} from '#/state/preferences' 39import {useComposerControls} from '#/state/shell/composer' 40import {useModerationOpts} from '#/state/queries/preferences' 41import {useOpenLink} from '#/state/preferences/in-app-browser' 42import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow' 43import {ThreadPost} from '#/state/queries/post-thread' 44import {useSession} from 'state/session' 45import {WhoCanReply} from '../threadgate/WhoCanReply' 46import {LoadingPlaceholder} from '../util/LoadingPlaceholder' 47import {atoms as a} from '#/alf' 48 49export function PostThreadItem({ 50 post, 51 record, 52 treeView, 53 depth, 54 prevPost, 55 nextPost, 56 isHighlightedPost, 57 hasMore, 58 showChildReplyLine, 59 showParentReplyLine, 60 hasPrecedingItem, 61 onPostReply, 62}: { 63 post: AppBskyFeedDefs.PostView 64 record: AppBskyFeedPost.Record 65 treeView: boolean 66 depth: number 67 prevPost: ThreadPost | undefined 68 nextPost: ThreadPost | undefined 69 isHighlightedPost?: boolean 70 hasMore?: boolean 71 showChildReplyLine?: boolean 72 showParentReplyLine?: boolean 73 hasPrecedingItem: boolean 74 onPostReply: () => void 75}) { 76 const moderationOpts = useModerationOpts() 77 const postShadowed = usePostShadow(post) 78 const richText = useMemo( 79 () => 80 new RichTextAPI({ 81 text: record.text, 82 facets: record.facets, 83 }), 84 [record], 85 ) 86 const moderation = useMemo( 87 () => 88 post && moderationOpts ? moderatePost(post, moderationOpts) : undefined, 89 [post, moderationOpts], 90 ) 91 if (postShadowed === POST_TOMBSTONE) { 92 return <PostThreadItemDeleted /> 93 } 94 if (richText && moderation) { 95 return ( 96 <PostThreadItemLoaded 97 // Safeguard from clobbering per-post state below: 98 key={postShadowed.uri} 99 post={postShadowed} 100 prevPost={prevPost} 101 nextPost={nextPost} 102 record={record} 103 richText={richText} 104 moderation={moderation} 105 treeView={treeView} 106 depth={depth} 107 isHighlightedPost={isHighlightedPost} 108 hasMore={hasMore} 109 showChildReplyLine={showChildReplyLine} 110 showParentReplyLine={showParentReplyLine} 111 hasPrecedingItem={hasPrecedingItem} 112 onPostReply={onPostReply} 113 /> 114 ) 115 } 116 return null 117} 118 119function PostThreadItemDeleted() { 120 const pal = usePalette('default') 121 return ( 122 <View style={[styles.outer, pal.border, pal.view, s.p20, s.flexRow]}> 123 <FontAwesomeIcon icon={['far', 'trash-can']} color={pal.colors.icon} /> 124 <Text style={[pal.textLight, s.ml10]}> 125 <Trans>This post has been deleted.</Trans> 126 </Text> 127 </View> 128 ) 129} 130 131let PostThreadItemLoaded = ({ 132 post, 133 record, 134 richText, 135 moderation, 136 treeView, 137 depth, 138 prevPost, 139 nextPost, 140 isHighlightedPost, 141 hasMore, 142 showChildReplyLine, 143 showParentReplyLine, 144 hasPrecedingItem, 145 onPostReply, 146}: { 147 post: Shadow<AppBskyFeedDefs.PostView> 148 record: AppBskyFeedPost.Record 149 richText: RichTextAPI 150 moderation: ModerationDecision 151 treeView: boolean 152 depth: number 153 prevPost: ThreadPost | undefined 154 nextPost: ThreadPost | undefined 155 isHighlightedPost?: boolean 156 hasMore?: boolean 157 showChildReplyLine?: boolean 158 showParentReplyLine?: boolean 159 hasPrecedingItem: boolean 160 onPostReply: () => void 161}): React.ReactNode => { 162 const pal = usePalette('default') 163 const {_} = useLingui() 164 const langPrefs = useLanguagePrefs() 165 const {openComposer} = useComposerControls() 166 const [limitLines, setLimitLines] = React.useState( 167 () => countLines(richText?.text) >= MAX_POST_LINES, 168 ) 169 const {currentAccount} = useSession() 170 const rootUri = record.reply?.root?.uri || post.uri 171 const postHref = React.useMemo(() => { 172 const urip = new AtUri(post.uri) 173 return makeProfileLink(post.author, 'post', urip.rkey) 174 }, [post.uri, post.author]) 175 const itemTitle = _(msg`Post by ${post.author.handle}`) 176 const authorHref = makeProfileLink(post.author) 177 const authorTitle = post.author.handle 178 const likesHref = React.useMemo(() => { 179 const urip = new AtUri(post.uri) 180 return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by') 181 }, [post.uri, post.author]) 182 const likesTitle = _(msg`Likes on this post`) 183 const repostsHref = React.useMemo(() => { 184 const urip = new AtUri(post.uri) 185 return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by') 186 }, [post.uri, post.author]) 187 const repostsTitle = _(msg`Reposts of this post`) 188 189 const translatorUrl = getTranslatorLink( 190 record?.text || '', 191 langPrefs.primaryLanguage, 192 ) 193 const needsTranslation = useMemo( 194 () => 195 Boolean( 196 langPrefs.primaryLanguage && 197 !isPostInLanguage(post, [langPrefs.primaryLanguage]), 198 ), 199 [post, langPrefs.primaryLanguage], 200 ) 201 202 const onPressReply = React.useCallback(() => { 203 openComposer({ 204 replyTo: { 205 uri: post.uri, 206 cid: post.cid, 207 text: record.text, 208 author: post.author, 209 embed: post.embed, 210 moderation, 211 }, 212 onPost: onPostReply, 213 }) 214 }, [openComposer, post, record, onPostReply, moderation]) 215 216 const onPressShowMore = React.useCallback(() => { 217 setLimitLines(false) 218 }, [setLimitLines]) 219 220 if (!record) { 221 return <ErrorMessage message={_(msg`Invalid or unsupported post record`)} /> 222 } 223 224 if (isHighlightedPost) { 225 return ( 226 <> 227 {rootUri !== post.uri && ( 228 <View style={{paddingLeft: 16, flexDirection: 'row', height: 16}}> 229 <View style={{width: 38}}> 230 <View 231 style={[ 232 styles.replyLine, 233 { 234 flexGrow: 1, 235 backgroundColor: pal.colors.border, 236 }, 237 ]} 238 /> 239 </View> 240 </View> 241 )} 242 243 <View 244 testID={`postThreadItem-by-${post.author.handle}`} 245 style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]} 246 accessible={false}> 247 <View style={[styles.layout]}> 248 <View style={[styles.layoutAvi, {paddingBottom: 8}]}> 249 <PreviewableUserAvatar 250 size={42} 251 did={post.author.did} 252 handle={post.author.handle} 253 avatar={post.author.avatar} 254 moderation={moderation.ui('avatar')} 255 type={post.author.associated?.labeler ? 'labeler' : 'user'} 256 /> 257 </View> 258 <View style={styles.layoutContent}> 259 <View 260 style={[styles.meta, styles.metaExpandedLine1, {zIndex: 1}]}> 261 <Link style={s.flex1} href={authorHref} title={authorTitle}> 262 <Text 263 type="xl-bold" 264 style={[pal.text]} 265 numberOfLines={1} 266 lineHeight={1.2}> 267 {sanitizeDisplayName( 268 post.author.displayName || 269 sanitizeHandle(post.author.handle), 270 moderation.ui('displayName'), 271 )} 272 </Text> 273 </Link> 274 </View> 275 <View style={styles.meta}> 276 <Link style={s.flex1} href={authorHref} title={authorTitle}> 277 <Text type="md" style={[pal.textLight]} numberOfLines={1}> 278 {sanitizeHandle(post.author.handle, '@')} 279 </Text> 280 </Link> 281 </View> 282 </View> 283 {currentAccount?.did !== post.author.did && ( 284 <PostThreadFollowBtn did={post.author.did} /> 285 )} 286 </View> 287 <View style={[s.pl10, s.pr10, s.pb10]}> 288 <LabelsOnMyPost post={post} /> 289 <ContentHider 290 modui={moderation.ui('contentView')} 291 ignoreMute 292 style={styles.contentHider} 293 childContainerStyle={styles.contentHiderChild}> 294 <PostAlerts 295 modui={moderation.ui('contentView')} 296 includeMute 297 style={[a.pt_2xs, a.pb_sm]} 298 /> 299 {richText?.text ? ( 300 <View 301 style={[ 302 styles.postTextContainer, 303 styles.postTextLargeContainer, 304 ]}> 305 <RichText 306 enableTags 307 selectable 308 value={richText} 309 style={[a.flex_1, a.text_xl]} 310 authorHandle={post.author.handle} 311 /> 312 </View> 313 ) : undefined} 314 {post.embed && ( 315 <View style={[a.pb_sm]}> 316 <PostEmbeds embed={post.embed} moderation={moderation} /> 317 </View> 318 )} 319 </ContentHider> 320 <ExpandedPostDetails 321 post={post} 322 translatorUrl={translatorUrl} 323 needsTranslation={needsTranslation} 324 /> 325 {post.repostCount !== 0 || post.likeCount !== 0 ? ( 326 // Show this section unless we're *sure* it has no engagement. 327 <View style={[styles.expandedInfo, pal.border]}> 328 {post.repostCount == null && post.likeCount == null && ( 329 // If we're still loading and not sure, assume this post has engagement. 330 // This lets us avoid a layout shift for the common case (embedded post with likes/reposts). 331 // TODO: embeds should include metrics to avoid us having to guess. 332 <LoadingPlaceholder width={50} height={20} /> 333 )} 334 {post.repostCount != null && post.repostCount !== 0 ? ( 335 <Link 336 style={styles.expandedInfoItem} 337 href={repostsHref} 338 title={repostsTitle}> 339 <Text 340 testID="repostCount-expanded" 341 type="lg" 342 style={pal.textLight}> 343 <Text type="xl-bold" style={pal.text}> 344 {formatCount(post.repostCount)} 345 </Text>{' '} 346 {pluralize(post.repostCount, 'repost')} 347 </Text> 348 </Link> 349 ) : null} 350 {post.likeCount != null && post.likeCount !== 0 ? ( 351 <Link 352 style={styles.expandedInfoItem} 353 href={likesHref} 354 title={likesTitle}> 355 <Text 356 testID="likeCount-expanded" 357 type="lg" 358 style={pal.textLight}> 359 <Text type="xl-bold" style={pal.text}> 360 {formatCount(post.likeCount)} 361 </Text>{' '} 362 {pluralize(post.likeCount, 'like')} 363 </Text> 364 </Link> 365 ) : null} 366 </View> 367 ) : null} 368 <View style={[s.pl10, s.pr10, s.pb5]}> 369 <PostCtrls 370 big 371 post={post} 372 record={record} 373 richText={richText} 374 onPressReply={onPressReply} 375 logContext="PostThreadItem" 376 /> 377 </View> 378 </View> 379 </View> 380 <WhoCanReply post={post} /> 381 </> 382 ) 383 } else { 384 const isThreadedChild = treeView && depth > 0 385 const isThreadedChildAdjacentTop = 386 isThreadedChild && prevPost?.ctx.depth === depth && depth !== 1 387 const isThreadedChildAdjacentBot = 388 isThreadedChild && nextPost?.ctx.depth === depth 389 return ( 390 <> 391 <PostOuterWrapper 392 post={post} 393 depth={depth} 394 showParentReplyLine={!!showParentReplyLine} 395 treeView={treeView} 396 hasPrecedingItem={hasPrecedingItem}> 397 <PostHider 398 testID={`postThreadItem-by-${post.author.handle}`} 399 href={postHref} 400 style={[pal.view]} 401 modui={moderation.ui('contentList')} 402 iconSize={isThreadedChild ? 26 : 38} 403 iconStyles={ 404 isThreadedChild 405 ? {marginRight: 4} 406 : {marginLeft: 2, marginRight: 2} 407 }> 408 <View 409 style={{ 410 flexDirection: 'row', 411 gap: 10, 412 paddingLeft: 8, 413 height: isThreadedChildAdjacentTop ? 8 : 16, 414 }}> 415 <View style={{width: 38}}> 416 {!isThreadedChild && showParentReplyLine && ( 417 <View 418 style={[ 419 styles.replyLine, 420 { 421 flexGrow: 1, 422 backgroundColor: pal.colors.replyLine, 423 marginBottom: 4, 424 }, 425 ]} 426 /> 427 )} 428 </View> 429 </View> 430 431 <View 432 style={[ 433 styles.layout, 434 { 435 paddingBottom: 436 showChildReplyLine && !isThreadedChild 437 ? 0 438 : isThreadedChildAdjacentBot 439 ? 4 440 : 8, 441 }, 442 ]}> 443 {/* If we are in threaded mode, the avatar is rendered in PostMeta */} 444 {!isThreadedChild && ( 445 <View style={styles.layoutAvi}> 446 <PreviewableUserAvatar 447 size={38} 448 did={post.author.did} 449 handle={post.author.handle} 450 avatar={post.author.avatar} 451 moderation={moderation.ui('avatar')} 452 type={post.author.associated?.labeler ? 'labeler' : 'user'} 453 /> 454 455 {showChildReplyLine && ( 456 <View 457 style={[ 458 styles.replyLine, 459 { 460 flexGrow: 1, 461 backgroundColor: pal.colors.replyLine, 462 marginTop: 4, 463 }, 464 ]} 465 /> 466 )} 467 </View> 468 )} 469 470 <View 471 style={ 472 isThreadedChild 473 ? styles.layoutContentThreaded 474 : styles.layoutContent 475 }> 476 <PostMeta 477 author={post.author} 478 moderation={moderation} 479 authorHasWarning={!!post.author.labels?.length} 480 timestamp={post.indexedAt} 481 postHref={postHref} 482 showAvatar={isThreadedChild} 483 avatarModeration={moderation.ui('avatar')} 484 avatarSize={28} 485 displayNameType="md-bold" 486 displayNameStyle={isThreadedChild && s.ml2} 487 style={isThreadedChild && s.mb2} 488 /> 489 <LabelsOnMyPost post={post} /> 490 <PostAlerts 491 modui={moderation.ui('contentList')} 492 style={[a.pt_xs, a.pb_sm]} 493 /> 494 {richText?.text ? ( 495 <View style={styles.postTextContainer}> 496 <RichText 497 enableTags 498 value={richText} 499 style={[a.flex_1, a.text_md]} 500 numberOfLines={limitLines ? MAX_POST_LINES : undefined} 501 authorHandle={post.author.handle} 502 /> 503 </View> 504 ) : undefined} 505 {limitLines ? ( 506 <TextLink 507 text={_(msg`Show More`)} 508 style={pal.link} 509 onPress={onPressShowMore} 510 href="#" 511 /> 512 ) : undefined} 513 {post.embed && ( 514 <View style={[a.pb_xs]}> 515 <PostEmbeds embed={post.embed} moderation={moderation} /> 516 </View> 517 )} 518 <PostCtrls 519 post={post} 520 record={record} 521 richText={richText} 522 onPressReply={onPressReply} 523 logContext="PostThreadItem" 524 /> 525 </View> 526 </View> 527 {hasMore ? ( 528 <Link 529 style={[ 530 styles.loadMore, 531 { 532 paddingLeft: treeView ? 8 : 70, 533 paddingTop: 0, 534 paddingBottom: treeView ? 4 : 12, 535 }, 536 ]} 537 href={postHref} 538 title={itemTitle} 539 noFeedback> 540 <Text type="sm-medium" style={pal.textLight}> 541 <Trans>More</Trans> 542 </Text> 543 <FontAwesomeIcon 544 icon="angle-right" 545 color={pal.colors.textLight} 546 size={14} 547 /> 548 </Link> 549 ) : undefined} 550 </PostHider> 551 </PostOuterWrapper> 552 <WhoCanReply 553 post={post} 554 style={{ 555 marginTop: 4, 556 }} 557 /> 558 </> 559 ) 560 } 561} 562PostThreadItemLoaded = memo(PostThreadItemLoaded) 563 564function PostOuterWrapper({ 565 post, 566 treeView, 567 depth, 568 showParentReplyLine, 569 hasPrecedingItem, 570 children, 571}: React.PropsWithChildren<{ 572 post: AppBskyFeedDefs.PostView 573 treeView: boolean 574 depth: number 575 showParentReplyLine: boolean 576 hasPrecedingItem: boolean 577}>) { 578 const {isMobile} = useWebMediaQueries() 579 const pal = usePalette('default') 580 if (treeView && depth > 0) { 581 return ( 582 <View 583 style={[ 584 pal.border, 585 styles.cursor, 586 { 587 flexDirection: 'row', 588 paddingHorizontal: isMobile ? 10 : 6, 589 borderTopWidth: depth === 1 ? 1 : 0, 590 }, 591 ]}> 592 {Array.from(Array(depth - 1)).map((_, n: number) => ( 593 <View 594 key={`${post.uri}-padding-${n}`} 595 style={{ 596 borderLeftWidth: 2, 597 borderLeftColor: pal.colors.border, 598 marginLeft: isMobile ? 6 : 12, 599 paddingLeft: isMobile ? 6 : 8, 600 }} 601 /> 602 ))} 603 <View style={{flex: 1}}>{children}</View> 604 </View> 605 ) 606 } 607 return ( 608 <View 609 style={[ 610 styles.outer, 611 pal.border, 612 showParentReplyLine && hasPrecedingItem && styles.noTopBorder, 613 styles.cursor, 614 ]}> 615 {children} 616 </View> 617 ) 618} 619 620function ExpandedPostDetails({ 621 post, 622 needsTranslation, 623 translatorUrl, 624}: { 625 post: AppBskyFeedDefs.PostView 626 needsTranslation: boolean 627 translatorUrl: string 628}) { 629 const pal = usePalette('default') 630 const {_} = useLingui() 631 const openLink = useOpenLink() 632 const onTranslatePress = React.useCallback( 633 () => openLink(translatorUrl), 634 [openLink, translatorUrl], 635 ) 636 return ( 637 <View style={[s.flexRow, s.mt2, s.mb10]}> 638 <Text style={pal.textLight}>{niceDate(post.indexedAt)}</Text> 639 {needsTranslation && ( 640 <> 641 <Text style={pal.textLight}> &middot; </Text> 642 <Text 643 style={pal.link} 644 title={_(msg`Translate`)} 645 onPress={onTranslatePress}> 646 <Trans>Translate</Trans> 647 </Text> 648 </> 649 )} 650 </View> 651 ) 652} 653 654const styles = StyleSheet.create({ 655 outer: { 656 borderTopWidth: 1, 657 paddingLeft: 8, 658 }, 659 outerHighlighted: { 660 paddingTop: 16, 661 paddingLeft: 8, 662 paddingRight: 8, 663 }, 664 noTopBorder: { 665 borderTopWidth: 0, 666 }, 667 layout: { 668 flexDirection: 'row', 669 paddingHorizontal: 8, 670 }, 671 layoutAvi: {}, 672 layoutContent: { 673 flex: 1, 674 marginLeft: 10, 675 }, 676 layoutContentThreaded: { 677 flex: 1, 678 paddingRight: 10, 679 }, 680 meta: { 681 flexDirection: 'row', 682 paddingVertical: 2, 683 }, 684 metaExpandedLine1: { 685 paddingVertical: 0, 686 }, 687 alert: { 688 marginBottom: 6, 689 }, 690 postTextContainer: { 691 flexDirection: 'row', 692 alignItems: 'center', 693 flexWrap: 'wrap', 694 paddingBottom: 4, 695 paddingRight: 10, 696 }, 697 postTextLargeContainer: { 698 paddingHorizontal: 0, 699 paddingRight: 0, 700 paddingBottom: 10, 701 }, 702 translateLink: { 703 marginBottom: 6, 704 }, 705 contentHider: { 706 marginBottom: 6, 707 }, 708 contentHiderChild: { 709 marginTop: 6, 710 }, 711 expandedInfo: { 712 flexDirection: 'row', 713 padding: 10, 714 borderTopWidth: 1, 715 borderBottomWidth: 1, 716 marginTop: 5, 717 marginBottom: 15, 718 }, 719 expandedInfoItem: { 720 marginRight: 10, 721 }, 722 loadMore: { 723 flexDirection: 'row', 724 alignItems: 'center', 725 justifyContent: 'flex-start', 726 gap: 4, 727 paddingHorizontal: 20, 728 }, 729 replyLine: { 730 width: 2, 731 marginLeft: 'auto', 732 marginRight: 'auto', 733 }, 734 cursor: { 735 // @ts-ignore web only 736 cursor: 'pointer', 737 }, 738})