about summary refs log tree commit diff
path: root/src/screens/Messages/Conversation
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens/Messages/Conversation')
-rw-r--r--src/screens/Messages/Conversation/MessageInput.tsx111
-rw-r--r--src/screens/Messages/Conversation/MessageInput.web.tsx94
-rw-r--r--src/screens/Messages/Conversation/MessageListError.tsx60
-rw-r--r--src/screens/Messages/Conversation/MessagesList.tsx258
-rw-r--r--src/screens/Messages/Conversation/index.tsx142
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)