Compare commits

...

9 Commits

Author SHA1 Message Date
Samuel Newman 42949e6dd3 fix error message typo 2026-02-11 09:35:39 +02:00
Samuel Newman f3140dbccf disable menu for self 2026-02-11 09:35:39 +02:00
Samuel Newman 094ad9acbc fix type error 2026-02-11 09:35:39 +02:00
Samuel Newman 1fa527cfbb prefetch subject profile 2026-02-11 09:35:39 +02:00
Samuel Newman bfe6c81ab3 fail quietly 2026-02-11 09:35:39 +02:00
Samuel Newman 55724b8c2f simplify logic a bit 2026-02-11 09:35:39 +02:00
Samuel Newman 6570bb35b7 optimistic update sample+count 2026-02-11 09:35:39 +02:00
Samuel Newman 2b3e6edb4b add actual optimistic updates 2026-02-11 09:35:39 +02:00
Samuel Newman 8b57006fdb clean up starter pack dialog styles 2026-02-11 09:34:34 +02:00
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 {
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}) => (
<StarterPackItem starterPackWithMembership={item} targetDid={targetDid} />
<StarterPackItem
starterPackWithMembership={item}
targetDid={targetDid}
subject={subject}
/>
),
[targetDid],
[targetDid, subject],
)
const onClose = useCallback(() => {
@@ -168,9 +172,11 @@ function StarterPackList({
<>
<View
style={[
{justifyContent: 'space-between', flexDirection: 'row'},
IS_WEB ? a.mb_2xl : a.my_lg,
a.justify_between,
a.align_center,
a.flex_row,
a.pb_lg,
native(a.pt_lg),
]}>
<Text style={[a.text_lg, a.font_semi_bold]}>
<Trans>Add to starter packs</Trans>
@@ -181,7 +187,8 @@ function StarterPackList({
variant="ghost"
color="secondary"
size="small"
shape="round">
shape="round"
style={{margin: -8}}>
<ButtonIcon icon={XIcon} />
</Button>
</View>
@@ -232,7 +239,10 @@ function StarterPackList({
onEndReachedThreshold={0.1}
ListHeaderComponent={listHeader}
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({
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 && (
<>
<AvatarStack
size={32}
size={24}
profiles={starterPack.listItemsSample
?.slice(0, 4)
.map(p => 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 && <ButtonIcon icon={Loader} />}
<ButtonText>
{isInPack ? <Trans>Remove</Trans> : <Trans>Add</Trans>}
</ButtonText>
+3 -24
View File
@@ -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<AppBskyGraphGetActorStarterPacks.OutputSchema>,
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<AppBskyGraphGetStarterPacksWithMembership.OutputSchema>,
QueryKey,
string | undefined
>({
return useInfiniteQuery({
queryKey: RQKEY_WITH_MEMBERSHIP(did),
queryFn: async ({pageParam}: {pageParam?: string}) => {
const res = await agent.app.bsky.graph.getStarterPacksWithMembership({
+116 -2
View File
@@ -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<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)
},
onError,
@@ -206,6 +276,50 @@ export function useListMembershipRemoveMutation({
queryKey: LIST_MEMBERS_RQKEY(variables.listUri),
})
}, 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)
},
onError,
+11 -9
View File
@@ -329,15 +329,17 @@ let ProfileMenu = ({
)}
</>
)}
<Menu.Item
testID="profileHeaderDropdownStarterPackAddRemoveBtn"
label={_(msg`Add to starter packs`)}
onPress={onPressAddToStarterPacks}>
<Menu.ItemText>
<Trans>Add to starter packs</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={StarterPack} />
</Menu.Item>
{!isSelf && (
<Menu.Item
testID="profileHeaderDropdownStarterPackAddRemoveBtn"
label={_(msg`Add to starter packs`)}
onPress={onPressAddToStarterPacks}>
<Menu.ItemText>
<Trans>Add to starter packs</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={StarterPack} />
</Menu.Item>
)}
<Menu.Item
testID="profileHeaderDropdownListAddRemoveBtn"
label={_(msg`Add to lists`)}