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