diff options
author | dan <dan.abramov@gmail.com> | 2024-12-10 20:57:53 +0000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-12-10 20:57:53 +0000 |
commit | d00879e628145ec6ded18048be212d09c0227ba8 (patch) | |
tree | 1a3b32f7640cc65f792b6162c12278c769cd504f /src/view/com/notifications/FeedItem.tsx | |
parent | e052f5e198603246cb031e00d9cadc2ae4bb140d (diff) | |
download | voidsky-d00879e628145ec6ded18048be212d09c0227ba8.tar.zst |
Disambiguate feed component naming (#7040)
* Rename posts/Feed* -> posts/PostFeed* * Rename notifications/Feed* -> notifications/NotificationFeed*
Diffstat (limited to 'src/view/com/notifications/FeedItem.tsx')
-rw-r--r-- | src/view/com/notifications/FeedItem.tsx | 817 |
1 files changed, 0 insertions, 817 deletions
diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx deleted file mode 100644 index b90f2ecd6..000000000 --- a/src/view/com/notifications/FeedItem.tsx +++ /dev/null @@ -1,817 +0,0 @@ -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 FeedItem = ({ - item, - moderationOpts, - hideTopBorder, -}: { - item: FeedNotification - moderationOpts: ModerationOpts - hideTopBorder?: boolean -}): React.ReactNode => { - const queryClient = useQueryClient() - const pal = usePalette('default') - const {_, i18n} = useLingui() - const t = useTheme() - const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(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 <View /> - } - - if ( - item.type === 'reply' || - item.type === 'mention' || - item.type === 'quote' - ) { - if (!item.subject) { - return null - } - return ( - <Link - testID={`feedItem-by-${item.notification.author.handle}`} - href={itemHref} - noFeedback - accessible={false}> - <Post - post={item.subject} - style={ - item.notification.isRead - ? undefined - : { - backgroundColor: pal.colors.unreadNotifBg, - borderColor: pal.colors.unreadNotifBorder, - } - } - hideTopBorder={hideTopBorder} - /> - </Link> - ) - } - - const niceTimestamp = niceDate(i18n, item.notification.indexedAt) - const firstAuthor = authors[0] - const firstAuthorName = sanitizeDisplayName( - firstAuthor.profile.displayName || firstAuthor.profile.handle, - ) - const firstAuthorLink = ( - <TextLink - key={firstAuthor.href} - style={[pal.text, s.bold]} - href={firstAuthor.href} - text={ - <Text emoji style={[pal.text, s.bold]}> - {forceLTR(firstAuthorName)} - </Text> - } - disableMismatchWarning - /> - ) - const additionalAuthorsCount = authors.length - 1 - const hasMultipleAuthors = additionalAuthorsCount > 0 - const formattedAuthorsCount = hasMultipleAuthors - ? formatCount(i18n, additionalAuthorsCount) - : '' - - let a11yLabel = '' - let notificationContent: ReactElement - let icon = ( - <HeartIconFilled - size="xl" - style={[ - s.likeColor, - // {position: 'relative', top: -4} - ]} - /> - ) - - 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 ? ( - <Trans> - {firstAuthorLink} and{' '} - <Text style={[pal.text, s.bold]}> - <Plural - value={additionalAuthorsCount} - one={`${formattedAuthorsCount} other`} - other={`${formattedAuthorsCount} others`} - /> - </Text>{' '} - liked your post - </Trans> - ) : ( - <Trans>{firstAuthorLink} liked your post</Trans> - ) - } 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 ? ( - <Trans> - {firstAuthorLink} and{' '} - <Text style={[pal.text, s.bold]}> - <Plural - value={additionalAuthorsCount} - one={`${formattedAuthorsCount} other`} - other={`${formattedAuthorsCount} others`} - /> - </Text>{' '} - reposted your post - </Trans> - ) : ( - <Trans>{firstAuthorLink} reposted your post</Trans> - ) - icon = <RepostIcon size="xl" style={{color: t.palette.positive_600}} /> - } 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 = <Trans>{firstAuthorLink} followed you back</Trans> - } else { - a11yLabel = hasMultipleAuthors - ? _( - msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { - one: `${formattedAuthorsCount} other`, - other: `${formattedAuthorsCount} others`, - })} followed you`, - ) - : _(msg`${firstAuthorName} followed you`) - notificationContent = hasMultipleAuthors ? ( - <Trans> - {firstAuthorLink} and{' '} - <Text style={[pal.text, s.bold]}> - <Plural - value={additionalAuthorsCount} - one={`${formattedAuthorsCount} other`} - other={`${formattedAuthorsCount} others`} - /> - </Text>{' '} - followed you - </Trans> - ) : ( - <Trans>{firstAuthorLink} followed you</Trans> - ) - } - icon = <PersonPlusIcon size="xl" style={{color: t.palette.primary_500}} /> - } 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 ? ( - <Trans> - {firstAuthorLink} and{' '} - <Text style={[pal.text, s.bold]}> - <Plural - value={additionalAuthorsCount} - one={`${formattedAuthorsCount} other`} - other={`${formattedAuthorsCount} others`} - /> - </Text>{' '} - liked your custom feed - </Trans> - ) : ( - <Trans>{firstAuthorLink} liked your custom feed</Trans> - ) - } 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 ? ( - <Trans> - {firstAuthorLink} and{' '} - <Text style={[pal.text, s.bold]}> - <Plural - value={additionalAuthorsCount} - one={`${formattedAuthorsCount} other`} - other={`${formattedAuthorsCount} others`} - /> - </Text>{' '} - signed up with your starter pack - </Trans> - ) : ( - <Trans>{firstAuthorLink} signed up with your starter pack</Trans> - ) - icon = ( - <View style={{height: 30, width: 30}}> - <StarterPack width={30} gradient="sky" /> - </View> - ) - } else { - return null - } - a11yLabel += ` ยท ${niceTimestamp}` - - return ( - <Link - testID={`feedItem-by-${item.notification.author.handle}`} - style={[ - styles.outer, - pal.border, - item.notification.isRead - ? undefined - : { - backgroundColor: pal.colors.unreadNotifBg, - borderColor: pal.colors.unreadNotifBorder, - }, - {borderTopWidth: hideTopBorder ? 0 : StyleSheet.hairlineWidth}, - a.overflow_hidden, - ]} - href={itemHref} - noFeedback - accessibilityHint="" - accessibilityLabel={a11yLabel} - accessible={!isAuthorsExpanded} - accessibilityActions={ - hasMultipleAuthors - ? [ - { - 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() - } - }} - onPointerEnter={() => { - setHover(true) - }} - onPointerLeave={() => { - setHover(false) - }}> - <SubtleWebHover hover={hover} /> - <View style={[styles.layoutIcon, a.pr_sm]}> - {/* TODO: Prevent conditional rendering and move toward composable - notifications for clearer accessibility labeling */} - {icon} - </View> - <View style={styles.layoutContent}> - <ExpandListPressable - hasMultipleAuthors={hasMultipleAuthors} - onToggleAuthorsExpanded={onToggleAuthorsExpanded}> - <CondensedAuthorsList - visible={!isAuthorsExpanded} - authors={authors} - onToggleAuthorsExpanded={onToggleAuthorsExpanded} - showDmButton={item.type === 'starterpack-joined'} - /> - <ExpandedAuthorsList visible={isAuthorsExpanded} authors={authors} /> - <Text - style={[styles.meta, a.self_start, pal.text]} - accessibilityHint="" - accessibilityLabel={a11yLabel}> - {notificationContent} - <TimeElapsed timestamp={item.notification.indexedAt}> - {({timeElapsed}) => ( - <> - {/* make sure there's whitespace around the middot -sfn */} - <Text style={[pal.textLight]}> · </Text> - <Text style={[pal.textLight]} title={niceTimestamp}> - {timeElapsed} - </Text> - </> - )} - </TimeElapsed> - </Text> - </ExpandListPressable> - {item.type === 'post-like' || item.type === 'repost' ? ( - <AdditionalPostText post={item.subject} /> - ) : null} - {item.type === 'feedgen-like' && item.subjectUri ? ( - <FeedSourceCard - feedUri={item.subjectUri} - style={[ - t.atoms.bg, - t.atoms.border_contrast_low, - a.border, - styles.feedcard, - ]} - showLikes - /> - ) : null} - {item.type === 'starterpack-joined' ? ( - <View> - <View - style={[ - a.border, - a.p_sm, - a.rounded_sm, - a.mt_sm, - t.atoms.border_contrast_low, - ]}> - <StarterPackCard starterPack={item.subject} /> - </View> - </View> - ) : null} - </View> - </Link> - ) -} -FeedItem = memo(FeedItem) -export {FeedItem} - -function ExpandListPressable({ - hasMultipleAuthors, - children, - onToggleAuthorsExpanded, -}: { - hasMultipleAuthors: boolean - children: React.ReactNode - onToggleAuthorsExpanded: () => void -}) { - if (hasMultipleAuthors) { - return ( - <Pressable - onPress={onToggleAuthorsExpanded} - style={[styles.expandedAuthorsTrigger]} - accessible={false}> - {children} - </Pressable> - ) - } else { - return <>{children}</> - } -} - -function SayHelloBtn({profile}: {profile: AppBskyActorDefs.ProfileViewBasic}) { - const {_} = useLingui() - const agent = useAgent() - const navigation = useNavigation<NavigationProp>() - const [isLoading, setIsLoading] = React.useState(false) - - if ( - profile.associated?.chat?.allowIncoming === 'none' || - (profile.associated?.chat?.allowIncoming === 'following' && - !profile.viewer?.followedBy) - ) { - return null - } - - return ( - <Button - label={_(msg`Say hello!`)} - variant="ghost" - color="primary" - size="small" - style={[a.self_center, {marginLeft: 'auto'}]} - disabled={isLoading} - onPress={async () => { - try { - setIsLoading(true) - const res = await agent.api.chat.bsky.convo.getConvoForMembers( - { - members: [profile.did, agent.session!.did!], - }, - {headers: DM_SERVICE_HEADERS}, - ) - navigation.navigate('MessagesConversation', { - conversation: res.data.convo.id, - }) - } catch (e) { - logger.error('Failed to get conversation', {safeMessage: e}) - } finally { - setIsLoading(false) - } - }}> - <ButtonText> - <Trans>Say hello!</Trans> - </ButtonText> - </Button> - ) -} - -function CondensedAuthorsList({ - visible, - authors, - onToggleAuthorsExpanded, - showDmButton = true, -}: { - visible: boolean - authors: Author[] - onToggleAuthorsExpanded: () => void - showDmButton?: boolean -}) { - const pal = usePalette('default') - const {_} = useLingui() - - if (!visible) { - return ( - <View style={styles.avis}> - <TouchableOpacity - style={styles.expandedAuthorsCloseBtn} - onPress={onToggleAuthorsExpanded} - accessibilityRole="button" - accessibilityLabel={_(msg`Hide user list`)} - accessibilityHint={_( - msg`Collapses list of users for a given notification`, - )}> - <ChevronUpIcon - size="md" - style={[styles.expandedAuthorsCloseBtnIcon, pal.text]} - /> - <Text type="sm-medium" style={pal.text}> - <Trans context="action">Hide</Trans> - </Text> - </TouchableOpacity> - </View> - ) - } - if (authors.length === 1) { - return ( - <View style={[styles.avis]}> - <PreviewableUserAvatar - size={35} - profile={authors[0].profile} - moderation={authors[0].moderation.ui('avatar')} - type={authors[0].profile.associated?.labeler ? 'labeler' : 'user'} - /> - {showDmButton ? <SayHelloBtn profile={authors[0].profile} /> : null} - </View> - ) - } - return ( - <TouchableOpacity - accessibilityRole="none" - onPress={onToggleAuthorsExpanded}> - <View style={styles.avis}> - {authors.slice(0, MAX_AUTHORS).map(author => ( - <View key={author.href} style={s.mr5}> - <PreviewableUserAvatar - size={35} - profile={author.profile} - moderation={author.moderation.ui('avatar')} - type={author.profile.associated?.labeler ? 'labeler' : 'user'} - /> - </View> - ))} - {authors.length > MAX_AUTHORS ? ( - <Text style={[styles.aviExtraCount, pal.textLight]}> - +{authors.length - MAX_AUTHORS} - </Text> - ) : undefined} - <ChevronDownIcon - size="md" - style={[styles.expandedAuthorsCloseBtnIcon, pal.textLight]} - /> - </View> - </TouchableOpacity> - ) -} - -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 ( - <Animated.View style={[a.overflow_hidden, heightStyle]}> - {visible && - authors.map(author => ( - <NewLink - key={author.profile.did} - label={author.profile.displayName || author.profile.handle} - accessibilityHint={_(msg`Opens this profile`)} - to={makeProfileLink({ - did: author.profile.did, - handle: author.profile.handle, - })} - style={styles.expandedAuthor}> - <View style={styles.expandedAuthorAvi}> - <ProfileHoverCard did={author.profile.did}> - <UserAvatar - size={35} - avatar={author.profile.avatar} - moderation={author.moderation.ui('avatar')} - type={author.profile.associated?.labeler ? 'labeler' : 'user'} - /> - </ProfileHoverCard> - </View> - <View style={s.flex1}> - <Text - type="lg-bold" - numberOfLines={1} - style={pal.text} - lineHeight={1.2}> - <Text emoji type="lg-bold" style={pal.text} lineHeight={1.2}> - {sanitizeDisplayName( - author.profile.displayName || author.profile.handle, - )} - </Text>{' '} - <Text style={[pal.textLight]} lineHeight={1.2}> - {sanitizeHandle(author.profile.handle, '@')} - </Text> - </Text> - </View> - </NewLink> - ))} - </Animated.View> - ) -} - -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 emoji style={pal.textLight}> - {text} - </Text> - )} - <MediaPreview.Embed - embed={post.embed} - style={styles.additionalPostImages} - /> - </> - ) - } -} - -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, - }, -}) |