this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at e28f6d2f370b4e882ed6f23d08ca0f8d94dbac5f 548 lines 15 kB view raw
1import {memo, useCallback, useMemo, useState} from 'react' 2import {StyleSheet, View} from 'react-native' 3import { 4 type AppBskyActorDefs, 5 AppBskyFeedDefs, 6 AppBskyFeedPost, 7 AppBskyFeedThreadgate, 8 AtUri, 9 type ModerationDecision, 10 RichText as RichTextAPI, 11} from '@atproto/api' 12import {useQueryClient} from '@tanstack/react-query' 13 14import {type ReasonFeedSource} from '#/lib/api/feed/types' 15import {MAX_POST_LINES} from '#/lib/constants' 16import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 17import {usePalette} from '#/lib/hooks/usePalette' 18import {makeProfileLink} from '#/lib/routes/links' 19import {countLines} from '#/lib/strings/helpers' 20import { 21 POST_TOMBSTONE, 22 type Shadow, 23 usePostShadow, 24} from '#/state/cache/post-shadow' 25import {useFeedFeedbackContext} from '#/state/feed-feedback' 26import {unstableCacheProfileView} from '#/state/queries/profile' 27import {useSession} from '#/state/session' 28import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' 29import { 30 buildPostSourceKey, 31 setUnstablePostSource, 32} from '#/state/unstable-post-source' 33import {Link} from '#/view/com/util/Link' 34import {PostMeta} from '#/view/com/util/PostMeta' 35import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' 36import {atoms as a} from '#/alf' 37import {ContentHider} from '#/components/moderation/ContentHider' 38import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' 39import {PostAlerts} from '#/components/moderation/PostAlerts' 40import {type AppModerationCause} from '#/components/Pills' 41import {Embed} from '#/components/Post/Embed' 42import {PostEmbedViewContext} from '#/components/Post/Embed/types' 43import {PostRepliedTo} from '#/components/Post/PostRepliedTo' 44import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton' 45import {TranslatedPost} from '#/components/Post/Translated' 46import {PostControls} from '#/components/PostControls' 47import {DiscoverDebug} from '#/components/PostControls/DiscoverDebug' 48import {RichText} from '#/components/RichText' 49import {SubtleHover} from '#/components/SubtleHover' 50import {useAnalytics} from '#/analytics' 51import {useActorStatus} from '#/features/liveNow' 52import * as bsky from '#/types/bsky' 53import {PostFeedReason} from './PostFeedReason' 54 55interface FeedItemProps { 56 record: AppBskyFeedPost.Record 57 reason: 58 | AppBskyFeedDefs.ReasonRepost 59 | AppBskyFeedDefs.ReasonPin 60 | ReasonFeedSource 61 | {[k: string]: unknown; $type: string} 62 | undefined 63 moderation: ModerationDecision 64 parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined 65 showReplyTo: boolean 66 isThreadChild?: boolean 67 isThreadLastChild?: boolean 68 isThreadParent?: boolean 69 feedContext: string | undefined 70 reqId: string | undefined 71 hideTopBorder?: boolean 72 isParentBlocked?: boolean 73 isParentNotFound?: boolean 74} 75 76export function PostFeedItem({ 77 post, 78 record, 79 reason, 80 feedContext, 81 reqId, 82 moderation, 83 parentAuthor, 84 showReplyTo, 85 isThreadChild, 86 isThreadLastChild, 87 isThreadParent, 88 hideTopBorder, 89 isParentBlocked, 90 isParentNotFound, 91 rootPost, 92 onShowLess, 93}: FeedItemProps & { 94 post: AppBskyFeedDefs.PostView 95 rootPost: AppBskyFeedDefs.PostView 96 onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void 97}): React.ReactNode { 98 const postShadowed = usePostShadow(post) 99 const richText = useMemo( 100 () => 101 new RichTextAPI({ 102 text: record.text, 103 facets: record.facets, 104 }), 105 [record], 106 ) 107 if (postShadowed === POST_TOMBSTONE) { 108 return null 109 } 110 if (richText && moderation) { 111 return ( 112 <FeedItemInner 113 // Safeguard from clobbering per-post state below: 114 key={postShadowed.uri} 115 post={postShadowed} 116 record={record} 117 reason={reason} 118 feedContext={feedContext} 119 reqId={reqId} 120 richText={richText} 121 parentAuthor={parentAuthor} 122 showReplyTo={showReplyTo} 123 moderation={moderation} 124 isThreadChild={isThreadChild} 125 isThreadLastChild={isThreadLastChild} 126 isThreadParent={isThreadParent} 127 hideTopBorder={hideTopBorder} 128 isParentBlocked={isParentBlocked} 129 isParentNotFound={isParentNotFound} 130 rootPost={rootPost} 131 onShowLess={onShowLess} 132 /> 133 ) 134 } 135 return null 136} 137 138let FeedItemInner = ({ 139 post, 140 record, 141 reason, 142 feedContext, 143 reqId, 144 richText, 145 moderation, 146 parentAuthor, 147 showReplyTo, 148 isThreadChild, 149 isThreadLastChild, 150 isThreadParent, 151 hideTopBorder, 152 isParentBlocked, 153 isParentNotFound, 154 rootPost, 155 onShowLess, 156}: FeedItemProps & { 157 richText: RichTextAPI 158 post: Shadow<AppBskyFeedDefs.PostView> 159 rootPost: AppBskyFeedDefs.PostView 160 onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void 161}): React.ReactNode => { 162 const ax = useAnalytics() 163 const queryClient = useQueryClient() 164 const {openComposer} = useOpenComposer() 165 const pal = usePalette('default') 166 167 const [hover, setHover] = useState(false) 168 169 const [href] = useMemo(() => { 170 const urip = new AtUri(post.uri) 171 return [makeProfileLink(post.author, 'post', urip.rkey), urip.rkey] 172 }, [post.uri, post.author]) 173 const {sendInteraction, feedSourceInfo, feedDescriptor} = 174 useFeedFeedbackContext() 175 176 const onPressReply = () => { 177 sendInteraction({ 178 item: post.uri, 179 event: 'app.bsky.feed.defs#interactionReply', 180 feedContext, 181 reqId, 182 }) 183 openComposer({ 184 replyTo: { 185 uri: post.uri, 186 cid: post.cid, 187 text: record.text || '', 188 author: post.author, 189 embed: post.embed, 190 moderation, 191 langs: record.langs, 192 }, 193 logContext: 'PostReply', 194 }) 195 } 196 197 const onOpenAuthor = () => { 198 sendInteraction({ 199 item: post.uri, 200 event: 'app.bsky.feed.defs#clickthroughAuthor', 201 feedContext, 202 reqId, 203 }) 204 ax.metric('post:clickthroughAuthor', { 205 uri: post.uri, 206 authorDid: post.author.did, 207 logContext: 'FeedItem', 208 feedDescriptor, 209 }) 210 } 211 212 const onOpenReposter = () => { 213 sendInteraction({ 214 item: post.uri, 215 event: 'app.bsky.feed.defs#clickthroughReposter', 216 feedContext, 217 reqId, 218 }) 219 } 220 221 const onOpenEmbed = () => { 222 sendInteraction({ 223 item: post.uri, 224 event: 'app.bsky.feed.defs#clickthroughEmbed', 225 feedContext, 226 reqId, 227 }) 228 ax.metric('post:clickthroughEmbed', { 229 uri: post.uri, 230 authorDid: post.author.did, 231 logContext: 'FeedItem', 232 feedDescriptor, 233 }) 234 } 235 236 const onBeforePress = () => { 237 sendInteraction({ 238 item: post.uri, 239 event: 'app.bsky.feed.defs#clickthroughItem', 240 feedContext, 241 reqId, 242 }) 243 ax.metric('post:clickthroughItem', { 244 uri: post.uri, 245 authorDid: post.author.did, 246 logContext: 'FeedItem', 247 feedDescriptor, 248 }) 249 unstableCacheProfileView(queryClient, post.author) 250 setUnstablePostSource(buildPostSourceKey(post.uri, post.author.handle), { 251 feedSourceInfo, 252 post: { 253 post, 254 reason: AppBskyFeedDefs.isReasonRepost(reason) ? reason : undefined, 255 feedContext, 256 reqId, 257 }, 258 }) 259 } 260 261 const outerStyles = [ 262 styles.outer, 263 { 264 borderColor: pal.colors.border, 265 paddingBottom: 266 isThreadLastChild || (!isThreadChild && !isThreadParent) 267 ? 8 268 : undefined, 269 borderTopWidth: 270 hideTopBorder || isThreadChild ? 0 : StyleSheet.hairlineWidth, 271 }, 272 ] 273 274 /** 275 * If `post[0]` in this slice is the actual root post (not an orphan thread), 276 * then we may have a threadgate record to reference 277 */ 278 const threadgateRecord = bsky.dangerousIsType<AppBskyFeedThreadgate.Record>( 279 rootPost.threadgate?.record, 280 AppBskyFeedThreadgate.isRecord, 281 ) 282 ? rootPost.threadgate.record 283 : undefined 284 285 const {isActive: live} = useActorStatus(post.author) 286 287 const viaRepost = useMemo(() => { 288 if (AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid) { 289 return { 290 uri: reason.uri, 291 cid: reason.cid, 292 } 293 } 294 }, [reason]) 295 296 return ( 297 <Link 298 testID={`feedItem-by-${post.author.handle}`} 299 style={outerStyles} 300 href={href} 301 noFeedback 302 accessible={false} 303 onBeforePress={onBeforePress} 304 dataSet={{feedContext}} 305 onPointerEnter={() => { 306 setHover(true) 307 }} 308 onPointerLeave={() => { 309 setHover(false) 310 }}> 311 <SubtleHover hover={hover} /> 312 <View style={{flexDirection: 'row', gap: 10, paddingLeft: 8}}> 313 <View style={{width: 42}}> 314 {isThreadChild && ( 315 <View 316 style={[ 317 styles.replyLine, 318 { 319 flexGrow: 1, 320 backgroundColor: pal.colors.replyLine, 321 marginBottom: 4, 322 }, 323 ]} 324 /> 325 )} 326 </View> 327 328 <View style={[a.pt_sm, a.flex_shrink]}> 329 {reason && ( 330 <PostFeedReason 331 reason={reason} 332 moderation={moderation} 333 onOpenReposter={onOpenReposter} 334 /> 335 )} 336 </View> 337 </View> 338 339 <View style={styles.layout}> 340 <View style={styles.layoutAvi}> 341 <PreviewableUserAvatar 342 size={42} 343 profile={post.author} 344 moderation={moderation.ui('avatar')} 345 type={post.author.associated?.labeler ? 'labeler' : 'user'} 346 onBeforePress={onOpenAuthor} 347 live={live} 348 /> 349 {isThreadParent && ( 350 <View 351 style={[ 352 styles.replyLine, 353 { 354 flexGrow: 1, 355 backgroundColor: pal.colors.replyLine, 356 marginTop: live ? 8 : 4, 357 }, 358 ]} 359 /> 360 )} 361 </View> 362 <View style={styles.layoutContent}> 363 <PostMeta 364 author={post.author} 365 moderation={moderation} 366 timestamp={post.indexedAt} 367 postHref={href} 368 onOpenAuthor={onOpenAuthor} 369 /> 370 {showReplyTo && 371 (parentAuthor || isParentBlocked || isParentNotFound) && ( 372 <PostRepliedTo 373 parentAuthor={parentAuthor} 374 isParentBlocked={isParentBlocked} 375 isParentNotFound={isParentNotFound} 376 /> 377 )} 378 <LabelsOnMyPost post={post} /> 379 <PostContent 380 moderation={moderation} 381 richText={richText} 382 postEmbed={post.embed} 383 postAuthor={post.author} 384 onOpenEmbed={onOpenEmbed} 385 post={post} 386 threadgateRecord={threadgateRecord} 387 /> 388 <PostControls 389 post={post} 390 record={record} 391 richText={richText} 392 onPressReply={onPressReply} 393 logContext="FeedItem" 394 feedContext={feedContext} 395 reqId={reqId} 396 threadgateRecord={threadgateRecord} 397 onShowLess={onShowLess} 398 viaRepost={viaRepost} 399 /> 400 </View> 401 402 <DiscoverDebug feedContext={feedContext} /> 403 </View> 404 </Link> 405 ) 406} 407FeedItemInner = memo(FeedItemInner) 408 409let PostContent = ({ 410 post, 411 moderation, 412 richText, 413 postEmbed, 414 postAuthor, 415 onOpenEmbed, 416 threadgateRecord, 417}: { 418 moderation: ModerationDecision 419 richText: RichTextAPI 420 postEmbed: AppBskyFeedDefs.PostView['embed'] 421 postAuthor: AppBskyFeedDefs.PostView['author'] 422 onOpenEmbed: () => void 423 post: AppBskyFeedDefs.PostView 424 threadgateRecord?: AppBskyFeedThreadgate.Record 425}): React.ReactNode => { 426 const {currentAccount} = useSession() 427 const [limitLines, setLimitLines] = useState( 428 () => countLines(richText.text) >= MAX_POST_LINES, 429 ) 430 const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ 431 threadgateRecord, 432 }) 433 const additionalPostAlerts: AppModerationCause[] = useMemo(() => { 434 const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri) 435 const rootPostUri = bsky.dangerousIsType<AppBskyFeedPost.Record>( 436 post.record, 437 AppBskyFeedPost.isRecord, 438 ) 439 ? post.record?.reply?.root?.uri || post.uri 440 : undefined 441 const isControlledByViewer = 442 rootPostUri && new AtUri(rootPostUri).host === currentAccount?.did 443 return isControlledByViewer && isPostHiddenByThreadgate 444 ? [ 445 { 446 type: 'reply-hidden', 447 source: {type: 'user', did: currentAccount?.did}, 448 priority: 6, 449 }, 450 ] 451 : [] 452 }, [post, currentAccount?.did, threadgateHiddenReplies]) 453 454 const record = useMemo<AppBskyFeedPost.Record | undefined>( 455 () => 456 bsky.validate(post.record, AppBskyFeedPost.validateRecord) 457 ? post.record 458 : undefined, 459 [post], 460 ) 461 462 const onPressShowMore = useCallback(() => { 463 setLimitLines(false) 464 }, [setLimitLines]) 465 466 return ( 467 <ContentHider 468 testID="contentHider-post" 469 modui={moderation.ui('contentList')} 470 ignoreMute 471 childContainerStyle={styles.contentHiderChild}> 472 <PostAlerts 473 modui={moderation.ui('contentList')} 474 style={[a.pb_xs]} 475 additionalCauses={additionalPostAlerts} 476 /> 477 {richText.text ? ( 478 <View style={[a.mb_2xs]}> 479 <RichText 480 enableTags 481 testID="postText" 482 value={richText} 483 numberOfLines={limitLines ? MAX_POST_LINES : undefined} 484 style={[a.flex_1, a.text_md]} 485 authorHandle={postAuthor.handle} 486 shouldProxyLinks={true} 487 /> 488 {limitLines && ( 489 <ShowMoreTextButton style={[a.text_md]} onPress={onPressShowMore} /> 490 )} 491 </View> 492 ) : undefined} 493 {record && <TranslatedPost hideTranslateLink post={post} />} 494 {postEmbed ? ( 495 <View style={[a.pb_xs]}> 496 <Embed 497 embed={postEmbed} 498 moderation={moderation} 499 onOpen={onOpenEmbed} 500 viewContext={PostEmbedViewContext.Feed} 501 /> 502 </View> 503 ) : null} 504 </ContentHider> 505 ) 506} 507PostContent = memo(PostContent) 508 509const styles = StyleSheet.create({ 510 outer: { 511 paddingLeft: 10, 512 paddingRight: 15, 513 cursor: 'pointer', 514 }, 515 replyLine: { 516 width: 2, 517 marginLeft: 'auto', 518 marginRight: 'auto', 519 }, 520 layout: { 521 flexDirection: 'row', 522 marginTop: 1, 523 }, 524 layoutAvi: { 525 paddingLeft: 8, 526 paddingRight: 10, 527 position: 'relative', 528 zIndex: 999, 529 }, 530 layoutContent: { 531 position: 'relative', 532 flex: 1, 533 zIndex: 0, 534 }, 535 alert: { 536 marginTop: 6, 537 marginBottom: 6, 538 }, 539 contentHiderChild: { 540 marginTop: 6, 541 }, 542 embed: { 543 marginBottom: 6, 544 }, 545 translateLink: { 546 marginBottom: 6, 547 }, 548})