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>
This commit is contained in:
Alex Benzer
2025-11-17 05:00:26 -08:00
committed by GitHub
parent d464cde412
commit 2f678a3ccd
3 changed files with 148 additions and 29 deletions
+2
View File
@@ -12,6 +12,7 @@ import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} fro
import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from '#/state/queries/messages/list-conversations' import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from '#/state/queries/messages/list-conversations'
import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '#/state/queries/my-blocked-accounts' import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '#/state/queries/my-blocked-accounts'
import {findAllProfilesInQueryData as findAllProfilesInMyMutedAccountsQueryData} from '#/state/queries/my-muted-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 findAllProfilesInFeedsQueryData} from '#/state/queries/post-feed'
import {findAllProfilesInQueryData as findAllProfilesInPostLikedByQueryData} from '#/state/queries/post-liked-by' import {findAllProfilesInQueryData as findAllProfilesInPostLikedByQueryData} from '#/state/queries/post-liked-by'
import {findAllProfilesInQueryData as findAllProfilesInPostQuotesQueryData} from '#/state/queries/post-quotes' import {findAllProfilesInQueryData as findAllProfilesInPostQuotesQueryData} from '#/state/queries/post-quotes'
@@ -176,4 +177,5 @@ function* findProfilesInCache(
yield* findAllProfilesInKnownFollowersQueryData(queryClient, did) yield* findAllProfilesInKnownFollowersQueryData(queryClient, did)
yield* findAllProfilesInExploreFeedPreviewsQueryData(queryClient, did) yield* findAllProfilesInExploreFeedPreviewsQueryData(queryClient, did)
yield* findAllProfilesInActivitySubscriptionsQueryData(queryClient, did) yield* findAllProfilesInActivitySubscriptionsQueryData(queryClient, did)
yield* findAllProfilesInNotifsQueryData(queryClient, did)
} }
+5 -3
View File
@@ -18,7 +18,6 @@
import {useCallback, useEffect, useMemo, useRef} from 'react' import {useCallback, useEffect, useMemo, useRef} from 'react'
import { import {
type AppBskyActorDefs,
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedPost, AppBskyFeedPost,
AtUri, AtUri,
@@ -36,6 +35,7 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {useThreadgateHiddenReplyUris} from '#/state/threadgate-hidden-replies' import {useThreadgateHiddenReplyUris} from '#/state/threadgate-hidden-replies'
import type * as bsky from '#/types/bsky'
import { import {
didOrHandleUriMatches, didOrHandleUriMatches,
embedViewRecordToPostView, embedViewRecordToPostView,
@@ -309,7 +309,7 @@ export function* findAllPostsInQueryData(
export function* findAllProfilesInQueryData( export function* findAllProfilesInQueryData(
queryClient: QueryClient, queryClient: QueryClient,
did: string, did: string,
): Generator<AppBskyActorDefs.ProfileViewBasic, void> { ): Generator<bsky.profile.AnyProfileView, void> {
const queryDatas = queryClient.getQueriesData<InfiniteData<FeedPage>>({ const queryDatas = queryClient.getQueriesData<InfiniteData<FeedPage>>({
queryKey: [RQKEY_ROOT], queryKey: [RQKEY_ROOT],
}) })
@@ -319,7 +319,9 @@ export function* findAllProfilesInQueryData(
} }
for (const page of queryData?.pages) { for (const page of queryData?.pages) {
for (const item of page.items) { 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.type !== 'starterpack-joined' &&
item.subject?.author.did === did item.subject?.author.did === did
) { ) {
@@ -42,23 +42,28 @@ import {sanitizeHandle} from '#/lib/strings/handles'
import {niceDate} from '#/lib/strings/time' import {niceDate} from '#/lib/strings/time'
import {s} from '#/lib/styles' import {s} from '#/lib/styles'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {type FeedNotification} from '#/state/queries/notifications/feed' import {type FeedNotification} from '#/state/queries/notifications/feed'
import {useProfileFollowMutationQueue} from '#/state/queries/profile'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' 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 {FeedSourceCard} from '#/view/com/feeds/FeedSourceCard'
import {Post} from '#/view/com/post/Post' import {Post} from '#/view/com/post/Post'
import {formatCount} from '#/view/com/util/numeric/format' import {formatCount} from '#/view/com/util/numeric/format'
import {TimeElapsed} from '#/view/com/util/TimeElapsed' import {TimeElapsed} from '#/view/com/util/TimeElapsed'
import * as Toast from '#/view/com/util/Toast'
import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar' import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, platform, useTheme} from '#/alf' 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 {BellRinging_Filled_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
import { import {
ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon, ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon,
ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon, ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon,
} from '#/components/icons/Chevron' } from '#/components/icons/Chevron'
import {Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled} from '#/components/icons/Heart2' 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 {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 {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost'
import {StarterPack} from '#/components/icons/StarterPack' import {StarterPack} from '#/components/icons/StarterPack'
import {VerifiedCheck} from '#/components/icons/VerifiedCheck' import {VerifiedCheck} from '#/components/icons/VerifiedCheck'
@@ -180,6 +185,32 @@ let NotificationFeedItem = ({
firstAuthor.profile.displayName || firstAuthor.profile.handle, 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<AppBskyGraphFollow.Record>(
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') { if (item.subjectUri && !item.subject && item.type !== 'feedgen-like') {
// don't render anything if the target post was deleted or unfindable // don't render anything if the target post was deleted or unfindable
return <View /> return <View />
@@ -309,30 +340,6 @@ let NotificationFeedItem = ({
) )
icon = <RepostIcon size="xl" style={{color: t.palette.positive_500}} /> icon = <RepostIcon size="xl" style={{color: t.palette.positive_500}} />
} else if (item.type === 'follow') { } else if (item.type === 'follow') {
let isFollowBack = false
if (
item.notification.author.viewer?.following &&
bsky.dangerousIsType<AppBskyGraphFollow.Record>(
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) { if (isFollowBack && !hasMultipleAuthors) {
/* /*
* Follow-backs are ungrouped, grouped follow-backs not supported atm, * Follow-backs are ungrouped, grouped follow-backs not supported atm,
@@ -663,6 +670,9 @@ let NotificationFeedItem = ({
</TimeElapsed> </TimeElapsed>
</Text> </Text>
</ExpandListPressable> </ExpandListPressable>
{item.type === 'follow' && !hasMultipleAuthors && !isFollowBack ? (
<FollowBackButton profile={item.notification.author} />
) : null}
{item.type === 'post-like' || {item.type === 'post-like' ||
item.type === 'repost' || item.type === 'repost' ||
item.type === 'like-via-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 (
<View style={[a.pt_sm]}>
{isFollowing ? (
<Button
label={followingLabel}
color="secondary"
size="small"
style={[a.self_start]}
onPress={onPressUnfollow}>
<ButtonIcon icon={CheckIcon} />
<ButtonText>
<Trans>Following</Trans>
</ButtonText>
</Button>
) : (
<Button
label={_(msg`Follow back`)}
color="primary"
size="small"
style={[a.self_start]}
onPress={onPressFollow}>
<ButtonIcon icon={PlusIcon} />
<ButtonText>
<Trans>Follow back</Trans>
</ButtonText>
</Button>
)}
</View>
)
}
function SayHelloBtn({profile}: {profile: AppBskyActorDefs.ProfileView}) { function SayHelloBtn({profile}: {profile: AppBskyActorDefs.ProfileView}) {
const {_} = useLingui() const {_} = useLingui()
const agent = useAgent() const agent = useAgent()