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

Configure Feed

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

at a876aae44ea07494ebea9727350aa060b81f317b 571 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} 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 {currentAccount} = useSession() 175 176 const [hover, setHover] = useState(false) 177 178 const [href] = useMemo(() => { 179 const urip = new AtUri(post.uri) 180 return [makeProfileLink(post.author, 'post', urip.rkey), urip.rkey] 181 }, [post.uri, post.author]) 182 const {sendInteraction, feedSourceInfo, feedDescriptor} = 183 useFeedFeedbackContext() 184 185 const onPressReply = () => { 186 sendInteraction({ 187 item: post.uri, 188 event: 'app.bsky.feed.defs#interactionReply', 189 feedContext, 190 reqId, 191 }) 192 openComposer({ 193 replyTo: { 194 uri: post.uri, 195 cid: post.cid, 196 text: record.text || '', 197 author: post.author, 198 embed: post.embed, 199 moderation, 200 langs: record.langs, 201 }, 202 logContext: 'PostReply', 203 }) 204 } 205 206 const onOpenAuthor = () => { 207 sendInteraction({ 208 item: post.uri, 209 event: 'app.bsky.feed.defs#clickthroughAuthor', 210 feedContext, 211 reqId, 212 }) 213 ax.metric('post:clickthroughAuthor', { 214 uri: post.uri, 215 authorDid: post.author.did, 216 logContext: 'FeedItem', 217 feedDescriptor, 218 }) 219 } 220 221 const onOpenReposter = () => { 222 sendInteraction({ 223 item: post.uri, 224 event: 'app.bsky.feed.defs#clickthroughReposter', 225 feedContext, 226 reqId, 227 }) 228 } 229 230 const onOpenEmbed = () => { 231 sendInteraction({ 232 item: post.uri, 233 event: 'app.bsky.feed.defs#clickthroughEmbed', 234 feedContext, 235 reqId, 236 }) 237 ax.metric('post:clickthroughEmbed', { 238 uri: post.uri, 239 authorDid: post.author.did, 240 logContext: 'FeedItem', 241 feedDescriptor, 242 }) 243 } 244 245 const onBeforePress = () => { 246 sendInteraction({ 247 item: post.uri, 248 event: 'app.bsky.feed.defs#clickthroughItem', 249 feedContext, 250 reqId, 251 }) 252 ax.metric('post:clickthroughItem', { 253 uri: post.uri, 254 authorDid: post.author.did, 255 logContext: 'FeedItem', 256 feedDescriptor, 257 }) 258 unstableCacheProfileView(queryClient, post.author) 259 setUnstablePostSource(buildPostSourceKey(post.uri, post.author.handle), { 260 feedSourceInfo, 261 post: { 262 post, 263 reason: AppBskyFeedDefs.isReasonRepost(reason) ? reason : undefined, 264 feedContext, 265 reqId, 266 }, 267 }) 268 } 269 270 const outerStyles = [ 271 styles.outer, 272 { 273 borderColor: pal.colors.border, 274 paddingBottom: 275 isThreadLastChild || (!isThreadChild && !isThreadParent) 276 ? 8 277 : undefined, 278 borderTopWidth: 279 hideTopBorder || isThreadChild ? 0 : StyleSheet.hairlineWidth, 280 }, 281 ] 282 283 /** 284 * If `post[0]` in this slice is the actual root post (not an orphan thread), 285 * then we may have a threadgate record to reference 286 */ 287 const threadgateRecord = bsky.dangerousIsType<AppBskyFeedThreadgate.Record>( 288 rootPost.threadgate?.record, 289 AppBskyFeedThreadgate.isRecord, 290 ) 291 ? rootPost.threadgate.record 292 : undefined 293 294 const {isActive: live} = useActorStatus(post.author) 295 296 const viaRepost = useMemo(() => { 297 if (AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid) { 298 return { 299 uri: reason.uri, 300 cid: reason.cid, 301 } 302 } 303 }, [reason]) 304 305 const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ 306 threadgateRecord, 307 }) 308 const additionalPostAlerts: AppModerationCause[] = useMemo(() => { 309 const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri) 310 const rootPostUri = bsky.dangerousIsType<AppBskyFeedPost.Record>( 311 post.record, 312 AppBskyFeedPost.isRecord, 313 ) 314 ? post.record?.reply?.root?.uri || post.uri 315 : undefined 316 const isControlledByViewer = 317 rootPostUri && new AtUri(rootPostUri).host === currentAccount?.did 318 return isControlledByViewer && isPostHiddenByThreadgate 319 ? [ 320 { 321 type: 'reply-hidden', 322 source: {type: 'user', did: currentAccount?.did}, 323 priority: 6, 324 }, 325 ] 326 : [] 327 }, [post, currentAccount?.did, threadgateHiddenReplies]) 328 329 return ( 330 <GalleryBleed> 331 <Link 332 testID={`feedItem-by-${post.author.handle}`} 333 style={outerStyles} 334 href={href} 335 noFeedback 336 accessible={false} 337 onBeforePress={onBeforePress} 338 dataSet={{feedContext}} 339 onPointerEnter={() => { 340 setHover(true) 341 }} 342 onPointerLeave={() => { 343 setHover(false) 344 }}> 345 <SubtleHover hover={hover} /> 346 <View style={{flexDirection: 'row', gap: 10, paddingLeft: 8}}> 347 <View style={{width: isCarouselItem ? 0 : 42}}> 348 {isThreadChild && ( 349 <View 350 style={[ 351 styles.replyLine, 352 { 353 flexGrow: 1, 354 backgroundColor: pal.colors.replyLine, 355 marginBottom: 4, 356 }, 357 ]} 358 /> 359 )} 360 </View> 361 362 <View style={[a.pt_sm, a.flex_shrink]}> 363 {reason && ( 364 <PostFeedReason 365 reason={reason} 366 moderation={moderation} 367 onOpenReposter={onOpenReposter} 368 /> 369 )} 370 </View> 371 </View> 372 373 <View style={styles.layout}> 374 <View style={styles.layoutAvi}> 375 <PreviewableUserAvatar 376 size={42} 377 profile={post.author} 378 moderation={moderation.ui('avatar')} 379 type={post.author.associated?.labeler ? 'labeler' : 'user'} 380 onBeforePress={onOpenAuthor} 381 live={live} 382 /> 383 {isThreadParent && ( 384 <View 385 style={[ 386 styles.replyLine, 387 { 388 flexGrow: 1, 389 backgroundColor: pal.colors.replyLine, 390 marginTop: live ? 8 : 4, 391 }, 392 ]} 393 /> 394 )} 395 </View> 396 <View 397 style={[ 398 styles.layoutContent, 399 maybeApplyGalleryOffsetStyles('meta', { 400 post, 401 modui: moderation.ui('contentList'), 402 additionalCauses: additionalPostAlerts, 403 }), 404 ]}> 405 <PostMeta 406 author={post.author} 407 moderation={moderation} 408 timestamp={post.indexedAt} 409 postHref={href} 410 onOpenAuthor={onOpenAuthor} 411 /> 412 {showReplyTo && 413 (parentAuthor || isParentBlocked || isParentNotFound) && ( 414 <PostRepliedTo 415 parentAuthor={parentAuthor} 416 isParentBlocked={isParentBlocked} 417 isParentNotFound={isParentNotFound} 418 /> 419 )} 420 <LabelsOnMyPost post={post} /> 421 <PostContent 422 moderation={moderation} 423 richText={richText} 424 postEmbed={post.embed} 425 postAuthor={post.author} 426 onOpenEmbed={onOpenEmbed} 427 post={post} 428 additionalPostAlerts={additionalPostAlerts} 429 /> 430 <PostControls 431 post={post} 432 record={record} 433 richText={richText} 434 onPressReply={onPressReply} 435 logContext="FeedItem" 436 feedContext={feedContext} 437 reqId={reqId} 438 threadgateRecord={threadgateRecord} 439 onShowLess={onShowLess} 440 viaRepost={viaRepost} 441 /> 442 </View> 443 444 <DiscoverDebug feedContext={feedContext} /> 445 </View> 446 </Link> 447 </GalleryBleed> 448 ) 449} 450FeedItemInner = memo(FeedItemInner) 451 452let PostContent = ({ 453 post, 454 moderation, 455 richText, 456 postEmbed, 457 postAuthor, 458 onOpenEmbed, 459 additionalPostAlerts, 460}: { 461 moderation: ModerationDecision 462 richText: RichTextAPI 463 postEmbed: AppBskyFeedDefs.PostView['embed'] 464 postAuthor: AppBskyFeedDefs.PostView['author'] 465 onOpenEmbed: () => void 466 post: AppBskyFeedDefs.PostView 467 additionalPostAlerts?: AppModerationCause[] 468}): React.ReactNode => { 469 const [limitLines, setLimitLines] = useState( 470 () => countLines(richText.text) >= MAX_POST_LINES, 471 ) 472 473 const record = useMemo<AppBskyFeedPost.Record | undefined>( 474 () => 475 bsky.validate(post.record, AppBskyFeedPost.validateRecord) 476 ? post.record 477 : undefined, 478 [post], 479 ) 480 481 const onPressShowMore = useCallback(() => { 482 setLimitLines(false) 483 }, [setLimitLines]) 484 485 return ( 486 <ContentHider 487 testID="contentHider-post" 488 modui={moderation.ui('contentList')} 489 ignoreMute 490 childContainerStyle={styles.contentHiderChild}> 491 <PostAlerts 492 modui={moderation.ui('contentList')} 493 style={[a.pb_xs]} 494 additionalCauses={additionalPostAlerts} 495 /> 496 {richText.text ? ( 497 <View style={[a.mb_2xs]}> 498 <RichText 499 enableTags 500 testID="postText" 501 value={richText} 502 numberOfLines={limitLines ? MAX_POST_LINES : undefined} 503 style={[a.flex_1, a.text_md]} 504 authorHandle={postAuthor.handle} 505 shouldProxyLinks={true} 506 /> 507 {limitLines && ( 508 <ShowMoreTextButton style={[a.text_md]} onPress={onPressShowMore} /> 509 )} 510 </View> 511 ) : undefined} 512 {record && <TranslatedPost hideTranslateLink post={post} />} 513 {postEmbed ? ( 514 <View 515 style={[ 516 a.pb_xs, 517 maybeApplyGalleryOffsetStyles('embed', { 518 post, 519 modui: moderation.ui('contentList'), 520 additionalCauses: additionalPostAlerts, 521 }), 522 ]}> 523 <Embed 524 embed={postEmbed} 525 moderation={moderation} 526 onOpen={onOpenEmbed} 527 viewContext={PostEmbedViewContext.Feed} 528 /> 529 </View> 530 ) : null} 531 </ContentHider> 532 ) 533} 534PostContent = memo(PostContent) 535 536const styles = StyleSheet.create({ 537 outer: { 538 paddingLeft: 10, 539 paddingRight: 15, 540 cursor: 'pointer', 541 }, 542 replyLine: { 543 width: 2, 544 marginLeft: 'auto', 545 marginRight: 'auto', 546 }, 547 layout: { 548 flexDirection: 'row', 549 marginTop: 1, 550 }, 551 layoutAvi: { 552 paddingLeft: 8, 553 paddingRight: 10, 554 }, 555 layoutContent: { 556 flex: 1, 557 }, 558 alert: { 559 marginTop: 6, 560 marginBottom: 6, 561 }, 562 contentHiderChild: { 563 marginTop: 6, 564 }, 565 embed: { 566 marginBottom: 6, 567 }, 568 translateLink: { 569 marginBottom: 6, 570 }, 571})