import { memo, type ReactElement, useCallback, useEffect, useMemo, useState, } from 'react' import { Animated, type GestureResponderEvent, Pressable, StyleSheet, TouchableOpacity, View, } from 'react-native' import { type AppBskyActorDefs, type AppBskyFeedDefs, AppBskyFeedPost, AppBskyGraphFollow, moderateProfile, type ModerationDecision, type ModerationOpts, } from '@atproto/api' import {AtUri} from '@atproto/api' import {TID} from '@atproto/common-web' import {msg, Plural, plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' import {useAnimatedValue} from '#/lib/hooks/useAnimatedValue' import {usePalette} from '#/lib/hooks/usePalette' import {makeProfileLink} from '#/lib/routes/links' import {type NavigationProp} from '#/lib/routes/types' import {forceLTR} from '#/lib/strings/bidi' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {niceDate} from '#/lib/strings/time' import {s} from '#/lib/styles' import {logger} from '#/logger' import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' import {type FeedNotification} from '#/state/queries/notifications/feed' import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' import {useAgent} from '#/state/session' import {FeedSourceCard} from '#/view/com/feeds/FeedSourceCard' import {Post} from '#/view/com/post/Post' import {formatCount} from '#/view/com/util/numeric/format' import {TimeElapsed} from '#/view/com/util/TimeElapsed' import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, platform, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import { ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon, ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon, } from '#/components/icons/Chevron' import {Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled} from '#/components/icons/Heart2' import {PersonPlus_Filled_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' import {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost' import {StarterPack} from '#/components/icons/StarterPack' import {VerifiedCheck} from '#/components/icons/VerifiedCheck' import {InlineLinkText, Link} from '#/components/Link' import * as MediaPreview from '#/components/MediaPreview' import {ProfileHoverCard} from '#/components/ProfileHoverCard' import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard' import {SubtleWebHover} from '#/components/SubtleWebHover' import {Text} from '#/components/Typography' import {useSimpleVerificationState} from '#/components/verification' import {VerificationCheck} from '#/components/verification/VerificationCheck' import * as bsky from '#/types/bsky' const MAX_AUTHORS = 5 const EXPANDED_AUTHOR_EL_HEIGHT = 35 interface Author { profile: AppBskyActorDefs.ProfileView href: string moderation: ModerationDecision } let NotificationFeedItem = ({ item, moderationOpts, highlightUnread, hideTopBorder, }: { item: FeedNotification moderationOpts: ModerationOpts highlightUnread: boolean hideTopBorder?: boolean }): React.ReactNode => { const queryClient = useQueryClient() const pal = usePalette('default') const t = useTheme() const {_, i18n} = useLingui() const [isAuthorsExpanded, setAuthorsExpanded] = useState(false) const itemHref = useMemo(() => { if (item.type === 'post-like' || item.type === 'repost') { if (item.subjectUri) { const urip = new AtUri(item.subjectUri) return `/profile/${urip.host}/post/${urip.rkey}` } } else if ( item.type === 'follow' || item.type === 'verified' || item.type === 'unverified' ) { return makeProfileLink(item.notification.author) } else if (item.type === 'reply') { const urip = new AtUri(item.notification.uri) return `/profile/${urip.host}/post/${urip.rkey}` } else if ( item.type === 'feedgen-like' || item.type === 'starterpack-joined' ) { if (item.subjectUri) { const urip = new AtUri(item.subjectUri) return `/profile/${urip.host}/feed/${urip.rkey}` } } return '' }, [item]) const onToggleAuthorsExpanded = (e?: GestureResponderEvent) => { if (e) { e.preventDefault() e.stopPropagation() } setAuthorsExpanded(currentlyExpanded => !currentlyExpanded) } const onBeforePress = useCallback(() => { unstableCacheProfileView(queryClient, item.notification.author) }, [queryClient, item.notification.author]) const authors: Author[] = useMemo(() => { return [ { profile: item.notification.author, href: makeProfileLink(item.notification.author), moderation: moderateProfile(item.notification.author, moderationOpts), }, ...(item.additional?.map(({author}) => ({ profile: author, href: makeProfileLink(author), moderation: moderateProfile(author, moderationOpts), })) || []), ] }, [item, moderationOpts]) const niceTimestamp = niceDate(i18n, item.notification.indexedAt) const firstAuthor = authors[0] const firstAuthorVerification = useSimpleVerificationState({ profile: firstAuthor.profile, }) const firstAuthorName = sanitizeDisplayName( firstAuthor.profile.displayName || firstAuthor.profile.handle, ) if (item.subjectUri && !item.subject && item.type !== 'feedgen-like') { // don't render anything if the target post was deleted or unfindable return } if ( item.type === 'reply' || item.type === 'mention' || item.type === 'quote' ) { if (!item.subject) { return null } const isHighlighted = highlightUnread && !item.notification.isRead return ( ) } const firstAuthorLink = ( {forceLTR(firstAuthorName)} {firstAuthorVerification.showBadge && ( )} ) const additionalAuthorsCount = authors.length - 1 const hasMultipleAuthors = additionalAuthorsCount > 0 const formattedAuthorsCount = hasMultipleAuthors ? formatCount(i18n, additionalAuthorsCount) : '' let a11yLabel = '' let notificationContent: ReactElement let icon = ( ) if (item.type === 'post-like') { a11yLabel = hasMultipleAuthors ? _( msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { one: `${formattedAuthorsCount} other`, other: `${formattedAuthorsCount} others`, })} liked your post`, ) : _(msg`${firstAuthorName} liked your post`) notificationContent = hasMultipleAuthors ? ( {firstAuthorLink} and{' '} {' '} liked your post ) : ( {firstAuthorLink} liked your post ) } else if (item.type === 'repost') { a11yLabel = hasMultipleAuthors ? _( msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { one: `${formattedAuthorsCount} other`, other: `${formattedAuthorsCount} others`, })} reposted your post`, ) : _(msg`${firstAuthorName} reposted your post`) notificationContent = hasMultipleAuthors ? ( {firstAuthorLink} and{' '} {' '} reposted your post ) : ( {firstAuthorLink} reposted your post ) icon = } else if (item.type === 'follow') { let isFollowBack = false if ( item.notification.author.viewer?.following && bsky.dangerousIsType( item.notification.record, AppBskyGraphFollow.isRecord, ) ) { let followingTimestamp try { const rkey = new AtUri(item.notification.author.viewer.following).rkey followingTimestamp = TID.fromStr(rkey).timestamp() } catch (e) { // For some reason the following URI was invalid. Default to it not being a follow back. console.error('Invalid following URI') } if (followingTimestamp) { const followedTimestamp = new Date(item.notification.record.createdAt).getTime() * 1000 isFollowBack = followedTimestamp > followingTimestamp } } if (isFollowBack && !hasMultipleAuthors) { /* * Follow-backs are ungrouped, grouped follow-backs not supported atm, * see `src/state/queries/notifications/util.ts` */ a11yLabel = _(msg`${firstAuthorName} followed you back`) notificationContent = {firstAuthorLink} followed you back } else { a11yLabel = hasMultipleAuthors ? _( msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { one: `${formattedAuthorsCount} other`, other: `${formattedAuthorsCount} others`, })} followed you`, ) : _(msg`${firstAuthorName} followed you`) notificationContent = hasMultipleAuthors ? ( {firstAuthorLink} and{' '} {' '} followed you ) : ( {firstAuthorLink} followed you ) } icon = } else if (item.type === 'feedgen-like') { a11yLabel = hasMultipleAuthors ? _( msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { one: `${formattedAuthorsCount} other`, other: `${formattedAuthorsCount} others`, })} liked your custom feed`, ) : _(msg`${firstAuthorName} liked your custom feed`) notificationContent = hasMultipleAuthors ? ( {firstAuthorLink} and{' '} {' '} liked your custom feed ) : ( {firstAuthorLink} liked your custom feed ) } else if (item.type === 'starterpack-joined') { a11yLabel = hasMultipleAuthors ? _( msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { one: `${formattedAuthorsCount} other`, other: `${formattedAuthorsCount} others`, })} signed up with your starter pack`, ) : _(msg`${firstAuthorName} signed up with your starter pack`) notificationContent = hasMultipleAuthors ? ( {firstAuthorLink} and{' '} {' '} signed up with your starter pack ) : ( {firstAuthorLink} signed up with your starter pack ) icon = ( ) } else if (item.type === 'verified') { a11yLabel = hasMultipleAuthors ? _( msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { one: `${formattedAuthorsCount} other`, other: `${formattedAuthorsCount} others`, })} verified you`, ) : _(msg`${firstAuthorName} verified you`) notificationContent = hasMultipleAuthors ? ( {firstAuthorLink} and{' '} {' '} verified you ) : ( {firstAuthorLink} verified you ) icon = } else if (item.type === 'unverified') { a11yLabel = hasMultipleAuthors ? _( msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { one: `${formattedAuthorsCount} other`, other: `${formattedAuthorsCount} others`, })} removed their verifications from your account`, ) : _(msg`${firstAuthorName} removed their verification from your account`) notificationContent = hasMultipleAuthors ? ( {firstAuthorLink} and{' '} {' '} removed their verifications from your account ) : ( {firstAuthorLink} removed their verification from your account ) icon = } else if (item.type === 'like-via-repost') { a11yLabel = hasMultipleAuthors ? _( msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { one: `${formattedAuthorsCount} other`, other: `${formattedAuthorsCount} others`, })} liked your repost`, ) : _(msg`${firstAuthorName} liked your repost`) notificationContent = hasMultipleAuthors ? ( {firstAuthorLink} and{' '} {' '} liked your repost ) : ( {firstAuthorLink} liked your repost ) } else if (item.type === 'repost-via-repost') { a11yLabel = hasMultipleAuthors ? _( msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { one: `${formattedAuthorsCount} other`, other: `${formattedAuthorsCount} others`, })} reposted your repost`, ) : _(msg`${firstAuthorName} reposted your repost`) notificationContent = hasMultipleAuthors ? ( {firstAuthorLink} and{' '} {' '} reposted your repost ) : ( {firstAuthorLink} reposted your repost ) icon = } else { return null } a11yLabel += ` ยท ${niceTimestamp}` return ( { if (e.nativeEvent.actionName === 'activate') { onBeforePress() } if (e.nativeEvent.actionName === 'toggleAuthorsExpanded') { onToggleAuthorsExpanded() } }}> {({hovered}) => ( <> {/* TODO: Prevent conditional rendering and move toward composable notifications for clearer accessibility labeling */} {icon} {notificationContent} {({timeElapsed}) => ( <> {/* make sure there's whitespace around the middot -sfn */} {' '} ·{' '} {timeElapsed} )} {item.type === 'post-like' || item.type === 'repost' || item.type === 'like-via-repost' || item.type === 'repost-via-repost' ? ( ) : null} {item.type === 'feedgen-like' && item.subjectUri ? ( ) : null} {item.type === 'starterpack-joined' ? ( ) : null} )} ) } NotificationFeedItem = memo(NotificationFeedItem) export {NotificationFeedItem} function ExpandListPressable({ hasMultipleAuthors, children, onToggleAuthorsExpanded, }: { hasMultipleAuthors: boolean children: React.ReactNode onToggleAuthorsExpanded: (e: GestureResponderEvent) => void }) { if (hasMultipleAuthors) { return ( {children} ) } else { return <>{children} } } function SayHelloBtn({profile}: {profile: AppBskyActorDefs.ProfileView}) { const {_} = useLingui() const agent = useAgent() const navigation = useNavigation() const [isLoading, setIsLoading] = useState(false) if ( profile.associated?.chat?.allowIncoming === 'none' || (profile.associated?.chat?.allowIncoming === 'following' && !profile.viewer?.followedBy) ) { return null } return ( ) } function CondensedAuthorsList({ visible, authors, onToggleAuthorsExpanded, showDmButton = true, }: { visible: boolean authors: Author[] onToggleAuthorsExpanded: (e: GestureResponderEvent) => void showDmButton?: boolean }) { const t = useTheme() const {_} = useLingui() if (!visible) { return ( Hide ) } if (authors.length === 1) { return ( {showDmButton ? : null} ) } return ( {authors.slice(0, MAX_AUTHORS).map(author => ( ))} {authors.length > MAX_AUTHORS ? ( +{authors.length - MAX_AUTHORS} ) : undefined} ) } function ExpandedAuthorsList({ visible, authors, }: { visible: boolean authors: Author[] }) { const heightInterp = useAnimatedValue(visible ? 1 : 0) const targetHeight = authors.length * (EXPANDED_AUTHOR_EL_HEIGHT + 10) /*10=margin*/ const heightStyle = { height: Animated.multiply(heightInterp, targetHeight), } useEffect(() => { Animated.timing(heightInterp, { toValue: visible ? 1 : 0, duration: 200, useNativeDriver: false, }).start() }, [heightInterp, visible]) return ( {visible && authors.map(author => ( ))} ) } function ExpandedAuthorCard({author}: {author: Author}) { const t = useTheme() const {_} = useLingui() const verification = useSimpleVerificationState({ profile: author.profile, }) return ( {sanitizeDisplayName( author.profile.displayName || author.profile.handle, )} {verification.showBadge && ( )} {sanitizeHandle(author.profile.handle, '@')} ) } function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) { const t = useTheme() if ( post && bsky.dangerousIsType( post?.record, AppBskyFeedPost.isRecord, ) ) { const text = post.record.text return ( <> {text?.length > 0 && ( {text} )} ) } } const styles = StyleSheet.create({ layoutIcon: { width: 60, alignItems: 'flex-end', paddingTop: 2, }, icon: { marginRight: 10, marginTop: 4, }, additionalPostImages: { marginTop: 5, marginLeft: 2, opacity: 0.8, }, feedcard: { borderRadius: 8, paddingVertical: 12, marginTop: 6, }, addedContainer: { paddingTop: 4, paddingLeft: 36, }, expandedAuthorsTrigger: { zIndex: 1, }, expandedAuthorsCloseBtn: { flexDirection: 'row', alignItems: 'center', paddingTop: 10, paddingBottom: 6, }, expandedAuthor: { flexDirection: 'row', alignItems: 'center', marginTop: 10, height: EXPANDED_AUTHOR_EL_HEIGHT, }, })