diff options
Diffstat (limited to 'src/screens/Messages/components/MessagesList.tsx')
-rw-r--r-- | src/screens/Messages/components/MessagesList.tsx | 454 |
1 files changed, 454 insertions, 0 deletions
diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx new file mode 100644 index 000000000..b659e98d6 --- /dev/null +++ b/src/screens/Messages/components/MessagesList.tsx @@ -0,0 +1,454 @@ +import React, {useCallback, useRef} from 'react' +import {FlatList, LayoutChangeEvent, View} from 'react-native' +import { + KeyboardStickyView, + useKeyboardHandler, +} from 'react-native-keyboard-controller' +import { + runOnJS, + scrollTo, + useAnimatedRef, + useAnimatedStyle, + 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 {AppBskyEmbedRecord, AppBskyRichtextFacet, RichText} from '@atproto/api' + +import {clamp} from '#/lib/numbers' +import {ScrollProvider} from '#/lib/ScrollContext' +import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' +import { + convertBskyAppUrlIfNeeded, + isBskyPostUrl, +} from '#/lib/strings/url-helpers' +import {logger} from '#/logger' +import {isNative} from '#/platform/detection' +import {isWeb} from '#/platform/detection' +import {isConvoActive, useConvoActive} from '#/state/messages/convo' +import {ConvoItem, ConvoStatus} from '#/state/messages/convo/types' +import {useGetPost} from '#/state/queries/post' +import {useAgent} from '#/state/session' +import { + EmojiPicker, + EmojiPickerState, +} from '#/view/com/composer/text-input/web/EmojiPicker.web' +import {List} from '#/view/com/util/List' +import {ChatDisabled} from '#/screens/Messages/components/ChatDisabled' +import {MessageInput} from '#/screens/Messages/components/MessageInput' +import {MessageListError} from '#/screens/Messages/components/MessageListError' +import {ChatEmptyPill} from '#/components/dms/ChatEmptyPill' +import {MessageItem} from '#/components/dms/MessageItem' +import {NewMessagesPill} from '#/components/dms/NewMessagesPill' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' +import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed' + +function MaybeLoader({isLoading}: {isLoading: boolean}) { + return ( + <View + style={{ + height: 50, + width: '100%', + alignItems: 'center', + justifyContent: 'center', + }}> + {isLoading && <Loader size="xl" />} + </View> + ) +} + +function renderItem({item}: {item: ConvoItem}) { + if (item.type === 'message' || item.type === 'pending-message') { + return <MessageItem item={item} /> + } else if (item.type === 'deleted-message') { + return <Text>Deleted message</Text> + } else if (item.type === 'error') { + 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({ + hasScrolled, + setHasScrolled, + blocked, + footer, +}: { + hasScrolled: boolean + setHasScrolled: React.Dispatch<React.SetStateAction<boolean>> + blocked?: boolean + footer?: React.ReactNode +}) { + const convoState = useConvoActive() + const agent = useAgent() + const getPost = useGetPost() + const {embedUri, setEmbed} = useMessageEmbed() + + const flatListRef = useAnimatedRef<FlatList>() + + const [newMessagesPill, setNewMessagesPill] = React.useState({ + show: false, + startContentOffset: 0, + }) + + const [emojiPickerState, setEmojiPickerState] = + React.useState<EmojiPickerState>({ + isOpen: false, + pos: {top: 0, left: 0, right: 0, bottom: 0}, + }) + + // 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 determining 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 prevContentHeight = useRef(0) + const prevItemCount = useRef(0) + + // -- Keep track of background state and positioning for new pill + const layoutHeight = useSharedValue(0) + const didBackground = React.useRef(false) + React.useEffect(() => { + if (convoState.status === ConvoStatus.Backgrounded) { + didBackground.current = true + } + }, [convoState.status]) + + // -- Scroll handling + + // 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 off whenever we add new content to the previous offset whenever we add new content to the list. + if (isWeb && isAtTop.value && hasScrolled) { + flatListRef.current?.scrollToOffset({ + offset: height - prevContentHeight.current, + animated: false, + }) + } + + // This number _must_ be the height of the MaybeLoader component + if (height > 50 && isAtBottom.value) { + // If the size of the content is changing by more than the height of the screen, then we don't + // want to scroll further than the start of all the new content. Since we are storing the previous offset, + // we can just scroll the user to that offset and add a little bit of padding. We'll also show the pill + // that can be pressed to immediately scroll to the end. + if ( + didBackground.current && + hasScrolled && + height - prevContentHeight.current > layoutHeight.value - 50 && + convoState.items.length - prevItemCount.current > 1 + ) { + flatListRef.current?.scrollToOffset({ + offset: prevContentHeight.current - 65, + animated: true, + }) + setNewMessagesPill({ + show: true, + startContentOffset: prevContentHeight.current - 65, + }) + } else { + flatListRef.current?.scrollToOffset({ + offset: height, + animated: hasScrolled && height > prevContentHeight.current, + }) + + // HACK Unfortunately, we need to call `setHasScrolled` after a brief delay, + // because otherwise there is too much of a delay between the time the content + // scrolls and the time the screen appears, causing a flicker. + // We cannot actually use a synchronous scroll here, because `onContentSizeChange` + // is actually async itself - all the info has to come across the bridge first. + if (!hasScrolled && !convoState.isFetchingHistory) { + setTimeout(() => { + setHasScrolled(true) + }, 100) + } + } + } + + prevContentHeight.current = height + prevItemCount.current = convoState.items.length + didBackground.current = false + }, + [ + hasScrolled, + setHasScrolled, + convoState.isFetchingHistory, + convoState.items.length, + // these are stable + flatListRef, + isAtTop.value, + isAtBottom.value, + layoutHeight.value, + ], + ) + + const onStartReached = useCallback(() => { + if (hasScrolled && prevContentHeight.current > layoutHeight.value) { + convoState.fetchMessageHistory() + } + }, [convoState, hasScrolled, layoutHeight.value]) + + const onScroll = React.useCallback( + (e: ReanimatedScrollEvent) => { + 'worklet' + layoutHeight.value = e.layoutMeasurement.height + 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 + + if ( + newMessagesPill.show && + (e.contentOffset.y > newMessagesPill.startContentOffset + 200 || + isAtBottom.value) + ) { + runOnJS(setNewMessagesPill)({ + show: false, + startContentOffset: 0, + }) + } + }, + [layoutHeight, newMessagesPill, isAtBottom, isAtTop], + ) + + // -- Keyboard animation handling + const {bottom: bottomInset} = useSafeAreaInsets() + const bottomOffset = isWeb ? 0 : clamp(60 + bottomInset, 60, 75) + + const keyboardHeight = useSharedValue(0) + const keyboardIsOpening = useSharedValue(false) + + // In some cases - like when the emoji piker opens - we don't want to animate the scroll in the list onLayout event. + // We use this value to keep track of when we want to disable the animation. + const layoutScrollWithoutAnimation = useSharedValue(false) + + useKeyboardHandler({ + onStart: e => { + 'worklet' + // Immediate updates - like opening the emoji picker - will have a duration of zero. In those cases, we should + // just update the height here instead of having the `onMove` event do it (that event will not fire!) + if (e.duration === 0) { + layoutScrollWithoutAnimation.value = true + keyboardHeight.value = e.height + } else { + keyboardIsOpening.value = true + } + }, + onMove: e => { + 'worklet' + keyboardHeight.value = e.height + if (e.height > bottomOffset) { + scrollTo(flatListRef, 0, 1e7, false) + } + }, + onEnd: () => { + 'worklet' + keyboardIsOpening.value = false + }, + }) + + const animatedListStyle = useAnimatedStyle(() => ({ + marginBottom: + keyboardHeight.value > bottomOffset ? keyboardHeight.value : bottomOffset, + })) + + // -- Message sending + const onSendMessage = useCallback( + async (text: string) => { + let rt = new RichText({text: text.trimEnd()}, {cleanNewlines: true}) + + // detect facets without resolution first - this is used to see if there's + // any post links in the text that we can embed. We do this first because + // we want to remove the post link from the text, re-trim, then detect facets + rt.detectFacetsWithoutResolution() + + let embed: AppBskyEmbedRecord.Main | undefined + + if (embedUri) { + try { + const post = await getPost({uri: embedUri}) + if (post) { + embed = { + $type: 'app.bsky.embed.record', + record: { + uri: post.uri, + cid: post.cid, + }, + } + + // look for the embed uri in the facets, so we can remove it from the text + const postLinkFacet = rt.facets?.find(facet => { + return facet.features.find(feature => { + if (AppBskyRichtextFacet.isLink(feature)) { + if (isBskyPostUrl(feature.uri)) { + const url = convertBskyAppUrlIfNeeded(feature.uri) + const [_0, _1, _2, rkey] = url.split('/').filter(Boolean) + + // this might have a handle instead of a DID + // so just compare the rkey - not particularly dangerous + return post.uri.endsWith(rkey) + } + } + return false + }) + }) + + if (postLinkFacet) { + const isAtStart = postLinkFacet.index.byteStart === 0 + const isAtEnd = + postLinkFacet.index.byteEnd === rt.unicodeText.graphemeLength + + // remove the post link from the text + if (isAtStart || isAtEnd) { + rt.delete( + postLinkFacet.index.byteStart, + postLinkFacet.index.byteEnd, + ) + } + + rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true}) + } + } + } catch (error) { + logger.error('Failed to get post as quote for DM', {error}) + } + } + + await rt.detectFacets(agent) + + rt = shortenLinks(rt) + rt = stripInvalidMentions(rt) + + if (!hasScrolled) { + setHasScrolled(true) + } + + convoState.sendMessage({ + text: rt.text, + facets: rt.facets, + embed, + }) + }, + [agent, convoState, embedUri, getPost, hasScrolled, setHasScrolled], + ) + + // -- List layout changes (opening emoji keyboard, etc.) + const onListLayout = React.useCallback( + (e: LayoutChangeEvent) => { + layoutHeight.value = e.nativeEvent.layout.height + + if (isWeb || !keyboardIsOpening.value) { + flatListRef.current?.scrollToEnd({ + animated: !layoutScrollWithoutAnimation.value, + }) + layoutScrollWithoutAnimation.value = false + } + }, + [ + flatListRef, + keyboardIsOpening.value, + layoutScrollWithoutAnimation, + layoutHeight, + ], + ) + + const scrollToEndOnPress = React.useCallback(() => { + flatListRef.current?.scrollToOffset({ + offset: prevContentHeight.current, + animated: true, + }) + }, [flatListRef]) + + return ( + <> + {/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */} + <ScrollProvider onScroll={onScroll}> + <List + ref={flatListRef} + data={convoState.items} + renderItem={renderItem} + keyExtractor={keyExtractor} + disableFullWindowScroll={true} + disableVirtualization={true} + style={animatedListStyle} + // The extra two items account for the header and the footer components + initialNumToRender={isNative ? 32 : 62} + maxToRenderPerBatch={isWeb ? 32 : 62} + keyboardDismissMode="on-drag" + keyboardShouldPersistTaps="handled" + maintainVisibleContentPosition={{ + minIndexForVisible: 0, + }} + removeClippedSubviews={false} + sideBorders={false} + onContentSizeChange={onContentSizeChange} + onLayout={onListLayout} + onStartReached={onStartReached} + onScrollToIndexFailed={onScrollToIndexFailed} + scrollEventThrottle={100} + ListHeaderComponent={ + <MaybeLoader isLoading={convoState.isFetchingHistory} /> + } + /> + </ScrollProvider> + <KeyboardStickyView offset={{closed: -bottomOffset, opened: 0}}> + {convoState.status === ConvoStatus.Disabled ? ( + <ChatDisabled /> + ) : blocked ? ( + footer + ) : ( + <> + {isConvoActive(convoState) && + !convoState.isFetchingHistory && + convoState.items.length === 0 && <ChatEmptyPill />} + <MessageInput + onSendMessage={onSendMessage} + hasEmbed={!!embedUri} + setEmbed={setEmbed} + openEmojiPicker={pos => setEmojiPickerState({isOpen: true, pos})}> + <MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} /> + </MessageInput> + </> + )} + </KeyboardStickyView> + + {isWeb && ( + <EmojiPicker + pinToTop + state={emojiPickerState} + close={() => setEmojiPickerState(prev => ({...prev, isOpen: false}))} + /> + )} + + {newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />} + </> + ) +} |