From 13c9c79aeec77edc33b1a926843b005c14acccc7 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 2 Oct 2024 22:21:59 +0300 Subject: move files around (#5576) --- src/Navigation.tsx | 2 +- src/screens/Messages/ChatList.tsx | 345 ++++++++++++++++ src/screens/Messages/Conversation.tsx | 205 ++++++++++ src/screens/Messages/Conversation/ChatDisabled.tsx | 150 ------- src/screens/Messages/Conversation/MessageInput.tsx | 180 -------- .../Messages/Conversation/MessageInput.web.tsx | 238 ----------- .../Messages/Conversation/MessageInputEmbed.tsx | 219 ---------- .../Messages/Conversation/MessageListError.tsx | 61 --- src/screens/Messages/Conversation/MessagesList.tsx | 454 --------------------- src/screens/Messages/Conversation/index.tsx | 205 ---------- src/screens/Messages/List/ChatListItem.tsx | 378 ----------------- src/screens/Messages/List/index.tsx | 345 ---------------- src/screens/Messages/components/ChatDisabled.tsx | 150 +++++++ src/screens/Messages/components/ChatListItem.tsx | 378 +++++++++++++++++ src/screens/Messages/components/MessageInput.tsx | 180 ++++++++ .../Messages/components/MessageInput.web.tsx | 238 +++++++++++ .../Messages/components/MessageInputEmbed.tsx | 219 ++++++++++ .../Messages/components/MessageListError.tsx | 61 +++ src/screens/Messages/components/MessagesList.tsx | 454 +++++++++++++++++++++ 19 files changed, 2231 insertions(+), 2231 deletions(-) create mode 100644 src/screens/Messages/ChatList.tsx create mode 100644 src/screens/Messages/Conversation.tsx delete mode 100644 src/screens/Messages/Conversation/ChatDisabled.tsx delete mode 100644 src/screens/Messages/Conversation/MessageInput.tsx delete mode 100644 src/screens/Messages/Conversation/MessageInput.web.tsx delete mode 100644 src/screens/Messages/Conversation/MessageInputEmbed.tsx delete mode 100644 src/screens/Messages/Conversation/MessageListError.tsx delete mode 100644 src/screens/Messages/Conversation/MessagesList.tsx delete mode 100644 src/screens/Messages/Conversation/index.tsx delete mode 100644 src/screens/Messages/List/ChatListItem.tsx delete mode 100644 src/screens/Messages/List/index.tsx create mode 100644 src/screens/Messages/components/ChatDisabled.tsx create mode 100644 src/screens/Messages/components/ChatListItem.tsx create mode 100644 src/screens/Messages/components/MessageInput.tsx create mode 100644 src/screens/Messages/components/MessageInput.web.tsx create mode 100644 src/screens/Messages/components/MessageInputEmbed.tsx create mode 100644 src/screens/Messages/components/MessageListError.tsx create mode 100644 src/screens/Messages/components/MessagesList.tsx (limited to 'src') diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 53e8274d5..323f668b7 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -78,8 +78,8 @@ import {BottomBar} from '#/view/shell/bottom-bar/BottomBar' import {createNativeStackNavigatorWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth' import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen' import HashtagScreen from '#/screens/Hashtag' +import {MessagesScreen} from '#/screens/Messages/ChatList' import {MessagesConversationScreen} from '#/screens/Messages/Conversation' -import {MessagesScreen} from '#/screens/Messages/List' import {MessagesSettingsScreen} from '#/screens/Messages/Settings' import {ModerationScreen} from '#/screens/Moderation' import {PostLikedByScreen} from '#/screens/Post/PostLikedBy' diff --git a/src/screens/Messages/ChatList.tsx b/src/screens/Messages/ChatList.tsx new file mode 100644 index 000000000..6cf561d11 --- /dev/null +++ b/src/screens/Messages/ChatList.tsx @@ -0,0 +1,345 @@ +import React, {useCallback, useEffect, useMemo, useState} from 'react' +import {View} from 'react-native' +import {ChatBskyConvoDefs} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useFocusEffect} from '@react-navigation/native' +import {NativeStackScreenProps} from '@react-navigation/native-stack' + +import {useAppState} from '#/lib/hooks/useAppState' +import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' +import {MessagesTabNavigatorParams} from '#/lib/routes/types' +import {cleanError} from '#/lib/strings/errors' +import {logger} from '#/logger' +import {isNative} from '#/platform/detection' +import {MESSAGE_SCREEN_POLL_INTERVAL} from '#/state/messages/convo/const' +import {useMessagesEventBus} from '#/state/messages/events' +import {useListConvosQuery} from '#/state/queries/messages/list-converations' +import {List} from '#/view/com/util/List' +import {ViewHeader} from '#/view/com/util/ViewHeader' +import {CenteredView} 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 {NewChat} from '#/components/dms/dialogs/NewChatDialog' +import {MessagesNUX} from '#/components/dms/MessagesNUX' +import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus' +import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotateCounterClockwise' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {Message_Stroke2_Corner0_Rounded as Message} from '#/components/icons/Message' +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' +import {ListFooter} from '#/components/Lists' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' +import {ChatListItem} from './components/ChatListItem' + +type Props = NativeStackScreenProps + +function renderItem({item}: {item: ChatBskyConvoDefs.ConvoView}) { + return +} + +function keyExtractor(item: ChatBskyConvoDefs.ConvoView) { + return item.id +} + +export function MessagesScreen({navigation, route}: Props) { + const {_} = useLingui() + const t = useTheme() + const newChatControl = useDialogControl() + const {gtMobile} = useBreakpoints() + const pushToConversation = route.params?.pushToConversation + + // Whenever we have `pushToConversation` set, it means we pressed a notification for a chat without being on + // this tab. We should immediately push to the conversation after pressing the notification. + // After we push, reset with `setParams` so that this effect will fire next time we press a notification, even if + // the conversation is the same as before + useEffect(() => { + if (pushToConversation) { + navigation.navigate('MessagesConversation', { + conversation: pushToConversation, + }) + navigation.setParams({pushToConversation: undefined}) + } + }, [navigation, pushToConversation]) + + // Request the poll interval to be 10s (or whatever the MESSAGE_SCREEN_POLL_INTERVAL is set to in the future) + // but only when the screen is active + const messagesBus = useMessagesEventBus() + const state = useAppState() + const isActive = state === 'active' + useFocusEffect( + useCallback(() => { + if (isActive) { + const unsub = messagesBus.requestPollInterval( + MESSAGE_SCREEN_POLL_INTERVAL, + ) + return () => unsub() + } + }, [messagesBus, isActive]), + ) + + const renderButton = useCallback(() => { + return ( + + + + ) + }, [_, t]) + + const initialNumToRender = useInitialNumToRender({minItemHeight: 80}) + const [isPTRing, setIsPTRing] = useState(false) + + const { + data, + isLoading, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + isError, + error, + refetch, + } = useListConvosQuery() + + useRefreshOnFocus(refetch) + + const conversations = useMemo(() => { + if (data?.pages) { + return data.pages.flatMap(page => page.convos) + } + return [] + }, [data]) + + const onRefresh = useCallback(async () => { + setIsPTRing(true) + try { + await refetch() + } catch (err) { + logger.error('Failed to refresh conversations', {message: err}) + } + setIsPTRing(false) + }, [refetch, setIsPTRing]) + + const onEndReached = useCallback(async () => { + if (isFetchingNextPage || !hasNextPage || isError) return + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more conversations', {message: err}) + } + }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) + + const onNewChat = useCallback( + (conversation: string) => + navigation.navigate('MessagesConversation', {conversation}), + [navigation], + ) + + const onNavigateToSettings = useCallback(() => { + navigation.navigate('MessagesSettings') + }, [navigation]) + + if (conversations.length < 1) { + return ( + + + + + {gtMobile ? ( + + ) : ( + + )} + + {isLoading ? ( + + + + ) : ( + <> + {isError ? ( + <> + + + + Whoops! + + + {cleanError(error)} + + + + + + ) : ( + <> + + + + Nothing here + + + You have no conversations yet. Start one! + + + + )} + + )} + + + {!isLoading && !isError && ( + + )} + + ) + } + + return ( + + + {!gtMobile && ( + + )} + + + } + ListFooterComponent={ + + } + onEndReachedThreshold={isNative ? 1.5 : 0} + initialNumToRender={initialNumToRender} + windowSize={11} + // @ts-ignore our .web version only -sfn + desktopFixedHeight + /> + + ) +} + +function DesktopHeader({ + newChatControl, + onNavigateToSettings, +}: { + newChatControl: DialogControlProps + onNavigateToSettings: () => void +}) { + const t = useTheme() + const {_} = useLingui() + const {gtMobile, gtTablet} = useBreakpoints() + + if (!gtMobile) { + return null + } + + return ( + + + Messages + + + + {gtTablet && ( + + )} + + + ) +} diff --git a/src/screens/Messages/Conversation.tsx b/src/screens/Messages/Conversation.tsx new file mode 100644 index 000000000..21fdfe0ea --- /dev/null +++ b/src/screens/Messages/Conversation.tsx @@ -0,0 +1,205 @@ +import React, {useCallback} from 'react' +import {View} from 'react-native' +import {useKeyboardController} from 'react-native-keyboard-controller' +import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useFocusEffect} from '@react-navigation/native' +import {NativeStackScreenProps} from '@react-navigation/native-stack' + +import {CommonNavigatorParams} from '#/lib/routes/types' +import {isWeb} from '#/platform/detection' +import {useProfileShadow} from '#/state/cache/profile-shadow' +import {ConvoProvider, isConvoActive, useConvo} from '#/state/messages/convo' +import {ConvoStatus} from '#/state/messages/convo/types' +import {useCurrentConvoId} from '#/state/messages/current-convo-id' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useProfileQuery} from '#/state/queries/profile' +import {useSetMinimalShellMode} from '#/state/shell' +import {CenteredView} from '#/view/com/util/Views' +import {MessagesList} from '#/screens/Messages/components/MessagesList' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {MessagesListBlockedFooter} from '#/components/dms/MessagesListBlockedFooter' +import {MessagesListHeader} from '#/components/dms/MessagesListHeader' +import {Error} from '#/components/Error' +import {Loader} from '#/components/Loader' + +type Props = NativeStackScreenProps< + CommonNavigatorParams, + 'MessagesConversation' +> +export function MessagesConversationScreen({route}: Props) { + const {gtMobile} = useBreakpoints() + const setMinimalShellMode = useSetMinimalShellMode() + + const convoId = route.params.conversation + const {setCurrentConvoId} = useCurrentConvoId() + + const {setEnabled} = useKeyboardController() + useFocusEffect( + useCallback(() => { + if (isWeb) return + setEnabled(true) + return () => { + setEnabled(false) + } + }, [setEnabled]), + ) + + useFocusEffect( + useCallback(() => { + setCurrentConvoId(convoId) + + if (isWeb && !gtMobile) { + setMinimalShellMode(true) + } else { + setMinimalShellMode(false) + } + + return () => { + setCurrentConvoId(undefined) + setMinimalShellMode(false) + } + }, [gtMobile, convoId, setCurrentConvoId, setMinimalShellMode]), + ) + + return ( + + + + ) +} + +function Inner() { + const t = useTheme() + const convoState = useConvo() + const {_} = useLingui() + + const moderationOpts = useModerationOpts() + const {data: recipient} = useProfileQuery({ + did: convoState.recipients?.[0].did, + }) + + // Because we want to give the list a chance to asynchronously scroll to the end before it is visible to the user, + // we use `hasScrolled` to determine when to render. With that said however, there is a chance that the chat will be + // empty. So, we also check for that possible state as well and render once we can. + const [hasScrolled, setHasScrolled] = React.useState(false) + const readyToShow = + hasScrolled || + (isConvoActive(convoState) && + !convoState.isFetchingHistory && + convoState.items.length === 0) + + // Any time that we re-render the `Initializing` state, we have to reset `hasScrolled` to false. After entering this + // state, we know that we're resetting the list of messages and need to re-scroll to the bottom when they get added. + React.useEffect(() => { + if (convoState.status === ConvoStatus.Initializing) { + setHasScrolled(false) + } + }, [convoState.status]) + + if (convoState.status === ConvoStatus.Error) { + return ( + + + convoState.error.retry()} + sideBorders={false} + /> + + ) + } + + return ( + + {!readyToShow && } + + {moderationOpts && recipient ? ( + + ) : ( + <> + + + )} + {!readyToShow && ( + + + + + + )} + + + ) +} + +function InnerReady({ + moderationOpts, + recipient: recipientUnshadowed, + hasScrolled, + setHasScrolled, +}: { + moderationOpts: ModerationOpts + recipient: AppBskyActorDefs.ProfileViewBasic + hasScrolled: boolean + setHasScrolled: React.Dispatch> +}) { + const convoState = useConvo() + const recipient = useProfileShadow(recipientUnshadowed) + + const moderation = React.useMemo(() => { + return moderateProfile(recipient, moderationOpts) + }, [recipient, moderationOpts]) + + const blockInfo = React.useMemo(() => { + const modui = moderation.ui('profileView') + const blocks = modui.alerts.filter(alert => alert.type === 'blocking') + const listBlocks = blocks.filter(alert => alert.source.type === 'list') + const userBlock = blocks.find(alert => alert.source.type === 'user') + return { + listBlocks, + userBlock, + } + }, [moderation]) + + return ( + <> + + {isConvoActive(convoState) && ( + 0} + blockInfo={blockInfo} + /> + } + /> + )} + + ) +} diff --git a/src/screens/Messages/Conversation/ChatDisabled.tsx b/src/screens/Messages/Conversation/ChatDisabled.tsx deleted file mode 100644 index c768d2504..000000000 --- a/src/screens/Messages/Conversation/ChatDisabled.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import React, {useCallback, useState} from 'react' -import {View} from 'react-native' -import {ComAtprotoModerationDefs} from '@atproto/api' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useMutation} from '@tanstack/react-query' - -import {logger} from '#/logger' -import {useAgent, useSession} from '#/state/session' -import * as Toast from '#/view/com/util/Toast' -import {atoms as a, useBreakpoints, useTheme} from '#/alf' -import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import * as Dialog from '#/components/Dialog' -import {Loader} from '#/components/Loader' -import {Text} from '#/components/Typography' - -export function ChatDisabled() { - const t = useTheme() - return ( - - - - Your chats have been disabled - - - - Our moderators have reviewed reports and decided to disable your - access to chats on Bluesky. - - - - - - ) -} - -function AppealDialog() { - const control = Dialog.useDialogControl() - const {_} = useLingui() - - return ( - <> - - - - - - - - ) -} - -function DialogInner() { - const {_} = useLingui() - const control = Dialog.useDialogContext() - const [details, setDetails] = useState('') - const {gtMobile} = useBreakpoints() - const agent = useAgent() - const {currentAccount} = useSession() - - const {mutate, isPending} = useMutation({ - mutationFn: async () => { - if (!currentAccount) - throw new Error('No current account, should be unreachable') - await agent.createModerationReport({ - reasonType: ComAtprotoModerationDefs.REASONAPPEAL, - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: currentAccount.did, - }, - reason: details, - }) - }, - onError: err => { - logger.error('Failed to submit chat appeal', {message: err}) - Toast.show(_(msg`Failed to submit appeal, please try again.`), 'xmark') - }, - onSuccess: () => { - control.close() - Toast.show(_(msg`Appeal submitted`)) - }, - }) - - const onSubmit = useCallback(() => mutate(), [mutate]) - const onBack = useCallback(() => control.close(), [control]) - - return ( - - - Appeal this decision - - - This appeal will be sent to Bluesky's moderation service. - - - - - - - - - - - - ) -} diff --git a/src/screens/Messages/Conversation/MessageInput.tsx b/src/screens/Messages/Conversation/MessageInput.tsx deleted file mode 100644 index 674edc41e..000000000 --- a/src/screens/Messages/Conversation/MessageInput.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import React from 'react' -import {Pressable, TextInput, useWindowDimensions, View} from 'react-native' -import { - useFocusedInputHandler, - useReanimatedKeyboardAnimation, -} from 'react-native-keyboard-controller' -import Animated, { - measure, - useAnimatedProps, - useAnimatedRef, - useAnimatedStyle, - useSharedValue, -} from 'react-native-reanimated' -import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import Graphemer from 'graphemer' - -import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants' -import {useHaptics} from '#/lib/haptics' -import { - useMessageDraft, - useSaveMessageDraft, -} from '#/state/messages/message-drafts' -import {isIOS} from 'platform/detection' -import {EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker.web' -import * as Toast from '#/view/com/util/Toast' -import {atoms as a, useTheme} from '#/alf' -import {useSharedInputStyles} from '#/components/forms/TextField' -import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane' -import {useExtractEmbedFromFacets} from './MessageInputEmbed' - -const AnimatedTextInput = Animated.createAnimatedComponent(TextInput) - -export function MessageInput({ - onSendMessage, - hasEmbed, - setEmbed, - children, -}: { - onSendMessage: (message: string) => void - hasEmbed: boolean - setEmbed: (embedUrl: string | undefined) => void - children?: React.ReactNode - openEmojiPicker?: (pos: EmojiPickerPosition) => void -}) { - const {_} = useLingui() - const t = useTheme() - const playHaptic = useHaptics() - const {getDraft, clearDraft} = useMessageDraft() - - // Input layout - const {top: topInset} = useSafeAreaInsets() - const {height: windowHeight} = useWindowDimensions() - const {height: keyboardHeight} = useReanimatedKeyboardAnimation() - const maxHeight = useSharedValue(undefined) - const isInputScrollable = useSharedValue(false) - - const inputStyles = useSharedInputStyles() - const [isFocused, setIsFocused] = React.useState(false) - const [message, setMessage] = React.useState(getDraft) - const inputRef = useAnimatedRef() - - useSaveMessageDraft(message) - useExtractEmbedFromFacets(message, setEmbed) - - const onSubmit = React.useCallback(() => { - if (!hasEmbed && message.trim() === '') { - return - } - if (new Graphemer().countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) { - Toast.show(_(msg`Message is too long`), 'xmark') - return - } - clearDraft() - onSendMessage(message) - playHaptic() - setMessage('') - setEmbed(undefined) - - // Pressing the send button causes the text input to lose focus, so we need to - // re-focus it after sending - setTimeout(() => { - inputRef.current?.focus() - }, 100) - }, [ - hasEmbed, - message, - clearDraft, - onSendMessage, - playHaptic, - setEmbed, - _, - inputRef, - ]) - - useFocusedInputHandler( - { - onChangeText: () => { - 'worklet' - const measurement = measure(inputRef) - if (!measurement) return - - const max = windowHeight - -keyboardHeight.value - topInset - 150 - const availableSpace = max - measurement.height - - maxHeight.value = max - isInputScrollable.value = availableSpace < 30 - }, - }, - [windowHeight, topInset], - ) - - const animatedStyle = useAnimatedStyle(() => ({ - maxHeight: maxHeight.value, - })) - - const animatedProps = useAnimatedProps(() => ({ - scrollEnabled: isInputScrollable.value, - })) - - return ( - - {children} - - setIsFocused(true)} - onBlur={() => setIsFocused(false)} - ref={inputRef} - hitSlop={HITSLOP_10} - animatedProps={animatedProps} - /> - - - - - - ) -} diff --git a/src/screens/Messages/Conversation/MessageInput.web.tsx b/src/screens/Messages/Conversation/MessageInput.web.tsx deleted file mode 100644 index 0b7e47920..000000000 --- a/src/screens/Messages/Conversation/MessageInput.web.tsx +++ /dev/null @@ -1,238 +0,0 @@ -import React from 'react' -import {Pressable, StyleSheet, View} from 'react-native' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import Graphemer from 'graphemer' -import TextareaAutosize from 'react-textarea-autosize' - -import {MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants' -import { - useMessageDraft, - useSaveMessageDraft, -} from '#/state/messages/message-drafts' -import {isSafari, isTouchDevice} from 'lib/browser' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' -import { - Emoji, - EmojiPickerPosition, -} from '#/view/com/composer/text-input/web/EmojiPicker.web' -import * as Toast from '#/view/com/util/Toast' -import {atoms as a, useTheme} from '#/alf' -import {Button} from '#/components/Button' -import {useSharedInputStyles} from '#/components/forms/TextField' -import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' -import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane' -import {useExtractEmbedFromFacets} from './MessageInputEmbed' - -export function MessageInput({ - onSendMessage, - hasEmbed, - setEmbed, - children, - openEmojiPicker, -}: { - onSendMessage: (message: string) => void - hasEmbed: boolean - setEmbed: (embedUrl: string | undefined) => void - children?: React.ReactNode - openEmojiPicker?: (pos: EmojiPickerPosition) => void -}) { - const {isTabletOrDesktop} = useWebMediaQueries() - const {_} = useLingui() - const t = useTheme() - const {getDraft, clearDraft} = useMessageDraft() - const [message, setMessage] = React.useState(getDraft) - - const inputStyles = useSharedInputStyles() - const isComposing = React.useRef(false) - const [isFocused, setIsFocused] = React.useState(false) - const [isHovered, setIsHovered] = React.useState(false) - const [textAreaHeight, setTextAreaHeight] = React.useState(38) - const textAreaRef = React.useRef(null) - - const onSubmit = React.useCallback(() => { - if (!hasEmbed && message.trim() === '') { - return - } - if (new Graphemer().countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) { - Toast.show(_(msg`Message is too long`), 'xmark') - return - } - clearDraft() - onSendMessage(message) - setMessage('') - setEmbed(undefined) - }, [message, onSendMessage, _, clearDraft, hasEmbed, setEmbed]) - - const onKeyDown = React.useCallback( - (e: React.KeyboardEvent) => { - // Don't submit the form when the Japanese or any other IME is composing - if (isComposing.current) return - - // see https://github.com/bluesky-social/social-app/issues/4178 - // see https://www.stum.de/2016/06/24/handling-ime-events-in-javascript/ - // see https://lists.w3.org/Archives/Public/www-dom/2010JulSep/att-0182/keyCode-spec.html - // - // On Safari, the final keydown event to dismiss the IME - which is the enter key - is also "Enter" below. - // Obviously, this causes problems because the final dismissal should _not_ submit the text, but should just - // stop the IME editing. This is the behavior of Chrome and Firefox, but not Safari. - // - // Keycode is deprecated, however the alternative seems to only be to compare the timestamp from the - // onCompositionEnd event to the timestamp of the keydown event, which is not reliable. For example, this hack - // uses that method: https://github.com/ProseMirror/prosemirror-view/pull/44. However, from my 500ms resulted in - // far too long of a delay, and a subsequent enter press would often just end up doing nothing. A shorter time - // frame was also not great, since it was too short to be reliable (i.e. an older system might have a larger - // time gap between the two events firing. - if (isSafari && e.key === 'Enter' && e.keyCode === 229) { - return - } - - if (e.key === 'Enter') { - if (e.shiftKey) return - e.preventDefault() - onSubmit() - } - }, - [onSubmit], - ) - - const onChange = React.useCallback( - (e: React.ChangeEvent) => { - setMessage(e.target.value) - }, - [], - ) - - const onEmojiInserted = React.useCallback( - (emoji: Emoji) => { - const position = textAreaRef.current?.selectionStart ?? 0 - setMessage( - message => - message.slice(0, position) + emoji.native + message.slice(position), - ) - }, - [setMessage], - ) - React.useEffect(() => { - textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted) - return () => { - textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted) - } - }, [onEmojiInserted]) - - useSaveMessageDraft(message) - useExtractEmbedFromFacets(message, setEmbed) - - return ( - - {children} - setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)}> - - setIsFocused(true)} - onBlur={() => setIsFocused(false)} - onCompositionStart={() => { - isComposing.current = true - }} - onCompositionEnd={() => { - isComposing.current = false - }} - onHeightChange={height => setTextAreaHeight(height)} - onChange={onChange} - // On mobile web phones, we want to keep the same behavior as the native app. Do not submit the message - // in these cases. - onKeyDown={isTouchDevice && isTabletOrDesktop ? undefined : onKeyDown} - /> - - - - - - ) -} diff --git a/src/screens/Messages/Conversation/MessageInputEmbed.tsx b/src/screens/Messages/Conversation/MessageInputEmbed.tsx deleted file mode 100644 index 2d1551019..000000000 --- a/src/screens/Messages/Conversation/MessageInputEmbed.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import React, {useCallback, useEffect, useMemo, useState} from 'react' -import {LayoutAnimation, View} from 'react-native' -import { - AppBskyFeedPost, - AppBskyRichtextFacet, - AtUri, - RichText as RichTextAPI, -} from '@atproto/api' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {RouteProp, useNavigation, useRoute} from '@react-navigation/native' - -import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' -import {makeProfileLink} from '#/lib/routes/links' -import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' -import { - convertBskyAppUrlIfNeeded, - isBskyPostUrl, - makeRecordUri, -} from '#/lib/strings/url-helpers' -import {useModerationOpts} from '#/state/preferences/moderation-opts' -import {usePostQuery} from '#/state/queries/post' -import {PostMeta} from '#/view/com/util/PostMeta' -import {atoms as a, useTheme} from '#/alf' -import {Button, ButtonIcon} from '#/components/Button' -import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' -import {Loader} from '#/components/Loader' -import * as MediaPreview from '#/components/MediaPreview' -import {ContentHider} from '#/components/moderation/ContentHider' -import {PostAlerts} from '#/components/moderation/PostAlerts' -import {RichText} from '#/components/RichText' -import {Text} from '#/components/Typography' - -export function useMessageEmbed() { - const route = - useRoute>() - const navigation = useNavigation() - const embedFromParams = route.params.embed - - const [embedUri, setEmbed] = useState(embedFromParams) - - if (embedFromParams && embedUri !== embedFromParams) { - setEmbed(embedFromParams) - } - - return { - embedUri, - setEmbed: useCallback( - (embedUrl: string | undefined) => { - if (!embedUrl) { - navigation.setParams({embed: ''}) - setEmbed(undefined) - return - } - - if (embedFromParams) return - - const url = convertBskyAppUrlIfNeeded(embedUrl) - const [_0, user, _1, rkey] = url.split('/').filter(Boolean) - const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey) - - setEmbed(uri) - }, - [embedFromParams, navigation], - ), - } -} - -export function useExtractEmbedFromFacets( - message: string, - setEmbed: (embedUrl: string | undefined) => void, -) { - const rt = new RichTextAPI({text: message}) - rt.detectFacetsWithoutResolution() - - let uriFromFacet: string | undefined - - for (const facet of rt.facets ?? []) { - for (const feature of facet.features) { - if (AppBskyRichtextFacet.isLink(feature) && isBskyPostUrl(feature.uri)) { - uriFromFacet = feature.uri - break - } - } - } - - useEffect(() => { - if (uriFromFacet) { - setEmbed(uriFromFacet) - } - }, [uriFromFacet, setEmbed]) -} - -export function MessageInputEmbed({ - embedUri, - setEmbed, -}: { - embedUri: string | undefined - setEmbed: (embedUrl: string | undefined) => void -}) { - const t = useTheme() - const {_} = useLingui() - - const {data: post, status} = usePostQuery(embedUri) - - const moderationOpts = useModerationOpts() - const moderation = useMemo( - () => - moderationOpts && post ? moderatePost(post, moderationOpts) : undefined, - [moderationOpts, post], - ) - - const {rt, record} = useMemo(() => { - if ( - post && - AppBskyFeedPost.isRecord(post.record) && - AppBskyFeedPost.validateRecord(post.record).success - ) { - return { - rt: new RichTextAPI({ - text: post.record.text, - facets: post.record.facets, - }), - record: post.record, - } - } - - return {rt: undefined, record: undefined} - }, [post]) - - if (!embedUri) { - return null - } - - let content = null - switch (status) { - case 'pending': - content = ( - - - - ) - break - case 'error': - content = ( - - Could not fetch post - - ) - break - case 'success': - const itemUrip = new AtUri(post.uri) - const itemHref = makeProfileLink(post.author, 'post', itemUrip.rkey) - - if (!post || !moderation || !rt || !record) { - return null - } - - content = ( - - - - - {rt.text && ( - - - - )} - - - - ) - break - } - - return ( - - {content} - - - ) -} diff --git a/src/screens/Messages/Conversation/MessageListError.tsx b/src/screens/Messages/Conversation/MessageListError.tsx deleted file mode 100644 index 6f50948df..000000000 --- a/src/screens/Messages/Conversation/MessageListError.tsx +++ /dev/null @@ -1,61 +0,0 @@ -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/types' -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'}}) { - const t = useTheme() - const {_} = useLingui() - const {description, help, cta} = React.useMemo(() => { - return { - [ConvoItemError.FirehoseFailed]: { - description: _(msg`This chat was disconnected`), - help: _(msg`Press to attempt reconnection`), - cta: _(msg`Reconnect`), - }, - [ConvoItemError.HistoryFailed]: { - description: _(msg`Failed to load past messages`), - help: _(msg`Press to retry`), - cta: _(msg`Retry`), - }, - }[item.code] - }, [_, item.code]) - - return ( - - - - - - {description} ·{' '} - {item.retry && ( - { - e.preventDefault() - item.retry?.() - return false - }}> - {cta} - - )} - - - - ) -} diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx deleted file mode 100644 index 3034f0290..000000000 --- a/src/screens/Messages/Conversation/MessagesList.tsx +++ /dev/null @@ -1,454 +0,0 @@ -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 {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 {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 {clamp} from 'lib/numbers' -import {ScrollProvider} from 'lib/ScrollContext' -import {isWeb} from 'platform/detection' -import { - EmojiPicker, - EmojiPickerState, -} from '#/view/com/composer/text-input/web/EmojiPicker.web' -import {List} from 'view/com/util/List' -import {ChatDisabled} from '#/screens/Messages/Conversation/ChatDisabled' -import {MessageInput} from '#/screens/Messages/Conversation/MessageInput' -import {MessageListError} from '#/screens/Messages/Conversation/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 ( - - {isLoading && } - - ) -} - -function renderItem({item}: {item: ConvoItem}) { - if (item.type === 'message' || item.type === 'pending-message') { - return - } else if (item.type === 'deleted-message') { - return Deleted message - } else if (item.type === 'error') { - return - } - - 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> - blocked?: boolean - footer?: React.ReactNode -}) { - const convoState = useConvoActive() - const agent = useAgent() - const getPost = useGetPost() - const {embedUri, setEmbed} = useMessageEmbed() - - const flatListRef = useAnimatedRef() - - const [newMessagesPill, setNewMessagesPill] = React.useState({ - show: false, - startContentOffset: 0, - }) - - const [emojiPickerState, setEmojiPickerState] = - React.useState({ - 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 */} - - - } - /> - - - {convoState.status === ConvoStatus.Disabled ? ( - - ) : blocked ? ( - footer - ) : ( - <> - {isConvoActive(convoState) && - !convoState.isFetchingHistory && - convoState.items.length === 0 && } - setEmojiPickerState({isOpen: true, pos})}> - - - - )} - - - {isWeb && ( - setEmojiPickerState(prev => ({...prev, isOpen: false}))} - /> - )} - - {newMessagesPill.show && } - - ) -} diff --git a/src/screens/Messages/Conversation/index.tsx b/src/screens/Messages/Conversation/index.tsx deleted file mode 100644 index d14ed160a..000000000 --- a/src/screens/Messages/Conversation/index.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import React, {useCallback} from 'react' -import {View} from 'react-native' -import {useKeyboardController} from 'react-native-keyboard-controller' -import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useFocusEffect} from '@react-navigation/native' -import {NativeStackScreenProps} from '@react-navigation/native-stack' - -import {CommonNavigatorParams} from '#/lib/routes/types' -import {useCurrentConvoId} from '#/state/messages/current-convo-id' -import {useModerationOpts} from '#/state/preferences/moderation-opts' -import {useProfileQuery} from '#/state/queries/profile' -import {isWeb} from 'platform/detection' -import {useProfileShadow} from 'state/cache/profile-shadow' -import {ConvoProvider, isConvoActive, useConvo} from 'state/messages/convo' -import {ConvoStatus} from 'state/messages/convo/types' -import {useSetMinimalShellMode} from 'state/shell' -import {CenteredView} from 'view/com/util/Views' -import {MessagesList} from '#/screens/Messages/Conversation/MessagesList' -import {atoms as a, useBreakpoints, useTheme} from '#/alf' -import {MessagesListBlockedFooter} from '#/components/dms/MessagesListBlockedFooter' -import {MessagesListHeader} from '#/components/dms/MessagesListHeader' -import {Error} from '#/components/Error' -import {Loader} from '#/components/Loader' - -type Props = NativeStackScreenProps< - CommonNavigatorParams, - 'MessagesConversation' -> -export function MessagesConversationScreen({route}: Props) { - const {gtMobile} = useBreakpoints() - const setMinimalShellMode = useSetMinimalShellMode() - - const convoId = route.params.conversation - const {setCurrentConvoId} = useCurrentConvoId() - - const {setEnabled} = useKeyboardController() - useFocusEffect( - useCallback(() => { - if (isWeb) return - setEnabled(true) - return () => { - setEnabled(false) - } - }, [setEnabled]), - ) - - useFocusEffect( - useCallback(() => { - setCurrentConvoId(convoId) - - if (isWeb && !gtMobile) { - setMinimalShellMode(true) - } else { - setMinimalShellMode(false) - } - - return () => { - setCurrentConvoId(undefined) - setMinimalShellMode(false) - } - }, [gtMobile, convoId, setCurrentConvoId, setMinimalShellMode]), - ) - - return ( - - - - ) -} - -function Inner() { - const t = useTheme() - const convoState = useConvo() - const {_} = useLingui() - - const moderationOpts = useModerationOpts() - const {data: recipient} = useProfileQuery({ - did: convoState.recipients?.[0].did, - }) - - // Because we want to give the list a chance to asynchronously scroll to the end before it is visible to the user, - // we use `hasScrolled` to determine when to render. With that said however, there is a chance that the chat will be - // empty. So, we also check for that possible state as well and render once we can. - const [hasScrolled, setHasScrolled] = React.useState(false) - const readyToShow = - hasScrolled || - (isConvoActive(convoState) && - !convoState.isFetchingHistory && - convoState.items.length === 0) - - // Any time that we re-render the `Initializing` state, we have to reset `hasScrolled` to false. After entering this - // state, we know that we're resetting the list of messages and need to re-scroll to the bottom when they get added. - React.useEffect(() => { - if (convoState.status === ConvoStatus.Initializing) { - setHasScrolled(false) - } - }, [convoState.status]) - - if (convoState.status === ConvoStatus.Error) { - return ( - - - convoState.error.retry()} - sideBorders={false} - /> - - ) - } - - return ( - - {!readyToShow && } - - {moderationOpts && recipient ? ( - - ) : ( - <> - - - )} - {!readyToShow && ( - - - - - - )} - - - ) -} - -function InnerReady({ - moderationOpts, - recipient: recipientUnshadowed, - hasScrolled, - setHasScrolled, -}: { - moderationOpts: ModerationOpts - recipient: AppBskyActorDefs.ProfileViewBasic - hasScrolled: boolean - setHasScrolled: React.Dispatch> -}) { - const convoState = useConvo() - const recipient = useProfileShadow(recipientUnshadowed) - - const moderation = React.useMemo(() => { - return moderateProfile(recipient, moderationOpts) - }, [recipient, moderationOpts]) - - const blockInfo = React.useMemo(() => { - const modui = moderation.ui('profileView') - const blocks = modui.alerts.filter(alert => alert.type === 'blocking') - const listBlocks = blocks.filter(alert => alert.source.type === 'list') - const userBlock = blocks.find(alert => alert.source.type === 'user') - return { - listBlocks, - userBlock, - } - }, [moderation]) - - return ( - <> - - {isConvoActive(convoState) && ( - 0} - blockInfo={blockInfo} - /> - } - /> - )} - - ) -} diff --git a/src/screens/Messages/List/ChatListItem.tsx b/src/screens/Messages/List/ChatListItem.tsx deleted file mode 100644 index 11c071082..000000000 --- a/src/screens/Messages/List/ChatListItem.tsx +++ /dev/null @@ -1,378 +0,0 @@ -import React, {useCallback, useState} from 'react' -import {GestureResponderEvent, View} from 'react-native' -import { - AppBskyActorDefs, - AppBskyEmbedRecord, - ChatBskyConvoDefs, - moderateProfile, - ModerationOpts, -} from '@atproto/api' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {useHaptics} from '#/lib/haptics' -import {decrementBadgeCount} from '#/lib/notifications/notifications' -import {logEvent} from '#/lib/statsig/statsig' -import {sanitizeDisplayName} from '#/lib/strings/display-names' -import { - postUriToRelativePath, - toBskyAppUrl, - toShortUrl, -} from '#/lib/strings/url-helpers' -import {isNative} from '#/platform/detection' -import {useProfileShadow} from '#/state/cache/profile-shadow' -import {useModerationOpts} from '#/state/preferences/moderation-opts' -import {useSession} from '#/state/session' -import {TimeElapsed} from '#/view/com/util/TimeElapsed' -import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' -import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' -import * as tokens from '#/alf/tokens' -import {ConvoMenu} from '#/components/dms/ConvoMenu' -import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2' -import {Link} from '#/components/Link' -import {useMenuControl} from '#/components/Menu' -import {PostAlerts} from '#/components/moderation/PostAlerts' -import {Text} from '#/components/Typography' - -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, - ) - - if (!otherUser || !moderationOpts) { - return null - } - - return ( - - ) -} - -ChatListItem = React.memo(ChatListItem) - -function ChatListItemReady({ - convo, - profile: profileUnshadowed, - moderationOpts, -}: { - convo: ChatBskyConvoDefs.ConvoView - profile: AppBskyActorDefs.ProfileViewBasic - moderationOpts: ModerationOpts -}) { - const t = useTheme() - const {_} = useLingui() - const {currentAccount} = useSession() - const menuControl = useMenuControl() - const {gtMobile} = useBreakpoints() - const profile = useProfileShadow(profileUnshadowed) - const moderation = React.useMemo( - () => moderateProfile(profile, moderationOpts), - [profile, moderationOpts], - ) - const playHaptic = useHaptics() - - const blockInfo = React.useMemo(() => { - const modui = moderation.ui('profileView') - const blocks = modui.alerts.filter(alert => alert.type === 'blocking') - const listBlocks = blocks.filter(alert => alert.source.type === 'list') - const userBlock = blocks.find(alert => alert.source.type === 'user') - return { - listBlocks, - userBlock, - } - }, [moderation]) - - const isDeletedAccount = profile.handle === 'missing.invalid' - const displayName = isDeletedAccount - ? 'Deleted Account' - : sanitizeDisplayName( - profile.displayName || profile.handle, - moderation.ui('displayName'), - ) - - const isDimStyle = convo.muted || moderation.blocked || isDeletedAccount - - const {lastMessage, lastMessageSentAt} = React.useMemo(() => { - let lastMessage = _(msg`No messages yet`) - let lastMessageSentAt: string | null = null - - if (ChatBskyConvoDefs.isMessageView(convo.lastMessage)) { - const isFromMe = convo.lastMessage.sender?.did === currentAccount?.did - - if (convo.lastMessage.text) { - if (isFromMe) { - lastMessage = _(msg`You: ${convo.lastMessage.text}`) - } else { - lastMessage = convo.lastMessage.text - } - } else if (convo.lastMessage.embed) { - const defaultEmbeddedContentMessage = _( - msg`(contains embedded content)`, - ) - - if (AppBskyEmbedRecord.isView(convo.lastMessage.embed)) { - const embed = convo.lastMessage.embed - - if (AppBskyEmbedRecord.isViewRecord(embed.record)) { - const record = embed.record - const path = postUriToRelativePath(record.uri, { - handle: record.author.handle, - }) - const href = path ? toBskyAppUrl(path) : undefined - const short = href - ? toShortUrl(href) - : defaultEmbeddedContentMessage - if (isFromMe) { - lastMessage = _(msg`You: ${short}`) - } else { - lastMessage = short - } - } - } else { - if (isFromMe) { - lastMessage = _(msg`You: ${defaultEmbeddedContentMessage}`) - } else { - lastMessage = defaultEmbeddedContentMessage - } - } - } - - lastMessageSentAt = convo.lastMessage.sentAt - } - if (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage)) { - lastMessage = isDeletedAccount - ? _(msg`Conversation deleted`) - : _(msg`Message deleted`) - } - - return { - lastMessage, - lastMessageSentAt, - } - }, [_, convo.lastMessage, currentAccount?.did, isDeletedAccount]) - - const [showActions, setShowActions] = useState(false) - - const onMouseEnter = useCallback(() => { - setShowActions(true) - }, []) - - const onMouseLeave = useCallback(() => { - setShowActions(false) - }, []) - - const onFocus = useCallback(e => { - if (e.nativeEvent.relatedTarget == null) return - setShowActions(true) - }, []) - - const onPress = useCallback( - (e: GestureResponderEvent) => { - decrementBadgeCount(convo.unreadCount) - if (isDeletedAccount) { - e.preventDefault() - menuControl.open() - return false - } else { - logEvent('chat:open', {logContext: 'ChatsList'}) - } - }, - [convo.unreadCount, isDeletedAccount, menuControl], - ) - - const onLongPress = useCallback(() => { - playHaptic() - menuControl.open() - }, [playHaptic, menuControl]) - - return ( - - - - - - - {({hovered, pressed, focused}) => ( - - {/* Avatar goes here */} - - - - - - - {displayName} - - - {lastMessageSentAt && ( - - {({timeElapsed}) => ( - - {' '} - · {timeElapsed} - - )} - - )} - {(convo.muted || moderation.blocked) && ( - - {' '} - ·{' '} - - - )} - - - {!isDeletedAccount && ( - - @{profile.handle} - - )} - - 0 - ? a.font_bold - : t.atoms.text_contrast_high, - isDimStyle && t.atoms.text_contrast_medium, - ]}> - {lastMessage} - - - - - - {convo.unreadCount > 0 && ( - - )} - - )} - - - 0} - hideTrigger={isNative} - blockInfo={blockInfo} - style={[ - a.absolute, - a.h_full, - a.self_end, - a.justify_center, - { - right: tokens.space.lg, - opacity: !gtMobile || showActions || menuControl.isOpen ? 1 : 0, - }, - ]} - /> - - ) -} diff --git a/src/screens/Messages/List/index.tsx b/src/screens/Messages/List/index.tsx deleted file mode 100644 index efd717f0b..000000000 --- a/src/screens/Messages/List/index.tsx +++ /dev/null @@ -1,345 +0,0 @@ -import React, {useCallback, useEffect, useMemo, useState} from 'react' -import {View} from 'react-native' -import {ChatBskyConvoDefs} from '@atproto/api' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useFocusEffect} from '@react-navigation/native' -import {NativeStackScreenProps} from '@react-navigation/native-stack' - -import {useAppState} from '#/lib/hooks/useAppState' -import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' -import {MessagesTabNavigatorParams} from '#/lib/routes/types' -import {cleanError} from '#/lib/strings/errors' -import {logger} from '#/logger' -import {isNative} from '#/platform/detection' -import {MESSAGE_SCREEN_POLL_INTERVAL} from '#/state/messages/convo/const' -import {useMessagesEventBus} from '#/state/messages/events' -import {useListConvosQuery} from '#/state/queries/messages/list-converations' -import {List} from '#/view/com/util/List' -import {ViewHeader} from '#/view/com/util/ViewHeader' -import {CenteredView} 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 {NewChat} from '#/components/dms/dialogs/NewChatDialog' -import {MessagesNUX} from '#/components/dms/MessagesNUX' -import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus' -import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotateCounterClockwise' -import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' -import {Message_Stroke2_Corner0_Rounded as Message} from '#/components/icons/Message' -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' -import {ListFooter} from '#/components/Lists' -import {Loader} from '#/components/Loader' -import {Text} from '#/components/Typography' -import {ChatListItem} from './ChatListItem' - -type Props = NativeStackScreenProps - -function renderItem({item}: {item: ChatBskyConvoDefs.ConvoView}) { - return -} - -function keyExtractor(item: ChatBskyConvoDefs.ConvoView) { - return item.id -} - -export function MessagesScreen({navigation, route}: Props) { - const {_} = useLingui() - const t = useTheme() - const newChatControl = useDialogControl() - const {gtMobile} = useBreakpoints() - const pushToConversation = route.params?.pushToConversation - - // Whenever we have `pushToConversation` set, it means we pressed a notification for a chat without being on - // this tab. We should immediately push to the conversation after pressing the notification. - // After we push, reset with `setParams` so that this effect will fire next time we press a notification, even if - // the conversation is the same as before - useEffect(() => { - if (pushToConversation) { - navigation.navigate('MessagesConversation', { - conversation: pushToConversation, - }) - navigation.setParams({pushToConversation: undefined}) - } - }, [navigation, pushToConversation]) - - // Request the poll interval to be 10s (or whatever the MESSAGE_SCREEN_POLL_INTERVAL is set to in the future) - // but only when the screen is active - const messagesBus = useMessagesEventBus() - const state = useAppState() - const isActive = state === 'active' - useFocusEffect( - useCallback(() => { - if (isActive) { - const unsub = messagesBus.requestPollInterval( - MESSAGE_SCREEN_POLL_INTERVAL, - ) - return () => unsub() - } - }, [messagesBus, isActive]), - ) - - const renderButton = useCallback(() => { - return ( - - - - ) - }, [_, t]) - - const initialNumToRender = useInitialNumToRender({minItemHeight: 80}) - const [isPTRing, setIsPTRing] = useState(false) - - const { - data, - isLoading, - isFetchingNextPage, - hasNextPage, - fetchNextPage, - isError, - error, - refetch, - } = useListConvosQuery() - - useRefreshOnFocus(refetch) - - const conversations = useMemo(() => { - if (data?.pages) { - return data.pages.flatMap(page => page.convos) - } - return [] - }, [data]) - - const onRefresh = useCallback(async () => { - setIsPTRing(true) - try { - await refetch() - } catch (err) { - logger.error('Failed to refresh conversations', {message: err}) - } - setIsPTRing(false) - }, [refetch, setIsPTRing]) - - const onEndReached = useCallback(async () => { - if (isFetchingNextPage || !hasNextPage || isError) return - try { - await fetchNextPage() - } catch (err) { - logger.error('Failed to load more conversations', {message: err}) - } - }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) - - const onNewChat = useCallback( - (conversation: string) => - navigation.navigate('MessagesConversation', {conversation}), - [navigation], - ) - - const onNavigateToSettings = useCallback(() => { - navigation.navigate('MessagesSettings') - }, [navigation]) - - if (conversations.length < 1) { - return ( - - - - - {gtMobile ? ( - - ) : ( - - )} - - {isLoading ? ( - - - - ) : ( - <> - {isError ? ( - <> - - - - Whoops! - - - {cleanError(error)} - - - - - - ) : ( - <> - - - - Nothing here - - - You have no conversations yet. Start one! - - - - )} - - )} - - - {!isLoading && !isError && ( - - )} - - ) - } - - return ( - - - {!gtMobile && ( - - )} - - - } - ListFooterComponent={ - - } - onEndReachedThreshold={isNative ? 1.5 : 0} - initialNumToRender={initialNumToRender} - windowSize={11} - // @ts-ignore our .web version only -sfn - desktopFixedHeight - /> - - ) -} - -function DesktopHeader({ - newChatControl, - onNavigateToSettings, -}: { - newChatControl: DialogControlProps - onNavigateToSettings: () => void -}) { - const t = useTheme() - const {_} = useLingui() - const {gtMobile, gtTablet} = useBreakpoints() - - if (!gtMobile) { - return null - } - - return ( - - - Messages - - - - {gtTablet && ( - - )} - - - ) -} diff --git a/src/screens/Messages/components/ChatDisabled.tsx b/src/screens/Messages/components/ChatDisabled.tsx new file mode 100644 index 000000000..c768d2504 --- /dev/null +++ b/src/screens/Messages/components/ChatDisabled.tsx @@ -0,0 +1,150 @@ +import React, {useCallback, useState} from 'react' +import {View} from 'react-native' +import {ComAtprotoModerationDefs} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useMutation} from '@tanstack/react-query' + +import {logger} from '#/logger' +import {useAgent, useSession} from '#/state/session' +import * as Toast from '#/view/com/util/Toast' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' + +export function ChatDisabled() { + const t = useTheme() + return ( + + + + Your chats have been disabled + + + + Our moderators have reviewed reports and decided to disable your + access to chats on Bluesky. + + + + + + ) +} + +function AppealDialog() { + const control = Dialog.useDialogControl() + const {_} = useLingui() + + return ( + <> + + + + + + + + ) +} + +function DialogInner() { + const {_} = useLingui() + const control = Dialog.useDialogContext() + const [details, setDetails] = useState('') + const {gtMobile} = useBreakpoints() + const agent = useAgent() + const {currentAccount} = useSession() + + const {mutate, isPending} = useMutation({ + mutationFn: async () => { + if (!currentAccount) + throw new Error('No current account, should be unreachable') + await agent.createModerationReport({ + reasonType: ComAtprotoModerationDefs.REASONAPPEAL, + subject: { + $type: 'com.atproto.admin.defs#repoRef', + did: currentAccount.did, + }, + reason: details, + }) + }, + onError: err => { + logger.error('Failed to submit chat appeal', {message: err}) + Toast.show(_(msg`Failed to submit appeal, please try again.`), 'xmark') + }, + onSuccess: () => { + control.close() + Toast.show(_(msg`Appeal submitted`)) + }, + }) + + const onSubmit = useCallback(() => mutate(), [mutate]) + const onBack = useCallback(() => control.close(), [control]) + + return ( + + + Appeal this decision + + + This appeal will be sent to Bluesky's moderation service. + + + + + + + + + + + + ) +} diff --git a/src/screens/Messages/components/ChatListItem.tsx b/src/screens/Messages/components/ChatListItem.tsx new file mode 100644 index 000000000..11c071082 --- /dev/null +++ b/src/screens/Messages/components/ChatListItem.tsx @@ -0,0 +1,378 @@ +import React, {useCallback, useState} from 'react' +import {GestureResponderEvent, View} from 'react-native' +import { + AppBskyActorDefs, + AppBskyEmbedRecord, + ChatBskyConvoDefs, + moderateProfile, + ModerationOpts, +} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useHaptics} from '#/lib/haptics' +import {decrementBadgeCount} from '#/lib/notifications/notifications' +import {logEvent} from '#/lib/statsig/statsig' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import { + postUriToRelativePath, + toBskyAppUrl, + toShortUrl, +} from '#/lib/strings/url-helpers' +import {isNative} from '#/platform/detection' +import {useProfileShadow} from '#/state/cache/profile-shadow' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useSession} from '#/state/session' +import {TimeElapsed} from '#/view/com/util/TimeElapsed' +import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' +import * as tokens from '#/alf/tokens' +import {ConvoMenu} from '#/components/dms/ConvoMenu' +import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2' +import {Link} from '#/components/Link' +import {useMenuControl} from '#/components/Menu' +import {PostAlerts} from '#/components/moderation/PostAlerts' +import {Text} from '#/components/Typography' + +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, + ) + + if (!otherUser || !moderationOpts) { + return null + } + + return ( + + ) +} + +ChatListItem = React.memo(ChatListItem) + +function ChatListItemReady({ + convo, + profile: profileUnshadowed, + moderationOpts, +}: { + convo: ChatBskyConvoDefs.ConvoView + profile: AppBskyActorDefs.ProfileViewBasic + moderationOpts: ModerationOpts +}) { + const t = useTheme() + const {_} = useLingui() + const {currentAccount} = useSession() + const menuControl = useMenuControl() + const {gtMobile} = useBreakpoints() + const profile = useProfileShadow(profileUnshadowed) + const moderation = React.useMemo( + () => moderateProfile(profile, moderationOpts), + [profile, moderationOpts], + ) + const playHaptic = useHaptics() + + const blockInfo = React.useMemo(() => { + const modui = moderation.ui('profileView') + const blocks = modui.alerts.filter(alert => alert.type === 'blocking') + const listBlocks = blocks.filter(alert => alert.source.type === 'list') + const userBlock = blocks.find(alert => alert.source.type === 'user') + return { + listBlocks, + userBlock, + } + }, [moderation]) + + const isDeletedAccount = profile.handle === 'missing.invalid' + const displayName = isDeletedAccount + ? 'Deleted Account' + : sanitizeDisplayName( + profile.displayName || profile.handle, + moderation.ui('displayName'), + ) + + const isDimStyle = convo.muted || moderation.blocked || isDeletedAccount + + const {lastMessage, lastMessageSentAt} = React.useMemo(() => { + let lastMessage = _(msg`No messages yet`) + let lastMessageSentAt: string | null = null + + if (ChatBskyConvoDefs.isMessageView(convo.lastMessage)) { + const isFromMe = convo.lastMessage.sender?.did === currentAccount?.did + + if (convo.lastMessage.text) { + if (isFromMe) { + lastMessage = _(msg`You: ${convo.lastMessage.text}`) + } else { + lastMessage = convo.lastMessage.text + } + } else if (convo.lastMessage.embed) { + const defaultEmbeddedContentMessage = _( + msg`(contains embedded content)`, + ) + + if (AppBskyEmbedRecord.isView(convo.lastMessage.embed)) { + const embed = convo.lastMessage.embed + + if (AppBskyEmbedRecord.isViewRecord(embed.record)) { + const record = embed.record + const path = postUriToRelativePath(record.uri, { + handle: record.author.handle, + }) + const href = path ? toBskyAppUrl(path) : undefined + const short = href + ? toShortUrl(href) + : defaultEmbeddedContentMessage + if (isFromMe) { + lastMessage = _(msg`You: ${short}`) + } else { + lastMessage = short + } + } + } else { + if (isFromMe) { + lastMessage = _(msg`You: ${defaultEmbeddedContentMessage}`) + } else { + lastMessage = defaultEmbeddedContentMessage + } + } + } + + lastMessageSentAt = convo.lastMessage.sentAt + } + if (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage)) { + lastMessage = isDeletedAccount + ? _(msg`Conversation deleted`) + : _(msg`Message deleted`) + } + + return { + lastMessage, + lastMessageSentAt, + } + }, [_, convo.lastMessage, currentAccount?.did, isDeletedAccount]) + + const [showActions, setShowActions] = useState(false) + + const onMouseEnter = useCallback(() => { + setShowActions(true) + }, []) + + const onMouseLeave = useCallback(() => { + setShowActions(false) + }, []) + + const onFocus = useCallback(e => { + if (e.nativeEvent.relatedTarget == null) return + setShowActions(true) + }, []) + + const onPress = useCallback( + (e: GestureResponderEvent) => { + decrementBadgeCount(convo.unreadCount) + if (isDeletedAccount) { + e.preventDefault() + menuControl.open() + return false + } else { + logEvent('chat:open', {logContext: 'ChatsList'}) + } + }, + [convo.unreadCount, isDeletedAccount, menuControl], + ) + + const onLongPress = useCallback(() => { + playHaptic() + menuControl.open() + }, [playHaptic, menuControl]) + + return ( + + + + + + + {({hovered, pressed, focused}) => ( + + {/* Avatar goes here */} + + + + + + + {displayName} + + + {lastMessageSentAt && ( + + {({timeElapsed}) => ( + + {' '} + · {timeElapsed} + + )} + + )} + {(convo.muted || moderation.blocked) && ( + + {' '} + ·{' '} + + + )} + + + {!isDeletedAccount && ( + + @{profile.handle} + + )} + + 0 + ? a.font_bold + : t.atoms.text_contrast_high, + isDimStyle && t.atoms.text_contrast_medium, + ]}> + {lastMessage} + + + + + + {convo.unreadCount > 0 && ( + + )} + + )} + + + 0} + hideTrigger={isNative} + blockInfo={blockInfo} + style={[ + a.absolute, + a.h_full, + a.self_end, + a.justify_center, + { + right: tokens.space.lg, + opacity: !gtMobile || showActions || menuControl.isOpen ? 1 : 0, + }, + ]} + /> + + ) +} diff --git a/src/screens/Messages/components/MessageInput.tsx b/src/screens/Messages/components/MessageInput.tsx new file mode 100644 index 000000000..21d6e574e --- /dev/null +++ b/src/screens/Messages/components/MessageInput.tsx @@ -0,0 +1,180 @@ +import React from 'react' +import {Pressable, TextInput, useWindowDimensions, View} from 'react-native' +import { + useFocusedInputHandler, + useReanimatedKeyboardAnimation, +} from 'react-native-keyboard-controller' +import Animated, { + measure, + useAnimatedProps, + useAnimatedRef, + useAnimatedStyle, + useSharedValue, +} from 'react-native-reanimated' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import Graphemer from 'graphemer' + +import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants' +import {useHaptics} from '#/lib/haptics' +import {isIOS} from '#/platform/detection' +import { + useMessageDraft, + useSaveMessageDraft, +} from '#/state/messages/message-drafts' +import {EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker.web' +import * as Toast from '#/view/com/util/Toast' +import {atoms as a, useTheme} from '#/alf' +import {useSharedInputStyles} from '#/components/forms/TextField' +import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane' +import {useExtractEmbedFromFacets} from './MessageInputEmbed' + +const AnimatedTextInput = Animated.createAnimatedComponent(TextInput) + +export function MessageInput({ + onSendMessage, + hasEmbed, + setEmbed, + children, +}: { + onSendMessage: (message: string) => void + hasEmbed: boolean + setEmbed: (embedUrl: string | undefined) => void + children?: React.ReactNode + openEmojiPicker?: (pos: EmojiPickerPosition) => void +}) { + const {_} = useLingui() + const t = useTheme() + const playHaptic = useHaptics() + const {getDraft, clearDraft} = useMessageDraft() + + // Input layout + const {top: topInset} = useSafeAreaInsets() + const {height: windowHeight} = useWindowDimensions() + const {height: keyboardHeight} = useReanimatedKeyboardAnimation() + const maxHeight = useSharedValue(undefined) + const isInputScrollable = useSharedValue(false) + + const inputStyles = useSharedInputStyles() + const [isFocused, setIsFocused] = React.useState(false) + const [message, setMessage] = React.useState(getDraft) + const inputRef = useAnimatedRef() + + useSaveMessageDraft(message) + useExtractEmbedFromFacets(message, setEmbed) + + const onSubmit = React.useCallback(() => { + if (!hasEmbed && message.trim() === '') { + return + } + if (new Graphemer().countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) { + Toast.show(_(msg`Message is too long`), 'xmark') + return + } + clearDraft() + onSendMessage(message) + playHaptic() + setMessage('') + setEmbed(undefined) + + // Pressing the send button causes the text input to lose focus, so we need to + // re-focus it after sending + setTimeout(() => { + inputRef.current?.focus() + }, 100) + }, [ + hasEmbed, + message, + clearDraft, + onSendMessage, + playHaptic, + setEmbed, + _, + inputRef, + ]) + + useFocusedInputHandler( + { + onChangeText: () => { + 'worklet' + const measurement = measure(inputRef) + if (!measurement) return + + const max = windowHeight - -keyboardHeight.value - topInset - 150 + const availableSpace = max - measurement.height + + maxHeight.value = max + isInputScrollable.value = availableSpace < 30 + }, + }, + [windowHeight, topInset], + ) + + const animatedStyle = useAnimatedStyle(() => ({ + maxHeight: maxHeight.value, + })) + + const animatedProps = useAnimatedProps(() => ({ + scrollEnabled: isInputScrollable.value, + })) + + return ( + + {children} + + setIsFocused(true)} + onBlur={() => setIsFocused(false)} + ref={inputRef} + hitSlop={HITSLOP_10} + animatedProps={animatedProps} + /> + + + + + + ) +} diff --git a/src/screens/Messages/components/MessageInput.web.tsx b/src/screens/Messages/components/MessageInput.web.tsx new file mode 100644 index 000000000..b15cd2492 --- /dev/null +++ b/src/screens/Messages/components/MessageInput.web.tsx @@ -0,0 +1,238 @@ +import React from 'react' +import {Pressable, StyleSheet, View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import Graphemer from 'graphemer' +import TextareaAutosize from 'react-textarea-autosize' + +import {isSafari, isTouchDevice} from '#/lib/browser' +import {MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants' +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' +import { + useMessageDraft, + useSaveMessageDraft, +} from '#/state/messages/message-drafts' +import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' +import { + Emoji, + EmojiPickerPosition, +} from '#/view/com/composer/text-input/web/EmojiPicker.web' +import * as Toast from '#/view/com/util/Toast' +import {atoms as a, useTheme} from '#/alf' +import {Button} from '#/components/Button' +import {useSharedInputStyles} from '#/components/forms/TextField' +import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' +import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane' +import {useExtractEmbedFromFacets} from './MessageInputEmbed' + +export function MessageInput({ + onSendMessage, + hasEmbed, + setEmbed, + children, + openEmojiPicker, +}: { + onSendMessage: (message: string) => void + hasEmbed: boolean + setEmbed: (embedUrl: string | undefined) => void + children?: React.ReactNode + openEmojiPicker?: (pos: EmojiPickerPosition) => void +}) { + const {isTabletOrDesktop} = useWebMediaQueries() + const {_} = useLingui() + const t = useTheme() + const {getDraft, clearDraft} = useMessageDraft() + const [message, setMessage] = React.useState(getDraft) + + const inputStyles = useSharedInputStyles() + const isComposing = React.useRef(false) + const [isFocused, setIsFocused] = React.useState(false) + const [isHovered, setIsHovered] = React.useState(false) + const [textAreaHeight, setTextAreaHeight] = React.useState(38) + const textAreaRef = React.useRef(null) + + const onSubmit = React.useCallback(() => { + if (!hasEmbed && message.trim() === '') { + return + } + if (new Graphemer().countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) { + Toast.show(_(msg`Message is too long`), 'xmark') + return + } + clearDraft() + onSendMessage(message) + setMessage('') + setEmbed(undefined) + }, [message, onSendMessage, _, clearDraft, hasEmbed, setEmbed]) + + const onKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + // Don't submit the form when the Japanese or any other IME is composing + if (isComposing.current) return + + // see https://github.com/bluesky-social/social-app/issues/4178 + // see https://www.stum.de/2016/06/24/handling-ime-events-in-javascript/ + // see https://lists.w3.org/Archives/Public/www-dom/2010JulSep/att-0182/keyCode-spec.html + // + // On Safari, the final keydown event to dismiss the IME - which is the enter key - is also "Enter" below. + // Obviously, this causes problems because the final dismissal should _not_ submit the text, but should just + // stop the IME editing. This is the behavior of Chrome and Firefox, but not Safari. + // + // Keycode is deprecated, however the alternative seems to only be to compare the timestamp from the + // onCompositionEnd event to the timestamp of the keydown event, which is not reliable. For example, this hack + // uses that method: https://github.com/ProseMirror/prosemirror-view/pull/44. However, from my 500ms resulted in + // far too long of a delay, and a subsequent enter press would often just end up doing nothing. A shorter time + // frame was also not great, since it was too short to be reliable (i.e. an older system might have a larger + // time gap between the two events firing. + if (isSafari && e.key === 'Enter' && e.keyCode === 229) { + return + } + + if (e.key === 'Enter') { + if (e.shiftKey) return + e.preventDefault() + onSubmit() + } + }, + [onSubmit], + ) + + const onChange = React.useCallback( + (e: React.ChangeEvent) => { + setMessage(e.target.value) + }, + [], + ) + + const onEmojiInserted = React.useCallback( + (emoji: Emoji) => { + const position = textAreaRef.current?.selectionStart ?? 0 + setMessage( + message => + message.slice(0, position) + emoji.native + message.slice(position), + ) + }, + [setMessage], + ) + React.useEffect(() => { + textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted) + return () => { + textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted) + } + }, [onEmojiInserted]) + + useSaveMessageDraft(message) + useExtractEmbedFromFacets(message, setEmbed) + + return ( + + {children} + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)}> + + setIsFocused(true)} + onBlur={() => setIsFocused(false)} + onCompositionStart={() => { + isComposing.current = true + }} + onCompositionEnd={() => { + isComposing.current = false + }} + onHeightChange={height => setTextAreaHeight(height)} + onChange={onChange} + // On mobile web phones, we want to keep the same behavior as the native app. Do not submit the message + // in these cases. + onKeyDown={isTouchDevice && isTabletOrDesktop ? undefined : onKeyDown} + /> + + + + + + ) +} diff --git a/src/screens/Messages/components/MessageInputEmbed.tsx b/src/screens/Messages/components/MessageInputEmbed.tsx new file mode 100644 index 000000000..2d1551019 --- /dev/null +++ b/src/screens/Messages/components/MessageInputEmbed.tsx @@ -0,0 +1,219 @@ +import React, {useCallback, useEffect, useMemo, useState} from 'react' +import {LayoutAnimation, View} from 'react-native' +import { + AppBskyFeedPost, + AppBskyRichtextFacet, + AtUri, + RichText as RichTextAPI, +} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {RouteProp, useNavigation, useRoute} from '@react-navigation/native' + +import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' +import {makeProfileLink} from '#/lib/routes/links' +import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' +import { + convertBskyAppUrlIfNeeded, + isBskyPostUrl, + makeRecordUri, +} from '#/lib/strings/url-helpers' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {usePostQuery} from '#/state/queries/post' +import {PostMeta} from '#/view/com/util/PostMeta' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonIcon} from '#/components/Button' +import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' +import {Loader} from '#/components/Loader' +import * as MediaPreview from '#/components/MediaPreview' +import {ContentHider} from '#/components/moderation/ContentHider' +import {PostAlerts} from '#/components/moderation/PostAlerts' +import {RichText} from '#/components/RichText' +import {Text} from '#/components/Typography' + +export function useMessageEmbed() { + const route = + useRoute>() + const navigation = useNavigation() + const embedFromParams = route.params.embed + + const [embedUri, setEmbed] = useState(embedFromParams) + + if (embedFromParams && embedUri !== embedFromParams) { + setEmbed(embedFromParams) + } + + return { + embedUri, + setEmbed: useCallback( + (embedUrl: string | undefined) => { + if (!embedUrl) { + navigation.setParams({embed: ''}) + setEmbed(undefined) + return + } + + if (embedFromParams) return + + const url = convertBskyAppUrlIfNeeded(embedUrl) + const [_0, user, _1, rkey] = url.split('/').filter(Boolean) + const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey) + + setEmbed(uri) + }, + [embedFromParams, navigation], + ), + } +} + +export function useExtractEmbedFromFacets( + message: string, + setEmbed: (embedUrl: string | undefined) => void, +) { + const rt = new RichTextAPI({text: message}) + rt.detectFacetsWithoutResolution() + + let uriFromFacet: string | undefined + + for (const facet of rt.facets ?? []) { + for (const feature of facet.features) { + if (AppBskyRichtextFacet.isLink(feature) && isBskyPostUrl(feature.uri)) { + uriFromFacet = feature.uri + break + } + } + } + + useEffect(() => { + if (uriFromFacet) { + setEmbed(uriFromFacet) + } + }, [uriFromFacet, setEmbed]) +} + +export function MessageInputEmbed({ + embedUri, + setEmbed, +}: { + embedUri: string | undefined + setEmbed: (embedUrl: string | undefined) => void +}) { + const t = useTheme() + const {_} = useLingui() + + const {data: post, status} = usePostQuery(embedUri) + + const moderationOpts = useModerationOpts() + const moderation = useMemo( + () => + moderationOpts && post ? moderatePost(post, moderationOpts) : undefined, + [moderationOpts, post], + ) + + const {rt, record} = useMemo(() => { + if ( + post && + AppBskyFeedPost.isRecord(post.record) && + AppBskyFeedPost.validateRecord(post.record).success + ) { + return { + rt: new RichTextAPI({ + text: post.record.text, + facets: post.record.facets, + }), + record: post.record, + } + } + + return {rt: undefined, record: undefined} + }, [post]) + + if (!embedUri) { + return null + } + + let content = null + switch (status) { + case 'pending': + content = ( + + + + ) + break + case 'error': + content = ( + + Could not fetch post + + ) + break + case 'success': + const itemUrip = new AtUri(post.uri) + const itemHref = makeProfileLink(post.author, 'post', itemUrip.rkey) + + if (!post || !moderation || !rt || !record) { + return null + } + + content = ( + + + + + {rt.text && ( + + + + )} + + + + ) + break + } + + return ( + + {content} + + + ) +} diff --git a/src/screens/Messages/components/MessageListError.tsx b/src/screens/Messages/components/MessageListError.tsx new file mode 100644 index 000000000..6f50948df --- /dev/null +++ b/src/screens/Messages/components/MessageListError.tsx @@ -0,0 +1,61 @@ +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/types' +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'}}) { + const t = useTheme() + const {_} = useLingui() + const {description, help, cta} = React.useMemo(() => { + return { + [ConvoItemError.FirehoseFailed]: { + description: _(msg`This chat was disconnected`), + help: _(msg`Press to attempt reconnection`), + cta: _(msg`Reconnect`), + }, + [ConvoItemError.HistoryFailed]: { + description: _(msg`Failed to load past messages`), + help: _(msg`Press to retry`), + cta: _(msg`Retry`), + }, + }[item.code] + }, [_, item.code]) + + return ( + + + + + + {description} ·{' '} + {item.retry && ( + { + e.preventDefault() + item.retry?.() + return false + }}> + {cta} + + )} + + + + ) +} 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 ( + + {isLoading && } + + ) +} + +function renderItem({item}: {item: ConvoItem}) { + if (item.type === 'message' || item.type === 'pending-message') { + return + } else if (item.type === 'deleted-message') { + return Deleted message + } else if (item.type === 'error') { + return + } + + 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> + blocked?: boolean + footer?: React.ReactNode +}) { + const convoState = useConvoActive() + const agent = useAgent() + const getPost = useGetPost() + const {embedUri, setEmbed} = useMessageEmbed() + + const flatListRef = useAnimatedRef() + + const [newMessagesPill, setNewMessagesPill] = React.useState({ + show: false, + startContentOffset: 0, + }) + + const [emojiPickerState, setEmojiPickerState] = + React.useState({ + 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 */} + + + } + /> + + + {convoState.status === ConvoStatus.Disabled ? ( + + ) : blocked ? ( + footer + ) : ( + <> + {isConvoActive(convoState) && + !convoState.isFetchingHistory && + convoState.items.length === 0 && } + setEmojiPickerState({isOpen: true, pos})}> + + + + )} + + + {isWeb && ( + setEmojiPickerState(prev => ({...prev, isOpen: false}))} + /> + )} + + {newMessagesPill.show && } + + ) +} -- cgit 1.4.1