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