Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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