Bluesky app fork with some witchin' additions 馃挮
at readme-update 541 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 {useQueryClient} from '@tanstack/react-query' 13 14import {useActorStatus} from '#/lib/actor-status' 15import {type ReasonFeedSource} from '#/lib/api/feed/types' 16import {MAX_POST_LINES} from '#/lib/constants' 17import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 18import {usePalette} from '#/lib/hooks/usePalette' 19import {makeProfileLink} from '#/lib/routes/links' 20import {countLines} from '#/lib/strings/helpers' 21import { 22 POST_TOMBSTONE, 23 type Shadow, 24 usePostShadow, 25} from '#/state/cache/post-shadow' 26import {useFeedFeedbackContext} from '#/state/feed-feedback' 27import {unstableCacheProfileView} from '#/state/queries/profile' 28import {useSession} from '#/state/session' 29import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' 30import { 31 buildPostSourceKey, 32 setUnstablePostSource, 33} from '#/state/unstable-post-source' 34import {Link} from '#/view/com/util/Link' 35import {PostMeta} from '#/view/com/util/PostMeta' 36import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' 37import {atoms as a} from '#/alf' 38import {ContentHider} from '#/components/moderation/ContentHider' 39import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' 40import {PostAlerts} from '#/components/moderation/PostAlerts' 41import {type AppModerationCause} from '#/components/Pills' 42import {Embed} from '#/components/Post/Embed' 43import {PostEmbedViewContext} from '#/components/Post/Embed/types' 44import {PostRepliedTo} from '#/components/Post/PostRepliedTo' 45import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton' 46import {PostControls} from '#/components/PostControls' 47import {DiscoverDebug} from '#/components/PostControls/DiscoverDebug' 48import {RichText} from '#/components/RichText' 49import {SubtleHover} from '#/components/SubtleHover' 50import {useAnalytics} from '#/analytics' 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 }) 197 } 198 199 const onOpenAuthor = () => { 200 sendInteraction({ 201 item: post.uri, 202 event: 'app.bsky.feed.defs#clickthroughAuthor', 203 feedContext, 204 reqId, 205 }) 206 ax.metric('post:clickthroughAuthor', { 207 uri: post.uri, 208 authorDid: post.author.did, 209 logContext: 'FeedItem', 210 feedDescriptor, 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 ax.metric('post:clickthroughEmbed', { 231 uri: post.uri, 232 authorDid: post.author.did, 233 logContext: 'FeedItem', 234 feedDescriptor, 235 }) 236 } 237 238 const onBeforePress = () => { 239 sendInteraction({ 240 item: post.uri, 241 event: 'app.bsky.feed.defs#clickthroughItem', 242 feedContext, 243 reqId, 244 }) 245 ax.metric('post:clickthroughItem', { 246 uri: post.uri, 247 authorDid: post.author.did, 248 logContext: 'FeedItem', 249 feedDescriptor, 250 }) 251 unstableCacheProfileView(queryClient, post.author) 252 setUnstablePostSource(buildPostSourceKey(post.uri, post.author.handle), { 253 feedSourceInfo, 254 post: { 255 post, 256 reason: AppBskyFeedDefs.isReasonRepost(reason) ? reason : undefined, 257 feedContext, 258 reqId, 259 }, 260 }) 261 } 262 263 const outerStyles = [ 264 styles.outer, 265 { 266 borderColor: pal.colors.border, 267 paddingBottom: 268 isThreadLastChild || (!isThreadChild && !isThreadParent) 269 ? 8 270 : undefined, 271 borderTopWidth: 272 hideTopBorder || isThreadChild ? 0 : StyleSheet.hairlineWidth, 273 }, 274 ] 275 276 /** 277 * If `post[0]` in this slice is the actual root post (not an orphan thread), 278 * then we may have a threadgate record to reference 279 */ 280 const threadgateRecord = bsky.dangerousIsType<AppBskyFeedThreadgate.Record>( 281 rootPost.threadgate?.record, 282 AppBskyFeedThreadgate.isRecord, 283 ) 284 ? rootPost.threadgate.record 285 : undefined 286 287 const {isActive: live} = useActorStatus(post.author) 288 289 const viaRepost = useMemo(() => { 290 if (AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid) { 291 return { 292 uri: reason.uri, 293 cid: reason.cid, 294 } 295 } 296 }, [reason]) 297 298 return ( 299 <Link 300 testID={`feedItem-by-${post.author.handle}`} 301 style={outerStyles} 302 href={href} 303 noFeedback 304 accessible={false} 305 onBeforePress={onBeforePress} 306 dataSet={{feedContext}} 307 onPointerEnter={() => { 308 setHover(true) 309 }} 310 onPointerLeave={() => { 311 setHover(false) 312 }}> 313 <SubtleHover hover={hover} /> 314 <View style={{flexDirection: 'row', gap: 10, paddingLeft: 8}}> 315 <View style={{width: isCarouselItem ? 0 : 42}}> 316 {isThreadChild && ( 317 <View 318 style={[ 319 styles.replyLine, 320 { 321 flexGrow: 1, 322 backgroundColor: pal.colors.replyLine, 323 marginBottom: 4, 324 }, 325 ]} 326 /> 327 )} 328 </View> 329 330 <View style={[a.pt_sm, a.flex_shrink]}> 331 {reason && ( 332 <PostFeedReason 333 reason={reason} 334 moderation={moderation} 335 onOpenReposter={onOpenReposter} 336 /> 337 )} 338 </View> 339 </View> 340 341 <View style={styles.layout}> 342 <View style={styles.layoutAvi}> 343 <PreviewableUserAvatar 344 size={42} 345 profile={post.author} 346 moderation={moderation.ui('avatar')} 347 type={post.author.associated?.labeler ? 'labeler' : 'user'} 348 onBeforePress={onOpenAuthor} 349 live={live} 350 /> 351 {isThreadParent && ( 352 <View 353 style={[ 354 styles.replyLine, 355 { 356 flexGrow: 1, 357 backgroundColor: pal.colors.replyLine, 358 marginTop: live ? 8 : 4, 359 }, 360 ]} 361 /> 362 )} 363 </View> 364 <View style={styles.layoutContent}> 365 <PostMeta 366 author={post.author} 367 moderation={moderation} 368 timestamp={post.indexedAt} 369 postHref={href} 370 onOpenAuthor={onOpenAuthor} 371 /> 372 {showReplyTo && 373 (parentAuthor || isParentBlocked || isParentNotFound) && ( 374 <PostRepliedTo 375 parentAuthor={parentAuthor} 376 isParentBlocked={isParentBlocked} 377 isParentNotFound={isParentNotFound} 378 /> 379 )} 380 <LabelsOnMyPost post={post} /> 381 <PostContent 382 moderation={moderation} 383 richText={richText} 384 postEmbed={post.embed} 385 postAuthor={post.author} 386 onOpenEmbed={onOpenEmbed} 387 post={post} 388 threadgateRecord={threadgateRecord} 389 /> 390 <PostControls 391 post={post} 392 record={record} 393 richText={richText} 394 onPressReply={onPressReply} 395 logContext="FeedItem" 396 feedContext={feedContext} 397 reqId={reqId} 398 threadgateRecord={threadgateRecord} 399 onShowLess={onShowLess} 400 viaRepost={viaRepost} 401 /> 402 </View> 403 404 <DiscoverDebug feedContext={feedContext} /> 405 </View> 406 </Link> 407 ) 408} 409FeedItemInner = memo(FeedItemInner) 410 411let PostContent = ({ 412 post, 413 moderation, 414 richText, 415 postEmbed, 416 postAuthor, 417 onOpenEmbed, 418 threadgateRecord, 419}: { 420 moderation: ModerationDecision 421 richText: RichTextAPI 422 postEmbed: AppBskyFeedDefs.PostView['embed'] 423 postAuthor: AppBskyFeedDefs.PostView['author'] 424 onOpenEmbed: () => void 425 post: AppBskyFeedDefs.PostView 426 threadgateRecord?: AppBskyFeedThreadgate.Record 427}): React.ReactNode => { 428 const {currentAccount} = useSession() 429 const [limitLines, setLimitLines] = useState( 430 () => countLines(richText.text) >= MAX_POST_LINES, 431 ) 432 const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ 433 threadgateRecord, 434 }) 435 const additionalPostAlerts: AppModerationCause[] = useMemo(() => { 436 const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri) 437 const rootPostUri = bsky.dangerousIsType<AppBskyFeedPost.Record>( 438 post.record, 439 AppBskyFeedPost.isRecord, 440 ) 441 ? post.record?.reply?.root?.uri || post.uri 442 : undefined 443 const isControlledByViewer = 444 rootPostUri && new AtUri(rootPostUri).host === currentAccount?.did 445 return isControlledByViewer && isPostHiddenByThreadgate 446 ? [ 447 { 448 type: 'reply-hidden', 449 source: {type: 'user', did: currentAccount?.did}, 450 priority: 6, 451 }, 452 ] 453 : [] 454 }, [post, currentAccount?.did, threadgateHiddenReplies]) 455 456 const onPressShowMore = useCallback(() => { 457 setLimitLines(false) 458 }, [setLimitLines]) 459 460 return ( 461 <ContentHider 462 testID="contentHider-post" 463 modui={moderation.ui('contentList')} 464 ignoreMute 465 childContainerStyle={styles.contentHiderChild}> 466 <PostAlerts 467 modui={moderation.ui('contentList')} 468 style={[a.pb_xs]} 469 additionalCauses={additionalPostAlerts} 470 /> 471 {richText.text ? ( 472 <> 473 <RichText 474 enableTags 475 testID="postText" 476 value={richText} 477 numberOfLines={limitLines ? MAX_POST_LINES : undefined} 478 style={[a.flex_1, a.text_md]} 479 authorHandle={postAuthor.handle} 480 shouldProxyLinks={true} 481 /> 482 {limitLines && ( 483 <ShowMoreTextButton style={[a.text_md]} onPress={onPressShowMore} /> 484 )} 485 </> 486 ) : undefined} 487 {postEmbed ? ( 488 <View style={[a.pb_xs]}> 489 <Embed 490 embed={postEmbed} 491 moderation={moderation} 492 onOpen={onOpenEmbed} 493 viewContext={PostEmbedViewContext.Feed} 494 /> 495 </View> 496 ) : null} 497 </ContentHider> 498 ) 499} 500PostContent = memo(PostContent) 501 502const styles = StyleSheet.create({ 503 outer: { 504 paddingLeft: 10, 505 paddingRight: 15, 506 cursor: 'pointer', 507 }, 508 replyLine: { 509 width: 2, 510 marginLeft: 'auto', 511 marginRight: 'auto', 512 }, 513 layout: { 514 flexDirection: 'row', 515 marginTop: 1, 516 }, 517 layoutAvi: { 518 paddingLeft: 8, 519 paddingRight: 10, 520 position: 'relative', 521 zIndex: 999, 522 }, 523 layoutContent: { 524 position: 'relative', 525 flex: 1, 526 zIndex: 0, 527 }, 528 alert: { 529 marginTop: 6, 530 marginBottom: 6, 531 }, 532 contentHiderChild: { 533 marginTop: 6, 534 }, 535 embed: { 536 marginBottom: 6, 537 }, 538 translateLink: { 539 marginBottom: 6, 540 }, 541})