Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
120
fork

Configure Feed

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

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