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