diff options
author | Samuel Newman <mozzius@protonmail.com> | 2024-10-02 22:21:59 +0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-10-02 22:21:59 +0300 |
commit | 13c9c79aeec77edc33b1a926843b005c14acccc7 (patch) | |
tree | 0ce80c052a7e504c99e842f0ba6a0f1f2f379a1e /src/screens/Messages/components | |
parent | 405966830ccdbee6152037eebb76c4815ff5526c (diff) | |
download | voidsky-13c9c79aeec77edc33b1a926843b005c14acccc7.tar.zst |
move files around (#5576)
Diffstat (limited to 'src/screens/Messages/components')
-rw-r--r-- | src/screens/Messages/components/ChatDisabled.tsx | 150 | ||||
-rw-r--r-- | src/screens/Messages/components/ChatListItem.tsx | 378 | ||||
-rw-r--r-- | src/screens/Messages/components/MessageInput.tsx | 180 | ||||
-rw-r--r-- | src/screens/Messages/components/MessageInput.web.tsx | 238 | ||||
-rw-r--r-- | src/screens/Messages/components/MessageInputEmbed.tsx | 219 | ||||
-rw-r--r-- | src/screens/Messages/components/MessageListError.tsx | 61 | ||||
-rw-r--r-- | src/screens/Messages/components/MessagesList.tsx | 454 |
7 files changed, 1680 insertions, 0 deletions
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 ( + <View style={[a.p_md]}> + <View + style={[a.align_start, a.p_xl, a.rounded_md, t.atoms.bg_contrast_25]}> + <Text + style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}> + <Trans>Your chats have been disabled</Trans> + </Text> + <Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans> + Our moderators have reviewed reports and decided to disable your + access to chats on Bluesky. + </Trans> + </Text> + <AppealDialog /> + </View> + </View> + ) +} + +function AppealDialog() { + const control = Dialog.useDialogControl() + const {_} = useLingui() + + return ( + <> + <Button + testID="appealDisabledChatBtn" + variant="ghost" + color="secondary" + size="small" + onPress={control.open} + label={_(msg`Appeal this decision`)} + style={a.mt_sm}> + <ButtonText>{_(msg`Appeal this decision`)}</ButtonText> + </Button> + + <Dialog.Outer control={control}> + <Dialog.Handle /> + <DialogInner /> + </Dialog.Outer> + </> + ) +} + +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 ( + <Dialog.ScrollableInner label={_(msg`Appeal this decision`)}> + <Text style={[a.text_2xl, a.font_bold, a.pb_xs, a.leading_tight]}> + <Trans>Appeal this decision</Trans> + </Text> + <Text style={[a.text_md, a.leading_snug]}> + <Trans>This appeal will be sent to Bluesky's moderation service.</Trans> + </Text> + <View style={[a.my_md]}> + <Dialog.Input + label={_(msg`Text input field`)} + placeholder={_( + msg`Please explain why you think your chats were incorrectly disabled`, + )} + value={details} + onChangeText={setDetails} + autoFocus={true} + numberOfLines={3} + multiline + maxLength={300} + /> + </View> + + <View + style={ + gtMobile + ? [a.flex_row, a.justify_between] + : [{flexDirection: 'column-reverse'}, a.gap_sm] + }> + <Button + testID="backBtn" + variant="solid" + color="secondary" + size="large" + onPress={onBack} + label={_(msg`Back`)}> + <ButtonText>{_(msg`Back`)}</ButtonText> + </Button> + <Button + testID="submitBtn" + variant="solid" + color="primary" + size="large" + onPress={onSubmit} + label={_(msg`Submit`)}> + <ButtonText>{_(msg`Submit`)}</ButtonText> + {isPending && <ButtonIcon icon={Loader} />} + </Button> + </View> + <Dialog.Close /> + </Dialog.ScrollableInner> + ) +} 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 ( + <ChatListItemReady + convo={convo} + profile={otherUser} + moderationOpts={moderationOpts} + /> + ) +} + +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<React.FocusEventHandler>(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 ( + <View + // @ts-expect-error web only + onMouseEnter={onMouseEnter} + onMouseLeave={onMouseLeave} + onFocus={onFocus} + onBlur={onMouseLeave} + style={[a.relative]}> + <View + style={[ + a.z_10, + a.absolute, + {top: tokens.space.md, left: tokens.space.lg}, + ]}> + <PreviewableUserAvatar + profile={profile} + size={52} + moderation={moderation.ui('avatar')} + /> + </View> + + <Link + to={`/messages/${convo.id}`} + label={displayName} + accessibilityHint={ + !isDeletedAccount + ? _(msg`Go to conversation with ${profile.handle}`) + : _( + msg`This conversation is with a deleted or a deactivated account. Press for options.`, + ) + } + accessibilityActions={ + isNative + ? [ + {name: 'magicTap', label: _(msg`Open conversation options`)}, + {name: 'longpress', label: _(msg`Open conversation options`)}, + ] + : undefined + } + onPress={onPress} + onLongPress={isNative ? onLongPress : undefined} + onAccessibilityAction={onLongPress}> + {({hovered, pressed, focused}) => ( + <View + style={[ + a.flex_row, + isDeletedAccount ? a.align_center : a.align_start, + a.flex_1, + a.px_lg, + a.py_md, + a.gap_md, + (hovered || pressed || focused) && t.atoms.bg_contrast_25, + t.atoms.border_contrast_low, + ]}> + {/* Avatar goes here */} + <View style={{width: 52, height: 52}} /> + + <View style={[a.flex_1, a.justify_center, web({paddingRight: 45})]}> + <View style={[a.w_full, a.flex_row, a.align_end, a.pb_2xs]}> + <Text + numberOfLines={1} + style={[{maxWidth: '85%'}, web([a.leading_normal])]}> + <Text + emoji + style={[ + a.text_md, + t.atoms.text, + a.font_bold, + {lineHeight: 21}, + isDimStyle && t.atoms.text_contrast_medium, + ]}> + {displayName} + </Text> + </Text> + {lastMessageSentAt && ( + <TimeElapsed timestamp={lastMessageSentAt}> + {({timeElapsed}) => ( + <Text + style={[ + a.text_sm, + {lineHeight: 21}, + t.atoms.text_contrast_medium, + web({whiteSpace: 'preserve nowrap'}), + ]}> + {' '} + · {timeElapsed} + </Text> + )} + </TimeElapsed> + )} + {(convo.muted || moderation.blocked) && ( + <Text + style={[ + a.text_sm, + {lineHeight: 21}, + t.atoms.text_contrast_medium, + web({whiteSpace: 'preserve nowrap'}), + ]}> + {' '} + ·{' '} + <BellStroke + size="xs" + style={[t.atoms.text_contrast_medium]} + /> + </Text> + )} + </View> + + {!isDeletedAccount && ( + <Text + numberOfLines={1} + style={[a.text_sm, t.atoms.text_contrast_medium, a.pb_xs]}> + @{profile.handle} + </Text> + )} + + <Text + emoji + numberOfLines={2} + style={[ + a.text_sm, + a.leading_snug, + convo.unreadCount > 0 + ? a.font_bold + : t.atoms.text_contrast_high, + isDimStyle && t.atoms.text_contrast_medium, + ]}> + {lastMessage} + </Text> + + <PostAlerts + modui={moderation.ui('contentList')} + size="lg" + style={[a.pt_xs]} + /> + </View> + + {convo.unreadCount > 0 && ( + <View + style={[ + a.absolute, + a.rounded_full, + { + backgroundColor: isDimStyle + ? t.palette.contrast_200 + : t.palette.primary_500, + height: 7, + width: 7, + top: 15, + right: 12, + }, + ]} + /> + )} + </View> + )} + </Link> + + <ConvoMenu + convo={convo} + profile={profile} + control={menuControl} + currentScreen="list" + showMarkAsRead={convo.unreadCount > 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, + }, + ]} + /> + </View> + ) +} 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 | number>(undefined) + const isInputScrollable = useSharedValue(false) + + const inputStyles = useSharedInputStyles() + const [isFocused, setIsFocused] = React.useState(false) + const [message, setMessage] = React.useState(getDraft) + const inputRef = useAnimatedRef<TextInput>() + + 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 ( + <View style={[a.px_md, a.pb_sm, a.pt_xs]}> + {children} + <View + style={[ + a.w_full, + a.flex_row, + t.atoms.bg_contrast_25, + { + padding: a.p_sm.padding - 2, + paddingLeft: a.p_md.padding - 2, + borderWidth: 1, + borderRadius: 23, + borderColor: 'transparent', + }, + isFocused && inputStyles.chromeFocus, + ]}> + <AnimatedTextInput + accessibilityLabel={_(msg`Message input field`)} + accessibilityHint={_(msg`Type your message here`)} + placeholder={_(msg`Write a message`)} + placeholderTextColor={t.palette.contrast_500} + value={message} + multiline={true} + onChangeText={setMessage} + style={[ + a.flex_1, + a.text_md, + a.px_sm, + t.atoms.text, + {paddingBottom: isIOS ? 5 : 0}, + animatedStyle, + ]} + keyboardAppearance={t.name === 'light' ? 'light' : 'dark'} + blurOnSubmit={false} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + ref={inputRef} + hitSlop={HITSLOP_10} + animatedProps={animatedProps} + /> + <Pressable + accessibilityRole="button" + accessibilityLabel={_(msg`Send message`)} + accessibilityHint="" + hitSlop={HITSLOP_10} + style={[ + a.rounded_full, + a.align_center, + a.justify_center, + {height: 30, width: 30, backgroundColor: t.palette.primary_500}, + ]} + onPress={onSubmit}> + <PaperPlane fill={t.palette.white} style={[a.relative, {left: 1}]} /> + </Pressable> + </View> + </View> + ) +} diff --git a/src/screens/Messages/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<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => { + // 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<HTMLTextAreaElement>) => { + 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 ( + <View style={a.p_sm}> + {children} + <View + style={[ + a.flex_row, + t.atoms.bg_contrast_25, + { + paddingRight: a.p_sm.padding - 2, + paddingLeft: a.p_sm.padding - 2, + borderWidth: 1, + borderRadius: 23, + borderColor: 'transparent', + height: textAreaHeight + 23, + }, + isHovered && inputStyles.chromeHover, + isFocused && inputStyles.chromeFocus, + ]} + // @ts-expect-error web only + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)}> + <Button + onPress={e => { + e.currentTarget.measure((_fx, _fy, _width, _height, px, py) => { + openEmojiPicker?.({top: py, left: px, right: px, bottom: py}) + }) + }} + style={[ + a.rounded_full, + a.overflow_hidden, + a.align_center, + a.justify_center, + { + marginTop: 5, + height: 30, + width: 30, + }, + ]} + label={_(msg`Open emoji picker`)}> + {state => ( + <View + style={[ + a.absolute, + a.inset_0, + a.align_center, + a.justify_center, + { + backgroundColor: + state.hovered || state.focused || state.pressed + ? t.atoms.bg.backgroundColor + : undefined, + }, + ]}> + <EmojiSmile size="lg" /> + </View> + )} + </Button> + <TextareaAutosize + ref={textAreaRef} + style={StyleSheet.flatten([ + a.flex_1, + a.px_sm, + a.border_0, + t.atoms.text, + { + paddingTop: 10, + backgroundColor: 'transparent', + resize: 'none', + }, + ])} + maxRows={12} + placeholder={_(msg`Write a message`)} + defaultValue="" + value={message} + dirName="ltr" + autoFocus={true} + onFocus={() => 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} + /> + <Pressable + accessibilityRole="button" + accessibilityLabel={_(msg`Send message`)} + accessibilityHint="" + style={[ + a.rounded_full, + a.align_center, + a.justify_center, + { + height: 30, + width: 30, + marginTop: 5, + backgroundColor: t.palette.primary_500, + }, + ]} + onPress={onSubmit}> + <PaperPlane fill={t.palette.white} style={[a.relative, {left: 1}]} /> + </Pressable> + </View> + </View> + ) +} diff --git a/src/screens/Messages/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<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>() + const navigation = useNavigation<NavigationProp>() + 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 = ( + <View + style={[a.flex_1, {minHeight: 64}, a.justify_center, a.align_center]}> + <Loader /> + </View> + ) + break + case 'error': + content = ( + <View + style={[a.flex_1, {minHeight: 64}, a.justify_center, a.align_center]}> + <Text style={a.text_center}>Could not fetch post</Text> + </View> + ) + 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 = ( + <View + style={[ + a.flex_1, + t.atoms.bg, + t.atoms.border_contrast_low, + a.rounded_md, + a.border, + a.p_sm, + a.mb_sm, + ]} + pointerEvents="none"> + <PostMeta + showAvatar + author={post.author} + moderation={moderation} + timestamp={post.indexedAt} + postHref={itemHref} + style={a.flex_0} + /> + <ContentHider modui={moderation.ui('contentView')}> + <PostAlerts modui={moderation.ui('contentView')} style={a.py_xs} /> + {rt.text && ( + <View style={a.mt_xs}> + <RichText + enableTags + testID="postText" + value={rt} + style={[a.text_sm, t.atoms.text_contrast_high]} + authorHandle={post.author.handle} + numberOfLines={3} + /> + </View> + )} + <MediaPreview.Embed embed={post.embed} style={a.mt_sm} /> + </ContentHider> + </View> + ) + break + } + + return ( + <View style={[a.flex_row, a.gap_sm]}> + {content} + <Button + label={_(msg`Remove embed`)} + onPress={() => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + setEmbed(undefined) + }} + size="tiny" + variant="solid" + color="secondary" + shape="round"> + <ButtonIcon icon={X} /> + </Button> + </View> + ) +} 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 ( + <View style={[a.py_md, a.w_full, a.flex_row, a.justify_center]}> + <View + style={[ + a.flex_1, + a.flex_row, + a.align_center, + a.justify_center, + a.gap_sm, + {maxWidth: 400}, + ]}> + <CircleInfo size="sm" fill={t.palette.negative_400} /> + + <Text style={[a.leading_snug, t.atoms.text_contrast_medium]}> + {description} ·{' '} + {item.retry && ( + <InlineLinkText + to="#" + label={help} + onPress={e => { + e.preventDefault() + item.retry?.() + return false + }}> + {cta} + </InlineLinkText> + )} + </Text> + </View> + </View> + ) +} diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx new file mode 100644 index 000000000..b659e98d6 --- /dev/null +++ b/src/screens/Messages/components/MessagesList.tsx @@ -0,0 +1,454 @@ +import React, {useCallback, useRef} from 'react' +import {FlatList, LayoutChangeEvent, View} from 'react-native' +import { + KeyboardStickyView, + useKeyboardHandler, +} from 'react-native-keyboard-controller' +import { + runOnJS, + scrollTo, + useAnimatedRef, + useAnimatedStyle, + useSharedValue, +} from 'react-native-reanimated' +import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {AppBskyEmbedRecord, AppBskyRichtextFacet, RichText} from '@atproto/api' + +import {clamp} from '#/lib/numbers' +import {ScrollProvider} from '#/lib/ScrollContext' +import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' +import { + convertBskyAppUrlIfNeeded, + isBskyPostUrl, +} from '#/lib/strings/url-helpers' +import {logger} from '#/logger' +import {isNative} from '#/platform/detection' +import {isWeb} from '#/platform/detection' +import {isConvoActive, useConvoActive} from '#/state/messages/convo' +import {ConvoItem, ConvoStatus} from '#/state/messages/convo/types' +import {useGetPost} from '#/state/queries/post' +import {useAgent} from '#/state/session' +import { + EmojiPicker, + EmojiPickerState, +} from '#/view/com/composer/text-input/web/EmojiPicker.web' +import {List} from '#/view/com/util/List' +import {ChatDisabled} from '#/screens/Messages/components/ChatDisabled' +import {MessageInput} from '#/screens/Messages/components/MessageInput' +import {MessageListError} from '#/screens/Messages/components/MessageListError' +import {ChatEmptyPill} from '#/components/dms/ChatEmptyPill' +import {MessageItem} from '#/components/dms/MessageItem' +import {NewMessagesPill} from '#/components/dms/NewMessagesPill' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' +import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed' + +function MaybeLoader({isLoading}: {isLoading: boolean}) { + return ( + <View + style={{ + height: 50, + width: '100%', + alignItems: 'center', + justifyContent: 'center', + }}> + {isLoading && <Loader size="xl" />} + </View> + ) +} + +function renderItem({item}: {item: ConvoItem}) { + if (item.type === 'message' || item.type === 'pending-message') { + return <MessageItem item={item} /> + } else if (item.type === 'deleted-message') { + return <Text>Deleted message</Text> + } else if (item.type === 'error') { + return <MessageListError item={item} /> + } + + return null +} + +function keyExtractor(item: ConvoItem) { + return item.key +} + +function onScrollToIndexFailed() { + // Placeholder function. You have to give FlatList something or else it will error. +} + +export function MessagesList({ + hasScrolled, + setHasScrolled, + blocked, + footer, +}: { + hasScrolled: boolean + setHasScrolled: React.Dispatch<React.SetStateAction<boolean>> + blocked?: boolean + footer?: React.ReactNode +}) { + const convoState = useConvoActive() + const agent = useAgent() + const getPost = useGetPost() + const {embedUri, setEmbed} = useMessageEmbed() + + const flatListRef = useAnimatedRef<FlatList>() + + const [newMessagesPill, setNewMessagesPill] = React.useState({ + show: false, + startContentOffset: 0, + }) + + const [emojiPickerState, setEmojiPickerState] = + React.useState<EmojiPickerState>({ + isOpen: false, + pos: {top: 0, left: 0, right: 0, bottom: 0}, + }) + + // We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items + // are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to + // the bottom. + const isAtBottom = useSharedValue(true) + + // This will be used on web to assist in determining if we need to maintain the content offset + const isAtTop = useSharedValue(true) + + // Used to keep track of the current content height. We'll need this in `onScroll` so we know when to start allowing + // onStartReached to fire. + const prevContentHeight = useRef(0) + const prevItemCount = useRef(0) + + // -- Keep track of background state and positioning for new pill + const layoutHeight = useSharedValue(0) + const didBackground = React.useRef(false) + React.useEffect(() => { + if (convoState.status === ConvoStatus.Backgrounded) { + didBackground.current = true + } + }, [convoState.status]) + + // -- Scroll handling + + // Every time the content size changes, that means one of two things is happening: + // 1. New messages are being added from the log or from a message you have sent + // 2. Old messages are being prepended to the top + // + // The first time that the content size changes is when the initial items are rendered. Because we cannot rely on + // `initialScrollIndex`, we need to immediately scroll to the bottom of the list. That scroll will not be animated. + // + // Subsequent resizes will only scroll to the bottom if the user is at the bottom of the list (within 100 pixels of + // the bottom). Therefore, any new messages that come in or are sent will result in an animated scroll to end. However + // we will not scroll whenever new items get prepended to the top. + const onContentSizeChange = useCallback( + (_: number, height: number) => { + // Because web does not have `maintainVisibleContentPosition` support, we will need to manually scroll to the + // previous off whenever we add new content to the previous offset whenever we add new content to the list. + if (isWeb && isAtTop.value && hasScrolled) { + flatListRef.current?.scrollToOffset({ + offset: height - prevContentHeight.current, + animated: false, + }) + } + + // This number _must_ be the height of the MaybeLoader component + if (height > 50 && isAtBottom.value) { + // If the size of the content is changing by more than the height of the screen, then we don't + // want to scroll further than the start of all the new content. Since we are storing the previous offset, + // we can just scroll the user to that offset and add a little bit of padding. We'll also show the pill + // that can be pressed to immediately scroll to the end. + if ( + didBackground.current && + hasScrolled && + height - prevContentHeight.current > layoutHeight.value - 50 && + convoState.items.length - prevItemCount.current > 1 + ) { + flatListRef.current?.scrollToOffset({ + offset: prevContentHeight.current - 65, + animated: true, + }) + setNewMessagesPill({ + show: true, + startContentOffset: prevContentHeight.current - 65, + }) + } else { + flatListRef.current?.scrollToOffset({ + offset: height, + animated: hasScrolled && height > prevContentHeight.current, + }) + + // HACK Unfortunately, we need to call `setHasScrolled` after a brief delay, + // because otherwise there is too much of a delay between the time the content + // scrolls and the time the screen appears, causing a flicker. + // We cannot actually use a synchronous scroll here, because `onContentSizeChange` + // is actually async itself - all the info has to come across the bridge first. + if (!hasScrolled && !convoState.isFetchingHistory) { + setTimeout(() => { + setHasScrolled(true) + }, 100) + } + } + } + + prevContentHeight.current = height + prevItemCount.current = convoState.items.length + didBackground.current = false + }, + [ + hasScrolled, + setHasScrolled, + convoState.isFetchingHistory, + convoState.items.length, + // these are stable + flatListRef, + isAtTop.value, + isAtBottom.value, + layoutHeight.value, + ], + ) + + const onStartReached = useCallback(() => { + if (hasScrolled && prevContentHeight.current > layoutHeight.value) { + convoState.fetchMessageHistory() + } + }, [convoState, hasScrolled, layoutHeight.value]) + + const onScroll = React.useCallback( + (e: ReanimatedScrollEvent) => { + 'worklet' + layoutHeight.value = e.layoutMeasurement.height + const bottomOffset = e.contentOffset.y + e.layoutMeasurement.height + + // Most apps have a little bit of space the user can scroll past while still automatically scrolling ot the bottom + // when a new message is added, hence the 100 pixel offset + isAtBottom.value = e.contentSize.height - 100 < bottomOffset + isAtTop.value = e.contentOffset.y <= 1 + + if ( + newMessagesPill.show && + (e.contentOffset.y > newMessagesPill.startContentOffset + 200 || + isAtBottom.value) + ) { + runOnJS(setNewMessagesPill)({ + show: false, + startContentOffset: 0, + }) + } + }, + [layoutHeight, newMessagesPill, isAtBottom, isAtTop], + ) + + // -- Keyboard animation handling + const {bottom: bottomInset} = useSafeAreaInsets() + const bottomOffset = isWeb ? 0 : clamp(60 + bottomInset, 60, 75) + + const keyboardHeight = useSharedValue(0) + const keyboardIsOpening = useSharedValue(false) + + // In some cases - like when the emoji piker opens - we don't want to animate the scroll in the list onLayout event. + // We use this value to keep track of when we want to disable the animation. + const layoutScrollWithoutAnimation = useSharedValue(false) + + useKeyboardHandler({ + onStart: e => { + 'worklet' + // Immediate updates - like opening the emoji picker - will have a duration of zero. In those cases, we should + // just update the height here instead of having the `onMove` event do it (that event will not fire!) + if (e.duration === 0) { + layoutScrollWithoutAnimation.value = true + keyboardHeight.value = e.height + } else { + keyboardIsOpening.value = true + } + }, + onMove: e => { + 'worklet' + keyboardHeight.value = e.height + if (e.height > bottomOffset) { + scrollTo(flatListRef, 0, 1e7, false) + } + }, + onEnd: () => { + 'worklet' + keyboardIsOpening.value = false + }, + }) + + const animatedListStyle = useAnimatedStyle(() => ({ + marginBottom: + keyboardHeight.value > bottomOffset ? keyboardHeight.value : bottomOffset, + })) + + // -- Message sending + const onSendMessage = useCallback( + async (text: string) => { + let rt = new RichText({text: text.trimEnd()}, {cleanNewlines: true}) + + // detect facets without resolution first - this is used to see if there's + // any post links in the text that we can embed. We do this first because + // we want to remove the post link from the text, re-trim, then detect facets + rt.detectFacetsWithoutResolution() + + let embed: AppBskyEmbedRecord.Main | undefined + + if (embedUri) { + try { + const post = await getPost({uri: embedUri}) + if (post) { + embed = { + $type: 'app.bsky.embed.record', + record: { + uri: post.uri, + cid: post.cid, + }, + } + + // look for the embed uri in the facets, so we can remove it from the text + const postLinkFacet = rt.facets?.find(facet => { + return facet.features.find(feature => { + if (AppBskyRichtextFacet.isLink(feature)) { + if (isBskyPostUrl(feature.uri)) { + const url = convertBskyAppUrlIfNeeded(feature.uri) + const [_0, _1, _2, rkey] = url.split('/').filter(Boolean) + + // this might have a handle instead of a DID + // so just compare the rkey - not particularly dangerous + return post.uri.endsWith(rkey) + } + } + return false + }) + }) + + if (postLinkFacet) { + const isAtStart = postLinkFacet.index.byteStart === 0 + const isAtEnd = + postLinkFacet.index.byteEnd === rt.unicodeText.graphemeLength + + // remove the post link from the text + if (isAtStart || isAtEnd) { + rt.delete( + postLinkFacet.index.byteStart, + postLinkFacet.index.byteEnd, + ) + } + + rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true}) + } + } + } catch (error) { + logger.error('Failed to get post as quote for DM', {error}) + } + } + + await rt.detectFacets(agent) + + rt = shortenLinks(rt) + rt = stripInvalidMentions(rt) + + if (!hasScrolled) { + setHasScrolled(true) + } + + convoState.sendMessage({ + text: rt.text, + facets: rt.facets, + embed, + }) + }, + [agent, convoState, embedUri, getPost, hasScrolled, setHasScrolled], + ) + + // -- List layout changes (opening emoji keyboard, etc.) + const onListLayout = React.useCallback( + (e: LayoutChangeEvent) => { + layoutHeight.value = e.nativeEvent.layout.height + + if (isWeb || !keyboardIsOpening.value) { + flatListRef.current?.scrollToEnd({ + animated: !layoutScrollWithoutAnimation.value, + }) + layoutScrollWithoutAnimation.value = false + } + }, + [ + flatListRef, + keyboardIsOpening.value, + layoutScrollWithoutAnimation, + layoutHeight, + ], + ) + + const scrollToEndOnPress = React.useCallback(() => { + flatListRef.current?.scrollToOffset({ + offset: prevContentHeight.current, + animated: true, + }) + }, [flatListRef]) + + return ( + <> + {/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */} + <ScrollProvider onScroll={onScroll}> + <List + ref={flatListRef} + data={convoState.items} + renderItem={renderItem} + keyExtractor={keyExtractor} + disableFullWindowScroll={true} + disableVirtualization={true} + style={animatedListStyle} + // The extra two items account for the header and the footer components + initialNumToRender={isNative ? 32 : 62} + maxToRenderPerBatch={isWeb ? 32 : 62} + keyboardDismissMode="on-drag" + keyboardShouldPersistTaps="handled" + maintainVisibleContentPosition={{ + minIndexForVisible: 0, + }} + removeClippedSubviews={false} + sideBorders={false} + onContentSizeChange={onContentSizeChange} + onLayout={onListLayout} + onStartReached={onStartReached} + onScrollToIndexFailed={onScrollToIndexFailed} + scrollEventThrottle={100} + ListHeaderComponent={ + <MaybeLoader isLoading={convoState.isFetchingHistory} /> + } + /> + </ScrollProvider> + <KeyboardStickyView offset={{closed: -bottomOffset, opened: 0}}> + {convoState.status === ConvoStatus.Disabled ? ( + <ChatDisabled /> + ) : blocked ? ( + footer + ) : ( + <> + {isConvoActive(convoState) && + !convoState.isFetchingHistory && + convoState.items.length === 0 && <ChatEmptyPill />} + <MessageInput + onSendMessage={onSendMessage} + hasEmbed={!!embedUri} + setEmbed={setEmbed} + openEmojiPicker={pos => setEmojiPickerState({isOpen: true, pos})}> + <MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} /> + </MessageInput> + </> + )} + </KeyboardStickyView> + + {isWeb && ( + <EmojiPicker + pinToTop + state={emojiPickerState} + close={() => setEmojiPickerState(prev => ({...prev, isOpen: false}))} + /> + )} + + {newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />} + </> + ) +} |