Add starter pack reference-list opt-out UI (#11578)
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user