diff --git a/src/components/dialogs/StarterPackDialog.tsx b/src/components/dialogs/StarterPackDialog.tsx index 871b34da57..06aea1bec7 100644 --- a/src/components/dialogs/StarterPackDialog.tsx +++ b/src/components/dialogs/StarterPackDialog.tsx @@ -1,4 +1,4 @@ -import {useCallback, useState} from 'react' +import {useCallback} from 'react' import {View} from 'react-native' import { type AppBskyGraphGetStarterPacksWithMembership, @@ -7,20 +7,18 @@ import { import {msg, Plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' -import {useQueryClient} from '@tanstack/react-query' import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' import {type NavigationProp} from '#/lib/routes/types' -import { - invalidateActorStarterPacksWithMembershipQuery, - useActorStarterPacksWithMembershipsQuery, -} from '#/state/queries/actor-starter-packs' +import {isNetworkError} from '#/lib/strings/errors' +import {logger} from '#/logger' +import {useActorStarterPacksWithMembershipsQuery} from '#/state/queries/actor-starter-packs' import { useListMembershipAddMutation, useListMembershipRemoveMutation, } from '#/state/queries/list-memberships' -import * as Toast from '#/view/com/util/Toast' -import {atoms as a, useTheme} from '#/alf' +import {useProfileQuery} from '#/state/queries/profile' +import {atoms as a, native, platform, useTheme} from '#/alf' import {AvatarStack} from '#/components/AvatarStack' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' @@ -29,6 +27,7 @@ import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/ import {StarterPack} from '#/components/icons/StarterPack' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import {Loader} from '#/components/Loader' +import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' import {IS_WEB} from '#/env' @@ -131,6 +130,7 @@ function StarterPackList({ }) { const control = Dialog.useDialogContext() const {_} = useLingui() + const {data: subject} = useProfileQuery({did: targetDid}) const { data, @@ -155,9 +155,13 @@ function StarterPackList({ const renderItem = useCallback( ({item}: {item: StarterPackWithMembership}) => ( - + ), - [targetDid], + [targetDid, subject], ) const onClose = useCallback(() => { @@ -168,9 +172,11 @@ function StarterPackList({ <> Add to starter packs @@ -181,7 +187,8 @@ function StarterPackList({ variant="ghost" color="secondary" size="small" - shape="round"> + shape="round" + style={{margin: -8}}> @@ -232,7 +239,10 @@ function StarterPackList({ onEndReachedThreshold={0.1} ListHeaderComponent={listHeader} ListEmptyComponent={} - style={IS_WEB ? [a.px_md, {minHeight: 500}] : [a.px_2xl, a.pt_lg]} + style={platform({ + web: [a.px_2xl, {minHeight: 500}], + native: [a.px_2xl, a.pt_lg], + })} /> ) } @@ -240,66 +250,54 @@ function StarterPackList({ function StarterPackItem({ starterPackWithMembership, targetDid, + subject, }: { starterPackWithMembership: StarterPackWithMembership targetDid: string + subject?: bsky.profile.AnyProfileView }) { const t = useTheme() const ax = useAnalytics() const {_} = useLingui() - const queryClient = useQueryClient() const starterPack = starterPackWithMembership.starterPack const isInPack = !!starterPackWithMembership.listItem - const [isPendingRefresh, setIsPendingRefresh] = useState(false) + const {mutate: addMembership, isPending: isPendingAdd} = + useListMembershipAddMutation({ + subject, + onSuccess: () => { + Toast.show(_(msg`Added to starter pack`)) + }, + onError: err => { + if (!isNetworkError(err)) { + logger.error('Failed to add to starter pack', {safeMessage: err}) + } + Toast.show(_(msg`Failed to add to starter pack`), {type: 'error'}) + }, + }) - const {mutate: addMembership} = useListMembershipAddMutation({ - onSuccess: () => { - Toast.show(_(msg`Added to starter pack`)) - // Use a timeout to wait for the appview to update, matching the pattern - // in list-memberships.ts - setTimeout(() => { - invalidateActorStarterPacksWithMembershipQuery({ - queryClient, - did: targetDid, - }) - setIsPendingRefresh(false) - }, 1e3) - }, - onError: () => { - Toast.show(_(msg`Failed to add to starter pack`), 'xmark') - setIsPendingRefresh(false) - }, - }) + const {mutate: removeMembership, isPending: isPendingRemove} = + useListMembershipRemoveMutation({ + onSuccess: () => { + Toast.show(_(msg`Removed from starter pack`)) + }, + onError: err => { + if (!isNetworkError(err)) { + logger.error('Failed to remove from starter pack', {safeMessage: err}) + } + Toast.show(_(msg`Failed to remove from starter pack`), {type: 'error'}) + }, + }) - const {mutate: removeMembership} = useListMembershipRemoveMutation({ - onSuccess: () => { - Toast.show(_(msg`Removed from starter pack`)) - // Use a timeout to wait for the appview to update, matching the pattern - // in list-memberships.ts - setTimeout(() => { - invalidateActorStarterPacksWithMembershipQuery({ - queryClient, - did: targetDid, - }) - setIsPendingRefresh(false) - }, 1e3) - }, - onError: () => { - Toast.show(_(msg`Failed to remove from starter pack`), 'xmark') - setIsPendingRefresh(false) - }, - }) + const isPending = isPendingAdd || isPendingRemove const handleToggleMembership = () => { - if (!starterPack.list?.uri || isPendingRefresh) return + if (!starterPack.list?.uri || isPending) return const listUri = starterPack.list.uri const starterPackUri = starterPack.uri - setIsPendingRefresh(true) - if (!isInPack) { addMembership({ listUri: listUri, @@ -309,7 +307,6 @@ function StarterPackItem({ } else { if (!starterPackWithMembership.listItem?.uri) { console.error('Cannot remove: missing membership URI') - setIsPendingRefresh(false) return } removeMembership({ @@ -344,7 +341,7 @@ function StarterPackItem({ starterPack.listItemsSample.length > 0 && ( <> p.subject)} @@ -375,8 +372,9 @@ function StarterPackItem({ label={isInPack ? _(msg`Remove`) : _(msg`Add`)} color={isInPack ? 'secondary' : 'primary_subtle'} size="tiny" - disabled={isPendingRefresh} + disabled={isPending} onPress={handleToggleMembership}> + {isPending && } {isInPack ? Remove : Add} diff --git a/src/state/queries/actor-starter-packs.ts b/src/state/queries/actor-starter-packs.ts index d40e054535..97cc3b5e05 100644 --- a/src/state/queries/actor-starter-packs.ts +++ b/src/state/queries/actor-starter-packs.ts @@ -1,13 +1,4 @@ -import { - type AppBskyGraphGetActorStarterPacks, - type AppBskyGraphGetStarterPacksWithMembership, -} from '@atproto/api' -import { - type InfiniteData, - type QueryClient, - type QueryKey, - useInfiniteQuery, -} from '@tanstack/react-query' +import {type QueryClient, useInfiniteQuery} from '@tanstack/react-query' import {useAgent} from '#/state/session' @@ -28,13 +19,7 @@ export function useActorStarterPacksQuery({ }) { const agent = useAgent() - return useInfiniteQuery< - AppBskyGraphGetActorStarterPacks.OutputSchema, - Error, - InfiniteData, - QueryKey, - string | undefined - >({ + return useInfiniteQuery({ queryKey: RQKEY(did), queryFn: async ({pageParam}: {pageParam?: string}) => { const res = await agent.app.bsky.graph.getActorStarterPacks({ @@ -59,13 +44,7 @@ export function useActorStarterPacksWithMembershipsQuery({ }) { const agent = useAgent() - return useInfiniteQuery< - AppBskyGraphGetStarterPacksWithMembership.OutputSchema, - Error, - InfiniteData, - QueryKey, - string | undefined - >({ + return useInfiniteQuery({ queryKey: RQKEY_WITH_MEMBERSHIP(did), queryFn: async ({pageParam}: {pageParam?: string}) => { const res = await agent.app.bsky.graph.getStarterPacksWithMembership({ diff --git a/src/state/queries/list-memberships.ts b/src/state/queries/list-memberships.ts index 410a613ada..dd7fde2482 100644 --- a/src/state/queries/list-memberships.ts +++ b/src/state/queries/list-memberships.ts @@ -14,12 +14,23 @@ * -prf */ -import {AtUri} from '@atproto/api' -import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' +import { + type AppBskyActorDefs, + type AppBskyGraphGetStarterPacksWithMembership, + AtUri, +} from '@atproto/api' +import { + type InfiniteData, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' import {STALE} from '#/state/queries' import {RQKEY as LIST_MEMBERS_RQKEY} from '#/state/queries/list-members' import {useAgent, useSession} from '#/state/session' +import type * as bsky from '#/types/bsky' +import {RQKEY_WITH_MEMBERSHIP as STARTER_PACKS_WITH_MEMBERSHIPS_RKEY} from './actor-starter-packs' // sanity limit is SANITY_PAGE_LIMIT*PAGE_SIZE total records const SANITY_PAGE_LIMIT = 1000 @@ -91,9 +102,14 @@ export function getMembership( } export function useListMembershipAddMutation({ + subject, onSuccess, onError, }: { + /** + * Needed for optimistic update of starter pack query + */ + subject?: bsky.profile.AnyProfileView onSuccess?: (data: {uri: string; cid: string}) => void onError?: (error: Error) => void } = {}) { @@ -151,6 +167,60 @@ export function useListMembershipAddMutation({ queryKey: LIST_MEMBERS_RQKEY(variables.listUri), }) }, 1e3) + + // update WITH_MEMBERSHIPS query + + if (subject) { + queryClient.setQueryData< + InfiniteData + >(STARTER_PACKS_WITH_MEMBERSHIPS_RKEY(variables.actorDid), old => { + if (!old) return old + + return { + ...old, + pages: old.pages.map(page => ({ + ...page, + starterPacksWithMembership: page.starterPacksWithMembership.map( + spWithMembership => { + if ( + spWithMembership.starterPack.list && + spWithMembership.starterPack.list?.uri === variables.listUri + ) { + return { + ...spWithMembership, + starterPack: { + ...spWithMembership.starterPack, + listItemsSample: [ + { + uri: data.uri, + subject: subject as AppBskyActorDefs.ProfileView, + }, + ...(spWithMembership.starterPack.listItemsSample?.filter( + item => item.subject.did !== variables.actorDid, + ) ?? []), + ], + list: { + ...spWithMembership.starterPack.list, + listItemCount: + (spWithMembership.starterPack.list.listItemCount ?? + 0) + 1, + }, + }, + listItem: { + uri: data.uri, + subject: subject as AppBskyActorDefs.ProfileView, + }, + } + } + + return spWithMembership + }, + ), + })), + } + }) + } + onSuccess?.(data) }, onError, @@ -206,6 +276,50 @@ export function useListMembershipRemoveMutation({ queryKey: LIST_MEMBERS_RQKEY(variables.listUri), }) }, 1e3) + + queryClient.setQueryData< + InfiniteData + >(STARTER_PACKS_WITH_MEMBERSHIPS_RKEY(variables.actorDid), old => { + if (!old) return old + + return { + ...old, + pages: old.pages.map(page => ({ + ...page, + starterPacksWithMembership: page.starterPacksWithMembership.map( + spWithMembership => { + if ( + spWithMembership.starterPack.list && + spWithMembership.starterPack.list.uri === variables.listUri + ) { + return { + ...spWithMembership, + starterPack: { + ...spWithMembership.starterPack, + listItemsSample: + spWithMembership.starterPack.listItemsSample?.filter( + item => item.subject.did !== variables.actorDid, + ), + list: { + ...spWithMembership.starterPack.list, + listItemCount: Math.max( + 0, + (spWithMembership.starterPack.list.listItemCount ?? + 1) - 1, + ), + }, + }, + listItem: undefined, + } + } + + return spWithMembership + }, + ), + })), + } + }) + onSuccess?.(data) }, onError, diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx index 8d7846d815..684dcbf0fe 100644 --- a/src/view/com/profile/ProfileMenu.tsx +++ b/src/view/com/profile/ProfileMenu.tsx @@ -329,15 +329,17 @@ let ProfileMenu = ({ )} )} - - - Add to starter packs - - - + {!isSelf && ( + + + Add to starter packs + + + + )}