import React, { memo, type ReactElement, useEffect, useMemo, useState, } from 'react' import { Animated, Pressable, StyleSheet, TouchableOpacity, View, } from 'react-native' import { AppBskyActorDefs, AppBskyFeedDefs, AppBskyFeedPost, AppBskyGraphFollow, moderateProfile, ModerationDecision, 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 {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 {colors, s} from '#/lib/styles' import {logger} from '#/logger' import {isWeb} from '#/platform/detection' import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' import {FeedNotification} from '#/state/queries/notifications/feed' import {precacheProfile} from '#/state/queries/profile' import {useAgent} from '#/state/session' import {atoms as a, 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 {Link as NewLink} 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 {FeedSourceCard} from '../feeds/FeedSourceCard' import {Post} from '../post/Post' import {Link, TextLink} from '../util/Link' import {formatCount} from '../util/numeric/format' import {Text} from '../util/text/Text' import {TimeElapsed} from '../util/TimeElapsed' import {PreviewableUserAvatar, UserAvatar} from '../util/UserAvatar' const MAX_AUTHORS = 5 const EXPANDED_AUTHOR_EL_HEIGHT = 35 interface Author { profile: AppBskyActorDefs.ProfileViewBasic 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 {_, i18n} = useLingui() const t = useTheme() 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') { 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 = () => { setAuthorsExpanded(currentlyExpanded => !currentlyExpanded) } const onBeforePress = React.useCallback(() => { precacheProfile(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 [hover, setHover] = React.useState(false) 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 niceTimestamp = niceDate(i18n, item.notification.indexedAt) const firstAuthor = authors[0] const firstAuthorName = sanitizeDisplayName( firstAuthor.profile.displayName || firstAuthor.profile.handle, ) const firstAuthorLink = ( {forceLTR(firstAuthorName)} } disableMismatchWarning /> ) 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 && AppBskyGraphFollow.isRecord(item.notification.record) ) { 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 { return null } a11yLabel += ` ยท ${niceTimestamp}` return ( { if (e.nativeEvent.actionName === 'activate') { onBeforePress() } if (e.nativeEvent.actionName === 'toggleAuthorsExpanded') { onToggleAuthorsExpanded() } }} onPointerEnter={() => { setHover(true) }} onPointerLeave={() => { setHover(false) }}> {/* 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' ? ( ) : 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: () => void }) { if (hasMultipleAuthors) { return ( {children} ) } else { return <>{children} } } function SayHelloBtn({profile}: {profile: AppBskyActorDefs.ProfileViewBasic}) { const {_} = useLingui() const agent = useAgent() const navigation = useNavigation() const [isLoading, setIsLoading] = React.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: () => void showDmButton?: boolean }) { const pal = usePalette('default') 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 {_} = useLingui() const pal = usePalette('default') 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 => ( {sanitizeDisplayName( author.profile.displayName || author.profile.handle, )} {' '} {sanitizeHandle(author.profile.handle, '@')} ))} ) } function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) { const pal = usePalette('default') if (post && AppBskyFeedPost.isRecord(post?.record)) { const text = post.record.text return ( <> {text?.length > 0 && ( {text} )} ) } } const styles = StyleSheet.create({ pointer: isWeb ? { // @ts-ignore web only cursor: 'pointer', } : {}, outer: { padding: 10, paddingRight: 15, flexDirection: 'row', }, layoutIcon: { width: 60, alignItems: 'flex-end', paddingTop: 2, }, icon: { marginRight: 10, marginTop: 4, }, layoutContent: { flex: 1, }, avis: { flexDirection: 'row', alignItems: 'center', }, aviExtraCount: { fontWeight: '600', paddingLeft: 6, }, meta: { flexDirection: 'row', flexWrap: 'wrap', paddingTop: 6, paddingBottom: 2, }, postText: { paddingBottom: 5, color: colors.black, }, 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, }, expandedAuthorsCloseBtnIcon: { marginLeft: 4, marginRight: 4, }, expandedAuthor: { flexDirection: 'row', alignItems: 'center', marginTop: 10, height: EXPANDED_AUTHOR_EL_HEIGHT, }, expandedAuthorAvi: { marginRight: 5, }, })