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