diff options
Diffstat (limited to 'src/screens/Messages/Conversation')
-rw-r--r-- | src/screens/Messages/Conversation/MessageInput.tsx | 111 | ||||
-rw-r--r-- | src/screens/Messages/Conversation/MessageInput.web.tsx | 94 | ||||
-rw-r--r-- | src/screens/Messages/Conversation/MessageListError.tsx | 60 | ||||
-rw-r--r-- | src/screens/Messages/Conversation/MessagesList.tsx | 258 | ||||
-rw-r--r-- | src/screens/Messages/Conversation/index.tsx | 142 |
5 files changed, 652 insertions, 13 deletions
diff --git a/src/screens/Messages/Conversation/MessageInput.tsx b/src/screens/Messages/Conversation/MessageInput.tsx new file mode 100644 index 000000000..3de15e661 --- /dev/null +++ b/src/screens/Messages/Conversation/MessageInput.tsx @@ -0,0 +1,111 @@ +import React from 'react' +import { + Dimensions, + Keyboard, + NativeSyntheticEvent, + Pressable, + TextInput, + TextInputContentSizeChangeEventData, + View, +} from 'react-native' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {HITSLOP_10} from '#/lib/constants' +import {useHaptics} from 'lib/haptics' +import {atoms as a, useTheme} from '#/alf' +import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane' + +export function MessageInput({ + onSendMessage, + scrollToEnd, +}: { + onSendMessage: (message: string) => void + scrollToEnd: () => void +}) { + const {_} = useLingui() + const t = useTheme() + const playHaptic = useHaptics() + const [message, setMessage] = React.useState('') + const [maxHeight, setMaxHeight] = React.useState<number | undefined>() + const [isInputScrollable, setIsInputScrollable] = React.useState(false) + + const {top: topInset} = useSafeAreaInsets() + + const inputRef = React.useRef<TextInput>(null) + + const onSubmit = React.useCallback(() => { + if (message.trim() === '') { + return + } + onSendMessage(message.trimEnd()) + playHaptic() + setMessage('') + setTimeout(() => { + inputRef.current?.focus() + }, 100) + }, [message, onSendMessage, playHaptic]) + + const onInputLayout = React.useCallback( + (e: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => { + const keyboardHeight = Keyboard.metrics()?.height ?? 0 + const windowHeight = Dimensions.get('window').height + + const max = windowHeight - keyboardHeight - topInset - 100 + const availableSpace = max - e.nativeEvent.contentSize.height + + setMaxHeight(max) + setIsInputScrollable(availableSpace < 30) + + scrollToEnd() + }, + [scrollToEnd, topInset], + ) + + return ( + <View style={a.p_sm}> + <View + style={[ + a.w_full, + a.flex_row, + a.py_sm, + a.px_sm, + a.pl_md, + t.atoms.bg_contrast_25, + {borderRadius: 23}, + ]}> + <TextInput + accessibilityLabel={_(msg`Message input field`)} + accessibilityHint={_(msg`Type your message here`)} + placeholder={_(msg`Write a message`)} + placeholderTextColor={t.palette.contrast_500} + value={message} + multiline={true} + onChangeText={setMessage} + style={[a.flex_1, a.text_md, a.px_sm, t.atoms.text, {maxHeight}]} + keyboardAppearance={t.name === 'light' ? 'light' : 'dark'} + scrollEnabled={isInputScrollable} + blurOnSubmit={false} + onFocus={scrollToEnd} + onContentSizeChange={onInputLayout} + ref={inputRef} + /> + <Pressable + accessibilityRole="button" + accessibilityLabel={_(msg`Send message`)} + accessibilityHint="" + hitSlop={HITSLOP_10} + style={[ + a.rounded_full, + a.align_center, + a.justify_center, + {height: 30, width: 30, backgroundColor: t.palette.primary_500}, + ]} + onPress={onSubmit}> + <PaperPlane fill={t.palette.white} style={[a.relative, {left: 1}]} /> + </Pressable> + </View> + </View> + ) +} diff --git a/src/screens/Messages/Conversation/MessageInput.web.tsx b/src/screens/Messages/Conversation/MessageInput.web.tsx new file mode 100644 index 000000000..a2f255bdc --- /dev/null +++ b/src/screens/Messages/Conversation/MessageInput.web.tsx @@ -0,0 +1,94 @@ +import React from 'react' +import {Pressable, StyleSheet, View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import TextareaAutosize from 'react-textarea-autosize' + +import {atoms as a, useTheme} from '#/alf' +import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane' + +export function MessageInput({ + onSendMessage, +}: { + onSendMessage: (message: string) => void + scrollToEnd: () => void +}) { + const {_} = useLingui() + const t = useTheme() + const [message, setMessage] = React.useState('') + + const onSubmit = React.useCallback(() => { + if (message.trim() === '') { + return + } + onSendMessage(message.trimEnd()) + setMessage('') + }, [message, onSendMessage]) + + const onKeyDown = React.useCallback( + (e: React.KeyboardEvent<HTMLTextAreaElement>) => { + if (e.key === 'Enter') { + if (e.shiftKey) return + e.preventDefault() + onSubmit() + } + }, + [onSubmit], + ) + + const onChange = React.useCallback( + (e: React.ChangeEvent<HTMLTextAreaElement>) => { + setMessage(e.target.value) + }, + [], + ) + + return ( + <View style={a.p_sm}> + <View + style={[ + a.flex_row, + a.py_sm, + a.px_sm, + a.pl_md, + t.atoms.bg_contrast_25, + {borderRadius: 23}, + ]}> + <TextareaAutosize + style={StyleSheet.flatten([ + a.flex_1, + a.px_sm, + a.border_0, + t.atoms.text, + { + backgroundColor: 'transparent', + resize: 'none', + paddingTop: 4, + }, + ])} + maxRows={12} + placeholder={_(msg`Write a message`)} + defaultValue="" + value={message} + dirName="ltr" + autoFocus={true} + onChange={onChange} + onKeyDown={onKeyDown} + /> + <Pressable + accessibilityRole="button" + accessibilityLabel={_(msg`Send message`)} + accessibilityHint="" + style={[ + a.rounded_full, + a.align_center, + a.justify_center, + {height: 30, width: 30, backgroundColor: t.palette.primary_500}, + ]} + onPress={onSubmit}> + <PaperPlane fill={t.palette.white} style={[a.relative, {left: 1}]} /> + </Pressable> + </View> + </View> + ) +} diff --git a/src/screens/Messages/Conversation/MessageListError.tsx b/src/screens/Messages/Conversation/MessageListError.tsx new file mode 100644 index 000000000..523788d4d --- /dev/null +++ b/src/screens/Messages/Conversation/MessageListError.tsx @@ -0,0 +1,60 @@ +import React from 'react' +import {View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {ConvoItem, ConvoItemError} from '#/state/messages/convo' +import {atoms as a, useTheme} from '#/alf' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {InlineLinkText} from '#/components/Link' +import {Text} from '#/components/Typography' + +export function MessageListError({ + item, +}: { + item: ConvoItem & {type: 'error-recoverable'} +}) { + const t = useTheme() + const {_} = useLingui() + const message = React.useMemo(() => { + return { + [ConvoItemError.HistoryFailed]: _(msg`Failed to load past messages.`), + [ConvoItemError.ResumeFailed]: _( + msg`There was an issue connecting to the chat.`, + ), + [ConvoItemError.PollFailed]: _( + msg`This chat was disconnected due to a network error.`, + ), + }[item.code] + }, [_, item.code]) + + return ( + <View style={[a.py_md, a.align_center]}> + <View + style={[ + a.align_center, + a.pt_md, + a.pb_lg, + a.px_3xl, + a.rounded_md, + t.atoms.bg_contrast_25, + {maxWidth: 300}, + ]}> + <CircleInfo size="lg" fill={t.palette.negative_400} /> + <Text style={[a.pt_sm, a.leading_snug]}> + {message}{' '} + <InlineLinkText + to="#" + label={_(msg`Press to retry`)} + onPress={e => { + e.preventDefault() + item.retry() + return false + }}> + {_(msg`Retry.`)} + </InlineLinkText> + </Text> + </View> + </View> + ) +} diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx new file mode 100644 index 000000000..1dc26d6c3 --- /dev/null +++ b/src/screens/Messages/Conversation/MessagesList.tsx @@ -0,0 +1,258 @@ +import React, {useCallback, useRef} from 'react' +import {FlatList, View} from 'react-native' +import { + KeyboardAvoidingView, + useKeyboardHandler, +} from 'react-native-keyboard-controller' +import {runOnJS, useSharedValue} from 'react-native-reanimated' +import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {isIOS} from '#/platform/detection' +import {useChat} from '#/state/messages' +import {ConvoItem, ConvoStatus} from '#/state/messages/convo' +import {ScrollProvider} from 'lib/ScrollContext' +import {isWeb} from 'platform/detection' +import {List} from 'view/com/util/List' +import {MessageInput} from '#/screens/Messages/Conversation/MessageInput' +import {MessageListError} from '#/screens/Messages/Conversation/MessageListError' +import {atoms as a, useBreakpoints} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {MessageItem} from '#/components/dms/MessageItem' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' + +function MaybeLoader({isLoading}: {isLoading: boolean}) { + return ( + <View + style={{ + height: 50, + width: '100%', + alignItems: 'center', + justifyContent: 'center', + }}> + {isLoading && <Loader size="xl" />} + </View> + ) +} + +function RetryButton({onPress}: {onPress: () => unknown}) { + const {_} = useLingui() + + return ( + <View style={{alignItems: 'center'}}> + <Button + label={_(msg`Press to Retry`)} + onPress={onPress} + variant="ghost" + color="negative" + size="small"> + <ButtonText> + <Trans>Press to Retry</Trans> + </ButtonText> + </Button> + </View> + ) +} + +function renderItem({item}: {item: ConvoItem}) { + if (item.type === 'message' || item.type === 'pending-message') { + return ( + <MessageItem + item={item.message} + next={item.nextMessage} + pending={item.type === 'pending-message'} + /> + ) + } else if (item.type === 'deleted-message') { + return <Text>Deleted message</Text> + } else if (item.type === 'pending-retry') { + return <RetryButton onPress={item.retry} /> + } else if (item.type === 'error-recoverable') { + return <MessageListError item={item} /> + } + + return null +} + +function keyExtractor(item: ConvoItem) { + return item.key +} + +function onScrollToIndexFailed() { + // Placeholder function. You have to give FlatList something or else it will error. +} + +export function MessagesList() { + const chat = useChat() + const flatListRef = useRef<FlatList>(null) + + // We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items + // are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to + // the bottom. + const isAtBottom = useSharedValue(true) + + // This will be used on web to assist in determing if we need to maintain the content offset + const isAtTop = useSharedValue(true) + + // Used to keep track of the current content height. We'll need this in `onScroll` so we know when to start allowing + // onStartReached to fire. + const contentHeight = useSharedValue(0) + + // We don't want to call `scrollToEnd` again if we are already scolling to the end, because this creates a bit of jank + // Instead, we use `onMomentumScrollEnd` and this value to determine if we need to start scrolling or not. + const isMomentumScrolling = useSharedValue(false) + + const [hasInitiallyScrolled, setHasInitiallyScrolled] = React.useState(false) + + // Every time the content size changes, that means one of two things is happening: + // 1. New messages are being added from the log or from a message you have sent + // 2. Old messages are being prepended to the top + // + // The first time that the content size changes is when the initial items are rendered. Because we cannot rely on + // `initialScrollIndex`, we need to immediately scroll to the bottom of the list. That scroll will not be animated. + // + // Subsequent resizes will only scroll to the bottom if the user is at the bottom of the list (within 100 pixels of + // the bottom). Therefore, any new messages that come in or are sent will result in an animated scroll to end. However + // we will not scroll whenever new items get prepended to the top. + const onContentSizeChange = useCallback( + (_: number, height: number) => { + // Because web does not have `maintainVisibleContentPosition` support, we will need to manually scroll to the + // previous offset whenever we add new content to the previous offset whenever we add new content to the list. + if (isWeb && isAtTop.value && hasInitiallyScrolled) { + flatListRef.current?.scrollToOffset({ + animated: false, + offset: height - contentHeight.value, + }) + } + + contentHeight.value = height + + // This number _must_ be the height of the MaybeLoader component + if (height <= 50 || !isAtBottom.value) { + return + } + + flatListRef.current?.scrollToOffset({ + animated: hasInitiallyScrolled, + offset: height, + }) + isMomentumScrolling.value = true + }, + [ + contentHeight, + hasInitiallyScrolled, + isAtBottom.value, + isAtTop.value, + isMomentumScrolling, + ], + ) + + // The check for `hasInitiallyScrolled` prevents an initial fetch on mount. FlatList triggers `onStartReached` + // immediately on mount, since we are in fact at an offset of zero, so we have to ignore those initial calls. + const onStartReached = useCallback(() => { + if (chat.status === ConvoStatus.Ready && hasInitiallyScrolled) { + chat.fetchMessageHistory() + } + }, [chat, hasInitiallyScrolled]) + + const onSendMessage = useCallback( + (text: string) => { + if (chat.status === ConvoStatus.Ready) { + chat.sendMessage({ + text, + }) + } + }, + [chat], + ) + + const onScroll = React.useCallback( + (e: ReanimatedScrollEvent) => { + 'worklet' + const bottomOffset = e.contentOffset.y + e.layoutMeasurement.height + + // Most apps have a little bit of space the user can scroll past while still automatically scrolling ot the bottom + // when a new message is added, hence the 100 pixel offset + isAtBottom.value = e.contentSize.height - 100 < bottomOffset + isAtTop.value = e.contentOffset.y <= 1 + + // This number _must_ be the height of the MaybeLoader component. + // We don't check for zero, because the `MaybeLoader` component is always present, even when not visible, which + // adds a 50 pixel offset. + if (contentHeight.value > 50 && !hasInitiallyScrolled) { + runOnJS(setHasInitiallyScrolled)(true) + } + }, + [contentHeight.value, hasInitiallyScrolled, isAtBottom, isAtTop], + ) + + const onMomentumEnd = React.useCallback(() => { + 'worklet' + isMomentumScrolling.value = false + }, [isMomentumScrolling]) + + const scrollToEnd = React.useCallback(() => { + requestAnimationFrame(() => { + if (isMomentumScrolling.value) return + + flatListRef.current?.scrollToEnd({animated: true}) + isMomentumScrolling.value = true + }) + }, [isMomentumScrolling]) + + const {bottom: bottomInset, top: topInset} = useSafeAreaInsets() + const {gtMobile} = useBreakpoints() + const bottomBarHeight = gtMobile ? 0 : isIOS ? 40 : 60 + + // This is only used inside the useKeyboardHandler because the worklet won't work with a ref directly. + const scrollToEndNow = React.useCallback(() => { + flatListRef.current?.scrollToEnd({animated: false}) + }, []) + + useKeyboardHandler({ + onMove: () => { + 'worklet' + runOnJS(scrollToEndNow)() + }, + }) + + return ( + <KeyboardAvoidingView + style={[a.flex_1, {marginBottom: bottomInset + bottomBarHeight}]} + keyboardVerticalOffset={isIOS ? topInset : 0} + behavior="padding" + contentContainerStyle={a.flex_1}> + {/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */} + <ScrollProvider onScroll={onScroll} onMomentumEnd={onMomentumEnd}> + <List + ref={flatListRef} + data={chat.items} + renderItem={renderItem} + keyExtractor={keyExtractor} + disableVirtualization={true} + initialNumToRender={isWeb ? 50 : 25} + maxToRenderPerBatch={isWeb ? 50 : 25} + keyboardDismissMode="on-drag" + keyboardShouldPersistTaps="handled" + maintainVisibleContentPosition={{ + minIndexForVisible: 1, + }} + containWeb={true} + contentContainerStyle={{paddingHorizontal: 10}} + removeClippedSubviews={false} + onContentSizeChange={onContentSizeChange} + onStartReached={onStartReached} + onScrollToIndexFailed={onScrollToIndexFailed} + scrollEventThrottle={100} + ListHeaderComponent={ + <MaybeLoader isLoading={chat.isFetchingHistory} /> + } + /> + </ScrollProvider> + <MessageInput onSendMessage={onSendMessage} scrollToEnd={scrollToEnd} /> + </KeyboardAvoidingView> + ) +} diff --git a/src/screens/Messages/Conversation/index.tsx b/src/screens/Messages/Conversation/index.tsx index 239425a2f..11044c213 100644 --- a/src/screens/Messages/Conversation/index.tsx +++ b/src/screens/Messages/Conversation/index.tsx @@ -1,12 +1,26 @@ -import React from 'react' -import {View} from 'react-native' +import React, {useCallback} from 'react' +import {TouchableOpacity, View} from 'react-native' +import {KeyboardProvider} from 'react-native-keyboard-controller' +import {AppBskyActorDefs} from '@atproto/api' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' import {NativeStackScreenProps} from '@react-navigation/native-stack' -import {CommonNavigatorParams} from '#/lib/routes/types' +import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' import {useGate} from '#/lib/statsig/statsig' -import {ViewHeader} from '#/view/com/util/ViewHeader' +import {BACK_HITSLOP} from 'lib/constants' +import {isWeb} from 'platform/detection' +import {ChatProvider, useChat} from 'state/messages' +import {ConvoStatus} from 'state/messages/convo' +import {PreviewableUserAvatar} from 'view/com/util/UserAvatar' +import {CenteredView} from 'view/com/util/Views' +import {MessagesList} from '#/screens/Messages/Conversation/MessagesList' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {ConvoMenu} from '#/components/dms/ConvoMenu' +import {ListMaybePlaceholder} from '#/components/Lists' +import {Text} from '#/components/Typography' import {ClipClopGate} from '../gate' type Props = NativeStackScreenProps< @@ -14,19 +28,121 @@ type Props = NativeStackScreenProps< 'MessagesConversation' > export function MessagesConversationScreen({route}: Props) { - const chatId = route.params.conversation - const {_} = useLingui() - const gate = useGate() + const convoId = route.params.conversation + if (!gate('dms')) return <ClipClopGate /> return ( - <View> - <ViewHeader - title={_(msg`Chat with ${chatId}`)} - showOnDesktop - showBorder - /> + <ChatProvider convoId={convoId}> + <Inner /> + </ChatProvider> + ) +} + +function Inner() { + const chat = useChat() + + if ( + chat.status === ConvoStatus.Uninitialized || + chat.status === ConvoStatus.Initializing + ) { + return <ListMaybePlaceholder isLoading /> + } + + if (chat.status === ConvoStatus.Error) { + // TODO error + return null + } + + /* + * Any other chat states (atm) are "ready" states + */ + + return ( + <KeyboardProvider> + <CenteredView style={{flex: 1}} sideBorders> + <Header profile={chat.recipients[0]} /> + <MessagesList /> + </CenteredView> + </KeyboardProvider> + ) +} + +let Header = ({ + profile, +}: { + profile: AppBskyActorDefs.ProfileViewBasic +}): React.ReactNode => { + const t = useTheme() + const {_} = useLingui() + const {gtTablet} = useBreakpoints() + const navigation = useNavigation<NavigationProp>() + const chat = useChat() + + const onPressBack = useCallback(() => { + if (isWeb) { + navigation.replace('Messages') + } else { + navigation.pop() + } + }, [navigation]) + + const onUpdateConvo = useCallback(() => { + // TODO eric update muted state + }, []) + + return ( + <View + style={[ + t.atoms.bg, + t.atoms.border_contrast_low, + a.border_b, + a.flex_row, + a.justify_between, + a.align_start, + a.gap_lg, + a.px_lg, + a.py_sm, + ]}> + {!gtTablet ? ( + <TouchableOpacity + testID="conversationHeaderBackBtn" + onPress={onPressBack} + hitSlop={BACK_HITSLOP} + style={{width: 30, height: 30}} + accessibilityRole="button" + accessibilityLabel={_(msg`Back`)} + accessibilityHint=""> + <FontAwesomeIcon + size={18} + icon="angle-left" + style={{ + marginTop: 6, + }} + color={t.atoms.text.color} + /> + </TouchableOpacity> + ) : ( + <View style={{width: 30}} /> + )} + <View style={[a.align_center, a.gap_sm, a.flex_1]}> + <PreviewableUserAvatar size={32} profile={profile} /> + <Text style={[a.text_lg, a.font_bold, a.text_center]}> + {profile.displayName} + </Text> + </View> + {chat.status === ConvoStatus.Ready ? ( + <ConvoMenu + convo={chat.convo} + profile={profile} + onUpdateConvo={onUpdateConvo} + currentScreen="conversation" + /> + ) : ( + <View style={{width: 30}} /> + )} </View> ) } +Header = React.memo(Header) |