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:
Vendored
+2
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user