diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx index b5258c02b9..b88159613e 100644 --- a/src/components/Dialog/index.tsx +++ b/src/components/Dialog/index.tsx @@ -1,5 +1,5 @@ import React, {useImperativeHandle} from 'react' -import {Dimensions, Pressable, View} from 'react-native' +import {Dimensions, Pressable, StyleProp, View, ViewStyle} from 'react-native' import Animated, {useAnimatedStyle} from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' import BottomSheet, { @@ -257,9 +257,10 @@ export const ScrollableInner = React.forwardRef< export const InnerFlatList = React.forwardRef< BottomSheetFlatListMethods, - BottomSheetFlatListProps + BottomSheetFlatListProps & {webInnerStyle?: StyleProp} >(function InnerFlatList({style, contentContainerStyle, ...props}, ref) { const insets = useSafeAreaInsets() + return ( & {label: string} ->(function InnerFlatList({label, style, ...props}, ref) { + FlatListProps & {label: string} & {webInnerStyle?: StyleProp} +>(function InnerFlatList({label, style, webInnerStyle, ...props}, ref) { const {gtMobile} = useBreakpoints() return ( + style={[ + // @ts-ignore web only -sfn + { + paddingHorizontal: 0, + maxHeight: 'calc(-36px + 100vh)', + overflow: 'hidden', + }, + webInnerStyle, + ]}> void -}) { - const t = useTheme() - const {_} = useLingui() - - const {mutate: createChat} = useGetConvoForMembers({ - onSuccess: data => { - onNewChat(data.convo.id) - }, - onError: error => { - Toast.show(error.message) - }, - }) - - const onCreateChat = useCallback( - (did: string) => { - control.close(() => createChat([did])) - }, - [control, createChat], - ) - - return ( - <> - } - accessibilityRole="button" - accessibilityLabel={_(msg`New chat`)} - accessibilityHint="" - /> - - - - - - - ) -} - -function SearchablePeopleList({ - onCreateChat, -}: { - onCreateChat: (did: string) => void -}) { - const t = useTheme() - const {_} = useLingui() - const moderationOpts = useModerationOpts() - const control = Dialog.useDialogContext() - const listRef = useRef(null) - const {currentAccount} = useSession() - - const [searchText, setSearchText] = useState('') - - const { - data: actorAutocompleteData, - isFetching, - isError, - refetch, - } = useActorAutocompleteQuery(searchText, true) - - const renderItem = useCallback( - ({item: profile}: {item: AppBskyActorDefs.ProfileView}) => { - if (!moderationOpts) return null - - const moderation = moderateProfile(profile, moderationOpts) - - const disabled = !canBeMessaged(profile) - const handle = sanitizeHandle(profile.handle, '@') - - return ( - - ) - }, - [ - moderationOpts, - onCreateChat, - t.atoms.bg_contrast_25, - t.atoms.bg_contrast_50, - t.atoms.bg, - t.atoms.text, - t.atoms.text_contrast_high, - ], - ) - - const listHeader = useMemo(() => { - return ( - - {/* cover top corners */} - - - Start a new chat - - - - { - setSearchText(text) - listRef.current?.scrollToOffset({offset: 0, animated: false}) - }} - returnKeyType="search" - clearButtonMode="while-editing" - maxLength={50} - onKeyPress={({nativeEvent}) => { - if (nativeEvent.key === 'Escape') { - control.close() - } - }} - autoCorrect={false} - autoComplete="off" - autoCapitalize="none" - autoFocus - /> - - - - ) - }, [t.atoms.bg, _, control, searchText]) - - const dataWithoutSelf = useMemo(() => { - return ( - actorAutocompleteData?.filter( - profile => profile.did !== currentAccount?.did, - ) ?? [] - ) - }, [actorAutocompleteData, currentAccount?.did]) - - return ( - - {listHeader} - {searchText.length === 0 ? ( - - - - Search for someone to start a conversation with. - - - ) : ( - !actorAutocompleteData?.length && ( - - ) - )} - - } - stickyHeaderIndices={[0]} - keyExtractor={(item: AppBskyActorDefs.ProfileView) => item.did} - // @ts-expect-error web only - style={isWeb && {minHeight: '100vh'}} - onScrollBeginDrag={() => Keyboard.dismiss()} - /> - ) -} diff --git a/src/components/dms/NewChatDialog/TextInput.tsx b/src/components/dms/NewChatDialog/TextInput.tsx new file mode 100644 index 0000000000..b4e77e3e07 --- /dev/null +++ b/src/components/dms/NewChatDialog/TextInput.tsx @@ -0,0 +1 @@ +export {BottomSheetTextInput as TextInput} from '@discord/bottom-sheet/src' diff --git a/src/components/dms/NewChatDialog/TextInput.web.tsx b/src/components/dms/NewChatDialog/TextInput.web.tsx new file mode 100644 index 0000000000..5371a534f1 --- /dev/null +++ b/src/components/dms/NewChatDialog/TextInput.web.tsx @@ -0,0 +1 @@ +export {TextInput} from 'react-native' diff --git a/src/components/dms/NewChatDialog/index.tsx b/src/components/dms/NewChatDialog/index.tsx new file mode 100644 index 0000000000..99572fd5cf --- /dev/null +++ b/src/components/dms/NewChatDialog/index.tsx @@ -0,0 +1,496 @@ +import React, {useCallback, useMemo, useRef, useState} from 'react' +import type {TextInput as TextInputType} from 'react-native' +import {View} from 'react-native' +import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' +import {BottomSheetFlatListMethods} from '@discord/bottom-sheet' +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 {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' +import {useProfileFollowsQuery} from '#/state/queries/profile-follows' +import {useSession} from '#/state/session' +import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete' +import {FAB} from '#/view/com/util/fab/FAB' +import * as Toast from '#/view/com/util/Toast' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, native, useTheme, web} from '#/alf' +import {Button} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {TextInput} from '#/components/dms/NewChatDialog/TextInput' +import {canBeMessaged} from '#/components/dms/util' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron' +import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' +import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' +import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' +import {Text} from '#/components/Typography' + +type Item = + | { + type: 'profile' + key: string + enabled: boolean + profile: AppBskyActorDefs.ProfileView + } + | { + type: 'empty' + key: string + message: string + } + | { + type: 'placeholder' + key: string + } + | { + type: 'error' + key: string + } + +export function NewChat({ + control, + onNewChat, +}: { + control: Dialog.DialogControlProps + onNewChat: (chatId: string) => void +}) { + const t = useTheme() + const {_} = useLingui() + + const {mutate: createChat} = useGetConvoForMembers({ + onSuccess: data => { + onNewChat(data.convo.id) + }, + onError: error => { + Toast.show(error.message) + }, + }) + + const onCreateChat = useCallback( + (did: string) => { + control.close(() => createChat([did])) + }, + [control, createChat], + ) + + return ( + <> + } + accessibilityRole="button" + accessibilityLabel={_(msg`New chat`)} + accessibilityHint="" + /> + + + + + + ) +} + +function ProfileCard({ + enabled, + profile, + moderationOpts, + onPress, +}: { + enabled: boolean + profile: AppBskyActorDefs.ProfileView + moderationOpts: ModerationOpts + onPress: (did: string) => void +}) { + const t = useTheme() + const {_} = useLingui() + 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 ( + + ) +} + +function ProfileCardSkeleton() { + const t = useTheme() + + return ( + + + + + + + + + ) +} + +function Empty({message}: {message: string}) { + const t = useTheme() + return ( + + + {message} + + + (╯°□°)╯︵ ┻━┻ + + ) +} + +function SearchInput({ + value, + onChangeText, + onEscape, + inputRef, +}: { + value: string + onChangeText: (text: string) => void + onEscape: () => void + inputRef: React.RefObject +}) { + 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 ( + + + + { + if (nativeEvent.key === 'Escape') { + onEscape() + } + }} + autoCorrect={false} + autoComplete="off" + autoCapitalize="none" + autoFocus + accessibilityLabel={_(msg`Search profiles`)} + accessibilityHint={_(msg`Search profiles`)} + /> + + ) +} + +function SearchablePeopleList({ + onCreateChat, +}: { + onCreateChat: (did: string) => void +}) { + const t = useTheme() + const {_} = useLingui() + const moderationOpts = useModerationOpts() + const control = Dialog.useDialogContext() + const listRef = useRef(null) + const {currentAccount} = useSession() + const inputRef = React.useRef(null) + + const [searchText, setSearchText] = useState('') + + const { + data: results, + isError, + isFetching, + } = useActorAutocompleteQuery(searchText, true, 12) + const {data: follows} = useProfileFollowsQuery(currentAccount?.did, { + limit: 12, + }) + + const items = React.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, + enabled: canBeMessaged(profile), + profile, + }) + } + + _items = _items.sort(a => { + // @ts-ignore + return a.enabled ? -1 : 1 + }) + } + } else { + if (follows) { + for (const page of follows.pages) { + for (const profile of page.follows) { + _items.push({ + type: 'profile', + key: profile.did, + enabled: canBeMessaged(profile), + profile, + }) + } + } + + _items = _items.sort(a => { + // @ts-ignore + return a.enabled ? -1 : 1 + }) + } else { + Array(10) + .fill(0) + .forEach((_, i) => { + _items.push({ + type: 'placeholder', + key: i + '', + }) + }) + } + } + + return _items + }, [_, searchText, results, isError, currentAccount?.did, follows]) + + if (searchText && !isFetching && !items.length && !isError) { + items.push({type: 'empty', key: 'empty', message: _(msg`No results`)}) + } + + const renderItems = React.useCallback( + ({item}: {item: Item}) => { + switch (item.type) { + case 'profile': { + return ( + + ) + } + case 'placeholder': { + return + } + case 'empty': { + return + } + default: + return null + } + }, + [moderationOpts, onCreateChat], + ) + + React.useLayoutEffect(() => { + if (isWeb) { + setImmediate(() => { + inputRef?.current?.focus() + }) + } + }, []) + + const listHeader = useMemo(() => { + return ( + + + + + Start a new chat + + + + + { + setSearchText(text) + listRef.current?.scrollToOffset({offset: 0, animated: false}) + }} + onEscape={control.close} + /> + + + ) + }, [t, _, control, searchText]) + + return ( + item.key} + style={[ + web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]), + native({ + paddingHorizontal: 0, + marginTop: 0, + paddingTop: 0, + }), + ]} + webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} + keyboardDismissMode="on-drag" + /> + ) +} diff --git a/src/screens/Messages/List/index.tsx b/src/screens/Messages/List/index.tsx index e36d1edf2d..c198d44c45 100644 --- a/src/screens/Messages/List/index.tsx +++ b/src/screens/Messages/List/index.tsx @@ -18,7 +18,7 @@ import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {DialogControlProps, useDialogControl} from '#/components/Dialog' import {MessagesNUX} from '#/components/dms/MessagesNUX' -import {NewChat} from '#/components/dms/NewChat' +import {NewChat} from '#/components/dms/NewChatDialog' import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider' diff --git a/src/state/queries/actor-autocomplete.ts b/src/state/queries/actor-autocomplete.ts index 8708a244bd..17b00dc26e 100644 --- a/src/state/queries/actor-autocomplete.ts +++ b/src/state/queries/actor-autocomplete.ts @@ -20,6 +20,7 @@ export const RQKEY = (prefix: string) => [RQKEY_ROOT, prefix] export function useActorAutocompleteQuery( prefix: string, maintainData?: boolean, + limit?: number, ) { const moderationOpts = useModerationOpts() const {getAgent} = useAgent() @@ -37,7 +38,7 @@ export function useActorAutocompleteQuery( const res = prefix ? await getAgent().searchActorsTypeahead({ q: prefix, - limit: 8, + limit: limit || 8, }) : undefined return res?.data.actors || [] diff --git a/src/state/queries/profile-follows.ts b/src/state/queries/profile-follows.ts index 23c0dce3e7..1919409c7f 100644 --- a/src/state/queries/profile-follows.ts +++ b/src/state/queries/profile-follows.ts @@ -16,7 +16,16 @@ type RQPageParam = string | undefined const RQKEY_ROOT = 'profile-follows' export const RQKEY = (did: string) => [RQKEY_ROOT, did] -export function useProfileFollowsQuery(did: string | undefined) { +export function useProfileFollowsQuery( + did: string | undefined, + { + limit, + }: { + limit?: number + } = { + limit: PAGE_SIZE, + }, +) { const {getAgent} = useAgent() return useInfiniteQuery< AppBskyGraphGetFollows.OutputSchema, @@ -30,7 +39,7 @@ export function useProfileFollowsQuery(did: string | undefined) { async queryFn({pageParam}: {pageParam: RQPageParam}) { const res = await getAgent().app.bsky.graph.getFollows({ actor: did || '', - limit: PAGE_SIZE, + limit: limit || PAGE_SIZE, cursor: pageParam, }) return res.data