Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb95ee5e3f | |||
| b8505ba65a | |||
| 4103e7e2c4 | |||
| 9eaee783e9 | |||
| d993cc4163 | |||
| 28beaee23a | |||
| b8e6807925 | |||
| fce9167d86 | |||
| fbed7d4bb8 | |||
| 7ecf1dde7b | |||
| eae25ed04e | |||
| 84444be047 | |||
| 004e489592 | |||
| 028bfac359 | |||
| b9e218c543 | |||
| 364602a000 |
+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
|
||||
}
|
||||
@@ -703,6 +703,7 @@ export type Events = {
|
||||
}
|
||||
'starterPack:removeUser': {
|
||||
starterPack?: string
|
||||
context?: 'opt-out'
|
||||
}
|
||||
'starterPack:share': {
|
||||
starterPack: string
|
||||
@@ -715,6 +716,10 @@ export type Events = {
|
||||
count: number
|
||||
}
|
||||
'starterPack:delete': {}
|
||||
'starterPack:optOut': {
|
||||
starterPack: string
|
||||
action: 'optOut' | 'undo'
|
||||
}
|
||||
'starterPack:create': {
|
||||
setName: boolean
|
||||
setDescription: boolean
|
||||
|
||||
@@ -2,22 +2,32 @@ 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 {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
|
||||
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
||||
import {isBlockedOrBlocking} from '#/lib/moderation/blocked-and-muted'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {useAllListMembersQuery} from '#/state/queries/list-members'
|
||||
import {useListMembershipRemoveMutation} from '#/state/queries/list-memberships'
|
||||
import {useSession} from '#/state/session'
|
||||
import {List, type ListRef} from '#/view/com/util/List'
|
||||
import {type SectionRef} from '#/screens/Profile/Sections/types'
|
||||
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 {Loader} from '#/components/Loader'
|
||||
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 {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 +52,26 @@ 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
|
||||
|
||||
const myIndex = profiles.findIndex(p => p.did === currentAccount?.did)
|
||||
return myIndex !== -1
|
||||
? [
|
||||
profiles[myIndex],
|
||||
...profiles.slice(0, myIndex),
|
||||
...profiles.slice(myIndex + 1),
|
||||
]
|
||||
: profiles
|
||||
return [...listItems].sort((a, b) => {
|
||||
if (a.subjectOptedOut !== b.subjectOptedOut) {
|
||||
return a.subjectOptedOut ? -1 : 1
|
||||
}
|
||||
if (isOwn) {
|
||||
if (a.subject.did === currentAccount?.did) return -1
|
||||
if (b.subject.did === currentAccount?.did) return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
}
|
||||
const onScrollToTop = useCallback(() => {
|
||||
scrollElRef.current?.scrollToOffset({
|
||||
@@ -77,7 +87,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 +96,13 @@ 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 ? (
|
||||
<OptedOutControls item={item} listUri={listUri} canRemove={isOwn} />
|
||||
) : null}
|
||||
</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,
|
||||
included,
|
||||
disabled,
|
||||
subjectOptedOut,
|
||||
moderationUi,
|
||||
}: {
|
||||
type: 'user' | 'algo'
|
||||
@@ -48,6 +49,7 @@ function WizardListCard({
|
||||
avatar?: string
|
||||
included?: boolean
|
||||
disabled?: boolean
|
||||
subjectOptedOut?: boolean
|
||||
moderationUi: ModerationUI
|
||||
}) {
|
||||
const t = useTheme()
|
||||
@@ -97,6 +99,11 @@ function WizardListCard({
|
||||
numberOfLines={1}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
{subjectOptedOut ? (
|
||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Opted out</Trans>
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{btnType === 'checkbox' ? (
|
||||
<Checkbox />
|
||||
@@ -123,12 +130,14 @@ export function WizardProfileCard({
|
||||
dispatch,
|
||||
profile,
|
||||
moderationOpts,
|
||||
subjectOptedOut = false,
|
||||
}: {
|
||||
btnType: 'checkbox' | 'remove'
|
||||
state: WizardState
|
||||
dispatch: (action: WizardAction) => void
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
subjectOptedOut?: boolean
|
||||
}) {
|
||||
const ax = useAnalytics()
|
||||
const {currentAccount} = useSession()
|
||||
@@ -138,7 +147,9 @@ export function WizardProfileCard({
|
||||
const isTarget = profile.did === targetProfileDid
|
||||
const included = isTarget || state.profiles.some(p => p.did === profile.did)
|
||||
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 displayName = profile.displayName
|
||||
? sanitizeDisplayName(profile.displayName)
|
||||
@@ -169,6 +180,7 @@ export function WizardProfileCard({
|
||||
avatar={profile.avatar}
|
||||
included={included}
|
||||
disabled={disabled}
|
||||
subjectOptedOut={subjectOptedOut}
|
||||
moderationUi={moderationUi}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
useListBlockMutation,
|
||||
useListDeleteMutation,
|
||||
useListMuteMutation,
|
||||
useReferenceListOptOutMutation,
|
||||
} from '#/state/queries/list'
|
||||
import {useRemoveFeedMutation} from '#/state/queries/preferences'
|
||||
import {useSession} from '#/state/session'
|
||||
@@ -51,6 +52,7 @@ export function MoreOptionsMenu({
|
||||
const editListDialogControl = useDialogControl()
|
||||
const deleteListPromptControl = useDialogControl()
|
||||
const reportDialogControl = useReportDialogControl()
|
||||
const optOutDialogControl = useDialogControl()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
||||
const {mutateAsync: removeSavedFeed} = useRemoveFeedMutation()
|
||||
@@ -60,10 +62,36 @@ export function MoreOptionsMenu({
|
||||
|
||||
const isCurateList = list.purpose === app.bsky.graph.defs.curatelist.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 isMuting = !!list.viewer?.muted
|
||||
const isPinned = Boolean(savedFeedConfig?.pinned)
|
||||
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 {rkey} = new AtUri(list.uri)
|
||||
@@ -206,16 +234,41 @@ export function MoreOptionsMenu({
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
) : (
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
label={_(msg`Report list`)}
|
||||
onPress={reportDialogControl.open}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Report list</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon position="right" icon={WarningIcon} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
<>
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
label={_(msg`Report list`)}
|
||||
onPress={reportDialogControl.open}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Report list</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon position="right" icon={WarningIcon} />
|
||||
</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 && (
|
||||
@@ -277,6 +330,46 @@ export function MoreOptionsMenu({
|
||||
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
|
||||
control={reportDialogControl}
|
||||
subject={{
|
||||
|
||||
@@ -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,30 @@ function OverflowMenu({
|
||||
})
|
||||
|
||||
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 () => {
|
||||
if (!starterPack.list) {
|
||||
@@ -650,6 +676,24 @@ function OverflowMenu({
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={CircleInfo} position="right" />
|
||||
</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>
|
||||
@@ -709,6 +753,58 @@ function OverflowMenu({
|
||||
</Prompt.Actions>
|
||||
</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
|
||||
control={convertToListDialogControl}
|
||||
starterPack={starterPack}
|
||||
|
||||
@@ -138,7 +138,8 @@ export function Provider({
|
||||
currentStep: 'Details',
|
||||
name: starterPack.record.name,
|
||||
description: starterPack.record.description,
|
||||
profiles: listItems?.map(i => i.subject) ?? [],
|
||||
profiles:
|
||||
listItems?.filter(i => !i.subjectOptedOut).map(i => i.subject) ?? [],
|
||||
feeds: starterPack.feeds ?? [],
|
||||
processing: false,
|
||||
transitionDirection: 'Forward',
|
||||
|
||||
@@ -24,8 +24,10 @@ function keyExtractor(item: bsky.profile.AnyProfileView) {
|
||||
|
||||
export function StepProfiles({
|
||||
moderationOpts,
|
||||
optedOutDids,
|
||||
}: {
|
||||
moderationOpts: ModerationOpts
|
||||
optedOutDids: Set<string>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const [state, dispatch] = useWizardState()
|
||||
@@ -59,6 +61,7 @@ export function StepProfiles({
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
moderationOpts={moderationOpts}
|
||||
subjectOptedOut={optedOutDids.has(item.did)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -341,7 +341,16 @@ function WizardInner({
|
||||
{state.currentStep === 'Details' ? (
|
||||
<StepDetails />
|
||||
) : 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' ? (
|
||||
<StepFeeds moderationOpts={moderationOpts} />
|
||||
) : 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()
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
purpose: string
|
||||
name: string
|
||||
|
||||
@@ -71,6 +71,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({
|
||||
queryClient,
|
||||
did,
|
||||
|
||||
Reference in New Issue
Block a user