about summary refs log tree commit diff
path: root/src/screens/Messages/Conversation/MessagesList.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens/Messages/Conversation/MessagesList.tsx')
-rw-r--r--src/screens/Messages/Conversation/MessagesList.tsx258
1 files changed, 258 insertions, 0 deletions
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>
+  )
+}