Bluesky app fork with some witchin' additions 馃挮
at feat/markdown-basic 406 lines 12 kB view raw
1import {memo, type ReactNode, 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 {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' 26import { 27 LINEAR_AVI_WIDTH, 28 OUTER_SPACE, 29 REPLY_LINE_WIDTH, 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' 47import {useActorStatus} from '#/features/liveNow' 48 49export type ThreadItemPostProps = { 50 item: Extract<ThreadItem, {type: 'threadPost'}> 51 overrides?: { 52 moderation?: boolean 53 topBorder?: boolean 54 } 55 onPostSuccess?: (data: OnPostSuccessData) => void 56 threadgateRecord?: AppBskyFeedThreadgate.Record 57} 58 59export function ThreadItemPost({ 60 item, 61 overrides, 62 onPostSuccess, 63 threadgateRecord, 64}: ThreadItemPostProps) { 65 const postShadow = usePostShadow(item.value.post) 66 67 if (postShadow === POST_TOMBSTONE) { 68 return <ThreadItemPostDeleted item={item} overrides={overrides} /> 69 } 70 71 return ( 72 <ThreadItemPostInner 73 item={item} 74 postShadow={postShadow} 75 threadgateRecord={threadgateRecord} 76 overrides={overrides} 77 onPostSuccess={onPostSuccess} 78 /> 79 ) 80} 81 82function ThreadItemPostDeleted({ 83 item, 84 overrides, 85}: Pick<ThreadItemPostProps, 'item' | 'overrides'>) { 86 const t = useTheme() 87 88 return ( 89 <ThreadItemPostOuterWrapper item={item} overrides={overrides}> 90 <ThreadItemPostParentReplyLine item={item} /> 91 92 <View 93 style={[ 94 a.flex_row, 95 a.align_center, 96 a.py_md, 97 a.rounded_sm, 98 t.atoms.bg_contrast_25, 99 ]}> 100 <View 101 style={[ 102 a.flex_row, 103 a.align_center, 104 a.justify_center, 105 { 106 width: LINEAR_AVI_WIDTH, 107 }, 108 ]}> 109 <TrashIcon style={[t.atoms.text_contrast_medium]} /> 110 </View> 111 <Text 112 style={[a.text_md, a.font_semi_bold, t.atoms.text_contrast_medium]}> 113 <Trans>Post has been deleted</Trans> 114 </Text> 115 </View> 116 117 <View style={[{height: 4}]} /> 118 </ThreadItemPostOuterWrapper> 119 ) 120} 121 122const ThreadItemPostOuterWrapper = memo(function ThreadItemPostOuterWrapper({ 123 item, 124 overrides, 125 children, 126}: Pick<ThreadItemPostProps, 'item' | 'overrides'> & { 127 children: ReactNode 128}) { 129 const t = useTheme() 130 const showTopBorder = 131 !item.ui.showParentReplyLine && overrides?.topBorder !== true 132 133 return ( 134 <View 135 style={[ 136 showTopBorder && [a.border_t, t.atoms.border_contrast_low], 137 {paddingHorizontal: OUTER_SPACE}, 138 // If there's no next child, add a little padding to bottom 139 !item.ui.showChildReplyLine && 140 !item.ui.precedesChildReadMore && { 141 paddingBottom: OUTER_SPACE / 2, 142 }, 143 ]}> 144 {children} 145 </View> 146 ) 147}) 148 149/** 150 * Provides some space between posts as well as contains the reply line 151 */ 152const ThreadItemPostParentReplyLine = memo( 153 function ThreadItemPostParentReplyLine({ 154 item, 155 }: Pick<ThreadItemPostProps, 'item'>) { 156 const t = useTheme() 157 return ( 158 <View style={[a.flex_row, {height: 12}]}> 159 <View style={{width: LINEAR_AVI_WIDTH}}> 160 {item.ui.showParentReplyLine && ( 161 <View 162 style={[ 163 a.mx_auto, 164 a.flex_1, 165 a.mb_xs, 166 { 167 width: REPLY_LINE_WIDTH, 168 backgroundColor: t.atoms.border_contrast_low.borderColor, 169 }, 170 ]} 171 /> 172 )} 173 </View> 174 </View> 175 ) 176 }, 177) 178 179const ThreadItemPostInner = memo(function ThreadItemPostInner({ 180 item, 181 postShadow, 182 overrides, 183 onPostSuccess, 184 threadgateRecord, 185}: ThreadItemPostProps & { 186 postShadow: Shadow<AppBskyFeedDefs.PostView> 187}) { 188 const t = useTheme() 189 const {openComposer} = useOpenComposer() 190 const {currentAccount} = useSession() 191 192 const post = item.value.post 193 const record = item.value.post.record 194 const moderation = item.moderation 195 const richText = useMemo( 196 () => 197 new RichTextAPI({ 198 text: record.text, 199 facets: record.facets, 200 }), 201 [record], 202 ) 203 const [limitLines, setLimitLines] = useState( 204 () => countLines(richText?.text) >= MAX_POST_LINES, 205 ) 206 const threadRootUri = record.reply?.root?.uri || post.uri 207 const postHref = useMemo(() => { 208 const urip = new AtUri(post.uri) 209 return makeProfileLink(post.author, 'post', urip.rkey) 210 }, [post.uri, post.author]) 211 const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ 212 threadgateRecord, 213 }) 214 const additionalPostAlerts: AppModerationCause[] = useMemo(() => { 215 const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri) 216 const isControlledByViewer = 217 new AtUri(threadRootUri).host === currentAccount?.did 218 return isControlledByViewer && isPostHiddenByThreadgate 219 ? [ 220 { 221 type: 'reply-hidden', 222 source: {type: 'user', did: currentAccount?.did}, 223 priority: 6, 224 }, 225 ] 226 : [] 227 }, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri]) 228 229 const onPressReply = useCallback(() => { 230 openComposer({ 231 replyTo: { 232 uri: post.uri, 233 cid: post.cid, 234 text: record.text, 235 facets: record.facets, 236 author: post.author, 237 embed: post.embed, 238 moderation, 239 langs: post.record.langs, 240 }, 241 onPostSuccess: onPostSuccess, 242 logContext: 'PostReply', 243 }) 244 }, [openComposer, post, record, onPostSuccess, moderation]) 245 246 const onPressShowMore = useCallback(() => { 247 setLimitLines(false) 248 }, [setLimitLines]) 249 250 const {isActive: live} = useActorStatus(post.author) 251 252 return ( 253 <SubtleHoverWrapper> 254 <ThreadItemPostOuterWrapper item={item} overrides={overrides}> 255 <PostHider 256 testID={`postThreadItem-by-${post.author.handle}`} 257 href={postHref} 258 disabled={overrides?.moderation === true} 259 modui={moderation.ui('contentList')} 260 hiderStyle={[a.pl_0, a.pr_2xs, a.bg_transparent]} 261 iconSize={LINEAR_AVI_WIDTH} 262 iconStyles={[a.mr_xs]} 263 profile={post.author} 264 interpretFilterAsBlur> 265 <ThreadItemPostParentReplyLine item={item} /> 266 267 <View style={[a.flex_row, a.gap_md]}> 268 <View> 269 <PreviewableUserAvatar 270 size={LINEAR_AVI_WIDTH} 271 profile={post.author} 272 moderation={moderation.ui('avatar')} 273 type={post.author.associated?.labeler ? 'labeler' : 'user'} 274 live={live} 275 /> 276 277 {(item.ui.showChildReplyLine || 278 item.ui.precedesChildReadMore) && ( 279 <View 280 style={[ 281 a.mx_auto, 282 a.mt_xs, 283 a.flex_1, 284 { 285 width: REPLY_LINE_WIDTH, 286 backgroundColor: t.atoms.border_contrast_low.borderColor, 287 }, 288 ]} 289 /> 290 )} 291 </View> 292 293 <View style={[a.flex_1]}> 294 <PostMeta 295 author={post.author} 296 moderation={moderation} 297 timestamp={post.indexedAt} 298 postHref={postHref} 299 style={[a.pb_xs]} 300 /> 301 <LabelsOnMyPost post={post} style={[a.pb_xs]} /> 302 <PostAlerts 303 modui={moderation.ui('contentList')} 304 style={[a.pb_2xs]} 305 additionalCauses={additionalPostAlerts} 306 /> 307 {richText?.text ? ( 308 <View style={[a.mb_2xs]}> 309 <RichText 310 enableTags 311 value={richText} 312 style={[a.flex_1, a.text_md]} 313 numberOfLines={limitLines ? MAX_POST_LINES : undefined} 314 authorHandle={post.author.handle} 315 shouldProxyLinks={true} 316 /> 317 {limitLines && ( 318 <ShowMoreTextButton 319 style={[a.text_md]} 320 onPress={onPressShowMore} 321 /> 322 )} 323 </View> 324 ) : undefined} 325 <TranslatedPost 326 hideTranslateLink={true} 327 post={post} 328 postText={record.text} 329 /> 330 {post.embed && ( 331 <View style={[a.pb_xs]}> 332 <Embed 333 embed={post.embed} 334 moderation={moderation} 335 viewContext={PostEmbedViewContext.Feed} 336 /> 337 </View> 338 )} 339 <PostControls 340 post={postShadow} 341 record={record} 342 richText={richText} 343 onPressReply={onPressReply} 344 logContext="PostThreadItem" 345 threadgateRecord={threadgateRecord} 346 /> 347 <DebugFieldDisplay subject={post} /> 348 </View> 349 </View> 350 </PostHider> 351 </ThreadItemPostOuterWrapper> 352 </SubtleHoverWrapper> 353 ) 354}) 355 356function SubtleHoverWrapper({children}: {children: ReactNode}) { 357 const { 358 state: hover, 359 onIn: onHoverIn, 360 onOut: onHoverOut, 361 } = useInteractionState() 362 return ( 363 <View 364 onPointerEnter={onHoverIn} 365 onPointerLeave={onHoverOut} 366 style={a.pointer}> 367 <SubtleHover hover={hover} /> 368 {children} 369 </View> 370 ) 371} 372 373export function ThreadItemPostSkeleton({index}: {index: number}) { 374 const even = index % 2 === 0 375 return ( 376 <View 377 style={[ 378 {paddingHorizontal: OUTER_SPACE, paddingVertical: OUTER_SPACE / 1.5}, 379 a.gap_md, 380 ]}> 381 <Skele.Row style={[a.align_start, a.gap_md]}> 382 <Skele.Circle size={LINEAR_AVI_WIDTH} /> 383 384 <Skele.Col style={[a.gap_xs]}> 385 <Skele.Row style={[a.gap_sm]}> 386 <Skele.Text style={[a.text_md, {width: '20%'}]} /> 387 <Skele.Text blend style={[a.text_md, {width: '30%'}]} /> 388 </Skele.Row> 389 390 <Skele.Col> 391 {even ? ( 392 <> 393 <Skele.Text blend style={[a.text_md, {width: '100%'}]} /> 394 <Skele.Text blend style={[a.text_md, {width: '60%'}]} /> 395 </> 396 ) : ( 397 <Skele.Text blend style={[a.text_md, {width: '60%'}]} /> 398 )} 399 </Skele.Col> 400 401 <PostControlsSkeleton /> 402 </Skele.Col> 403 </Skele.Row> 404 </View> 405 ) 406}