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