Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
120
fork

Configure Feed

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

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