import React, {memo, useEffect, useMemo, useState} from 'react' import { Animated, Pressable, StyleSheet, TouchableOpacity, View, } from 'react-native' import { AppBskyActorDefs, AppBskyEmbedExternal, AppBskyEmbedImages, AppBskyEmbedRecordWithMedia, AppBskyFeedDefs, AppBskyFeedPost, AppBskyGraphFollow, moderateProfile, ModerationDecision, ModerationOpts, } from '@atproto/api' import {AtUri} from '@atproto/api' import {TID} from '@atproto/common-web' import {msg, plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' import {parseTenorGif} from '#/lib/strings/embed-player' import {logger} from '#/logger' import {FeedNotification} from '#/state/queries/notifications/feed' 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 {isWeb} from 'platform/detection' import {DM_SERVICE_HEADERS} from 'state/queries/messages/const' 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 {ProfileHoverCard} from '#/components/ProfileHoverCard' import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard' import {FeedSourceCard} from '../feeds/FeedSourceCard' import {Post} from '../post/Post' import {ImageHorzList} from '../util/images/ImageHorzList' 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 FeedItem = ({ item, moderationOpts, hideTopBorder, }: { item: FeedNotification moderationOpts: ModerationOpts hideTopBorder?: boolean }): React.ReactNode => { const queryClient = useQueryClient() const pal = usePalette('default') const {_} = 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]) 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 } return ( ) } let action = '' let icon = ( ) if (item.type === 'post-like') { action = _(msg`liked your post`) } else if (item.type === 'repost') { action = _(msg`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) { action = _(msg`followed you back`) } else { action = _(msg`followed you`) } icon = } else if (item.type === 'feedgen-like') { action = _(msg`liked your custom feed`) } else if (item.type === 'starterpack-joined') { icon = ( ) action = _(msg`signed up with your starter pack`) } else { return null } const formattedCount = authors.length > 1 ? formatCount(authors.length - 1) : '' const firstAuthorName = sanitizeDisplayName( authors[0].profile.displayName || authors[0].profile.handle, ) const niceTimestamp = niceDate(item.notification.indexedAt) const a11yLabelUsers = authors.length > 1 ? _(msg` and `) + plural(authors.length - 1, { one: `${formattedCount} other`, other: `${formattedCount} others`, }) : '' const a11yLabel = `${firstAuthorName}${a11yLabelUsers} ${action} ${niceTimestamp}` return ( 1 ? [ { name: 'toggleAuthorsExpanded', label: isAuthorsExpanded ? _(msg`Collapse list of users`) : _(msg`Expand list of users`), }, ] : [ { name: 'viewProfile', label: _( msg`View ${ authors[0].profile.displayName || authors[0].profile.handle }'s profile`, ), }, ] } onAccessibilityAction={e => { if (e.nativeEvent.actionName === 'activate') { onBeforePress() } if (e.nativeEvent.actionName === 'toggleAuthorsExpanded') { onToggleAuthorsExpanded() } }} onBeforePress={onBeforePress}> {/* TODO: Prevent conditional rendering and move toward composable notifications for clearer accessibility labeling */} {icon} 1} onToggleAuthorsExpanded={onToggleAuthorsExpanded}> {authors.length > 1 ? ( <> {' '} and{' '} {plural(authors.length - 1, { one: `${formattedCount} other`, other: `${formattedCount} others`, })} ) : undefined} {action} {({timeElapsed}) => ( {' ' + timeElapsed} )} {item.type === 'post-like' || item.type === 'repost' ? ( ) : null} {item.type === 'feedgen-like' && item.subjectUri ? ( ) : null} {item.type === 'starterpack-joined' ? ( ) : null} ) } FeedItem = memo(FeedItem) export {FeedItem} 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 let images let isGif = false if (AppBskyEmbedImages.isView(post.embed)) { images = post.embed.images } else if ( AppBskyEmbedRecordWithMedia.isView(post.embed) && AppBskyEmbedImages.isView(post.embed.media) ) { images = post.embed.media.images } else if ( AppBskyEmbedExternal.isView(post.embed) && post.embed.external.thumb ) { let url: URL | undefined try { url = new URL(post.embed.external.uri) } catch {} if (url) { const {success} = parseTenorGif(url) if (success) { isGif = true images = [ { thumb: post.embed.external.thumb, alt: post.embed.external.title, fullsize: post.embed.external.thumb, }, ] } } } return ( <> {text?.length > 0 && {text}} {images && images.length > 0 && ( )} ) } } const styles = StyleSheet.create({ pointer: isWeb ? { // @ts-ignore web only cursor: 'pointer', } : {}, outer: { padding: 10, paddingRight: 15, flexDirection: 'row', }, layoutIcon: { width: 70, alignItems: 'flex-end', paddingTop: 2, }, icon: { marginRight: 10, marginTop: 4, }, layoutContent: { flex: 1, }, avis: { flexDirection: 'row', alignItems: 'center', }, aviExtraCount: { fontWeight: 'bold', 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: { borderWidth: 1, 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, }, })