From 68e56eaea048b8166a8e03755a4f4b4a809470da Mon Sep 17 00:00:00 2001 From: Spence Pope Date: Thu, 3 Sep 2026 03:08:48 -0400 Subject: [PATCH] Add starter pack reference-list opt-out UI (#11578) --- lexicons.json | 5 + .../app/bsky/graph/referencelistoptout.json | 30 +++ src/analytics/metrics/types.ts | 5 + .../StarterPack/Main/ProfilesList.tsx | 108 +++++++++-- .../StarterPack/Wizard/WizardListCard.tsx | 14 +- .../components/MoreOptionsMenu.tsx | 113 +++++++++++- src/screens/StarterPack/StarterPackScreen.tsx | 96 ++++++++++ src/screens/StarterPack/Wizard/State.tsx | 3 +- .../StarterPack/Wizard/StepProfiles.tsx | 3 + src/screens/StarterPack/Wizard/index.tsx | 11 +- .../queries/__tests__/list-opt-out.test.tsx | 93 ++++++++++ .../queries/__tests__/starter-packs.test.tsx | 173 ++++++++++++++++++ src/state/queries/list-members.ts | 5 +- src/state/queries/list.ts | 112 ++++++++++++ src/state/queries/starter-packs.ts | 144 +++++++++++++++ 15 files changed, 885 insertions(+), 30 deletions(-) create mode 100644 lexicons/app/bsky/graph/referencelistoptout.json create mode 100644 src/state/queries/__tests__/list-opt-out.test.tsx create mode 100644 src/state/queries/__tests__/starter-packs.test.tsx diff --git a/lexicons.json b/lexicons.json index b36ba16b61..1d8d9d5dab 100644 --- a/lexicons.json +++ b/lexicons.json @@ -92,6 +92,7 @@ "app.bsky.graph.muteActor", "app.bsky.graph.muteActorList", "app.bsky.graph.muteThread", + "app.bsky.graph.referencelistoptout", "app.bsky.graph.searchStarterPacks", "app.bsky.graph.searchStarterPacksV2", "app.bsky.graph.starterpack", @@ -632,6 +633,10 @@ "uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.graph.muteThread", "cid": "bafyreib6ppci3qzye6wktkm4byxtb5mnl2vg22fm7oawcdvof2tfogx4dy" }, + "app.bsky.graph.referencelistoptout": { + "uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.graph.referencelistoptout", + "cid": "bafyreifode2cfu7x7yamiorzg66u46l4zdr2yxmtuoikrup7j2dhwzxf3q" + }, "app.bsky.graph.searchStarterPacks": { "uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.graph.searchStarterPacks", "cid": "bafyreia446ip6mbnwpml6hlvxab7jtsud7zczde3u6zmnsa3op4bpxu7um" diff --git a/lexicons/app/bsky/graph/referencelistoptout.json b/lexicons/app/bsky/graph/referencelistoptout.json new file mode 100644 index 0000000000..e5387d8d82 --- /dev/null +++ b/lexicons/app/bsky/graph/referencelistoptout.json @@ -0,0 +1,30 @@ +{ + "id": "app.bsky.graph.referencelistoptout", + "defs": { + "main": { + "key": "tid", + "type": "record", + "record": { + "type": "object", + "required": [ + "subject", + "createdAt" + ], + "properties": { + "subject": { + "type": "string", + "format": "at-uri", + "description": "Canonical, DID-based AT URI of the app.bsky.graph.list record from which the author requests omission." + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + }, + "description": "Record requesting that its author be omitted from the public presentation of a reference list. This record is only enforced when the subject list's current purpose is app.bsky.graph.defs#referencelist. AppView indexes at most one record per actor and list pair, and ignores duplicate records." + } + }, + "$type": "com.atproto.lexicon.schema", + "lexicon": 1 +} diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index d3ebd65319..42e9ec2124 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -705,6 +705,7 @@ export type Events = { } 'starterPack:removeUser': { starterPack?: string + context?: 'opt-out' } 'starterPack:share': { starterPack: string @@ -717,6 +718,10 @@ export type Events = { count: number } 'starterPack:delete': {} + 'starterPack:optOut': { + starterPack: string + action: 'optOut' | 'undo' + } 'starterPack:create': { setName: boolean setDescription: boolean diff --git a/src/components/StarterPack/Main/ProfilesList.tsx b/src/components/StarterPack/Main/ProfilesList.tsx index 56ac697a31..3b9cfbb89a 100644 --- a/src/components/StarterPack/Main/ProfilesList.tsx +++ b/src/components/StarterPack/Main/ProfilesList.tsx @@ -2,22 +2,32 @@ import {forwardRef, useCallback, useImperativeHandle, useState} from 'react' import {type ListRenderItemInfo, View} from 'react-native' import {AtUri} from '@atproto/syntax' import {type ModerationOpts} from '@bsky/sdk/moderation' +import {msg} from '@lingui/core/macro' +import {useLingui} from '@lingui/react' +import {Trans} from '@lingui/react/macro' import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {isBlockedOrBlocking} from '#/lib/moderation/blocked-and-muted' +import {cleanError} from '#/lib/strings/errors' import {useAllListMembersQuery} from '#/state/queries/list-members' +import {useListMembershipRemoveMutation} from '#/state/queries/list-memberships' import {useSession} from '#/state/session' import {List, type ListRef} from '#/view/com/util/List' import {type SectionRef} from '#/screens/Profile/Sections/types' import {atoms as a, useTheme} from '#/alf' +import * as Admonition from '#/components/Admonition' +import {ButtonIcon, ButtonText} from '#/components/Button' import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' +import {Loader} from '#/components/Loader' import {Default as ProfileCard} from '#/components/ProfileCard' +import * as Toast from '#/components/Toast' +import {useAnalytics} from '#/analytics' import {IS_NATIVE, IS_WEB} from '#/env' import {type app} from '#/lexicons' -function keyExtractor(item: app.bsky.actor.defs.ProfileView, index: number) { - return `${item.did}-${index}` +function keyExtractor(item: app.bsky.graph.defs.ListItemView) { + return item.uri } interface ProfilesListProps { @@ -42,26 +52,26 @@ export const ProfilesList = forwardRef( // The server returns these sorted by descending creation date, so we want to invert - const profiles = data + const listItems = data ?.filter( p => !isBlockedOrBlocking(p.subject) && !p.subject.associated?.labeler, ) - .map(p => p.subject) .reverse() const isOwn = new AtUri(listUri).host === currentAccount?.did const getSortedProfiles = () => { - if (!profiles) return - if (!isOwn) return profiles + if (!listItems) return - const myIndex = profiles.findIndex(p => p.did === currentAccount?.did) - return myIndex !== -1 - ? [ - profiles[myIndex], - ...profiles.slice(0, myIndex), - ...profiles.slice(myIndex + 1), - ] - : profiles + return [...listItems].sort((a, b) => { + if (a.subjectOptedOut !== b.subjectOptedOut) { + return a.subjectOptedOut ? -1 : 1 + } + if (isOwn) { + if (a.subject.did === currentAccount?.did) return -1 + if (b.subject.did === currentAccount?.did) return 1 + } + return 0 + }) } const onScrollToTop = useCallback(() => { scrollElRef.current?.scrollToOffset({ @@ -77,7 +87,7 @@ export const ProfilesList = forwardRef( const renderItem = ({ item, index, - }: ListRenderItemInfo) => { + }: ListRenderItemInfo) => { return ( ( (IS_WEB || index !== 0) && a.border_t, ]}> + {item.subjectOptedOut ? ( + + ) : null} ) } @@ -136,3 +149,66 @@ export const ProfilesList = forwardRef( ) }, ) + +function OptedOutControls({ + item, + listUri, + canRemove, +}: { + item: app.bsky.graph.defs.ListItemView + listUri: string + canRemove: boolean +}) { + const {_} = useLingui() + const ax = useAnalytics() + const [isRemoved, setIsRemoved] = useState(false) + const {mutate: removeMembership, isPending} = useListMembershipRemoveMutation( + { + onSuccess: () => { + setIsRemoved(true) + Toast.show(_(msg`Removed from starter pack`)) + }, + onError: error => + Toast.show(cleanError(error), { + type: 'error', + }), + }, + ) + + if (isRemoved) return null + + return ( + + + + + + Opted out of this starter pack + + + {canRemove ? ( + { + ax.metric('starterPack:removeUser', {context: 'opt-out'}) + removeMembership({ + listUri, + actorDid: item.subject.did, + membershipUri: item.uri, + }) + }}> + {isPending ? ( + + ) : ( + + Remove + + )} + + ) : null} + + + ) +} diff --git a/src/components/StarterPack/Wizard/WizardListCard.tsx b/src/components/StarterPack/Wizard/WizardListCard.tsx index 39397f38e3..eba7398754 100644 --- a/src/components/StarterPack/Wizard/WizardListCard.tsx +++ b/src/components/StarterPack/Wizard/WizardListCard.tsx @@ -36,6 +36,7 @@ function WizardListCard({ avatar, included, disabled, + subjectOptedOut, moderationUi, }: { type: 'user' | 'algo' @@ -48,6 +49,7 @@ function WizardListCard({ avatar?: string included?: boolean disabled?: boolean + subjectOptedOut?: boolean moderationUi: ModerationUI }) { const t = useTheme() @@ -97,6 +99,11 @@ function WizardListCard({ numberOfLines={1}> {subtitle} + {subjectOptedOut ? ( + + Opted out + + ) : null} {btnType === 'checkbox' ? ( @@ -123,12 +130,14 @@ export function WizardProfileCard({ dispatch, profile, moderationOpts, + subjectOptedOut = false, }: { btnType: 'checkbox' | 'remove' state: WizardState dispatch: (action: WizardAction) => void profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts + subjectOptedOut?: boolean }) { const ax = useAnalytics() const {currentAccount} = useSession() @@ -138,7 +147,9 @@ export function WizardProfileCard({ const isTarget = profile.did === targetProfileDid const included = isTarget || state.profiles.some(p => p.did === profile.did) const disabled = - isTarget || (!included && state.profiles.length >= STARTER_PACK_MAX_SIZE) + subjectOptedOut || + isTarget || + (!included && state.profiles.length >= STARTER_PACK_MAX_SIZE) const moderationUi = moderateProfile(profile, moderationOpts).ui('avatar') const displayName = profile.displayName ? sanitizeDisplayName(profile.displayName) @@ -169,6 +180,7 @@ export function WizardProfileCard({ avatar={profile.avatar} included={included} disabled={disabled} + subjectOptedOut={subjectOptedOut} moderationUi={moderationUi} /> ) diff --git a/src/screens/ProfileList/components/MoreOptionsMenu.tsx b/src/screens/ProfileList/components/MoreOptionsMenu.tsx index 02d9cc7212..533a183eda 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) @@ -206,16 +234,41 @@ export function MoreOptionsMenu({ ) : ( - - - - Report list - - - - + <> + + + + Report list + + + + + {isReferenceList ? ( + <> + + + + + {referenceListOptOut ? ( + Undo opt-out + ) : ( + Opt out of starter pack + )} + + + + + ) : null} + )} {isModList && isPinned && ( @@ -277,6 +330,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} + () const { @@ -546,6 +548,30 @@ function OverflowMenu({ }) const isOwn = starterPack.creator.did === currentAccount?.did + const referenceListOptOut = starterPack.list?.viewer?.referenceListOptOut + const {mutate: setReferenceListOptOut, isPending: isOptOutPending} = + useReferenceListOptOutMutation({ + starterPack, + onSuccess: action => { + ax.metric('starterPack:optOut', { + starterPack: starterPack.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 onDeleteStarterPack = async () => { if (!starterPack.list) { @@ -650,6 +676,24 @@ function OverflowMenu({ + {starterPack.list ? ( + optOutDialogControl.open()}> + + {referenceListOptOut ? ( + Undo opt-out + ) : ( + Opt out of starter pack + )} + + + ) : null} )} @@ -709,6 +753,58 @@ function OverflowMenu({ + {starterPack.list ? ( + + + {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. + + )} + + + + + + + ) : null} + i.subject) ?? [], + profiles: + listItems?.filter(i => !i.subjectOptedOut).map(i => i.subject) ?? [], feeds: starterPack.feeds ?? [], processing: false, transitionDirection: 'Forward', diff --git a/src/screens/StarterPack/Wizard/StepProfiles.tsx b/src/screens/StarterPack/Wizard/StepProfiles.tsx index 3ba167e856..2761fc1f0d 100644 --- a/src/screens/StarterPack/Wizard/StepProfiles.tsx +++ b/src/screens/StarterPack/Wizard/StepProfiles.tsx @@ -24,8 +24,10 @@ function keyExtractor(item: bsky.profile.AnyProfileView) { export function StepProfiles({ moderationOpts, + optedOutDids, }: { moderationOpts: ModerationOpts + optedOutDids: Set }) { const t = useTheme() const [state, dispatch] = useWizardState() @@ -59,6 +61,7 @@ export function StepProfiles({ state={state} dispatch={dispatch} moderationOpts={moderationOpts} + subjectOptedOut={optedOutDids.has(item.did)} /> ) } diff --git a/src/screens/StarterPack/Wizard/index.tsx b/src/screens/StarterPack/Wizard/index.tsx index d1abb10900..96a6b3ce28 100644 --- a/src/screens/StarterPack/Wizard/index.tsx +++ b/src/screens/StarterPack/Wizard/index.tsx @@ -341,7 +341,16 @@ function WizardInner({ {state.currentStep === 'Details' ? ( ) : state.currentStep === 'Profiles' ? ( - + item.subjectOptedOut) + .map(item => item.subject.did), + ) + } + /> ) : state.currentStep === 'Feeds' ? ( ) : null} diff --git a/src/state/queries/__tests__/list-opt-out.test.tsx b/src/state/queries/__tests__/list-opt-out.test.tsx new file mode 100644 index 0000000000..a2aaca22b4 --- /dev/null +++ b/src/state/queries/__tests__/list-opt-out.test.tsx @@ -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) => ( + {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/__tests__/starter-packs.test.tsx b/src/state/queries/__tests__/starter-packs.test.tsx new file mode 100644 index 0000000000..7cfb17b66c --- /dev/null +++ b/src/state/queries/__tests__/starter-packs.test.tsx @@ -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) => ( + {children} + ) + 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(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(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(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(queryKey) + ?.list?.viewer?.referenceListOptOut, + ).toBeUndefined() + }) +}) diff --git a/src/state/queries/list-members.ts b/src/state/queries/list-members.ts index 1d76296623..409ad0a227 100644 --- a/src/state/queries/list-members.ts +++ b/src/state/queries/list-members.ts @@ -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( diff --git a/src/state/queries/list.ts b/src/state/queries/list.ts index 4ecd8fffbe..a40f7b3fcd 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 diff --git a/src/state/queries/starter-packs.ts b/src/state/queries/starter-packs.ts index 76f8fd2e31..5614d717d5 100644 --- a/src/state/queries/starter-packs.ts +++ b/src/state/queries/starter-packs.ts @@ -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(queryKey) + queryClient.setQueryData( + 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( + 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,