Add starter pack reference-list opt-out UI (#11578)

This commit is contained in:
Spence Pope
2026-09-03 03:08:48 -04:00
committed by GitHub
parent bb747c5f26
commit 68e56eaea0
15 changed files with 885 additions and 30 deletions
@@ -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}
/>
)