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