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