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/posts/PostFeedItem.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/posts/PostFeedItem.tsx')
-rw-r--r-- | src/view/com/posts/PostFeedItem.tsx | 653 |
1 files changed, 653 insertions, 0 deletions
diff --git a/src/view/com/posts/PostFeedItem.tsx b/src/view/com/posts/PostFeedItem.tsx new file mode 100644 index 000000000..4b18c470a --- /dev/null +++ b/src/view/com/posts/PostFeedItem.tsx @@ -0,0 +1,653 @@ +import React, {memo, useMemo, useState} from 'react' +import {StyleSheet, View} from 'react-native' +import { + AppBskyActorDefs, + AppBskyFeedDefs, + AppBskyFeedPost, + AppBskyFeedThreadgate, + AtUri, + ModerationDecision, + RichText as RichTextAPI, +} from '@atproto/api' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useQueryClient} from '@tanstack/react-query' + +import {isReasonFeedSource, ReasonFeedSource} from '#/lib/api/feed/types' +import {MAX_POST_LINES} from '#/lib/constants' +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 {countLines} from '#/lib/strings/helpers' +import {s} from '#/lib/styles' +import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow' +import {useFeedFeedbackContext} from '#/state/feed-feedback' +import {precacheProfile} from '#/state/queries/profile' +import {useSession} from '#/state/session' +import {useComposerControls} from '#/state/shell/composer' +import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' +import {FeedNameText} from '#/view/com/util/FeedInfoText' +import {PostCtrls} from '#/view/com/util/post-ctrls/PostCtrls' +import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds' +import {PostMeta} from '#/view/com/util/PostMeta' +import {Text} from '#/view/com/util/text/Text' +import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a} from '#/alf' +import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' +import {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost' +import {ContentHider} from '#/components/moderation/ContentHider' +import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' +import {PostAlerts} from '#/components/moderation/PostAlerts' +import {AppModerationCause} from '#/components/Pills' +import {ProfileHoverCard} from '#/components/ProfileHoverCard' +import {RichText} from '#/components/RichText' +import {SubtleWebHover} from '#/components/SubtleWebHover' +import {Link, TextLink, TextLinkOnWebOnly} from '../util/Link' +import {AviFollowButton} from './AviFollowButton' + +interface FeedItemProps { + record: AppBskyFeedPost.Record + reason: + | AppBskyFeedDefs.ReasonRepost + | AppBskyFeedDefs.ReasonPin + | ReasonFeedSource + | {[k: string]: unknown; $type: string} + | undefined + moderation: ModerationDecision + parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined + showReplyTo: boolean + isThreadChild?: boolean + isThreadLastChild?: boolean + isThreadParent?: boolean + feedContext: string | undefined + hideTopBorder?: boolean + isParentBlocked?: boolean + isParentNotFound?: boolean +} + +export function PostFeedItem({ + post, + record, + reason, + feedContext, + moderation, + parentAuthor, + showReplyTo, + isThreadChild, + isThreadLastChild, + isThreadParent, + hideTopBorder, + isParentBlocked, + isParentNotFound, + rootPost, +}: FeedItemProps & { + post: AppBskyFeedDefs.PostView + rootPost: AppBskyFeedDefs.PostView +}): React.ReactNode { + const postShadowed = usePostShadow(post) + const richText = useMemo( + () => + new RichTextAPI({ + text: record.text, + facets: record.facets, + }), + [record], + ) + if (postShadowed === POST_TOMBSTONE) { + return null + } + if (richText && moderation) { + return ( + <FeedItemInner + // Safeguard from clobbering per-post state below: + key={postShadowed.uri} + post={postShadowed} + record={record} + reason={reason} + feedContext={feedContext} + richText={richText} + parentAuthor={parentAuthor} + showReplyTo={showReplyTo} + moderation={moderation} + isThreadChild={isThreadChild} + isThreadLastChild={isThreadLastChild} + isThreadParent={isThreadParent} + hideTopBorder={hideTopBorder} + isParentBlocked={isParentBlocked} + isParentNotFound={isParentNotFound} + rootPost={rootPost} + /> + ) + } + return null +} + +let FeedItemInner = ({ + post, + record, + reason, + feedContext, + richText, + moderation, + parentAuthor, + showReplyTo, + isThreadChild, + isThreadLastChild, + isThreadParent, + hideTopBorder, + isParentBlocked, + isParentNotFound, + rootPost, +}: FeedItemProps & { + richText: RichTextAPI + post: Shadow<AppBskyFeedDefs.PostView> + rootPost: AppBskyFeedDefs.PostView +}): React.ReactNode => { + const queryClient = useQueryClient() + const {openComposer} = useComposerControls() + const pal = usePalette('default') + const {_} = useLingui() + + const href = useMemo(() => { + const urip = new AtUri(post.uri) + return makeProfileLink(post.author, 'post', urip.rkey) + }, [post.uri, post.author]) + const {sendInteraction} = useFeedFeedbackContext() + + const onPressReply = React.useCallback(() => { + sendInteraction({ + item: post.uri, + event: 'app.bsky.feed.defs#interactionReply', + feedContext, + }) + openComposer({ + replyTo: { + uri: post.uri, + cid: post.cid, + text: record.text || '', + author: post.author, + embed: post.embed, + moderation, + }, + }) + }, [post, record, openComposer, moderation, sendInteraction, feedContext]) + + const onOpenAuthor = React.useCallback(() => { + sendInteraction({ + item: post.uri, + event: 'app.bsky.feed.defs#clickthroughAuthor', + feedContext, + }) + }, [sendInteraction, post, feedContext]) + + const onOpenReposter = React.useCallback(() => { + sendInteraction({ + item: post.uri, + event: 'app.bsky.feed.defs#clickthroughReposter', + feedContext, + }) + }, [sendInteraction, post, feedContext]) + + const onOpenEmbed = React.useCallback(() => { + sendInteraction({ + item: post.uri, + event: 'app.bsky.feed.defs#clickthroughEmbed', + feedContext, + }) + }, [sendInteraction, post, feedContext]) + + const onBeforePress = React.useCallback(() => { + sendInteraction({ + item: post.uri, + event: 'app.bsky.feed.defs#clickthroughItem', + feedContext, + }) + precacheProfile(queryClient, post.author) + }, [queryClient, post, sendInteraction, feedContext]) + + const outerStyles = [ + styles.outer, + { + borderColor: pal.colors.border, + paddingBottom: + isThreadLastChild || (!isThreadChild && !isThreadParent) + ? 8 + : undefined, + borderTopWidth: + hideTopBorder || isThreadChild ? 0 : StyleSheet.hairlineWidth, + }, + ] + + const {currentAccount} = useSession() + const isOwner = + AppBskyFeedDefs.isReasonRepost(reason) && + reason.by.did === currentAccount?.did + + /** + * If `post[0]` in this slice is the actual root post (not an orphan thread), + * then we may have a threadgate record to reference + */ + const threadgateRecord = AppBskyFeedThreadgate.isRecord( + rootPost.threadgate?.record, + ) + ? rootPost.threadgate.record + : undefined + + const [hover, setHover] = useState(false) + return ( + <Link + testID={`feedItem-by-${post.author.handle}`} + style={outerStyles} + href={href} + noFeedback + accessible={false} + onBeforePress={onBeforePress} + dataSet={{feedContext}} + onPointerEnter={() => { + setHover(true) + }} + onPointerLeave={() => { + setHover(false) + }}> + <SubtleWebHover hover={hover} /> + <View style={{flexDirection: 'row', gap: 10, paddingLeft: 8}}> + <View style={{width: 42}}> + {isThreadChild && ( + <View + style={[ + styles.replyLine, + { + flexGrow: 1, + backgroundColor: pal.colors.replyLine, + marginBottom: 4, + }, + ]} + /> + )} + </View> + + <View style={{paddingTop: 12, flexShrink: 1}}> + {isReasonFeedSource(reason) ? ( + <Link href={reason.href}> + <Text + type="sm-bold" + style={pal.textLight} + lineHeight={1.2} + numberOfLines={1}> + <Trans context="from-feed"> + From{' '} + <FeedNameText + type="sm-bold" + uri={reason.uri} + href={reason.href} + lineHeight={1.2} + numberOfLines={1} + style={pal.textLight} + /> + </Trans> + </Text> + </Link> + ) : AppBskyFeedDefs.isReasonRepost(reason) ? ( + <Link + style={styles.includeReason} + href={makeProfileLink(reason.by)} + title={ + isOwner + ? _(msg`Reposted by you`) + : _( + msg`Reposted by ${sanitizeDisplayName( + reason.by.displayName || reason.by.handle, + )}`, + ) + } + onBeforePress={onOpenReposter}> + <RepostIcon + style={{color: pal.colors.textLight, marginRight: 3}} + width={13} + height={13} + /> + <Text + type="sm-bold" + style={pal.textLight} + lineHeight={1.2} + numberOfLines={1}> + {isOwner ? ( + <Trans>Reposted by you</Trans> + ) : ( + <Trans> + Reposted by{' '} + <ProfileHoverCard inline did={reason.by.did}> + <TextLinkOnWebOnly + type="sm-bold" + style={pal.textLight} + lineHeight={1.2} + numberOfLines={1} + text={ + <Text + emoji + type="sm-bold" + style={pal.textLight} + lineHeight={1.2}> + {sanitizeDisplayName( + reason.by.displayName || + sanitizeHandle(reason.by.handle), + moderation.ui('displayName'), + )} + </Text> + } + href={makeProfileLink(reason.by)} + onBeforePress={onOpenReposter} + /> + </ProfileHoverCard> + </Trans> + )} + </Text> + </Link> + ) : AppBskyFeedDefs.isReasonPin(reason) ? ( + <View style={styles.includeReason}> + <PinIcon + style={{color: pal.colors.textLight, marginRight: 3}} + width={13} + height={13} + /> + <Text + type="sm-bold" + style={pal.textLight} + lineHeight={1.2} + numberOfLines={1}> + <Trans>Pinned</Trans> + </Text> + </View> + ) : null} + </View> + </View> + + <View style={styles.layout}> + <View style={styles.layoutAvi}> + <AviFollowButton author={post.author} moderation={moderation}> + <PreviewableUserAvatar + size={42} + profile={post.author} + moderation={moderation.ui('avatar')} + type={post.author.associated?.labeler ? 'labeler' : 'user'} + onBeforePress={onOpenAuthor} + /> + </AviFollowButton> + {isThreadParent && ( + <View + style={[ + styles.replyLine, + { + flexGrow: 1, + backgroundColor: pal.colors.replyLine, + marginTop: 4, + }, + ]} + /> + )} + </View> + <View style={styles.layoutContent}> + <PostMeta + author={post.author} + moderation={moderation} + timestamp={post.indexedAt} + postHref={href} + onOpenAuthor={onOpenAuthor} + /> + {showReplyTo && + (parentAuthor || isParentBlocked || isParentNotFound) && ( + <ReplyToLabel + blocked={isParentBlocked} + notFound={isParentNotFound} + profile={parentAuthor} + /> + )} + <LabelsOnMyPost post={post} /> + <PostContent + moderation={moderation} + richText={richText} + postEmbed={post.embed} + postAuthor={post.author} + onOpenEmbed={onOpenEmbed} + post={post} + threadgateRecord={threadgateRecord} + /> + <PostCtrls + post={post} + record={record} + richText={richText} + onPressReply={onPressReply} + logContext="FeedItem" + feedContext={feedContext} + threadgateRecord={threadgateRecord} + /> + </View> + </View> + </Link> + ) +} +FeedItemInner = memo(FeedItemInner) + +let PostContent = ({ + post, + moderation, + richText, + postEmbed, + postAuthor, + onOpenEmbed, + threadgateRecord, +}: { + moderation: ModerationDecision + richText: RichTextAPI + postEmbed: AppBskyFeedDefs.PostView['embed'] + postAuthor: AppBskyFeedDefs.PostView['author'] + onOpenEmbed: () => void + post: AppBskyFeedDefs.PostView + threadgateRecord?: AppBskyFeedThreadgate.Record +}): React.ReactNode => { + const pal = usePalette('default') + const {_} = useLingui() + const {currentAccount} = useSession() + const [limitLines, setLimitLines] = useState( + () => countLines(richText.text) >= MAX_POST_LINES, + ) + const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ + threadgateRecord, + }) + const additionalPostAlerts: AppModerationCause[] = React.useMemo(() => { + const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri) + const rootPostUri = AppBskyFeedPost.isRecord(post.record) + ? post.record?.reply?.root?.uri || post.uri + : undefined + const isControlledByViewer = + rootPostUri && new AtUri(rootPostUri).host === currentAccount?.did + return isControlledByViewer && isPostHiddenByThreadgate + ? [ + { + type: 'reply-hidden', + source: {type: 'user', did: currentAccount?.did}, + priority: 6, + }, + ] + : [] + }, [post, currentAccount?.did, threadgateHiddenReplies]) + + const onPressShowMore = React.useCallback(() => { + setLimitLines(false) + }, [setLimitLines]) + + return ( + <ContentHider + testID="contentHider-post" + modui={moderation.ui('contentList')} + ignoreMute + childContainerStyle={styles.contentHiderChild}> + <PostAlerts + modui={moderation.ui('contentList')} + style={[a.py_2xs]} + additionalCauses={additionalPostAlerts} + /> + {richText.text ? ( + <View style={styles.postTextContainer}> + <RichText + enableTags + testID="postText" + value={richText} + numberOfLines={limitLines ? MAX_POST_LINES : undefined} + style={[a.flex_1, a.text_md]} + authorHandle={postAuthor.handle} + /> + </View> + ) : undefined} + {limitLines ? ( + <TextLink + text={_(msg`Show More`)} + style={pal.link} + onPress={onPressShowMore} + href="#" + /> + ) : undefined} + {postEmbed ? ( + <View style={[a.pb_xs]}> + <PostEmbeds + embed={postEmbed} + moderation={moderation} + onOpen={onOpenEmbed} + viewContext={PostEmbedViewContext.Feed} + /> + </View> + ) : null} + </ContentHider> + ) +} +PostContent = memo(PostContent) + +function ReplyToLabel({ + profile, + blocked, + notFound, +}: { + profile: AppBskyActorDefs.ProfileViewBasic | undefined + blocked?: boolean + notFound?: boolean +}) { + const pal = usePalette('default') + const {currentAccount} = useSession() + + let label + if (blocked) { + label = <Trans context="description">Reply to a blocked post</Trans> + } else if (notFound) { + label = <Trans context="description">Reply to a post</Trans> + } else if (profile != null) { + const isMe = profile.did === currentAccount?.did + if (isMe) { + label = <Trans context="description">Reply to you</Trans> + } else { + label = ( + <Trans context="description"> + Reply to{' '} + <ProfileHoverCard inline did={profile.did}> + <TextLinkOnWebOnly + type="md" + style={pal.textLight} + lineHeight={1.2} + numberOfLines={1} + href={makeProfileLink(profile)} + text={ + <Text emoji type="md" style={pal.textLight} lineHeight={1.2}> + {profile.displayName + ? sanitizeDisplayName(profile.displayName) + : sanitizeHandle(profile.handle)} + </Text> + } + /> + </ProfileHoverCard> + </Trans> + ) + } + } + + if (!label) { + // Should not happen. + return null + } + + return ( + <View style={[s.flexRow, s.mb2, s.alignCenter]}> + <FontAwesomeIcon + icon="reply" + size={9} + style={[{color: pal.colors.textLight} as FontAwesomeIconStyle, s.mr5]} + /> + <Text + type="md" + style={[pal.textLight, s.mr2]} + lineHeight={1.2} + numberOfLines={1}> + {label} + </Text> + </View> + ) +} + +const styles = StyleSheet.create({ + outer: { + paddingLeft: 10, + paddingRight: 15, + // @ts-ignore web only -prf + cursor: 'pointer', + }, + replyLine: { + width: 2, + marginLeft: 'auto', + marginRight: 'auto', + }, + includeReason: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 2, + marginBottom: 2, + marginLeft: -16, + }, + layout: { + flexDirection: 'row', + marginTop: 1, + }, + layoutAvi: { + paddingLeft: 8, + paddingRight: 10, + position: 'relative', + zIndex: 999, + }, + layoutContent: { + position: 'relative', + flex: 1, + zIndex: 0, + }, + alert: { + marginTop: 6, + marginBottom: 6, + }, + postTextContainer: { + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + paddingBottom: 2, + overflow: 'hidden', + }, + contentHiderChild: { + marginTop: 6, + }, + embed: { + marginBottom: 6, + }, + translateLink: { + marginBottom: 6, + }, +}) |