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

Configure Feed

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

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