diff options
Diffstat (limited to 'src/components/dialogs')
-rw-r--r-- | src/components/dialogs/SearchablePeopleList.tsx | 538 | ||||
-rw-r--r-- | src/components/dialogs/lists/ListAddRemoveUsersDialog.tsx | 180 |
2 files changed, 718 insertions, 0 deletions
diff --git a/src/components/dialogs/SearchablePeopleList.tsx b/src/components/dialogs/SearchablePeopleList.tsx new file mode 100644 index 000000000..26e20db57 --- /dev/null +++ b/src/components/dialogs/SearchablePeopleList.tsx @@ -0,0 +1,538 @@ +import { + Fragment, + useCallback, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' +import {TextInput, View} from 'react-native' +import {moderateProfile, type ModerationOpts} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' +import {isWeb} from '#/platform/detection' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' +import {useListConvosQuery} from '#/state/queries/messages/list-conversations' +import {useProfileFollowsQuery} from '#/state/queries/profile-follows' +import {useSession} from '#/state/session' +import {type ListMethods} from '#/view/com/util/List' +import {android, atoms as a, native, useTheme, web} from '#/alf' +import {Button, ButtonIcon} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {canBeMessaged} from '#/components/dms/util' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' +import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' +import * as ProfileCard from '#/components/ProfileCard' +import {Text} from '#/components/Typography' +import type * as bsky from '#/types/bsky' + +export type ProfileItem = { + type: 'profile' + key: string + profile: bsky.profile.AnyProfileView +} + +type EmptyItem = { + type: 'empty' + key: string + message: string +} + +type PlaceholderItem = { + type: 'placeholder' + key: string +} + +type ErrorItem = { + type: 'error' + key: string +} + +type Item = ProfileItem | EmptyItem | PlaceholderItem | ErrorItem + +export function SearchablePeopleList({ + title, + showRecentConvos, + sortByMessageDeclaration, + onSelectChat, + renderProfileCard, +}: { + title: string + showRecentConvos?: boolean + sortByMessageDeclaration?: boolean +} & ( + | { + renderProfileCard: (item: ProfileItem) => React.ReactNode + onSelectChat?: undefined + } + | { + onSelectChat: (did: string) => void + renderProfileCard?: undefined + } +)) { + const t = useTheme() + const {_} = useLingui() + const moderationOpts = useModerationOpts() + const control = Dialog.useDialogContext() + const [headerHeight, setHeaderHeight] = useState(0) + const listRef = useRef<ListMethods>(null) + const {currentAccount} = useSession() + const inputRef = useRef<TextInput>(null) + + const [searchText, setSearchText] = useState('') + + const { + data: results, + isError, + isFetching, + } = useActorAutocompleteQuery(searchText, true, 12) + const {data: follows} = useProfileFollowsQuery(currentAccount?.did) + const {data: convos} = useListConvosQuery({ + enabled: showRecentConvos, + status: 'accepted', + }) + + const items = useMemo(() => { + let _items: Item[] = [] + + if (isError) { + _items.push({ + type: 'empty', + key: 'empty', + message: _(msg`We're having network issues, try again`), + }) + } else if (searchText.length) { + if (results?.length) { + for (const profile of results) { + if (profile.did === currentAccount?.did) continue + _items.push({ + type: 'profile', + key: profile.did, + profile, + }) + } + + if (sortByMessageDeclaration) { + _items = _items.sort(item => { + return item.type === 'profile' && canBeMessaged(item.profile) + ? -1 + : 1 + }) + } + } + } else { + const placeholders: Item[] = Array(10) + .fill(0) + .map((__, i) => ({ + type: 'placeholder', + key: i + '', + })) + + if (showRecentConvos) { + if (convos && follows) { + const usedDids = new Set() + + for (const page of convos.pages) { + for (const convo of page.convos) { + const profiles = convo.members.filter( + m => m.did !== currentAccount?.did, + ) + + for (const profile of profiles) { + if (usedDids.has(profile.did)) continue + + usedDids.add(profile.did) + + _items.push({ + type: 'profile', + key: profile.did, + profile, + }) + } + } + } + + let followsItems: ProfileItem[] = [] + + for (const page of follows.pages) { + for (const profile of page.follows) { + if (usedDids.has(profile.did)) continue + + followsItems.push({ + type: 'profile', + key: profile.did, + profile, + }) + } + } + + if (sortByMessageDeclaration) { + // only sort follows + followsItems = followsItems.sort(item => { + return canBeMessaged(item.profile) ? -1 : 1 + }) + } + + // then append + _items.push(...followsItems) + } else { + _items.push(...placeholders) + } + } else if (follows) { + for (const page of follows.pages) { + for (const profile of page.follows) { + _items.push({ + type: 'profile', + key: profile.did, + profile, + }) + } + } + + if (sortByMessageDeclaration) { + _items = _items.sort(item => { + return item.type === 'profile' && canBeMessaged(item.profile) + ? -1 + : 1 + }) + } + } else { + _items.push(...placeholders) + } + } + + return _items + }, [ + _, + searchText, + results, + isError, + currentAccount?.did, + follows, + convos, + showRecentConvos, + sortByMessageDeclaration, + ]) + + if (searchText && !isFetching && !items.length && !isError) { + items.push({type: 'empty', key: 'empty', message: _(msg`No results`)}) + } + + const renderItems = useCallback( + ({item}: {item: Item}) => { + switch (item.type) { + case 'profile': { + if (renderProfileCard) { + return <Fragment key={item.key}>{renderProfileCard(item)}</Fragment> + } else { + return ( + <DefaultProfileCard + key={item.key} + profile={item.profile} + moderationOpts={moderationOpts!} + onPress={onSelectChat} + /> + ) + } + } + case 'placeholder': { + return <ProfileCardSkeleton key={item.key} /> + } + case 'empty': { + return <Empty key={item.key} message={item.message} /> + } + default: + return null + } + }, + [moderationOpts, onSelectChat, renderProfileCard], + ) + + useLayoutEffect(() => { + if (isWeb) { + setImmediate(() => { + inputRef?.current?.focus() + }) + } + }, []) + + const listHeader = useMemo(() => { + return ( + <View + onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)} + style={[ + a.relative, + web(a.pt_lg), + native(a.pt_4xl), + android({ + borderTopLeftRadius: a.rounded_md.borderRadius, + borderTopRightRadius: a.rounded_md.borderRadius, + }), + a.pb_xs, + a.px_lg, + a.border_b, + t.atoms.border_contrast_low, + t.atoms.bg, + ]}> + <View style={[a.relative, native(a.align_center), a.justify_center]}> + <Text + style={[ + a.z_10, + a.text_lg, + a.font_heavy, + a.leading_tight, + t.atoms.text_contrast_high, + ]}> + {title} + </Text> + {isWeb ? ( + <Button + label={_(msg`Close`)} + size="small" + shape="round" + variant={isWeb ? 'ghost' : 'solid'} + color="secondary" + style={[ + a.absolute, + a.z_20, + web({right: -4}), + native({right: 0}), + native({height: 32, width: 32, borderRadius: 16}), + ]} + onPress={() => control.close()}> + <ButtonIcon icon={X} size="md" /> + </Button> + ) : null} + </View> + + <View style={web([a.pt_xs])}> + <SearchInput + inputRef={inputRef} + value={searchText} + onChangeText={text => { + setSearchText(text) + listRef.current?.scrollToOffset({offset: 0, animated: false}) + }} + onEscape={control.close} + /> + </View> + </View> + ) + }, [ + t.atoms.border_contrast_low, + t.atoms.bg, + t.atoms.text_contrast_high, + _, + title, + searchText, + control, + ]) + + return ( + <Dialog.InnerFlatList + ref={listRef} + data={items} + renderItem={renderItems} + ListHeaderComponent={listHeader} + stickyHeaderIndices={[0]} + keyExtractor={(item: Item) => item.key} + style={[ + web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]), + native({height: '100%'}), + ]} + webInnerContentContainerStyle={a.py_0} + webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} + scrollIndicatorInsets={{top: headerHeight}} + keyboardDismissMode="on-drag" + /> + ) +} + +function DefaultProfileCard({ + profile, + moderationOpts, + onPress, +}: { + profile: bsky.profile.AnyProfileView + moderationOpts: ModerationOpts + onPress: (did: string) => void +}) { + const t = useTheme() + const {_} = useLingui() + const enabled = canBeMessaged(profile) + const moderation = moderateProfile(profile, moderationOpts) + const handle = sanitizeHandle(profile.handle, '@') + const displayName = sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + moderation.ui('displayName'), + ) + + const handleOnPress = useCallback(() => { + onPress(profile.did) + }, [onPress, profile.did]) + + return ( + <Button + disabled={!enabled} + label={_(msg`Start chat with ${displayName}`)} + onPress={handleOnPress}> + {({hovered, pressed, focused}) => ( + <View + style={[ + a.flex_1, + a.py_sm, + a.px_lg, + !enabled + ? {opacity: 0.5} + : pressed || focused || hovered + ? t.atoms.bg_contrast_25 + : t.atoms.bg, + ]}> + <ProfileCard.Header> + <ProfileCard.Avatar + profile={profile} + moderationOpts={moderationOpts} + /> + <View style={[a.flex_1]}> + <ProfileCard.Name + profile={profile} + moderationOpts={moderationOpts} + /> + {enabled ? ( + <ProfileCard.Handle profile={profile} /> + ) : ( + <Text + style={[a.leading_snug, t.atoms.text_contrast_high]} + numberOfLines={2}> + <Trans>{handle} can't be messaged</Trans> + </Text> + )} + </View> + </ProfileCard.Header> + </View> + )} + </Button> + ) +} + +function ProfileCardSkeleton() { + const t = useTheme() + + return ( + <View + style={[ + a.flex_1, + a.py_md, + a.px_lg, + a.gap_md, + a.align_center, + a.flex_row, + ]}> + <View + style={[ + a.rounded_full, + {width: 42, height: 42}, + t.atoms.bg_contrast_25, + ]} + /> + + <View style={[a.flex_1, a.gap_sm]}> + <View + style={[ + a.rounded_xs, + {width: 80, height: 14}, + t.atoms.bg_contrast_25, + ]} + /> + <View + style={[ + a.rounded_xs, + {width: 120, height: 10}, + t.atoms.bg_contrast_25, + ]} + /> + </View> + </View> + ) +} + +function Empty({message}: {message: string}) { + const t = useTheme() + return ( + <View style={[a.p_lg, a.py_xl, a.align_center, a.gap_md]}> + <Text style={[a.text_sm, a.italic, t.atoms.text_contrast_high]}> + {message} + </Text> + + <Text style={[a.text_xs, t.atoms.text_contrast_low]}>(╯°□°)╯︵ ┻━┻</Text> + </View> + ) +} + +function SearchInput({ + value, + onChangeText, + onEscape, + inputRef, +}: { + value: string + onChangeText: (text: string) => void + onEscape: () => void + inputRef: React.RefObject<TextInput> +}) { + const t = useTheme() + const {_} = useLingui() + const { + state: hovered, + onIn: onMouseEnter, + onOut: onMouseLeave, + } = useInteractionState() + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + const interacted = hovered || focused + + return ( + <View + {...web({ + onMouseEnter, + onMouseLeave, + })} + style={[a.flex_row, a.align_center, a.gap_sm]}> + <Search + size="md" + fill={interacted ? t.palette.primary_500 : t.palette.contrast_300} + /> + + <TextInput + // @ts-ignore bottom sheet input types issue — esb + ref={inputRef} + placeholder={_(msg`Search`)} + value={value} + onChangeText={onChangeText} + onFocus={onFocus} + onBlur={onBlur} + style={[a.flex_1, a.py_md, a.text_md, t.atoms.text]} + placeholderTextColor={t.palette.contrast_500} + keyboardAppearance={t.name === 'light' ? 'light' : 'dark'} + returnKeyType="search" + clearButtonMode="while-editing" + maxLength={50} + onKeyPress={({nativeEvent}) => { + if (nativeEvent.key === 'Escape') { + onEscape() + } + }} + autoCorrect={false} + autoComplete="off" + autoCapitalize="none" + autoFocus + accessibilityLabel={_(msg`Search profiles`)} + accessibilityHint={_(msg`Searches for profiles`)} + /> + </View> + ) +} diff --git a/src/components/dialogs/lists/ListAddRemoveUsersDialog.tsx b/src/components/dialogs/lists/ListAddRemoveUsersDialog.tsx new file mode 100644 index 000000000..d975c89ed --- /dev/null +++ b/src/components/dialogs/lists/ListAddRemoveUsersDialog.tsx @@ -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> + ) +} |