Bluesky app fork with some witchin' additions 💫

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

authored by samuel.fm and committed by

GitHub 37e5a0f7 4eca1394

+186 -93
+56 -58
src/components/dialogs/StarterPackDialog.tsx
··· 1 - import {useCallback, useState} from 'react' 1 + import {useCallback} from 'react' 2 2 import {View} from 'react-native' 3 3 import { 4 4 type AppBskyGraphGetStarterPacksWithMembership, ··· 7 7 import {msg, Plural, Trans} from '@lingui/macro' 8 8 import {useLingui} from '@lingui/react' 9 9 import {useNavigation} from '@react-navigation/native' 10 - import {useQueryClient} from '@tanstack/react-query' 11 10 12 11 import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' 13 12 import {type NavigationProp} from '#/lib/routes/types' 14 - import { 15 - invalidateActorStarterPacksWithMembershipQuery, 16 - useActorStarterPacksWithMembershipsQuery, 17 - } from '#/state/queries/actor-starter-packs' 13 + import {isNetworkError} from '#/lib/strings/errors' 14 + import {logger} from '#/logger' 15 + import {useActorStarterPacksWithMembershipsQuery} from '#/state/queries/actor-starter-packs' 18 16 import { 19 17 useListMembershipAddMutation, 20 18 useListMembershipRemoveMutation, 21 19 } from '#/state/queries/list-memberships' 22 - import * as Toast from '#/view/com/util/Toast' 23 - import {atoms as a, useTheme} from '#/alf' 20 + import {useProfileQuery} from '#/state/queries/profile' 21 + import {atoms as a, native, platform, useTheme} from '#/alf' 24 22 import {AvatarStack} from '#/components/AvatarStack' 25 23 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 26 24 import * as Dialog from '#/components/Dialog' ··· 29 27 import {StarterPack} from '#/components/icons/StarterPack' 30 28 import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 31 29 import {Loader} from '#/components/Loader' 30 + import * as Toast from '#/components/Toast' 32 31 import {Text} from '#/components/Typography' 33 32 import {useAnalytics} from '#/analytics' 34 33 import {IS_WEB} from '#/env' ··· 131 130 }) { 132 131 const control = Dialog.useDialogContext() 133 132 const {_} = useLingui() 133 + const {data: subject} = useProfileQuery({did: targetDid}) 134 134 135 135 const { 136 136 data, ··· 155 155 156 156 const renderItem = useCallback( 157 157 ({item}: {item: StarterPackWithMembership}) => ( 158 - <StarterPackItem starterPackWithMembership={item} targetDid={targetDid} /> 158 + <StarterPackItem 159 + starterPackWithMembership={item} 160 + targetDid={targetDid} 161 + subject={subject} 162 + /> 159 163 ), 160 - [targetDid], 164 + [targetDid, subject], 161 165 ) 162 166 163 167 const onClose = useCallback(() => { ··· 168 172 <> 169 173 <View 170 174 style={[ 171 - {justifyContent: 'space-between', flexDirection: 'row'}, 172 - IS_WEB ? a.mb_2xl : a.my_lg, 175 + a.justify_between, 173 176 a.align_center, 177 + a.flex_row, 178 + a.pb_lg, 179 + native(a.pt_lg), 174 180 ]}> 175 181 <Text style={[a.text_lg, a.font_semi_bold]}> 176 182 <Trans>Add to starter packs</Trans> ··· 181 187 variant="ghost" 182 188 color="secondary" 183 189 size="small" 184 - shape="round"> 190 + shape="round" 191 + style={{margin: -8}}> 185 192 <ButtonIcon icon={XIcon} /> 186 193 </Button> 187 194 </View> ··· 232 239 onEndReachedThreshold={0.1} 233 240 ListHeaderComponent={listHeader} 234 241 ListEmptyComponent={<Empty onStartWizard={onStartWizard} />} 235 - style={IS_WEB ? [a.px_md, {minHeight: 500}] : [a.px_2xl, a.pt_lg]} 242 + style={platform({ 243 + web: [a.px_2xl, {minHeight: 500}], 244 + native: [a.px_2xl, a.pt_lg], 245 + })} 236 246 /> 237 247 ) 238 248 } ··· 240 250 function StarterPackItem({ 241 251 starterPackWithMembership, 242 252 targetDid, 253 + subject, 243 254 }: { 244 255 starterPackWithMembership: StarterPackWithMembership 245 256 targetDid: string 257 + subject?: bsky.profile.AnyProfileView 246 258 }) { 247 259 const t = useTheme() 248 260 const ax = useAnalytics() 249 261 const {_} = useLingui() 250 - const queryClient = useQueryClient() 251 262 252 263 const starterPack = starterPackWithMembership.starterPack 253 264 const isInPack = !!starterPackWithMembership.listItem 254 265 255 - const [isPendingRefresh, setIsPendingRefresh] = useState(false) 266 + const {mutate: addMembership, isPending: isPendingAdd} = 267 + useListMembershipAddMutation({ 268 + subject, 269 + onSuccess: () => { 270 + Toast.show(_(msg`Added to starter pack`)) 271 + }, 272 + onError: err => { 273 + if (!isNetworkError(err)) { 274 + logger.error('Failed to add to starter pack', {safeMessage: err}) 275 + } 276 + Toast.show(_(msg`Failed to add to starter pack`), {type: 'error'}) 277 + }, 278 + }) 256 279 257 - const {mutate: addMembership} = useListMembershipAddMutation({ 258 - onSuccess: () => { 259 - Toast.show(_(msg`Added to starter pack`)) 260 - // Use a timeout to wait for the appview to update, matching the pattern 261 - // in list-memberships.ts 262 - setTimeout(() => { 263 - invalidateActorStarterPacksWithMembershipQuery({ 264 - queryClient, 265 - did: targetDid, 266 - }) 267 - setIsPendingRefresh(false) 268 - }, 1e3) 269 - }, 270 - onError: () => { 271 - Toast.show(_(msg`Failed to add to starter pack`), 'xmark') 272 - setIsPendingRefresh(false) 273 - }, 274 - }) 280 + const {mutate: removeMembership, isPending: isPendingRemove} = 281 + useListMembershipRemoveMutation({ 282 + onSuccess: () => { 283 + Toast.show(_(msg`Removed from starter pack`)) 284 + }, 285 + onError: err => { 286 + if (!isNetworkError(err)) { 287 + logger.error('Failed to remove from starter pack', {safeMessage: err}) 288 + } 289 + Toast.show(_(msg`Failed to remove from starter pack`), {type: 'error'}) 290 + }, 291 + }) 275 292 276 - const {mutate: removeMembership} = useListMembershipRemoveMutation({ 277 - onSuccess: () => { 278 - Toast.show(_(msg`Removed from starter pack`)) 279 - // Use a timeout to wait for the appview to update, matching the pattern 280 - // in list-memberships.ts 281 - setTimeout(() => { 282 - invalidateActorStarterPacksWithMembershipQuery({ 283 - queryClient, 284 - did: targetDid, 285 - }) 286 - setIsPendingRefresh(false) 287 - }, 1e3) 288 - }, 289 - onError: () => { 290 - Toast.show(_(msg`Failed to remove from starter pack`), 'xmark') 291 - setIsPendingRefresh(false) 292 - }, 293 - }) 293 + const isPending = isPendingAdd || isPendingRemove 294 294 295 295 const handleToggleMembership = () => { 296 - if (!starterPack.list?.uri || isPendingRefresh) return 296 + if (!starterPack.list?.uri || isPending) return 297 297 298 298 const listUri = starterPack.list.uri 299 299 const starterPackUri = starterPack.uri 300 300 301 - setIsPendingRefresh(true) 302 - 303 301 if (!isInPack) { 304 302 addMembership({ 305 303 listUri: listUri, ··· 309 307 } else { 310 308 if (!starterPackWithMembership.listItem?.uri) { 311 309 console.error('Cannot remove: missing membership URI') 312 - setIsPendingRefresh(false) 313 310 return 314 311 } 315 312 removeMembership({ ··· 344 341 starterPack.listItemsSample.length > 0 && ( 345 342 <> 346 343 <AvatarStack 347 - size={32} 344 + size={24} 348 345 profiles={starterPack.listItemsSample 349 346 ?.slice(0, 4) 350 347 .map(p => p.subject)} ··· 375 372 label={isInPack ? _(msg`Remove`) : _(msg`Add`)} 376 373 color={isInPack ? 'secondary' : 'primary_subtle'} 377 374 size="tiny" 378 - disabled={isPendingRefresh} 375 + disabled={isPending} 379 376 onPress={handleToggleMembership}> 377 + {isPending && <ButtonIcon icon={Loader} />} 380 378 <ButtonText> 381 379 {isInPack ? <Trans>Remove</Trans> : <Trans>Add</Trans>} 382 380 </ButtonText>
+3 -24
src/state/queries/actor-starter-packs.ts
··· 1 - import { 2 - type AppBskyGraphGetActorStarterPacks, 3 - type AppBskyGraphGetStarterPacksWithMembership, 4 - } from '@atproto/api' 5 - import { 6 - type InfiniteData, 7 - type QueryClient, 8 - type QueryKey, 9 - useInfiniteQuery, 10 - } from '@tanstack/react-query' 1 + import {type QueryClient, useInfiniteQuery} from '@tanstack/react-query' 11 2 12 3 import {useAgent} from '#/state/session' 13 4 ··· 28 19 }) { 29 20 const agent = useAgent() 30 21 31 - return useInfiniteQuery< 32 - AppBskyGraphGetActorStarterPacks.OutputSchema, 33 - Error, 34 - InfiniteData<AppBskyGraphGetActorStarterPacks.OutputSchema>, 35 - QueryKey, 36 - string | undefined 37 - >({ 22 + return useInfiniteQuery({ 38 23 queryKey: RQKEY(did), 39 24 queryFn: async ({pageParam}: {pageParam?: string}) => { 40 25 const res = await agent.app.bsky.graph.getActorStarterPacks({ ··· 59 44 }) { 60 45 const agent = useAgent() 61 46 62 - return useInfiniteQuery< 63 - AppBskyGraphGetStarterPacksWithMembership.OutputSchema, 64 - Error, 65 - InfiniteData<AppBskyGraphGetStarterPacksWithMembership.OutputSchema>, 66 - QueryKey, 67 - string | undefined 68 - >({ 47 + return useInfiniteQuery({ 69 48 queryKey: RQKEY_WITH_MEMBERSHIP(did), 70 49 queryFn: async ({pageParam}: {pageParam?: string}) => { 71 50 const res = await agent.app.bsky.graph.getStarterPacksWithMembership({
+116 -2
src/state/queries/list-memberships.ts
··· 14 14 * -prf 15 15 */ 16 16 17 - import {AtUri} from '@atproto/api' 18 - import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' 17 + import { 18 + type AppBskyActorDefs, 19 + type AppBskyGraphGetStarterPacksWithMembership, 20 + AtUri, 21 + } from '@atproto/api' 22 + import { 23 + type InfiniteData, 24 + useMutation, 25 + useQuery, 26 + useQueryClient, 27 + } from '@tanstack/react-query' 19 28 20 29 import {STALE} from '#/state/queries' 21 30 import {RQKEY as LIST_MEMBERS_RQKEY} from '#/state/queries/list-members' 22 31 import {useAgent, useSession} from '#/state/session' 32 + import type * as bsky from '#/types/bsky' 33 + import {RQKEY_WITH_MEMBERSHIP as STARTER_PACKS_WITH_MEMBERSHIPS_RKEY} from './actor-starter-packs' 23 34 24 35 // sanity limit is SANITY_PAGE_LIMIT*PAGE_SIZE total records 25 36 const SANITY_PAGE_LIMIT = 1000 ··· 91 102 } 92 103 93 104 export function useListMembershipAddMutation({ 105 + subject, 94 106 onSuccess, 95 107 onError, 96 108 }: { 109 + /** 110 + * Needed for optimistic update of starter pack query 111 + */ 112 + subject?: bsky.profile.AnyProfileView 97 113 onSuccess?: (data: {uri: string; cid: string}) => void 98 114 onError?: (error: Error) => void 99 115 } = {}) { ··· 151 167 queryKey: LIST_MEMBERS_RQKEY(variables.listUri), 152 168 }) 153 169 }, 1e3) 170 + 171 + // update WITH_MEMBERSHIPS query 172 + 173 + if (subject) { 174 + queryClient.setQueryData< 175 + InfiniteData<AppBskyGraphGetStarterPacksWithMembership.OutputSchema> 176 + >(STARTER_PACKS_WITH_MEMBERSHIPS_RKEY(variables.actorDid), old => { 177 + if (!old) return old 178 + 179 + return { 180 + ...old, 181 + pages: old.pages.map(page => ({ 182 + ...page, 183 + starterPacksWithMembership: page.starterPacksWithMembership.map( 184 + spWithMembership => { 185 + if ( 186 + spWithMembership.starterPack.list && 187 + spWithMembership.starterPack.list?.uri === variables.listUri 188 + ) { 189 + return { 190 + ...spWithMembership, 191 + starterPack: { 192 + ...spWithMembership.starterPack, 193 + listItemsSample: [ 194 + { 195 + uri: data.uri, 196 + subject: subject as AppBskyActorDefs.ProfileView, 197 + }, 198 + ...(spWithMembership.starterPack.listItemsSample?.filter( 199 + item => item.subject.did !== variables.actorDid, 200 + ) ?? []), 201 + ], 202 + list: { 203 + ...spWithMembership.starterPack.list, 204 + listItemCount: 205 + (spWithMembership.starterPack.list.listItemCount ?? 206 + 0) + 1, 207 + }, 208 + }, 209 + listItem: { 210 + uri: data.uri, 211 + subject: subject as AppBskyActorDefs.ProfileView, 212 + }, 213 + } 214 + } 215 + 216 + return spWithMembership 217 + }, 218 + ), 219 + })), 220 + } 221 + }) 222 + } 223 + 154 224 onSuccess?.(data) 155 225 }, 156 226 onError, ··· 206 276 queryKey: LIST_MEMBERS_RQKEY(variables.listUri), 207 277 }) 208 278 }, 1e3) 279 + 280 + queryClient.setQueryData< 281 + InfiniteData<AppBskyGraphGetStarterPacksWithMembership.OutputSchema> 282 + >(STARTER_PACKS_WITH_MEMBERSHIPS_RKEY(variables.actorDid), old => { 283 + if (!old) return old 284 + 285 + return { 286 + ...old, 287 + pages: old.pages.map(page => ({ 288 + ...page, 289 + starterPacksWithMembership: page.starterPacksWithMembership.map( 290 + spWithMembership => { 291 + if ( 292 + spWithMembership.starterPack.list && 293 + spWithMembership.starterPack.list.uri === variables.listUri 294 + ) { 295 + return { 296 + ...spWithMembership, 297 + starterPack: { 298 + ...spWithMembership.starterPack, 299 + listItemsSample: 300 + spWithMembership.starterPack.listItemsSample?.filter( 301 + item => item.subject.did !== variables.actorDid, 302 + ), 303 + list: { 304 + ...spWithMembership.starterPack.list, 305 + listItemCount: Math.max( 306 + 0, 307 + (spWithMembership.starterPack.list.listItemCount ?? 308 + 1) - 1, 309 + ), 310 + }, 311 + }, 312 + listItem: undefined, 313 + } 314 + } 315 + 316 + return spWithMembership 317 + }, 318 + ), 319 + })), 320 + } 321 + }) 322 + 209 323 onSuccess?.(data) 210 324 }, 211 325 onError,
+11 -9
src/view/com/profile/ProfileMenu.tsx
··· 329 329 )} 330 330 </> 331 331 )} 332 - <Menu.Item 333 - testID="profileHeaderDropdownStarterPackAddRemoveBtn" 334 - label={_(msg`Add to starter packs`)} 335 - onPress={onPressAddToStarterPacks}> 336 - <Menu.ItemText> 337 - <Trans>Add to starter packs</Trans> 338 - </Menu.ItemText> 339 - <Menu.ItemIcon icon={StarterPack} /> 340 - </Menu.Item> 332 + {!isSelf && ( 333 + <Menu.Item 334 + testID="profileHeaderDropdownStarterPackAddRemoveBtn" 335 + label={_(msg`Add to starter packs`)} 336 + onPress={onPressAddToStarterPacks}> 337 + <Menu.ItemText> 338 + <Trans>Add to starter packs</Trans> 339 + </Menu.ItemText> 340 + <Menu.ItemIcon icon={StarterPack} /> 341 + </Menu.Item> 342 + )} 341 343 <Menu.Item 342 344 testID="profileHeaderDropdownListAddRemoveBtn" 343 345 label={_(msg`Add to lists`)}