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

Configure Feed

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

at c540dae4e7db67031ee5f67feb076927999e364d 1151 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 {PersonPlus_Filled_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' 60import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' 61import {Repost_Stroke2_Corner3_Rounded as RepostIcon} from '#/components/icons/Repost' 62import {StarterPack} from '#/components/icons/StarterPack' 63import {VerifiedCheck} from '#/components/icons/VerifiedCheck' 64import {InlineLinkText, Link} from '#/components/Link' 65import * as MediaPreview from '#/components/MediaPreview' 66import {ProfileHoverCard} from '#/components/ProfileHoverCard' 67import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard' 68import {SubtleHover} from '#/components/SubtleHover' 69import {Text} from '#/components/Typography' 70import {useSimpleVerificationState} from '#/components/verification' 71import {VerificationCheck} from '#/components/verification/VerificationCheck' 72import * as bsky from '#/types/bsky' 73 74const MAX_AUTHORS = 5 75 76const EXPANDED_AUTHOR_EL_HEIGHT = 35 77 78interface Author { 79 profile: AppBskyActorDefs.ProfileView 80 href: string 81 moderation: ModerationDecision 82} 83 84let NotificationFeedItem = ({ 85 item, 86 moderationOpts, 87 highlightUnread, 88 hideTopBorder, 89}: { 90 item: FeedNotification 91 moderationOpts: ModerationOpts 92 highlightUnread: boolean 93 hideTopBorder?: boolean 94}): React.ReactNode => { 95 const queryClient = useQueryClient() 96 const pal = usePalette('default') 97 const t = useTheme() 98 const {_, i18n} = useLingui() 99 const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(false) 100 const itemHref = useMemo(() => { 101 switch (item.type) { 102 case 'post-like': 103 case 'repost': 104 case 'like-via-repost': 105 case 'repost-via-repost': { 106 if (item.subjectUri) { 107 const urip = new AtUri(item.subjectUri) 108 return `/profile/${urip.host}/post/${urip.rkey}` 109 } 110 break 111 } 112 case 'follow': 113 case 'contact-match': 114 case 'verified': 115 case 'unverified': { 116 return makeProfileLink(item.notification.author) 117 } 118 case 'reply': 119 case 'mention': 120 case 'quote': { 121 const uripReply = new AtUri(item.notification.uri) 122 return `/profile/${uripReply.host}/post/${uripReply.rkey}` 123 } 124 case 'feedgen-like': 125 case 'starterpack-joined': { 126 if (item.subjectUri) { 127 const urip = new AtUri(item.subjectUri) 128 return `/profile/${urip.host}/feed/${urip.rkey}` 129 } 130 break 131 } 132 case 'subscribed-post': { 133 const posts: string[] = [] 134 for (const post of [item.notification, ...(item.additional ?? [])]) { 135 posts.push(post.uri) 136 } 137 return `/notifications/activity?posts=${encodeURIComponent(posts.slice(0, 25).join(','))}` 138 } 139 } 140 141 return '' 142 }, [item]) 143 144 const onToggleAuthorsExpanded = (e?: GestureResponderEvent) => { 145 if (e) { 146 e.preventDefault() 147 e.stopPropagation() 148 } 149 setAuthorsExpanded(currentlyExpanded => !currentlyExpanded) 150 } 151 152 const onBeforePress = useCallback(() => { 153 unstableCacheProfileView(queryClient, item.notification.author) 154 }, [queryClient, item.notification.author]) 155 156 const authors: Author[] = useMemo(() => { 157 return [ 158 { 159 profile: item.notification.author, 160 href: makeProfileLink(item.notification.author), 161 moderation: moderateProfile(item.notification.author, moderationOpts), 162 }, 163 ...(item.additional?.map(({author}) => ({ 164 profile: author, 165 href: makeProfileLink(author), 166 moderation: moderateProfile(author, moderationOpts), 167 })) || []), 168 ].filter( 169 (author, index, arr) => 170 arr.findIndex(au => au.profile.did === author.profile.did) === index, 171 ) 172 }, [item, moderationOpts]) 173 174 const niceTimestamp = niceDate(i18n, item.notification.indexedAt) 175 const firstAuthor = authors[0] 176 const firstAuthorVerification = useSimpleVerificationState({ 177 profile: firstAuthor.profile, 178 }) 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: pal.colors.unreadNotifBg, 230 borderColor: pal.colors.unreadNotifBorder, 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 {firstAuthorVerification.showBadge && ( 250 <View 251 style={[ 252 a.relative, 253 { 254 paddingTop: platform({android: 2}), 255 marginBottom: platform({ios: -7}), 256 top: platform({web: 1}), 257 paddingLeft: 3, 258 paddingRight: 2, 259 }, 260 ]}> 261 <VerificationCheck 262 width={14} 263 verifier={firstAuthorVerification.role === 'verifier'} 264 /> 265 </View> 266 )} 267 </InlineLinkText> 268 </ProfileHoverCard> 269 ) 270 const additionalAuthorsCount = authors.length - 1 271 const hasMultipleAuthors = additionalAuthorsCount > 0 272 const formattedAuthorsCount = hasMultipleAuthors 273 ? formatCount(i18n, additionalAuthorsCount) 274 : '' 275 276 let a11yLabel = '' 277 let notificationContent: React.ReactElement<any> 278 let icon = ( 279 <HeartIconFilled 280 size="xl" 281 style={[ 282 s.likeColor, 283 // {position: 'relative', top: -4} 284 ]} 285 /> 286 ) 287 288 if (item.type === 'post-like') { 289 a11yLabel = hasMultipleAuthors 290 ? _( 291 msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { 292 one: `${formattedAuthorsCount} other`, 293 other: `${formattedAuthorsCount} others`, 294 })} liked your post`, 295 ) 296 : _(msg`${firstAuthorName} liked your post`) 297 notificationContent = hasMultipleAuthors ? ( 298 <Trans> 299 {firstAuthorLink} and{' '} 300 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}> 301 <Plural 302 value={additionalAuthorsCount} 303 one={`${formattedAuthorsCount} other`} 304 other={`${formattedAuthorsCount} others`} 305 /> 306 </Text>{' '} 307 liked your post 308 </Trans> 309 ) : ( 310 <Trans>{firstAuthorLink} liked your post</Trans> 311 ) 312 } else if (item.type === 'repost') { 313 a11yLabel = hasMultipleAuthors 314 ? _( 315 msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { 316 one: `${formattedAuthorsCount} other`, 317 other: `${formattedAuthorsCount} others`, 318 })} reposted your post`, 319 ) 320 : _(msg`${firstAuthorName} reposted your post`) 321 notificationContent = hasMultipleAuthors ? ( 322 <Trans> 323 {firstAuthorLink} and{' '} 324 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}> 325 <Plural 326 value={additionalAuthorsCount} 327 one={`${formattedAuthorsCount} other`} 328 other={`${formattedAuthorsCount} others`} 329 /> 330 </Text>{' '} 331 reposted your post 332 </Trans> 333 ) : ( 334 <Trans>{firstAuthorLink} reposted your post</Trans> 335 ) 336 icon = <RepostIcon size="xl" style={{color: t.palette.positive_500}} /> 337 } else if (item.type === 'follow') { 338 if (isFollowBack && !hasMultipleAuthors) { 339 /* 340 * Follow-backs are ungrouped, grouped follow-backs not supported atm, 341 * see `src/state/queries/notifications/util.ts` 342 */ 343 a11yLabel = _(msg`${firstAuthorName} followed you back`) 344 notificationContent = <Trans>{firstAuthorLink} followed you back</Trans> 345 } else { 346 a11yLabel = hasMultipleAuthors 347 ? _( 348 msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { 349 one: `${formattedAuthorsCount} other`, 350 other: `${formattedAuthorsCount} others`, 351 })} followed you`, 352 ) 353 : _(msg`${firstAuthorName} followed you`) 354 notificationContent = hasMultipleAuthors ? ( 355 <Trans> 356 {firstAuthorLink} and{' '} 357 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}> 358 <Plural 359 value={additionalAuthorsCount} 360 one={`${formattedAuthorsCount} other`} 361 other={`${formattedAuthorsCount} others`} 362 /> 363 </Text>{' '} 364 followed you 365 </Trans> 366 ) : ( 367 <Trans>{firstAuthorLink} followed you</Trans> 368 ) 369 } 370 icon = <PersonPlusIcon size="xl" style={{color: t.palette.primary_500}} /> 371 } else if (item.type === 'contact-match') { 372 a11yLabel = _(msg`Your contact ${firstAuthorName} is on Bluesky`) 373 notificationContent = ( 374 <Trans>Your contact {firstAuthorLink} is on Bluesky</Trans> 375 ) 376 icon = ( 377 <ContactsIconFilled size="xl" style={{color: t.palette.primary_500}} /> 378 ) 379 } else if (item.type === 'feedgen-like') { 380 a11yLabel = hasMultipleAuthors 381 ? _( 382 msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { 383 one: `${formattedAuthorsCount} other`, 384 other: `${formattedAuthorsCount} others`, 385 })} liked your custom feed`, 386 ) 387 : _(msg`${firstAuthorName} liked your custom feed`) 388 notificationContent = hasMultipleAuthors ? ( 389 <Trans> 390 {firstAuthorLink} and{' '} 391 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}> 392 <Plural 393 value={additionalAuthorsCount} 394 one={`${formattedAuthorsCount} other`} 395 other={`${formattedAuthorsCount} others`} 396 /> 397 </Text>{' '} 398 liked your custom feed 399 </Trans> 400 ) : ( 401 <Trans>{firstAuthorLink} liked your custom feed</Trans> 402 ) 403 } else if (item.type === 'starterpack-joined') { 404 a11yLabel = hasMultipleAuthors 405 ? _( 406 msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { 407 one: `${formattedAuthorsCount} other`, 408 other: `${formattedAuthorsCount} others`, 409 })} signed up with your starter pack`, 410 ) 411 : _(msg`${firstAuthorName} signed up with your starter pack`) 412 notificationContent = hasMultipleAuthors ? ( 413 <Trans> 414 {firstAuthorLink} and{' '} 415 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}> 416 <Plural 417 value={additionalAuthorsCount} 418 one={`${formattedAuthorsCount} other`} 419 other={`${formattedAuthorsCount} others`} 420 /> 421 </Text>{' '} 422 signed up with your starter pack 423 </Trans> 424 ) : ( 425 <Trans>{firstAuthorLink} signed up with your starter pack</Trans> 426 ) 427 icon = ( 428 <View style={{height: 30, width: 30}}> 429 <StarterPack width={30} gradient="sky" /> 430 </View> 431 ) 432 } else if (item.type === 'verified') { 433 a11yLabel = hasMultipleAuthors 434 ? _( 435 msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { 436 one: `${formattedAuthorsCount} other`, 437 other: `${formattedAuthorsCount} others`, 438 })} verified you`, 439 ) 440 : _(msg`${firstAuthorName} verified you`) 441 notificationContent = hasMultipleAuthors ? ( 442 <Trans> 443 {firstAuthorLink} and{' '} 444 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}> 445 <Plural 446 value={additionalAuthorsCount} 447 one={`${formattedAuthorsCount} other`} 448 other={`${formattedAuthorsCount} others`} 449 /> 450 </Text>{' '} 451 verified you 452 </Trans> 453 ) : ( 454 <Trans>{firstAuthorLink} verified you</Trans> 455 ) 456 icon = <VerifiedCheck size="xl" /> 457 } else if (item.type === 'unverified') { 458 a11yLabel = hasMultipleAuthors 459 ? _( 460 msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { 461 one: `${formattedAuthorsCount} other`, 462 other: `${formattedAuthorsCount} others`, 463 })} removed their verifications from your account`, 464 ) 465 : _(msg`${firstAuthorName} removed their verification from your account`) 466 notificationContent = hasMultipleAuthors ? ( 467 <Trans> 468 {firstAuthorLink} and{' '} 469 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}> 470 <Plural 471 value={additionalAuthorsCount} 472 one={`${formattedAuthorsCount} other`} 473 other={`${formattedAuthorsCount} others`} 474 /> 475 </Text>{' '} 476 removed their verifications from your account 477 </Trans> 478 ) : ( 479 <Trans> 480 {firstAuthorLink} removed their verification from your account 481 </Trans> 482 ) 483 icon = <VerifiedCheck size="xl" fill={t.palette.contrast_500} /> 484 } else if (item.type === 'like-via-repost') { 485 a11yLabel = hasMultipleAuthors 486 ? _( 487 msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { 488 one: `${formattedAuthorsCount} other`, 489 other: `${formattedAuthorsCount} others`, 490 })} liked your repost`, 491 ) 492 : _(msg`${firstAuthorName} liked your repost`) 493 notificationContent = hasMultipleAuthors ? ( 494 <Trans> 495 {firstAuthorLink} and{' '} 496 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}> 497 <Plural 498 value={additionalAuthorsCount} 499 one={`${formattedAuthorsCount} other`} 500 other={`${formattedAuthorsCount} others`} 501 /> 502 </Text>{' '} 503 liked your repost 504 </Trans> 505 ) : ( 506 <Trans>{firstAuthorLink} liked your repost</Trans> 507 ) 508 } else if (item.type === 'repost-via-repost') { 509 a11yLabel = hasMultipleAuthors 510 ? _( 511 msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { 512 one: `${formattedAuthorsCount} other`, 513 other: `${formattedAuthorsCount} others`, 514 })} reposted your repost`, 515 ) 516 : _(msg`${firstAuthorName} reposted your repost`) 517 notificationContent = hasMultipleAuthors ? ( 518 <Trans> 519 {firstAuthorLink} and{' '} 520 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}> 521 <Plural 522 value={additionalAuthorsCount} 523 one={`${formattedAuthorsCount} other`} 524 other={`${formattedAuthorsCount} others`} 525 /> 526 </Text>{' '} 527 reposted your repost 528 </Trans> 529 ) : ( 530 <Trans>{firstAuthorLink} reposted your repost</Trans> 531 ) 532 icon = <RepostIcon size="xl" style={{color: t.palette.positive_500}} /> 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: pal.colors.unreadNotifBg, 588 borderColor: pal.colors.unreadNotifBorder, 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.`), 'xmark') 780 } 781 } 782 } 783 784 const onPressUnfollow = async (e: GestureResponderEvent) => { 785 e.preventDefault() 786 e.stopPropagation() 787 788 try { 789 await queueUnfollow() 790 Toast.show( 791 _( 792 msg`No longer following ${sanitizeDisplayName( 793 profile.displayName || profile.handle, 794 )}`, 795 ), 796 ) 797 } catch (err: any) { 798 if (err?.name !== 'AbortError') { 799 Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') 800 } 801 } 802 } 803 804 // Don't show button if viewer data is missing or user is blocked 805 if (!profileShadow.viewer) { 806 return null 807 } 808 if ( 809 profileShadow.viewer.blockedBy || 810 profileShadow.viewer.blocking || 811 profileShadow.viewer.blockingByList 812 ) { 813 return null 814 } 815 816 const isFollowing = profileShadow.viewer.following 817 const isFollowedBy = profileShadow.viewer.followedBy 818 const followingLabel = _( 819 msg({ 820 message: 'Following', 821 comment: 'User is following this account, click to unfollow', 822 }), 823 ) 824 825 return ( 826 <View style={[a.pt_sm]}> 827 {isFollowing ? ( 828 <Button 829 label={followingLabel} 830 color="secondary" 831 size="small" 832 style={[a.self_start]} 833 onPress={onPressUnfollow}> 834 <ButtonIcon icon={CheckIcon} /> 835 <ButtonText> 836 <Trans>Following</Trans> 837 </ButtonText> 838 </Button> 839 ) : ( 840 <Button 841 label={isFollowedBy ? _(msg`Follow back`) : _(msg`Follow`)} 842 color="primary" 843 size="small" 844 style={[a.self_start]} 845 onPress={onPressFollow}> 846 <ButtonIcon icon={PlusIcon} /> 847 <ButtonText> 848 {isFollowedBy ? <Trans>Follow back</Trans> : <Trans>Follow</Trans>} 849 </ButtonText> 850 </Button> 851 )} 852 </View> 853 ) 854} 855 856function SayHelloBtn({profile}: {profile: AppBskyActorDefs.ProfileView}) { 857 const {_} = useLingui() 858 const agent = useAgent() 859 const navigation = useNavigation<NavigationProp>() 860 const [isLoading, setIsLoading] = useState(false) 861 862 if ( 863 profile.associated?.chat?.allowIncoming === 'none' || 864 (profile.associated?.chat?.allowIncoming === 'following' && 865 !profile.viewer?.followedBy) 866 ) { 867 return null 868 } 869 870 return ( 871 <Button 872 label={_(msg`Say hello!`)} 873 variant="ghost" 874 color="primary" 875 size="small" 876 style={[a.self_center, {marginLeft: 'auto'}]} 877 disabled={isLoading} 878 onPress={async () => { 879 try { 880 setIsLoading(true) 881 const res = await agent.api.chat.bsky.convo.getConvoForMembers( 882 { 883 members: [profile.did, agent.session!.did!], 884 }, 885 {headers: DM_SERVICE_HEADERS}, 886 ) 887 navigation.navigate('MessagesConversation', { 888 conversation: res.data.convo.id, 889 }) 890 } catch (e) { 891 logger.error('Failed to get conversation', {safeMessage: e}) 892 } finally { 893 setIsLoading(false) 894 } 895 }}> 896 <ButtonText> 897 <Trans>Say hello!</Trans> 898 </ButtonText> 899 </Button> 900 ) 901} 902 903function CondensedAuthorsList({ 904 visible, 905 authors, 906 onToggleAuthorsExpanded, 907 showDmButton = true, 908}: { 909 visible: boolean 910 authors: Author[] 911 onToggleAuthorsExpanded: (e: GestureResponderEvent) => void 912 showDmButton?: boolean 913}) { 914 const t = useTheme() 915 const {_} = useLingui() 916 917 if (!visible) { 918 return ( 919 <View style={[a.flex_row, a.align_center]}> 920 <TouchableOpacity 921 style={styles.expandedAuthorsCloseBtn} 922 onPress={onToggleAuthorsExpanded} 923 accessibilityRole="button" 924 accessibilityLabel={_(msg`Hide user list`)} 925 accessibilityHint={_( 926 msg`Collapses list of users for a given notification`, 927 )}> 928 <ChevronUpIcon 929 size="md" 930 style={[a.ml_xs, a.mr_md, t.atoms.text_contrast_high]} 931 /> 932 <Text style={[a.text_md, t.atoms.text_contrast_high]}> 933 <Trans context="action">Hide</Trans> 934 </Text> 935 </TouchableOpacity> 936 </View> 937 ) 938 } 939 if (authors.length === 1) { 940 return ( 941 <View style={[a.flex_row, a.align_center]}> 942 <PreviewableUserAvatar 943 size={35} 944 profile={authors[0].profile} 945 moderation={authors[0].moderation.ui('avatar')} 946 type={authors[0].profile.associated?.labeler ? 'labeler' : 'user'} 947 /> 948 {showDmButton ? <SayHelloBtn profile={authors[0].profile} /> : null} 949 </View> 950 ) 951 } 952 return ( 953 <TouchableOpacity 954 accessibilityRole="none" 955 onPress={onToggleAuthorsExpanded}> 956 <View style={[a.flex_row, a.align_center]}> 957 {authors.slice(0, MAX_AUTHORS).map(author => ( 958 <View key={author.href} style={s.mr5}> 959 <PreviewableUserAvatar 960 size={35} 961 profile={author.profile} 962 moderation={author.moderation.ui('avatar')} 963 type={author.profile.associated?.labeler ? 'labeler' : 'user'} 964 /> 965 </View> 966 ))} 967 {authors.length > MAX_AUTHORS ? ( 968 <Text 969 style={[ 970 a.font_semi_bold, 971 {paddingLeft: 6}, 972 t.atoms.text_contrast_medium, 973 ]}> 974 +{authors.length - MAX_AUTHORS} 975 </Text> 976 ) : undefined} 977 <ChevronDownIcon 978 size="md" 979 style={[a.mx_xs, t.atoms.text_contrast_medium]} 980 /> 981 </View> 982 </TouchableOpacity> 983 ) 984} 985 986function ExpandedAuthorsList({ 987 visible, 988 authors, 989}: { 990 visible: boolean 991 authors: Author[] 992}) { 993 const heightInterp = useAnimatedValue(visible ? 1 : 0) 994 const targetHeight = 995 authors.length * (EXPANDED_AUTHOR_EL_HEIGHT + 10) /*10=margin*/ 996 const heightStyle = { 997 height: Animated.multiply(heightInterp, targetHeight), 998 } 999 useEffect(() => { 1000 Animated.timing(heightInterp, { 1001 toValue: visible ? 1 : 0, 1002 duration: 200, 1003 useNativeDriver: false, 1004 }).start() 1005 }, [heightInterp, visible]) 1006 1007 return ( 1008 <Animated.View style={[a.overflow_hidden, heightStyle]}> 1009 {visible && 1010 authors.map(author => ( 1011 <ExpandedAuthorCard key={author.profile.did} author={author} /> 1012 ))} 1013 </Animated.View> 1014 ) 1015} 1016 1017function ExpandedAuthorCard({author}: {author: Author}) { 1018 const t = useTheme() 1019 const {_} = useLingui() 1020 const verification = useSimpleVerificationState({ 1021 profile: author.profile, 1022 }) 1023 return ( 1024 <Link 1025 key={author.profile.did} 1026 label={author.profile.displayName || author.profile.handle} 1027 accessibilityHint={_(msg`Opens this profile`)} 1028 to={makeProfileLink({ 1029 did: author.profile.did, 1030 handle: author.profile.handle, 1031 })} 1032 style={styles.expandedAuthor}> 1033 <View style={[a.mr_sm]}> 1034 <ProfileHoverCard did={author.profile.did}> 1035 <UserAvatar 1036 size={35} 1037 avatar={author.profile.avatar} 1038 moderation={author.moderation.ui('avatar')} 1039 type={author.profile.associated?.labeler ? 'labeler' : 'user'} 1040 /> 1041 </ProfileHoverCard> 1042 </View> 1043 <View style={[a.flex_1]}> 1044 <View style={[a.flex_row, a.align_end]}> 1045 <Text 1046 numberOfLines={1} 1047 emoji 1048 style={[ 1049 a.text_md, 1050 a.font_semi_bold, 1051 a.leading_tight, 1052 {maxWidth: '70%'}, 1053 ]}> 1054 {sanitizeDisplayName( 1055 author.profile.displayName || author.profile.handle, 1056 )} 1057 </Text> 1058 {verification.showBadge && ( 1059 <View style={[a.pl_xs, a.self_center]}> 1060 <VerificationCheck 1061 width={14} 1062 verifier={verification.role === 'verifier'} 1063 /> 1064 </View> 1065 )} 1066 <Text 1067 numberOfLines={1} 1068 style={[ 1069 a.pl_xs, 1070 a.text_md, 1071 a.leading_tight, 1072 a.flex_shrink, 1073 t.atoms.text_contrast_medium, 1074 ]}> 1075 {sanitizeHandle(author.profile.handle, '@')} 1076 </Text> 1077 </View> 1078 </View> 1079 </Link> 1080 ) 1081} 1082 1083function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) { 1084 const t = useTheme() 1085 if ( 1086 post && 1087 bsky.dangerousIsType<AppBskyFeedPost.Record>( 1088 post?.record, 1089 AppBskyFeedPost.isRecord, 1090 ) 1091 ) { 1092 const text = post.record.text 1093 1094 return ( 1095 <> 1096 {text?.length > 0 && ( 1097 <Text 1098 emoji 1099 style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]} 1100 numberOfLines={MAX_POST_LINES}> 1101 {text} 1102 </Text> 1103 )} 1104 <MediaPreview.Embed 1105 embed={post.embed} 1106 style={styles.additionalPostImages} 1107 /> 1108 </> 1109 ) 1110 } 1111} 1112 1113const styles = StyleSheet.create({ 1114 layoutIcon: { 1115 width: 60, 1116 alignItems: 'flex-end', 1117 paddingTop: 2, 1118 }, 1119 icon: { 1120 marginRight: 10, 1121 marginTop: 4, 1122 }, 1123 additionalPostImages: { 1124 marginTop: 5, 1125 marginLeft: 2, 1126 opacity: 0.8, 1127 }, 1128 feedcard: { 1129 borderRadius: 8, 1130 marginTop: 6, 1131 }, 1132 addedContainer: { 1133 paddingTop: 4, 1134 paddingLeft: 36, 1135 }, 1136 expandedAuthorsTrigger: { 1137 zIndex: 1, 1138 }, 1139 expandedAuthorsCloseBtn: { 1140 flexDirection: 'row', 1141 alignItems: 'center', 1142 paddingTop: 10, 1143 paddingBottom: 6, 1144 }, 1145 expandedAuthor: { 1146 flexDirection: 'row', 1147 alignItems: 'center', 1148 marginTop: 10, 1149 height: EXPANDED_AUTHOR_EL_HEIGHT, 1150 }, 1151})