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