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, 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 ·{' '}
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})