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.muteActor",
"app.bsky.graph.muteActorList", "app.bsky.graph.muteActorList",
"app.bsky.graph.muteThread", "app.bsky.graph.muteThread",
"app.bsky.graph.referencelistoptout",
"app.bsky.graph.searchStarterPacks", "app.bsky.graph.searchStarterPacks",
"app.bsky.graph.searchStarterPacksV2", "app.bsky.graph.searchStarterPacksV2",
"app.bsky.graph.starterpack", "app.bsky.graph.starterpack",
@@ -632,6 +633,10 @@
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.graph.muteThread", "uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.graph.muteThread",
"cid": "bafyreib6ppci3qzye6wktkm4byxtb5mnl2vg22fm7oawcdvof2tfogx4dy" "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": { "app.bsky.graph.searchStarterPacks": {
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.graph.searchStarterPacks", "uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.graph.searchStarterPacks",
"cid": "bafyreia446ip6mbnwpml6hlvxab7jtsud7zczde3u6zmnsa3op4bpxu7um" "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:removeUser': {
starterPack?: string starterPack?: string
context?: 'opt-out'
} }
'starterPack:share': { 'starterPack:share': {
starterPack: string starterPack: string
@@ -717,6 +718,10 @@ export type Events = {
count: number count: number
} }
'starterPack:delete': {} 'starterPack:delete': {}
'starterPack:optOut': {
starterPack: string
action: 'optOut' | 'undo'
}
'starterPack:create': { 'starterPack:create': {
setName: boolean setName: boolean
setDescription: boolean setDescription: boolean
@@ -2,22 +2,32 @@ import {forwardRef, useCallback, useImperativeHandle, useState} from 'react'
import {type ListRenderItemInfo, View} from 'react-native' import {type ListRenderItemInfo, View} from 'react-native'
import {AtUri} from '@atproto/syntax' import {AtUri} from '@atproto/syntax'
import {type ModerationOpts} from '@bsky/sdk/moderation' 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 {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {isBlockedOrBlocking} from '#/lib/moderation/blocked-and-muted' import {isBlockedOrBlocking} from '#/lib/moderation/blocked-and-muted'
import {cleanError} from '#/lib/strings/errors'
import {useAllListMembersQuery} from '#/state/queries/list-members' import {useAllListMembersQuery} from '#/state/queries/list-members'
import {useListMembershipRemoveMutation} from '#/state/queries/list-memberships'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {List, type ListRef} from '#/view/com/util/List' import {List, type ListRef} from '#/view/com/util/List'
import {type SectionRef} from '#/screens/Profile/Sections/types' import {type SectionRef} from '#/screens/Profile/Sections/types'
import {atoms as a, useTheme} from '#/alf' 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 {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
import {Loader} from '#/components/Loader'
import {Default as ProfileCard} from '#/components/ProfileCard' 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 {IS_NATIVE, IS_WEB} from '#/env'
import {type app} from '#/lexicons' import {type app} from '#/lexicons'
function keyExtractor(item: app.bsky.actor.defs.ProfileView, index: number) { function keyExtractor(item: app.bsky.graph.defs.ListItemView) {
return `${item.did}-${index}` return item.uri
} }
interface ProfilesListProps { 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 // The server returns these sorted by descending creation date, so we want to invert
const profiles = data const listItems = data
?.filter( ?.filter(
p => !isBlockedOrBlocking(p.subject) && !p.subject.associated?.labeler, p => !isBlockedOrBlocking(p.subject) && !p.subject.associated?.labeler,
) )
.map(p => p.subject)
.reverse() .reverse()
const isOwn = new AtUri(listUri).host === currentAccount?.did const isOwn = new AtUri(listUri).host === currentAccount?.did
const getSortedProfiles = () => { const getSortedProfiles = () => {
if (!profiles) return if (!listItems) return
if (!isOwn) return profiles
const myIndex = profiles.findIndex(p => p.did === currentAccount?.did) return [...listItems].sort((a, b) => {
return myIndex !== -1 if (a.subjectOptedOut !== b.subjectOptedOut) {
? [ return a.subjectOptedOut ? -1 : 1
profiles[myIndex], }
...profiles.slice(0, myIndex), if (isOwn) {
...profiles.slice(myIndex + 1), if (a.subject.did === currentAccount?.did) return -1
] if (b.subject.did === currentAccount?.did) return 1
: profiles }
return 0
})
} }
const onScrollToTop = useCallback(() => { const onScrollToTop = useCallback(() => {
scrollElRef.current?.scrollToOffset({ scrollElRef.current?.scrollToOffset({
@@ -77,7 +87,7 @@ export const ProfilesList = forwardRef<SectionRef, ProfilesListProps>(
const renderItem = ({ const renderItem = ({
item, item,
index, index,
}: ListRenderItemInfo<app.bsky.actor.defs.ProfileView>) => { }: ListRenderItemInfo<app.bsky.graph.defs.ListItemView>) => {
return ( return (
<View <View
style={[ style={[
@@ -86,10 +96,13 @@ export const ProfilesList = forwardRef<SectionRef, ProfilesListProps>(
(IS_WEB || index !== 0) && a.border_t, (IS_WEB || index !== 0) && a.border_t,
]}> ]}>
<ProfileCard <ProfileCard
profile={item} profile={item.subject}
moderationOpts={moderationOpts} moderationOpts={moderationOpts}
logContext="StarterPackProfilesList" logContext="StarterPackProfilesList"
/> />
{item.subjectOptedOut ? (
<OptedOutControls item={item} listUri={listUri} canRemove={isOwn} />
) : null}
</View> </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, avatar,
included, included,
disabled, disabled,
subjectOptedOut,
moderationUi, moderationUi,
}: { }: {
type: 'user' | 'algo' type: 'user' | 'algo'
@@ -48,6 +49,7 @@ function WizardListCard({
avatar?: string avatar?: string
included?: boolean included?: boolean
disabled?: boolean disabled?: boolean
subjectOptedOut?: boolean
moderationUi: ModerationUI moderationUi: ModerationUI
}) { }) {
const t = useTheme() const t = useTheme()
@@ -97,6 +99,11 @@ function WizardListCard({
numberOfLines={1}> numberOfLines={1}>
{subtitle} {subtitle}
</Text> </Text>
{subjectOptedOut ? (
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
<Trans>Opted out</Trans>
</Text>
) : null}
</View> </View>
{btnType === 'checkbox' ? ( {btnType === 'checkbox' ? (
<Checkbox /> <Checkbox />
@@ -123,12 +130,14 @@ export function WizardProfileCard({
dispatch, dispatch,
profile, profile,
moderationOpts, moderationOpts,
subjectOptedOut = false,
}: { }: {
btnType: 'checkbox' | 'remove' btnType: 'checkbox' | 'remove'
state: WizardState state: WizardState
dispatch: (action: WizardAction) => void dispatch: (action: WizardAction) => void
profile: bsky.profile.AnyProfileView profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
subjectOptedOut?: boolean
}) { }) {
const ax = useAnalytics() const ax = useAnalytics()
const {currentAccount} = useSession() const {currentAccount} = useSession()
@@ -138,7 +147,9 @@ export function WizardProfileCard({
const isTarget = profile.did === targetProfileDid const isTarget = profile.did === targetProfileDid
const included = isTarget || state.profiles.some(p => p.did === profile.did) const included = isTarget || state.profiles.some(p => p.did === profile.did)
const disabled = 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 moderationUi = moderateProfile(profile, moderationOpts).ui('avatar')
const displayName = profile.displayName const displayName = profile.displayName
? sanitizeDisplayName(profile.displayName) ? sanitizeDisplayName(profile.displayName)
@@ -169,6 +180,7 @@ export function WizardProfileCard({
avatar={profile.avatar} avatar={profile.avatar}
included={included} included={included}
disabled={disabled} disabled={disabled}
subjectOptedOut={subjectOptedOut}
moderationUi={moderationUi} moderationUi={moderationUi}
/> />
) )
@@ -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)
@@ -206,16 +234,41 @@ export function MoreOptionsMenu({
</Menu.Item> </Menu.Item>
</Menu.Group> </Menu.Group>
) : ( ) : (
<Menu.Group> <>
<Menu.Item <Menu.Group>
label={_(msg`Report list`)} <Menu.Item
onPress={reportDialogControl.open}> label={_(msg`Report list`)}
<Menu.ItemText> onPress={reportDialogControl.open}>
<Trans>Report list</Trans> <Menu.ItemText>
</Menu.ItemText> <Trans>Report list</Trans>
<Menu.ItemIcon position="right" icon={WarningIcon} /> </Menu.ItemText>
</Menu.Item> <Menu.ItemIcon position="right" icon={WarningIcon} />
</Menu.Group> </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 && ( {isModList && isPinned && (
@@ -277,6 +330,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={{
@@ -30,6 +30,7 @@ import {useResolveDidQuery} from '#/state/queries/resolve-uri'
import {useShortenLink} from '#/state/queries/shorten-link' import {useShortenLink} from '#/state/queries/shorten-link'
import { import {
useDeleteStarterPackMutation, useDeleteStarterPackMutation,
useReferenceListOptOutMutation,
useStarterPackQuery, useStarterPackQuery,
} from '#/state/queries/starter-packs' } from '#/state/queries/starter-packs'
import {useAppviewClient, usePdsClient, useSession} from '#/state/session' import {useAppviewClient, usePdsClient, useSession} from '#/state/session'
@@ -523,6 +524,7 @@ function OverflowMenu({
const reportDialogControl = useReportDialogControl() const reportDialogControl = useReportDialogControl()
const deleteDialogControl = useDialogControl() const deleteDialogControl = useDialogControl()
const convertToListDialogControl = useDialogControl() const convertToListDialogControl = useDialogControl()
const optOutDialogControl = useDialogControl()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const { const {
@@ -546,6 +548,30 @@ function OverflowMenu({
}) })
const isOwn = starterPack.creator.did === currentAccount?.did 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 () => { const onDeleteStarterPack = async () => {
if (!starterPack.list) { if (!starterPack.list) {
@@ -650,6 +676,24 @@ function OverflowMenu({
</Menu.ItemText> </Menu.ItemText>
<Menu.ItemIcon icon={CircleInfo} position="right" /> <Menu.ItemIcon icon={CircleInfo} position="right" />
</Menu.Item> </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> </Menu.Outer>
@@ -709,6 +753,58 @@ function OverflowMenu({
</Prompt.Actions> </Prompt.Actions>
</Prompt.Outer> </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 <CreateListFromStarterPackDialog
control={convertToListDialogControl} control={convertToListDialogControl}
starterPack={starterPack} starterPack={starterPack}
+2 -1
View File
@@ -138,7 +138,8 @@ export function Provider({
currentStep: 'Details', currentStep: 'Details',
name: starterPack.record.name, name: starterPack.record.name,
description: starterPack.record.description, description: starterPack.record.description,
profiles: listItems?.map(i => i.subject) ?? [], profiles:
listItems?.filter(i => !i.subjectOptedOut).map(i => i.subject) ?? [],
feeds: starterPack.feeds ?? [], feeds: starterPack.feeds ?? [],
processing: false, processing: false,
transitionDirection: 'Forward', transitionDirection: 'Forward',
@@ -24,8 +24,10 @@ function keyExtractor(item: bsky.profile.AnyProfileView) {
export function StepProfiles({ export function StepProfiles({
moderationOpts, moderationOpts,
optedOutDids,
}: { }: {
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
optedOutDids: Set<string>
}) { }) {
const t = useTheme() const t = useTheme()
const [state, dispatch] = useWizardState() const [state, dispatch] = useWizardState()
@@ -59,6 +61,7 @@ export function StepProfiles({
state={state} state={state}
dispatch={dispatch} dispatch={dispatch}
moderationOpts={moderationOpts} moderationOpts={moderationOpts}
subjectOptedOut={optedOutDids.has(item.did)}
/> />
) )
} }
+10 -1
View File
@@ -341,7 +341,16 @@ function WizardInner({
{state.currentStep === 'Details' ? ( {state.currentStep === 'Details' ? (
<StepDetails /> <StepDetails />
) : state.currentStep === 'Profiles' ? ( ) : 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' ? ( ) : state.currentStep === 'Feeds' ? (
<StepFeeds moderationOpts={moderationOpts} /> <StepFeeds moderationOpts={moderationOpts} />
) : null} ) : 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 queryClient: QueryClient
uri: string 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( 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 { export interface ListCreateMutateParams {
purpose: string purpose: string
name: 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({ export async function invalidateStarterPack({
queryClient, queryClient,
did, did,