diff --git a/src/screens/ProfileList/components/MoreOptionsMenu.tsx b/src/screens/ProfileList/components/MoreOptionsMenu.tsx index 02d9cc7212..82f6860ec3 100644 --- a/src/screens/ProfileList/components/MoreOptionsMenu.tsx +++ b/src/screens/ProfileList/components/MoreOptionsMenu.tsx @@ -12,6 +12,7 @@ import { useListBlockMutation, useListDeleteMutation, useListMuteMutation, + useReferenceListOptOutMutation, } from '#/state/queries/list' import {useRemoveFeedMutation} from '#/state/queries/preferences' import {useSession} from '#/state/session' @@ -51,6 +52,7 @@ export function MoreOptionsMenu({ const editListDialogControl = useDialogControl() const deleteListPromptControl = useDialogControl() const reportDialogControl = useReportDialogControl() + const optOutDialogControl = useDialogControl() const navigation = useNavigation() const {mutateAsync: removeSavedFeed} = useRemoveFeedMutation() @@ -60,10 +62,36 @@ export function MoreOptionsMenu({ const isCurateList = list.purpose === app.bsky.graph.defs.curatelist.value const isModList = list.purpose === app.bsky.graph.defs.modlist.value + const isReferenceList = + list.purpose === app.bsky.graph.defs.referencelist.value const isBlocking = !!list.viewer?.blocked const isMuting = !!list.viewer?.muted const isPinned = Boolean(savedFeedConfig?.pinned) const isOwner = currentAccount?.did === list.creator.did + const referenceListOptOut = list.viewer?.referenceListOptOut + const {mutate: setReferenceListOptOut, isPending: isOptOutPending} = + useReferenceListOptOutMutation({ + list, + onSuccess: action => { + ax.metric('starterPack:optOut', { + starterPack: list.uri, + action, + }) + Toast.show( + action === 'optOut' + ? _(msg`Opted out of starter pack`) + : _(msg`Opt-out undone`), + ) + }, + onError: error => { + logger.error('Failed to update starter pack opt-out', { + safeMessage: error, + }) + Toast.show(_(msg`Failed to update starter pack opt-out`), { + type: 'error', + }) + }, + }) const onPressShare = () => { const {rkey} = new AtUri(list.uri) @@ -215,6 +243,24 @@ export function MoreOptionsMenu({ + {isReferenceList ? ( + + + {referenceListOptOut ? ( + Undo opt-out + ) : ( + Opt out of starter pack + )} + + + ) : null} )} @@ -277,6 +323,46 @@ export function MoreOptionsMenu({ confirmButtonColor="negative" /> + {isReferenceList ? ( + + + {referenceListOptOut ? ( + Undo opt-out? + ) : ( + Opt out of this starter pack? + )} + + + {referenceListOptOut ? ( + + You will be eligible to appear in this starter pack again. + + ) : ( + + You will no longer appear in this starter pack. The creator will + be able to see that you've opted out and remove you if they + wish. + + )} + + + { + setReferenceListOptOut({referenceListOptOut}) + }} + /> + + + + ) : null} + ({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) => ( + {children} + ) + 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(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', + }) + }) +}) diff --git a/src/state/queries/list.ts b/src/state/queries/list.ts index bd29d578dc..21b11edc98 100644 --- a/src/state/queries/list.ts +++ b/src/state/queries/list.ts @@ -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(queryKey) + queryClient.setQueryData( + 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( + 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