import {memo, useCallback, useEffect, useMemo, useState} from 'react' import { Animated, type GestureResponderEvent, type LayoutChangeEvent, Pressable, StyleSheet, TouchableOpacity, View, } from 'react-native' import { type AppBskyActorDefs, type AppBskyFeedDefs, AppBskyFeedPost, type AppBskyGraphDefs, AppBskyGraphFollow, AppBskyGraphStarterpack, AtUri, } from '@atproto/api' import {TID} from '@atproto/common-web' import {type DidString} from '@atproto/syntax' import { type ModerationDecision, type ModerationOpts, } from '@bsky.app/sdk/moderation' import {plural} from '@lingui/core/macro' import {Plural, Trans, useLingui} from '@lingui/react/macro' import {useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' import {MAX_POST_LINES} from '#/lib/constants' import {useAnimatedValue} from '#/lib/hooks/useAnimatedValue' import {moderateProfile} from '#/lib/moderation/subjects' 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 {niceDate} from '#/lib/strings/time' import {logger} from '#/logger' import {useProfileShadow} from '#/state/cache/profile-shadow' import {type FeedNotification} from '#/state/queries/notifications/feed' import {useProfileFollowMutationQueue} from '#/state/queries/profile' import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' import {useChatClient, useSession} 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} from '#/view/com/util/UserAvatar' import {atoms as a, native, platform, useTheme, web} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {BellRinging_Filled_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging' import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' import { ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon, ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon, } from '#/components/icons/Chevron' import {Contacts_Filled_Corner2_Rounded as ContactsIconFilled} from '#/components/icons/Contacts' 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 {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' import {Repost_Stroke2_Corner3_Rounded as RepostIcon} from '#/components/icons/Repost' import {StarterPackMultiPathLarge as StarterPackIcon} from '#/components/icons/StarterPack' import {VerifiedCheck} from '#/components/icons/VerifiedCheck' import {InlineLinkText, Link} from '#/components/Link' import * as MediaPreview from '#/components/MediaPreview' import {ProfileBadges} from '#/components/ProfileBadges' import * as ProfileCard from '#/components/ProfileCard' import {ProfileHoverCard} from '#/components/ProfileHoverCard' import { Notification as StarterPackCard, useStarterPackLink, } from '#/components/StarterPack/StarterPackCard' import {SubtleHover} from '#/components/SubtleHover' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' import {IS_WEB} from '#/env' import {chat} from '#/lexicons' import * as bsky from '#/types/bsky' const MAX_AUTHORS = 5 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 t = useTheme() const {t: l, i18n} = useLingui() const ax = useAnalytics() const [isAuthorsExpanded, setIsAuthorsExpanded] = useState(false) const [isHoveringAuthorsList, setIsHoveringAuthorsList] = useState(false) const itemHref = useMemo(() => { switch (item.type) { case 'post-like': case 'repost': case 'like-via-repost': case 'repost-via-repost': { if (item.subjectUri) { const urip = new AtUri(item.subjectUri) return `/profile/${urip.host}/post/${urip.rkey}` } break } case 'follow': case 'contact-match': case 'verified': case 'unverified': { return makeProfileLink(item.notification.author) } case 'reply': case 'mention': case 'quote': { const uripReply = new AtUri(item.notification.uri) return `/profile/${uripReply.host}/post/${uripReply.rkey}` } case 'feedgen-like': case 'starterpack-joined': { if (item.subjectUri) { const urip = new AtUri(item.subjectUri) return `/profile/${urip.host}/feed/${urip.rkey}` } break } case 'subscribed-post': { const posts: string[] = [] for (const post of [item.notification, ...(item.additional ?? [])]) { posts.push(post.uri) } return `/notifications/activity?posts=${encodeURIComponent(posts.slice(0, 25).join(','))}` } } return '' }, [item]) 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), })) || []), ].filter( (author, index, arr) => arr.findIndex(au => au.profile.did === author.profile.did) === index, ) }, [item, moderationOpts]) const onToggleAuthorsExpanded = (e?: GestureResponderEvent) => { if (e) { e.preventDefault() e.stopPropagation() } if (!isAuthorsExpanded) { ax.metric('notifications:bundleExpand', { notificationType: item.type, authorCount: authors.length, }) } setIsAuthorsExpanded(currentlyExpanded => !currentlyExpanded) } const niceTimestamp = niceDate(i18n, item.notification.indexedAt) const firstAuthor = authors[0] const firstAuthorName = sanitizeDisplayName( firstAuthor.profile.displayName || firstAuthor.profile.handle, ) // Calculate if this is a follow-back notification const isFollowBack = useMemo(() => { if (item.type !== 'follow') return 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) { return false } if (followingTimestamp) { const followedTimestamp = new Date(item.notification.record.createdAt).getTime() * 1000 return followedTimestamp > followingTimestamp } } return false }, [item]) 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)} ) const additionalAuthorsCount = authors.length - 1 const hasMultipleAuthors = additionalAuthorsCount > 0 const starterPack = item.notification.starterPack const allFollowedViaSameStarterPack = item.type === 'follow' && starterPack !== undefined && (item.additional ?? []).every( notification => notification.starterPack?.uri === starterPack.uri, ) const starterPackName = allFollowedViaSameStarterPack && starterPack ? getStarterPackName(starterPack) : undefined const formattedAuthorsCount = hasMultipleAuthors ? formatCount(i18n, additionalAuthorsCount) : '' let a11yLabel = '' let notificationContent: React.ReactElement let icon = ( ) if (item.type === 'post-like') { a11yLabel = hasMultipleAuthors ? l`${firstAuthorName} and ${plural(additionalAuthorsCount, { one: `${formattedAuthorsCount} other`, other: `${formattedAuthorsCount} others`, })} liked your post` : l`${firstAuthorName} liked your post` notificationContent = hasMultipleAuthors ? ( {firstAuthorLink} and{' '} {' '} liked your post ) : ( {firstAuthorLink} liked your post ) } else if (item.type === 'repost') { a11yLabel = hasMultipleAuthors ? l`${firstAuthorName} and ${plural(additionalAuthorsCount, { one: `${formattedAuthorsCount} other`, other: `${formattedAuthorsCount} others`, })} reposted your post` : l`${firstAuthorName} reposted your post` notificationContent = hasMultipleAuthors ? ( {firstAuthorLink} and{' '} {' '} reposted your post ) : ( {firstAuthorLink} reposted your post ) icon = } else if (item.type === 'follow') { if (isFollowBack && !hasMultipleAuthors) { /* * Follow-backs are ungrouped, grouped follow-backs not supported atm, * see `src/state/queries/notifications/util.ts` */ a11yLabel = starterPackName ? l`${firstAuthorName} followed you back via starter pack ${starterPackName}` : l`${firstAuthorName} followed you back` notificationContent = {firstAuthorLink} followed you back } else { a11yLabel = starterPackName ? hasMultipleAuthors ? l`${firstAuthorName} and ${plural(additionalAuthorsCount, { one: `${formattedAuthorsCount} other`, other: `${formattedAuthorsCount} others`, })} followed you via starter pack ${starterPackName}` : l`${firstAuthorName} followed you via starter pack ${starterPackName}` : hasMultipleAuthors ? l`${firstAuthorName} and ${plural(additionalAuthorsCount, { one: `${formattedAuthorsCount} other`, other: `${formattedAuthorsCount} others`, })} followed you` : l`${firstAuthorName} followed you` notificationContent = hasMultipleAuthors ? ( {firstAuthorLink} and{' '} {' '} followed you ) : ( {firstAuthorLink} followed you ) } icon = } else if (item.type === 'contact-match') { a11yLabel = l`Your contact ${firstAuthorName} is on Bluesky` notificationContent = ( Your contact {firstAuthorLink} is on Bluesky ) icon = ( ) } else if (item.type === 'feedgen-like') { a11yLabel = hasMultipleAuthors ? l`${firstAuthorName} and ${plural(additionalAuthorsCount, { one: `${formattedAuthorsCount} other`, other: `${formattedAuthorsCount} others`, })} liked your custom feed` : l`${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 ? l`${firstAuthorName} and ${plural(additionalAuthorsCount, { one: `${formattedAuthorsCount} other`, other: `${formattedAuthorsCount} others`, })} signed up with your starter pack` : l`${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 ? l`${firstAuthorName} and ${plural(additionalAuthorsCount, { one: `${formattedAuthorsCount} other`, other: `${formattedAuthorsCount} others`, })} verified you` : l`${firstAuthorName} verified you` notificationContent = hasMultipleAuthors ? ( {firstAuthorLink} and{' '} {' '} verified you ) : ( {firstAuthorLink} verified you ) icon = } else if (item.type === 'unverified') { a11yLabel = hasMultipleAuthors ? l`${firstAuthorName} and ${plural(additionalAuthorsCount, { one: `${formattedAuthorsCount} other`, other: `${formattedAuthorsCount} others`, })} removed their verifications from your account` : l`${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 ? l`${firstAuthorName} and ${plural(additionalAuthorsCount, { one: `${formattedAuthorsCount} other`, other: `${formattedAuthorsCount} others`, })} liked your repost` : l`${firstAuthorName} liked your repost` notificationContent = hasMultipleAuthors ? ( {firstAuthorLink} and{' '} {' '} liked your repost ) : ( {firstAuthorLink} liked your repost ) } else if (item.type === 'repost-via-repost') { a11yLabel = hasMultipleAuthors ? l`${firstAuthorName} and ${plural(additionalAuthorsCount, { one: `${formattedAuthorsCount} other`, other: `${formattedAuthorsCount} others`, })} reposted your repost` : l`${firstAuthorName} reposted your repost` notificationContent = hasMultipleAuthors ? ( {firstAuthorLink} and{' '} {' '} reposted your repost ) : ( {firstAuthorLink} reposted your repost ) icon = } else if (item.type === 'subscribed-post') { const postsCount = 1 + (item.additional?.length || 0) a11yLabel = hasMultipleAuthors ? l`New posts from ${firstAuthorName} and ${plural( additionalAuthorsCount, { one: `${formattedAuthorsCount} other`, other: `${formattedAuthorsCount} others`, }, )}` : l`New ${plural(postsCount, { one: 'post', other: 'posts', })} from ${firstAuthorName}` notificationContent = hasMultipleAuthors ? ( New posts from {firstAuthorLink} and{' '} {' '} ) : ( New from{' '} {firstAuthorLink} ) 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} setIsHoveringAuthorsList(true)} onHoverOut={() => setIsHoveringAuthorsList(false)}> {notificationContent} {({timeElapsed}) => ( <> {/* make sure there's whitespace around the middot -sfn */} {' '} ·{' '} {timeElapsed} )} {allFollowedViaSameStarterPack && starterPack ? ( ) : null} {(item.type === 'follow' && !hasMultipleAuthors && !isFollowBack) || (item.type === 'contact-match' && !item.notification.author.viewer?.following) ? ( ) : null} {item.type === 'post-like' || item.type === 'repost' || item.type === 'like-via-repost' || item.type === 'repost-via-repost' || item.type === 'subscribed-post' ? ( ) : null} {item.type === 'feedgen-like' && item.subjectUri ? ( ) : null} {item.type === 'starterpack-joined' ? ( ) : null} )} ) } NotificationFeedItem = memo(NotificationFeedItem) export {NotificationFeedItem} function FollowedViaStarterPack({ starterPack, }: { starterPack: AppBskyGraphDefs.StarterPackViewBasic }) { const t = useTheme() const link = useStarterPackLink({view: starterPack}) const starterPackName = getStarterPackName(starterPack) if (!starterPackName) { return null } return ( via starter pack{' '} {starterPackName} ) } function getStarterPackName( starterPack: AppBskyGraphDefs.StarterPackViewBasic, ) { return bsky.dangerousIsType( starterPack.record, AppBskyGraphStarterpack.isRecord, ) ? starterPack.record.name : undefined } function ExpandListPressable({ hasMultipleAuthors, children, onToggleAuthorsExpanded, onHoverIn, onHoverOut, }: { hasMultipleAuthors: boolean children: React.ReactNode onToggleAuthorsExpanded: (e: GestureResponderEvent) => void onHoverIn?: () => void onHoverOut?: () => void }) { if (hasMultipleAuthors) { return ( {children} ) } else { return <>{children} } } function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) { const {t: l} = useLingui() const {currentAccount, hasSession} = useSession() const profileShadow = useProfileShadow(profile) const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( profileShadow, 'ProfileCard', ) // Don't show button if not logged in or for own profile if (!hasSession || profile.did === currentAccount?.did) { return null } const onPressFollow = async (e: GestureResponderEvent) => { e.preventDefault() e.stopPropagation() try { await queueFollow() Toast.show( l`Following ${sanitizeDisplayName( profile.displayName || profile.handle, )}`, ) } catch (error) { const err = error as Error if (err?.name !== 'AbortError') { Toast.show(l`An issue occurred, please try again.`, { type: 'error', }) } } } const onPressUnfollow = async (e: GestureResponderEvent) => { e.preventDefault() e.stopPropagation() try { await queueUnfollow() Toast.show( l`No longer following ${sanitizeDisplayName( profile.displayName || profile.handle, )}`, ) } catch (error) { const err = error as Error if (err?.name !== 'AbortError') { Toast.show(l`An issue occurred, please try again.`, { type: 'error', }) } } } // Don't show button if viewer data is missing or user is blocked if (!profileShadow.viewer) { return null } if ( profileShadow.viewer.blockedBy || profileShadow.viewer.blocking || profileShadow.viewer.blockingByList ) { return null } const isFollowing = profileShadow.viewer.following const isFollowedBy = profileShadow.viewer.followedBy const followingLabel = l({ message: 'Following', comment: 'User is following this account, click to unfollow', }) return ( {isFollowing ? ( ) : ( )} ) } function SayHelloBtn({profile}: {profile: AppBskyActorDefs.ProfileView}) { const {t: l} = useLingui() const client = useChatClient() const {currentAccount} = useSession() const navigation = useNavigation() const [isLoading, setIsLoading] = useState(false) const onPress = async () => { try { setIsLoading(true) const data = await client.call(chat.bsky.convo.getConvoForMembers, { // both dids are already resolved - one from the profile view, one from // the active session members: [profile.did, currentAccount!.did] as DidString[], }) navigation.navigate('MessagesConversation', { conversation: data.convo.id, }) } catch (e) { logger.error('Failed to get conversation', {safeMessage: e}) } finally { setIsLoading(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 {t: l} = 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, moderationOpts, }: { visible: boolean authors: Author[] moderationOpts: ModerationOpts }) { const heightInterp = useAnimatedValue(visible ? 1 : 0) const opacityInterp = useAnimatedValue(visible ? 1 : 0) const [measuredHeight, setMeasuredHeight] = useState(0) useEffect(() => { Animated.timing(IS_WEB ? heightInterp : opacityInterp, { toValue: visible ? 1 : 0, duration: 200, useNativeDriver: !IS_WEB, }).start() }, [heightInterp, opacityInterp, visible]) const onInnerLayout = (e: LayoutChangeEvent) => { if (measuredHeight === 0) { setMeasuredHeight(e.nativeEvent.layout.height) } } if (!IS_WEB) { return ( {visible && ( {authors.map((author, i) => ( ))} )} ) } const heightStyle = { height: Animated.multiply(heightInterp, measuredHeight), opacity: Animated.divide(heightInterp, 1), } return ( {authors.map((author, i) => ( ))} ) } function ExpandedAuthorProfileCard({ author, moderationOpts, isLast, }: { author: Author moderationOpts: ModerationOpts isLast: boolean }) { const profile = useProfileShadow(author.profile) const isFollowing = !!profile.viewer?.following return ( ) } 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, marginTop: 6, }, addedContainer: { paddingTop: 4, paddingLeft: 36, }, expandedAuthorsTrigger: { zIndex: 1, }, expandedAuthorsCloseBtn: { flexDirection: 'row', alignItems: 'center', paddingTop: 10, paddingBottom: 6, }, })