import React, {memo, useEffect, useMemo, useState} from 'react' import { Animated, Pressable, StyleSheet, TouchableOpacity, View, } from 'react-native' import { AppBskyActorDefs, AppBskyEmbedImages, AppBskyEmbedRecordWithMedia, AppBskyFeedDefs, AppBskyFeedPost, moderateProfile, ModerationDecision, ModerationOpts, } from '@atproto/api' import {AtUri} from '@atproto/api' import {msg, plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' 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 {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 {precacheProfile} from 'state/queries/profile' import {atoms as a, useTheme} from '#/alf' 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 {Link as NewLink} from '#/components/Link' import {ProfileHoverCard} from '#/components/ProfileHoverCard' 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' import hairlineWidth = StyleSheet.hairlineWidth 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') { 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') { action = _(msg`followed you`) icon = } else if (item.type === 'feedgen-like') { action = _(msg`liked your custom feed`) } else { return null } let formattedCount = authors.length > 1 ? formatCount(authors.length - 1) : '' return ( {/* 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} ) } 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 CondensedAuthorsList({ visible, authors, onToggleAuthorsExpanded, }: { visible: boolean authors: Author[] onToggleAuthorsExpanded: () => void }) { const pal = usePalette('default') const {_} = useLingui() if (!visible) { return ( Hide ) } if (authors.length === 1) { return ( ) } 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 ( {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 const images = AppBskyEmbedImages.isView(post.embed) ? post.embed.images : AppBskyEmbedRecordWithMedia.isView(post.embed) && AppBskyEmbedImages.isView(post.embed.media) ? post.embed.media.images : undefined return ( <> {text?.length > 0 && {text}} {images && images.length > 0 && ( )} ) } } const styles = StyleSheet.create({ overflowHidden: { overflow: 'hidden', }, 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, }, })