diff options
Diffstat (limited to 'src/screens/Messages')
-rw-r--r-- | src/screens/Messages/Conversation/MessageInput.tsx | 94 | ||||
-rw-r--r-- | src/screens/Messages/Conversation/MessageInput.web.tsx | 85 | ||||
-rw-r--r-- | src/screens/Messages/Conversation/MessageItem.tsx | 165 | ||||
-rw-r--r-- | src/screens/Messages/Conversation/MessageListError.tsx | 60 | ||||
-rw-r--r-- | src/screens/Messages/Conversation/MessagesList.tsx | 248 | ||||
-rw-r--r-- | src/screens/Messages/Conversation/index.tsx | 65 | ||||
-rw-r--r-- | src/screens/Messages/List/index.tsx | 48 |
7 files changed, 399 insertions, 366 deletions
diff --git a/src/screens/Messages/Conversation/MessageInput.tsx b/src/screens/Messages/Conversation/MessageInput.tsx index 3848bcab3..3de15e661 100644 --- a/src/screens/Messages/Conversation/MessageInput.tsx +++ b/src/screens/Messages/Conversation/MessageInput.tsx @@ -12,20 +12,21 @@ 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, - onFocus, - onBlur, + scrollToEnd, }: { onSendMessage: (message: string) => void - onFocus: () => void - onBlur: () => 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) @@ -39,11 +40,12 @@ export function MessageInput({ return } onSendMessage(message.trimEnd()) + playHaptic() setMessage('') setTimeout(() => { inputRef.current?.focus() }, 100) - }, [message, onSendMessage]) + }, [message, onSendMessage, playHaptic]) const onInputLayout = React.useCallback( (e: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => { @@ -55,49 +57,55 @@ export function MessageInput({ setMaxHeight(max) setIsInputScrollable(availableSpace < 30) + + scrollToEnd() }, - [topInset], + [scrollToEnd, topInset], ) return ( - <View - style={[ - a.flex_row, - a.py_sm, - a.px_sm, - a.pl_md, - a.mt_sm, - 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={onFocus} - onBlur={onBlur} - onContentSizeChange={onInputLayout} - ref={inputRef} - /> - <Pressable - accessibilityRole="button" + <View style={a.p_sm}> + <View 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} /> - </Pressable> + 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 index 5ecaad3ae..a2f255bdc 100644 --- a/src/screens/Messages/Conversation/MessageInput.web.tsx +++ b/src/screens/Messages/Conversation/MessageInput.web.tsx @@ -11,8 +11,7 @@ export function MessageInput({ onSendMessage, }: { onSendMessage: (message: string) => void - onFocus: () => void - onBlur: () => void + scrollToEnd: () => void }) { const {_} = useLingui() const t = useTheme() @@ -45,47 +44,51 @@ export function MessageInput({ ) return ( - <View - style={[ - a.flex_row, - a.py_sm, - a.px_sm, - a.pl_md, - a.mt_sm, - 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: 6, - }, - ])} - maxRows={12} - placeholder={_(msg`Write a message`)} - defaultValue="" - value={message} - dirName="ltr" - autoFocus={true} - onChange={onChange} - onKeyDown={onKeyDown} - /> - <Pressable - accessibilityRole="button" + <View style={a.p_sm}> + <View style={[ - a.rounded_full, - a.align_center, - a.justify_center, - {height: 30, width: 30, backgroundColor: t.palette.primary_500}, + a.flex_row, + a.py_sm, + a.px_sm, + a.pl_md, + t.atoms.bg_contrast_25, + {borderRadius: 23}, ]}> - <PaperPlane fill={t.palette.white} /> - </Pressable> + <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/MessageItem.tsx b/src/screens/Messages/Conversation/MessageItem.tsx deleted file mode 100644 index ba10978e8..000000000 --- a/src/screens/Messages/Conversation/MessageItem.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import React, {useCallback, useMemo} from 'react' -import {StyleProp, TextStyle, View} from 'react-native' -import {ChatBskyConvoDefs} from '@atproto-labs/api' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {useSession} from '#/state/session' -import {TimeElapsed} from '#/view/com/util/TimeElapsed' -import {atoms as a, useTheme} from '#/alf' -import {Text} from '#/components/Typography' - -export function MessageItem({ - item, - next, -}: { - item: ChatBskyConvoDefs.MessageView - next: - | ChatBskyConvoDefs.MessageView - | ChatBskyConvoDefs.DeletedMessageView - | null -}) { - const t = useTheme() - const {currentAccount} = useSession() - - const isFromSelf = item.sender?.did === currentAccount?.did - - const isNextFromSelf = - ChatBskyConvoDefs.isMessageView(next) && - next.sender?.did === currentAccount?.did - - const isLastInGroup = useMemo(() => { - // if the next message is from a different sender, then it's the last in the group - if (isFromSelf ? !isNextFromSelf : isNextFromSelf) { - return true - } - - // or, if there's a 10 minute gap between this message and the next - if (ChatBskyConvoDefs.isMessageView(next)) { - const thisDate = new Date(item.sentAt) - const nextDate = new Date(next.sentAt) - - const diff = nextDate.getTime() - thisDate.getTime() - - // 10 minutes - return diff > 10 * 60 * 1000 - } - - return true - }, [item, next, isFromSelf, isNextFromSelf]) - - return ( - <View> - <View - style={[ - a.py_sm, - a.px_lg, - a.my_2xs, - a.rounded_md, - isFromSelf ? a.self_end : a.self_start, - { - maxWidth: '65%', - backgroundColor: isFromSelf - ? t.palette.primary_500 - : t.palette.contrast_50, - borderRadius: 17, - }, - isFromSelf - ? {borderBottomRightRadius: isLastInGroup ? 2 : 17} - : {borderBottomLeftRadius: isLastInGroup ? 2 : 17}, - ]}> - <Text - style={[ - a.text_md, - a.leading_snug, - isFromSelf && {color: t.palette.white}, - ]}> - {item.text} - </Text> - </View> - <Metadata - message={item} - isLastInGroup={isLastInGroup} - style={isFromSelf ? a.text_right : a.text_left} - /> - </View> - ) -} - -function Metadata({ - message, - isLastInGroup, - style, -}: { - message: ChatBskyConvoDefs.MessageView - isLastInGroup: boolean - style: StyleProp<TextStyle> -}) { - const t = useTheme() - const {_} = useLingui() - - const relativeTimestamp = useCallback( - (timestamp: string) => { - const date = new Date(timestamp) - const now = new Date() - - const time = new Intl.DateTimeFormat(undefined, { - hour: 'numeric', - minute: 'numeric', - hour12: true, - }).format(date) - - const diff = now.getTime() - date.getTime() - - // if under 1 minute - if (diff < 1000 * 60) { - return _(msg`Now`) - } - - // if in the last day - if (now.toISOString().slice(0, 10) === date.toISOString().slice(0, 10)) { - return time - } - - // if yesterday - const yesterday = new Date(now) - yesterday.setDate(yesterday.getDate() - 1) - if ( - yesterday.toISOString().slice(0, 10) === date.toISOString().slice(0, 10) - ) { - return _(msg`Yesterday, ${time}`) - } - - return new Intl.DateTimeFormat(undefined, { - hour: 'numeric', - minute: 'numeric', - hour12: true, - day: 'numeric', - month: 'numeric', - year: 'numeric', - }).format(date) - }, - [_], - ) - - if (!isLastInGroup) { - return null - } - - return ( - <TimeElapsed timestamp={message.sentAt} timeToString={relativeTimestamp}> - {({timeElapsed}) => ( - <Text - style={[ - t.atoms.text_contrast_medium, - a.text_xs, - a.mt_xs, - a.mb_lg, - style, - ]}> - {timeElapsed} - </Text> - )} - </TimeElapsed> - ) -} 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 index 5fedf062a..1dc26d6c3 100644 --- a/src/screens/Messages/Conversation/MessagesList.tsx +++ b/src/screens/Messages/Conversation/MessagesList.tsx @@ -1,20 +1,26 @@ import React, {useCallback, useRef} from 'react' +import {FlatList, View} from 'react-native' import { - FlatList, - NativeScrollEvent, - NativeSyntheticEvent, - View, -} from 'react-native' -import {KeyboardAvoidingView} from 'react-native-keyboard-controller' + 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 {MessageItem} from '#/screens/Messages/Conversation/MessageItem' +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' @@ -53,11 +59,19 @@ function RetryButton({onPress}: {onPress: () => unknown}) { function renderItem({item}: {item: ConvoItem}) { if (item.type === 'message' || item.type === 'pending-message') { - return <MessageItem item={item.message} next={item.nextMessage} /> + 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 @@ -67,100 +81,178 @@ function keyExtractor(item: ConvoItem) { return item.key } -function onScrollToEndFailed() { +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 use this to know if we should scroll after a new clop is added to the list - const isAtBottom = useRef(false) - const currentOffset = React.useRef(0) - const onContentSizeChange = useCallback(() => { - if (currentOffset.current <= 100) { - flatListRef.current?.scrollToOffset({offset: 0, animated: true}) - } - }, []) + // 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) - const onEndReached = useCallback(() => { - chat.service.fetchMessageHistory() - }, [chat]) + // This will be used on web to assist in determing if we need to maintain the content offset + const isAtTop = useSharedValue(true) - const onInputFocus = useCallback(() => { - if (!isAtBottom.current) { - flatListRef.current?.scrollToOffset({offset: 0, animated: 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, + ], + ) - const onInputBlur = useCallback(() => {}, []) + // 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) => { - chat.service.sendMessage({ - text, - }) + if (chat.status === ConvoStatus.Ready) { + chat.sendMessage({ + text, + }) + } }, - [chat.service], + [chat], ) const onScroll = React.useCallback( - (e: NativeSyntheticEvent<NativeScrollEvent>) => { - currentOffset.current = e.nativeEvent.contentOffset.y + (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={{flex: 1, marginBottom: isWeb ? 20 : 85}} + style={[a.flex_1, {marginBottom: bottomInset + bottomBarHeight}]} + keyboardVerticalOffset={isIOS ? topInset : 0} behavior="padding" - keyboardVerticalOffset={70} - contentContainerStyle={{flex: 1}}> - <FlatList - data={ - chat.state.status === ConvoStatus.Ready ? chat.state.items : undefined - } - keyExtractor={keyExtractor} - renderItem={renderItem} - contentContainerStyle={{paddingHorizontal: 10}} - // In the future, we might want to adjust this value. Not very concerning right now as long as we are only - // dealing with text. But whenever we have images or other media and things are taller, we will want to lower - // this...probably. - initialNumToRender={20} - // Same with the max to render per batch. Let's be safe for now though. - maxToRenderPerBatch={25} - inverted={true} - onEndReached={onEndReached} - onScrollToIndexFailed={onScrollToEndFailed} - onContentSizeChange={onContentSizeChange} - onScroll={onScroll} - // We don't really need to call this much since there are not any animations that rely on this - scrollEventThrottle={100} - maintainVisibleContentPosition={{ - minIndexForVisible: 1, - }} - ListFooterComponent={ - <MaybeLoader - isLoading={ - chat.state.status === ConvoStatus.Ready && - chat.state.isFetchingHistory - } - /> - } - removeClippedSubviews={true} - ref={flatListRef} - keyboardDismissMode="none" - /> - - <View style={{paddingHorizontal: 10}}> - <MessageInput - onSendMessage={onSendMessage} - onFocus={onInputFocus} - onBlur={onInputBlur} + 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} /> + } /> - </View> + </ScrollProvider> + <MessageInput onSendMessage={onSendMessage} scrollToEnd={scrollToEnd} /> </KeyboardAvoidingView> ) } diff --git a/src/screens/Messages/Conversation/index.tsx b/src/screens/Messages/Conversation/index.tsx index f5663fdcb..11044c213 100644 --- a/src/screens/Messages/Conversation/index.tsx +++ b/src/screens/Messages/Conversation/index.tsx @@ -1,9 +1,9 @@ import React, {useCallback} from 'react' import {TouchableOpacity, View} from 'react-native' +import {KeyboardProvider} from 'react-native-keyboard-controller' import {AppBskyActorDefs} from '@atproto/api' -import {ChatBskyConvoDefs} from '@atproto-labs/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {msg, Trans} from '@lingui/macro' +import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' import {NativeStackScreenProps} from '@react-navigation/native-stack' @@ -14,7 +14,6 @@ import {BACK_HITSLOP} from 'lib/constants' import {isWeb} from 'platform/detection' import {ChatProvider, useChat} from 'state/messages' import {ConvoStatus} from 'state/messages/convo' -import {useSession} from 'state/session' import {PreviewableUserAvatar} from 'view/com/util/UserAvatar' import {CenteredView} from 'view/com/util/Views' import {MessagesList} from '#/screens/Messages/Conversation/MessagesList' @@ -43,29 +42,30 @@ export function MessagesConversationScreen({route}: Props) { function Inner() { const chat = useChat() - const {currentAccount} = useSession() - const myDid = currentAccount?.did - const otherProfile = React.useMemo(() => { - if (chat.state.status !== ConvoStatus.Ready) return - return chat.state.convo.members.find(m => m.did !== myDid) - }, [chat.state, myDid]) + if ( + chat.status === ConvoStatus.Uninitialized || + chat.status === ConvoStatus.Initializing + ) { + return <ListMaybePlaceholder isLoading /> + } - // TODO whenever we have error messages, we should use them in here -hailey - if (chat.state.status !== ConvoStatus.Ready || !otherProfile) { - return ( - <ListMaybePlaceholder - isLoading={true} - isError={chat.state.status === ConvoStatus.Error} - /> - ) + if (chat.status === ConvoStatus.Error) { + // TODO error + return null } + /* + * Any other chat states (atm) are "ready" states + */ + return ( - <CenteredView style={{flex: 1}} sideBorders> - <Header profile={otherProfile} /> - <MessagesList /> - </CenteredView> + <KeyboardProvider> + <CenteredView style={{flex: 1}} sideBorders> + <Header profile={chat.recipients[0]} /> + <MessagesList /> + </CenteredView> + </KeyboardProvider> ) } @@ -78,22 +78,19 @@ let Header = ({ const {_} = useLingui() const {gtTablet} = useBreakpoints() const navigation = useNavigation<NavigationProp>() - const {service} = useChat() + const chat = useChat() const onPressBack = useCallback(() => { if (isWeb) { - navigation.replace('MessagesList') + navigation.replace('Messages') } else { navigation.pop() } }, [navigation]) - const onUpdateConvo = useCallback( - (convo: ChatBskyConvoDefs.ConvoView) => { - service.convo = convo - }, - [service], - ) + const onUpdateConvo = useCallback(() => { + // TODO eric update muted state + }, []) return ( <View @@ -129,15 +126,15 @@ let Header = ({ ) : ( <View style={{width: 30}} /> )} - <View style={[a.align_center, a.gap_sm]}> + <View style={[a.align_center, a.gap_sm, a.flex_1]}> <PreviewableUserAvatar size={32} profile={profile} /> - <Text style={[a.text_lg, a.font_bold]}> - <Trans>{profile.displayName}</Trans> + <Text style={[a.text_lg, a.font_bold, a.text_center]}> + {profile.displayName} </Text> </View> - {service.convo ? ( + {chat.status === ConvoStatus.Ready ? ( <ConvoMenu - convo={service.convo} + convo={chat.convo} profile={profile} onUpdateConvo={onUpdateConvo} currentScreen="conversation" diff --git a/src/screens/Messages/List/index.tsx b/src/screens/Messages/List/index.tsx index 497b23898..ce8f52af9 100644 --- a/src/screens/Messages/List/index.tsx +++ b/src/screens/Messages/List/index.tsx @@ -6,6 +6,7 @@ import {ChatBskyConvoDefs} from '@atproto-labs/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {NativeStackScreenProps} from '@react-navigation/native-stack' +import {sha256} from 'js-sha256' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {MessagesTabNavigatorParams} from '#/lib/routes/types' @@ -20,11 +21,14 @@ import {TimeElapsed} from '#/view/com/util/TimeElapsed' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {ViewHeader} from '#/view/com/util/ViewHeader' import {CenteredView} from '#/view/com/util/Views' -import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {ScrollView} from '#/view/com/util/Views' +import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {DialogControlProps, useDialogControl} from '#/components/Dialog' import {ConvoMenu} from '#/components/dms/ConvoMenu' import {NewChat} from '#/components/dms/NewChat' +import * as TextField from '#/components/forms/TextField' +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' import {Link} from '#/components/Link' @@ -32,14 +36,25 @@ import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' import {useMenuControl} from '#/components/Menu' import {Text} from '#/components/Typography' import {ClipClopGate} from '../gate' +import {useDmServiceUrlStorage} from '../Temp/useDmServiceUrlStorage' -type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'MessagesList'> -export function MessagesListScreen({navigation}: Props) { +type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'> +export function MessagesScreen({navigation}: Props) { const {_} = useLingui() const t = useTheme() const newChatControl = useDialogControl() const {gtMobile} = useBreakpoints() + // TEMP + const {serviceUrl, setServiceUrl} = useDmServiceUrlStorage() + const hasValidServiceUrl = useMemo(() => { + const hash = sha256(serviceUrl) + return ( + hash === + 'a32318b49dd3fe6aa6a35c66c13fcc4c1cb6202b24f5a852d9a2279acee4169f' + ) + }, [serviceUrl]) + const renderButton = useCallback(() => { return ( <Link @@ -62,7 +77,9 @@ export function MessagesListScreen({navigation}: Props) { fetchNextPage, error, refetch, - } = useListConvos() + } = useListConvos({refetchInterval: 15_000}) + + useRefreshOnFocus(refetch) const isError = !!error @@ -112,6 +129,25 @@ export function MessagesListScreen({navigation}: Props) { const gate = useGate() if (!gate('dms')) return <ClipClopGate /> + if (!hasValidServiceUrl) { + return ( + <ScrollView contentContainerStyle={a.p_lg}> + <View> + <TextField.LabelText>Service URL</TextField.LabelText> + <TextField.Root> + <TextField.Input + value={serviceUrl} + onChangeText={text => setServiceUrl(text)} + autoCapitalize="none" + keyboardType="url" + label="https://" + /> + </TextField.Root> + </View> + </ScrollView> + ) + } + if (conversations.length < 1) { return ( <View style={a.flex_1}> @@ -237,7 +273,9 @@ function ChatListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) { <PreviewableUserAvatar profile={otherUser} size={42} /> </View> <View style={[a.flex_1]}> - <Text numberOfLines={1} style={[a.text_md, a.leading_normal]}> + <Text + numberOfLines={1} + style={[a.text_md, web([a.leading_normal, {marginTop: -4}])]}> <Text style={[t.atoms.text, convo.unreadCount > 0 && a.font_bold]}> {otherUser.displayName || otherUser.handle} |