ALF add to list dialog

This commit is contained in:
Samuel Newman
2025-04-17 18:13:22 +03:00
parent 95ab4fa0a1
commit 27bd1bb65e
4 changed files with 405 additions and 17 deletions
+4 -1
View File
@@ -28,6 +28,8 @@ export function Header({
<View
onLayout={onLayout}
style={[
a.sticky,
a.top_0,
a.relative,
a.w_full,
a.py_sm,
@@ -41,7 +43,8 @@ export function Header({
{borderTopLeftRadius: a.rounded_md.borderRadius},
{borderTopRightRadius: a.rounded_md.borderRadius},
style,
]}>
]}
onLayout={onLayout}>
{renderLeft && (
<View style={[a.absolute, {left: 6}]}>{renderLeft()}</View>
)}
@@ -0,0 +1,390 @@
import {useCallback, useMemo, useState} from 'react'
import {useWindowDimensions, View} from 'react-native'
import {type AppBskyGraphDefs, type ModerationOpts} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {cleanError} from '#/lib/strings/errors'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {
getMembership,
type ListMembersip,
useDangerousListMembershipsQuery,
useListMembershipAddMutation,
useListMembershipRemoveMutation,
} from '#/state/queries/list-memberships'
import {useMyListsQuery} from '#/state/queries/my-lists'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
import * as Toast from '#/view/com/util/Toast'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {BulletList_Stroke2_Corner0_Rounded as ListIcon} from '#/components/icons/BulletList'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
export function UserAddRemoveListsDialog({
control,
profile,
onChange,
}: {
control: Dialog.DialogControlProps
profile: bsky.profile.AnyProfileView
onChange?: (
type: 'add' | 'remove',
list: AppBskyGraphDefs.ListView,
) => void | undefined
}) {
const {height} = useWindowDimensions()
return (
<Dialog.Outer
control={control}
testID="listAddRemoveUsersDialog"
nativeOptions={{minHeight: height / 2}}>
<DialogInner profile={profile} onChange={onChange} />
</Dialog.Outer>
)
}
const LOADING = {type: 'loading', _reactKey: '__loading__'} as const
const EMPTY = {type: 'empty', _reactKey: '__empty__'} as const
const ERROR_ITEM = {type: 'error', _reactKey: '__error__'} as const
type ListItem =
| typeof LOADING
| typeof EMPTY
| typeof ERROR_ITEM
| {
type: 'section-header'
title: string
_reactKey: string
}
| {
type: 'list'
list: AppBskyGraphDefs.ListView
}
function DialogInner({
profile,
onChange,
}: {
profile: bsky.profile.AnyProfileView
onChange?: (
type: 'add' | 'remove',
list: AppBskyGraphDefs.ListView,
) => void | undefined
}) {
const t = useTheme()
const {_} = useLingui()
const control = Dialog.useDialogContext()
const moderationOpts = useModerationOpts()
const [headerHeight, setHeaderHeight] = useState(50)
const {data, isPending, isError, error} = useMyListsQuery('all')
const {data: memberships} = useDangerousListMembershipsQuery()
const isEmpty = !isPending && !data?.length
const items = useMemo(() => {
let _items: ListItem[] = []
if (isError && isEmpty) {
_items = _items.concat([ERROR_ITEM])
}
if (isPending || !moderationOpts) {
_items = _items.concat([LOADING])
} else if (isEmpty) {
_items = _items.concat([EMPTY])
} else if (data) {
const curateLists = data.filter(
list => list.purpose === 'app.bsky.graph.defs#curatelist',
)
const starterPacks = data.filter(
list => list.purpose === 'app.bsky.graph.defs#referencelist',
)
const modLists = data.filter(
list => list.purpose === 'app.bsky.graph.defs#modlist',
)
if (curateLists.length > 0) {
_items = _items.concat(
{
type: 'section-header',
title: _(msg`User lists`),
_reactKey: 'curatelist',
},
curateLists.map(list => ({type: 'list', list})),
)
}
if (starterPacks.length > 0) {
_items = _items.concat(
{
type: 'section-header',
title: _(msg`Starter packs`),
_reactKey: 'referencelist',
},
starterPacks.map(list => ({type: 'list', list})),
)
}
if (modLists.length > 0) {
_items = _items.concat(
{
type: 'section-header',
title: _(msg`Moderation lists`),
_reactKey: 'modlist',
},
modLists.map(list => ({type: 'list', list})),
)
}
}
return _items
}, [isError, isEmpty, isPending, moderationOpts, data, _])
const renderItem = useCallback(
({item}: {item: ListItem}) => {
switch (item.type) {
case 'empty': {
return (
<View
style={[a.flex_1, a.align_center, a.gap_sm, a.px_xl, a.pt_xl]}>
<View
style={[
a.align_center,
a.justify_center,
a.rounded_full,
t.atoms.bg_contrast_25,
{
width: 32,
height: 32,
},
]}>
<ListIcon size="md" fill={t.atoms.text_contrast_low.color} />
</View>
<Text
style={[
a.text_center,
a.flex_1,
a.text_sm,
a.leading_snug,
t.atoms.text_contrast_medium,
{
maxWidth: 200,
},
]}>
<Trans>You have no lists.</Trans>
</Text>
</View>
)
}
case 'error':
return <ErrorMessage message={cleanError(error)} />
case 'loading':
return (
<View style={[a.flex_1, a.align_center, a.py_5xl]}>
<Loader />
</View>
)
case 'section-header':
return (
<View
style={[
a.flex_1,
a.px_lg,
a.py_xs,
t.atoms.bg_contrast_25,
a.border_b,
t.atoms.border_contrast_low,
]}>
<Text
style={[a.text_sm, a.font_bold, t.atoms.text_contrast_medium]}>
{item.title}
</Text>
</View>
)
case 'list':
return (
<ListRow
list={item.list}
profile={profile}
onChange={onChange}
memberships={memberships}
moderationOpts={moderationOpts}
/>
)
}
return null
},
[t, error, memberships, onChange, profile, moderationOpts],
)
const renderCloseButton = useCallback(
() => (
<Button
label={_(msg`Close`)}
onPress={() => control.close()}
size="small"
color="secondary"
shape="round"
variant="ghost"
style={[a.mr_xs]}>
<ButtonIcon icon={XIcon} size="md" />
</Button>
),
[control, _],
)
return (
<Dialog.InnerFlatList
data={items}
renderItem={renderItem}
keyExtractor={keyExtractor}
style={[web(a.px_0)]}
webInnerStyle={[web({minHeight: '80vh'})]}
webInnerContentContainerStyle={[web(a.py_0)]}
contentContainerStyle={[web(a.pb_xl)]}
stickyHeaderIndices={[0]}
scrollIndicatorInsets={{top: headerHeight}}
ListHeaderComponent={
<Dialog.Header
onLayout={e => setHeaderHeight(e.nativeEvent.layout.height)}
renderRight={renderCloseButton}>
<Dialog.HeaderText style={[a.pl_lg, a.pr_5xl, a.text_left, a.flex_1]}>
<Trans>
Add{' '}
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
)}{' '}
to lists
</Trans>
</Dialog.HeaderText>
</Dialog.Header>
}
/>
)
}
function keyExtractor(item: ListItem) {
return item.type === 'list' ? item.list.uri : item._reactKey
}
function ListRow({
profile,
list,
memberships,
onChange,
moderationOpts,
}: {
profile: bsky.profile.AnyProfileView
list: AppBskyGraphDefs.ListView
memberships: ListMembersip[] | undefined
onChange?: (
type: 'add' | 'remove',
list: AppBskyGraphDefs.ListView,
) => void | undefined
moderationOpts?: ModerationOpts
}) {
const t = useTheme()
const {_} = useLingui()
const membership = useMemo(
() => getMembership(memberships, list.uri, profile.did),
[memberships, list.uri, profile.did],
)
const {mutate: listMembershipAdd, isPending: isAddingPending} =
useListMembershipAddMutation({
onSuccess: () => {
Toast.show(_(msg`Added to list`))
onChange?.('add', list)
},
onError: e => Toast.show(cleanError(e), 'xmark'),
})
const {mutate: listMembershipRemove, isPending: isRemovingPending} =
useListMembershipRemoveMutation({
onSuccess: () => {
Toast.show(_(msg`Removed from list`))
onChange?.('remove', list)
},
onError: e => Toast.show(cleanError(e), 'xmark'),
})
const isMutating = isAddingPending || isRemovingPending
const onToggleMembership = useCallback(() => {
if (typeof membership === 'undefined') {
return
}
if (membership === false) {
listMembershipAdd({
listUri: list.uri,
actorDid: profile.did,
})
} else {
listMembershipRemove({
listUri: list.uri,
actorDid: profile.did,
membershipUri: membership,
})
}
}, [list, profile, membership, listMembershipAdd, listMembershipRemove])
if (!moderationOpts) return null
return (
<View
style={[
a.flex_1,
a.py_md,
a.px_lg,
a.border_b,
t.atoms.border_contrast_low,
]}>
<ProfileCard.Header>
<UserAvatar size={40} avatar={list.avatar} type="list" />
<View style={[a.mb_2xs, a.flex_1]}>
<Text
emoji
style={[a.text_md, a.font_bold, a.leading_snug, a.self_start]}
numberOfLines={1}>
{sanitizeDisplayName(list.name)}
</Text>
<Text
emoji
style={[a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={1}>
{list.purpose === 'app.bsky.graph.defs#curatelist' && (
<Trans>User list</Trans>
)}
{list.purpose === 'app.bsky.graph.defs#modlist' && (
<Trans>Moderation list</Trans>
)}
</Text>
</View>
{membership !== undefined && (
<Button
label={
membership === false
? _(msg`Add user to list`)
: _(msg`Remove user from list`)
}
onPress={onToggleMembership}
disabled={isMutating}
size="small"
variant="solid"
color="secondary">
{isMutating ? (
<ButtonIcon icon={Loader} />
) : (
<ButtonText>
{membership === false ? (
<Trans>Add</Trans>
) : (
<Trans>Remove</Trans>
)}
</ButtonText>
)}
</Button>
)}
</ProfileCard.Header>
</View>
)
}
+2 -2
View File
@@ -1,5 +1,5 @@
import {AppBskyGraphDefs} from '@atproto/api'
import {QueryClient, useQuery} from '@tanstack/react-query'
import {type AppBskyGraphDefs} from '@atproto/api'
import {type QueryClient, useQuery} from '@tanstack/react-query'
import {accumulate} from '#/lib/async/accumulate'
import {STALE} from '#/state/queries'
+9 -14
View File
@@ -14,7 +14,6 @@ import {toShareUrl} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {type Shadow} from '#/state/cache/types'
import {useModalControls} from '#/state/modals'
import {
RQKEY as profileQueryKey,
useProfileBlockMutationQueue,
@@ -27,6 +26,7 @@ import {EventStopper} from '#/view/com/util/EventStopper'
import * as Toast from '#/view/com/util/Toast'
import {Button, ButtonIcon} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {UserAddRemoveListsDialog} from '#/components/dialogs/lists/UserAddRemoveListsDialog'
import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox'
import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck'
@@ -65,8 +65,8 @@ let ProfileMenu = ({
}): React.ReactNode => {
const {_} = useLingui()
const {currentAccount, hasSession} = useSession()
const {openModal} = useModalControls()
const reportDialogControl = useReportDialogControl()
const addToListsDialogControl = useDialogControl()
const queryClient = useQueryClient()
const navigation = useNavigation<NavigationProp>()
const isSelf = currentAccount?.did === profile.did
@@ -106,17 +106,6 @@ let ProfileMenu = ({
shareUrl(toShareUrl(makeProfileLink(profile)))
}, [profile])
const onPressAddRemoveLists = React.useCallback(() => {
openModal({
name: 'user-add-remove-lists',
subject: profile.did,
handle: profile.handle,
displayName: profile.displayName || profile.handle,
onAdd: invalidateProfileQuery,
onRemove: invalidateProfileQuery,
})
}, [profile, openModal, invalidateProfileQuery])
const onPressMuteAccount = React.useCallback(async () => {
if (profile.viewer?.muted) {
try {
@@ -303,7 +292,7 @@ let ProfileMenu = ({
<Menu.Item
testID="profileHeaderDropdownListAddRemoveBtn"
label={_(msg`Add to lists`)}
onPress={onPressAddRemoveLists}>
onPress={addToListsDialogControl.open}>
<Menu.ItemText>
<Trans>Add to lists</Trans>
</Menu.ItemText>
@@ -448,6 +437,12 @@ let ProfileMenu = ({
}}
/>
<UserAddRemoveListsDialog
control={addToListsDialogControl}
profile={profile}
onChange={invalidateProfileQuery}
/>
<Prompt.Basic
control={blockPromptControl}
title={