Add starter pack reference-list opt-out UI (#11578)

This commit is contained in:
Spence Pope
2026-09-03 03:08:48 -04:00
committed by GitHub
parent bb747c5f26
commit 68e56eaea0
15 changed files with 885 additions and 30 deletions
+5
View File
@@ -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"
@@ -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
}
+5
View File
@@ -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
@@ -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<SectionRef, ProfilesListProps>(
// 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<SectionRef, ProfilesListProps>(
const renderItem = ({
item,
index,
}: ListRenderItemInfo<app.bsky.actor.defs.ProfileView>) => {
}: ListRenderItemInfo<app.bsky.graph.defs.ListItemView>) => {
return (
<View
style={[
@@ -86,10 +96,13 @@ export const ProfilesList = forwardRef<SectionRef, ProfilesListProps>(
(IS_WEB || index !== 0) && a.border_t,
]}>
<ProfileCard
profile={item}
profile={item.subject}
moderationOpts={moderationOpts}
logContext="StarterPackProfilesList"
/>
{item.subjectOptedOut ? (
<OptedOutControls item={item} listUri={listUri} canRemove={isOwn} />
) : null}
</View>
)
}
@@ -136,3 +149,66 @@ export const ProfilesList = forwardRef<SectionRef, ProfilesListProps>(
)
},
)
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 (
<Admonition.Outer type="info" style={[a.mt_sm]}>
<Admonition.Row style={[a.align_center]}>
<Admonition.Icon />
<Admonition.Content>
<Admonition.Text>
<Trans>Opted out of this starter pack</Trans>
</Admonition.Text>
</Admonition.Content>
{canRemove ? (
<Admonition.Button
label={_(msg`Remove user from starter pack`)}
color="secondary"
disabled={isPending}
onPress={() => {
ax.metric('starterPack:removeUser', {context: 'opt-out'})
removeMembership({
listUri,
actorDid: item.subject.did,
membershipUri: item.uri,
})
}}>
{isPending ? (
<ButtonIcon icon={Loader} />
) : (
<ButtonText>
<Trans>Remove</Trans>
</ButtonText>
)}
</Admonition.Button>
) : null}
</Admonition.Row>
</Admonition.Outer>
)
}
@@ -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}
</Text>
{subjectOptedOut ? (
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
<Trans>Opted out</Trans>
</Text>
) : null}
</View>
{btnType === 'checkbox' ? (
<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}
/>
)
@@ -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)
@@ -206,16 +234,41 @@ export function MoreOptionsMenu({
</Menu.Item>
</Menu.Group>
) : (
<Menu.Group>
<Menu.Item
label={_(msg`Report list`)}
onPress={reportDialogControl.open}>
<Menu.ItemText>
<Trans>Report list</Trans>
</Menu.ItemText>
<Menu.ItemIcon position="right" icon={WarningIcon} />
</Menu.Item>
</Menu.Group>
<>
<Menu.Group>
<Menu.Item
label={_(msg`Report list`)}
onPress={reportDialogControl.open}>
<Menu.ItemText>
<Trans>Report list</Trans>
</Menu.ItemText>
<Menu.ItemIcon position="right" icon={WarningIcon} />
</Menu.Item>
</Menu.Group>
{isReferenceList ? (
<>
<Menu.Divider />
<Menu.Group>
<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>
</Menu.Group>
</>
) : null}
</>
)}
{isModList && isPinned && (
@@ -277,6 +330,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={{
@@ -30,6 +30,7 @@ import {useResolveDidQuery} from '#/state/queries/resolve-uri'
import {useShortenLink} from '#/state/queries/shorten-link'
import {
useDeleteStarterPackMutation,
useReferenceListOptOutMutation,
useStarterPackQuery,
} from '#/state/queries/starter-packs'
import {useAppviewClient, usePdsClient, useSession} from '#/state/session'
@@ -523,6 +524,7 @@ function OverflowMenu({
const reportDialogControl = useReportDialogControl()
const deleteDialogControl = useDialogControl()
const convertToListDialogControl = useDialogControl()
const optOutDialogControl = useDialogControl()
const navigation = useNavigation<NavigationProp>()
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({
</Menu.ItemText>
<Menu.ItemIcon icon={CircleInfo} position="right" />
</Menu.Item>
{starterPack.list ? (
<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.Outer>
@@ -709,6 +753,58 @@ function OverflowMenu({
</Prompt.Actions>
</Prompt.Outer>
{starterPack.list ? (
<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>
<Button
variant="solid"
color={referenceListOptOut ? 'primary' : 'negative'}
size="large"
label={
referenceListOptOut
? _(msg`Undo opt-out`)
: _(msg`Opt out of starter pack`)
}
disabled={isOptOutPending}
onPress={() => {
optOutDialogControl.close(() => {
setReferenceListOptOut({referenceListOptOut})
})
}}>
<ButtonText>
{referenceListOptOut ? (
<Trans>Undo opt-out</Trans>
) : (
<Trans>Opt out</Trans>
)}
</ButtonText>
{isOptOutPending && <ButtonIcon icon={Loader} />}
</Button>
<Prompt.Cancel />
</Prompt.Actions>
</Prompt.Outer>
) : null}
<CreateListFromStarterPackDialog
control={convertToListDialogControl}
starterPack={starterPack}
+2 -1
View File
@@ -138,7 +138,8 @@ export function Provider({
currentStep: 'Details',
name: starterPack.record.name,
description: starterPack.record.description,
profiles: listItems?.map(i => i.subject) ?? [],
profiles:
listItems?.filter(i => !i.subjectOptedOut).map(i => i.subject) ?? [],
feeds: starterPack.feeds ?? [],
processing: false,
transitionDirection: 'Forward',
@@ -24,8 +24,10 @@ function keyExtractor(item: bsky.profile.AnyProfileView) {
export function StepProfiles({
moderationOpts,
optedOutDids,
}: {
moderationOpts: ModerationOpts
optedOutDids: Set<string>
}) {
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)}
/>
)
}
+10 -1
View File
@@ -341,7 +341,16 @@ function WizardInner({
{state.currentStep === 'Details' ? (
<StepDetails />
) : state.currentStep === 'Profiles' ? (
<StepProfiles moderationOpts={moderationOpts} />
<StepProfiles
moderationOpts={moderationOpts}
optedOutDids={
new Set(
currentListItems
?.filter(item => item.subjectOptedOut)
.map(item => item.subject.did),
)
}
/>
) : state.currentStep === 'Feeds' ? (
<StepFeeds moderationOpts={moderationOpts} />
) : null}
@@ -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()
})
})
+4 -1
View File
@@ -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(
+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
+144
View File
@@ -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,