Clean up starter packs dialog styles + add optimistic updates (#9469)

This commit is contained in:
Samuel Newman
2026-02-11 17:46:28 +00:00
committed by GitHub
parent 4eca13941a
commit 37e5a0f7a4
4 changed files with 186 additions and 93 deletions
+56 -58
View File
@@ -1,4 +1,4 @@
import {useCallback, useState} from 'react' import {useCallback} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import { import {
type AppBskyGraphGetStarterPacksWithMembership, type AppBskyGraphGetStarterPacksWithMembership,
@@ -7,20 +7,18 @@ import {
import {msg, Plural, Trans} from '@lingui/macro' import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
import {type NavigationProp} from '#/lib/routes/types' import {type NavigationProp} from '#/lib/routes/types'
import { import {isNetworkError} from '#/lib/strings/errors'
invalidateActorStarterPacksWithMembershipQuery, import {logger} from '#/logger'
useActorStarterPacksWithMembershipsQuery, import {useActorStarterPacksWithMembershipsQuery} from '#/state/queries/actor-starter-packs'
} from '#/state/queries/actor-starter-packs'
import { import {
useListMembershipAddMutation, useListMembershipAddMutation,
useListMembershipRemoveMutation, useListMembershipRemoveMutation,
} from '#/state/queries/list-memberships' } from '#/state/queries/list-memberships'
import * as Toast from '#/view/com/util/Toast' import {useProfileQuery} from '#/state/queries/profile'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, native, platform, useTheme} from '#/alf'
import {AvatarStack} from '#/components/AvatarStack' import {AvatarStack} from '#/components/AvatarStack'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' 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 {StarterPack} from '#/components/icons/StarterPack'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env' import {IS_WEB} from '#/env'
@@ -131,6 +130,7 @@ function StarterPackList({
}) { }) {
const control = Dialog.useDialogContext() const control = Dialog.useDialogContext()
const {_} = useLingui() const {_} = useLingui()
const {data: subject} = useProfileQuery({did: targetDid})
const { const {
data, data,
@@ -155,9 +155,13 @@ function StarterPackList({
const renderItem = useCallback( const renderItem = useCallback(
({item}: {item: StarterPackWithMembership}) => ( ({item}: {item: StarterPackWithMembership}) => (
<StarterPackItem starterPackWithMembership={item} targetDid={targetDid} /> <StarterPackItem
starterPackWithMembership={item}
targetDid={targetDid}
subject={subject}
/>
), ),
[targetDid], [targetDid, subject],
) )
const onClose = useCallback(() => { const onClose = useCallback(() => {
@@ -168,9 +172,11 @@ function StarterPackList({
<> <>
<View <View
style={[ style={[
{justifyContent: 'space-between', flexDirection: 'row'}, a.justify_between,
IS_WEB ? a.mb_2xl : a.my_lg,
a.align_center, a.align_center,
a.flex_row,
a.pb_lg,
native(a.pt_lg),
]}> ]}>
<Text style={[a.text_lg, a.font_semi_bold]}> <Text style={[a.text_lg, a.font_semi_bold]}>
<Trans>Add to starter packs</Trans> <Trans>Add to starter packs</Trans>
@@ -181,7 +187,8 @@ function StarterPackList({
variant="ghost" variant="ghost"
color="secondary" color="secondary"
size="small" size="small"
shape="round"> shape="round"
style={{margin: -8}}>
<ButtonIcon icon={XIcon} /> <ButtonIcon icon={XIcon} />
</Button> </Button>
</View> </View>
@@ -232,7 +239,10 @@ function StarterPackList({
onEndReachedThreshold={0.1} onEndReachedThreshold={0.1}
ListHeaderComponent={listHeader} ListHeaderComponent={listHeader}
ListEmptyComponent={<Empty onStartWizard={onStartWizard} />} ListEmptyComponent={<Empty onStartWizard={onStartWizard} />}
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({ function StarterPackItem({
starterPackWithMembership, starterPackWithMembership,
targetDid, targetDid,
subject,
}: { }: {
starterPackWithMembership: StarterPackWithMembership starterPackWithMembership: StarterPackWithMembership
targetDid: string targetDid: string
subject?: bsky.profile.AnyProfileView
}) { }) {
const t = useTheme() const t = useTheme()
const ax = useAnalytics() const ax = useAnalytics()
const {_} = useLingui() const {_} = useLingui()
const queryClient = useQueryClient()
const starterPack = starterPackWithMembership.starterPack const starterPack = starterPackWithMembership.starterPack
const isInPack = !!starterPackWithMembership.listItem 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({ const {mutate: removeMembership, isPending: isPendingRemove} =
onSuccess: () => { useListMembershipRemoveMutation({
Toast.show(_(msg`Added to starter pack`)) onSuccess: () => {
// Use a timeout to wait for the appview to update, matching the pattern Toast.show(_(msg`Removed from starter pack`))
// in list-memberships.ts },
setTimeout(() => { onError: err => {
invalidateActorStarterPacksWithMembershipQuery({ if (!isNetworkError(err)) {
queryClient, logger.error('Failed to remove from starter pack', {safeMessage: err})
did: targetDid, }
}) Toast.show(_(msg`Failed to remove from starter pack`), {type: 'error'})
setIsPendingRefresh(false) },
}, 1e3) })
},
onError: () => {
Toast.show(_(msg`Failed to add to starter pack`), 'xmark')
setIsPendingRefresh(false)
},
})
const {mutate: removeMembership} = useListMembershipRemoveMutation({ const isPending = isPendingAdd || isPendingRemove
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 handleToggleMembership = () => { const handleToggleMembership = () => {
if (!starterPack.list?.uri || isPendingRefresh) return if (!starterPack.list?.uri || isPending) return
const listUri = starterPack.list.uri const listUri = starterPack.list.uri
const starterPackUri = starterPack.uri const starterPackUri = starterPack.uri
setIsPendingRefresh(true)
if (!isInPack) { if (!isInPack) {
addMembership({ addMembership({
listUri: listUri, listUri: listUri,
@@ -309,7 +307,6 @@ function StarterPackItem({
} else { } else {
if (!starterPackWithMembership.listItem?.uri) { if (!starterPackWithMembership.listItem?.uri) {
console.error('Cannot remove: missing membership URI') console.error('Cannot remove: missing membership URI')
setIsPendingRefresh(false)
return return
} }
removeMembership({ removeMembership({
@@ -344,7 +341,7 @@ function StarterPackItem({
starterPack.listItemsSample.length > 0 && ( starterPack.listItemsSample.length > 0 && (
<> <>
<AvatarStack <AvatarStack
size={32} size={24}
profiles={starterPack.listItemsSample profiles={starterPack.listItemsSample
?.slice(0, 4) ?.slice(0, 4)
.map(p => p.subject)} .map(p => p.subject)}
@@ -375,8 +372,9 @@ function StarterPackItem({
label={isInPack ? _(msg`Remove`) : _(msg`Add`)} label={isInPack ? _(msg`Remove`) : _(msg`Add`)}
color={isInPack ? 'secondary' : 'primary_subtle'} color={isInPack ? 'secondary' : 'primary_subtle'}
size="tiny" size="tiny"
disabled={isPendingRefresh} disabled={isPending}
onPress={handleToggleMembership}> onPress={handleToggleMembership}>
{isPending && <ButtonIcon icon={Loader} />}
<ButtonText> <ButtonText>
{isInPack ? <Trans>Remove</Trans> : <Trans>Add</Trans>} {isInPack ? <Trans>Remove</Trans> : <Trans>Add</Trans>}
</ButtonText> </ButtonText>
+3 -24
View File
@@ -1,13 +1,4 @@
import { import {type QueryClient, useInfiniteQuery} from '@tanstack/react-query'
type AppBskyGraphGetActorStarterPacks,
type AppBskyGraphGetStarterPacksWithMembership,
} from '@atproto/api'
import {
type InfiniteData,
type QueryClient,
type QueryKey,
useInfiniteQuery,
} from '@tanstack/react-query'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
@@ -28,13 +19,7 @@ export function useActorStarterPacksQuery({
}) { }) {
const agent = useAgent() const agent = useAgent()
return useInfiniteQuery< return useInfiniteQuery({
AppBskyGraphGetActorStarterPacks.OutputSchema,
Error,
InfiniteData<AppBskyGraphGetActorStarterPacks.OutputSchema>,
QueryKey,
string | undefined
>({
queryKey: RQKEY(did), queryKey: RQKEY(did),
queryFn: async ({pageParam}: {pageParam?: string}) => { queryFn: async ({pageParam}: {pageParam?: string}) => {
const res = await agent.app.bsky.graph.getActorStarterPacks({ const res = await agent.app.bsky.graph.getActorStarterPacks({
@@ -59,13 +44,7 @@ export function useActorStarterPacksWithMembershipsQuery({
}) { }) {
const agent = useAgent() const agent = useAgent()
return useInfiniteQuery< return useInfiniteQuery({
AppBskyGraphGetStarterPacksWithMembership.OutputSchema,
Error,
InfiniteData<AppBskyGraphGetStarterPacksWithMembership.OutputSchema>,
QueryKey,
string | undefined
>({
queryKey: RQKEY_WITH_MEMBERSHIP(did), queryKey: RQKEY_WITH_MEMBERSHIP(did),
queryFn: async ({pageParam}: {pageParam?: string}) => { queryFn: async ({pageParam}: {pageParam?: string}) => {
const res = await agent.app.bsky.graph.getStarterPacksWithMembership({ const res = await agent.app.bsky.graph.getStarterPacksWithMembership({
+116 -2
View File
@@ -14,12 +14,23 @@
* -prf * -prf
*/ */
import {AtUri} from '@atproto/api' import {
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' type AppBskyActorDefs,
type AppBskyGraphGetStarterPacksWithMembership,
AtUri,
} from '@atproto/api'
import {
type InfiniteData,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {RQKEY as LIST_MEMBERS_RQKEY} from '#/state/queries/list-members' import {RQKEY as LIST_MEMBERS_RQKEY} from '#/state/queries/list-members'
import {useAgent, useSession} from '#/state/session' 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 // sanity limit is SANITY_PAGE_LIMIT*PAGE_SIZE total records
const SANITY_PAGE_LIMIT = 1000 const SANITY_PAGE_LIMIT = 1000
@@ -91,9 +102,14 @@ export function getMembership(
} }
export function useListMembershipAddMutation({ export function useListMembershipAddMutation({
subject,
onSuccess, onSuccess,
onError, onError,
}: { }: {
/**
* Needed for optimistic update of starter pack query
*/
subject?: bsky.profile.AnyProfileView
onSuccess?: (data: {uri: string; cid: string}) => void onSuccess?: (data: {uri: string; cid: string}) => void
onError?: (error: Error) => void onError?: (error: Error) => void
} = {}) { } = {}) {
@@ -151,6 +167,60 @@ export function useListMembershipAddMutation({
queryKey: LIST_MEMBERS_RQKEY(variables.listUri), queryKey: LIST_MEMBERS_RQKEY(variables.listUri),
}) })
}, 1e3) }, 1e3)
// update WITH_MEMBERSHIPS query
if (subject) {
queryClient.setQueryData<
InfiniteData<AppBskyGraphGetStarterPacksWithMembership.OutputSchema>
>(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) onSuccess?.(data)
}, },
onError, onError,
@@ -206,6 +276,50 @@ export function useListMembershipRemoveMutation({
queryKey: LIST_MEMBERS_RQKEY(variables.listUri), queryKey: LIST_MEMBERS_RQKEY(variables.listUri),
}) })
}, 1e3) }, 1e3)
queryClient.setQueryData<
InfiniteData<AppBskyGraphGetStarterPacksWithMembership.OutputSchema>
>(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) onSuccess?.(data)
}, },
onError, onError,
+11 -9
View File
@@ -329,15 +329,17 @@ let ProfileMenu = ({
)} )}
</> </>
)} )}
<Menu.Item {!isSelf && (
testID="profileHeaderDropdownStarterPackAddRemoveBtn" <Menu.Item
label={_(msg`Add to starter packs`)} testID="profileHeaderDropdownStarterPackAddRemoveBtn"
onPress={onPressAddToStarterPacks}> label={_(msg`Add to starter packs`)}
<Menu.ItemText> onPress={onPressAddToStarterPacks}>
<Trans>Add to starter packs</Trans> <Menu.ItemText>
</Menu.ItemText> <Trans>Add to starter packs</Trans>
<Menu.ItemIcon icon={StarterPack} /> </Menu.ItemText>
</Menu.Item> <Menu.ItemIcon icon={StarterPack} />
</Menu.Item>
)}
<Menu.Item <Menu.Item
testID="profileHeaderDropdownListAddRemoveBtn" testID="profileHeaderDropdownListAddRemoveBtn"
label={_(msg`Add to lists`)} label={_(msg`Add to lists`)}