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