From 2f678a3ccd17af3834c26a44274a5811f2f96691 Mon Sep 17 00:00:00 2001 From: Alex Benzer Date: Mon, 17 Nov 2025 05:00:26 -0800 Subject: [PATCH] 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 --- src/state/cache/profile-shadow.ts | 2 + src/state/queries/notifications/feed.ts | 8 +- .../notifications/NotificationFeedItem.tsx | 167 +++++++++++++++--- 3 files changed, 148 insertions(+), 29 deletions(-) diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts index 168661e0d1..e1cff94092 100644 --- a/src/state/cache/profile-shadow.ts +++ b/src/state/cache/profile-shadow.ts @@ -12,6 +12,7 @@ import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} fro import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from '#/state/queries/messages/list-conversations' import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '#/state/queries/my-blocked-accounts' import {findAllProfilesInQueryData as findAllProfilesInMyMutedAccountsQueryData} from '#/state/queries/my-muted-accounts' +import {findAllProfilesInQueryData as findAllProfilesInNotifsQueryData} from '#/state/queries/notifications/feed' import {findAllProfilesInQueryData as findAllProfilesInFeedsQueryData} from '#/state/queries/post-feed' import {findAllProfilesInQueryData as findAllProfilesInPostLikedByQueryData} from '#/state/queries/post-liked-by' import {findAllProfilesInQueryData as findAllProfilesInPostQuotesQueryData} from '#/state/queries/post-quotes' @@ -176,4 +177,5 @@ function* findProfilesInCache( yield* findAllProfilesInKnownFollowersQueryData(queryClient, did) yield* findAllProfilesInExploreFeedPreviewsQueryData(queryClient, did) yield* findAllProfilesInActivitySubscriptionsQueryData(queryClient, did) + yield* findAllProfilesInNotifsQueryData(queryClient, did) } diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts index 6010f11b40..7959c67a76 100644 --- a/src/state/queries/notifications/feed.ts +++ b/src/state/queries/notifications/feed.ts @@ -18,7 +18,6 @@ import {useCallback, useEffect, useMemo, useRef} from 'react' import { - type AppBskyActorDefs, AppBskyFeedDefs, AppBskyFeedPost, AtUri, @@ -36,6 +35,7 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts' import {STALE} from '#/state/queries' import {useAgent} from '#/state/session' import {useThreadgateHiddenReplyUris} from '#/state/threadgate-hidden-replies' +import type * as bsky from '#/types/bsky' import { didOrHandleUriMatches, embedViewRecordToPostView, @@ -309,7 +309,7 @@ export function* findAllPostsInQueryData( export function* findAllProfilesInQueryData( queryClient: QueryClient, did: string, -): Generator { +): Generator { const queryDatas = queryClient.getQueriesData>({ queryKey: [RQKEY_ROOT], }) @@ -319,7 +319,9 @@ export function* findAllProfilesInQueryData( } for (const page of queryData?.pages) { for (const item of page.items) { - if ( + if (item.type === 'follow' && item.notification.author.did === did) { + yield item.notification.author + } else if ( item.type !== 'starterpack-joined' && item.subject?.author.did === did ) { diff --git a/src/view/com/notifications/NotificationFeedItem.tsx b/src/view/com/notifications/NotificationFeedItem.tsx index 5809e71065..f78b9650e1 100644 --- a/src/view/com/notifications/NotificationFeedItem.tsx +++ b/src/view/com/notifications/NotificationFeedItem.tsx @@ -42,23 +42,28 @@ import {sanitizeHandle} from '#/lib/strings/handles' import {niceDate} from '#/lib/strings/time' import {s} from '#/lib/styles' import {logger} from '#/logger' +import {useProfileShadow} from '#/state/cache/profile-shadow' import {type FeedNotification} from '#/state/queries/notifications/feed' +import {useProfileFollowMutationQueue} from '#/state/queries/profile' import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' -import {useAgent} from '#/state/session' +import {useAgent, useSession} from '#/state/session' import {FeedSourceCard} from '#/view/com/feeds/FeedSourceCard' import {Post} from '#/view/com/post/Post' import {formatCount} from '#/view/com/util/numeric/format' import {TimeElapsed} from '#/view/com/util/TimeElapsed' +import * as Toast from '#/view/com/util/Toast' import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, platform, useTheme} from '#/alf' -import {Button, ButtonText} from '#/components/Button' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {BellRinging_Filled_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging' +import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' import { ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon, ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon, } from '#/components/icons/Chevron' import {Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled} from '#/components/icons/Heart2' import {PersonPlus_Filled_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' +import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' import {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost' import {StarterPack} from '#/components/icons/StarterPack' import {VerifiedCheck} from '#/components/icons/VerifiedCheck' @@ -180,6 +185,32 @@ let NotificationFeedItem = ({ firstAuthor.profile.displayName || firstAuthor.profile.handle, ) + // Calculate if this is a follow-back notification + const isFollowBack = useMemo(() => { + if (item.type !== 'follow') return false + if ( + item.notification.author.viewer?.following && + bsky.dangerousIsType( + item.notification.record, + AppBskyGraphFollow.isRecord, + ) + ) { + let followingTimestamp + try { + const rkey = new AtUri(item.notification.author.viewer.following).rkey + followingTimestamp = TID.fromStr(rkey).timestamp() + } catch (e) { + return false + } + if (followingTimestamp) { + const followedTimestamp = + new Date(item.notification.record.createdAt).getTime() * 1000 + return followedTimestamp > followingTimestamp + } + } + return false + }, [item]) + if (item.subjectUri && !item.subject && item.type !== 'feedgen-like') { // don't render anything if the target post was deleted or unfindable return @@ -309,30 +340,6 @@ let NotificationFeedItem = ({ ) icon = } else if (item.type === 'follow') { - let isFollowBack = false - - if ( - item.notification.author.viewer?.following && - bsky.dangerousIsType( - item.notification.record, - AppBskyGraphFollow.isRecord, - ) - ) { - let followingTimestamp - try { - const rkey = new AtUri(item.notification.author.viewer.following).rkey - followingTimestamp = TID.fromStr(rkey).timestamp() - } catch (e) { - // For some reason the following URI was invalid. Default to it not being a follow back. - console.error('Invalid following URI') - } - if (followingTimestamp) { - const followedTimestamp = - new Date(item.notification.record.createdAt).getTime() * 1000 - isFollowBack = followedTimestamp > followingTimestamp - } - } - if (isFollowBack && !hasMultipleAuthors) { /* * Follow-backs are ungrouped, grouped follow-backs not supported atm, @@ -663,6 +670,9 @@ let NotificationFeedItem = ({ + {item.type === 'follow' && !hasMultipleAuthors && !isFollowBack ? ( + + ) : null} {item.type === 'post-like' || item.type === 'repost' || item.type === 'like-via-repost' || @@ -732,6 +742,111 @@ function ExpandListPressable({ } } +function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) { + const {_} = useLingui() + const {currentAccount, hasSession} = useSession() + const profileShadow = useProfileShadow(profile) + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( + profileShadow, + 'ProfileCard', + ) + + // Don't show button if not logged in or for own profile + if (!hasSession || profile.did === currentAccount?.did) { + return null + } + + const onPressFollow = async (e: GestureResponderEvent) => { + e.preventDefault() + e.stopPropagation() + + try { + await queueFollow() + Toast.show( + _( + msg`Following ${sanitizeDisplayName( + profile.displayName || profile.handle, + )}`, + ), + ) + } catch (err: any) { + if (err?.name !== 'AbortError') { + Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') + } + } + } + + const onPressUnfollow = async (e: GestureResponderEvent) => { + e.preventDefault() + e.stopPropagation() + + try { + await queueUnfollow() + Toast.show( + _( + msg`No longer following ${sanitizeDisplayName( + profile.displayName || profile.handle, + )}`, + ), + ) + } catch (err: any) { + if (err?.name !== 'AbortError') { + Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') + } + } + } + + // Don't show button if viewer data is missing or user is blocked + if (!profileShadow.viewer) { + return null + } + if ( + profileShadow.viewer.blockedBy || + profileShadow.viewer.blocking || + profileShadow.viewer.blockingByList + ) { + return null + } + + const isFollowing = profileShadow.viewer.following + const followingLabel = _( + msg({ + message: 'Following', + comment: 'User is following this account, click to unfollow', + }), + ) + + return ( + + {isFollowing ? ( + + ) : ( + + )} + + ) +} + function SayHelloBtn({profile}: {profile: AppBskyActorDefs.ProfileView}) { const {_} = useLingui() const agent = useAgent()