Bluesky app fork with some witchin' additions 💫

Adds a "follow back" button to follow notifications (#9359)

* Adds a "follow back" button to follow notifications

* get shadowcache logic working, strip out manual optimistic update

* whoops, don't just stick any old profile in there

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

authored by

Alex Benzer
Samuel Newman
and committed by
GitHub
2f678a3c d464cde4

+148 -29
+2
src/state/cache/profile-shadow.ts
··· 12 12 import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from '#/state/queries/messages/list-conversations' 13 13 import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '#/state/queries/my-blocked-accounts' 14 14 import {findAllProfilesInQueryData as findAllProfilesInMyMutedAccountsQueryData} from '#/state/queries/my-muted-accounts' 15 + import {findAllProfilesInQueryData as findAllProfilesInNotifsQueryData} from '#/state/queries/notifications/feed' 15 16 import {findAllProfilesInQueryData as findAllProfilesInFeedsQueryData} from '#/state/queries/post-feed' 16 17 import {findAllProfilesInQueryData as findAllProfilesInPostLikedByQueryData} from '#/state/queries/post-liked-by' 17 18 import {findAllProfilesInQueryData as findAllProfilesInPostQuotesQueryData} from '#/state/queries/post-quotes' ··· 176 177 yield* findAllProfilesInKnownFollowersQueryData(queryClient, did) 177 178 yield* findAllProfilesInExploreFeedPreviewsQueryData(queryClient, did) 178 179 yield* findAllProfilesInActivitySubscriptionsQueryData(queryClient, did) 180 + yield* findAllProfilesInNotifsQueryData(queryClient, did) 179 181 }
+5 -3
src/state/queries/notifications/feed.ts
··· 18 18 19 19 import {useCallback, useEffect, useMemo, useRef} from 'react' 20 20 import { 21 - type AppBskyActorDefs, 22 21 AppBskyFeedDefs, 23 22 AppBskyFeedPost, 24 23 AtUri, ··· 36 35 import {STALE} from '#/state/queries' 37 36 import {useAgent} from '#/state/session' 38 37 import {useThreadgateHiddenReplyUris} from '#/state/threadgate-hidden-replies' 38 + import type * as bsky from '#/types/bsky' 39 39 import { 40 40 didOrHandleUriMatches, 41 41 embedViewRecordToPostView, ··· 309 309 export function* findAllProfilesInQueryData( 310 310 queryClient: QueryClient, 311 311 did: string, 312 - ): Generator<AppBskyActorDefs.ProfileViewBasic, void> { 312 + ): Generator<bsky.profile.AnyProfileView, void> { 313 313 const queryDatas = queryClient.getQueriesData<InfiniteData<FeedPage>>({ 314 314 queryKey: [RQKEY_ROOT], 315 315 }) ··· 319 319 } 320 320 for (const page of queryData?.pages) { 321 321 for (const item of page.items) { 322 - if ( 322 + if (item.type === 'follow' && item.notification.author.did === did) { 323 + yield item.notification.author 324 + } else if ( 323 325 item.type !== 'starterpack-joined' && 324 326 item.subject?.author.did === did 325 327 ) {
+141 -26
src/view/com/notifications/NotificationFeedItem.tsx
··· 42 42 import {niceDate} from '#/lib/strings/time' 43 43 import {s} from '#/lib/styles' 44 44 import {logger} from '#/logger' 45 + import {useProfileShadow} from '#/state/cache/profile-shadow' 45 46 import {type FeedNotification} from '#/state/queries/notifications/feed' 47 + import {useProfileFollowMutationQueue} from '#/state/queries/profile' 46 48 import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' 47 - import {useAgent} from '#/state/session' 49 + import {useAgent, useSession} from '#/state/session' 48 50 import {FeedSourceCard} from '#/view/com/feeds/FeedSourceCard' 49 51 import {Post} from '#/view/com/post/Post' 50 52 import {formatCount} from '#/view/com/util/numeric/format' 51 53 import {TimeElapsed} from '#/view/com/util/TimeElapsed' 54 + import * as Toast from '#/view/com/util/Toast' 52 55 import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar' 53 56 import {atoms as a, platform, useTheme} from '#/alf' 54 - import {Button, ButtonText} from '#/components/Button' 57 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 55 58 import {BellRinging_Filled_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging' 59 + import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' 56 60 import { 57 61 ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon, 58 62 ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon, 59 63 } from '#/components/icons/Chevron' 60 64 import {Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled} from '#/components/icons/Heart2' 61 65 import {PersonPlus_Filled_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' 66 + import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' 62 67 import {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost' 63 68 import {StarterPack} from '#/components/icons/StarterPack' 64 69 import {VerifiedCheck} from '#/components/icons/VerifiedCheck' ··· 180 185 firstAuthor.profile.displayName || firstAuthor.profile.handle, 181 186 ) 182 187 188 + // Calculate if this is a follow-back notification 189 + const isFollowBack = useMemo(() => { 190 + if (item.type !== 'follow') return false 191 + if ( 192 + item.notification.author.viewer?.following && 193 + bsky.dangerousIsType<AppBskyGraphFollow.Record>( 194 + item.notification.record, 195 + AppBskyGraphFollow.isRecord, 196 + ) 197 + ) { 198 + let followingTimestamp 199 + try { 200 + const rkey = new AtUri(item.notification.author.viewer.following).rkey 201 + followingTimestamp = TID.fromStr(rkey).timestamp() 202 + } catch (e) { 203 + return false 204 + } 205 + if (followingTimestamp) { 206 + const followedTimestamp = 207 + new Date(item.notification.record.createdAt).getTime() * 1000 208 + return followedTimestamp > followingTimestamp 209 + } 210 + } 211 + return false 212 + }, [item]) 213 + 183 214 if (item.subjectUri && !item.subject && item.type !== 'feedgen-like') { 184 215 // don't render anything if the target post was deleted or unfindable 185 216 return <View /> ··· 309 340 ) 310 341 icon = <RepostIcon size="xl" style={{color: t.palette.positive_500}} /> 311 342 } else if (item.type === 'follow') { 312 - let isFollowBack = false 313 - 314 - if ( 315 - item.notification.author.viewer?.following && 316 - bsky.dangerousIsType<AppBskyGraphFollow.Record>( 317 - item.notification.record, 318 - AppBskyGraphFollow.isRecord, 319 - ) 320 - ) { 321 - let followingTimestamp 322 - try { 323 - const rkey = new AtUri(item.notification.author.viewer.following).rkey 324 - followingTimestamp = TID.fromStr(rkey).timestamp() 325 - } catch (e) { 326 - // For some reason the following URI was invalid. Default to it not being a follow back. 327 - console.error('Invalid following URI') 328 - } 329 - if (followingTimestamp) { 330 - const followedTimestamp = 331 - new Date(item.notification.record.createdAt).getTime() * 1000 332 - isFollowBack = followedTimestamp > followingTimestamp 333 - } 334 - } 335 - 336 343 if (isFollowBack && !hasMultipleAuthors) { 337 344 /* 338 345 * Follow-backs are ungrouped, grouped follow-backs not supported atm, ··· 663 670 </TimeElapsed> 664 671 </Text> 665 672 </ExpandListPressable> 673 + {item.type === 'follow' && !hasMultipleAuthors && !isFollowBack ? ( 674 + <FollowBackButton profile={item.notification.author} /> 675 + ) : null} 666 676 {item.type === 'post-like' || 667 677 item.type === 'repost' || 668 678 item.type === 'like-via-repost' || ··· 730 740 } else { 731 741 return <>{children}</> 732 742 } 743 + } 744 + 745 + function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) { 746 + const {_} = useLingui() 747 + const {currentAccount, hasSession} = useSession() 748 + const profileShadow = useProfileShadow(profile) 749 + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( 750 + profileShadow, 751 + 'ProfileCard', 752 + ) 753 + 754 + // Don't show button if not logged in or for own profile 755 + if (!hasSession || profile.did === currentAccount?.did) { 756 + return null 757 + } 758 + 759 + const onPressFollow = async (e: GestureResponderEvent) => { 760 + e.preventDefault() 761 + e.stopPropagation() 762 + 763 + try { 764 + await queueFollow() 765 + Toast.show( 766 + _( 767 + msg`Following ${sanitizeDisplayName( 768 + profile.displayName || profile.handle, 769 + )}`, 770 + ), 771 + ) 772 + } catch (err: any) { 773 + if (err?.name !== 'AbortError') { 774 + Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') 775 + } 776 + } 777 + } 778 + 779 + const onPressUnfollow = async (e: GestureResponderEvent) => { 780 + e.preventDefault() 781 + e.stopPropagation() 782 + 783 + try { 784 + await queueUnfollow() 785 + Toast.show( 786 + _( 787 + msg`No longer following ${sanitizeDisplayName( 788 + profile.displayName || profile.handle, 789 + )}`, 790 + ), 791 + ) 792 + } catch (err: any) { 793 + if (err?.name !== 'AbortError') { 794 + Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') 795 + } 796 + } 797 + } 798 + 799 + // Don't show button if viewer data is missing or user is blocked 800 + if (!profileShadow.viewer) { 801 + return null 802 + } 803 + if ( 804 + profileShadow.viewer.blockedBy || 805 + profileShadow.viewer.blocking || 806 + profileShadow.viewer.blockingByList 807 + ) { 808 + return null 809 + } 810 + 811 + const isFollowing = profileShadow.viewer.following 812 + const followingLabel = _( 813 + msg({ 814 + message: 'Following', 815 + comment: 'User is following this account, click to unfollow', 816 + }), 817 + ) 818 + 819 + return ( 820 + <View style={[a.pt_sm]}> 821 + {isFollowing ? ( 822 + <Button 823 + label={followingLabel} 824 + color="secondary" 825 + size="small" 826 + style={[a.self_start]} 827 + onPress={onPressUnfollow}> 828 + <ButtonIcon icon={CheckIcon} /> 829 + <ButtonText> 830 + <Trans>Following</Trans> 831 + </ButtonText> 832 + </Button> 833 + ) : ( 834 + <Button 835 + label={_(msg`Follow back`)} 836 + color="primary" 837 + size="small" 838 + style={[a.self_start]} 839 + onPress={onPressFollow}> 840 + <ButtonIcon icon={PlusIcon} /> 841 + <ButtonText> 842 + <Trans>Follow back</Trans> 843 + </ButtonText> 844 + </Button> 845 + )} 846 + </View> 847 + ) 733 848 } 734 849 735 850 function SayHelloBtn({profile}: {profile: AppBskyActorDefs.ProfileView}) {