Add starter pack reference-list opt-out UI (#11578)
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import {type PropsWithChildren} from 'react'
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {act, renderHook} from '@testing-library/react-native'
|
||||
|
||||
import {until} from '#/lib/async/until'
|
||||
import {useAppviewClient, usePdsClient} from '#/state/session'
|
||||
import {type app} from '#/lexicons'
|
||||
import {RQKEY, useReferenceListOptOutMutation} from '../list'
|
||||
|
||||
jest.mock('#/lib/async/until', () => ({until: jest.fn()}))
|
||||
jest.mock('#/lib/api', () => ({uploadBlob: jest.fn()}))
|
||||
jest.mock('../feed', () => ({FEED_INFO_RQKEY_ROOT: 'feed-info'}))
|
||||
jest.mock('../my-lists', () => ({invalidate: jest.fn()}))
|
||||
jest.mock('../profile-lists', () => ({RQKEY: jest.fn()}))
|
||||
jest.mock('#/state/session', () => ({
|
||||
useAppviewClient: jest.fn(),
|
||||
usePdsClient: jest.fn(),
|
||||
useSession: jest.fn(),
|
||||
}))
|
||||
|
||||
const list = {
|
||||
uri: 'at://did:plc:creator/app.bsky.graph.list/list',
|
||||
viewer: {},
|
||||
} as unknown as app.bsky.graph.defs.ListView
|
||||
const createdOptOut =
|
||||
'at://did:plc:viewer/app.bsky.graph.referencelistoptout/created'
|
||||
|
||||
function setup() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {gcTime: Infinity, retry: false},
|
||||
mutations: {gcTime: Infinity, retry: false},
|
||||
},
|
||||
})
|
||||
const pdsClient = {
|
||||
assertDid: 'did:plc:viewer',
|
||||
create: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
}
|
||||
jest.mocked(usePdsClient).mockReturnValue(pdsClient as never)
|
||||
jest.mocked(useAppviewClient).mockReturnValue({call: jest.fn()} as never)
|
||||
queryClient.setQueryData(RQKEY(list.uri), list)
|
||||
const wrapper = ({children}: PropsWithChildren) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useReferenceListOptOutMutation({
|
||||
list,
|
||||
onError: jest.fn(),
|
||||
}),
|
||||
{wrapper},
|
||||
)
|
||||
return {hook, pdsClient, queryClient}
|
||||
}
|
||||
|
||||
beforeEach(() => jest.clearAllMocks())
|
||||
|
||||
describe('useReferenceListOptOutMutation', () => {
|
||||
it('creates an opt-out for the reference list', async () => {
|
||||
const {hook, pdsClient, queryClient} = setup()
|
||||
pdsClient.create.mockResolvedValue({uri: createdOptOut})
|
||||
jest.mocked(until).mockResolvedValue(false)
|
||||
|
||||
await act(() =>
|
||||
hook.result.current.mutateAsync({referenceListOptOut: undefined}),
|
||||
)
|
||||
|
||||
expect(pdsClient.create).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({subject: list.uri}),
|
||||
)
|
||||
expect(
|
||||
queryClient.getQueryData<app.bsky.graph.defs.ListView>(RQKEY(list.uri))
|
||||
?.viewer?.referenceListOptOut,
|
||||
).toBe(createdOptOut)
|
||||
})
|
||||
|
||||
it('deletes the existing opt-out record when undoing', async () => {
|
||||
const {hook, pdsClient} = setup()
|
||||
pdsClient.delete.mockResolvedValue(undefined)
|
||||
jest.mocked(until).mockResolvedValue(true)
|
||||
|
||||
await act(() =>
|
||||
hook.result.current.mutateAsync({referenceListOptOut: createdOptOut}),
|
||||
)
|
||||
|
||||
expect(pdsClient.delete).toHaveBeenCalledWith(expect.anything(), {
|
||||
repo: 'did:plc:viewer',
|
||||
rkey: 'created',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
import {type PropsWithChildren} from 'react'
|
||||
import {
|
||||
notifyManager,
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
} from '@tanstack/react-query'
|
||||
import {act, renderHook, waitFor} from '@testing-library/react-native'
|
||||
|
||||
import {until} from '#/lib/async/until'
|
||||
import {useAppviewClient, usePdsClient} from '#/state/session'
|
||||
import {type app} from '#/lexicons'
|
||||
import {useReferenceListOptOutMutation} from '../starter-packs'
|
||||
|
||||
jest.mock('#/lib/async/until', () => ({
|
||||
until: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('#/state/session', () => ({
|
||||
useAppviewClient: jest.fn(),
|
||||
usePdsClient: jest.fn(),
|
||||
}))
|
||||
|
||||
const starterPack = {
|
||||
uri: 'at://did:plc:creator/app.bsky.graph.starterpack/pack',
|
||||
list: {
|
||||
uri: 'at://did:plc:creator/app.bsky.graph.list/list',
|
||||
viewer: {},
|
||||
},
|
||||
} as unknown as app.bsky.graph.defs.StarterPackView
|
||||
|
||||
const queryKey = ['starter-pack', 'did:plc:creator', 'pack']
|
||||
const createdOptOut =
|
||||
'at://did:plc:viewer/app.bsky.graph.referencelistoptout/created'
|
||||
const indexedOptOut =
|
||||
'at://did:plc:viewer/app.bsky.graph.referencelistoptout/indexed'
|
||||
|
||||
function setup({onSuccess = jest.fn()} = {}) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {gcTime: Infinity, retry: false},
|
||||
mutations: {gcTime: Infinity, retry: false},
|
||||
},
|
||||
})
|
||||
const pdsClient = {
|
||||
assertDid: 'did:plc:viewer',
|
||||
create: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
}
|
||||
const appviewClient = {call: jest.fn()}
|
||||
const onError = jest.fn()
|
||||
|
||||
jest.mocked(usePdsClient).mockReturnValue(pdsClient as never)
|
||||
jest.mocked(useAppviewClient).mockReturnValue(appviewClient as never)
|
||||
queryClient.setQueryData(queryKey, starterPack)
|
||||
|
||||
const wrapper = ({children}: PropsWithChildren) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
const hook = renderHook(
|
||||
() => useReferenceListOptOutMutation({starterPack, onError, onSuccess}),
|
||||
{wrapper},
|
||||
)
|
||||
|
||||
return {appviewClient, hook, onError, onSuccess, pdsClient, queryClient}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
beforeAll(() => {
|
||||
notifyManager.setNotifyFunction(callback => {
|
||||
act(callback)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useReferenceListOptOutMutation', () => {
|
||||
it('keeps the successful PDS write optimistic when AppView has not caught up', async () => {
|
||||
const {hook, onSuccess, pdsClient, queryClient} = setup()
|
||||
pdsClient.create.mockResolvedValue({uri: createdOptOut})
|
||||
jest.mocked(until).mockResolvedValue(false)
|
||||
|
||||
await act(() =>
|
||||
hook.result.current.mutateAsync({referenceListOptOut: undefined}),
|
||||
)
|
||||
|
||||
expect(pdsClient.create).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({subject: starterPack.list!.uri}),
|
||||
)
|
||||
expect(
|
||||
queryClient.getQueryData<app.bsky.graph.defs.StarterPackView>(queryKey)
|
||||
?.list?.viewer?.referenceListOptOut,
|
||||
).toBe(createdOptOut)
|
||||
expect(onSuccess).toHaveBeenCalledWith('optOut')
|
||||
})
|
||||
|
||||
it('uses the indexed viewer-state URI when AppView reports a duplicate', async () => {
|
||||
const {hook, pdsClient, queryClient} = setup()
|
||||
pdsClient.create.mockResolvedValue({uri: createdOptOut})
|
||||
jest.mocked(until).mockImplementation((_retries, _delay, cond) =>
|
||||
Promise.resolve(
|
||||
cond(
|
||||
{
|
||||
starterPack: {
|
||||
list: {viewer: {referenceListOptOut: indexedOptOut}},
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
await act(() =>
|
||||
hook.result.current.mutateAsync({referenceListOptOut: undefined}),
|
||||
)
|
||||
|
||||
expect(
|
||||
queryClient.getQueryData<app.bsky.graph.defs.StarterPackView>(queryKey)
|
||||
?.list?.viewer?.referenceListOptOut,
|
||||
).toBe(indexedOptOut)
|
||||
})
|
||||
|
||||
it('deletes the viewer-state record URI when undoing', async () => {
|
||||
const {hook, onSuccess, pdsClient, queryClient} = setup()
|
||||
queryClient.setQueryData(queryKey, {
|
||||
...starterPack,
|
||||
list: {
|
||||
...starterPack.list,
|
||||
viewer: {referenceListOptOut: indexedOptOut},
|
||||
},
|
||||
})
|
||||
pdsClient.delete.mockResolvedValue(undefined)
|
||||
jest
|
||||
.mocked(until)
|
||||
.mockImplementation((_retries, _delay, cond) =>
|
||||
Promise.resolve(cond({starterPack: {list: {viewer: {}}}}, undefined)),
|
||||
)
|
||||
|
||||
await act(() =>
|
||||
hook.result.current.mutateAsync({referenceListOptOut: indexedOptOut}),
|
||||
)
|
||||
|
||||
expect(pdsClient.delete).toHaveBeenCalledWith(expect.anything(), {
|
||||
repo: 'did:plc:viewer',
|
||||
rkey: 'indexed',
|
||||
})
|
||||
expect(
|
||||
queryClient.getQueryData<app.bsky.graph.defs.StarterPackView>(queryKey)
|
||||
?.list?.viewer?.referenceListOptOut,
|
||||
).toBeUndefined()
|
||||
expect(onSuccess).toHaveBeenCalledWith('undo')
|
||||
})
|
||||
|
||||
it('restores viewer state and surfaces PDS write failures', async () => {
|
||||
const {hook, onError, onSuccess, pdsClient, queryClient} = setup()
|
||||
const error = new Error('write failed')
|
||||
pdsClient.create.mockRejectedValue(error)
|
||||
|
||||
await act(async () => {
|
||||
await expect(
|
||||
hook.result.current.mutateAsync({referenceListOptOut: undefined}),
|
||||
).rejects.toThrow('write failed')
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onError).toHaveBeenCalledWith(error))
|
||||
expect(onSuccess).not.toHaveBeenCalled()
|
||||
expect(
|
||||
queryClient.getQueryData<app.bsky.graph.defs.StarterPackView>(queryKey)
|
||||
?.list?.viewer?.referenceListOptOut,
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -84,7 +84,10 @@ export async function invalidateListMembersQuery({
|
||||
queryClient: QueryClient
|
||||
uri: string
|
||||
}) {
|
||||
await queryClient.invalidateQueries({queryKey: RQKEY(uri)})
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({queryKey: RQKEY(uri)}),
|
||||
queryClient.invalidateQueries({queryKey: RQKEY_ALL(uri)}),
|
||||
])
|
||||
}
|
||||
|
||||
export function* findAllProfilesInQueryData(
|
||||
|
||||
@@ -41,6 +41,118 @@ export function useListQuery(uri?: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export function useReferenceListOptOutMutation({
|
||||
list,
|
||||
onError,
|
||||
onSuccess,
|
||||
}: {
|
||||
list: app.bsky.graph.defs.ListView
|
||||
onError: (error: Error) => void
|
||||
onSuccess?: (action: 'optOut' | 'undo') => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const appviewClient = useAppviewClient()
|
||||
const pdsClient = usePdsClient()
|
||||
|
||||
return useMutation<
|
||||
{
|
||||
referenceListOptOut: AtUriString | undefined
|
||||
didObserveRequestedState: boolean
|
||||
},
|
||||
Error,
|
||||
{referenceListOptOut?: string},
|
||||
{previous?: app.bsky.graph.defs.ListView}
|
||||
>({
|
||||
mutationFn: async ({referenceListOptOut}) => {
|
||||
let nextOptOut: AtUriString | undefined
|
||||
if (referenceListOptOut) {
|
||||
const {rkeySafe: rkey} = new AtUri(referenceListOptOut)
|
||||
await pdsClient.delete(app.bsky.graph.referencelistoptout, {
|
||||
repo: pdsClient.assertDid,
|
||||
rkey,
|
||||
})
|
||||
} else {
|
||||
const result = await pdsClient.create(
|
||||
app.bsky.graph.referencelistoptout,
|
||||
{
|
||||
subject: list.uri,
|
||||
createdAt: toDatetimeString(new Date()),
|
||||
},
|
||||
)
|
||||
nextOptOut = result.uri
|
||||
}
|
||||
|
||||
const didObserveRequestedState = await until(
|
||||
5,
|
||||
1e3,
|
||||
(value, error) => {
|
||||
if (error) return false
|
||||
const observedOptOut = value?.list.viewer?.referenceListOptOut
|
||||
const didObserveRequestedState = referenceListOptOut
|
||||
? !observedOptOut
|
||||
: Boolean(observedOptOut)
|
||||
if (didObserveRequestedState) nextOptOut = observedOptOut
|
||||
return didObserveRequestedState
|
||||
},
|
||||
async () =>
|
||||
await appviewClient.call(app.bsky.graph.getList, {
|
||||
list: list.uri,
|
||||
limit: 1,
|
||||
}),
|
||||
)
|
||||
|
||||
return {referenceListOptOut: nextOptOut, didObserveRequestedState}
|
||||
},
|
||||
onMutate: async ({referenceListOptOut}) => {
|
||||
const queryKey = RQKEY(list.uri)
|
||||
await queryClient.cancelQueries({queryKey})
|
||||
const previous =
|
||||
queryClient.getQueryData<app.bsky.graph.defs.ListView>(queryKey)
|
||||
queryClient.setQueryData<app.bsky.graph.defs.ListView>(
|
||||
queryKey,
|
||||
current =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
viewer: {
|
||||
...current.viewer,
|
||||
referenceListOptOut: referenceListOptOut
|
||||
? undefined
|
||||
: `at://${pdsClient.assertDid}/app.bsky.graph.referencelistoptout/pending`,
|
||||
},
|
||||
}
|
||||
: current,
|
||||
)
|
||||
return {previous}
|
||||
},
|
||||
onSuccess: ({referenceListOptOut}, variables) => {
|
||||
queryClient.setQueryData<app.bsky.graph.defs.ListView>(
|
||||
RQKEY(list.uri),
|
||||
current =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
viewer: {...current.viewer, referenceListOptOut},
|
||||
}
|
||||
: current,
|
||||
)
|
||||
onSuccess?.(variables.referenceListOptOut ? 'undo' : 'optOut')
|
||||
},
|
||||
onError: (error, _, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(RQKEY(list.uri), context.previous)
|
||||
}
|
||||
onError(error)
|
||||
},
|
||||
onSettled: data => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: RQKEY(list.uri),
|
||||
refetchType: data && !data.didObserveRequestedState ? 'none' : 'active',
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface ListCreateMutateParams {
|
||||
purpose: string
|
||||
name: string
|
||||
|
||||
@@ -72,6 +72,150 @@ export function useStarterPackQuery({
|
||||
})
|
||||
}
|
||||
|
||||
export function useReferenceListOptOutMutation({
|
||||
starterPack,
|
||||
onError,
|
||||
onSuccess,
|
||||
}: {
|
||||
starterPack: app.bsky.graph.defs.StarterPackView
|
||||
onError: (error: Error) => void
|
||||
onSuccess?: (action: 'optOut' | 'undo') => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const appviewClient = useAppviewClient()
|
||||
const pdsClient = usePdsClient()
|
||||
const parsed = parseStarterPackUri(starterPack.uri)!
|
||||
const queryKey = RQKEY({did: parsed.name, rkey: parsed.rkey})
|
||||
|
||||
return useMutation<
|
||||
{
|
||||
referenceListOptOut: AtUriString | undefined
|
||||
didObserveRequestedState: boolean
|
||||
},
|
||||
Error,
|
||||
{referenceListOptOut?: string},
|
||||
{previous?: app.bsky.graph.defs.StarterPackView}
|
||||
>({
|
||||
mutationFn: async ({referenceListOptOut}) => {
|
||||
if (!starterPack.list) {
|
||||
throw new Error('Starter pack does not have a reference list')
|
||||
}
|
||||
|
||||
let nextOptOut: AtUriString | undefined
|
||||
if (referenceListOptOut) {
|
||||
const {rkeySafe: rkey} = new AtUri(referenceListOptOut)
|
||||
await pdsClient.delete(app.bsky.graph.referencelistoptout, {
|
||||
repo: pdsClient.assertDid,
|
||||
rkey,
|
||||
})
|
||||
} else {
|
||||
const result = await pdsClient.create(
|
||||
app.bsky.graph.referencelistoptout,
|
||||
{
|
||||
subject: starterPack.list.uri,
|
||||
createdAt: toDatetimeString(new Date()),
|
||||
},
|
||||
)
|
||||
nextOptOut = result.uri
|
||||
}
|
||||
|
||||
const didObserveRequestedState = await until(
|
||||
5,
|
||||
1e3,
|
||||
(value, error) => {
|
||||
if (error) return false
|
||||
|
||||
const observedOptOut =
|
||||
value?.starterPack.list?.viewer?.referenceListOptOut
|
||||
|
||||
// AppView ignores duplicate records and continues to expose the URI
|
||||
// of the record it indexed first. Treat that viewer state as the
|
||||
// source of truth instead of waiting for the newly-created URI.
|
||||
const didObserveRequestedState = referenceListOptOut
|
||||
? !observedOptOut
|
||||
: Boolean(observedOptOut)
|
||||
if (didObserveRequestedState) {
|
||||
nextOptOut = observedOptOut
|
||||
}
|
||||
return didObserveRequestedState
|
||||
},
|
||||
async () =>
|
||||
await appviewClient.call(app.bsky.graph.getStarterPack, {
|
||||
starterPack: starterPack.uri,
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
referenceListOptOut: nextOptOut,
|
||||
didObserveRequestedState,
|
||||
}
|
||||
},
|
||||
onMutate: async ({referenceListOptOut}) => {
|
||||
await queryClient.cancelQueries({queryKey})
|
||||
const previous =
|
||||
queryClient.getQueryData<app.bsky.graph.defs.StarterPackView>(queryKey)
|
||||
queryClient.setQueryData<app.bsky.graph.defs.StarterPackView>(
|
||||
queryKey,
|
||||
current =>
|
||||
current?.list
|
||||
? {
|
||||
...current,
|
||||
list: {
|
||||
...current.list,
|
||||
viewer: {
|
||||
...current.list.viewer,
|
||||
referenceListOptOut: referenceListOptOut
|
||||
? undefined
|
||||
: `at://${pdsClient.assertDid}/app.bsky.graph.referencelistoptout/pending`,
|
||||
},
|
||||
},
|
||||
}
|
||||
: current,
|
||||
)
|
||||
return {previous}
|
||||
},
|
||||
onSuccess: ({referenceListOptOut}, variables) => {
|
||||
queryClient.setQueryData<app.bsky.graph.defs.StarterPackView>(
|
||||
queryKey,
|
||||
current =>
|
||||
current?.list
|
||||
? {
|
||||
...current,
|
||||
list: {
|
||||
...current.list,
|
||||
viewer: {
|
||||
...current.list.viewer,
|
||||
referenceListOptOut,
|
||||
},
|
||||
},
|
||||
}
|
||||
: current,
|
||||
)
|
||||
void invalidateListMembersQuery({
|
||||
queryClient,
|
||||
uri: starterPack.list!.uri,
|
||||
})
|
||||
onSuccess?.(variables.referenceListOptOut ? 'undo' : 'optOut')
|
||||
},
|
||||
onError: (error, _, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(queryKey, context.previous)
|
||||
}
|
||||
onError(error)
|
||||
},
|
||||
onSettled: data => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey,
|
||||
// If AppView has not indexed the PDS write yet, keep the committed
|
||||
// optimistic state visible. Mark it stale so a later mount/focus can
|
||||
// refetch once AppView has caught up without replacing it immediately
|
||||
// with the known-outdated value.
|
||||
refetchType: data && !data.didObserveRequestedState ? 'none' : 'active',
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function invalidateStarterPack({
|
||||
queryClient,
|
||||
did,
|
||||
|
||||
Reference in New Issue
Block a user