Bluesky app fork with some witchin' additions 馃挮
at jean/pds-label 444 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 {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>Post 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 logContext: 'PostReply', 306 }) 307 }, [openComposer, post, record, onPostSuccess, moderation]) 308 309 const onPressShowMore = useCallback(() => { 310 setLimitLines(false) 311 }, [setLimitLines]) 312 313 return ( 314 <ThreadItemTreePostOuterWrapper item={item}> 315 <SubtleHoverWrapper> 316 <PostHider 317 testID={`postThreadItem-by-${post.author.handle}`} 318 href={postHref} 319 disabled={overrides?.moderation === true} 320 modui={moderation.ui('contentList')} 321 iconSize={42} 322 iconStyles={{marginLeft: 2, marginRight: 2}} 323 profile={post.author} 324 interpretFilterAsBlur> 325 <ThreadItemTreePostInnerWrapper item={item}> 326 <View style={[a.flex_1]}> 327 <PostMeta 328 author={post.author} 329 moderation={moderation} 330 timestamp={post.indexedAt} 331 postHref={postHref} 332 avatarSize={TREE_AVI_WIDTH} 333 style={[a.pb_0]} 334 showAvatar 335 /> 336 <View style={[a.flex_row]}> 337 <ThreadItemTreeReplyChildReplyLine item={item} /> 338 <View style={[a.flex_1, a.pl_2xs]}> 339 <LabelsOnMyPost post={post} style={[a.pb_2xs]} /> 340 <PostAlerts 341 modui={moderation.ui('contentList')} 342 style={[a.pb_2xs]} 343 additionalCauses={additionalPostAlerts} 344 /> 345 {richText?.text ? ( 346 <View style={[a.mb_2xs]}> 347 <RichText 348 enableTags 349 value={richText} 350 style={[a.flex_1, a.text_md]} 351 numberOfLines={limitLines ? MAX_POST_LINES : undefined} 352 authorHandle={post.author.handle} 353 shouldProxyLinks={true} 354 /> 355 {limitLines && ( 356 <ShowMoreTextButton 357 style={[a.text_md]} 358 onPress={onPressShowMore} 359 /> 360 )} 361 </View> 362 ) : null} 363 {post.embed && ( 364 <View style={[a.pb_xs]}> 365 <Embed 366 embed={post.embed} 367 moderation={moderation} 368 viewContext={PostEmbedViewContext.Feed} 369 /> 370 </View> 371 )} 372 <PostControls 373 variant="compact" 374 post={postShadow} 375 record={record} 376 richText={richText} 377 onPressReply={onPressReply} 378 logContext="PostThreadItem" 379 threadgateRecord={threadgateRecord} 380 /> 381 <DebugFieldDisplay subject={post} /> 382 </View> 383 </View> 384 </View> 385 </ThreadItemTreePostInnerWrapper> 386 </PostHider> 387 </SubtleHoverWrapper> 388 </ThreadItemTreePostOuterWrapper> 389 ) 390}) 391 392function SubtleHoverWrapper({children}: {children: React.ReactNode}) { 393 const { 394 state: hover, 395 onIn: onHoverIn, 396 onOut: onHoverOut, 397 } = useInteractionState() 398 return ( 399 <View 400 onPointerEnter={onHoverIn} 401 onPointerLeave={onHoverOut} 402 style={[a.flex_1, a.pointer]}> 403 <SubtleHover hover={hover} /> 404 {children} 405 </View> 406 ) 407} 408 409export function ThreadItemTreePostSkeleton({index}: {index: number}) { 410 const t = useTheme() 411 const even = index % 2 === 0 412 return ( 413 <View 414 style={[ 415 {paddingHorizontal: OUTER_SPACE, paddingVertical: OUTER_SPACE / 1.5}, 416 a.border_t, 417 t.atoms.border_contrast_low, 418 ]}> 419 <Skele.Row style={[a.align_start, a.gap_xs]}> 420 <Skele.Circle size={TREE_AVI_WIDTH} /> 421 422 <Skele.Col style={[a.gap_xs]}> 423 <Skele.Row style={[a.gap_sm]}> 424 <Skele.Text style={[a.text_md, {width: '20%'}]} /> 425 <Skele.Text blend style={[a.text_md, {width: '30%'}]} /> 426 </Skele.Row> 427 428 <Skele.Col> 429 {even ? ( 430 <> 431 <Skele.Text blend style={[a.text_md, {width: '100%'}]} /> 432 <Skele.Text blend style={[a.text_md, {width: '60%'}]} /> 433 </> 434 ) : ( 435 <Skele.Text blend style={[a.text_md, {width: '60%'}]} /> 436 )} 437 </Skele.Col> 438 439 <PostControlsSkeleton /> 440 </Skele.Col> 441 </Skele.Row> 442 </View> 443 ) 444}