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.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",
@@ -538,7 +539,7 @@
}, },
"app.bsky.graph.defs": { "app.bsky.graph.defs": {
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/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": { "app.bsky.graph.follow": {
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/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", "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"
+10
View File
@@ -100,6 +100,11 @@
"subject": { "subject": {
"ref": "app.bsky.actor.defs#profileView", "ref": "app.bsky.actor.defs#profileView",
"type": "ref" "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": { "blocked": {
"type": "string", "type": "string",
"format": "at-uri" "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 {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 {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'
@@ -13,11 +14,12 @@ import {type SectionRef} from '#/screens/Profile/Sections/types'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
import {Default as ProfileCard} from '#/components/ProfileCard' import {Default as ProfileCard} from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
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 +44,27 @@ 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 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 return myIndex !== -1
? [ ? [
profiles[myIndex], listItems[myIndex],
...profiles.slice(0, myIndex), ...listItems.slice(0, myIndex),
...profiles.slice(myIndex + 1), ...listItems.slice(myIndex + 1),
] ]
: profiles : listItems
} }
const onScrollToTop = useCallback(() => { const onScrollToTop = useCallback(() => {
scrollElRef.current?.scrollToOffset({ scrollElRef.current?.scrollToOffset({
@@ -77,7 +80,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 +89,15 @@ 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 ? (
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
<Trans>Opted out of this starter pack</Trans>
</Text>
) : null}
</View> </View>
) )
} }
@@ -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,19 @@ 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,
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 +665,22 @@ function OverflowMenu({
</Menu.ItemText> </Menu.ItemText>
<Menu.ItemIcon icon={CircleInfo} position="right" /> <Menu.ItemIcon icon={CircleInfo} position="right" />
</Menu.Item> </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> </Menu.Outer>
@@ -709,6 +740,52 @@ function OverflowMenu({
</Prompt.Actions> </Prompt.Actions>
</Prompt.Outer> </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 <CreateListFromStarterPackDialog
control={convertToListDialogControl} control={convertToListDialogControl}
starterPack={starterPack} starterPack={starterPack}
+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(
+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({ export async function invalidateStarterPack({
queryClient, queryClient,
did, did,