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

Configure Feed

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

at cope-settings-sync 717 lines 22 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 {msg, 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, useSession} 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 {EphemeralAccountSwitcher} from '#/components/EphemeralAccountSwitcher' 39import {useAnalytics} from '#/analytics' 40import {useRunWithEphemeralAgent} from '../hooks/useRunWithEphemeralAgent' 41import {useAutoLikeOnRepost} from '../../state/preferences/auto-like-on-repost.tsx' 42import {BookmarkButton} from './BookmarkButton' 43import { 44 PostControlButton, 45 PostControlButtonIcon, 46 PostControlButtonText, 47} from './PostControlButton' 48import {PostMenuButton} from './PostMenu' 49import {RepostButton} from './RepostButton' 50import {ShareMenuButton} from './ShareMenu' 51 52let PostControls = ({ 53 big, 54 post, 55 record, 56 richText, 57 feedContext, 58 reqId, 59 style, 60 onPressReply, 61 onPostReply, 62 logContext, 63 threadgateRecord, 64 onShowLess, 65 viaRepost, 66 variant, 67 forceGoogleTranslate = false, 68}: { 69 big?: boolean 70 post: Shadow<AppBskyFeedDefs.PostView> 71 record: AppBskyFeedPost.Record 72 richText: RichTextAPI 73 feedContext?: string | undefined 74 reqId?: string | undefined 75 style?: StyleProp<ViewStyle> 76 onPressReply: () => void 77 onPostReply?: (postUri: string | undefined) => void 78 logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 79 threadgateRecord?: AppBskyFeedThreadgate.Record 80 onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void 81 viaRepost?: {uri: string; cid: string} 82 variant?: 'compact' | 'normal' | 'large' 83 forceGoogleTranslate?: boolean 84}): React.ReactNode => { 85 const ax = useAnalytics() 86 const t = useTheme() 87 const {t: l} = useLingui() 88 const {openComposer} = useOpenComposer() 89 const {feedDescriptor} = useFeedFeedbackContext() 90 const {accounts, currentAccount} = useSession() 91 const getPost = useGetPost() 92 const runWithEphemeralAgent = useRunWithEphemeralAgent() 93 const [queueLike, queueUnlike] = usePostLikeMutationQueue( 94 post, 95 viaRepost, 96 feedDescriptor, 97 logContext, 98 ) 99 const [queueRepost, queueUnrepost] = usePostRepostMutationQueue( 100 post, 101 viaRepost, 102 feedDescriptor, 103 logContext, 104 ) 105 const requireAuth = useRequireAuth() 106 const {sendInteraction} = useFeedFeedbackContext() 107 const {captureAction} = useProgressGuideControls() 108 const playHaptic = useHaptics() 109 const isBlocked = Boolean( 110 post.author.viewer?.blocking || 111 post.author.viewer?.blockedBy || 112 post.author.viewer?.blockingByList, 113 ) 114 const replyDisabled = post.viewer?.replyDisabled 115 const {gtPhone} = useBreakpoints() 116 const formatPostStatCount = useFormatPostStatCount() 117 118 const [hasLikeIconBeenToggled, setHasLikeIconBeenToggled] = useState(false) 119 120 // disable metrics 121 const disableLikesMetrics = useDisableLikesMetrics() 122 const disableRepostsMetrics = useDisableRepostsMetrics() 123 const disableReplyMetrics = useDisableReplyMetrics() 124 const disableQuotesMetrics = useDisableQuotesMetrics() 125 126 const autoLikeOnRepost = useAutoLikeOnRepost() 127 128 const shouldAutoLikeOnRepost = async () => { 129 if (post.viewer?.like) return false 130 131 if (userActionHistory.getActionHistory().likes.includes(post.uri)) { 132 return false 133 } 134 135 try { 136 const latestPost = await getPost({uri: post.uri}) 137 return !latestPost.viewer?.like 138 } catch { 139 return false 140 } 141 } 142 143 const onPressToggleLike = async () => { 144 if (isBlocked) { 145 Toast.show(l`Cannot interact with a blocked user`, { 146 type: 'warning', 147 }) 148 return 149 } 150 151 try { 152 setHasLikeIconBeenToggled(true) 153 if (!post.viewer?.like) { 154 playHaptic('Light') 155 sendInteraction({ 156 item: post.uri, 157 event: 'app.bsky.feed.defs#interactionLike', 158 feedContext, 159 reqId, 160 }) 161 captureAction(ProgressGuideAction.Like) 162 await queueLike() 163 } else { 164 await queueUnlike() 165 } 166 } catch (err) { 167 const e = err as Error 168 if (e?.name !== 'AbortError') { 169 throw e 170 } 171 } 172 } 173 174 const onRepost = async () => { 175 if (isBlocked) { 176 Toast.show(l`Cannot interact with a blocked user`, { 177 type: 'warning', 178 }) 179 return 180 } 181 182 try { 183 if (!post.viewer?.repost) { 184 sendInteraction({ 185 item: post.uri, 186 event: 'app.bsky.feed.defs#interactionRepost', 187 feedContext, 188 reqId, 189 }) 190 await queueRepost() 191 setHasLikeIconBeenToggled(true) 192 if (autoLikeOnRepost && (await shouldAutoLikeOnRepost())) { 193 sendInteraction({ 194 item: post.uri, 195 event: 'app.bsky.feed.defs#interactionLike', 196 feedContext, 197 reqId, 198 }) 199 captureAction(ProgressGuideAction.Like) 200 await queueLike() 201 } 202 } else { 203 await queueUnrepost() 204 } 205 } catch (err) { 206 const e = err as Error 207 if (e?.name !== 'AbortError') { 208 throw e 209 } 210 } 211 } 212 213 const onQuote = () => { 214 if (isBlocked) { 215 Toast.show(l`Cannot interact with a blocked user`, { 216 type: 'warning', 217 }) 218 return 219 } 220 221 sendInteraction({ 222 item: post.uri, 223 event: 'app.bsky.feed.defs#interactionQuote', 224 feedContext, 225 reqId, 226 }) 227 ax.metric('post:clickQuotePost', { 228 uri: post.uri, 229 authorDid: post.author.did, 230 logContext, 231 feedDescriptor, 232 }) 233 openComposer({ 234 quote: post, 235 onPost: onPostReply, 236 logContext: 'QuotePost', 237 }) 238 } 239 240 const onShare = () => { 241 sendInteraction({ 242 item: post.uri, 243 event: 'app.bsky.feed.defs#interactionShare', 244 feedContext, 245 reqId, 246 }) 247 } 248 249 const onReplyAsAccount = (accountDid: string) => { 250 setTimeout(() => { 251 ax.metric('post:clickReply', { 252 uri: post.uri, 253 authorDid: post.author.did, 254 logContext, 255 feedDescriptor, 256 }) 257 openComposer({ 258 activeAccountDid: accountDid, 259 replyTo: { 260 uri: post.uri, 261 cid: post.cid, 262 text: record.text || '', 263 author: post.author, 264 embed: post.embed, 265 langs: record.langs, 266 }, 267 onPost: onPostReply, 268 logContext: 'PostReply', 269 }) 270 }, 0) 271 } 272 273 const secondaryControlSpacingStyles = useSecondaryControlSpacingStyles({ 274 variant, 275 big, 276 gtPhone, 277 }) 278 const hasAlternateAccounts = accounts.some( 279 account => account.did !== currentAccount?.did, 280 ) 281 282 const onSelectLikeAccount = async (account: (typeof accounts)[number]) => { 283 try { 284 const wasLiked = await runWithEphemeralAgent(account, async agent => { 285 const res = await agent.getPosts({uris: [post.uri]}) 286 const target = res.data.posts[0] 287 const likeUri = target?.viewer?.like 288 289 if (likeUri) { 290 await agent.deleteLike(likeUri) 291 return true 292 } 293 294 await agent.like(post.uri, post.cid) 295 return false 296 }) 297 298 Toast.show( 299 wasLiked 300 ? l`Removed like as @${account.handle}` 301 : l`Liked as @${account.handle}`, 302 ) 303 } catch (e) { 304 Toast.show(l`An issue occurred, please try again.`, { 305 type: 'error', 306 }) 307 } 308 } 309 310 const onSelectRepostAccount = async (account: (typeof accounts)[number]) => { 311 try { 312 const wasReposted = await runWithEphemeralAgent(account, async agent => { 313 const res = await agent.getPosts({uris: [post.uri]}) 314 const target = res.data.posts[0] 315 const repostUri = target?.viewer?.repost 316 317 if (repostUri) { 318 await agent.deleteRepost(repostUri) 319 return true 320 } 321 322 await agent.repost(post.uri, post.cid) 323 return false 324 }) 325 326 Toast.show( 327 wasReposted 328 ? l`Removed repost as @${account.handle}` 329 : l`Reposted as @${account.handle}`, 330 ) 331 } catch (e) { 332 Toast.show(l`An issue occurred, please try again.`, { 333 type: 'error', 334 }) 335 } 336 } 337 338 const onSelectBookmarkAccount = async ( 339 account: (typeof accounts)[number], 340 ) => { 341 try { 342 const wasBookmarked = await runWithEphemeralAgent(account, async agent => { 343 const res = await agent.getPosts({uris: [post.uri]}) 344 const target = res.data.posts[0] 345 346 if (target?.viewer?.bookmarked) { 347 await agent.app.bsky.bookmark.deleteBookmark({uri: post.uri}) 348 return true 349 } 350 351 await agent.app.bsky.bookmark.createBookmark({ 352 uri: post.uri, 353 cid: post.cid, 354 }) 355 return false 356 }) 357 358 Toast.show( 359 wasBookmarked 360 ? l`Removed save as @${account.handle}` 361 : l`Saved as @${account.handle}`, 362 ) 363 } catch (e) { 364 Toast.show(l`An issue occurred, please try again.`, { 365 type: 'error', 366 }) 367 } 368 } 369 370 const renderLikeButton = (onLongPress?: () => void) => ( 371 <PostControlButton 372 testID="likeBtn" 373 big={big} 374 active={Boolean(post.viewer?.like)} 375 activeColor={t.palette.pink} 376 onPress={() => requireAuth(() => onPressToggleLike())} 377 onLongPress={onLongPress} 378 label={ 379 post.viewer?.like 380 ? l({ 381 message: `Unlike (${plural(post.likeCount || 0, { 382 one: '# like', 383 other: '# likes', 384 })})`, 385 comment: 386 'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun', 387 }) 388 : l({ 389 message: `Like (${plural(post.likeCount || 0, { 390 one: '# like', 391 other: '# likes', 392 })})`, 393 comment: 394 'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form', 395 }) 396 }> 397 <AnimatedLikeIcon 398 isLiked={Boolean(post.viewer?.like)} 399 big={big} 400 hasBeenToggled={hasLikeIconBeenToggled} 401 /> 402 {!disableLikesMetrics ? ( 403 <CountWheel 404 count={post.likeCount ?? 0} 405 isToggled={Boolean(post.viewer?.like)} 406 hasBeenToggled={hasLikeIconBeenToggled} 407 renderCount={({count}) => ( 408 <PostControlButtonText testID="likeCount"> 409 {formatPostStatCount(count)} 410 </PostControlButtonText> 411 )} 412 /> 413 ) : null} 414 </PostControlButton> 415 ) 416 417 return ( 418 <> 419 <View 420 style={[ 421 a.flex_row, 422 a.justify_between, 423 a.align_center, 424 !big && a.pt_2xs, 425 a.gap_md, 426 style, 427 ]}> 428 <View style={[a.flex_row, a.flex_1, {maxWidth: 320}]}> 429 <View 430 style={[ 431 a.flex_1, 432 a.align_start, 433 {marginLeft: big ? -2 : -6}, 434 replyDisabled ? {opacity: 0.6} : undefined, 435 ]}> 436 {currentAccount && hasAlternateAccounts && !replyDisabled ? ( 437 <EphemeralAccountSwitcher 438 selectedDid={currentAccount.did} 439 title={l`Reply as`} 440 triggerBehavior="longPress" 441 onSelectAccount={account => { 442 onReplyAsAccount(account.did) 443 }} 444 renderTrigger={({triggerProps}) => ( 445 <PostControlButton 446 testID="replyBtn" 447 onPress={() => 448 requireAuth(() => { 449 ax.metric('post:clickReply', { 450 uri: post.uri, 451 authorDid: post.author.did, 452 logContext, 453 feedDescriptor, 454 }) 455 onPressReply() 456 }) 457 } 458 onLongPress={triggerProps.onLongPress} 459 label={l({ 460 message: `Reply (${plural(post.replyCount || 0, { 461 one: '# reply', 462 other: '# replies', 463 })})`, 464 comment: 465 'Accessibility label for the reply button, verb form followed by number of replies and noun form', 466 })} 467 big={big}> 468 <PostControlButtonIcon icon={Bubble} /> 469 {typeof post.replyCount !== 'undefined' && 470 post.replyCount > 0 && 471 !disableReplyMetrics && ( 472 <PostControlButtonText> 473 {formatPostStatCount(post.replyCount)} 474 </PostControlButtonText> 475 )} 476 </PostControlButton> 477 )} 478 /> 479 ) : ( 480 <PostControlButton 481 testID="replyBtn" 482 onPress={ 483 !replyDisabled 484 ? () => 485 requireAuth(() => { 486 ax.metric('post:clickReply', { 487 uri: post.uri, 488 authorDid: post.author.did, 489 logContext, 490 feedDescriptor, 491 }) 492 onPressReply() 493 }) 494 : undefined 495 } 496 label={l({ 497 message: `Reply (${plural(post.replyCount || 0, { 498 one: '# reply', 499 other: '# replies', 500 })})`, 501 comment: 502 'Accessibility label for the reply button, verb form followed by number of replies and noun form', 503 })} 504 big={big}> 505 <PostControlButtonIcon icon={Bubble} /> 506 {typeof post.replyCount !== 'undefined' && 507 post.replyCount > 0 && 508 !disableReplyMetrics && ( 509 <PostControlButtonText> 510 {formatPostStatCount(post.replyCount)} 511 </PostControlButtonText> 512 )} 513 </PostControlButton> 514 )} 515 </View> 516 <View style={[a.flex_1, a.align_start]}> 517 {currentAccount && hasAlternateAccounts ? ( 518 <EphemeralAccountSwitcher 519 selectedDid={currentAccount.did} 520 title={l`Repost as`} 521 triggerBehavior="longPress" 522 onSelectAccount={account => { 523 void onSelectRepostAccount(account) 524 }} 525 renderTrigger={({triggerProps}) => ( 526 <RepostButton 527 isReposted={!!post.viewer?.repost} 528 repostCount={ 529 (!disableRepostsMetrics ? (post.repostCount ?? 0) : 0) + 530 (!disableQuotesMetrics ? (post.quoteCount ?? 0) : 0) 531 } 532 onRepost={() => void onRepost()} 533 onQuote={onQuote} 534 onLongPress={triggerProps.onLongPress} 535 big={big} 536 embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)} 537 /> 538 )} 539 /> 540 ) : ( 541 <RepostButton 542 isReposted={!!post.viewer?.repost} 543 repostCount={ 544 (!disableRepostsMetrics ? (post.repostCount ?? 0) : 0) + 545 (!disableQuotesMetrics ? (post.quoteCount ?? 0) : 0) 546 } 547 onRepost={() => void onRepost()} 548 onQuote={onQuote} 549 big={big} 550 embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)} 551 /> 552 )} 553 </View> 554 <View style={[a.flex_1, a.align_start]}> 555 {currentAccount && hasAlternateAccounts ? ( 556 <EphemeralAccountSwitcher 557 selectedDid={currentAccount.did} 558 title={l`Like as`} 559 triggerBehavior="longPress" 560 onSelectAccount={account => { 561 void onSelectLikeAccount(account) 562 }} 563 renderTrigger={({triggerProps}) => 564 renderLikeButton(triggerProps.onLongPress) 565 } 566 /> 567 ) : ( 568 renderLikeButton() 569 )} 570 </View> 571 {/* Spacer! */} 572 <View /> 573 </View> 574 <View style={[a.flex_row, a.justify_end, secondaryControlSpacingStyles]}> 575 {currentAccount && hasAlternateAccounts ? ( 576 <EphemeralAccountSwitcher 577 selectedDid={currentAccount.did} 578 title={l`Save as`} 579 triggerBehavior="longPress" 580 onSelectAccount={account => { 581 void onSelectBookmarkAccount(account) 582 }} 583 renderTrigger={({triggerProps}) => ( 584 <BookmarkButton 585 post={post} 586 big={big} 587 logContext={logContext} 588 onLongPress={triggerProps.onLongPress} 589 hitSlop={{ 590 right: secondaryControlSpacingStyles.gap / 2, 591 }} 592 /> 593 )} 594 /> 595 ) : ( 596 <BookmarkButton 597 post={post} 598 big={big} 599 logContext={logContext} 600 hitSlop={{ 601 right: secondaryControlSpacingStyles.gap / 2, 602 }} 603 /> 604 )} 605 <ShareMenuButton 606 testID="postShareBtn" 607 post={post} 608 big={big} 609 record={record} 610 richText={richText} 611 timestamp={post.indexedAt} 612 threadgateRecord={threadgateRecord} 613 onShare={onShare} 614 hitSlop={{ 615 left: secondaryControlSpacingStyles.gap / 2, 616 right: secondaryControlSpacingStyles.gap / 2, 617 }} 618 logContext={logContext} 619 /> 620 <PostMenuButton 621 testID="postDropdownBtn" 622 post={post} 623 postFeedContext={feedContext} 624 postReqId={reqId} 625 big={big} 626 record={record} 627 richText={richText} 628 timestamp={post.indexedAt} 629 threadgateRecord={threadgateRecord} 630 onShowLess={onShowLess} 631 hitSlop={{ 632 left: secondaryControlSpacingStyles.gap / 2, 633 }} 634 logContext={logContext} 635 forceGoogleTranslate={forceGoogleTranslate} 636 /> 637 </View> 638 </View> 639 </> 640 ) 641} 642PostControls = memo(PostControls) 643export {PostControls} 644 645export function PostControlsSkeleton({ 646 big, 647 style, 648 variant, 649}: { 650 big?: boolean 651 style?: StyleProp<ViewStyle> 652 variant?: 'compact' | 'normal' | 'large' 653}) { 654 const {gtPhone} = useBreakpoints() 655 656 const rowHeight = big ? 32 : 28 657 const padding = 4 658 const size = rowHeight - padding * 2 659 660 const secondaryControlSpacingStyles = useSecondaryControlSpacingStyles({ 661 variant, 662 big, 663 gtPhone, 664 }) 665 666 const itemStyles = { 667 padding, 668 } 669 670 return ( 671 <Skele.Row 672 style={[a.flex_row, a.justify_between, a.align_center, a.gap_md, style]}> 673 <View style={[a.flex_row, a.flex_1, {maxWidth: 320}]}> 674 <View 675 style={[itemStyles, a.flex_1, a.align_start, {marginLeft: -padding}]}> 676 <Skele.Pill blend size={size} /> 677 </View> 678 679 <View style={[itemStyles, a.flex_1, a.align_start]}> 680 <Skele.Pill blend size={size} /> 681 </View> 682 683 <View style={[itemStyles, a.flex_1, a.align_start]}> 684 <Skele.Pill blend size={size} /> 685 </View> 686 </View> 687 <View style={[a.flex_row, a.justify_end, secondaryControlSpacingStyles]}> 688 <View style={itemStyles}> 689 <Skele.Circle blend size={size} /> 690 </View> 691 <View style={itemStyles}> 692 <Skele.Circle blend size={size} /> 693 </View> 694 <View style={itemStyles}> 695 <Skele.Circle blend size={size} /> 696 </View> 697 </View> 698 </Skele.Row> 699 ) 700} 701 702function useSecondaryControlSpacingStyles({ 703 variant, 704 big, 705 gtPhone, 706}: { 707 variant?: 'compact' | 'normal' | 'large' 708 big?: boolean 709 gtPhone: boolean 710}) { 711 return useMemo(() => { 712 let gap = 0 // default, we want `gap` to be defined on the resulting object 713 if (variant !== 'compact') gap = a.gap_xs.gap 714 if (big || gtPhone) gap = a.gap_sm.gap 715 return {gap} 716 }, [variant, big, gtPhone]) 717}