Add starter pack opt-out UI
This commit is contained in:
+6
-1
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user