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