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 {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 {LikeRepost_Stroke2_Corner2_Rounded as RepostHeartIcon} 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 {Repost_Stroke2_Corner3_Rounded as RepostIcon} from '#/components/icons/Repost'
63import {RepostRepost_Stroke2_Corner2_Rounded as RepostRepostIcon} from '#/components/icons/Repost'
64import {StarterPack} from '#/components/icons/StarterPack'
65import {VerifiedCheck} from '#/components/icons/VerifiedCheck'
66import {InlineLinkText, Link} from '#/components/Link'
67import * as MediaPreview from '#/components/MediaPreview'
68import {PdsBadge} from '#/components/PdsBadge'
69import {ProfileHoverCard} from '#/components/ProfileHoverCard'
70import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
71import {SubtleHover} from '#/components/SubtleHover'
72import {Text} from '#/components/Typography'
73import {useSimpleVerificationState} from '#/components/verification'
74import {VerificationCheck} from '#/components/verification/VerificationCheck'
75import * as bsky from '#/types/bsky'
76
77const MAX_AUTHORS = 5
78
79const EXPANDED_AUTHOR_EL_HEIGHT = 35
80
81interface Author {
82 profile: AppBskyActorDefs.ProfileView
83 href: string
84 moderation: ModerationDecision
85}
86
87let NotificationFeedItem = ({
88 item,
89 moderationOpts,
90 highlightUnread,
91 hideTopBorder,
92}: {
93 item: FeedNotification
94 moderationOpts: ModerationOpts
95 highlightUnread: boolean
96 hideTopBorder?: boolean
97}): React.ReactNode => {
98 const queryClient = useQueryClient()
99 const pal = usePalette('default')
100 const t = useTheme()
101 const {_, i18n} = useLingui()
102 const [isAuthorsExpanded, setAuthorsExpanded] = 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 setAuthorsExpanded(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 firstAuthorVerification = useSimpleVerificationState({
180 profile: firstAuthor.profile,
181 })
182 const firstAuthorName = sanitizeDisplayName(
183 firstAuthor.profile.displayName || firstAuthor.profile.handle,
184 )
185
186 // Calculate if this is a follow-back notification
187 const isFollowBack = useMemo(() => {
188 if (item.type !== 'follow') return false
189 if (
190 item.notification.author.viewer?.following &&
191 bsky.dangerousIsType<AppBskyGraphFollow.Record>(
192 item.notification.record,
193 AppBskyGraphFollow.isRecord,
194 )
195 ) {
196 let followingTimestamp
197 try {
198 const rkey = new AtUri(item.notification.author.viewer.following).rkey
199 followingTimestamp = TID.fromStr(rkey).timestamp()
200 } catch (e) {
201 return false
202 }
203 if (followingTimestamp) {
204 const followedTimestamp =
205 new Date(item.notification.record.createdAt).getTime() * 1000
206 return followedTimestamp > followingTimestamp
207 }
208 }
209 return false
210 }, [item])
211
212 if (item.subjectUri && !item.subject && item.type !== 'feedgen-like') {
213 // don't render anything if the target post was deleted or unfindable
214 return <View />
215 }
216
217 if (
218 item.type === 'reply' ||
219 item.type === 'mention' ||
220 item.type === 'quote'
221 ) {
222 if (!item.subject) {
223 return null
224 }
225 const isHighlighted = highlightUnread && !item.notification.isRead
226 return (
227 <View testID={`feedItem-by-${item.notification.author.handle}`}>
228 <Post
229 post={item.subject}
230 style={
231 isHighlighted && {
232 backgroundColor: pal.colors.unreadNotifBg,
233 borderColor: pal.colors.unreadNotifBorder,
234 }
235 }
236 hideTopBorder={hideTopBorder}
237 />
238 </View>
239 )
240 }
241
242 const firstAuthorLink = (
243 <ProfileHoverCard did={firstAuthor.profile.did} inline>
244 <InlineLinkText
245 key={firstAuthor.href}
246 style={[t.atoms.text, a.font_semi_bold, a.text_md, a.leading_tight]}
247 to={firstAuthor.href}
248 disableMismatchWarning
249 emoji
250 label={_(msg`Go to ${firstAuthorName}'s profile`)}>
251 {forceLTR(firstAuthorName)}
252 {firstAuthorVerification.showBadge && (
253 <View
254 style={[
255 a.relative,
256 {
257 paddingTop: platform({android: 2}),
258 marginBottom: platform({ios: -7}),
259 top: platform({web: 1}),
260 paddingLeft: 3,
261 paddingRight: 2,
262 },
263 ]}>
264 <VerificationCheck
265 width={14}
266 verifier={firstAuthorVerification.role === 'verifier'}
267 />
268 </View>
269 )}
270 </InlineLinkText>
271 </ProfileHoverCard>
272 )
273 const additionalAuthorsCount = authors.length - 1
274 const hasMultipleAuthors = additionalAuthorsCount > 0
275 const formattedAuthorsCount = hasMultipleAuthors
276 ? formatCount(i18n, additionalAuthorsCount)
277 : ''
278
279 let a11yLabel = ''
280 let notificationContent: React.ReactElement<any>
281 let icon = (
282 <HeartIconFilled
283 size="xl"
284 style={[
285 s.likeColor,
286 // {position: 'relative', top: -4}
287 ]}
288 />
289 )
290
291 if (item.type === 'post-like') {
292 a11yLabel = hasMultipleAuthors
293 ? _(
294 msg`${firstAuthorName} and ${plural(additionalAuthorsCount, {
295 one: `${formattedAuthorsCount} other`,
296 other: `${formattedAuthorsCount} others`,
297 })} liked your post`,
298 )
299 : _(msg`${firstAuthorName} liked your post`)
300 notificationContent = hasMultipleAuthors ? (
301 <Trans>
302 {firstAuthorLink} and{' '}
303 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
304 <Plural
305 value={additionalAuthorsCount}
306 one={`${formattedAuthorsCount} other`}
307 other={`${formattedAuthorsCount} others`}
308 />
309 </Text>{' '}
310 liked your post
311 </Trans>
312 ) : (
313 <Trans>{firstAuthorLink} liked your post</Trans>
314 )
315 } else if (item.type === 'repost') {
316 a11yLabel = hasMultipleAuthors
317 ? _(
318 msg`${firstAuthorName} and ${plural(additionalAuthorsCount, {
319 one: `${formattedAuthorsCount} other`,
320 other: `${formattedAuthorsCount} others`,
321 })} reposted your post`,
322 )
323 : _(msg`${firstAuthorName} reposted your post`)
324 notificationContent = hasMultipleAuthors ? (
325 <Trans>
326 {firstAuthorLink} and{' '}
327 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
328 <Plural
329 value={additionalAuthorsCount}
330 one={`${formattedAuthorsCount} other`}
331 other={`${formattedAuthorsCount} others`}
332 />
333 </Text>{' '}
334 reposted your post
335 </Trans>
336 ) : (
337 <Trans>{firstAuthorLink} reposted your post</Trans>
338 )
339 icon = <RepostIcon size="xl" style={{color: t.palette.positive_500}} />
340 } else if (item.type === 'follow') {
341 if (isFollowBack && !hasMultipleAuthors) {
342 /*
343 * Follow-backs are ungrouped, grouped follow-backs not supported atm,
344 * see `src/state/queries/notifications/util.ts`
345 */
346 a11yLabel = _(msg`${firstAuthorName} followed you back`)
347 notificationContent = <Trans>{firstAuthorLink} followed you back</Trans>
348 } else {
349 a11yLabel = hasMultipleAuthors
350 ? _(
351 msg`${firstAuthorName} and ${plural(additionalAuthorsCount, {
352 one: `${formattedAuthorsCount} other`,
353 other: `${formattedAuthorsCount} others`,
354 })} followed you`,
355 )
356 : _(msg`${firstAuthorName} followed you`)
357 notificationContent = hasMultipleAuthors ? (
358 <Trans>
359 {firstAuthorLink} and{' '}
360 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
361 <Plural
362 value={additionalAuthorsCount}
363 one={`${formattedAuthorsCount} other`}
364 other={`${formattedAuthorsCount} others`}
365 />
366 </Text>{' '}
367 followed you
368 </Trans>
369 ) : (
370 <Trans>{firstAuthorLink} followed you</Trans>
371 )
372 }
373 icon = <PersonPlusIcon size="xl" style={{color: t.palette.primary_500}} />
374 } else if (item.type === 'contact-match') {
375 a11yLabel = _(msg`Your contact ${firstAuthorName} is on Bluesky`)
376 notificationContent = (
377 <Trans>Your contact {firstAuthorLink} is on Bluesky</Trans>
378 )
379 icon = (
380 <ContactsIconFilled size="xl" style={{color: t.palette.primary_500}} />
381 )
382 } else if (item.type === 'feedgen-like') {
383 a11yLabel = hasMultipleAuthors
384 ? _(
385 msg`${firstAuthorName} and ${plural(additionalAuthorsCount, {
386 one: `${formattedAuthorsCount} other`,
387 other: `${formattedAuthorsCount} others`,
388 })} liked your custom feed`,
389 )
390 : _(msg`${firstAuthorName} liked your custom feed`)
391 notificationContent = hasMultipleAuthors ? (
392 <Trans>
393 {firstAuthorLink} and{' '}
394 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
395 <Plural
396 value={additionalAuthorsCount}
397 one={`${formattedAuthorsCount} other`}
398 other={`${formattedAuthorsCount} others`}
399 />
400 </Text>{' '}
401 liked your custom feed
402 </Trans>
403 ) : (
404 <Trans>{firstAuthorLink} liked your custom feed</Trans>
405 )
406 } else if (item.type === 'starterpack-joined') {
407 a11yLabel = hasMultipleAuthors
408 ? _(
409 msg`${firstAuthorName} and ${plural(additionalAuthorsCount, {
410 one: `${formattedAuthorsCount} other`,
411 other: `${formattedAuthorsCount} others`,
412 })} signed up with your starter pack`,
413 )
414 : _(msg`${firstAuthorName} signed up with your starter pack`)
415 notificationContent = hasMultipleAuthors ? (
416 <Trans>
417 {firstAuthorLink} and{' '}
418 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
419 <Plural
420 value={additionalAuthorsCount}
421 one={`${formattedAuthorsCount} other`}
422 other={`${formattedAuthorsCount} others`}
423 />
424 </Text>{' '}
425 signed up with your starter pack
426 </Trans>
427 ) : (
428 <Trans>{firstAuthorLink} signed up with your starter pack</Trans>
429 )
430 icon = (
431 <View style={{height: 30, width: 30}}>
432 <StarterPack width={30} gradient="sky" />
433 </View>
434 )
435 } else if (item.type === 'verified') {
436 a11yLabel = hasMultipleAuthors
437 ? _(
438 msg`${firstAuthorName} and ${plural(additionalAuthorsCount, {
439 one: `${formattedAuthorsCount} other`,
440 other: `${formattedAuthorsCount} others`,
441 })} verified you`,
442 )
443 : _(msg`${firstAuthorName} verified you`)
444 notificationContent = hasMultipleAuthors ? (
445 <Trans>
446 {firstAuthorLink} and{' '}
447 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
448 <Plural
449 value={additionalAuthorsCount}
450 one={`${formattedAuthorsCount} other`}
451 other={`${formattedAuthorsCount} others`}
452 />
453 </Text>{' '}
454 verified you
455 </Trans>
456 ) : (
457 <Trans>{firstAuthorLink} verified you</Trans>
458 )
459 icon = <VerifiedCheck size="xl" />
460 } else if (item.type === 'unverified') {
461 a11yLabel = hasMultipleAuthors
462 ? _(
463 msg`${firstAuthorName} and ${plural(additionalAuthorsCount, {
464 one: `${formattedAuthorsCount} other`,
465 other: `${formattedAuthorsCount} others`,
466 })} removed their verifications from your account`,
467 )
468 : _(msg`${firstAuthorName} removed their verification from your account`)
469 notificationContent = hasMultipleAuthors ? (
470 <Trans>
471 {firstAuthorLink} and{' '}
472 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
473 <Plural
474 value={additionalAuthorsCount}
475 one={`${formattedAuthorsCount} other`}
476 other={`${formattedAuthorsCount} others`}
477 />
478 </Text>{' '}
479 removed their verifications from your account
480 </Trans>
481 ) : (
482 <Trans>
483 {firstAuthorLink} removed their verification from your account
484 </Trans>
485 )
486 icon = <VerifiedCheck size="xl" fill={t.palette.contrast_500} />
487 } else if (item.type === 'like-via-repost') {
488 a11yLabel = hasMultipleAuthors
489 ? _(
490 msg`${firstAuthorName} and ${plural(additionalAuthorsCount, {
491 one: `${formattedAuthorsCount} other`,
492 other: `${formattedAuthorsCount} others`,
493 })} liked your repost`,
494 )
495 : _(msg`${firstAuthorName} liked your repost`)
496 notificationContent = hasMultipleAuthors ? (
497 <Trans>
498 {firstAuthorLink} and{' '}
499 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
500 <Plural
501 value={additionalAuthorsCount}
502 one={`${formattedAuthorsCount} other`}
503 other={`${formattedAuthorsCount} others`}
504 />
505 </Text>{' '}
506 liked your repost
507 </Trans>
508 ) : (
509 <Trans>{firstAuthorLink} liked your repost</Trans>
510 )
511 icon = <RepostHeartIcon size="xl" style={[s.likeColor]} />
512 } else if (item.type === 'repost-via-repost') {
513 a11yLabel = hasMultipleAuthors
514 ? _(
515 msg`${firstAuthorName} and ${plural(additionalAuthorsCount, {
516 one: `${formattedAuthorsCount} other`,
517 other: `${formattedAuthorsCount} others`,
518 })} reposted your repost`,
519 )
520 : _(msg`${firstAuthorName} reposted your repost`)
521 notificationContent = hasMultipleAuthors ? (
522 <Trans>
523 {firstAuthorLink} and{' '}
524 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
525 <Plural
526 value={additionalAuthorsCount}
527 one={`${formattedAuthorsCount} other`}
528 other={`${formattedAuthorsCount} others`}
529 />
530 </Text>{' '}
531 reposted your repost
532 </Trans>
533 ) : (
534 <Trans>{firstAuthorLink} reposted your repost</Trans>
535 )
536 icon = (
537 <RepostRepostIcon size="xl" style={{color: t.palette.positive_500}} />
538 )
539 } else if (item.type === 'subscribed-post') {
540 const postsCount = 1 + (item.additional?.length || 0)
541 a11yLabel = hasMultipleAuthors
542 ? _(
543 msg`New posts from ${firstAuthorName} and ${plural(
544 additionalAuthorsCount,
545 {
546 one: `${formattedAuthorsCount} other`,
547 other: `${formattedAuthorsCount} others`,
548 },
549 )}`,
550 )
551 : _(
552 msg`New ${plural(postsCount, {
553 one: 'post',
554 other: 'posts',
555 })} from ${firstAuthorName}`,
556 )
557 notificationContent = hasMultipleAuthors ? (
558 <Trans>
559 New posts from {firstAuthorLink} and{' '}
560 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
561 <Plural
562 value={additionalAuthorsCount}
563 one={`${formattedAuthorsCount} other`}
564 other={`${formattedAuthorsCount} others`}
565 />
566 </Text>{' '}
567 </Trans>
568 ) : (
569 <Trans>
570 New <Plural value={postsCount} one="post" other="posts" /> from{' '}
571 {firstAuthorLink}
572 </Trans>
573 )
574 icon = <BellRingingIcon size="xl" style={{color: t.palette.primary_500}} />
575 } else {
576 return null
577 }
578 a11yLabel += ` 路 ${niceTimestamp}`
579
580 return (
581 <Link
582 label={a11yLabel}
583 testID={`feedItem-by-${item.notification.author.handle}`}
584 style={[
585 a.flex_row,
586 a.align_start,
587 {padding: 10},
588 a.pr_lg,
589 t.atoms.border_contrast_low,
590 item.notification.isRead
591 ? undefined
592 : {
593 backgroundColor: pal.colors.unreadNotifBg,
594 borderColor: pal.colors.unreadNotifBorder,
595 },
596 !hideTopBorder && a.border_t,
597 a.overflow_hidden,
598 ]}
599 to={itemHref}
600 accessible={!isAuthorsExpanded}
601 accessibilityActions={
602 hasMultipleAuthors
603 ? [
604 {
605 name: 'toggleAuthorsExpanded',
606 label: isAuthorsExpanded
607 ? _(msg`Collapse list of users`)
608 : _(msg`Expand list of users`),
609 },
610 ]
611 : [
612 {
613 name: 'viewProfile',
614 label: _(
615 msg`View ${
616 authors[0].profile.displayName || authors[0].profile.handle
617 }'s profile`,
618 ),
619 },
620 ]
621 }
622 onAccessibilityAction={e => {
623 if (e.nativeEvent.actionName === 'activate') {
624 onBeforePress()
625 }
626 if (e.nativeEvent.actionName === 'toggleAuthorsExpanded') {
627 onToggleAuthorsExpanded()
628 }
629 }}>
630 {({hovered}) => (
631 <>
632 <SubtleHover hover={hovered} />
633 <View style={[styles.layoutIcon, a.pr_sm]}>
634 {/* TODO: Prevent conditional rendering and move toward composable
635 notifications for clearer accessibility labeling */}
636 {icon}
637 </View>
638 <View style={[a.flex_1]}>
639 <ExpandListPressable
640 hasMultipleAuthors={hasMultipleAuthors}
641 onToggleAuthorsExpanded={onToggleAuthorsExpanded}>
642 <CondensedAuthorsList
643 visible={!isAuthorsExpanded}
644 authors={authors}
645 onToggleAuthorsExpanded={onToggleAuthorsExpanded}
646 showDmButton={item.type === 'starterpack-joined'}
647 />
648 <ExpandedAuthorsList
649 visible={isAuthorsExpanded}
650 authors={authors}
651 />
652 <Text
653 style={[
654 a.flex_row,
655 a.flex_wrap,
656 {paddingTop: 6},
657 a.self_start,
658 a.text_md,
659 a.leading_snug,
660 ]}
661 accessibilityHint=""
662 accessibilityLabel={a11yLabel}>
663 {notificationContent}
664 <TimeElapsed timestamp={item.notification.indexedAt}>
665 {({timeElapsed}) => (
666 <>
667 {/* make sure there's whitespace around the middot -sfn */}
668 <Text style={[a.text_md, t.atoms.text_contrast_medium]}>
669 {' '}
670 ·{' '}
671 </Text>
672 <Text
673 style={[a.text_md, t.atoms.text_contrast_medium]}
674 title={niceTimestamp}>
675 {timeElapsed}
676 </Text>
677 </>
678 )}
679 </TimeElapsed>
680 </Text>
681 </ExpandListPressable>
682 {(item.type === 'follow' && !hasMultipleAuthors && !isFollowBack) ||
683 (item.type === 'contact-match' &&
684 !item.notification.author.viewer?.following) ? (
685 <FollowBackButton profile={item.notification.author} />
686 ) : null}
687 {item.type === 'post-like' ||
688 item.type === 'repost' ||
689 item.type === 'like-via-repost' ||
690 item.type === 'repost-via-repost' ||
691 item.type === 'subscribed-post' ? (
692 <View style={[a.pt_2xs]}>
693 <AdditionalPostText post={item.subject} />
694 </View>
695 ) : null}
696 {item.type === 'feedgen-like' && item.subjectUri ? (
697 <FeedSourceCard
698 feedUri={item.subjectUri}
699 link={false}
700 style={[
701 t.atoms.bg,
702 t.atoms.border_contrast_low,
703 a.border,
704 a.p_md,
705 styles.feedcard,
706 ]}
707 showLikes
708 />
709 ) : null}
710 {item.type === 'starterpack-joined' ? (
711 <View>
712 <View
713 style={[
714 a.border,
715 a.p_sm,
716 a.rounded_sm,
717 a.mt_sm,
718 t.atoms.border_contrast_low,
719 ]}>
720 <StarterPackCard starterPack={item.subject} />
721 </View>
722 </View>
723 ) : null}
724 </View>
725 </>
726 )}
727 </Link>
728 )
729}
730NotificationFeedItem = memo(NotificationFeedItem)
731export {NotificationFeedItem}
732
733function ExpandListPressable({
734 hasMultipleAuthors,
735 children,
736 onToggleAuthorsExpanded,
737}: {
738 hasMultipleAuthors: boolean
739 children: React.ReactNode
740 onToggleAuthorsExpanded: (e: GestureResponderEvent) => void
741}) {
742 if (hasMultipleAuthors) {
743 return (
744 <Pressable
745 onPress={onToggleAuthorsExpanded}
746 style={[styles.expandedAuthorsTrigger]}
747 accessible={false}>
748 {children}
749 </Pressable>
750 )
751 } else {
752 return <>{children}</>
753 }
754}
755
756function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) {
757 const {_} = useLingui()
758 const {currentAccount, hasSession} = useSession()
759 const profileShadow = useProfileShadow(profile)
760 const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
761 profileShadow,
762 'ProfileCard',
763 )
764
765 // Don't show button if not logged in or for own profile
766 if (!hasSession || profile.did === currentAccount?.did) {
767 return null
768 }
769
770 const onPressFollow = async (e: GestureResponderEvent) => {
771 e.preventDefault()
772 e.stopPropagation()
773
774 try {
775 await queueFollow()
776 Toast.show(
777 _(
778 msg`Following ${sanitizeDisplayName(
779 profile.displayName || profile.handle,
780 )}`,
781 ),
782 )
783 } catch (err: any) {
784 if (err?.name !== 'AbortError') {
785 Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
786 }
787 }
788 }
789
790 const onPressUnfollow = async (e: GestureResponderEvent) => {
791 e.preventDefault()
792 e.stopPropagation()
793
794 try {
795 await queueUnfollow()
796 Toast.show(
797 _(
798 msg`No longer following ${sanitizeDisplayName(
799 profile.displayName || profile.handle,
800 )}`,
801 ),
802 )
803 } catch (err: any) {
804 if (err?.name !== 'AbortError') {
805 Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
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 const verification = useSimpleVerificationState({
1027 profile: author.profile,
1028 })
1029 return (
1030 <Link
1031 key={author.profile.did}
1032 label={author.profile.displayName || author.profile.handle}
1033 accessibilityHint={_(msg`Opens this profile`)}
1034 to={makeProfileLink({
1035 did: author.profile.did,
1036 handle: author.profile.handle,
1037 })}
1038 style={styles.expandedAuthor}>
1039 <View style={[a.mr_sm]}>
1040 <ProfileHoverCard did={author.profile.did}>
1041 <UserAvatar
1042 size={35}
1043 avatar={author.profile.avatar}
1044 moderation={author.moderation.ui('avatar')}
1045 type={author.profile.associated?.labeler ? 'labeler' : 'user'}
1046 />
1047 </ProfileHoverCard>
1048 </View>
1049 <View style={[a.flex_1]}>
1050 <View style={[a.flex_row, a.align_end]}>
1051 <Text
1052 numberOfLines={1}
1053 emoji
1054 style={[
1055 a.text_md,
1056 a.font_semi_bold,
1057 a.leading_tight,
1058 {maxWidth: '70%'},
1059 ]}>
1060 {sanitizeDisplayName(
1061 author.profile.displayName || author.profile.handle,
1062 )}
1063 </Text>
1064 <View style={[a.pl_xs, a.self_center]}>
1065 <PdsBadge did={author.profile.did} size="sm" />
1066 </View>
1067 {verification.showBadge && (
1068 <View style={[a.pl_xs, a.self_center]}>
1069 <VerificationCheck
1070 width={14}
1071 verifier={verification.role === 'verifier'}
1072 />
1073 </View>
1074 )}
1075 <Text
1076 numberOfLines={1}
1077 style={[
1078 a.pl_xs,
1079 a.text_md,
1080 a.leading_tight,
1081 a.flex_shrink,
1082 t.atoms.text_contrast_medium,
1083 ]}>
1084 {sanitizeHandle(author.profile.handle, '@')}
1085 </Text>
1086 </View>
1087 </View>
1088 </Link>
1089 )
1090}
1091
1092function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
1093 const t = useTheme()
1094 if (
1095 post &&
1096 bsky.dangerousIsType<AppBskyFeedPost.Record>(
1097 post?.record,
1098 AppBskyFeedPost.isRecord,
1099 )
1100 ) {
1101 const text = post.record.text
1102
1103 return (
1104 <>
1105 {text?.length > 0 && (
1106 <Text
1107 emoji
1108 style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}
1109 numberOfLines={MAX_POST_LINES}>
1110 {text}
1111 </Text>
1112 )}
1113 <MediaPreview.Embed
1114 embed={post.embed}
1115 style={styles.additionalPostImages}
1116 />
1117 </>
1118 )
1119 }
1120}
1121
1122const styles = StyleSheet.create({
1123 layoutIcon: {
1124 width: 60,
1125 alignItems: 'flex-end',
1126 paddingTop: 2,
1127 },
1128 icon: {
1129 marginRight: 10,
1130 marginTop: 4,
1131 },
1132 additionalPostImages: {
1133 marginTop: 5,
1134 marginLeft: 2,
1135 opacity: 0.8,
1136 },
1137 feedcard: {
1138 borderRadius: 8,
1139 marginTop: 6,
1140 },
1141 addedContainer: {
1142 paddingTop: 4,
1143 paddingLeft: 36,
1144 },
1145 expandedAuthorsTrigger: {
1146 zIndex: 1,
1147 },
1148 expandedAuthorsCloseBtn: {
1149 flexDirection: 'row',
1150 alignItems: 'center',
1151 paddingTop: 10,
1152 paddingBottom: 6,
1153 },
1154 expandedAuthor: {
1155 flexDirection: 'row',
1156 alignItems: 'center',
1157 marginTop: 10,
1158 height: EXPANDED_AUTHOR_EL_HEIGHT,
1159 },
1160})