mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at session/schema 737 lines 22 kB view raw
1import React, {memo, useMemo} from 'react' 2import {StyleSheet, View} from 'react-native' 3import { 4 AppBskyFeedDefs, 5 AppBskyFeedPost, 6 AtUri, 7 ModerationDecision, 8 RichText as RichTextAPI, 9} from '@atproto/api' 10import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 11import {msg, Trans} from '@lingui/macro' 12import {useLingui} from '@lingui/react' 13 14import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' 15import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow' 16import {useLanguagePrefs} from '#/state/preferences' 17import {useOpenLink} from '#/state/preferences/in-app-browser' 18import {ThreadPost} from '#/state/queries/post-thread' 19import {useModerationOpts} from '#/state/queries/preferences' 20import {useComposerControls} from '#/state/shell/composer' 21import {MAX_POST_LINES} from 'lib/constants' 22import {usePalette} from 'lib/hooks/usePalette' 23import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 24import {makeProfileLink} from 'lib/routes/links' 25import {sanitizeDisplayName} from 'lib/strings/display-names' 26import {sanitizeHandle} from 'lib/strings/handles' 27import {countLines, pluralize} from 'lib/strings/helpers' 28import {niceDate} from 'lib/strings/time' 29import {s} from 'lib/styles' 30import {isWeb} from 'platform/detection' 31import {useSession} from 'state/session' 32import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn' 33import {atoms as a} from '#/alf' 34import {RichText} from '#/components/RichText' 35import {ContentHider} from '../../../components/moderation/ContentHider' 36import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe' 37import {PostAlerts} from '../../../components/moderation/PostAlerts' 38import {PostHider} from '../../../components/moderation/PostHider' 39import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers' 40import {WhoCanReply} from '../threadgate/WhoCanReply' 41import {ErrorMessage} from '../util/error/ErrorMessage' 42import {Link, TextLink} from '../util/Link' 43import {formatCount} from '../util/numeric/format' 44import {PostCtrls} from '../util/post-ctrls/PostCtrls' 45import {PostEmbeds} from '../util/post-embeds' 46import {PostMeta} from '../util/PostMeta' 47import {Text} from '../util/text/Text' 48import {PreviewableUserAvatar} from '../util/UserAvatar' 49 50export function PostThreadItem({ 51 post, 52 record, 53 treeView, 54 depth, 55 prevPost, 56 nextPost, 57 isHighlightedPost, 58 hasMore, 59 showChildReplyLine, 60 showParentReplyLine, 61 hasPrecedingItem, 62 onPostReply, 63}: { 64 post: AppBskyFeedDefs.PostView 65 record: AppBskyFeedPost.Record 66 treeView: boolean 67 depth: number 68 prevPost: ThreadPost | undefined 69 nextPost: ThreadPost | undefined 70 isHighlightedPost?: boolean 71 hasMore?: boolean 72 showChildReplyLine?: boolean 73 showParentReplyLine?: boolean 74 hasPrecedingItem: boolean 75 onPostReply: () => void 76}) { 77 const moderationOpts = useModerationOpts() 78 const postShadowed = usePostShadow(post) 79 const richText = useMemo( 80 () => 81 new RichTextAPI({ 82 text: record.text, 83 facets: record.facets, 84 }), 85 [record], 86 ) 87 const moderation = useMemo( 88 () => 89 post && moderationOpts ? moderatePost(post, moderationOpts) : undefined, 90 [post, moderationOpts], 91 ) 92 if (postShadowed === POST_TOMBSTONE) { 93 return <PostThreadItemDeleted /> 94 } 95 if (richText && moderation) { 96 return ( 97 <PostThreadItemLoaded 98 // Safeguard from clobbering per-post state below: 99 key={postShadowed.uri} 100 post={postShadowed} 101 prevPost={prevPost} 102 nextPost={nextPost} 103 record={record} 104 richText={richText} 105 moderation={moderation} 106 treeView={treeView} 107 depth={depth} 108 isHighlightedPost={isHighlightedPost} 109 hasMore={hasMore} 110 showChildReplyLine={showChildReplyLine} 111 showParentReplyLine={showParentReplyLine} 112 hasPrecedingItem={hasPrecedingItem} 113 onPostReply={onPostReply} 114 /> 115 ) 116 } 117 return null 118} 119 120function PostThreadItemDeleted() { 121 const pal = usePalette('default') 122 return ( 123 <View style={[styles.outer, pal.border, pal.view, s.p20, s.flexRow]}> 124 <FontAwesomeIcon icon={['far', 'trash-can']} color={pal.colors.icon} /> 125 <Text style={[pal.textLight, s.ml10]}> 126 <Trans>This post has been deleted.</Trans> 127 </Text> 128 </View> 129 ) 130} 131 132let PostThreadItemLoaded = ({ 133 post, 134 record, 135 richText, 136 moderation, 137 treeView, 138 depth, 139 prevPost, 140 nextPost, 141 isHighlightedPost, 142 hasMore, 143 showChildReplyLine, 144 showParentReplyLine, 145 hasPrecedingItem, 146 onPostReply, 147}: { 148 post: Shadow<AppBskyFeedDefs.PostView> 149 record: AppBskyFeedPost.Record 150 richText: RichTextAPI 151 moderation: ModerationDecision 152 treeView: boolean 153 depth: number 154 prevPost: ThreadPost | undefined 155 nextPost: ThreadPost | undefined 156 isHighlightedPost?: boolean 157 hasMore?: boolean 158 showChildReplyLine?: boolean 159 showParentReplyLine?: boolean 160 hasPrecedingItem: boolean 161 onPostReply: () => void 162}): React.ReactNode => { 163 const pal = usePalette('default') 164 const {_} = useLingui() 165 const langPrefs = useLanguagePrefs() 166 const {openComposer} = useComposerControls() 167 const [limitLines, setLimitLines] = React.useState( 168 () => countLines(richText?.text) >= MAX_POST_LINES, 169 ) 170 const {currentAccount} = useSession() 171 const rootUri = record.reply?.root?.uri || post.uri 172 const postHref = React.useMemo(() => { 173 const urip = new AtUri(post.uri) 174 return makeProfileLink(post.author, 'post', urip.rkey) 175 }, [post.uri, post.author]) 176 const itemTitle = _(msg`Post by ${post.author.handle}`) 177 const authorHref = makeProfileLink(post.author) 178 const authorTitle = post.author.handle 179 const likesHref = React.useMemo(() => { 180 const urip = new AtUri(post.uri) 181 return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by') 182 }, [post.uri, post.author]) 183 const likesTitle = _(msg`Likes on this post`) 184 const repostsHref = React.useMemo(() => { 185 const urip = new AtUri(post.uri) 186 return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by') 187 }, [post.uri, post.author]) 188 const repostsTitle = _(msg`Reposts of this post`) 189 190 const translatorUrl = getTranslatorLink( 191 record?.text || '', 192 langPrefs.primaryLanguage, 193 ) 194 const needsTranslation = useMemo( 195 () => 196 Boolean( 197 langPrefs.primaryLanguage && 198 !isPostInLanguage(post, [langPrefs.primaryLanguage]), 199 ), 200 [post, langPrefs.primaryLanguage], 201 ) 202 203 const onPressReply = React.useCallback(() => { 204 openComposer({ 205 replyTo: { 206 uri: post.uri, 207 cid: post.cid, 208 text: record.text, 209 author: post.author, 210 embed: post.embed, 211 moderation, 212 }, 213 onPost: onPostReply, 214 }) 215 }, [openComposer, post, record, onPostReply, moderation]) 216 217 const onPressShowMore = React.useCallback(() => { 218 setLimitLines(false) 219 }, [setLimitLines]) 220 221 if (!record) { 222 return <ErrorMessage message={_(msg`Invalid or unsupported post record`)} /> 223 } 224 225 if (isHighlightedPost) { 226 return ( 227 <> 228 {rootUri !== post.uri && ( 229 <View style={{paddingLeft: 16, flexDirection: 'row', height: 16}}> 230 <View style={{width: 38}}> 231 <View 232 style={[ 233 styles.replyLine, 234 { 235 flexGrow: 1, 236 backgroundColor: pal.colors.border, 237 }, 238 ]} 239 /> 240 </View> 241 </View> 242 )} 243 244 <View 245 testID={`postThreadItem-by-${post.author.handle}`} 246 style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]} 247 accessible={false}> 248 <View style={[styles.layout]}> 249 <View style={[styles.layoutAvi, {paddingBottom: 8}]}> 250 <PreviewableUserAvatar 251 size={42} 252 did={post.author.did} 253 handle={post.author.handle} 254 avatar={post.author.avatar} 255 moderation={moderation.ui('avatar')} 256 type={post.author.associated?.labeler ? 'labeler' : 'user'} 257 /> 258 </View> 259 <View style={styles.layoutContent}> 260 <View 261 style={[styles.meta, styles.metaExpandedLine1, {zIndex: 1}]}> 262 <Link style={s.flex1} href={authorHref} title={authorTitle}> 263 <Text 264 type="xl-bold" 265 style={[pal.text]} 266 numberOfLines={1} 267 lineHeight={1.2}> 268 {sanitizeDisplayName( 269 post.author.displayName || 270 sanitizeHandle(post.author.handle), 271 moderation.ui('displayName'), 272 )} 273 </Text> 274 </Link> 275 </View> 276 <View style={styles.meta}> 277 <Link style={s.flex1} href={authorHref} title={authorTitle}> 278 <Text type="md" style={[pal.textLight]} numberOfLines={1}> 279 {sanitizeHandle(post.author.handle, '@')} 280 </Text> 281 </Link> 282 </View> 283 </View> 284 {currentAccount?.did !== post.author.did && ( 285 <PostThreadFollowBtn did={post.author.did} /> 286 )} 287 </View> 288 <View style={[s.pl10, s.pr10, s.pb10]}> 289 <LabelsOnMyPost post={post} /> 290 <ContentHider 291 modui={moderation.ui('contentView')} 292 ignoreMute 293 style={styles.contentHider} 294 childContainerStyle={styles.contentHiderChild}> 295 <PostAlerts 296 modui={moderation.ui('contentView')} 297 includeMute 298 style={[a.pt_2xs, a.pb_sm]} 299 /> 300 {richText?.text ? ( 301 <View 302 style={[ 303 styles.postTextContainer, 304 styles.postTextLargeContainer, 305 ]}> 306 <RichText 307 enableTags 308 selectable 309 value={richText} 310 style={[a.flex_1, a.text_xl]} 311 authorHandle={post.author.handle} 312 /> 313 </View> 314 ) : undefined} 315 {post.embed && ( 316 <View style={[a.pb_sm]}> 317 <PostEmbeds embed={post.embed} moderation={moderation} /> 318 </View> 319 )} 320 </ContentHider> 321 <ExpandedPostDetails 322 post={post} 323 translatorUrl={translatorUrl} 324 needsTranslation={needsTranslation} 325 /> 326 {post.repostCount !== 0 || post.likeCount !== 0 ? ( 327 // Show this section unless we're *sure* it has no engagement. 328 <View style={[styles.expandedInfo, pal.border]}> 329 {post.repostCount != null && post.repostCount !== 0 ? ( 330 <Link 331 style={styles.expandedInfoItem} 332 href={repostsHref} 333 title={repostsTitle}> 334 <Text 335 testID="repostCount-expanded" 336 type="lg" 337 style={pal.textLight}> 338 <Text type="xl-bold" style={pal.text}> 339 {formatCount(post.repostCount)} 340 </Text>{' '} 341 {pluralize(post.repostCount, 'repost')} 342 </Text> 343 </Link> 344 ) : null} 345 {post.likeCount != null && post.likeCount !== 0 ? ( 346 <Link 347 style={styles.expandedInfoItem} 348 href={likesHref} 349 title={likesTitle}> 350 <Text 351 testID="likeCount-expanded" 352 type="lg" 353 style={pal.textLight}> 354 <Text type="xl-bold" style={pal.text}> 355 {formatCount(post.likeCount)} 356 </Text>{' '} 357 {pluralize(post.likeCount, 'like')} 358 </Text> 359 </Link> 360 ) : null} 361 </View> 362 ) : null} 363 <View style={[s.pl10, s.pr10, s.pb5]}> 364 <PostCtrls 365 big 366 post={post} 367 record={record} 368 richText={richText} 369 onPressReply={onPressReply} 370 logContext="PostThreadItem" 371 /> 372 </View> 373 </View> 374 </View> 375 <WhoCanReply post={post} /> 376 </> 377 ) 378 } else { 379 const isThreadedChild = treeView && depth > 0 380 const isThreadedChildAdjacentTop = 381 isThreadedChild && prevPost?.ctx.depth === depth && depth !== 1 382 const isThreadedChildAdjacentBot = 383 isThreadedChild && nextPost?.ctx.depth === depth 384 return ( 385 <> 386 <PostOuterWrapper 387 post={post} 388 depth={depth} 389 showParentReplyLine={!!showParentReplyLine} 390 treeView={treeView} 391 hasPrecedingItem={hasPrecedingItem}> 392 <PostHider 393 testID={`postThreadItem-by-${post.author.handle}`} 394 href={postHref} 395 style={[pal.view]} 396 modui={moderation.ui('contentList')} 397 iconSize={isThreadedChild ? 26 : 38} 398 iconStyles={ 399 isThreadedChild 400 ? {marginRight: 4} 401 : {marginLeft: 2, marginRight: 2} 402 }> 403 <View 404 style={{ 405 flexDirection: 'row', 406 gap: 10, 407 paddingLeft: 8, 408 height: isThreadedChildAdjacentTop ? 8 : 16, 409 }}> 410 <View style={{width: 38}}> 411 {!isThreadedChild && showParentReplyLine && ( 412 <View 413 style={[ 414 styles.replyLine, 415 { 416 flexGrow: 1, 417 backgroundColor: pal.colors.replyLine, 418 marginBottom: 4, 419 }, 420 ]} 421 /> 422 )} 423 </View> 424 </View> 425 426 <View 427 style={[ 428 styles.layout, 429 { 430 paddingBottom: 431 showChildReplyLine && !isThreadedChild 432 ? 0 433 : isThreadedChildAdjacentBot 434 ? 4 435 : 8, 436 }, 437 ]}> 438 {/* If we are in threaded mode, the avatar is rendered in PostMeta */} 439 {!isThreadedChild && ( 440 <View style={styles.layoutAvi}> 441 <PreviewableUserAvatar 442 size={38} 443 did={post.author.did} 444 handle={post.author.handle} 445 avatar={post.author.avatar} 446 moderation={moderation.ui('avatar')} 447 type={post.author.associated?.labeler ? 'labeler' : 'user'} 448 /> 449 450 {showChildReplyLine && ( 451 <View 452 style={[ 453 styles.replyLine, 454 { 455 flexGrow: 1, 456 backgroundColor: pal.colors.replyLine, 457 marginTop: 4, 458 }, 459 ]} 460 /> 461 )} 462 </View> 463 )} 464 465 <View 466 style={ 467 isThreadedChild 468 ? styles.layoutContentThreaded 469 : styles.layoutContent 470 }> 471 <PostMeta 472 author={post.author} 473 moderation={moderation} 474 authorHasWarning={!!post.author.labels?.length} 475 timestamp={post.indexedAt} 476 postHref={postHref} 477 showAvatar={isThreadedChild} 478 avatarModeration={moderation.ui('avatar')} 479 avatarSize={28} 480 displayNameType="md-bold" 481 displayNameStyle={isThreadedChild && s.ml2} 482 style={ 483 isThreadedChild && { 484 alignItems: 'center', 485 paddingBottom: isWeb ? 5 : 2, 486 } 487 } 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 }, 679 meta: { 680 flexDirection: 'row', 681 paddingVertical: 2, 682 }, 683 metaExpandedLine1: { 684 paddingVertical: 0, 685 }, 686 alert: { 687 marginBottom: 6, 688 }, 689 postTextContainer: { 690 flexDirection: 'row', 691 alignItems: 'center', 692 flexWrap: 'wrap', 693 paddingBottom: 4, 694 paddingRight: 10, 695 }, 696 postTextLargeContainer: { 697 paddingHorizontal: 0, 698 paddingRight: 0, 699 paddingBottom: 10, 700 }, 701 translateLink: { 702 marginBottom: 6, 703 }, 704 contentHider: { 705 marginBottom: 6, 706 }, 707 contentHiderChild: { 708 marginTop: 6, 709 }, 710 expandedInfo: { 711 flexDirection: 'row', 712 padding: 10, 713 borderTopWidth: 1, 714 borderBottomWidth: 1, 715 marginTop: 5, 716 marginBottom: 15, 717 }, 718 expandedInfoItem: { 719 marginRight: 10, 720 }, 721 loadMore: { 722 flexDirection: 'row', 723 alignItems: 'center', 724 justifyContent: 'flex-start', 725 gap: 4, 726 paddingHorizontal: 20, 727 }, 728 replyLine: { 729 width: 2, 730 marginLeft: 'auto', 731 marginRight: 'auto', 732 }, 733 cursor: { 734 // @ts-ignore web only 735 cursor: 'pointer', 736 }, 737})