diff options
Diffstat (limited to 'src/screens/Messages/Conversation')
-rw-r--r-- | src/screens/Messages/Conversation/ChatDisabled.tsx | 150 | ||||
-rw-r--r-- | src/screens/Messages/Conversation/MessageInput.tsx | 180 | ||||
-rw-r--r-- | src/screens/Messages/Conversation/MessageInput.web.tsx | 238 | ||||
-rw-r--r-- | src/screens/Messages/Conversation/MessageInputEmbed.tsx | 219 | ||||
-rw-r--r-- | src/screens/Messages/Conversation/MessageListError.tsx | 61 | ||||
-rw-r--r-- | src/screens/Messages/Conversation/MessagesList.tsx | 454 | ||||
-rw-r--r-- | src/screens/Messages/Conversation/index.tsx | 205 |
7 files changed, 0 insertions, 1507 deletions
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 ( - <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/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 | 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/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<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/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<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/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 ( - <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/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 ( - <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} />} - </> - ) -} 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 ( - <ConvoProvider key={convoId} convoId={convoId}> - <Inner /> - </ConvoProvider> - ) -} - -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 ( - <CenteredView style={a.flex_1} sideBorders> - <MessagesListHeader /> - <Error - title={_(msg`Something went wrong`)} - message={_(msg`We couldn't load this conversation`)} - onRetry={() => convoState.error.retry()} - sideBorders={false} - /> - </CenteredView> - ) - } - - return ( - <CenteredView style={[a.flex_1]} sideBorders> - {!readyToShow && <MessagesListHeader />} - <View style={[a.flex_1]}> - {moderationOpts && recipient ? ( - <InnerReady - moderationOpts={moderationOpts} - recipient={recipient} - hasScrolled={hasScrolled} - setHasScrolled={setHasScrolled} - /> - ) : ( - <> - <View style={[a.align_center, a.gap_sm, a.flex_1]} /> - </> - )} - {!readyToShow && ( - <View - style={[ - a.absolute, - a.z_10, - a.w_full, - a.h_full, - a.justify_center, - a.align_center, - t.atoms.bg, - ]}> - <View style={[{marginBottom: 75}]}> - <Loader size="xl" /> - </View> - </View> - )} - </View> - </CenteredView> - ) -} - -function InnerReady({ - moderationOpts, - recipient: recipientUnshadowed, - hasScrolled, - setHasScrolled, -}: { - moderationOpts: ModerationOpts - recipient: AppBskyActorDefs.ProfileViewBasic - hasScrolled: boolean - setHasScrolled: React.Dispatch<React.SetStateAction<boolean>> -}) { - 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 ( - <> - <MessagesListHeader - profile={recipient} - moderation={moderation} - blockInfo={blockInfo} - /> - {isConvoActive(convoState) && ( - <MessagesList - hasScrolled={hasScrolled} - setHasScrolled={setHasScrolled} - blocked={moderation?.blocked} - footer={ - <MessagesListBlockedFooter - recipient={recipient} - convoId={convoState.convo.id} - hasMessages={convoState.items.length > 0} - blockInfo={blockInfo} - /> - } - /> - )} - </> - ) -} |