Add opt-out to reference list menu

This commit is contained in:
vineyardbovines
2026-09-02 20:15:49 -04:00
parent 4103e7e2c4
commit b8505ba65a
3 changed files with 291 additions and 0 deletions
@@ -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<NavigationProp>()
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({
</Menu.ItemText>
<Menu.ItemIcon position="right" icon={WarningIcon} />
</Menu.Item>
{isReferenceList ? (
<Menu.Item
label={
referenceListOptOut
? _(msg`Undo opt-out from starter pack`)
: _(msg`Opt out of starter pack`)
}
disabled={isOptOutPending}
onPress={optOutDialogControl.open}>
<Menu.ItemText>
{referenceListOptOut ? (
<Trans>Undo opt-out</Trans>
) : (
<Trans>Opt out of starter pack</Trans>
)}
</Menu.ItemText>
</Menu.Item>
) : null}
</Menu.Group>
)}
@@ -277,6 +323,46 @@ export function MoreOptionsMenu({
confirmButtonColor="negative"
/>
{isReferenceList ? (
<Prompt.Outer control={optOutDialogControl}>
<Prompt.TitleText>
{referenceListOptOut ? (
<Trans>Undo opt-out?</Trans>
) : (
<Trans>Opt out of this starter pack?</Trans>
)}
</Prompt.TitleText>
<Prompt.DescriptionText>
{referenceListOptOut ? (
<Trans>
You will be eligible to appear in this starter pack again.
</Trans>
) : (
<Trans>
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.
</Trans>
)}
</Prompt.DescriptionText>
<Prompt.Actions>
<Prompt.Action
cta={
referenceListOptOut
? _(msg`Undo opt-out`)
: _(msg`Opt out of starter pack`)
}
color={referenceListOptOut ? 'primary' : 'negative'}
disabled={isOptOutPending}
onPress={() => {
setReferenceListOptOut({referenceListOptOut})
}}
/>
<Prompt.Cancel />
</Prompt.Actions>
</Prompt.Outer>
) : null}
<ReportDialog
control={reportDialogControl}
subject={{
@@ -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',
})
})
})
+112
View File
@@ -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