diff --git a/lexicons.json b/lexicons.json index ca0e64f570..5679b0f071 100644 --- a/lexicons.json +++ b/lexicons.json @@ -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" diff --git a/lexicons/app/bsky/graph/defs.json b/lexicons/app/bsky/graph/defs.json index eca42fc875..8d77562184 100644 --- a/lexicons/app/bsky/graph/defs.json +++ b/lexicons/app/bsky/graph/defs.json @@ -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." } } }, diff --git a/lexicons/app/bsky/graph/referencelistoptout.json b/lexicons/app/bsky/graph/referencelistoptout.json new file mode 100644 index 0000000000..e5387d8d82 --- /dev/null +++ b/lexicons/app/bsky/graph/referencelistoptout.json @@ -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 +} diff --git a/src/components/StarterPack/Main/ProfilesList.tsx b/src/components/StarterPack/Main/ProfilesList.tsx index 56ac697a31..0bbe2ea080 100644 --- a/src/components/StarterPack/Main/ProfilesList.tsx +++ b/src/components/StarterPack/Main/ProfilesList.tsx @@ -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( // 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( const renderItem = ({ item, index, - }: ListRenderItemInfo) => { + }: ListRenderItemInfo) => { return ( ( (IS_WEB || index !== 0) && a.border_t, ]}> + {item.subjectOptedOut ? ( + + Opted out of this starter pack + + ) : null} ) } diff --git a/src/screens/StarterPack/StarterPackScreen.tsx b/src/screens/StarterPack/StarterPackScreen.tsx index e73bab09ce..d73e9b6b1e 100644 --- a/src/screens/StarterPack/StarterPackScreen.tsx +++ b/src/screens/StarterPack/StarterPackScreen.tsx @@ -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() 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({ + optOutDialogControl.open()}> + + {referenceListOptOut ? ( + Undo opt-out + ) : ( + Opt out of starter pack + )} + + )} @@ -709,6 +740,52 @@ function OverflowMenu({ + + + {referenceListOptOut ? ( + Undo opt-out? + ) : ( + Opt out of this starter pack? + )} + + + {referenceListOptOut ? ( + You will appear in this starter pack again. + ) : ( + + You will no longer appear in this starter pack. The creator can + still remove you from its member list. + + )} + + + + + + + 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(queryKey) + queryClient.setQueryData( + 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( + 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,