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

Configure Feed

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

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