Use SearchablePeopleList for add user to list dialog, replace old modal (#8212)

* move to dialogs dir

* make searchable people list more generic

* new list-add-remove-users dialog

* update header text

* fix header on android

* delete old modal

* reduce spacing on items
This commit is contained in:
Samuel Newman
2025-04-17 19:11:46 +03:00
committed by GitHub
parent 4f316538fb
commit 719d7b7a57
11 changed files with 378 additions and 465 deletions
+33 -15
View File
@@ -166,29 +166,47 @@ export function NameAndHandle({
profile: bsky.profile.AnyProfileView profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
}) { }) {
const t = useTheme() return (
<View style={[a.flex_1]}>
<Name profile={profile} moderationOpts={moderationOpts} />
<Handle profile={profile} />
</View>
)
}
export function Name({
profile,
moderationOpts,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
}) {
const moderation = moderateProfile(profile, moderationOpts) const moderation = moderateProfile(profile, moderationOpts)
const name = sanitizeDisplayName( const name = sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle), profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'), moderation.ui('displayName'),
) )
return (
<Text
emoji
style={[a.text_md, a.font_bold, a.leading_snug, a.self_start]}
numberOfLines={1}>
{name}
</Text>
)
}
export function Handle({profile}: {profile: bsky.profile.AnyProfileView}) {
const t = useTheme()
const handle = sanitizeHandle(profile.handle, '@') const handle = sanitizeHandle(profile.handle, '@')
return ( return (
<View style={[a.flex_1]}> <Text
<Text emoji
emoji style={[a.leading_snug, t.atoms.text_contrast_medium]}
style={[a.text_md, a.font_bold, a.leading_snug, a.self_start]} numberOfLines={1}>
numberOfLines={1}> {handle}
{name} </Text>
</Text>
<Text
emoji
style={[a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={1}>
{handle}
</Text>
</View>
) )
} }
@@ -1,4 +1,5 @@
import React, { import {
Fragment,
useCallback, useCallback,
useLayoutEffect, useLayoutEffect,
useMemo, useMemo,
@@ -6,7 +7,7 @@ import React, {
useState, useState,
} from 'react' } from 'react'
import {TextInput, View} from 'react-native' import {TextInput, View} from 'react-native'
import {moderateProfile, ModerationOpts} from '@atproto/api' import {moderateProfile, type ModerationOpts} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -18,48 +19,62 @@ import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {useListConvosQuery} from '#/state/queries/messages/list-conversations' import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
import {useProfileFollowsQuery} from '#/state/queries/profile-follows' import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {ListMethods} from '#/view/com/util/List' import {type ListMethods} from '#/view/com/util/List'
import {UserAvatar} from '#/view/com/util/UserAvatar' import {android, atoms as a, native, useTheme, web} from '#/alf'
import {atoms as a, native, useTheme, web} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button' import {Button, ButtonIcon} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {canBeMessaged} from '#/components/dms/util' import {canBeMessaged} from '#/components/dms/util'
import {useInteractionState} from '#/components/hooks/useInteractionState' import {useInteractionState} from '#/components/hooks/useInteractionState'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
type Item = export type ProfileItem = {
| { type: 'profile'
type: 'profile' key: string
key: string profile: bsky.profile.AnyProfileView
enabled: boolean }
profile: bsky.profile.AnyProfileView
} type EmptyItem = {
| { type: 'empty'
type: 'empty' key: string
key: string message: string
message: string }
}
| { type PlaceholderItem = {
type: 'placeholder' type: 'placeholder'
key: string key: string
} }
| {
type: 'error' type ErrorItem = {
key: string type: 'error'
} key: string
}
type Item = ProfileItem | EmptyItem | PlaceholderItem | ErrorItem
export function SearchablePeopleList({ export function SearchablePeopleList({
title, title,
onSelectChat,
showRecentConvos, showRecentConvos,
sortByMessageDeclaration,
onSelectChat,
renderProfileCard,
}: { }: {
title: string title: string
onSelectChat: (did: string) => void
showRecentConvos?: boolean showRecentConvos?: boolean
}) { sortByMessageDeclaration?: boolean
} & (
| {
renderProfileCard: (item: ProfileItem) => React.ReactNode
onSelectChat?: undefined
}
| {
onSelectChat: (did: string) => void
renderProfileCard?: undefined
}
)) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
@@ -98,15 +113,17 @@ export function SearchablePeopleList({
_items.push({ _items.push({
type: 'profile', type: 'profile',
key: profile.did, key: profile.did,
enabled: canBeMessaged(profile),
profile, profile,
}) })
} }
_items = _items.sort(item => { if (sortByMessageDeclaration) {
// @ts-ignore _items = _items.sort(item => {
return item.enabled ? -1 : 1 return item.type === 'profile' && canBeMessaged(item.profile)
}) ? -1
: 1
})
}
} }
} else { } else {
const placeholders: Item[] = Array(10) const placeholders: Item[] = Array(10)
@@ -134,14 +151,13 @@ export function SearchablePeopleList({
_items.push({ _items.push({
type: 'profile', type: 'profile',
key: profile.did, key: profile.did,
enabled: true,
profile, profile,
}) })
} }
} }
} }
let followsItems: typeof _items = [] let followsItems: ProfileItem[] = []
for (const page of follows.pages) { for (const page of follows.pages) {
for (const profile of page.follows) { for (const profile of page.follows) {
@@ -150,17 +166,17 @@ export function SearchablePeopleList({
followsItems.push({ followsItems.push({
type: 'profile', type: 'profile',
key: profile.did, key: profile.did,
enabled: canBeMessaged(profile),
profile, profile,
}) })
} }
} }
// only sort follows if (sortByMessageDeclaration) {
followsItems = followsItems.sort(item => { // only sort follows
// @ts-ignore followsItems = followsItems.sort(item => {
return item.enabled ? -1 : 1 return canBeMessaged(item.profile) ? -1 : 1
}) })
}
// then append // then append
_items.push(...followsItems) _items.push(...followsItems)
@@ -173,16 +189,18 @@ export function SearchablePeopleList({
_items.push({ _items.push({
type: 'profile', type: 'profile',
key: profile.did, key: profile.did,
enabled: canBeMessaged(profile),
profile, profile,
}) })
} }
} }
_items = _items.sort(item => { if (sortByMessageDeclaration) {
// @ts-ignore _items = _items.sort(item => {
return item.enabled ? -1 : 1 return item.type === 'profile' && canBeMessaged(item.profile)
}) ? -1
: 1
})
}
} else { } else {
_items.push(...placeholders) _items.push(...placeholders)
} }
@@ -198,6 +216,7 @@ export function SearchablePeopleList({
follows, follows,
convos, convos,
showRecentConvos, showRecentConvos,
sortByMessageDeclaration,
]) ])
if (searchText && !isFetching && !items.length && !isError) { if (searchText && !isFetching && !items.length && !isError) {
@@ -208,15 +227,18 @@ export function SearchablePeopleList({
({item}: {item: Item}) => { ({item}: {item: Item}) => {
switch (item.type) { switch (item.type) {
case 'profile': { case 'profile': {
return ( if (renderProfileCard) {
<ProfileCard return <Fragment key={item.key}>{renderProfileCard(item)}</Fragment>
key={item.key} } else {
enabled={item.enabled} return (
profile={item.profile} <DefaultProfileCard
moderationOpts={moderationOpts!} key={item.key}
onPress={onSelectChat} profile={item.profile}
/> moderationOpts={moderationOpts!}
) onPress={onSelectChat}
/>
)
}
} }
case 'placeholder': { case 'placeholder': {
return <ProfileCardSkeleton key={item.key} /> return <ProfileCardSkeleton key={item.key} />
@@ -228,7 +250,7 @@ export function SearchablePeopleList({
return null return null
} }
}, },
[moderationOpts, onSelectChat], [moderationOpts, onSelectChat, renderProfileCard],
) )
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -247,6 +269,10 @@ export function SearchablePeopleList({
a.relative, a.relative,
web(a.pt_lg), web(a.pt_lg),
native(a.pt_4xl), native(a.pt_4xl),
android({
borderTopLeftRadius: a.rounded_md.borderRadius,
borderTopRightRadius: a.rounded_md.borderRadius,
}),
a.pb_xs, a.pb_xs,
a.px_lg, a.px_lg,
a.border_b, a.border_b,
@@ -327,19 +353,18 @@ export function SearchablePeopleList({
) )
} }
function ProfileCard({ function DefaultProfileCard({
enabled,
profile, profile,
moderationOpts, moderationOpts,
onPress, onPress,
}: { }: {
enabled: boolean
profile: bsky.profile.AnyProfileView profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
onPress: (did: string) => void onPress: (did: string) => void
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const enabled = canBeMessaged(profile)
const moderation = moderateProfile(profile, moderationOpts) const moderation = moderateProfile(profile, moderationOpts)
const handle = sanitizeHandle(profile.handle, '@') const handle = sanitizeHandle(profile.handle, '@')
const displayName = sanitizeDisplayName( const displayName = sanitizeDisplayName(
@@ -360,38 +385,35 @@ function ProfileCard({
<View <View
style={[ style={[
a.flex_1, a.flex_1,
a.py_md, a.py_sm,
a.px_lg, a.px_lg,
a.gap_md,
a.align_center,
a.flex_row,
!enabled !enabled
? {opacity: 0.5} ? {opacity: 0.5}
: pressed || focused : pressed || focused || hovered
? t.atoms.bg_contrast_25 ? t.atoms.bg_contrast_25
: hovered
? t.atoms.bg_contrast_50
: t.atoms.bg, : t.atoms.bg,
]}> ]}>
<UserAvatar <ProfileCard.Header>
size={42} <ProfileCard.Avatar
avatar={profile.avatar} profile={profile}
moderation={moderation.ui('avatar')} moderationOpts={moderationOpts}
type={profile.associated?.labeler ? 'labeler' : 'user'} />
/> <View style={[a.flex_1]}>
<View style={[a.flex_1, a.gap_2xs]}> <ProfileCard.Name
<Text profile={profile}
style={[t.atoms.text, a.font_bold, a.leading_tight, a.self_start]} moderationOpts={moderationOpts}
numberOfLines={1} />
emoji> {enabled ? (
{displayName} <ProfileCard.Handle profile={profile} />
</Text> ) : (
<Text <Text
style={[a.leading_tight, t.atoms.text_contrast_high]} style={[a.leading_snug, t.atoms.text_contrast_high]}
numberOfLines={2}> numberOfLines={2}>
{!enabled ? <Trans>{handle} can't be messaged</Trans> : handle} <Trans>{handle} can't be messaged</Trans>
</Text> </Text>
</View> )}
</View>
</ProfileCard.Header>
</View> </View>
)} )}
</Button> </Button>
@@ -0,0 +1,180 @@
import {useCallback, useMemo} from 'react'
import {View} from 'react-native'
import {type AppBskyGraphDefs, type ModerationOpts} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {cleanError} from '#/lib/strings/errors'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {
getMembership,
type ListMembersip,
useDangerousListMembershipsQuery,
useListMembershipAddMutation,
useListMembershipRemoveMutation,
} from '#/state/queries/list-memberships'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {
type ProfileItem,
SearchablePeopleList,
} from '#/components/dialogs/SearchablePeopleList'
import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import type * as bsky from '#/types/bsky'
export function ListAddRemoveUsersDialog({
control,
list,
onChange,
}: {
control: Dialog.DialogControlProps
list: AppBskyGraphDefs.ListView
onChange?: (
type: 'add' | 'remove',
profile: bsky.profile.AnyProfileView,
) => void | undefined
}) {
return (
<Dialog.Outer control={control} testID="listAddRemoveUsersDialog">
<Dialog.Handle />
<DialogInner list={list} onChange={onChange} />
</Dialog.Outer>
)
}
function DialogInner({
list,
onChange,
}: {
list: AppBskyGraphDefs.ListView
onChange?: (
type: 'add' | 'remove',
profile: bsky.profile.AnyProfileView,
) => void | undefined
}) {
const {_} = useLingui()
const moderationOpts = useModerationOpts()
const {data: memberships} = useDangerousListMembershipsQuery()
const renderProfileCard = useCallback(
(item: ProfileItem) => {
return (
<UserResult
profile={item.profile}
onChange={onChange}
memberships={memberships}
list={list}
moderationOpts={moderationOpts}
/>
)
},
[onChange, memberships, list, moderationOpts],
)
return (
<SearchablePeopleList
title={_(msg`Add people to list`)}
renderProfileCard={renderProfileCard}
/>
)
}
function UserResult({
profile,
list,
memberships,
onChange,
moderationOpts,
}: {
profile: bsky.profile.AnyProfileView
list: AppBskyGraphDefs.ListView
memberships: ListMembersip[] | undefined
onChange?: (
type: 'add' | 'remove',
profile: bsky.profile.AnyProfileView,
) => void | undefined
moderationOpts?: ModerationOpts
}) {
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', profile)
},
onError: e => Toast.show(cleanError(e), 'xmark'),
})
const {mutate: listMembershipRemove, isPending: isRemovingPending} =
useListMembershipRemoveMutation({
onSuccess: () => {
Toast.show(_(msg`Removed from list`))
onChange?.('remove', profile)
},
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_sm, a.px_lg]}>
<ProfileCard.Header>
<ProfileCard.Avatar profile={profile} moderationOpts={moderationOpts} />
<View style={[a.flex_1]}>
<ProfileCard.Name profile={profile} moderationOpts={moderationOpts} />
<ProfileCard.Handle profile={profile} />
</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 -1
View File
@@ -11,9 +11,9 @@ import * as Toast from '#/view/com/util/Toast'
import {useTheme} from '#/alf' import {useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {useDialogControl} from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog'
import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList'
import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {SearchablePeopleList} from './SearchablePeopleList'
export function NewChat({ export function NewChat({
control, control,
@@ -71,6 +71,7 @@ export function NewChat({
<SearchablePeopleList <SearchablePeopleList
title={_(msg`Start a new chat`)} title={_(msg`Start a new chat`)}
onSelectChat={onCreateChat} onSelectChat={onCreateChat}
sortByMessageDeclaration
/> />
</Dialog.Outer> </Dialog.Outer>
@@ -7,7 +7,7 @@ import {logger} from '#/logger'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {SearchablePeopleList} from './SearchablePeopleList' import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList'
export function SendViaChatDialog({ export function SendViaChatDialog({
control, control,
@@ -62,6 +62,7 @@ function SendViaChatDialogInner({
title={_(msg`Send post to...`)} title={_(msg`Send post to...`)}
onSelectChat={onCreateChat} onSelectChat={onCreateChat}
showRecentConvos showRecentConvos
sortByMessageDeclaration
/> />
) )
} }
+2 -12
View File
@@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import {Image as RNImage} from 'react-native-image-crop-picker' import {type Image as RNImage} from 'react-native-image-crop-picker'
import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api' import {type AppBskyActorDefs, type AppBskyGraphDefs} from '@atproto/api'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
@@ -26,15 +26,6 @@ export interface UserAddRemoveListsModal {
onRemove?: (listUri: string) => void onRemove?: (listUri: string) => void
} }
export interface ListAddRemoveUsersModal {
name: 'list-add-remove-users'
list: AppBskyGraphDefs.ListView
onChange?: (
type: 'add' | 'remove',
profile: AppBskyActorDefs.ProfileViewBasic,
) => void
}
export interface CropImageModal { export interface CropImageModal {
name: 'crop-image' name: 'crop-image'
uri: string uri: string
@@ -107,7 +98,6 @@ export type Modal =
// Lists // Lists
| CreateOrEditListModal | CreateOrEditListModal
| UserAddRemoveListsModal | UserAddRemoveListsModal
| ListAddRemoveUsersModal
// Posts // Posts
| CropImageModal | CropImageModal
+20 -4
View File
@@ -90,7 +90,13 @@ export function getMembership(
return membership ? membership.membershipUri : false return membership ? membership.membershipUri : false
} }
export function useListMembershipAddMutation() { export function useListMembershipAddMutation({
onSuccess,
onError,
}: {
onSuccess?: (data: {uri: string; cid: string}) => void
onError?: (error: Error) => void
} = {}) {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const agent = useAgent() const agent = useAgent()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -117,7 +123,7 @@ export function useListMembershipAddMutation() {
// -prf // -prf
return res return res
}, },
onSuccess(data, variables) { onSuccess: (data, variables) => {
// manually update the cache; a refetch is too expensive // manually update the cache; a refetch is too expensive
let memberships = queryClient.getQueryData<ListMembersip[]>(RQKEY()) let memberships = queryClient.getQueryData<ListMembersip[]>(RQKEY())
if (memberships) { if (memberships) {
@@ -145,11 +151,19 @@ export function useListMembershipAddMutation() {
queryKey: LIST_MEMBERS_RQKEY(variables.listUri), queryKey: LIST_MEMBERS_RQKEY(variables.listUri),
}) })
}, 1e3) }, 1e3)
onSuccess?.(data)
}, },
onError,
}) })
} }
export function useListMembershipRemoveMutation() { export function useListMembershipRemoveMutation({
onSuccess,
onError,
}: {
onSuccess?: (data: void) => void
onError?: (error: Error) => void
} = {}) {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const agent = useAgent() const agent = useAgent()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -172,7 +186,7 @@ export function useListMembershipRemoveMutation() {
// query for that, so we use a timeout below // query for that, so we use a timeout below
// -prf // -prf
}, },
onSuccess(data, variables) { onSuccess: (data, variables) => {
// manually update the cache; a refetch is too expensive // manually update the cache; a refetch is too expensive
let memberships = queryClient.getQueryData<ListMembersip[]>(RQKEY()) let memberships = queryClient.getQueryData<ListMembersip[]>(RQKEY())
if (memberships) { if (memberships) {
@@ -192,6 +206,8 @@ export function useListMembershipRemoveMutation() {
queryKey: LIST_MEMBERS_RQKEY(variables.listUri), queryKey: LIST_MEMBERS_RQKEY(variables.listUri),
}) })
}, 1e3) }, 1e3)
onSuccess?.(data)
}, },
onError,
}) })
} }
-316
View File
@@ -1,316 +0,0 @@
import React, {useCallback, useState} from 'react'
import {
ActivityIndicator,
Pressable,
SafeAreaView,
StyleSheet,
View,
} from 'react-native'
import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {HITSLOP_20} from '#/lib/constants'
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {cleanError} from '#/lib/strings/errors'
import {sanitizeHandle} from '#/lib/strings/handles'
import {colors, s} from '#/lib/styles'
import {isWeb} from '#/platform/detection'
import {useModalControls} from '#/state/modals'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {
getMembership,
ListMembersip,
useDangerousListMembershipsQuery,
useListMembershipAddMutation,
useListMembershipRemoveMutation,
} from '#/state/queries/list-memberships'
import {Button} from '../util/forms/Button'
import {Text} from '../util/text/Text'
import * as Toast from '../util/Toast'
import {UserAvatar} from '../util/UserAvatar'
import {ScrollView, TextInput} from './util'
export const snapPoints = ['90%']
export function Component({
list,
onChange,
}: {
list: AppBskyGraphDefs.ListView
onChange?: (
type: 'add' | 'remove',
profile: AppBskyActorDefs.ProfileViewBasic,
) => void
}) {
const pal = usePalette('default')
const {_} = useLingui()
const {closeModal} = useModalControls()
const {isMobile} = useWebMediaQueries()
const [query, setQuery] = useState('')
const autocomplete = useActorAutocompleteQuery(query)
const {data: memberships} = useDangerousListMembershipsQuery()
const [isKeyboardVisible] = useIsKeyboardVisible()
const onPressCancelSearch = useCallback(() => setQuery(''), [setQuery])
return (
<SafeAreaView
testID="listAddUserModal"
style={[pal.view, isWeb ? styles.fixedHeight : s.flex1]}>
<View style={[s.flex1, isMobile && {paddingHorizontal: 18}]}>
<View style={[styles.searchContainer, pal.border]}>
<FontAwesomeIcon icon="search" size={16} />
<TextInput
testID="searchInput"
style={[styles.searchInput, pal.border, pal.text]}
placeholder={_(msg`Search for users`)}
placeholderTextColor={pal.colors.textLight}
value={query}
onChangeText={setQuery}
accessible={true}
accessibilityLabel={_(msg`Search`)}
accessibilityHint=""
autoFocus
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
selectTextOnFocus
/>
{query ? (
<Pressable
onPress={onPressCancelSearch}
accessibilityRole="button"
accessibilityLabel={_(msg`Cancel search`)}
accessibilityHint={_(msg`Exits inputting search query`)}
onAccessibilityEscape={onPressCancelSearch}
hitSlop={HITSLOP_20}>
<FontAwesomeIcon
icon="xmark"
size={16}
color={pal.colors.textLight}
/>
</Pressable>
) : undefined}
</View>
<ScrollView
style={[s.flex1]}
keyboardDismissMode="none"
keyboardShouldPersistTaps="always">
{autocomplete.isLoading ? (
<View style={{marginVertical: 20}}>
<ActivityIndicator />
</View>
) : autocomplete.data?.length ? (
<>
{autocomplete.data.slice(0, 40).map((item, i) => (
<UserResult
key={item.did}
list={list}
profile={item}
memberships={memberships}
noBorder={i === 0}
onChange={onChange}
/>
))}
</>
) : (
<Text
type="xl"
style={[
pal.textLight,
{paddingHorizontal: 12, paddingVertical: 16},
]}>
<Trans>No results found for {query}</Trans>
</Text>
)}
</ScrollView>
<View
style={[
styles.btnContainer,
{paddingBottom: isKeyboardVisible ? 10 : 20},
]}>
<Button
testID="doneBtn"
type="default"
onPress={() => {
closeModal()
}}
accessibilityLabel={_(msg`Done`)}
accessibilityHint=""
label={_(msg({message: 'Done', context: 'action'}))}
labelContainerStyle={{justifyContent: 'center', padding: 4}}
labelStyle={[s.f18]}
/>
</View>
</View>
</SafeAreaView>
)
}
function UserResult({
profile,
list,
memberships,
noBorder,
onChange,
}: {
profile: AppBskyActorDefs.ProfileViewBasic
list: AppBskyGraphDefs.ListView
memberships: ListMembersip[] | undefined
noBorder: boolean
onChange?: (
type: 'add' | 'remove',
profile: AppBskyActorDefs.ProfileViewBasic,
) => void | undefined
}) {
const pal = usePalette('default')
const {_} = useLingui()
const [isProcessing, setIsProcessing] = useState(false)
const membership = React.useMemo(
() => getMembership(memberships, list.uri, profile.did),
[memberships, list.uri, profile.did],
)
const listMembershipAddMutation = useListMembershipAddMutation()
const listMembershipRemoveMutation = useListMembershipRemoveMutation()
const onToggleMembership = useCallback(async () => {
if (typeof membership === 'undefined') {
return
}
setIsProcessing(true)
try {
if (membership === false) {
await listMembershipAddMutation.mutateAsync({
listUri: list.uri,
actorDid: profile.did,
})
Toast.show(_(msg`Added to list`))
onChange?.('add', profile)
} else {
await listMembershipRemoveMutation.mutateAsync({
listUri: list.uri,
actorDid: profile.did,
membershipUri: membership,
})
Toast.show(_(msg`Removed from list`))
onChange?.('remove', profile)
}
} catch (e) {
Toast.show(cleanError(e), 'xmark')
} finally {
setIsProcessing(false)
}
}, [
_,
list,
profile,
membership,
setIsProcessing,
onChange,
listMembershipAddMutation,
listMembershipRemoveMutation,
])
return (
<View
style={[
pal.border,
{
flexDirection: 'row',
alignItems: 'center',
borderTopWidth: noBorder ? 0 : 1,
paddingHorizontal: 8,
},
]}>
<View
style={{
width: 54,
paddingLeft: 4,
}}>
<UserAvatar
size={40}
avatar={profile.avatar}
type={profile.associated?.labeler ? 'labeler' : 'user'}
/>
</View>
<View
style={{
flex: 1,
paddingRight: 10,
paddingTop: 10,
paddingBottom: 10,
}}>
<Text
type="lg"
style={[s.bold, pal.text]}
numberOfLines={1}
lineHeight={1.2}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
)}
</Text>
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
{sanitizeHandle(profile.handle, '@')}
</Text>
{!!profile.viewer?.followedBy && <View style={s.flexRow} />}
</View>
<View>
{isProcessing || typeof membership === 'undefined' ? (
<ActivityIndicator />
) : (
<Button
testID={`user-${profile.handle}-addBtn`}
type="default"
label={membership === false ? _(msg`Add`) : _(msg`Remove`)}
onPress={onToggleMembership}
/>
)}
</View>
</View>
)
}
const styles = StyleSheet.create({
fixedHeight: {
// @ts-ignore web only -prf
height: '80vh',
},
titleSection: {
paddingTop: isWeb ? 0 : 4,
paddingBottom: isWeb ? 14 : 10,
},
title: {
textAlign: 'center',
fontWeight: '600',
marginBottom: 5,
},
searchContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
borderWidth: 1,
borderRadius: 24,
paddingHorizontal: 16,
paddingVertical: 10,
},
searchInput: {
fontSize: 16,
flex: 1,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 32,
padding: 14,
backgroundColor: colors.blue3,
},
btnContainer: {
paddingTop: 10,
},
})
-4
View File
@@ -17,7 +17,6 @@ import * as InviteCodesModal from './InviteCodes'
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
import * as LinkWarningModal from './LinkWarning' import * as LinkWarningModal from './LinkWarning'
import * as ListAddUserModal from './ListAddRemoveUsers'
import * as UserAddRemoveListsModal from './UserAddRemoveLists' import * as UserAddRemoveListsModal from './UserAddRemoveLists'
import * as VerifyEmailModal from './VerifyEmail' import * as VerifyEmailModal from './VerifyEmail'
@@ -61,9 +60,6 @@ export function ModalsContainer() {
} else if (activeModal?.name === 'user-add-remove-lists') { } else if (activeModal?.name === 'user-add-remove-lists') {
snapPoints = UserAddRemoveListsModal.snapPoints snapPoints = UserAddRemoveListsModal.snapPoints
element = <UserAddRemoveListsModal.Component {...activeModal} /> element = <UserAddRemoveListsModal.Component {...activeModal} />
} else if (activeModal?.name === 'list-add-remove-users') {
snapPoints = ListAddUserModal.snapPoints
element = <ListAddUserModal.Component {...activeModal} />
} else if (activeModal?.name === 'delete-account') { } else if (activeModal?.name === 'delete-account') {
snapPoints = DeleteAccountModal.snapPoints snapPoints = DeleteAccountModal.snapPoints
element = <DeleteAccountModal.Component /> element = <DeleteAccountModal.Component />
+1 -4
View File
@@ -4,7 +4,7 @@ import {RemoveScrollBar} from 'react-remove-scroll-bar'
import {usePalette} from '#/lib/hooks/usePalette' import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import type {Modal as ModalIface} from '#/state/modals' import {type Modal as ModalIface} from '#/state/modals'
import {useModalControls, useModals} from '#/state/modals' import {useModalControls, useModals} from '#/state/modals'
import * as ChangeEmailModal from './ChangeEmail' import * as ChangeEmailModal from './ChangeEmail'
import * as ChangePasswordModal from './ChangePassword' import * as ChangePasswordModal from './ChangePassword'
@@ -16,7 +16,6 @@ import * as InviteCodesModal from './InviteCodes'
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
import * as LinkWarningModal from './LinkWarning' import * as LinkWarningModal from './LinkWarning'
import * as ListAddUserModal from './ListAddRemoveUsers'
import * as UserAddRemoveLists from './UserAddRemoveLists' import * as UserAddRemoveLists from './UserAddRemoveLists'
import * as VerifyEmailModal from './VerifyEmail' import * as VerifyEmailModal from './VerifyEmail'
@@ -65,8 +64,6 @@ function Modal({modal}: {modal: ModalIface}) {
element = <CreateOrEditListModal.Component {...modal} /> element = <CreateOrEditListModal.Component {...modal} />
} else if (modal.name === 'user-add-remove-lists') { } else if (modal.name === 'user-add-remove-lists') {
element = <UserAddRemoveLists.Component {...modal} /> element = <UserAddRemoveLists.Component {...modal} />
} else if (modal.name === 'list-add-remove-users') {
element = <ListAddUserModal.Component {...modal} />
} else if (modal.name === 'crop-image') { } else if (modal.name === 'crop-image') {
element = <CropImageModal.Component {...modal} /> element = <CropImageModal.Component {...modal} />
} else if (modal.name === 'delete-account') { } else if (modal.name === 'delete-account') {
+30 -22
View File
@@ -5,7 +5,7 @@ import {
AppBskyGraphDefs, AppBskyGraphDefs,
AtUri, AtUri,
moderateUserList, moderateUserList,
ModerationOpts, type ModerationOpts,
RichText as RichTextAPI, RichText as RichTextAPI,
} from '@atproto/api' } from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
@@ -21,8 +21,11 @@ import {useSetTitle} from '#/lib/hooks/useSetTitle'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {ComposeIcon2} from '#/lib/icons' import {ComposeIcon2} from '#/lib/icons'
import {makeListLink} from '#/lib/routes/links' import {makeListLink} from '#/lib/routes/links'
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' import {
import {NavigationProp} from '#/lib/routes/types' type CommonNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
import {type NavigationProp} from '#/lib/routes/types'
import {shareUrl} from '#/lib/sharing' import {shareUrl} from '#/lib/sharing'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import {toShareUrl} from '#/lib/strings/url-helpers' import {toShareUrl} from '#/lib/strings/url-helpers'
@@ -38,12 +41,12 @@ import {
useListMuteMutation, useListMuteMutation,
useListQuery, useListQuery,
} from '#/state/queries/list' } from '#/state/queries/list'
import {FeedDescriptor} from '#/state/queries/post-feed' import {type FeedDescriptor} from '#/state/queries/post-feed'
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
import { import {
useAddSavedFeedsMutation, useAddSavedFeedsMutation,
usePreferencesQuery, usePreferencesQuery,
UsePreferencesQueryResponse, type UsePreferencesQueryResponse,
useRemoveFeedMutation, useRemoveFeedMutation,
useUpdateSavedFeedsMutation, useUpdateSavedFeedsMutation,
} from '#/state/queries/preferences' } from '#/state/queries/preferences'
@@ -60,10 +63,10 @@ import {EmptyState} from '#/view/com/util/EmptyState'
import {FAB} from '#/view/com/util/fab/FAB' import {FAB} from '#/view/com/util/fab/FAB'
import {Button} from '#/view/com/util/forms/Button' import {Button} from '#/view/com/util/forms/Button'
import { import {
DropdownItem, type DropdownItem,
NativeDropdown, NativeDropdown,
} from '#/view/com/util/forms/NativeDropdown' } from '#/view/com/util/forms/NativeDropdown'
import {ListRef} from '#/view/com/util/List' import {type ListRef} from '#/view/com/util/List'
import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn' import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn'
import {LoadingScreen} from '#/view/com/util/LoadingScreen' import {LoadingScreen} from '#/view/com/util/LoadingScreen'
import {Text} from '#/view/com/util/text/Text' import {Text} from '#/view/com/util/text/Text'
@@ -72,6 +75,7 @@ import {ListHiddenScreen} from '#/screens/List/ListHiddenScreen'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {Button as NewButton, ButtonIcon, ButtonText} from '#/components/Button' import {Button as NewButton, ButtonIcon, ButtonText} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog'
import {ListAddRemoveUsersDialog} from '#/components/dialogs/lists/ListAddRemoveUsersDialog'
import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person'
import * as Layout from '#/components/Layout' import * as Layout from '#/components/Layout'
import * as Hider from '#/components/moderation/Hider' import * as Hider from '#/components/moderation/Hider'
@@ -157,12 +161,12 @@ function ProfileListScreenLoaded({
const {rkey} = route.params const {rkey} = route.params
const feedSectionRef = React.useRef<SectionRef>(null) const feedSectionRef = React.useRef<SectionRef>(null)
const aboutSectionRef = React.useRef<SectionRef>(null) const aboutSectionRef = React.useRef<SectionRef>(null)
const {openModal} = useModalControls()
const isCurateList = list.purpose === AppBskyGraphDefs.CURATELIST const isCurateList = list.purpose === AppBskyGraphDefs.CURATELIST
const isScreenFocused = useIsFocused() const isScreenFocused = useIsFocused()
const isHidden = list.labels?.findIndex(l => l.val === '!hide') !== -1 const isHidden = list.labels?.findIndex(l => l.val === '!hide') !== -1
const isOwner = currentAccount?.did === list.creator.did const isOwner = currentAccount?.did === list.creator.did
const scrollElRef = useAnimatedRef() const scrollElRef = useAnimatedRef()
const addUserDialogControl = useDialogControl()
const sectionTitlesCurate = [_(msg`Posts`), _(msg`People`)] const sectionTitlesCurate = [_(msg`Posts`), _(msg`People`)]
const moderation = React.useMemo(() => { const moderation = React.useMemo(() => {
@@ -177,17 +181,11 @@ function ProfileListScreenLoaded({
}, [setMinimalShellMode]), }, [setMinimalShellMode]),
) )
const onPressAddUser = useCallback(() => { const onChangeMembers = useCallback(() => {
openModal({ if (isCurateList) {
name: 'list-add-remove-users', truncateAndInvalidate(queryClient, FEED_RQKEY(`list|${list.uri}`))
list, }
onChange() { }, [list.uri, isCurateList, queryClient])
if (isCurateList) {
truncateAndInvalidate(queryClient, FEED_RQKEY(`list|${list.uri}`))
}
},
})
}, [openModal, list, isCurateList, queryClient])
const onCurrentPageSelected = React.useCallback( const onCurrentPageSelected = React.useCallback(
(index: number) => { (index: number) => {
@@ -225,7 +223,7 @@ function ProfileListScreenLoaded({
headerHeight={headerHeight} headerHeight={headerHeight}
isFocused={isScreenFocused && isFocused} isFocused={isScreenFocused && isFocused}
isOwner={isOwner} isOwner={isOwner}
onPressAddUser={onPressAddUser} onPressAddUser={addUserDialogControl.open}
/> />
)} )}
{({headerHeight, scrollElRef}) => ( {({headerHeight, scrollElRef}) => (
@@ -233,7 +231,7 @@ function ProfileListScreenLoaded({
ref={aboutSectionRef} ref={aboutSectionRef}
scrollElRef={scrollElRef as ListRef} scrollElRef={scrollElRef as ListRef}
list={list} list={list}
onPressAddUser={onPressAddUser} onPressAddUser={addUserDialogControl.open}
headerHeight={headerHeight} headerHeight={headerHeight}
/> />
)} )}
@@ -253,6 +251,11 @@ function ProfileListScreenLoaded({
accessibilityHint="" accessibilityHint=""
/> />
</View> </View>
<ListAddRemoveUsersDialog
control={addUserDialogControl}
list={list}
onChange={onChangeMembers}
/>
</Hider.Content> </Hider.Content>
</Hider.Outer> </Hider.Outer>
) )
@@ -268,7 +271,7 @@ function ProfileListScreenLoaded({
<AboutSection <AboutSection
list={list} list={list}
scrollElRef={scrollElRef as ListRef} scrollElRef={scrollElRef as ListRef}
onPressAddUser={onPressAddUser} onPressAddUser={addUserDialogControl.open}
headerHeight={0} headerHeight={0}
/> />
<FAB <FAB
@@ -286,6 +289,11 @@ function ProfileListScreenLoaded({
accessibilityHint="" accessibilityHint=""
/> />
</View> </View>
<ListAddRemoveUsersDialog
control={addUserDialogControl}
list={list}
onChange={onChangeMembers}
/>
</Hider.Content> </Hider.Content>
</Hider.Outer> </Hider.Outer>
) )