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

Configure Feed

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

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