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