mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at tooltip 401 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/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 style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}> 110 <Trans>Post has been deleted</Trans> 111 </Text> 112 </View> 113 114 <View style={[{height: 4}]} /> 115 </ThreadItemPostOuterWrapper> 116 ) 117} 118 119const ThreadItemPostOuterWrapper = memo(function ThreadItemPostOuterWrapper({ 120 item, 121 overrides, 122 children, 123}: Pick<ThreadItemPostProps, 'item' | 'overrides'> & { 124 children: ReactNode 125}) { 126 const t = useTheme() 127 const showTopBorder = 128 !item.ui.showParentReplyLine && overrides?.topBorder !== true 129 130 return ( 131 <View 132 style={[ 133 showTopBorder && [a.border_t, t.atoms.border_contrast_low], 134 { 135 paddingHorizontal: OUTER_SPACE, 136 }, 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 }, 238 onPostSuccess: onPostSuccess, 239 }) 240 }, [openComposer, post, record, onPostSuccess, moderation]) 241 242 const onPressShowMore = useCallback(() => { 243 setLimitLines(false) 244 }, [setLimitLines]) 245 246 const {isActive: live} = useActorStatus(post.author) 247 248 return ( 249 <SubtleHover> 250 <ThreadItemPostOuterWrapper item={item} overrides={overrides}> 251 <PostHider 252 testID={`postThreadItem-by-${post.author.handle}`} 253 href={postHref} 254 disabled={overrides?.moderation === true} 255 modui={moderation.ui('contentList')} 256 iconSize={LINEAR_AVI_WIDTH} 257 iconStyles={{marginLeft: 2, marginRight: 2}} 258 profile={post.author} 259 interpretFilterAsBlur> 260 <ThreadItemPostParentReplyLine item={item} /> 261 262 <View style={[a.flex_row, a.gap_md]}> 263 <View> 264 <PreviewableUserAvatar 265 size={LINEAR_AVI_WIDTH} 266 profile={post.author} 267 moderation={moderation.ui('avatar')} 268 type={post.author.associated?.labeler ? 'labeler' : 'user'} 269 live={live} 270 /> 271 272 {(item.ui.showChildReplyLine || 273 item.ui.precedesChildReadMore) && ( 274 <View 275 style={[ 276 a.mx_auto, 277 a.mt_xs, 278 a.flex_1, 279 { 280 width: REPLY_LINE_WIDTH, 281 backgroundColor: t.atoms.border_contrast_low.borderColor, 282 }, 283 ]} 284 /> 285 )} 286 </View> 287 288 <View style={[a.flex_1]}> 289 <PostMeta 290 author={post.author} 291 moderation={moderation} 292 timestamp={post.indexedAt} 293 postHref={postHref} 294 style={[a.pb_xs]} 295 /> 296 <LabelsOnMyPost post={post} style={[a.pb_xs]} /> 297 <PostAlerts 298 modui={moderation.ui('contentList')} 299 style={[a.pb_2xs]} 300 additionalCauses={additionalPostAlerts} 301 /> 302 {richText?.text ? ( 303 <> 304 <RichText 305 enableTags 306 value={richText} 307 style={[a.flex_1, a.text_md]} 308 numberOfLines={limitLines ? MAX_POST_LINES : undefined} 309 authorHandle={post.author.handle} 310 shouldProxyLinks={true} 311 /> 312 {limitLines && ( 313 <ShowMoreTextButton 314 style={[a.text_md]} 315 onPress={onPressShowMore} 316 /> 317 )} 318 </> 319 ) : undefined} 320 {post.embed && ( 321 <View style={[a.pb_xs]}> 322 <Embed 323 embed={post.embed} 324 moderation={moderation} 325 viewContext={PostEmbedViewContext.Feed} 326 /> 327 </View> 328 )} 329 <PostControls 330 post={postShadow} 331 record={record} 332 richText={richText} 333 onPressReply={onPressReply} 334 logContext="PostThreadItem" 335 threadgateRecord={threadgateRecord} 336 /> 337 </View> 338 </View> 339 </PostHider> 340 </ThreadItemPostOuterWrapper> 341 </SubtleHover> 342 ) 343}) 344 345function SubtleHover({children}: {children: ReactNode}) { 346 const { 347 state: hover, 348 onIn: onHoverIn, 349 onOut: onHoverOut, 350 } = useInteractionState() 351 return ( 352 <View 353 onPointerEnter={onHoverIn} 354 onPointerLeave={onHoverOut} 355 style={a.pointer}> 356 <SubtleWebHover hover={hover} /> 357 {children} 358 </View> 359 ) 360} 361 362export function ThreadItemPostSkeleton({index}: {index: number}) { 363 const even = index % 2 === 0 364 return ( 365 <View 366 style={[ 367 {paddingHorizontal: OUTER_SPACE, paddingVertical: OUTER_SPACE / 1.5}, 368 a.gap_md, 369 ]}> 370 <Skele.Row style={[a.align_start, a.gap_md]}> 371 <Skele.Circle size={LINEAR_AVI_WIDTH} /> 372 373 <Skele.Col style={[a.gap_xs]}> 374 <Skele.Row style={[a.gap_sm]}> 375 <Skele.Text style={[a.text_md, {width: '20%'}]} /> 376 <Skele.Text blend style={[a.text_md, {width: '30%'}]} /> 377 </Skele.Row> 378 379 <Skele.Col> 380 {even ? ( 381 <> 382 <Skele.Text blend style={[a.text_md, {width: '100%'}]} /> 383 <Skele.Text blend style={[a.text_md, {width: '60%'}]} /> 384 </> 385 ) : ( 386 <Skele.Text blend style={[a.text_md, {width: '60%'}]} /> 387 )} 388 </Skele.Col> 389 390 <Skele.Row style={[a.justify_between, a.pt_xs]}> 391 <Skele.Pill blend size={16} /> 392 <Skele.Pill blend size={16} /> 393 <Skele.Pill blend size={16} /> 394 <Skele.Circle blend size={16} /> 395 <View /> 396 </Skele.Row> 397 </Skele.Col> 398 </Skele.Row> 399 </View> 400 ) 401}