this repo has no description
0
fork

Configure Feed

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

at main 460 lines 14 kB view raw
1import {memo, useMemo, useState} from 'react' 2import {type StyleProp, View, type ViewStyle} from 'react-native' 3import { 4 type AppBskyFeedDefs, 5 type AppBskyFeedPost, 6 type AppBskyFeedThreadgate, 7 type RichText as RichTextAPI, 8} from '@atproto/api' 9import {plural} from '@lingui/core/macro' 10import {useLingui} from '@lingui/react/macro' 11 12import {CountWheel} from '#/lib/custom-animations/CountWheel' 13import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon' 14import {useHaptics} from '#/lib/haptics' 15import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 16import {type Shadow} from '#/state/cache/types' 17import {useFeedFeedbackContext} from '#/state/feed-feedback' 18import {useDisableLikesMetrics} from '#/state/preferences/disable-likes-metrics' 19import {useDisableQuotesMetrics} from '#/state/preferences/disable-quotes-metrics' 20import {useDisableReplyMetrics} from '#/state/preferences/disable-reply-metrics' 21import {useDisableRepostsMetrics} from '#/state/preferences/disable-reposts-metrics' 22import { 23 usePostLikeMutationQueue, 24 usePostRepostMutationQueue, 25} from '#/state/queries/post' 26import {useRequireAuth} from '#/state/session' 27import { 28 ProgressGuideAction, 29 useProgressGuideControls, 30} from '#/state/shell/progress-guide' 31import {atoms as a, useBreakpoints} from '#/alf' 32import {Reply as Bubble} from '#/components/icons/Reply' 33import {useFormatPostStatCount} from '#/components/PostControls/util' 34import * as Skele from '#/components/Skeleton' 35import * as Toast from '#/components/Toast' 36import {useAnalytics} from '#/analytics' 37import {useAutoLikeOnRepost} from '../../state/preferences/auto-like-on-repost.tsx' 38import {BookmarkButton} from './BookmarkButton' 39import { 40 PostControlButton, 41 PostControlButtonIcon, 42 PostControlButtonText, 43} from './PostControlButton' 44import {PostMenuButton} from './PostMenu' 45import {RepostButton} from './RepostButton' 46import {ShareMenuButton} from './ShareMenu' 47 48let PostControls = ({ 49 big, 50 post, 51 record, 52 richText, 53 feedContext, 54 reqId, 55 style, 56 onPressReply, 57 onPostReply, 58 logContext, 59 threadgateRecord, 60 onShowLess, 61 viaRepost, 62 variant, 63 forceGoogleTranslate = false, 64}: { 65 big?: boolean 66 post: Shadow<AppBskyFeedDefs.PostView> 67 record: AppBskyFeedPost.Record 68 richText: RichTextAPI 69 feedContext?: string | undefined 70 reqId?: string | undefined 71 style?: StyleProp<ViewStyle> 72 onPressReply: () => void 73 onPostReply?: (postUri: string | undefined) => void 74 logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 75 threadgateRecord?: AppBskyFeedThreadgate.Record 76 onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void 77 viaRepost?: {uri: string; cid: string} 78 variant?: 'compact' | 'normal' | 'large' 79 forceGoogleTranslate?: boolean 80}): React.ReactNode => { 81 const ax = useAnalytics() 82 const {t: l} = useLingui() 83 const {openComposer} = useOpenComposer() 84 const {feedDescriptor} = useFeedFeedbackContext() 85 const [queueLike, queueUnlike] = usePostLikeMutationQueue( 86 post, 87 viaRepost, 88 feedDescriptor, 89 logContext, 90 ) 91 const [queueRepost, queueUnrepost] = usePostRepostMutationQueue( 92 post, 93 viaRepost, 94 feedDescriptor, 95 logContext, 96 ) 97 const requireAuth = useRequireAuth() 98 const {sendInteraction} = useFeedFeedbackContext() 99 const {captureAction} = useProgressGuideControls() 100 const playHaptic = useHaptics() 101 const isBlocked = Boolean( 102 post.author.viewer?.blocking || 103 post.author.viewer?.blockedBy || 104 post.author.viewer?.blockingByList, 105 ) 106 const replyDisabled = post.viewer?.replyDisabled 107 const {gtPhone} = useBreakpoints() 108 const formatPostStatCount = useFormatPostStatCount() 109 110 const [hasLikeIconBeenToggled, setHasLikeIconBeenToggled] = useState(false) 111 112 // disable metrics 113 const disableLikesMetrics = useDisableLikesMetrics() 114 const disableRepostsMetrics = useDisableRepostsMetrics() 115 const disableReplyMetrics = useDisableReplyMetrics() 116 const disableQuotesMetrics = useDisableQuotesMetrics() 117 118 const autoLikeOnRepost = useAutoLikeOnRepost() 119 120 const onPressToggleLike = async () => { 121 if (isBlocked) { 122 Toast.show(l`Cannot interact with a blocked user`, { 123 type: 'warning', 124 }) 125 return 126 } 127 128 try { 129 setHasLikeIconBeenToggled(true) 130 if (!post.viewer?.like) { 131 playHaptic('Light') 132 sendInteraction({ 133 item: post.uri, 134 event: 'app.bsky.feed.defs#interactionLike', 135 feedContext, 136 reqId, 137 }) 138 captureAction(ProgressGuideAction.Like) 139 await queueLike() 140 } else { 141 await queueUnlike() 142 } 143 } catch (err) { 144 const e = err as Error 145 if (e?.name !== 'AbortError') { 146 throw e 147 } 148 } 149 } 150 151 const onRepost = async () => { 152 if (isBlocked) { 153 Toast.show(l`Cannot interact with a blocked user`, { 154 type: 'warning', 155 }) 156 return 157 } 158 159 try { 160 if (!post.viewer?.repost) { 161 sendInteraction({ 162 item: post.uri, 163 event: 'app.bsky.feed.defs#interactionRepost', 164 feedContext, 165 reqId, 166 }) 167 await queueRepost() 168 setHasLikeIconBeenToggled(true) 169 if (!post.viewer?.like && autoLikeOnRepost) { 170 sendInteraction({ 171 item: post.uri, 172 event: 'app.bsky.feed.defs#interactionLike', 173 feedContext, 174 reqId, 175 }) 176 captureAction(ProgressGuideAction.Like) 177 await queueLike() 178 } 179 } else { 180 await queueUnrepost() 181 } 182 } catch (err) { 183 const e = err as Error 184 if (e?.name !== 'AbortError') { 185 throw e 186 } 187 } 188 } 189 190 const onQuote = () => { 191 if (isBlocked) { 192 Toast.show(l`Cannot interact with a blocked user`, { 193 type: 'warning', 194 }) 195 return 196 } 197 198 sendInteraction({ 199 item: post.uri, 200 event: 'app.bsky.feed.defs#interactionQuote', 201 feedContext, 202 reqId, 203 }) 204 ax.metric('post:clickQuotePost', { 205 uri: post.uri, 206 authorDid: post.author.did, 207 logContext, 208 feedDescriptor, 209 }) 210 openComposer({ 211 quote: post, 212 onPost: onPostReply, 213 logContext: 'QuotePost', 214 }) 215 } 216 217 const onShare = () => { 218 sendInteraction({ 219 item: post.uri, 220 event: 'app.bsky.feed.defs#interactionShare', 221 feedContext, 222 reqId, 223 }) 224 } 225 226 const secondaryControlSpacingStyles = useSecondaryControlSpacingStyles({ 227 variant, 228 big, 229 gtPhone, 230 }) 231 232 return ( 233 <View 234 style={[ 235 a.flex_row, 236 a.justify_between, 237 a.align_center, 238 !big && a.pt_2xs, 239 a.gap_md, 240 style, 241 ]}> 242 <View style={[a.flex_row, a.flex_1, {maxWidth: 320}]}> 243 <View 244 style={[ 245 a.flex_1, 246 a.align_start, 247 {marginLeft: big ? -2 : -6}, 248 replyDisabled ? {opacity: 0.6} : undefined, 249 ]}> 250 <PostControlButton 251 testID="replyBtn" 252 onPress={ 253 !replyDisabled 254 ? () => 255 requireAuth(() => { 256 ax.metric('post:clickReply', { 257 uri: post.uri, 258 authorDid: post.author.did, 259 logContext, 260 feedDescriptor, 261 }) 262 onPressReply() 263 }) 264 : undefined 265 } 266 label={l({ 267 message: `Reply (${plural(post.replyCount || 0, { 268 one: '# reply', 269 other: '# replies', 270 })})`, 271 comment: 272 'Accessibility label for the reply button, verb form followed by number of replies and noun form', 273 })} 274 big={big}> 275 <PostControlButtonIcon icon={Bubble} /> 276 {typeof post.replyCount !== 'undefined' && 277 post.replyCount > 0 && 278 !disableReplyMetrics && ( 279 <PostControlButtonText> 280 {formatPostStatCount(post.replyCount)} 281 </PostControlButtonText> 282 )} 283 </PostControlButton> 284 </View> 285 <View style={[a.flex_1, a.align_start]}> 286 <RepostButton 287 isReposted={!!post.viewer?.repost} 288 repostCount={ 289 (!disableRepostsMetrics ? (post.repostCount ?? 0) : 0) + 290 (!disableQuotesMetrics ? (post.quoteCount ?? 0) : 0) 291 } 292 onRepost={() => void onRepost()} 293 onQuote={onQuote} 294 big={big} 295 embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)} 296 /> 297 </View> 298 <View style={[a.flex_1, a.align_start]}> 299 <PostControlButton 300 testID="likeBtn" 301 big={big} 302 onPress={() => requireAuth(() => onPressToggleLike())} 303 label={ 304 post.viewer?.like 305 ? l({ 306 message: `Unlike (${plural(post.likeCount || 0, { 307 one: '# like', 308 other: '# likes', 309 })})`, 310 comment: 311 'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun', 312 }) 313 : l({ 314 message: `Like (${plural(post.likeCount || 0, { 315 one: '# like', 316 other: '# likes', 317 })})`, 318 comment: 319 'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form', 320 }) 321 }> 322 <AnimatedLikeIcon 323 isLiked={Boolean(post.viewer?.like)} 324 big={big} 325 hasBeenToggled={hasLikeIconBeenToggled} 326 /> 327 {!disableLikesMetrics ? ( 328 <CountWheel 329 likeCount={post.likeCount ?? 0} 330 big={big} 331 isLiked={Boolean(post.viewer?.like)} 332 hasBeenToggled={hasLikeIconBeenToggled} 333 /> 334 ) : null} 335 </PostControlButton> 336 </View> 337 {/* Spacer! */} 338 <View /> 339 </View> 340 <View style={[a.flex_row, a.justify_end, secondaryControlSpacingStyles]}> 341 <BookmarkButton 342 post={post} 343 big={big} 344 logContext={logContext} 345 hitSlop={{ 346 right: secondaryControlSpacingStyles.gap / 2, 347 }} 348 /> 349 <ShareMenuButton 350 testID="postShareBtn" 351 post={post} 352 big={big} 353 record={record} 354 richText={richText} 355 timestamp={post.indexedAt} 356 threadgateRecord={threadgateRecord} 357 onShare={onShare} 358 hitSlop={{ 359 left: secondaryControlSpacingStyles.gap / 2, 360 right: secondaryControlSpacingStyles.gap / 2, 361 }} 362 logContext={logContext} 363 /> 364 <PostMenuButton 365 testID="postDropdownBtn" 366 post={post} 367 postFeedContext={feedContext} 368 postReqId={reqId} 369 big={big} 370 record={record} 371 richText={richText} 372 timestamp={post.indexedAt} 373 threadgateRecord={threadgateRecord} 374 onShowLess={onShowLess} 375 hitSlop={{ 376 left: secondaryControlSpacingStyles.gap / 2, 377 }} 378 logContext={logContext} 379 forceGoogleTranslate={forceGoogleTranslate} 380 /> 381 </View> 382 </View> 383 ) 384} 385PostControls = memo(PostControls) 386export {PostControls} 387 388export function PostControlsSkeleton({ 389 big, 390 style, 391 variant, 392}: { 393 big?: boolean 394 style?: StyleProp<ViewStyle> 395 variant?: 'compact' | 'normal' | 'large' 396}) { 397 const {gtPhone} = useBreakpoints() 398 399 const rowHeight = big ? 32 : 28 400 const padding = 4 401 const size = rowHeight - padding * 2 402 403 const secondaryControlSpacingStyles = useSecondaryControlSpacingStyles({ 404 variant, 405 big, 406 gtPhone, 407 }) 408 409 const itemStyles = { 410 padding, 411 } 412 413 return ( 414 <Skele.Row 415 style={[a.flex_row, a.justify_between, a.align_center, a.gap_md, style]}> 416 <View style={[a.flex_row, a.flex_1, {maxWidth: 320}]}> 417 <View 418 style={[itemStyles, a.flex_1, a.align_start, {marginLeft: -padding}]}> 419 <Skele.Pill blend size={size} /> 420 </View> 421 422 <View style={[itemStyles, a.flex_1, a.align_start]}> 423 <Skele.Pill blend size={size} /> 424 </View> 425 426 <View style={[itemStyles, a.flex_1, a.align_start]}> 427 <Skele.Pill blend size={size} /> 428 </View> 429 </View> 430 <View style={[a.flex_row, a.justify_end, secondaryControlSpacingStyles]}> 431 <View style={itemStyles}> 432 <Skele.Circle blend size={size} /> 433 </View> 434 <View style={itemStyles}> 435 <Skele.Circle blend size={size} /> 436 </View> 437 <View style={itemStyles}> 438 <Skele.Circle blend size={size} /> 439 </View> 440 </View> 441 </Skele.Row> 442 ) 443} 444 445function useSecondaryControlSpacingStyles({ 446 variant, 447 big, 448 gtPhone, 449}: { 450 variant?: 'compact' | 'normal' | 'large' 451 big?: boolean 452 gtPhone: boolean 453}) { 454 return useMemo(() => { 455 let gap = 0 // default, we want `gap` to be defined on the resulting object 456 if (variant !== 'compact') gap = a.gap_xs.gap 457 if (big || gtPhone) gap = a.gap_sm.gap 458 return {gap} 459 }, [variant, big, gtPhone]) 460}