Add starter pack opt-out UI

This commit is contained in:
vineyardbovines
2026-08-27 10:01:30 -04:00
parent c4c999ff4f
commit 364602a000
7 changed files with 258 additions and 15 deletions
+6 -1
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",
@@ -538,7 +539,7 @@
},
"app.bsky.graph.defs": {
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.graph.defs",
"cid": "bafyreifcipomli7yggtl46xufgxlnrw7se6xmsdxmzgfcz2tiu76ljatxm"
"cid": "bafyreief2f7zpllyicjugbn7faohmnzwujeiytfzj76uckxmrqmtdvechy"
},
"app.bsky.graph.follow": {
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.graph.follow",
@@ -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"
+10
View File
@@ -100,6 +100,11 @@
"subject": {
"ref": "app.bsky.actor.defs#profileView",
"type": "ref"
},
"subjectOptedOut": {
"type": "boolean",
"const": true,
"description": "Set to true when the subject has opted out of appearing in the reference list. Only set when the viewer owns the list."
}
}
},
@@ -228,6 +233,11 @@
"blocked": {
"type": "string",
"format": "at-uri"
},
"referenceListOptOut": {
"type": "string",
"format": "at-uri",
"description": "The authenticated viewer's app.bsky.graph.referencelistoptout record URI for this reference list. Only set for reference lists. A client can delete this record to undo the opt-out."
}
}
},
@@ -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
}
@@ -2,6 +2,7 @@ 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 {Trans} from '@lingui/react/macro'
import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
@@ -13,11 +14,12 @@ import {type SectionRef} from '#/screens/Profile/Sections/types'
import {atoms as a, useTheme} from '#/alf'
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
import {Default as ProfileCard} from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
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 +44,27 @@ 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
if (!isOwn) return listItems
const myIndex = profiles.findIndex(p => p.did === currentAccount?.did)
const myIndex = listItems.findIndex(
item => item.subject.did === currentAccount?.did,
)
return myIndex !== -1
? [
profiles[myIndex],
...profiles.slice(0, myIndex),
...profiles.slice(myIndex + 1),
listItems[myIndex],
...listItems.slice(0, myIndex),
...listItems.slice(myIndex + 1),
]
: profiles
: listItems
}
const onScrollToTop = useCallback(() => {
scrollElRef.current?.scrollToOffset({
@@ -77,7 +80,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 +89,15 @@ 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 ? (
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
<Trans>Opted out of this starter pack</Trans>
</Text>
) : null}
</View>
)
}
@@ -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,19 @@ function OverflowMenu({
})
const isOwn = starterPack.creator.did === currentAccount?.did
const referenceListOptOut = starterPack.list?.viewer?.referenceListOptOut
const {mutate: setReferenceListOptOut, isPending: isOptOutPending} =
useReferenceListOptOutMutation({
starterPack,
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 +665,22 @@ function OverflowMenu({
</Menu.ItemText>
<Menu.ItemIcon icon={CircleInfo} position="right" />
</Menu.Item>
<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.Outer>
@@ -709,6 +740,52 @@ function OverflowMenu({
</Prompt.Actions>
</Prompt.Outer>
<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 appear in this starter pack again.</Trans>
) : (
<Trans>
You will no longer appear in this starter pack. The creator can
still remove you from its member list.
</Trans>
)}
</Prompt.DescriptionText>
<Prompt.Actions>
<Button
variant="solid"
color={referenceListOptOut ? 'primary' : 'negative'}
size={gtMobile ? 'small' : '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>
<CreateListFromStarterPackDialog
control={convertToListDialogControl}
starterPack={starterPack}
+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(
+110
View File
@@ -71,6 +71,116 @@ export function useStarterPackQuery({
})
}
export function useReferenceListOptOutMutation({
starterPack,
onError,
}: {
starterPack: app.bsky.graph.defs.StarterPackView
onError: (error: Error) => 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<
AtUriString | undefined,
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
}
await until(
5,
1e3,
(value, error) =>
!error &&
value.starterPack.list?.viewer?.referenceListOptOut === nextOptOut,
async () =>
await appviewClient.call(app.bsky.graph.getStarterPack, {
starterPack: starterPack.uri,
}),
)
return nextOptOut
},
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 => {
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,
})
},
onError: (error, _, context) => {
if (context?.previous) {
queryClient.setQueryData(queryKey, context.previous)
}
onError(error)
},
})
}
export async function invalidateStarterPack({
queryClient,
did,