Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

at main 579 lines 16 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, select, useTheme} from '#/alf' 37import { 38 GalleryBleed, 39 maybeApplyGalleryOffsetStyles, 40} from '#/components/images/Gallery' 41import {ContentHider} from '#/components/moderation/ContentHider' 42import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' 43import {PostAlerts} from '#/components/moderation/PostAlerts' 44import {type AppModerationCause} from '#/components/Pills' 45import {Embed} from '#/components/Post/Embed' 46import {PostEmbedViewContext} from '#/components/Post/Embed/types' 47import {PostRepliedTo} from '#/components/Post/PostRepliedTo' 48import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton' 49import {TranslatedPost} from '#/components/Post/Translated' 50import {PostControls} from '#/components/PostControls' 51import {DiscoverDebug} from '#/components/PostControls/DiscoverDebug' 52import {RichText} from '#/components/RichText' 53import {SubtleHover} from '#/components/SubtleHover' 54import {useAnalytics} from '#/analytics' 55import {useActorStatus} from '#/features/liveNow' 56import * as bsky from '#/types/bsky' 57import {PostFeedReason} from './PostFeedReason' 58 59interface FeedItemProps { 60 record: AppBskyFeedPost.Record 61 reason: 62 | AppBskyFeedDefs.ReasonRepost 63 | AppBskyFeedDefs.ReasonPin 64 | ReasonFeedSource 65 | {[k: string]: unknown; $type: string} 66 | undefined 67 moderation: ModerationDecision 68 parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined 69 showReplyTo: boolean 70 isThreadChild?: boolean 71 isThreadLastChild?: boolean 72 isThreadParent?: boolean 73 feedContext: string | undefined 74 reqId: string | undefined 75 hideTopBorder?: boolean 76 isParentBlocked?: boolean 77 isParentNotFound?: boolean 78 isCarouselItem?: boolean 79} 80 81export function PostFeedItem({ 82 post, 83 record, 84 reason, 85 feedContext, 86 reqId, 87 moderation, 88 parentAuthor, 89 showReplyTo, 90 isThreadChild, 91 isThreadLastChild, 92 isThreadParent, 93 hideTopBorder, 94 isParentBlocked, 95 isParentNotFound, 96 rootPost, 97 isCarouselItem, 98 onShowLess, 99}: FeedItemProps & { 100 post: AppBskyFeedDefs.PostView 101 rootPost: AppBskyFeedDefs.PostView 102 onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void 103}): React.ReactNode { 104 const postShadowed = usePostShadow(post) 105 const richText = useMemo( 106 () => 107 new RichTextAPI({ 108 text: record.text, 109 facets: record.facets, 110 }), 111 [record], 112 ) 113 if (postShadowed === POST_TOMBSTONE) { 114 return null 115 } 116 if (richText && moderation) { 117 return ( 118 <FeedItemInner 119 // Safeguard from clobbering per-post state below: 120 key={postShadowed.uri} 121 post={postShadowed} 122 record={record} 123 reason={reason} 124 feedContext={feedContext} 125 reqId={reqId} 126 richText={richText} 127 parentAuthor={parentAuthor} 128 showReplyTo={showReplyTo} 129 moderation={moderation} 130 isThreadChild={isThreadChild} 131 isThreadLastChild={isThreadLastChild} 132 isThreadParent={isThreadParent} 133 hideTopBorder={hideTopBorder} 134 isParentBlocked={isParentBlocked} 135 isParentNotFound={isParentNotFound} 136 isCarouselItem={isCarouselItem} 137 rootPost={rootPost} 138 onShowLess={onShowLess} 139 /> 140 ) 141 } 142 return null 143} 144 145let FeedItemInner = ({ 146 post, 147 record, 148 reason, 149 feedContext, 150 reqId, 151 richText, 152 moderation, 153 parentAuthor, 154 showReplyTo, 155 isThreadChild, 156 isThreadLastChild, 157 isThreadParent, 158 hideTopBorder, 159 isParentBlocked, 160 isParentNotFound, 161 isCarouselItem, 162 rootPost, 163 onShowLess, 164}: FeedItemProps & { 165 richText: RichTextAPI 166 post: Shadow<AppBskyFeedDefs.PostView> 167 rootPost: AppBskyFeedDefs.PostView 168 onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void 169}): React.ReactNode => { 170 const ax = useAnalytics() 171 const queryClient = useQueryClient() 172 const {openComposer} = useOpenComposer() 173 const pal = usePalette('default') 174 const t = useTheme() 175 const {currentAccount} = useSession() 176 177 const [hover, setHover] = useState(false) 178 179 const [href] = useMemo(() => { 180 const urip = new AtUri(post.uri) 181 return [makeProfileLink(post.author, 'post', urip.rkey), urip.rkey] 182 }, [post.uri, post.author]) 183 const {sendInteraction, feedSourceInfo, feedDescriptor} = 184 useFeedFeedbackContext() 185 186 const onPressReply = () => { 187 sendInteraction({ 188 item: post.uri, 189 event: 'app.bsky.feed.defs#interactionReply', 190 feedContext, 191 reqId, 192 }) 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 logContext: 'PostReply', 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 ax.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 ax.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 ax.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 const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ 307 threadgateRecord, 308 }) 309 const additionalPostAlerts: AppModerationCause[] = useMemo(() => { 310 const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri) 311 const rootPostUri = bsky.dangerousIsType<AppBskyFeedPost.Record>( 312 post.record, 313 AppBskyFeedPost.isRecord, 314 ) 315 ? post.record?.reply?.root?.uri || post.uri 316 : undefined 317 const isControlledByViewer = 318 rootPostUri && new AtUri(rootPostUri).host === currentAccount?.did 319 return isControlledByViewer && isPostHiddenByThreadgate 320 ? [ 321 { 322 type: 'reply-hidden', 323 source: {type: 'user', did: currentAccount?.did}, 324 priority: 6, 325 }, 326 ] 327 : [] 328 }, [post, currentAccount?.did, threadgateHiddenReplies]) 329 330 return ( 331 <GalleryBleed> 332 <Link 333 testID={`feedItem-by-${post.author.handle}`} 334 style={outerStyles} 335 href={href} 336 noFeedback 337 accessible={false} 338 onBeforePress={onBeforePress} 339 dataSet={{feedContext}} 340 onPointerEnter={() => { 341 setHover(true) 342 }} 343 onPointerLeave={() => { 344 setHover(false) 345 }}> 346 <SubtleHover hover={hover} /> 347 <View style={{flexDirection: 'row', gap: 10, paddingLeft: 8}}> 348 <View style={{width: isCarouselItem ? 0 : 42}}> 349 {isThreadChild && ( 350 <View 351 style={[ 352 styles.replyLine, 353 { 354 backgroundColor: select(t.name, { 355 light: t.palette.contrast_100, 356 dim: t.palette.contrast_200, 357 dark: t.palette.contrast_200, 358 }), 359 marginBottom: 4, 360 }, 361 ]} 362 /> 363 )} 364 </View> 365 366 <View style={[a.pt_sm, a.flex_shrink]}> 367 {reason && ( 368 <PostFeedReason 369 reason={reason} 370 moderation={moderation} 371 onOpenReposter={onOpenReposter} 372 /> 373 )} 374 </View> 375 </View> 376 377 <View style={styles.layout}> 378 <View style={styles.layoutAvi}> 379 <PreviewableUserAvatar 380 size={42} 381 profile={post.author} 382 moderation={moderation.ui('avatar')} 383 type={post.author.associated?.labeler ? 'labeler' : 'user'} 384 onBeforePress={onOpenAuthor} 385 live={live} 386 /> 387 {isThreadParent && ( 388 <View 389 style={[ 390 styles.replyLine, 391 { 392 backgroundColor: select(t.name, { 393 light: t.palette.contrast_100, 394 dim: t.palette.contrast_200, 395 dark: t.palette.contrast_200, 396 }), 397 marginTop: live ? 8 : 4, 398 }, 399 ]} 400 /> 401 )} 402 </View> 403 <View 404 style={[ 405 styles.layoutContent, 406 maybeApplyGalleryOffsetStyles('meta', { 407 post, 408 modui: moderation.ui('contentList'), 409 additionalCauses: additionalPostAlerts, 410 }), 411 ]}> 412 <PostMeta 413 author={post.author} 414 moderation={moderation} 415 timestamp={post.indexedAt} 416 postHref={href} 417 onOpenAuthor={onOpenAuthor} 418 /> 419 {showReplyTo && 420 (parentAuthor || isParentBlocked || isParentNotFound) && ( 421 <PostRepliedTo 422 parentAuthor={parentAuthor} 423 isParentBlocked={isParentBlocked} 424 isParentNotFound={isParentNotFound} 425 /> 426 )} 427 <LabelsOnMyPost post={post} /> 428 <PostContent 429 moderation={moderation} 430 richText={richText} 431 postEmbed={post.embed} 432 postAuthor={post.author} 433 onOpenEmbed={onOpenEmbed} 434 post={post} 435 additionalPostAlerts={additionalPostAlerts} 436 /> 437 <PostControls 438 post={post} 439 record={record} 440 richText={richText} 441 onPressReply={onPressReply} 442 logContext="FeedItem" 443 feedContext={feedContext} 444 reqId={reqId} 445 threadgateRecord={threadgateRecord} 446 onShowLess={onShowLess} 447 viaRepost={viaRepost} 448 /> 449 </View> 450 451 <DiscoverDebug feedContext={feedContext} /> 452 </View> 453 </Link> 454 </GalleryBleed> 455 ) 456} 457FeedItemInner = memo(FeedItemInner) 458 459let PostContent = ({ 460 post, 461 moderation, 462 richText, 463 postEmbed, 464 postAuthor, 465 onOpenEmbed, 466 additionalPostAlerts, 467}: { 468 moderation: ModerationDecision 469 richText: RichTextAPI 470 postEmbed: AppBskyFeedDefs.PostView['embed'] 471 postAuthor: AppBskyFeedDefs.PostView['author'] 472 onOpenEmbed: () => void 473 post: AppBskyFeedDefs.PostView 474 additionalPostAlerts?: AppModerationCause[] 475}): React.ReactNode => { 476 const [limitLines, setLimitLines] = useState( 477 () => countLines(richText.text) >= MAX_POST_LINES, 478 ) 479 480 const record = useMemo<AppBskyFeedPost.Record | undefined>( 481 () => 482 bsky.validate(post.record, AppBskyFeedPost.validateRecord) 483 ? post.record 484 : undefined, 485 [post], 486 ) 487 488 const onPressShowMore = useCallback(() => { 489 setLimitLines(false) 490 }, [setLimitLines]) 491 492 return ( 493 <ContentHider 494 testID="contentHider-post" 495 modui={moderation.ui('contentList')} 496 ignoreMute 497 childContainerStyle={styles.contentHiderChild}> 498 <PostAlerts 499 modui={moderation.ui('contentList')} 500 style={[a.pb_xs]} 501 additionalCauses={additionalPostAlerts} 502 /> 503 {richText.text ? ( 504 <View style={[a.mb_2xs]}> 505 <RichText 506 enableTags 507 testID="postText" 508 value={richText} 509 numberOfLines={limitLines ? MAX_POST_LINES : undefined} 510 style={[a.flex_1, a.text_md]} 511 authorHandle={postAuthor.handle} 512 shouldProxyLinks={true} 513 /> 514 {limitLines && ( 515 <ShowMoreTextButton style={[a.text_md]} onPress={onPressShowMore} /> 516 )} 517 </View> 518 ) : undefined} 519 {record && <TranslatedPost hideTranslateLink post={post} />} 520 {postEmbed ? ( 521 <View 522 style={[ 523 a.pb_xs, 524 maybeApplyGalleryOffsetStyles('embed', { 525 post, 526 modui: moderation.ui('contentList'), 527 additionalCauses: additionalPostAlerts, 528 }), 529 ]}> 530 <Embed 531 embed={postEmbed} 532 moderation={moderation} 533 onOpen={onOpenEmbed} 534 viewContext={PostEmbedViewContext.Feed} 535 /> 536 </View> 537 ) : null} 538 </ContentHider> 539 ) 540} 541PostContent = memo(PostContent) 542 543const styles = StyleSheet.create({ 544 outer: { 545 paddingLeft: 10, 546 paddingRight: 15, 547 cursor: 'pointer', 548 }, 549 replyLine: { 550 flexGrow: 1, 551 width: 2, 552 marginLeft: 'auto', 553 marginRight: 'auto', 554 }, 555 layout: { 556 flexDirection: 'row', 557 marginTop: 1, 558 }, 559 layoutAvi: { 560 paddingLeft: 8, 561 paddingRight: 10, 562 }, 563 layoutContent: { 564 flex: 1, 565 }, 566 alert: { 567 marginTop: 6, 568 marginBottom: 6, 569 }, 570 contentHiderChild: { 571 marginTop: 6, 572 }, 573 embed: { 574 marginBottom: 6, 575 }, 576 translateLink: { 577 marginBottom: 6, 578 }, 579})