Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

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