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