Add opt-out to reference list menu
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
|||||||
useListBlockMutation,
|
useListBlockMutation,
|
||||||
useListDeleteMutation,
|
useListDeleteMutation,
|
||||||
useListMuteMutation,
|
useListMuteMutation,
|
||||||
|
useReferenceListOptOutMutation,
|
||||||
} from '#/state/queries/list'
|
} from '#/state/queries/list'
|
||||||
import {useRemoveFeedMutation} from '#/state/queries/preferences'
|
import {useRemoveFeedMutation} from '#/state/queries/preferences'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
@@ -51,6 +52,7 @@ export function MoreOptionsMenu({
|
|||||||
const editListDialogControl = useDialogControl()
|
const editListDialogControl = useDialogControl()
|
||||||
const deleteListPromptControl = useDialogControl()
|
const deleteListPromptControl = useDialogControl()
|
||||||
const reportDialogControl = useReportDialogControl()
|
const reportDialogControl = useReportDialogControl()
|
||||||
|
const optOutDialogControl = useDialogControl()
|
||||||
const navigation = useNavigation<NavigationProp>()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
|
||||||
const {mutateAsync: removeSavedFeed} = useRemoveFeedMutation()
|
const {mutateAsync: removeSavedFeed} = useRemoveFeedMutation()
|
||||||
@@ -60,10 +62,36 @@ export function MoreOptionsMenu({
|
|||||||
|
|
||||||
const isCurateList = list.purpose === app.bsky.graph.defs.curatelist.value
|
const isCurateList = list.purpose === app.bsky.graph.defs.curatelist.value
|
||||||
const isModList = list.purpose === app.bsky.graph.defs.modlist.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 isBlocking = !!list.viewer?.blocked
|
||||||
const isMuting = !!list.viewer?.muted
|
const isMuting = !!list.viewer?.muted
|
||||||
const isPinned = Boolean(savedFeedConfig?.pinned)
|
const isPinned = Boolean(savedFeedConfig?.pinned)
|
||||||
const isOwner = currentAccount?.did === list.creator.did
|
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 onPressShare = () => {
|
||||||
const {rkey} = new AtUri(list.uri)
|
const {rkey} = new AtUri(list.uri)
|
||||||
@@ -215,6 +243,24 @@ export function MoreOptionsMenu({
|
|||||||
</Menu.ItemText>
|
</Menu.ItemText>
|
||||||
<Menu.ItemIcon position="right" icon={WarningIcon} />
|
<Menu.ItemIcon position="right" icon={WarningIcon} />
|
||||||
</Menu.Item>
|
</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>
|
</Menu.Group>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -277,6 +323,46 @@ export function MoreOptionsMenu({
|
|||||||
confirmButtonColor="negative"
|
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
|
<ReportDialog
|
||||||
control={reportDialogControl}
|
control={reportDialogControl}
|
||||||
subject={{
|
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',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 {
|
export interface ListCreateMutateParams {
|
||||||
purpose: string
|
purpose: string
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
Reference in New Issue
Block a user