Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

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