Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

at main 1160 lines 36 kB view raw
1import {memo, useCallback, useEffect, useMemo, useState} from 'react' 2import { 3 Animated, 4 type GestureResponderEvent, 5 Pressable, 6 StyleSheet, 7 TouchableOpacity, 8 View, 9} from 'react-native' 10import { 11 type AppBskyActorDefs, 12 type AppBskyFeedDefs, 13 AppBskyFeedPost, 14 AppBskyGraphFollow, 15 moderateProfile, 16 type ModerationDecision, 17 type ModerationOpts, 18} from '@atproto/api' 19import {AtUri} from '@atproto/api' 20import {TID} from '@atproto/common-web' 21import {msg, plural} from '@lingui/core/macro' 22import {useLingui} from '@lingui/react' 23import {Plural, Trans} from '@lingui/react/macro' 24import {useNavigation} from '@react-navigation/native' 25import {useQueryClient} from '@tanstack/react-query' 26 27import {DM_SERVICE_HEADERS, MAX_POST_LINES} from '#/lib/constants' 28import {useAnimatedValue} from '#/lib/hooks/useAnimatedValue' 29import {usePalette} from '#/lib/hooks/usePalette' 30import {makeProfileLink} from '#/lib/routes/links' 31import {type NavigationProp} from '#/lib/routes/types' 32import {forceLTR} from '#/lib/strings/bidi' 33import {sanitizeDisplayName} from '#/lib/strings/display-names' 34import {sanitizeHandle} from '#/lib/strings/handles' 35import {niceDate} from '#/lib/strings/time' 36import {s} from '#/lib/styles' 37import {logger} from '#/logger' 38import {useProfileShadow} from '#/state/cache/profile-shadow' 39import {type FeedNotification} from '#/state/queries/notifications/feed' 40import {useProfileFollowMutationQueue} from '#/state/queries/profile' 41import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' 42import {useAgent, useSession} from '#/state/session' 43import {FeedSourceCard} from '#/view/com/feeds/FeedSourceCard' 44import {Post} from '#/view/com/post/Post' 45import {formatCount} from '#/view/com/util/numeric/format' 46import {TimeElapsed} from '#/view/com/util/TimeElapsed' 47import * as Toast from '#/view/com/util/Toast' 48import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar' 49import {atoms as a, platform, useTheme} from '#/alf' 50import {Button, ButtonIcon, ButtonText} from '#/components/Button' 51import {BellRinging_Filled_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging' 52import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' 53import { 54 ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon, 55 ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon, 56} from '#/components/icons/Chevron' 57import {Contacts_Filled_Corner2_Rounded as ContactsIconFilled} from '#/components/icons/Contacts' 58import {Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled} from '#/components/icons/Heart2' 59import {LikeRepost_Stroke2_Corner2_Rounded as RepostHeartIcon} from '#/components/icons/Heart2' 60import {PersonPlus_Filled_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' 61import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' 62import {Repost_Stroke2_Corner3_Rounded as RepostIcon} from '#/components/icons/Repost' 63import {RepostRepost_Stroke2_Corner2_Rounded as RepostRepostIcon} from '#/components/icons/Repost' 64import {StarterPack} from '#/components/icons/StarterPack' 65import {VerifiedCheck} from '#/components/icons/VerifiedCheck' 66import {InlineLinkText, Link} from '#/components/Link' 67import * as MediaPreview from '#/components/MediaPreview' 68import {PdsBadge} from '#/components/PdsBadge' 69import {ProfileHoverCard} from '#/components/ProfileHoverCard' 70import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard' 71import {SubtleHover} from '#/components/SubtleHover' 72import {Text} from '#/components/Typography' 73import {useSimpleVerificationState} from '#/components/verification' 74import {VerificationCheck} from '#/components/verification/VerificationCheck' 75import * as bsky from '#/types/bsky' 76 77const MAX_AUTHORS = 5 78 79const EXPANDED_AUTHOR_EL_HEIGHT = 35 80 81interface Author { 82 profile: AppBskyActorDefs.ProfileView 83 href: string 84 moderation: ModerationDecision 85} 86 87let NotificationFeedItem = ({ 88 item, 89 moderationOpts, 90 highlightUnread, 91 hideTopBorder, 92}: { 93 item: FeedNotification 94 moderationOpts: ModerationOpts 95 highlightUnread: boolean 96 hideTopBorder?: boolean 97}): React.ReactNode => { 98 const queryClient = useQueryClient() 99 const pal = usePalette('default') 100 const t = useTheme() 101 const {_, i18n} = useLingui() 102 const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(false) 103 const itemHref = useMemo(() => { 104 switch (item.type) { 105 case 'post-like': 106 case 'repost': 107 case 'like-via-repost': 108 case 'repost-via-repost': { 109 if (item.subjectUri) { 110 const urip = new AtUri(item.subjectUri) 111 return `/profile/${urip.host}/post/${urip.rkey}` 112 } 113 break 114 } 115 case 'follow': 116 case 'contact-match': 117 case 'verified': 118 case 'unverified': { 119 return makeProfileLink(item.notification.author) 120 } 121 case 'reply': 122 case 'mention': 123 case 'quote': { 124 const uripReply = new AtUri(item.notification.uri) 125 return `/profile/${uripReply.host}/post/${uripReply.rkey}` 126 } 127 case 'feedgen-like': 128 case 'starterpack-joined': { 129 if (item.subjectUri) { 130 const urip = new AtUri(item.subjectUri) 131 return `/profile/${urip.host}/feed/${urip.rkey}` 132 } 133 break 134 } 135 case 'subscribed-post': { 136 const posts: string[] = [] 137 for (const post of [item.notification, ...(item.additional ?? [])]) { 138 posts.push(post.uri) 139 } 140 return `/notifications/activity?posts=${encodeURIComponent(posts.slice(0, 25).join(','))}` 141 } 142 } 143 144 return '' 145 }, [item]) 146 147 const onToggleAuthorsExpanded = (e?: GestureResponderEvent) => { 148 if (e) { 149 e.preventDefault() 150 e.stopPropagation() 151 } 152 setAuthorsExpanded(currentlyExpanded => !currentlyExpanded) 153 } 154 155 const onBeforePress = useCallback(() => { 156 unstableCacheProfileView(queryClient, item.notification.author) 157 }, [queryClient, item.notification.author]) 158 159 const authors: Author[] = useMemo(() => { 160 return [ 161 { 162 profile: item.notification.author, 163 href: makeProfileLink(item.notification.author), 164 moderation: moderateProfile(item.notification.author, moderationOpts), 165 }, 166 ...(item.additional?.map(({author}) => ({ 167 profile: author, 168 href: makeProfileLink(author), 169 moderation: moderateProfile(author, moderationOpts), 170 })) || []), 171 ].filter( 172 (author, index, arr) => 173 arr.findIndex(au => au.profile.did === author.profile.did) === index, 174 ) 175 }, [item, moderationOpts]) 176 177 const niceTimestamp = niceDate(i18n, item.notification.indexedAt) 178 const firstAuthor = authors[0] 179 const firstAuthorVerification = useSimpleVerificationState({ 180 profile: firstAuthor.profile, 181 }) 182 const firstAuthorName = sanitizeDisplayName( 183 firstAuthor.profile.displayName || firstAuthor.profile.handle, 184 ) 185 186 // Calculate if this is a follow-back notification 187 const isFollowBack = useMemo(() => { 188 if (item.type !== 'follow') return false 189 if ( 190 item.notification.author.viewer?.following && 191 bsky.dangerousIsType<AppBskyGraphFollow.Record>( 192 item.notification.record, 193 AppBskyGraphFollow.isRecord, 194 ) 195 ) { 196 let followingTimestamp 197 try { 198 const rkey = new AtUri(item.notification.author.viewer.following).rkey 199 followingTimestamp = TID.fromStr(rkey).timestamp() 200 } catch (e) { 201 return false 202 } 203 if (followingTimestamp) { 204 const followedTimestamp = 205 new Date(item.notification.record.createdAt).getTime() * 1000 206 return followedTimestamp > followingTimestamp 207 } 208 } 209 return false 210 }, [item]) 211 212 if (item.subjectUri && !item.subject && item.type !== 'feedgen-like') { 213 // don't render anything if the target post was deleted or unfindable 214 return <View /> 215 } 216 217 if ( 218 item.type === 'reply' || 219 item.type === 'mention' || 220 item.type === 'quote' 221 ) { 222 if (!item.subject) { 223 return null 224 } 225 const isHighlighted = highlightUnread && !item.notification.isRead 226 return ( 227 <View testID={`feedItem-by-${item.notification.author.handle}`}> 228 <Post 229 post={item.subject} 230 style={ 231 isHighlighted && { 232 backgroundColor: pal.colors.unreadNotifBg, 233 borderColor: pal.colors.unreadNotifBorder, 234 } 235 } 236 hideTopBorder={hideTopBorder} 237 /> 238 </View> 239 ) 240 } 241 242 const firstAuthorLink = ( 243 <ProfileHoverCard did={firstAuthor.profile.did} inline> 244 <InlineLinkText 245 key={firstAuthor.href} 246 style={[t.atoms.text, a.font_semi_bold, a.text_md, a.leading_tight]} 247 to={firstAuthor.href} 248 disableMismatchWarning 249 emoji 250 label={_(msg`Go to ${firstAuthorName}'s profile`)}> 251 {forceLTR(firstAuthorName)} 252 {firstAuthorVerification.showBadge && ( 253 <View 254 style={[ 255 a.relative, 256 { 257 paddingTop: platform({android: 2}), 258 marginBottom: platform({ios: -7}), 259 top: platform({web: 1}), 260 paddingLeft: 3, 261 paddingRight: 2, 262 }, 263 ]}> 264 <VerificationCheck 265 width={14} 266 verifier={firstAuthorVerification.role === 'verifier'} 267 /> 268 </View> 269 )} 270 </InlineLinkText> 271 </ProfileHoverCard> 272 ) 273 const additionalAuthorsCount = authors.length - 1 274 const hasMultipleAuthors = additionalAuthorsCount > 0 275 const formattedAuthorsCount = hasMultipleAuthors 276 ? formatCount(i18n, additionalAuthorsCount) 277 : '' 278 279 let a11yLabel = '' 280 let notificationContent: React.ReactElement<any> 281 let icon = ( 282 <HeartIconFilled 283 size="xl" 284 style={[ 285 s.likeColor, 286 // {position: 'relative', top: -4} 287 ]} 288 /> 289 ) 290 291 if (item.type === 'post-like') { 292 a11yLabel = hasMultipleAuthors 293 ? _( 294 msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { 295 one: `${formattedAuthorsCount} other`, 296 other: `${formattedAuthorsCount} others`, 297 })} liked your post`, 298 ) 299 : _(msg`${firstAuthorName} liked your post`) 300 notificationContent = hasMultipleAuthors ? ( 301 <Trans> 302 {firstAuthorLink} and{' '} 303 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}> 304 <Plural 305 value={additionalAuthorsCount} 306 one={`${formattedAuthorsCount} other`} 307 other={`${formattedAuthorsCount} others`} 308 /> 309 </Text>{' '} 310 liked your post 311 </Trans> 312 ) : ( 313 <Trans>{firstAuthorLink} liked your post</Trans> 314 ) 315 } else if (item.type === 'repost') { 316 a11yLabel = hasMultipleAuthors 317 ? _( 318 msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { 319 one: `${formattedAuthorsCount} other`, 320 other: `${formattedAuthorsCount} others`, 321 })} reposted your post`, 322 ) 323 : _(msg`${firstAuthorName} reposted your post`) 324 notificationContent = hasMultipleAuthors ? ( 325 <Trans> 326 {firstAuthorLink} and{' '} 327 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}> 328 <Plural 329 value={additionalAuthorsCount} 330 one={`${formattedAuthorsCount} other`} 331 other={`${formattedAuthorsCount} others`} 332 /> 333 </Text>{' '} 334 reposted your post 335 </Trans> 336 ) : ( 337 <Trans>{firstAuthorLink} reposted your post</Trans> 338 ) 339 icon = <RepostIcon size="xl" style={{color: t.palette.positive_500}} /> 340 } else if (item.type === 'follow') { 341 if (isFollowBack && !hasMultipleAuthors) { 342 /* 343 * Follow-backs are ungrouped, grouped follow-backs not supported atm, 344 * see `src/state/queries/notifications/util.ts` 345 */ 346 a11yLabel = _(msg`${firstAuthorName} followed you back`) 347 notificationContent = <Trans>{firstAuthorLink} followed you back</Trans> 348 } else { 349 a11yLabel = hasMultipleAuthors 350 ? _( 351 msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { 352 one: `${formattedAuthorsCount} other`, 353 other: `${formattedAuthorsCount} others`, 354 })} followed you`, 355 ) 356 : _(msg`${firstAuthorName} followed you`) 357 notificationContent = hasMultipleAuthors ? ( 358 <Trans> 359 {firstAuthorLink} and{' '} 360 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}> 361 <Plural 362 value={additionalAuthorsCount} 363 one={`${formattedAuthorsCount} other`} 364 other={`${formattedAuthorsCount} others`} 365 /> 366 </Text>{' '} 367 followed you 368 </Trans> 369 ) : ( 370 <Trans>{firstAuthorLink} followed you</Trans> 371 ) 372 } 373 icon = <PersonPlusIcon size="xl" style={{color: t.palette.primary_500}} /> 374 } else if (item.type === 'contact-match') { 375 a11yLabel = _(msg`Your contact ${firstAuthorName} is on Bluesky`) 376 notificationContent = ( 377 <Trans>Your contact {firstAuthorLink} is on Bluesky</Trans> 378 ) 379 icon = ( 380 <ContactsIconFilled size="xl" style={{color: t.palette.primary_500}} /> 381 ) 382 } else if (item.type === 'feedgen-like') { 383 a11yLabel = hasMultipleAuthors 384 ? _( 385 msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { 386 one: `${formattedAuthorsCount} other`, 387 other: `${formattedAuthorsCount} others`, 388 })} liked your custom feed`, 389 ) 390 : _(msg`${firstAuthorName} liked your custom feed`) 391 notificationContent = hasMultipleAuthors ? ( 392 <Trans> 393 {firstAuthorLink} and{' '} 394 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}> 395 <Plural 396 value={additionalAuthorsCount} 397 one={`${formattedAuthorsCount} other`} 398 other={`${formattedAuthorsCount} others`} 399 /> 400 </Text>{' '} 401 liked your custom feed 402 </Trans> 403 ) : ( 404 <Trans>{firstAuthorLink} liked your custom feed</Trans> 405 ) 406 } else if (item.type === 'starterpack-joined') { 407 a11yLabel = hasMultipleAuthors 408 ? _( 409 msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { 410 one: `${formattedAuthorsCount} other`, 411 other: `${formattedAuthorsCount} others`, 412 })} signed up with your starter pack`, 413 ) 414 : _(msg`${firstAuthorName} signed up with your starter pack`) 415 notificationContent = hasMultipleAuthors ? ( 416 <Trans> 417 {firstAuthorLink} and{' '} 418 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}> 419 <Plural 420 value={additionalAuthorsCount} 421 one={`${formattedAuthorsCount} other`} 422 other={`${formattedAuthorsCount} others`} 423 /> 424 </Text>{' '} 425 signed up with your starter pack 426 </Trans> 427 ) : ( 428 <Trans>{firstAuthorLink} signed up with your starter pack</Trans> 429 ) 430 icon = ( 431 <View style={{height: 30, width: 30}}> 432 <StarterPack width={30} gradient="sky" /> 433 </View> 434 ) 435 } else if (item.type === 'verified') { 436 a11yLabel = hasMultipleAuthors 437 ? _( 438 msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { 439 one: `${formattedAuthorsCount} other`, 440 other: `${formattedAuthorsCount} others`, 441 })} verified you`, 442 ) 443 : _(msg`${firstAuthorName} verified you`) 444 notificationContent = hasMultipleAuthors ? ( 445 <Trans> 446 {firstAuthorLink} and{' '} 447 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}> 448 <Plural 449 value={additionalAuthorsCount} 450 one={`${formattedAuthorsCount} other`} 451 other={`${formattedAuthorsCount} others`} 452 /> 453 </Text>{' '} 454 verified you 455 </Trans> 456 ) : ( 457 <Trans>{firstAuthorLink} verified you</Trans> 458 ) 459 icon = <VerifiedCheck size="xl" /> 460 } else if (item.type === 'unverified') { 461 a11yLabel = hasMultipleAuthors 462 ? _( 463 msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { 464 one: `${formattedAuthorsCount} other`, 465 other: `${formattedAuthorsCount} others`, 466 })} removed their verifications from your account`, 467 ) 468 : _(msg`${firstAuthorName} removed their verification from your account`) 469 notificationContent = hasMultipleAuthors ? ( 470 <Trans> 471 {firstAuthorLink} and{' '} 472 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}> 473 <Plural 474 value={additionalAuthorsCount} 475 one={`${formattedAuthorsCount} other`} 476 other={`${formattedAuthorsCount} others`} 477 /> 478 </Text>{' '} 479 removed their verifications from your account 480 </Trans> 481 ) : ( 482 <Trans> 483 {firstAuthorLink} removed their verification from your account 484 </Trans> 485 ) 486 icon = <VerifiedCheck size="xl" fill={t.palette.contrast_500} /> 487 } else if (item.type === 'like-via-repost') { 488 a11yLabel = hasMultipleAuthors 489 ? _( 490 msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { 491 one: `${formattedAuthorsCount} other`, 492 other: `${formattedAuthorsCount} others`, 493 })} liked your repost`, 494 ) 495 : _(msg`${firstAuthorName} liked your repost`) 496 notificationContent = hasMultipleAuthors ? ( 497 <Trans> 498 {firstAuthorLink} and{' '} 499 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}> 500 <Plural 501 value={additionalAuthorsCount} 502 one={`${formattedAuthorsCount} other`} 503 other={`${formattedAuthorsCount} others`} 504 /> 505 </Text>{' '} 506 liked your repost 507 </Trans> 508 ) : ( 509 <Trans>{firstAuthorLink} liked your repost</Trans> 510 ) 511 icon = <RepostHeartIcon size="xl" style={[s.likeColor]} /> 512 } else if (item.type === 'repost-via-repost') { 513 a11yLabel = hasMultipleAuthors 514 ? _( 515 msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { 516 one: `${formattedAuthorsCount} other`, 517 other: `${formattedAuthorsCount} others`, 518 })} reposted your repost`, 519 ) 520 : _(msg`${firstAuthorName} reposted your repost`) 521 notificationContent = hasMultipleAuthors ? ( 522 <Trans> 523 {firstAuthorLink} and{' '} 524 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}> 525 <Plural 526 value={additionalAuthorsCount} 527 one={`${formattedAuthorsCount} other`} 528 other={`${formattedAuthorsCount} others`} 529 /> 530 </Text>{' '} 531 reposted your repost 532 </Trans> 533 ) : ( 534 <Trans>{firstAuthorLink} reposted your repost</Trans> 535 ) 536 icon = ( 537 <RepostRepostIcon size="xl" style={{color: t.palette.positive_500}} /> 538 ) 539 } else if (item.type === 'subscribed-post') { 540 const postsCount = 1 + (item.additional?.length || 0) 541 a11yLabel = hasMultipleAuthors 542 ? _( 543 msg`New posts from ${firstAuthorName} and ${plural( 544 additionalAuthorsCount, 545 { 546 one: `${formattedAuthorsCount} other`, 547 other: `${formattedAuthorsCount} others`, 548 }, 549 )}`, 550 ) 551 : _( 552 msg`New ${plural(postsCount, { 553 one: 'post', 554 other: 'posts', 555 })} from ${firstAuthorName}`, 556 ) 557 notificationContent = hasMultipleAuthors ? ( 558 <Trans> 559 New posts from {firstAuthorLink} and{' '} 560 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}> 561 <Plural 562 value={additionalAuthorsCount} 563 one={`${formattedAuthorsCount} other`} 564 other={`${formattedAuthorsCount} others`} 565 /> 566 </Text>{' '} 567 </Trans> 568 ) : ( 569 <Trans> 570 New <Plural value={postsCount} one="post" other="posts" /> from{' '} 571 {firstAuthorLink} 572 </Trans> 573 ) 574 icon = <BellRingingIcon size="xl" style={{color: t.palette.primary_500}} /> 575 } else { 576 return null 577 } 578 a11yLabel += `${niceTimestamp}` 579 580 return ( 581 <Link 582 label={a11yLabel} 583 testID={`feedItem-by-${item.notification.author.handle}`} 584 style={[ 585 a.flex_row, 586 a.align_start, 587 {padding: 10}, 588 a.pr_lg, 589 t.atoms.border_contrast_low, 590 item.notification.isRead 591 ? undefined 592 : { 593 backgroundColor: pal.colors.unreadNotifBg, 594 borderColor: pal.colors.unreadNotifBorder, 595 }, 596 !hideTopBorder && a.border_t, 597 a.overflow_hidden, 598 ]} 599 to={itemHref} 600 accessible={!isAuthorsExpanded} 601 accessibilityActions={ 602 hasMultipleAuthors 603 ? [ 604 { 605 name: 'toggleAuthorsExpanded', 606 label: isAuthorsExpanded 607 ? _(msg`Collapse list of users`) 608 : _(msg`Expand list of users`), 609 }, 610 ] 611 : [ 612 { 613 name: 'viewProfile', 614 label: _( 615 msg`View ${ 616 authors[0].profile.displayName || authors[0].profile.handle 617 }'s profile`, 618 ), 619 }, 620 ] 621 } 622 onAccessibilityAction={e => { 623 if (e.nativeEvent.actionName === 'activate') { 624 onBeforePress() 625 } 626 if (e.nativeEvent.actionName === 'toggleAuthorsExpanded') { 627 onToggleAuthorsExpanded() 628 } 629 }}> 630 {({hovered}) => ( 631 <> 632 <SubtleHover hover={hovered} /> 633 <View style={[styles.layoutIcon, a.pr_sm]}> 634 {/* TODO: Prevent conditional rendering and move toward composable 635 notifications for clearer accessibility labeling */} 636 {icon} 637 </View> 638 <View style={[a.flex_1]}> 639 <ExpandListPressable 640 hasMultipleAuthors={hasMultipleAuthors} 641 onToggleAuthorsExpanded={onToggleAuthorsExpanded}> 642 <CondensedAuthorsList 643 visible={!isAuthorsExpanded} 644 authors={authors} 645 onToggleAuthorsExpanded={onToggleAuthorsExpanded} 646 showDmButton={item.type === 'starterpack-joined'} 647 /> 648 <ExpandedAuthorsList 649 visible={isAuthorsExpanded} 650 authors={authors} 651 /> 652 <Text 653 style={[ 654 a.flex_row, 655 a.flex_wrap, 656 {paddingTop: 6}, 657 a.self_start, 658 a.text_md, 659 a.leading_snug, 660 ]} 661 accessibilityHint="" 662 accessibilityLabel={a11yLabel}> 663 {notificationContent} 664 <TimeElapsed timestamp={item.notification.indexedAt}> 665 {({timeElapsed}) => ( 666 <> 667 {/* make sure there's whitespace around the middot -sfn */} 668 <Text style={[a.text_md, t.atoms.text_contrast_medium]}> 669 {' '} 670 &middot;{' '} 671 </Text> 672 <Text 673 style={[a.text_md, t.atoms.text_contrast_medium]} 674 title={niceTimestamp}> 675 {timeElapsed} 676 </Text> 677 </> 678 )} 679 </TimeElapsed> 680 </Text> 681 </ExpandListPressable> 682 {(item.type === 'follow' && !hasMultipleAuthors && !isFollowBack) || 683 (item.type === 'contact-match' && 684 !item.notification.author.viewer?.following) ? ( 685 <FollowBackButton profile={item.notification.author} /> 686 ) : null} 687 {item.type === 'post-like' || 688 item.type === 'repost' || 689 item.type === 'like-via-repost' || 690 item.type === 'repost-via-repost' || 691 item.type === 'subscribed-post' ? ( 692 <View style={[a.pt_2xs]}> 693 <AdditionalPostText post={item.subject} /> 694 </View> 695 ) : null} 696 {item.type === 'feedgen-like' && item.subjectUri ? ( 697 <FeedSourceCard 698 feedUri={item.subjectUri} 699 link={false} 700 style={[ 701 t.atoms.bg, 702 t.atoms.border_contrast_low, 703 a.border, 704 a.p_md, 705 styles.feedcard, 706 ]} 707 showLikes 708 /> 709 ) : null} 710 {item.type === 'starterpack-joined' ? ( 711 <View> 712 <View 713 style={[ 714 a.border, 715 a.p_sm, 716 a.rounded_sm, 717 a.mt_sm, 718 t.atoms.border_contrast_low, 719 ]}> 720 <StarterPackCard starterPack={item.subject} /> 721 </View> 722 </View> 723 ) : null} 724 </View> 725 </> 726 )} 727 </Link> 728 ) 729} 730NotificationFeedItem = memo(NotificationFeedItem) 731export {NotificationFeedItem} 732 733function ExpandListPressable({ 734 hasMultipleAuthors, 735 children, 736 onToggleAuthorsExpanded, 737}: { 738 hasMultipleAuthors: boolean 739 children: React.ReactNode 740 onToggleAuthorsExpanded: (e: GestureResponderEvent) => void 741}) { 742 if (hasMultipleAuthors) { 743 return ( 744 <Pressable 745 onPress={onToggleAuthorsExpanded} 746 style={[styles.expandedAuthorsTrigger]} 747 accessible={false}> 748 {children} 749 </Pressable> 750 ) 751 } else { 752 return <>{children}</> 753 } 754} 755 756function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) { 757 const {_} = useLingui() 758 const {currentAccount, hasSession} = useSession() 759 const profileShadow = useProfileShadow(profile) 760 const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( 761 profileShadow, 762 'ProfileCard', 763 ) 764 765 // Don't show button if not logged in or for own profile 766 if (!hasSession || profile.did === currentAccount?.did) { 767 return null 768 } 769 770 const onPressFollow = async (e: GestureResponderEvent) => { 771 e.preventDefault() 772 e.stopPropagation() 773 774 try { 775 await queueFollow() 776 Toast.show( 777 _( 778 msg`Following ${sanitizeDisplayName( 779 profile.displayName || profile.handle, 780 )}`, 781 ), 782 ) 783 } catch (err: any) { 784 if (err?.name !== 'AbortError') { 785 Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') 786 } 787 } 788 } 789 790 const onPressUnfollow = async (e: GestureResponderEvent) => { 791 e.preventDefault() 792 e.stopPropagation() 793 794 try { 795 await queueUnfollow() 796 Toast.show( 797 _( 798 msg`No longer following ${sanitizeDisplayName( 799 profile.displayName || profile.handle, 800 )}`, 801 ), 802 ) 803 } catch (err: any) { 804 if (err?.name !== 'AbortError') { 805 Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') 806 } 807 } 808 } 809 810 // Don't show button if viewer data is missing or user is blocked 811 if (!profileShadow.viewer) { 812 return null 813 } 814 if ( 815 profileShadow.viewer.blockedBy || 816 profileShadow.viewer.blocking || 817 profileShadow.viewer.blockingByList 818 ) { 819 return null 820 } 821 822 const isFollowing = profileShadow.viewer.following 823 const isFollowedBy = profileShadow.viewer.followedBy 824 const followingLabel = _( 825 msg({ 826 message: 'Following', 827 comment: 'User is following this account, click to unfollow', 828 }), 829 ) 830 831 return ( 832 <View style={[a.pt_sm]}> 833 {isFollowing ? ( 834 <Button 835 label={followingLabel} 836 color="secondary" 837 size="small" 838 style={[a.self_start]} 839 onPress={onPressUnfollow}> 840 <ButtonIcon icon={CheckIcon} /> 841 <ButtonText> 842 {isFollowedBy ? <Trans>Mutuals</Trans> : <Trans>Following</Trans>} 843 </ButtonText> 844 </Button> 845 ) : ( 846 <Button 847 label={isFollowedBy ? _(msg`Follow back`) : _(msg`Follow`)} 848 color="primary" 849 size="small" 850 style={[a.self_start]} 851 onPress={onPressFollow}> 852 <ButtonIcon icon={PlusIcon} /> 853 <ButtonText> 854 {isFollowedBy ? <Trans>Follow back</Trans> : <Trans>Follow</Trans>} 855 </ButtonText> 856 </Button> 857 )} 858 </View> 859 ) 860} 861 862function SayHelloBtn({profile}: {profile: AppBskyActorDefs.ProfileView}) { 863 const {_} = useLingui() 864 const agent = useAgent() 865 const navigation = useNavigation<NavigationProp>() 866 const [isLoading, setIsLoading] = useState(false) 867 868 if ( 869 profile.associated?.chat?.allowIncoming === 'none' || 870 (profile.associated?.chat?.allowIncoming === 'following' && 871 !profile.viewer?.followedBy) 872 ) { 873 return null 874 } 875 876 return ( 877 <Button 878 label={_(msg`Say hello!`)} 879 variant="ghost" 880 color="primary" 881 size="small" 882 style={[a.self_center, {marginLeft: 'auto'}]} 883 disabled={isLoading} 884 onPress={async () => { 885 try { 886 setIsLoading(true) 887 const res = await agent.api.chat.bsky.convo.getConvoForMembers( 888 { 889 members: [profile.did, agent.session!.did], 890 }, 891 {headers: DM_SERVICE_HEADERS}, 892 ) 893 navigation.navigate('MessagesConversation', { 894 conversation: res.data.convo.id, 895 }) 896 } catch (e) { 897 logger.error('Failed to get conversation', {safeMessage: e}) 898 } finally { 899 setIsLoading(false) 900 } 901 }}> 902 <ButtonText> 903 <Trans>Say hello!</Trans> 904 </ButtonText> 905 </Button> 906 ) 907} 908 909function CondensedAuthorsList({ 910 visible, 911 authors, 912 onToggleAuthorsExpanded, 913 showDmButton = true, 914}: { 915 visible: boolean 916 authors: Author[] 917 onToggleAuthorsExpanded: (e: GestureResponderEvent) => void 918 showDmButton?: boolean 919}) { 920 const t = useTheme() 921 const {_} = useLingui() 922 923 if (!visible) { 924 return ( 925 <View style={[a.flex_row, a.align_center]}> 926 <TouchableOpacity 927 style={styles.expandedAuthorsCloseBtn} 928 onPress={onToggleAuthorsExpanded} 929 accessibilityRole="button" 930 accessibilityLabel={_(msg`Hide user list`)} 931 accessibilityHint={_( 932 msg`Collapses list of users for a given notification`, 933 )}> 934 <ChevronUpIcon 935 size="md" 936 style={[a.ml_xs, a.mr_md, t.atoms.text_contrast_high]} 937 /> 938 <Text style={[a.text_md, t.atoms.text_contrast_high]}> 939 <Trans context="action">Hide</Trans> 940 </Text> 941 </TouchableOpacity> 942 </View> 943 ) 944 } 945 if (authors.length === 1) { 946 return ( 947 <View style={[a.flex_row, a.align_center]}> 948 <PreviewableUserAvatar 949 size={35} 950 profile={authors[0].profile} 951 moderation={authors[0].moderation.ui('avatar')} 952 type={authors[0].profile.associated?.labeler ? 'labeler' : 'user'} 953 /> 954 {showDmButton ? <SayHelloBtn profile={authors[0].profile} /> : null} 955 </View> 956 ) 957 } 958 return ( 959 <TouchableOpacity 960 accessibilityRole="none" 961 onPress={onToggleAuthorsExpanded}> 962 <View style={[a.flex_row, a.align_center]}> 963 {authors.slice(0, MAX_AUTHORS).map(author => ( 964 <View key={author.href} style={s.mr5}> 965 <PreviewableUserAvatar 966 size={35} 967 profile={author.profile} 968 moderation={author.moderation.ui('avatar')} 969 type={author.profile.associated?.labeler ? 'labeler' : 'user'} 970 /> 971 </View> 972 ))} 973 {authors.length > MAX_AUTHORS ? ( 974 <Text 975 style={[ 976 a.font_semi_bold, 977 {paddingLeft: 6}, 978 t.atoms.text_contrast_medium, 979 ]}> 980 +{authors.length - MAX_AUTHORS} 981 </Text> 982 ) : undefined} 983 <ChevronDownIcon 984 size="md" 985 style={[a.mx_xs, t.atoms.text_contrast_medium]} 986 /> 987 </View> 988 </TouchableOpacity> 989 ) 990} 991 992function ExpandedAuthorsList({ 993 visible, 994 authors, 995}: { 996 visible: boolean 997 authors: Author[] 998}) { 999 const heightInterp = useAnimatedValue(visible ? 1 : 0) 1000 const targetHeight = 1001 authors.length * (EXPANDED_AUTHOR_EL_HEIGHT + 10) /*10=margin*/ 1002 const heightStyle = { 1003 height: Animated.multiply(heightInterp, targetHeight), 1004 } 1005 useEffect(() => { 1006 Animated.timing(heightInterp, { 1007 toValue: visible ? 1 : 0, 1008 duration: 200, 1009 useNativeDriver: false, 1010 }).start() 1011 }, [heightInterp, visible]) 1012 1013 return ( 1014 <Animated.View style={[a.overflow_hidden, heightStyle]}> 1015 {visible && 1016 authors.map(author => ( 1017 <ExpandedAuthorCard key={author.profile.did} author={author} /> 1018 ))} 1019 </Animated.View> 1020 ) 1021} 1022 1023function ExpandedAuthorCard({author}: {author: Author}) { 1024 const t = useTheme() 1025 const {_} = useLingui() 1026 const verification = useSimpleVerificationState({ 1027 profile: author.profile, 1028 }) 1029 return ( 1030 <Link 1031 key={author.profile.did} 1032 label={author.profile.displayName || author.profile.handle} 1033 accessibilityHint={_(msg`Opens this profile`)} 1034 to={makeProfileLink({ 1035 did: author.profile.did, 1036 handle: author.profile.handle, 1037 })} 1038 style={styles.expandedAuthor}> 1039 <View style={[a.mr_sm]}> 1040 <ProfileHoverCard did={author.profile.did}> 1041 <UserAvatar 1042 size={35} 1043 avatar={author.profile.avatar} 1044 moderation={author.moderation.ui('avatar')} 1045 type={author.profile.associated?.labeler ? 'labeler' : 'user'} 1046 /> 1047 </ProfileHoverCard> 1048 </View> 1049 <View style={[a.flex_1]}> 1050 <View style={[a.flex_row, a.align_end]}> 1051 <Text 1052 numberOfLines={1} 1053 emoji 1054 style={[ 1055 a.text_md, 1056 a.font_semi_bold, 1057 a.leading_tight, 1058 {maxWidth: '70%'}, 1059 ]}> 1060 {sanitizeDisplayName( 1061 author.profile.displayName || author.profile.handle, 1062 )} 1063 </Text> 1064 <View style={[a.pl_xs, a.self_center]}> 1065 <PdsBadge did={author.profile.did} size="sm" /> 1066 </View> 1067 {verification.showBadge && ( 1068 <View style={[a.pl_xs, a.self_center]}> 1069 <VerificationCheck 1070 width={14} 1071 verifier={verification.role === 'verifier'} 1072 /> 1073 </View> 1074 )} 1075 <Text 1076 numberOfLines={1} 1077 style={[ 1078 a.pl_xs, 1079 a.text_md, 1080 a.leading_tight, 1081 a.flex_shrink, 1082 t.atoms.text_contrast_medium, 1083 ]}> 1084 {sanitizeHandle(author.profile.handle, '@')} 1085 </Text> 1086 </View> 1087 </View> 1088 </Link> 1089 ) 1090} 1091 1092function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) { 1093 const t = useTheme() 1094 if ( 1095 post && 1096 bsky.dangerousIsType<AppBskyFeedPost.Record>( 1097 post?.record, 1098 AppBskyFeedPost.isRecord, 1099 ) 1100 ) { 1101 const text = post.record.text 1102 1103 return ( 1104 <> 1105 {text?.length > 0 && ( 1106 <Text 1107 emoji 1108 style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]} 1109 numberOfLines={MAX_POST_LINES}> 1110 {text} 1111 </Text> 1112 )} 1113 <MediaPreview.Embed 1114 embed={post.embed} 1115 style={styles.additionalPostImages} 1116 /> 1117 </> 1118 ) 1119 } 1120} 1121 1122const styles = StyleSheet.create({ 1123 layoutIcon: { 1124 width: 60, 1125 alignItems: 'flex-end', 1126 paddingTop: 2, 1127 }, 1128 icon: { 1129 marginRight: 10, 1130 marginTop: 4, 1131 }, 1132 additionalPostImages: { 1133 marginTop: 5, 1134 marginLeft: 2, 1135 opacity: 0.8, 1136 }, 1137 feedcard: { 1138 borderRadius: 8, 1139 marginTop: 6, 1140 }, 1141 addedContainer: { 1142 paddingTop: 4, 1143 paddingLeft: 36, 1144 }, 1145 expandedAuthorsTrigger: { 1146 zIndex: 1, 1147 }, 1148 expandedAuthorsCloseBtn: { 1149 flexDirection: 'row', 1150 alignItems: 'center', 1151 paddingTop: 10, 1152 paddingBottom: 6, 1153 }, 1154 expandedAuthor: { 1155 flexDirection: 'row', 1156 alignItems: 'center', 1157 marginTop: 10, 1158 height: EXPANDED_AUTHOR_EL_HEIGHT, 1159 }, 1160})