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

Configure Feed

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

at main 454 lines 14 kB view raw
1import {memo, useCallback, useMemo, useState} from 'react' 2import {View} from 'react-native' 3import { 4 type AppBskyFeedDefs, 5 type AppBskyFeedThreadgate, 6 AtUri, 7 RichText as RichTextAPI, 8} from '@atproto/api' 9import {Trans} from '@lingui/react/macro' 10 11import {MAX_POST_LINES} from '#/lib/constants' 12import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 13import {makeProfileLink} from '#/lib/routes/links' 14import {countLines} from '#/lib/strings/helpers' 15import { 16 POST_TOMBSTONE, 17 type Shadow, 18 usePostShadow, 19} from '#/state/cache/post-shadow' 20import {useEnableSquareAvatars} from '#/state/preferences/enable-square-avatars' 21import {type ThreadItem} from '#/state/queries/usePostThread/types' 22import {useSession} from '#/state/session' 23import {type OnPostSuccessData} from '#/state/shell/composer' 24import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' 25import {PostMeta} from '#/view/com/util/PostMeta' 26import { 27 OUTER_SPACE, 28 REPLY_LINE_WIDTH, 29 TREE_AVI_WIDTH, 30 TREE_INDENT, 31} from '#/screens/PostThread/const' 32import {atoms as a, useTheme} from '#/alf' 33import {DebugFieldDisplay} from '#/components/DebugFieldDisplay' 34import {useInteractionState} from '#/components/hooks/useInteractionState' 35import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 36import {GalleryBleed} from '#/components/images/Gallery' 37import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' 38import {PostAlerts} from '#/components/moderation/PostAlerts' 39import {PostHider} from '#/components/moderation/PostHider' 40import {type AppModerationCause} from '#/components/Pills' 41import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' 42import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton' 43import {TranslatedPost} from '#/components/Post/Translated' 44import {PostControls, PostControlsSkeleton} from '#/components/PostControls' 45import {RichText} from '#/components/RichText' 46import * as Skele from '#/components/Skeleton' 47import {SubtleHover} from '#/components/SubtleHover' 48import {Text} from '#/components/Typography' 49 50/** 51 * Mimic the space in PostMeta 52 */ 53const TREE_AVI_PLUS_SPACE = TREE_AVI_WIDTH + a.gap_xs.gap 54 55export function ThreadItemTreePost({ 56 item, 57 overrides, 58 onPostSuccess, 59 threadgateRecord, 60}: { 61 item: Extract<ThreadItem, {type: 'threadPost'}> 62 overrides?: { 63 moderation?: boolean 64 topBorder?: boolean 65 } 66 onPostSuccess?: (data: OnPostSuccessData) => void 67 threadgateRecord?: AppBskyFeedThreadgate.Record 68}) { 69 const postShadow = usePostShadow(item.value.post) 70 71 if (postShadow === POST_TOMBSTONE) { 72 return <ThreadItemTreePostDeleted item={item} /> 73 } 74 75 return ( 76 <ThreadItemTreePostInner 77 // Safeguard from clobbering per-post state below: 78 key={postShadow.uri} 79 item={item} 80 postShadow={postShadow} 81 threadgateRecord={threadgateRecord} 82 overrides={overrides} 83 onPostSuccess={onPostSuccess} 84 /> 85 ) 86} 87 88function ThreadItemTreePostDeleted({ 89 item, 90}: { 91 item: Extract<ThreadItem, {type: 'threadPost'}> 92}) { 93 const t = useTheme() 94 return ( 95 <ThreadItemTreePostOuterWrapper item={item}> 96 <ThreadItemTreePostInnerWrapper item={item}> 97 <View 98 style={[ 99 a.flex_row, 100 a.align_center, 101 a.rounded_sm, 102 t.atoms.bg_contrast_25, 103 { 104 gap: 6, 105 paddingHorizontal: OUTER_SPACE / 2, 106 height: TREE_AVI_WIDTH, 107 }, 108 ]}> 109 <TrashIcon style={[t.atoms.text]} width={14} /> 110 <Text style={[t.atoms.text_contrast_medium, a.mt_2xs]}> 111 <Trans>Post has been deleted</Trans> 112 </Text> 113 </View> 114 {item.ui.isLastChild && !item.ui.precedesChildReadMore && ( 115 <View style={{height: OUTER_SPACE / 2}} /> 116 )} 117 </ThreadItemTreePostInnerWrapper> 118 </ThreadItemTreePostOuterWrapper> 119 ) 120} 121 122const ThreadItemTreePostOuterWrapper = memo( 123 function ThreadItemTreePostOuterWrapper({ 124 item, 125 children, 126 }: { 127 item: Extract<ThreadItem, {type: 'threadPost'}> 128 children: React.ReactNode 129 }) { 130 const t = useTheme() 131 const indents = Math.max(0, item.ui.indent - 1) 132 133 return ( 134 <GalleryBleed> 135 <View 136 style={[ 137 a.flex_row, 138 item.ui.indent === 1 && 139 !item.ui.showParentReplyLine && [ 140 a.border_t, 141 t.atoms.border_contrast_low, 142 ], 143 ]}> 144 {Array.from(Array(indents)).map((_, n: number) => { 145 const isSkipped = item.ui.skippedIndentIndices.has(n) 146 return ( 147 <View 148 key={`${item.value.post.uri}-padding-${n}`} 149 style={[ 150 t.atoms.border_contrast_low, 151 { 152 borderRightWidth: isSkipped ? 0 : REPLY_LINE_WIDTH, 153 width: TREE_INDENT + TREE_AVI_WIDTH / 2, 154 left: 1, 155 }, 156 ]} 157 /> 158 ) 159 })} 160 {children} 161 </View> 162 </GalleryBleed> 163 ) 164 }, 165) 166 167const ThreadItemTreePostInnerWrapper = memo( 168 function ThreadItemTreePostInnerWrapper({ 169 item, 170 children, 171 }: { 172 item: Extract<ThreadItem, {type: 'threadPost'}> 173 children: React.ReactNode 174 }) { 175 const t = useTheme() 176 return ( 177 <View 178 style={[ 179 a.flex_1, // TODO check on ios 180 { 181 paddingHorizontal: OUTER_SPACE, 182 paddingTop: OUTER_SPACE / 2, 183 }, 184 item.ui.indent === 1 && [ 185 !item.ui.showParentReplyLine && {paddingTop: OUTER_SPACE / 1.5}, 186 !item.ui.showChildReplyLine && a.pb_sm, 187 ], 188 item.ui.isLastChild && 189 !item.ui.precedesChildReadMore && [ 190 { 191 paddingBottom: OUTER_SPACE / 2, 192 }, 193 ], 194 ]}> 195 {item.ui.indent > 1 && ( 196 <View 197 style={[ 198 a.absolute, 199 t.atoms.border_contrast_low, 200 { 201 left: -1, 202 top: 0, 203 height: 204 TREE_AVI_WIDTH / 2 + REPLY_LINE_WIDTH / 2 + OUTER_SPACE / 2, 205 width: OUTER_SPACE, 206 borderLeftWidth: REPLY_LINE_WIDTH, 207 borderBottomWidth: REPLY_LINE_WIDTH, 208 borderBottomLeftRadius: a.rounded_sm.borderRadius, 209 }, 210 ]} 211 /> 212 )} 213 {children} 214 </View> 215 ) 216 }, 217) 218 219const ThreadItemTreeReplyChildReplyLine = memo( 220 function ThreadItemTreeReplyChildReplyLine({ 221 item, 222 }: { 223 item: Extract<ThreadItem, {type: 'threadPost'}> 224 }) { 225 const t = useTheme() 226 return ( 227 <View style={[a.relative, a.pt_2xs, {width: TREE_AVI_PLUS_SPACE}]}> 228 {item.ui.showChildReplyLine && ( 229 <View 230 style={[ 231 a.flex_1, 232 t.atoms.border_contrast_low, 233 {borderRightWidth: 2, width: '50%', left: -1}, 234 ]} 235 /> 236 )} 237 </View> 238 ) 239 }, 240) 241 242const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({ 243 item, 244 postShadow, 245 overrides, 246 onPostSuccess, 247 threadgateRecord, 248}: { 249 item: Extract<ThreadItem, {type: 'threadPost'}> 250 postShadow: Shadow<AppBskyFeedDefs.PostView> 251 overrides?: { 252 moderation?: boolean 253 topBorder?: boolean 254 } 255 onPostSuccess?: (data: OnPostSuccessData) => void 256 threadgateRecord?: AppBskyFeedThreadgate.Record 257}): React.ReactNode { 258 const {openComposer} = useOpenComposer() 259 const {currentAccount} = useSession() 260 261 const post = item.value.post 262 const record = item.value.post.record 263 const moderation = item.moderation 264 const richText = useMemo( 265 () => 266 new RichTextAPI({ 267 text: record.text, 268 facets: record.facets, 269 }), 270 [record], 271 ) 272 const [limitLines, setLimitLines] = useState( 273 () => countLines(richText?.text) >= MAX_POST_LINES, 274 ) 275 const threadRootUri = record.reply?.root?.uri || post.uri 276 const postHref = useMemo(() => { 277 const urip = new AtUri(post.uri) 278 return makeProfileLink(post.author, 'post', urip.rkey) 279 }, [post.uri, post.author]) 280 const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ 281 threadgateRecord, 282 }) 283 const additionalPostAlerts: AppModerationCause[] = useMemo(() => { 284 const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri) 285 const isControlledByViewer = 286 new AtUri(threadRootUri).host === currentAccount?.did 287 return isControlledByViewer && isPostHiddenByThreadgate 288 ? [ 289 { 290 type: 'reply-hidden', 291 source: {type: 'user', did: currentAccount?.did}, 292 priority: 6, 293 }, 294 ] 295 : [] 296 }, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri]) 297 298 const onPressReply = useCallback(() => { 299 openComposer({ 300 replyTo: { 301 uri: post.uri, 302 cid: post.cid, 303 text: record.text, 304 author: post.author, 305 embed: post.embed, 306 moderation, 307 langs: post.record.langs, 308 }, 309 onPostSuccess: onPostSuccess, 310 logContext: 'PostReply', 311 }) 312 }, [openComposer, post, record, onPostSuccess, moderation]) 313 314 const onPressShowMore = useCallback(() => { 315 setLimitLines(false) 316 }, [setLimitLines]) 317 318 return ( 319 <ThreadItemTreePostOuterWrapper item={item}> 320 <SubtleHoverWrapper> 321 <PostHider 322 testID={`postThreadItem-by-${post.author.handle}`} 323 href={postHref} 324 disabled={overrides?.moderation === true} 325 modui={moderation.ui('contentList')} 326 iconSize={42} 327 iconStyles={{marginLeft: 2, marginRight: 2}} 328 profile={post.author} 329 interpretFilterAsBlur> 330 <ThreadItemTreePostInnerWrapper item={item}> 331 <View style={[a.flex_1]}> 332 <PostMeta 333 author={post.author} 334 moderation={moderation} 335 timestamp={post.indexedAt} 336 postHref={postHref} 337 avatarSize={TREE_AVI_WIDTH} 338 style={[a.pb_0]} 339 showAvatar 340 /> 341 <View style={[a.flex_row]}> 342 <ThreadItemTreeReplyChildReplyLine item={item} /> 343 <View style={[a.flex_1, a.pl_2xs]}> 344 <LabelsOnMyPost post={post} style={[a.pb_2xs]} /> 345 <PostAlerts 346 modui={moderation.ui('contentList')} 347 style={[a.pb_2xs]} 348 additionalCauses={additionalPostAlerts} 349 /> 350 {richText?.text ? ( 351 <View style={[a.mb_2xs]}> 352 <RichText 353 enableTags 354 value={richText} 355 style={[a.flex_1, a.text_md]} 356 numberOfLines={limitLines ? MAX_POST_LINES : undefined} 357 authorHandle={post.author.handle} 358 shouldProxyLinks={true} 359 /> 360 {limitLines && ( 361 <ShowMoreTextButton 362 style={[a.text_md]} 363 onPress={onPressShowMore} 364 /> 365 )} 366 </View> 367 ) : null} 368 <TranslatedPost hideTranslateLink post={post} /> 369 {post.embed && ( 370 <View style={[a.pb_xs]}> 371 <Embed 372 embed={post.embed} 373 moderation={moderation} 374 viewContext={PostEmbedViewContext.Feed} 375 /> 376 </View> 377 )} 378 <PostControls 379 variant="compact" 380 post={postShadow} 381 record={record} 382 richText={richText} 383 onPressReply={onPressReply} 384 logContext="PostThreadItem" 385 threadgateRecord={threadgateRecord} 386 /> 387 <DebugFieldDisplay subject={post} /> 388 </View> 389 </View> 390 </View> 391 </ThreadItemTreePostInnerWrapper> 392 </PostHider> 393 </SubtleHoverWrapper> 394 </ThreadItemTreePostOuterWrapper> 395 ) 396}) 397 398function SubtleHoverWrapper({children}: {children: React.ReactNode}) { 399 const { 400 state: hover, 401 onIn: onHoverIn, 402 onOut: onHoverOut, 403 } = useInteractionState() 404 return ( 405 <View 406 onPointerEnter={onHoverIn} 407 onPointerLeave={onHoverOut} 408 style={[a.flex_1, a.pointer]}> 409 <SubtleHover hover={hover} /> 410 {children} 411 </View> 412 ) 413} 414 415export function ThreadItemTreePostSkeleton({index}: {index: number}) { 416 const t = useTheme() 417 const enableSquareAvatars = useEnableSquareAvatars() 418 const even = index % 2 === 0 419 return ( 420 <View 421 style={[ 422 {paddingHorizontal: OUTER_SPACE, paddingVertical: OUTER_SPACE / 1.5}, 423 a.border_t, 424 t.atoms.border_contrast_low, 425 ]}> 426 <Skele.Row style={[a.align_start, a.gap_xs]}> 427 <Skele.Circle 428 size={TREE_AVI_WIDTH} 429 style={enableSquareAvatars && {borderRadius: 8}} 430 /> 431 432 <Skele.Col style={[a.gap_xs]}> 433 <Skele.Row style={[a.gap_sm]}> 434 <Skele.Text style={[a.text_md, {width: '20%'}]} /> 435 <Skele.Text blend style={[a.text_md, {width: '30%'}]} /> 436 </Skele.Row> 437 438 <Skele.Col> 439 {even ? ( 440 <> 441 <Skele.Text blend style={[a.text_md, {width: '100%'}]} /> 442 <Skele.Text blend style={[a.text_md, {width: '60%'}]} /> 443 </> 444 ) : ( 445 <Skele.Text blend style={[a.text_md, {width: '60%'}]} /> 446 )} 447 </Skele.Col> 448 449 <PostControlsSkeleton /> 450 </Skele.Col> 451 </Skele.Row> 452 </View> 453 ) 454}