diff --git a/src/components/dms/MessagesListHeader.tsx b/src/components/dms/MessagesListHeader.tsx index a6dff40326..1e6fd36092 100644 --- a/src/components/dms/MessagesListHeader.tsx +++ b/src/components/dms/MessagesListHeader.tsx @@ -1,5 +1,5 @@ import React, {useCallback} from 'react' -import {TouchableOpacity, View} from 'react-native' +import {Keyboard, TouchableOpacity, View} from 'react-native' import { AppBskyActorDefs, ModerationCause, @@ -46,6 +46,7 @@ export let MessagesListHeader = ({ if (isWeb) { navigation.replace('Messages', {}) } else { + Keyboard.dismiss() navigation.goBack() } }, [navigation]) diff --git a/src/lib/hooks/useNotificationHandler.ts b/src/lib/hooks/useNotificationHandler.ts index f3c4da7f63..347062bebe 100644 --- a/src/lib/hooks/useNotificationHandler.ts +++ b/src/lib/hooks/useNotificationHandler.ts @@ -59,9 +59,13 @@ export function useNotificationsHandler() { const {setShowLoggedOut} = useLoggedOutViewControls() const closeAllActiveElements = useCloseAllActiveElements() + // On Android, we cannot control which sound is used for a notification on Android + // 28 or higher. Instead, we have to configure a notification channel ahead of time + // which has the sounds we want in the configuration for that channel. These two + // channels allow for the mute/unmute functionality we want for the background + // handler. React.useEffect(() => { if (!isAndroid) return - Notifications.setNotificationChannelAsync('chat-messages', { name: 'Chat', importance: Notifications.AndroidImportance.MAX, @@ -99,9 +103,27 @@ export function useNotificationsHandler() { } else { navigation.dispatch(state => { if (state.routes[0].name === 'Messages') { - return CommonActions.navigate('MessagesConversation', { - conversation: payload.convoId, - }) + if ( + state.routes[state.routes.length - 1].name === + 'MessagesConversation' + ) { + return CommonActions.reset({ + ...state, + routes: [ + ...state.routes.slice(0, state.routes.length - 1), + { + name: 'MessagesConversation', + params: { + conversation: payload.convoId, + }, + }, + ], + }) + } else { + return CommonActions.navigate('MessagesConversation', { + conversation: payload.convoId, + }) + } } else { return CommonActions.navigate('MessagesTab', { screen: 'Messages', diff --git a/src/screens/Messages/Conversation/MessageInput.tsx b/src/screens/Messages/Conversation/MessageInput.tsx index 48c3aeb37c..9deecfd49e 100644 --- a/src/screens/Messages/Conversation/MessageInput.tsx +++ b/src/screens/Messages/Conversation/MessageInput.tsx @@ -27,10 +27,8 @@ import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/ico export function MessageInput({ onSendMessage, - scrollToEnd, }: { onSendMessage: (message: string) => void - scrollToEnd: () => void }) { const {_} = useLingui() const t = useTheme() @@ -75,14 +73,12 @@ export function MessageInput({ setMaxHeight(max) setIsInputScrollable(availableSpace < 30) - - scrollToEnd() }, - [scrollToEnd, topInset], + [topInset], ) return ( - + void - scrollToEnd: () => void }) { const {_} = useLingui() const t = useTheme() diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx index 1f9147c576..b0723c0201 100644 --- a/src/screens/Messages/Conversation/MessagesList.tsx +++ b/src/screens/Messages/Conversation/MessagesList.tsx @@ -92,15 +92,14 @@ export function MessagesList({ // 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) + const prevContentHeight = useRef(0) const prevItemCount = useRef(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 keyboardIsAnimating = useSharedValue(false) + const isDragging = useSharedValue(false) const layoutHeight = useSharedValue(0) + // -- 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 @@ -117,85 +116,78 @@ export function MessagesList({ // 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 - contentHeight.value, + offset: height - prevContentHeight.current, animated: false, }) } // This number _must_ be the height of the MaybeLoader component - if (height > 50 && isAtBottom.value && !keyboardIsAnimating.value) { - let newOffset = height + if (height > 50 && isAtBottom.value) { // If the size of the content is changing by more than the height of the screen, then we should only // scroll 1 screen down, and let the user scroll the rest. However, because a single message could be // really large - and the normal chat behavior would be to still scroll to the end if it's only one // message - we ignore this rule if there's only one additional message if ( hasScrolled && - height - contentHeight.value > layoutHeight.value - 50 && + height - prevContentHeight.current > layoutHeight.value - 50 && convoState.items.length - prevItemCount.current > 1 ) { - newOffset = contentHeight.value - 50 + flatListRef.current?.scrollToOffset({ + offset: height - layoutHeight.value + 50, + animated: hasScrolled, + }) setShowNewMessagesPill(true) - } else if (!hasScrolled && !convoState.isFetchingHistory) { - setHasScrolled(true) - } + } else { + flatListRef.current?.scrollToOffset({ + offset: height, + animated: hasScrolled, + }) - flatListRef.current?.scrollToOffset({ - offset: newOffset, - animated: hasScrolled, - }) - isMomentumScrolling.value = true + // 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) + } + } } - contentHeight.value = height + + prevContentHeight.current = height prevItemCount.current = convoState.items.length }, [ hasScrolled, - convoState.items.length, - convoState.isFetchingHistory, setHasScrolled, - // all of these are stable - contentHeight, + convoState.isFetchingHistory, + convoState.items.length, + // these are stable flatListRef, - isAtBottom.value, isAtTop.value, - isMomentumScrolling, - keyboardIsAnimating.value, + isAtBottom.value, layoutHeight.value, ], ) + const onBeginDrag = React.useCallback(() => { + 'worklet' + isDragging.value = true + }, [isDragging]) + + const onEndDrag = React.useCallback(() => { + 'worklet' + isDragging.value = false + }, [isDragging]) + const onStartReached = useCallback(() => { if (hasScrolled) { convoState.fetchMessageHistory() } }, [convoState, hasScrolled]) - const onSendMessage = useCallback( - async (text: string) => { - let rt = new RichText({text}, {cleanNewlines: true}) - await rt.detectFacets(getAgent()) - rt = shortenLinks(rt) - - // filter out any mention facets that didn't map to a user - rt.facets = rt.facets?.filter(facet => { - const mention = facet.features.find(feature => - AppBskyRichtextFacet.isMention(feature), - ) - if (mention && !mention.did) { - return false - } - return true - }) - - convoState.sendMessage({ - text: rt.text, - facets: rt.facets, - }) - }, - [convoState, getAgent], - ) - const onScroll = React.useCallback( (e: ReanimatedScrollEvent) => { 'worklet' @@ -218,22 +210,12 @@ export function MessagesList({ [layoutHeight, showNewMessagesPill, isAtBottom, isAtTop], ) - // This tells us when we are no longer scrolling - const onMomentumEnd = React.useCallback(() => { - 'worklet' - isMomentumScrolling.value = false - }, [isMomentumScrolling]) - - const scrollToEndNow = React.useCallback(() => { - if (isMomentumScrolling.value) return - flatListRef.current?.scrollToEnd({animated: false}) - }, [flatListRef, isMomentumScrolling.value]) - // -- Keyboard animation handling const animatedKeyboard = useAnimatedKeyboard() const {bottom: bottomInset} = useSafeAreaInsets() const nativeBottomBarHeight = isIOS ? 42 : 60 const bottomOffset = isWeb ? 0 : bottomInset + nativeBottomBarHeight + const finalKeyboardHeight = useSharedValue(0) // On web, we don't want to do anything. // On native, we want to scroll the list to the bottom every frame that the keyboard is opening. `scrollTo` runs @@ -244,16 +226,24 @@ export function MessagesList({ 'worklet' // This never applies on web if (isWeb) { - keyboardIsAnimating.value = false return } - // We only need to scroll to end while the keyboard is _opening_. During close, the position changes as we - // "expand" the view. - if (prev && now > prev) { - scrollTo(flatListRef, 0, contentHeight.value + now, false) + // We are setting some arbitrarily high number here to ensure that we end up scrolling to the bottom. There is not + // any other way to synchronously scroll to the bottom of the list, since we cannot get the content size of the + // scrollview synchronously. + // On iOS we could have used `dispatchCommand('scrollToEnd', [])` since the underlying view has a `scrollToEnd` + // method. It doesn't exist on Android though. That's probably why `scrollTo` which is implemented in Reanimated + // doesn't support a `scrollToEnd`. + if (prev && now > 0 && now >= prev) { + scrollTo(flatListRef, 0, 1e7, false) + } + + // We want to store the full keyboard height after it fully opens so we can make some + // assumptions in onLayout + if (finalKeyboardHeight.value === 0 && prev && now > 0 && now === prev) { + finalKeyboardHeight.value = now } - keyboardIsAnimating.value = Boolean(prev) && now !== prev }, ) @@ -266,10 +256,62 @@ export function MessagesList({ : bottomOffset, })) + // -- Message sending + const onSendMessage = useCallback( + async (text: string) => { + let rt = new RichText({text}, {cleanNewlines: true}) + await rt.detectFacets(getAgent()) + rt = shortenLinks(rt) + + // filter out any mention facets that didn't map to a user + rt.facets = rt.facets?.filter(facet => { + const mention = facet.features.find(feature => + AppBskyRichtextFacet.isMention(feature), + ) + if (mention && !mention.did) { + return false + } + return true + }) + + convoState.sendMessage({ + text: rt.text, + facets: rt.facets, + }) + }, + [convoState, getAgent], + ) + + // Any time the List layout changes, we want to scroll to the bottom. This only happens whenever + // the _lists_ size changes, _not_ the content size which is handled by `onContentSizeChange`. + // This accounts for things like the emoji keyboard opening, changes in block state, etc. + const onListLayout = React.useCallback(() => { + if (isDragging.value) return + + const kh = animatedKeyboard.height.value + const fkh = finalKeyboardHeight.value + + // We only run the layout scroll if: + // - We're on web + // - The keyboard is not open. This accounts for changing block states + // - The final keyboard height has been initially set and the keyboard height is greater than that + if (isWeb || kh === 0 || (fkh > 0 && kh >= fkh)) { + flatListRef.current?.scrollToEnd({animated: true}) + } + }, [ + flatListRef, + finalKeyboardHeight.value, + animatedKeyboard.height.value, + isDragging.value, + ]) + return ( {/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */} - + ) : ( - + )} ) : ( diff --git a/src/screens/Messages/Conversation/index.tsx b/src/screens/Messages/Conversation/index.tsx index 0fe4138bbe..8e806ff7a7 100644 --- a/src/screens/Messages/Conversation/index.tsx +++ b/src/screens/Messages/Conversation/index.tsx @@ -43,6 +43,8 @@ export function MessagesConversationScreen({route}: Props) { if (isWeb && !gtMobile) { setMinimalShellMode(true) + } else { + setMinimalShellMode(false) } return () => { diff --git a/src/screens/Messages/List/ChatListItem.tsx b/src/screens/Messages/List/ChatListItem.tsx index 791dc82c0d..9a9a78ba7f 100644 --- a/src/screens/Messages/List/ChatListItem.tsx +++ b/src/screens/Messages/List/ChatListItem.tsx @@ -25,12 +25,17 @@ import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/ import {useMenuControl} from '#/components/Menu' import {Text} from '#/components/Typography' -export function ChatListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) { +export let ChatListItem = ({ + convo, +}: { + convo: ChatBskyConvoDefs.ConvoView +}): React.ReactNode => { const {currentAccount} = useSession() + const moderationOpts = useModerationOpts() + const otherUser = convo.members.find( member => member.did !== currentAccount?.did, ) - const moderationOpts = useModerationOpts() if (!otherUser || !moderationOpts) { return null @@ -45,6 +50,8 @@ export function ChatListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) { ) } +ChatListItem = React.memo(ChatListItem) + function ChatListItemReady({ convo, profile: profileUnshadowed, diff --git a/src/screens/Messages/List/index.tsx b/src/screens/Messages/List/index.tsx index c198d44c45..6de0ca0b02 100644 --- a/src/screens/Messages/List/index.tsx +++ b/src/screens/Messages/List/index.tsx @@ -73,7 +73,7 @@ export function MessagesScreen({navigation, route}: Props) { ) }, [_, t]) - const initialNumToRender = useInitialNumToRender() + const initialNumToRender = useInitialNumToRender(80) const [isPTRing, setIsPTRing] = useState(false) const {