forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 💫
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 ·{' '}
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})