mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
0
fork

Configure Feed

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

at react-sdui 516 lines 15 kB view raw
1import React, {memo, useMemo, useState} from 'react' 2import {StyleSheet, View} from 'react-native' 3import { 4 AppBskyActorDefs, 5 AppBskyFeedDefs, 6 AppBskyFeedPost, 7 AtUri, 8 ModerationDecision, 9 RichText as RichTextAPI, 10} from '@atproto/api' 11import { 12 FontAwesomeIcon, 13 FontAwesomeIconStyle, 14} from '@fortawesome/react-native-fontawesome' 15import {msg, Trans} from '@lingui/macro' 16import {useLingui} from '@lingui/react' 17import {useQueryClient} from '@tanstack/react-query' 18 19import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow' 20import {useFeedFeedbackContext} from '#/state/feed-feedback' 21import {useComposerControls} from '#/state/shell/composer' 22import {isReasonFeedSource, ReasonFeedSource} from 'lib/api/feed/types' 23import {MAX_POST_LINES} from 'lib/constants' 24import {usePalette} from 'lib/hooks/usePalette' 25import {makeProfileLink} from 'lib/routes/links' 26import {sanitizeDisplayName} from 'lib/strings/display-names' 27import {sanitizeHandle} from 'lib/strings/handles' 28import {countLines} from 'lib/strings/helpers' 29import {s} from 'lib/styles' 30import {precacheProfile} from 'state/queries/profile' 31import {atoms as a} from '#/alf' 32import {ContentHider} from '#/components/moderation/ContentHider' 33import {ProfileHoverCard} from '#/components/ProfileHoverCard' 34import {RichText} from '#/components/RichText' 35import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe' 36import {PostAlerts} from '../../../components/moderation/PostAlerts' 37import {FeedNameText} from '../util/FeedInfoText' 38import {Link, TextLink, TextLinkOnWebOnly} from '../util/Link' 39import {PostCtrls} from '../util/post-ctrls/PostCtrls' 40import {PostEmbeds} from '../util/post-embeds' 41import {PostMeta} from '../util/PostMeta' 42import {Text} from '../util/text/Text' 43import {PreviewableUserAvatar} from '../util/UserAvatar' 44import {AviFollowButton} from './AviFollowButton' 45import hairlineWidth = StyleSheet.hairlineWidth 46import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost' 47 48interface FeedItemProps { 49 record: AppBskyFeedPost.Record 50 reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined 51 moderation: ModerationDecision 52 parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined 53 showReplyTo: boolean 54 isThreadChild?: boolean 55 isThreadLastChild?: boolean 56 isThreadParent?: boolean 57 feedContext: string | undefined 58 hideTopBorder?: boolean 59 isParentBlocked?: boolean 60} 61 62export function FeedItem({ 63 post, 64 record, 65 reason, 66 feedContext, 67 moderation, 68 parentAuthor, 69 showReplyTo, 70 isThreadChild, 71 isThreadLastChild, 72 isThreadParent, 73 hideTopBorder, 74 isParentBlocked, 75}: FeedItemProps & {post: AppBskyFeedDefs.PostView}): React.ReactNode { 76 const postShadowed = usePostShadow(post) 77 const richText = useMemo( 78 () => 79 new RichTextAPI({ 80 text: record.text, 81 facets: record.facets, 82 }), 83 [record], 84 ) 85 if (postShadowed === POST_TOMBSTONE) { 86 return null 87 } 88 if (richText && moderation) { 89 return ( 90 <FeedItemInner 91 // Safeguard from clobbering per-post state below: 92 key={postShadowed.uri} 93 post={postShadowed} 94 record={record} 95 reason={reason} 96 feedContext={feedContext} 97 richText={richText} 98 parentAuthor={parentAuthor} 99 showReplyTo={showReplyTo} 100 moderation={moderation} 101 isThreadChild={isThreadChild} 102 isThreadLastChild={isThreadLastChild} 103 isThreadParent={isThreadParent} 104 hideTopBorder={hideTopBorder} 105 isParentBlocked={isParentBlocked} 106 /> 107 ) 108 } 109 return null 110} 111 112let FeedItemInner = ({ 113 post, 114 record, 115 reason, 116 feedContext, 117 richText, 118 moderation, 119 parentAuthor, 120 showReplyTo, 121 isThreadChild, 122 isThreadLastChild, 123 isThreadParent, 124 hideTopBorder, 125 isParentBlocked, 126}: FeedItemProps & { 127 richText: RichTextAPI 128 post: Shadow<AppBskyFeedDefs.PostView> 129}): React.ReactNode => { 130 const queryClient = useQueryClient() 131 const {openComposer} = useComposerControls() 132 const pal = usePalette('default') 133 const {_} = useLingui() 134 const href = useMemo(() => { 135 const urip = new AtUri(post.uri) 136 return makeProfileLink(post.author, 'post', urip.rkey) 137 }, [post.uri, post.author]) 138 const {sendInteraction} = useFeedFeedbackContext() 139 140 const onPressReply = React.useCallback(() => { 141 sendInteraction({ 142 item: post.uri, 143 event: 'app.bsky.feed.defs#interactionReply', 144 feedContext, 145 }) 146 openComposer({ 147 replyTo: { 148 uri: post.uri, 149 cid: post.cid, 150 text: record.text || '', 151 author: post.author, 152 embed: post.embed, 153 moderation, 154 }, 155 }) 156 }, [post, record, openComposer, moderation, sendInteraction, feedContext]) 157 158 const onOpenAuthor = React.useCallback(() => { 159 sendInteraction({ 160 item: post.uri, 161 event: 'app.bsky.feed.defs#clickthroughAuthor', 162 feedContext, 163 }) 164 }, [sendInteraction, post, feedContext]) 165 166 const onOpenReposter = React.useCallback(() => { 167 sendInteraction({ 168 item: post.uri, 169 event: 'app.bsky.feed.defs#clickthroughReposter', 170 feedContext, 171 }) 172 }, [sendInteraction, post, feedContext]) 173 174 const onOpenEmbed = React.useCallback(() => { 175 sendInteraction({ 176 item: post.uri, 177 event: 'app.bsky.feed.defs#clickthroughEmbed', 178 feedContext, 179 }) 180 }, [sendInteraction, post, feedContext]) 181 182 const onBeforePress = React.useCallback(() => { 183 sendInteraction({ 184 item: post.uri, 185 event: 'app.bsky.feed.defs#clickthroughItem', 186 feedContext, 187 }) 188 precacheProfile(queryClient, post.author) 189 }, [queryClient, post, sendInteraction, feedContext]) 190 191 const outerStyles = [ 192 styles.outer, 193 { 194 borderColor: pal.colors.border, 195 paddingBottom: 196 isThreadLastChild || (!isThreadChild && !isThreadParent) 197 ? 8 198 : undefined, 199 borderTopWidth: hideTopBorder || isThreadChild ? 0 : hairlineWidth, 200 }, 201 ] 202 203 return ( 204 <Link 205 testID={`feedItem-by-${post.author.handle}`} 206 style={outerStyles} 207 href={href} 208 noFeedback 209 accessible={false} 210 onBeforePress={onBeforePress} 211 dataSet={{feedContext}}> 212 <View style={{flexDirection: 'row', gap: 10, paddingLeft: 8}}> 213 <View style={{width: 52}}> 214 {isThreadChild && ( 215 <View 216 style={[ 217 styles.replyLine, 218 { 219 flexGrow: 1, 220 backgroundColor: pal.colors.replyLine, 221 marginBottom: 4, 222 }, 223 ]} 224 /> 225 )} 226 </View> 227 228 <View style={{paddingTop: 12, flexShrink: 1}}> 229 {isReasonFeedSource(reason) ? ( 230 <Link href={reason.href}> 231 <Text 232 type="sm-bold" 233 style={pal.textLight} 234 lineHeight={1.2} 235 numberOfLines={1}> 236 <Trans context="from-feed"> 237 From{' '} 238 <FeedNameText 239 type="sm-bold" 240 uri={reason.uri} 241 href={reason.href} 242 lineHeight={1.2} 243 numberOfLines={1} 244 style={pal.textLight} 245 /> 246 </Trans> 247 </Text> 248 </Link> 249 ) : AppBskyFeedDefs.isReasonRepost(reason) ? ( 250 <Link 251 style={styles.includeReason} 252 href={makeProfileLink(reason.by)} 253 title={_( 254 msg`Reposted by ${sanitizeDisplayName( 255 reason.by.displayName || reason.by.handle, 256 )}`, 257 )} 258 onBeforePress={onOpenReposter}> 259 <Repost 260 style={{color: pal.colors.textLight, marginRight: 3}} 261 width={14} 262 height={14} 263 /> 264 <Text 265 type="sm-bold" 266 style={pal.textLight} 267 lineHeight={1.2} 268 numberOfLines={1}> 269 <Trans> 270 Reposted by{' '} 271 <ProfileHoverCard inline did={reason.by.did}> 272 <TextLinkOnWebOnly 273 type="sm-bold" 274 style={pal.textLight} 275 lineHeight={1.2} 276 numberOfLines={1} 277 text={sanitizeDisplayName( 278 reason.by.displayName || 279 sanitizeHandle(reason.by.handle), 280 moderation.ui('displayName'), 281 )} 282 href={makeProfileLink(reason.by)} 283 onBeforePress={onOpenReposter} 284 /> 285 </ProfileHoverCard> 286 </Trans> 287 </Text> 288 </Link> 289 ) : null} 290 </View> 291 </View> 292 293 <View style={styles.layout}> 294 <View style={styles.layoutAvi}> 295 <AviFollowButton author={post.author} moderation={moderation}> 296 <PreviewableUserAvatar 297 size={52} 298 profile={post.author} 299 moderation={moderation.ui('avatar')} 300 type={post.author.associated?.labeler ? 'labeler' : 'user'} 301 onBeforePress={onOpenAuthor} 302 /> 303 </AviFollowButton> 304 {isThreadParent && ( 305 <View 306 style={[ 307 styles.replyLine, 308 { 309 flexGrow: 1, 310 backgroundColor: pal.colors.replyLine, 311 marginTop: 4, 312 }, 313 ]} 314 /> 315 )} 316 </View> 317 <View style={styles.layoutContent}> 318 <PostMeta 319 author={post.author} 320 moderation={moderation} 321 authorHasWarning={!!post.author.labels?.length} 322 timestamp={post.indexedAt} 323 postHref={href} 324 onOpenAuthor={onOpenAuthor} 325 /> 326 {!isThreadChild && showReplyTo && parentAuthor && ( 327 <ReplyToLabel blocked={isParentBlocked} profile={parentAuthor} /> 328 )} 329 <LabelsOnMyPost post={post} /> 330 <PostContent 331 moderation={moderation} 332 richText={richText} 333 postEmbed={post.embed} 334 postAuthor={post.author} 335 onOpenEmbed={onOpenEmbed} 336 /> 337 <PostCtrls 338 post={post} 339 record={record} 340 richText={richText} 341 onPressReply={onPressReply} 342 logContext="FeedItem" 343 feedContext={feedContext} 344 /> 345 </View> 346 </View> 347 </Link> 348 ) 349} 350FeedItemInner = memo(FeedItemInner) 351 352let PostContent = ({ 353 moderation, 354 richText, 355 postEmbed, 356 postAuthor, 357 onOpenEmbed, 358}: { 359 moderation: ModerationDecision 360 richText: RichTextAPI 361 postEmbed: AppBskyFeedDefs.PostView['embed'] 362 postAuthor: AppBskyFeedDefs.PostView['author'] 363 onOpenEmbed: () => void 364}): React.ReactNode => { 365 const pal = usePalette('default') 366 const {_} = useLingui() 367 const [limitLines, setLimitLines] = useState( 368 () => countLines(richText.text) >= MAX_POST_LINES, 369 ) 370 371 const onPressShowMore = React.useCallback(() => { 372 setLimitLines(false) 373 }, [setLimitLines]) 374 375 return ( 376 <ContentHider 377 testID="contentHider-post" 378 modui={moderation.ui('contentList')} 379 ignoreMute 380 childContainerStyle={styles.contentHiderChild}> 381 <PostAlerts modui={moderation.ui('contentList')} style={[a.py_2xs]} /> 382 {richText.text ? ( 383 <View style={styles.postTextContainer}> 384 <RichText 385 enableTags 386 testID="postText" 387 value={richText} 388 numberOfLines={limitLines ? MAX_POST_LINES : undefined} 389 style={[a.flex_1, a.text_md]} 390 authorHandle={postAuthor.handle} 391 /> 392 </View> 393 ) : undefined} 394 {limitLines ? ( 395 <TextLink 396 text={_(msg`Show More`)} 397 style={pal.link} 398 onPress={onPressShowMore} 399 href="#" 400 /> 401 ) : undefined} 402 {postEmbed ? ( 403 <View style={[a.pb_xs]}> 404 <PostEmbeds 405 embed={postEmbed} 406 moderation={moderation} 407 onOpen={onOpenEmbed} 408 /> 409 </View> 410 ) : null} 411 </ContentHider> 412 ) 413} 414PostContent = memo(PostContent) 415 416function ReplyToLabel({ 417 profile, 418 blocked, 419}: { 420 profile: AppBskyActorDefs.ProfileViewBasic 421 blocked?: boolean 422}) { 423 const pal = usePalette('default') 424 return ( 425 <View style={[s.flexRow, s.mb2, s.alignCenter]}> 426 <FontAwesomeIcon 427 icon="reply" 428 size={9} 429 style={[{color: pal.colors.textLight} as FontAwesomeIconStyle, s.mr5]} 430 /> 431 <Text 432 type="md" 433 style={[pal.textLight, s.mr2]} 434 lineHeight={1.2} 435 numberOfLines={1}> 436 {blocked ? ( 437 <Trans context="description">Reply to a blocked post</Trans> 438 ) : ( 439 <Trans context="description"> 440 Reply to{' '} 441 <ProfileHoverCard inline did={profile.did}> 442 <TextLinkOnWebOnly 443 type="md" 444 style={pal.textLight} 445 lineHeight={1.2} 446 numberOfLines={1} 447 href={makeProfileLink(profile)} 448 text={ 449 profile.displayName 450 ? sanitizeDisplayName(profile.displayName) 451 : sanitizeHandle(profile.handle) 452 } 453 /> 454 </ProfileHoverCard> 455 </Trans> 456 )} 457 </Text> 458 </View> 459 ) 460} 461 462const styles = StyleSheet.create({ 463 outer: { 464 paddingLeft: 10, 465 paddingRight: 15, 466 // @ts-ignore web only -prf 467 cursor: 'pointer', 468 overflow: 'hidden', 469 }, 470 replyLine: { 471 width: 2, 472 marginLeft: 'auto', 473 marginRight: 'auto', 474 }, 475 includeReason: { 476 flexDirection: 'row', 477 alignItems: 'center', 478 marginTop: 2, 479 marginBottom: 2, 480 marginLeft: -18, 481 }, 482 layout: { 483 flexDirection: 'row', 484 marginTop: 1, 485 gap: 10, 486 }, 487 layoutAvi: { 488 paddingLeft: 8, 489 position: 'relative', 490 zIndex: 999, 491 }, 492 layoutContent: { 493 position: 'relative', 494 flex: 1, 495 zIndex: 0, 496 }, 497 alert: { 498 marginTop: 6, 499 marginBottom: 6, 500 }, 501 postTextContainer: { 502 flexDirection: 'row', 503 alignItems: 'center', 504 flexWrap: 'wrap', 505 paddingBottom: 2, 506 }, 507 contentHiderChild: { 508 marginTop: 6, 509 }, 510 embed: { 511 marginBottom: 6, 512 }, 513 translateLink: { 514 marginBottom: 6, 515 }, 516})