mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
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/macro' 10 11import {useActorStatus} from '#/lib/actor-status' 12import {MAX_POST_LINES} from '#/lib/constants' 13import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 14import {makeProfileLink} from '#/lib/routes/links' 15import {countLines} from '#/lib/strings/helpers' 16import { 17 POST_TOMBSTONE, 18 type Shadow, 19 usePostShadow, 20} from '#/state/cache/post-shadow' 21import {type ThreadItem} from '#/state/queries/usePostThread/types' 22import {useSession} from '#/state/session' 23import {type OnPostSuccessData} from '#/state/shell/composer' 24import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' 25import {PostMeta} from '#/view/com/util/PostMeta' 26import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' 27import { 28 LINEAR_AVI_WIDTH, 29 OUTER_SPACE, 30 REPLY_LINE_WIDTH, 31} from '#/screens/PostThread/const' 32import {atoms as a, useTheme} from '#/alf' 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} from '#/components/PostControls' 42import {RichText} from '#/components/RichText' 43import * as Skele from '#/components/Skeleton' 44import {SubtleWebHover} from '#/components/SubtleWebHover' 45import {Text} from '#/components/Typography' 46 47export type ThreadItemPostProps = { 48 item: Extract<ThreadItem, {type: 'threadPost'}> 49 overrides?: { 50 moderation?: boolean 51 topBorder?: boolean 52 } 53 onPostSuccess?: (data: OnPostSuccessData) => void 54 threadgateRecord?: AppBskyFeedThreadgate.Record 55} 56 57export function ThreadItemPost({ 58 item, 59 overrides, 60 onPostSuccess, 61 threadgateRecord, 62}: ThreadItemPostProps) { 63 const postShadow = usePostShadow(item.value.post) 64 65 if (postShadow === POST_TOMBSTONE) { 66 return <ThreadItemPostDeleted item={item} overrides={overrides} /> 67 } 68 69 return ( 70 <ThreadItemPostInner 71 item={item} 72 postShadow={postShadow} 73 threadgateRecord={threadgateRecord} 74 overrides={overrides} 75 onPostSuccess={onPostSuccess} 76 /> 77 ) 78} 79 80function ThreadItemPostDeleted({ 81 item, 82 overrides, 83}: Pick<ThreadItemPostProps, 'item' | 'overrides'>) { 84 const t = useTheme() 85 86 return ( 87 <ThreadItemPostOuterWrapper item={item} overrides={overrides}> 88 <ThreadItemPostParentReplyLine item={item} /> 89 90 <View 91 style={[ 92 a.flex_row, 93 a.align_center, 94 a.py_md, 95 a.rounded_sm, 96 t.atoms.bg_contrast_25, 97 ]}> 98 <View 99 style={[ 100 a.flex_row, 101 a.align_center, 102 a.justify_center, 103 { 104 width: LINEAR_AVI_WIDTH, 105 }, 106 ]}> 107 <TrashIcon style={[t.atoms.text_contrast_medium]} /> 108 </View> 109 <Text 110 style={[a.text_md, a.font_semi_bold, t.atoms.text_contrast_medium]}> 111 <Trans>Post has been deleted</Trans> 112 </Text> 113 </View> 114 115 <View style={[{height: 4}]} /> 116 </ThreadItemPostOuterWrapper> 117 ) 118} 119 120const ThreadItemPostOuterWrapper = memo(function ThreadItemPostOuterWrapper({ 121 item, 122 overrides, 123 children, 124}: Pick<ThreadItemPostProps, 'item' | 'overrides'> & { 125 children: ReactNode 126}) { 127 const t = useTheme() 128 const showTopBorder = 129 !item.ui.showParentReplyLine && overrides?.topBorder !== true 130 131 return ( 132 <View 133 style={[ 134 showTopBorder && [a.border_t, t.atoms.border_contrast_low], 135 { 136 paddingHorizontal: OUTER_SPACE, 137 }, 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 author: post.author, 236 embed: post.embed, 237 moderation, 238 langs: post.record.langs, 239 }, 240 onPostSuccess: onPostSuccess, 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 <SubtleHover> 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 iconSize={LINEAR_AVI_WIDTH} 259 iconStyles={{marginLeft: 2, marginRight: 2}} 260 profile={post.author} 261 interpretFilterAsBlur> 262 <ThreadItemPostParentReplyLine item={item} /> 263 264 <View style={[a.flex_row, a.gap_md]}> 265 <View> 266 <PreviewableUserAvatar 267 size={LINEAR_AVI_WIDTH} 268 profile={post.author} 269 moderation={moderation.ui('avatar')} 270 type={post.author.associated?.labeler ? 'labeler' : 'user'} 271 live={live} 272 /> 273 274 {(item.ui.showChildReplyLine || 275 item.ui.precedesChildReadMore) && ( 276 <View 277 style={[ 278 a.mx_auto, 279 a.mt_xs, 280 a.flex_1, 281 { 282 width: REPLY_LINE_WIDTH, 283 backgroundColor: t.atoms.border_contrast_low.borderColor, 284 }, 285 ]} 286 /> 287 )} 288 </View> 289 290 <View style={[a.flex_1]}> 291 <PostMeta 292 author={post.author} 293 moderation={moderation} 294 timestamp={post.indexedAt} 295 postHref={postHref} 296 style={[a.pb_xs]} 297 /> 298 <LabelsOnMyPost post={post} style={[a.pb_xs]} /> 299 <PostAlerts 300 modui={moderation.ui('contentList')} 301 style={[a.pb_2xs]} 302 additionalCauses={additionalPostAlerts} 303 /> 304 {richText?.text ? ( 305 <> 306 <RichText 307 enableTags 308 value={richText} 309 style={[a.flex_1, a.text_md]} 310 numberOfLines={limitLines ? MAX_POST_LINES : undefined} 311 authorHandle={post.author.handle} 312 shouldProxyLinks={true} 313 /> 314 {limitLines && ( 315 <ShowMoreTextButton 316 style={[a.text_md]} 317 onPress={onPressShowMore} 318 /> 319 )} 320 </> 321 ) : undefined} 322 {post.embed && ( 323 <View style={[a.pb_xs]}> 324 <Embed 325 embed={post.embed} 326 moderation={moderation} 327 viewContext={PostEmbedViewContext.Feed} 328 /> 329 </View> 330 )} 331 <PostControls 332 post={postShadow} 333 record={record} 334 richText={richText} 335 onPressReply={onPressReply} 336 logContext="PostThreadItem" 337 threadgateRecord={threadgateRecord} 338 /> 339 </View> 340 </View> 341 </PostHider> 342 </ThreadItemPostOuterWrapper> 343 </SubtleHover> 344 ) 345}) 346 347function SubtleHover({children}: {children: ReactNode}) { 348 const { 349 state: hover, 350 onIn: onHoverIn, 351 onOut: onHoverOut, 352 } = useInteractionState() 353 return ( 354 <View 355 onPointerEnter={onHoverIn} 356 onPointerLeave={onHoverOut} 357 style={a.pointer}> 358 <SubtleWebHover hover={hover} /> 359 {children} 360 </View> 361 ) 362} 363 364export function ThreadItemPostSkeleton({index}: {index: number}) { 365 const even = index % 2 === 0 366 return ( 367 <View 368 style={[ 369 {paddingHorizontal: OUTER_SPACE, paddingVertical: OUTER_SPACE / 1.5}, 370 a.gap_md, 371 ]}> 372 <Skele.Row style={[a.align_start, a.gap_md]}> 373 <Skele.Circle size={LINEAR_AVI_WIDTH} /> 374 375 <Skele.Col style={[a.gap_xs]}> 376 <Skele.Row style={[a.gap_sm]}> 377 <Skele.Text style={[a.text_md, {width: '20%'}]} /> 378 <Skele.Text blend style={[a.text_md, {width: '30%'}]} /> 379 </Skele.Row> 380 381 <Skele.Col> 382 {even ? ( 383 <> 384 <Skele.Text blend style={[a.text_md, {width: '100%'}]} /> 385 <Skele.Text blend style={[a.text_md, {width: '60%'}]} /> 386 </> 387 ) : ( 388 <Skele.Text blend style={[a.text_md, {width: '60%'}]} /> 389 )} 390 </Skele.Col> 391 392 <Skele.Row style={[a.justify_between, a.pt_xs]}> 393 <Skele.Pill blend size={16} /> 394 <Skele.Pill blend size={16} /> 395 <Skele.Pill blend size={16} /> 396 <Skele.Circle blend size={16} /> 397 <View /> 398 </Skele.Row> 399 </Skele.Col> 400 </Skele.Row> 401 </View> 402 ) 403}