Add starter pack reference-list opt-out UI (#11578)
This commit is contained in:
@@ -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",
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -705,6 +705,7 @@ export type Events = {
|
|||||||
}
|
}
|
||||||
'starterPack:removeUser': {
|
'starterPack:removeUser': {
|
||||||
starterPack?: string
|
starterPack?: string
|
||||||
|
context?: 'opt-out'
|
||||||
}
|
}
|
||||||
'starterPack:share': {
|
'starterPack:share': {
|
||||||
starterPack: string
|
starterPack: string
|
||||||
@@ -717,6 +718,10 @@ export type Events = {
|
|||||||
count: number
|
count: number
|
||||||
}
|
}
|
||||||
'starterPack:delete': {}
|
'starterPack:delete': {}
|
||||||
|
'starterPack:optOut': {
|
||||||
|
starterPack: string
|
||||||
|
action: 'optOut' | 'undo'
|
||||||
|
}
|
||||||
'starterPack:create': {
|
'starterPack:create': {
|
||||||
setName: boolean
|
setName: boolean
|
||||||
setDescription: boolean
|
setDescription: boolean
|
||||||
|
|||||||
@@ -2,22 +2,32 @@ 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 {msg} from '@lingui/core/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
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'
|
||||||
import {isBlockedOrBlocking} from '#/lib/moderation/blocked-and-muted'
|
import {isBlockedOrBlocking} from '#/lib/moderation/blocked-and-muted'
|
||||||
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
import {useAllListMembersQuery} from '#/state/queries/list-members'
|
import {useAllListMembersQuery} from '#/state/queries/list-members'
|
||||||
|
import {useListMembershipRemoveMutation} from '#/state/queries/list-memberships'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {List, type ListRef} from '#/view/com/util/List'
|
import {List, type ListRef} from '#/view/com/util/List'
|
||||||
import {type SectionRef} from '#/screens/Profile/Sections/types'
|
import {type SectionRef} from '#/screens/Profile/Sections/types'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
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 {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
import {Default as ProfileCard} from '#/components/ProfileCard'
|
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 {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 +52,26 @@ 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
|
|
||||||
|
|
||||||
const myIndex = profiles.findIndex(p => p.did === currentAccount?.did)
|
return [...listItems].sort((a, b) => {
|
||||||
return myIndex !== -1
|
if (a.subjectOptedOut !== b.subjectOptedOut) {
|
||||||
? [
|
return a.subjectOptedOut ? -1 : 1
|
||||||
profiles[myIndex],
|
}
|
||||||
...profiles.slice(0, myIndex),
|
if (isOwn) {
|
||||||
...profiles.slice(myIndex + 1),
|
if (a.subject.did === currentAccount?.did) return -1
|
||||||
]
|
if (b.subject.did === currentAccount?.did) return 1
|
||||||
: profiles
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
}
|
}
|
||||||
const onScrollToTop = useCallback(() => {
|
const onScrollToTop = useCallback(() => {
|
||||||
scrollElRef.current?.scrollToOffset({
|
scrollElRef.current?.scrollToOffset({
|
||||||
@@ -77,7 +87,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 +96,13 @@ 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 ? (
|
||||||
|
<OptedOutControls item={item} listUri={listUri} canRemove={isOwn} />
|
||||||
|
) : null}
|
||||||
</View>
|
</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,
|
avatar,
|
||||||
included,
|
included,
|
||||||
disabled,
|
disabled,
|
||||||
|
subjectOptedOut,
|
||||||
moderationUi,
|
moderationUi,
|
||||||
}: {
|
}: {
|
||||||
type: 'user' | 'algo'
|
type: 'user' | 'algo'
|
||||||
@@ -48,6 +49,7 @@ function WizardListCard({
|
|||||||
avatar?: string
|
avatar?: string
|
||||||
included?: boolean
|
included?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
subjectOptedOut?: boolean
|
||||||
moderationUi: ModerationUI
|
moderationUi: ModerationUI
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
@@ -97,6 +99,11 @@ function WizardListCard({
|
|||||||
numberOfLines={1}>
|
numberOfLines={1}>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</Text>
|
</Text>
|
||||||
|
{subjectOptedOut ? (
|
||||||
|
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
||||||
|
<Trans>Opted out</Trans>
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
{btnType === 'checkbox' ? (
|
{btnType === 'checkbox' ? (
|
||||||
<Checkbox />
|
<Checkbox />
|
||||||
@@ -123,12 +130,14 @@ export function WizardProfileCard({
|
|||||||
dispatch,
|
dispatch,
|
||||||
profile,
|
profile,
|
||||||
moderationOpts,
|
moderationOpts,
|
||||||
|
subjectOptedOut = false,
|
||||||
}: {
|
}: {
|
||||||
btnType: 'checkbox' | 'remove'
|
btnType: 'checkbox' | 'remove'
|
||||||
state: WizardState
|
state: WizardState
|
||||||
dispatch: (action: WizardAction) => void
|
dispatch: (action: WizardAction) => void
|
||||||
profile: bsky.profile.AnyProfileView
|
profile: bsky.profile.AnyProfileView
|
||||||
moderationOpts: ModerationOpts
|
moderationOpts: ModerationOpts
|
||||||
|
subjectOptedOut?: boolean
|
||||||
}) {
|
}) {
|
||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
@@ -138,7 +147,9 @@ export function WizardProfileCard({
|
|||||||
const isTarget = profile.did === targetProfileDid
|
const isTarget = profile.did === targetProfileDid
|
||||||
const included = isTarget || state.profiles.some(p => p.did === profile.did)
|
const included = isTarget || state.profiles.some(p => p.did === profile.did)
|
||||||
const disabled =
|
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 moderationUi = moderateProfile(profile, moderationOpts).ui('avatar')
|
||||||
const displayName = profile.displayName
|
const displayName = profile.displayName
|
||||||
? sanitizeDisplayName(profile.displayName)
|
? sanitizeDisplayName(profile.displayName)
|
||||||
@@ -169,6 +180,7 @@ export function WizardProfileCard({
|
|||||||
avatar={profile.avatar}
|
avatar={profile.avatar}
|
||||||
included={included}
|
included={included}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
subjectOptedOut={subjectOptedOut}
|
||||||
moderationUi={moderationUi}
|
moderationUi={moderationUi}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
useListBlockMutation,
|
useListBlockMutation,
|
||||||
useListDeleteMutation,
|
useListDeleteMutation,
|
||||||
useListMuteMutation,
|
useListMuteMutation,
|
||||||
|
useReferenceListOptOutMutation,
|
||||||
} from '#/state/queries/list'
|
} from '#/state/queries/list'
|
||||||
import {useRemoveFeedMutation} from '#/state/queries/preferences'
|
import {useRemoveFeedMutation} from '#/state/queries/preferences'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
@@ -51,6 +52,7 @@ export function MoreOptionsMenu({
|
|||||||
const editListDialogControl = useDialogControl()
|
const editListDialogControl = useDialogControl()
|
||||||
const deleteListPromptControl = useDialogControl()
|
const deleteListPromptControl = useDialogControl()
|
||||||
const reportDialogControl = useReportDialogControl()
|
const reportDialogControl = useReportDialogControl()
|
||||||
|
const optOutDialogControl = useDialogControl()
|
||||||
const navigation = useNavigation<NavigationProp>()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
|
||||||
const {mutateAsync: removeSavedFeed} = useRemoveFeedMutation()
|
const {mutateAsync: removeSavedFeed} = useRemoveFeedMutation()
|
||||||
@@ -60,10 +62,36 @@ export function MoreOptionsMenu({
|
|||||||
|
|
||||||
const isCurateList = list.purpose === app.bsky.graph.defs.curatelist.value
|
const isCurateList = list.purpose === app.bsky.graph.defs.curatelist.value
|
||||||
const isModList = list.purpose === app.bsky.graph.defs.modlist.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 isBlocking = !!list.viewer?.blocked
|
||||||
const isMuting = !!list.viewer?.muted
|
const isMuting = !!list.viewer?.muted
|
||||||
const isPinned = Boolean(savedFeedConfig?.pinned)
|
const isPinned = Boolean(savedFeedConfig?.pinned)
|
||||||
const isOwner = currentAccount?.did === list.creator.did
|
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 onPressShare = () => {
|
||||||
const {rkey} = new AtUri(list.uri)
|
const {rkey} = new AtUri(list.uri)
|
||||||
@@ -206,16 +234,41 @@ export function MoreOptionsMenu({
|
|||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Group>
|
</Menu.Group>
|
||||||
) : (
|
) : (
|
||||||
<Menu.Group>
|
<>
|
||||||
<Menu.Item
|
<Menu.Group>
|
||||||
label={_(msg`Report list`)}
|
<Menu.Item
|
||||||
onPress={reportDialogControl.open}>
|
label={_(msg`Report list`)}
|
||||||
<Menu.ItemText>
|
onPress={reportDialogControl.open}>
|
||||||
<Trans>Report list</Trans>
|
<Menu.ItemText>
|
||||||
</Menu.ItemText>
|
<Trans>Report list</Trans>
|
||||||
<Menu.ItemIcon position="right" icon={WarningIcon} />
|
</Menu.ItemText>
|
||||||
</Menu.Item>
|
<Menu.ItemIcon position="right" icon={WarningIcon} />
|
||||||
</Menu.Group>
|
</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 && (
|
{isModList && isPinned && (
|
||||||
@@ -277,6 +330,46 @@ export function MoreOptionsMenu({
|
|||||||
confirmButtonColor="negative"
|
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
|
<ReportDialog
|
||||||
control={reportDialogControl}
|
control={reportDialogControl}
|
||||||
subject={{
|
subject={{
|
||||||
|
|||||||
@@ -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,30 @@ 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,
|
||||||
|
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 () => {
|
const onDeleteStarterPack = async () => {
|
||||||
if (!starterPack.list) {
|
if (!starterPack.list) {
|
||||||
@@ -650,6 +676,24 @@ function OverflowMenu({
|
|||||||
</Menu.ItemText>
|
</Menu.ItemText>
|
||||||
<Menu.ItemIcon icon={CircleInfo} position="right" />
|
<Menu.ItemIcon icon={CircleInfo} position="right" />
|
||||||
</Menu.Item>
|
</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>
|
</Menu.Outer>
|
||||||
@@ -709,6 +753,58 @@ function OverflowMenu({
|
|||||||
</Prompt.Actions>
|
</Prompt.Actions>
|
||||||
</Prompt.Outer>
|
</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
|
<CreateListFromStarterPackDialog
|
||||||
control={convertToListDialogControl}
|
control={convertToListDialogControl}
|
||||||
starterPack={starterPack}
|
starterPack={starterPack}
|
||||||
|
|||||||
@@ -138,7 +138,8 @@ export function Provider({
|
|||||||
currentStep: 'Details',
|
currentStep: 'Details',
|
||||||
name: starterPack.record.name,
|
name: starterPack.record.name,
|
||||||
description: starterPack.record.description,
|
description: starterPack.record.description,
|
||||||
profiles: listItems?.map(i => i.subject) ?? [],
|
profiles:
|
||||||
|
listItems?.filter(i => !i.subjectOptedOut).map(i => i.subject) ?? [],
|
||||||
feeds: starterPack.feeds ?? [],
|
feeds: starterPack.feeds ?? [],
|
||||||
processing: false,
|
processing: false,
|
||||||
transitionDirection: 'Forward',
|
transitionDirection: 'Forward',
|
||||||
|
|||||||
@@ -24,8 +24,10 @@ function keyExtractor(item: bsky.profile.AnyProfileView) {
|
|||||||
|
|
||||||
export function StepProfiles({
|
export function StepProfiles({
|
||||||
moderationOpts,
|
moderationOpts,
|
||||||
|
optedOutDids,
|
||||||
}: {
|
}: {
|
||||||
moderationOpts: ModerationOpts
|
moderationOpts: ModerationOpts
|
||||||
|
optedOutDids: Set<string>
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const [state, dispatch] = useWizardState()
|
const [state, dispatch] = useWizardState()
|
||||||
@@ -59,6 +61,7 @@ export function StepProfiles({
|
|||||||
state={state}
|
state={state}
|
||||||
dispatch={dispatch}
|
dispatch={dispatch}
|
||||||
moderationOpts={moderationOpts}
|
moderationOpts={moderationOpts}
|
||||||
|
subjectOptedOut={optedOutDids.has(item.did)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -341,7 +341,16 @@ function WizardInner({
|
|||||||
{state.currentStep === 'Details' ? (
|
{state.currentStep === 'Details' ? (
|
||||||
<StepDetails />
|
<StepDetails />
|
||||||
) : state.currentStep === 'Profiles' ? (
|
) : 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' ? (
|
) : state.currentStep === 'Feeds' ? (
|
||||||
<StepFeeds moderationOpts={moderationOpts} />
|
<StepFeeds moderationOpts={moderationOpts} />
|
||||||
) : null}
|
) : 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
|
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(
|
||||||
|
|||||||
@@ -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 {
|
export interface ListCreateMutateParams {
|
||||||
purpose: string
|
purpose: string
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
@@ -72,6 +72,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({
|
export async function invalidateStarterPack({
|
||||||
queryClient,
|
queryClient,
|
||||||
did,
|
did,
|
||||||
|
|||||||
Reference in New Issue
Block a user