diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/App.native.tsx | 45 | ||||
-rw-r--r-- | src/components/dms/ActionsWrapper.tsx | 74 | ||||
-rw-r--r-- | src/components/dms/MessagesListHeader.tsx | 3 | ||||
-rw-r--r-- | src/components/dms/NewMessagesPill.tsx | 100 | ||||
-rw-r--r-- | src/screens/Messages/Conversation/MessageInput.tsx | 3 | ||||
-rw-r--r-- | src/screens/Messages/Conversation/MessagesList.tsx | 199 | ||||
-rw-r--r-- | src/screens/Messages/Conversation/index.tsx | 8 | ||||
-rw-r--r-- | src/view/com/composer/Composer.tsx | 331 |
8 files changed, 410 insertions, 353 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx index 425d6ac6e..7c60d1624 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -4,6 +4,7 @@ import 'view/icons' import React, {useEffect, useState} from 'react' import {GestureHandlerRootView} from 'react-native-gesture-handler' +import {KeyboardProvider} from 'react-native-keyboard-controller' import {RootSiblingParent} from 'react-native-root-siblings' import { initialWindowMetrics, @@ -142,27 +143,29 @@ function App() { * that is set up in the InnerApp component above. */ return ( - <SessionProvider> - <ShellStateProvider> - <PrefsStateProvider> - <MutedThreadsProvider> - <InvitesStateProvider> - <ModalStateProvider> - <DialogStateProvider> - <LightboxStateProvider> - <I18nProvider> - <PortalProvider> - <InnerApp /> - </PortalProvider> - </I18nProvider> - </LightboxStateProvider> - </DialogStateProvider> - </ModalStateProvider> - </InvitesStateProvider> - </MutedThreadsProvider> - </PrefsStateProvider> - </ShellStateProvider> - </SessionProvider> + <KeyboardProvider enabled={true}> + <SessionProvider> + <ShellStateProvider> + <PrefsStateProvider> + <MutedThreadsProvider> + <InvitesStateProvider> + <ModalStateProvider> + <DialogStateProvider> + <LightboxStateProvider> + <I18nProvider> + <PortalProvider> + <InnerApp /> + </PortalProvider> + </I18nProvider> + </LightboxStateProvider> + </DialogStateProvider> + </ModalStateProvider> + </InvitesStateProvider> + </MutedThreadsProvider> + </PrefsStateProvider> + </ShellStateProvider> + </SessionProvider> + </KeyboardProvider> ) } diff --git a/src/components/dms/ActionsWrapper.tsx b/src/components/dms/ActionsWrapper.tsx index 3b9a56bdc..a349c3cfa 100644 --- a/src/components/dms/ActionsWrapper.tsx +++ b/src/components/dms/ActionsWrapper.tsx @@ -1,5 +1,6 @@ import React from 'react' -import {Keyboard, Pressable, View} from 'react-native' +import {Keyboard} from 'react-native' +import {Gesture, GestureDetector} from 'react-native-gesture-handler' import Animated, { cancelAnimation, runOnJS, @@ -15,8 +16,6 @@ import {atoms as a} from '#/alf' import {MessageMenu} from '#/components/dms/MessageMenu' import {useMenuControl} from '#/components/Menu' -const AnimatedPressable = Animated.createAnimatedComponent(Pressable) - export function ActionsWrapper({ message, isFromSelf, @@ -30,56 +29,59 @@ export function ActionsWrapper({ const menuControl = useMenuControl() const scale = useSharedValue(1) - const animationDidComplete = useSharedValue(false) const animatedStyle = useAnimatedStyle(() => ({ transform: [{scale: scale.value}], })) - // Reanimated's `runOnJS` doesn't like refs, so we can't use `runOnJS(menuControl.open)()`. Instead, we'll use this - // function const open = React.useCallback(() => { + playHaptic() Keyboard.dismiss() menuControl.open() - }, [menuControl]) + }, [menuControl, playHaptic]) const shrink = React.useCallback(() => { 'worklet' cancelAnimation(scale) - scale.value = withTiming(1, {duration: 200}, () => { - animationDidComplete.value = false - }) - }, [animationDidComplete, scale]) + scale.value = withTiming(1, {duration: 200}) + }, [scale]) - const grow = React.useCallback(() => { - 'worklet' - scale.value = withTiming(1.05, {duration: 450}, finished => { - if (!finished) return - animationDidComplete.value = true - runOnJS(playHaptic)() - runOnJS(open)() + const doubleTapGesture = Gesture.Tap() + .numberOfTaps(2) + .hitSlop(HITSLOP_10) + .onEnd(open) - shrink() + const pressAndHoldGesture = Gesture.LongPress() + .onStart(() => { + scale.value = withTiming(1.05, {duration: 200}, finished => { + if (!finished) return + runOnJS(open)() + shrink() + }) }) - }, [scale, animationDidComplete, playHaptic, shrink, open]) + .onTouchesUp(shrink) + .onTouchesMove(shrink) + .cancelsTouchesInView(false) + .runOnJS(true) + + const composedGestures = Gesture.Exclusive( + doubleTapGesture, + pressAndHoldGesture, + ) return ( - <View - style={[ - { - maxWidth: '80%', - }, - isFromSelf ? a.self_end : a.self_start, - ]}> - <AnimatedPressable - style={animatedStyle} - unstable_pressDelay={200} - onPressIn={grow} - onTouchEnd={shrink} - hitSlop={HITSLOP_10}> + <GestureDetector gesture={composedGestures}> + <Animated.View + style={[ + { + maxWidth: '80%', + }, + isFromSelf ? a.self_end : a.self_start, + animatedStyle, + ]}> {children} - </AnimatedPressable> - <MessageMenu message={message} control={menuControl} /> - </View> + <MessageMenu message={message} control={menuControl} /> + </Animated.View> + </GestureDetector> ) } diff --git a/src/components/dms/MessagesListHeader.tsx b/src/components/dms/MessagesListHeader.tsx index 1e6fd3609..a6dff4032 100644 --- a/src/components/dms/MessagesListHeader.tsx +++ b/src/components/dms/MessagesListHeader.tsx @@ -1,5 +1,5 @@ import React, {useCallback} from 'react' -import {Keyboard, TouchableOpacity, View} from 'react-native' +import {TouchableOpacity, View} from 'react-native' import { AppBskyActorDefs, ModerationCause, @@ -46,7 +46,6 @@ export let MessagesListHeader = ({ if (isWeb) { navigation.replace('Messages', {}) } else { - Keyboard.dismiss() navigation.goBack() } }, [navigation]) diff --git a/src/components/dms/NewMessagesPill.tsx b/src/components/dms/NewMessagesPill.tsx index 4a0ba22c9..924f7c455 100644 --- a/src/components/dms/NewMessagesPill.tsx +++ b/src/components/dms/NewMessagesPill.tsx @@ -1,47 +1,97 @@ import React from 'react' -import {View} from 'react-native' -import Animated from 'react-native-reanimated' +import {Pressable, View} from 'react-native' +import Animated, { + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated' +import {useSafeAreaInsets} from 'react-native-safe-area-context' import {Trans} from '@lingui/macro' import { ScaleAndFadeIn, ScaleAndFadeOut, } from 'lib/custom-animations/ScaleAndFade' +import {useHaptics} from 'lib/haptics' +import {isAndroid, isIOS, isWeb} from 'platform/detection' import {atoms as a, useTheme} from '#/alf' import {Text} from '#/components/Typography' -export function NewMessagesPill() { +const AnimatedPressable = Animated.createAnimatedComponent(Pressable) + +export function NewMessagesPill({ + onPress: onPressInner, +}: { + onPress: () => void +}) { const t = useTheme() + const playHaptic = useHaptics() + const {bottom: bottomInset} = useSafeAreaInsets() + const bottomBarHeight = isIOS ? 42 : isAndroid ? 60 : 0 + const bottomOffset = isWeb ? 0 : bottomInset + bottomBarHeight + + const scale = useSharedValue(1) + + const onPressIn = React.useCallback(() => { + if (isWeb) return + scale.value = withTiming(1.075, {duration: 100}) + }, [scale]) + + const onPressOut = React.useCallback(() => { + if (isWeb) return + scale.value = withTiming(1, {duration: 100}) + }, [scale]) + + const onPress = React.useCallback(() => { + runOnJS(playHaptic)() + onPressInner?.() + }, [onPressInner, playHaptic]) - React.useEffect(() => {}, []) + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{scale: scale.value}], + })) return ( - <Animated.View + <View style={[ - a.py_sm, - a.rounded_full, - a.shadow_sm, - a.border, - t.atoms.bg_contrast_50, - t.atoms.border_contrast_medium, + a.absolute, + a.w_full, + a.z_10, + a.align_center, { - position: 'absolute', - bottom: 70, - width: '40%', - left: '30%', - alignItems: 'center', - shadowOpacity: 0.125, - shadowRadius: 12, - shadowOffset: {width: 0, height: 5}, + bottom: bottomOffset + 70, + // Don't prevent scrolling in this area _except_ for in the pill itself + pointerEvents: 'box-none', }, - ]} - entering={ScaleAndFadeIn} - exiting={ScaleAndFadeOut}> - <View style={{flex: 1}}> + ]}> + <AnimatedPressable + style={[ + a.py_sm, + a.rounded_full, + a.shadow_sm, + a.border, + t.atoms.bg_contrast_50, + t.atoms.border_contrast_medium, + { + width: 160, + alignItems: 'center', + shadowOpacity: 0.125, + shadowRadius: 12, + shadowOffset: {width: 0, height: 5}, + pointerEvents: 'box-only', + }, + animatedStyle, + ]} + entering={ScaleAndFadeIn} + exiting={ScaleAndFadeOut} + onPress={onPress} + onPressIn={onPressIn} + onPressOut={onPressOut}> <Text style={[a.font_bold]}> <Trans>New messages</Trans> </Text> - </View> - </Animated.View> + </AnimatedPressable> + </View> ) } diff --git a/src/screens/Messages/Conversation/MessageInput.tsx b/src/screens/Messages/Conversation/MessageInput.tsx index 9deecfd49..1e33efdf6 100644 --- a/src/screens/Messages/Conversation/MessageInput.tsx +++ b/src/screens/Messages/Conversation/MessageInput.tsx @@ -58,6 +58,9 @@ export function MessageInput({ onSendMessage(message.trimEnd()) playHaptic() setMessage('') + + // 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) diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx index b0723c020..68e68b8cb 100644 --- a/src/screens/Messages/Conversation/MessagesList.tsx +++ b/src/screens/Messages/Conversation/MessagesList.tsx @@ -1,10 +1,12 @@ import React, {useCallback, useRef} from 'react' import {FlatList, View} from 'react-native' -import Animated, { +import { + KeyboardStickyView, + useKeyboardHandler, +} from 'react-native-keyboard-controller' +import { runOnJS, scrollTo, - useAnimatedKeyboard, - useAnimatedReaction, useAnimatedRef, useAnimatedStyle, useSharedValue, @@ -24,7 +26,6 @@ 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 {atoms as a} from '#/alf' import {MessageItem} from '#/components/dms/MessageItem' import {NewMessagesPill} from '#/components/dms/NewMessagesPill' import {Loader} from '#/components/Loader' @@ -80,7 +81,10 @@ export function MessagesList({ const flatListRef = useAnimatedRef<FlatList>() - const [showNewMessagesPill, setShowNewMessagesPill] = React.useState(false) + const [newMessagesPill, setNewMessagesPill] = React.useState({ + show: false, + startContentOffset: 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 @@ -95,8 +99,14 @@ export function MessagesList({ const prevContentHeight = useRef(0) const prevItemCount = useRef(0) - const isDragging = useSharedValue(false) + // -- 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 @@ -123,24 +133,28 @@ export function MessagesList({ // 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 should only - // scroll 1 screen down, and let the user scroll the rest. However, because a single message could be - // really large - and the normal chat behavior would be to still scroll to the end if it's only one - // message - we ignore this rule if there's only one additional message + // If 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: height - layoutHeight.value + 50, - animated: hasScrolled, + offset: prevContentHeight.current - 65, + animated: true, + }) + setNewMessagesPill({ + show: true, + startContentOffset: prevContentHeight.current - 65, }) - setShowNewMessagesPill(true) } else { flatListRef.current?.scrollToOffset({ offset: height, - animated: hasScrolled, + animated: hasScrolled && height > prevContentHeight.current, }) // HACK Unfortunately, we need to call `setHasScrolled` after a brief delay, @@ -158,6 +172,7 @@ export function MessagesList({ prevContentHeight.current = height prevItemCount.current = convoState.items.length + didBackground.current = false }, [ hasScrolled, @@ -172,88 +187,66 @@ export function MessagesList({ ], ) - const onBeginDrag = React.useCallback(() => { - 'worklet' - isDragging.value = true - }, [isDragging]) - - const onEndDrag = React.useCallback(() => { - 'worklet' - isDragging.value = false - }, [isDragging]) - const onStartReached = useCallback(() => { - if (hasScrolled) { + if (hasScrolled && prevContentHeight.current > layoutHeight.value) { convoState.fetchMessageHistory() } - }, [convoState, hasScrolled]) + }, [convoState, hasScrolled, layoutHeight.value]) const onScroll = React.useCallback( (e: ReanimatedScrollEvent) => { 'worklet' layoutHeight.value = e.layoutMeasurement.height - const bottomOffset = e.contentOffset.y + e.layoutMeasurement.height - if ( - showNewMessagesPill && - e.contentSize.height - e.layoutMeasurement.height / 3 < bottomOffset - ) { - runOnJS(setShowNewMessagesPill)(false) - } - // 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, showNewMessagesPill, isAtBottom, isAtTop], + [layoutHeight, newMessagesPill, isAtBottom, isAtTop], ) // -- Keyboard animation handling - const animatedKeyboard = useAnimatedKeyboard() const {bottom: bottomInset} = useSafeAreaInsets() const nativeBottomBarHeight = isIOS ? 42 : 60 const bottomOffset = isWeb ? 0 : bottomInset + nativeBottomBarHeight - const finalKeyboardHeight = useSharedValue(0) - - // On web, we don't want to do anything. - // On native, we want to scroll the list to the bottom every frame that the keyboard is opening. `scrollTo` runs - // on the UI thread - directly calling `scrollTo` on the underlying native component, so we achieve 60 FPS. - useAnimatedReaction( - () => animatedKeyboard.height.value, - (now, prev) => { - 'worklet' - // This never applies on web - if (isWeb) { - return - } - // We are setting some arbitrarily high number here to ensure that we end up scrolling to the bottom. There is not - // any other way to synchronously scroll to the bottom of the list, since we cannot get the content size of the - // scrollview synchronously. - // On iOS we could have used `dispatchCommand('scrollToEnd', [])` since the underlying view has a `scrollToEnd` - // method. It doesn't exist on Android though. That's probably why `scrollTo` which is implemented in Reanimated - // doesn't support a `scrollToEnd`. - if (prev && now > 0 && now >= prev) { - scrollTo(flatListRef, 0, 1e7, false) - } + const keyboardHeight = useSharedValue(0) + const keyboardIsOpening = useSharedValue(false) - // We want to store the full keyboard height after it fully opens so we can make some - // assumptions in onLayout - if (finalKeyboardHeight.value === 0 && prev && now > 0 && now === prev) { - finalKeyboardHeight.value = now + useKeyboardHandler({ + onStart: () => { + 'worklet' + keyboardIsOpening.value = true + }, + onMove: e => { + 'worklet' + keyboardHeight.value = e.height + if (e.height > bottomOffset) { + scrollTo(flatListRef, 0, 1e7, false) } }, - ) + onEnd: () => { + 'worklet' + keyboardIsOpening.value = false + }, + }) - // This changes the size of the `ListFooterComponent`. Whenever this changes, the content size will change and our - // `onContentSizeChange` function will handle scrolling to the appropriate offset. - const animatedStyle = useAnimatedStyle(() => ({ + const animatedListStyle = useAnimatedStyle(() => ({ marginBottom: - animatedKeyboard.height.value > bottomOffset - ? animatedKeyboard.height.value - : bottomOffset, + keyboardHeight.value > bottomOffset ? keyboardHeight.value : bottomOffset, })) // -- Message sending @@ -282,36 +275,25 @@ export function MessagesList({ [convoState, getAgent], ) - // Any time the List layout changes, we want to scroll to the bottom. This only happens whenever - // the _lists_ size changes, _not_ the content size which is handled by `onContentSizeChange`. - // This accounts for things like the emoji keyboard opening, changes in block state, etc. + // -- List layout changes (opening emoji keyboard, etc.) const onListLayout = React.useCallback(() => { - if (isDragging.value) return - - const kh = animatedKeyboard.height.value - const fkh = finalKeyboardHeight.value - - // We only run the layout scroll if: - // - We're on web - // - The keyboard is not open. This accounts for changing block states - // - The final keyboard height has been initially set and the keyboard height is greater than that - if (isWeb || kh === 0 || (fkh > 0 && kh >= fkh)) { + if (keyboardIsOpening.value) return + if (isWeb || !keyboardIsOpening.value) { flatListRef.current?.scrollToEnd({animated: true}) } - }, [ - flatListRef, - finalKeyboardHeight.value, - animatedKeyboard.height.value, - isDragging.value, - ]) + }, [flatListRef, keyboardIsOpening.value]) + + const scrollToEndOnPress = React.useCallback(() => { + flatListRef.current?.scrollToOffset({ + offset: prevContentHeight.current, + animated: true, + }) + }, [flatListRef]) return ( - <Animated.View style={[a.flex_1, animatedStyle]}> + <> {/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */} - <ScrollProvider - onScroll={onScroll} - onBeginDrag={onBeginDrag} - onEndDrag={onEndDrag}> + <ScrollProvider onScroll={onScroll}> <List ref={flatListRef} data={convoState.items} @@ -319,13 +301,14 @@ export function MessagesList({ keyExtractor={keyExtractor} containWeb={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: 1, + minIndexForVisible: 0, }} removeClippedSubviews={false} sideBorders={false} @@ -339,18 +322,20 @@ export function MessagesList({ } /> </ScrollProvider> - {!blocked ? ( - <> - {convoState.status === ConvoStatus.Disabled ? ( - <ChatDisabled /> - ) : ( - <MessageInput onSendMessage={onSendMessage} /> - )} - </> - ) : ( - footer - )} - {showNewMessagesPill && <NewMessagesPill />} - </Animated.View> + <KeyboardStickyView offset={{closed: -bottomOffset, opened: 0}}> + {!blocked ? ( + <> + {convoState.status === ConvoStatus.Disabled ? ( + <ChatDisabled /> + ) : ( + <MessageInput onSendMessage={onSendMessage} /> + )} + </> + ) : ( + footer + )} + </KeyboardStickyView> + {newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />} + </> ) } diff --git a/src/screens/Messages/Conversation/index.tsx b/src/screens/Messages/Conversation/index.tsx index 8e806ff7a..63175b551 100644 --- a/src/screens/Messages/Conversation/index.tsx +++ b/src/screens/Messages/Conversation/index.tsx @@ -83,6 +83,14 @@ function Inner() { !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> diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 61c339024..d85fca299 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -3,13 +3,15 @@ import { ActivityIndicator, BackHandler, Keyboard, - KeyboardAvoidingView, - Platform, ScrollView, StyleSheet, TouchableOpacity, View, } from 'react-native' +import { + KeyboardAvoidingView, + KeyboardStickyView, +} from 'react-native-keyboard-controller' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {LinearGradient} from 'expo-linear-gradient' import {RichText} from '@atproto/api' @@ -373,172 +375,178 @@ export const ComposePost = observer(function ComposePost({ ) return ( - <KeyboardAvoidingView - testID="composePostView" - behavior={Platform.OS === 'ios' ? 'padding' : 'height'} - style={styles.outer}> - <View style={[s.flex1, viewStyles]} aria-modal accessibilityViewIsModal> - <View style={[styles.topbar, isDesktop && styles.topbarDesktop]}> - <TouchableOpacity - testID="composerDiscardButton" - onPress={onPressCancel} - onAccessibilityEscape={onPressCancel} - accessibilityRole="button" - accessibilityLabel={_(msg`Cancel`)} - accessibilityHint={_( - msg`Closes post composer and discards post draft`, - )}> - <Text style={[pal.link, s.f18]}> - <Trans>Cancel</Trans> - </Text> - </TouchableOpacity> - <View style={s.flex1} /> - {isProcessing ? ( - <> - <Text style={pal.textLight}>{processingState}</Text> - <View style={styles.postBtn}> - <ActivityIndicator /> - </View> - </> - ) : ( - <> - <LabelsBtn - labels={labels} - onChange={setLabels} - hasMedia={hasMedia} - /> - {replyTo ? null : ( - <ThreadgateBtn - threadgate={threadgate} - onChange={setThreadgate} + <> + <KeyboardAvoidingView + testID="composePostView" + behavior="padding" + style={s.flex1} + keyboardVerticalOffset={60}> + <View style={[s.flex1, viewStyles]} aria-modal accessibilityViewIsModal> + <View style={[styles.topbar, isDesktop && styles.topbarDesktop]}> + <TouchableOpacity + testID="composerDiscardButton" + onPress={onPressCancel} + onAccessibilityEscape={onPressCancel} + accessibilityRole="button" + accessibilityLabel={_(msg`Cancel`)} + accessibilityHint={_( + msg`Closes post composer and discards post draft`, + )}> + <Text style={[pal.link, s.f18]}> + <Trans>Cancel</Trans> + </Text> + </TouchableOpacity> + <View style={s.flex1} /> + {isProcessing ? ( + <> + <Text style={pal.textLight}>{processingState}</Text> + <View style={styles.postBtn}> + <ActivityIndicator /> + </View> + </> + ) : ( + <> + <LabelsBtn + labels={labels} + onChange={setLabels} + hasMedia={hasMedia} /> - )} - {canPost ? ( - <TouchableOpacity - testID="composerPublishBtn" - onPress={onPressPublish} - accessibilityRole="button" - accessibilityLabel={ - replyTo ? _(msg`Publish reply`) : _(msg`Publish post`) - } - accessibilityHint=""> - <LinearGradient - colors={[ - gradients.blueLight.start, - gradients.blueLight.end, - ]} - start={{x: 0, y: 0}} - end={{x: 1, y: 1}} - style={styles.postBtn}> - <Text style={[s.white, s.f16, s.bold]}> - {replyTo ? ( - <Trans context="action">Reply</Trans> - ) : ( - <Trans context="action">Post</Trans> - )} + {replyTo ? null : ( + <ThreadgateBtn + threadgate={threadgate} + onChange={setThreadgate} + /> + )} + {canPost ? ( + <TouchableOpacity + testID="composerPublishBtn" + onPress={onPressPublish} + accessibilityRole="button" + accessibilityLabel={ + replyTo ? _(msg`Publish reply`) : _(msg`Publish post`) + } + accessibilityHint=""> + <LinearGradient + colors={[ + gradients.blueLight.start, + gradients.blueLight.end, + ]} + start={{x: 0, y: 0}} + end={{x: 1, y: 1}} + style={styles.postBtn}> + <Text style={[s.white, s.f16, s.bold]}> + {replyTo ? ( + <Trans context="action">Reply</Trans> + ) : ( + <Trans context="action">Post</Trans> + )} + </Text> + </LinearGradient> + </TouchableOpacity> + ) : ( + <View style={[styles.postBtn, pal.btn]}> + <Text style={[pal.textLight, s.f16, s.bold]}> + <Trans context="action">Post</Trans> </Text> - </LinearGradient> - </TouchableOpacity> - ) : ( - <View style={[styles.postBtn, pal.btn]}> - <Text style={[pal.textLight, s.f16, s.bold]}> - <Trans context="action">Post</Trans> - </Text> - </View> - )} - </> - )} - </View> - {isAltTextRequiredAndMissing && ( - <View style={[styles.reminderLine, pal.viewLight]}> - <View style={styles.errorIcon}> - <FontAwesomeIcon - icon="exclamation" - style={{color: colors.red4}} - size={10} - /> - </View> - <Text style={[pal.text, s.flex1]}> - <Trans>One or more images is missing alt text.</Trans> - </Text> + </View> + )} + </> + )} </View> - )} - {error !== '' && ( - <View style={styles.errorLine}> - <View style={styles.errorIcon}> - <FontAwesomeIcon - icon="exclamation" - style={{color: colors.red4}} - size={10} - /> + {isAltTextRequiredAndMissing && ( + <View style={[styles.reminderLine, pal.viewLight]}> + <View style={styles.errorIcon}> + <FontAwesomeIcon + icon="exclamation" + style={{color: colors.red4}} + size={10} + /> + </View> + <Text style={[pal.text, s.flex1]}> + <Trans>One or more images is missing alt text.</Trans> + </Text> </View> - <Text style={[s.red4, s.flex1]}>{error}</Text> - </View> - )} - <ScrollView - style={styles.scrollView} - keyboardShouldPersistTaps="always"> - {replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined} - - <View - style={[ - pal.border, - styles.textInputLayout, - isNative && styles.textInputLayoutMobile, - ]}> - <UserAvatar - avatar={currentProfile?.avatar} - size={50} - type={currentProfile?.associated?.labeler ? 'labeler' : 'user'} - /> - <TextInput - ref={textInput} - richtext={richtext} - placeholder={selectTextInputPlaceholder} - autoFocus={true} - setRichText={setRichText} - onPhotoPasted={onPhotoPasted} - onPressPublish={onPressPublish} - onNewLink={onNewLink} - onError={setError} - accessible={true} - accessibilityLabel={_(msg`Write post`)} - accessibilityHint={_( - msg`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`, - )} - /> - </View> + )} + {error !== '' && ( + <View style={styles.errorLine}> + <View style={styles.errorIcon}> + <FontAwesomeIcon + icon="exclamation" + style={{color: colors.red4}} + size={10} + /> + </View> + <Text style={[s.red4, s.flex1]}>{error}</Text> + </View> + )} + <ScrollView + style={styles.scrollView} + keyboardShouldPersistTaps="always"> + {replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined} - <Gallery gallery={gallery} /> - {gallery.isEmpty && extLink && ( - <View style={a.relative}> - <ExternalEmbed - link={extLink} - gif={extGif} - onRemove={() => { - setExtLink(undefined) - setExtGif(undefined) - }} + <View + style={[ + pal.border, + styles.textInputLayout, + isNative && styles.textInputLayoutMobile, + ]}> + <UserAvatar + avatar={currentProfile?.avatar} + size={50} + type={currentProfile?.associated?.labeler ? 'labeler' : 'user'} /> - <GifAltText - link={extLink} - gif={extGif} - onSubmit={handleChangeGifAltText} + <TextInput + ref={textInput} + richtext={richtext} + placeholder={selectTextInputPlaceholder} + autoFocus={true} + setRichText={setRichText} + onPhotoPasted={onPhotoPasted} + onPressPublish={onPressPublish} + onNewLink={onNewLink} + onError={setError} + accessible={true} + accessibilityLabel={_(msg`Write post`)} + accessibilityHint={_( + msg`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`, + )} /> </View> - )} - {quote ? ( - <View style={[s.mt5, isWeb && s.mb10]}> - <View style={{pointerEvents: 'none'}}> - <QuoteEmbed quote={quote} /> + + <Gallery gallery={gallery} /> + {gallery.isEmpty && extLink && ( + <View style={a.relative}> + <ExternalEmbed + link={extLink} + gif={extGif} + onRemove={() => { + setExtLink(undefined) + setExtGif(undefined) + }} + /> + <GifAltText + link={extLink} + gif={extGif} + onSubmit={handleChangeGifAltText} + /> </View> - {quote.uri !== initQuote?.uri && ( - <QuoteX onRemove={() => setQuote(undefined)} /> - )} - </View> - ) : undefined} - </ScrollView> - <SuggestedLanguage text={richtext.text} /> + )} + {quote ? ( + <View style={[s.mt5, isWeb && s.mb10]}> + <View style={{pointerEvents: 'none'}}> + <QuoteEmbed quote={quote} /> + </View> + {quote.uri !== initQuote?.uri && ( + <QuoteX onRemove={() => setQuote(undefined)} /> + )} + </View> + ) : undefined} + </ScrollView> + <SuggestedLanguage text={richtext.text} /> + </View> + </KeyboardAvoidingView> + <KeyboardStickyView + offset={{closed: isIOS ? -insets.bottom : 0, opened: 0}}> <View style={[pal.border, styles.bottomBar]}> <View style={[a.flex_row, a.align_center, a.gap_xs]}> <SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} /> @@ -565,8 +573,7 @@ export const ComposePost = observer(function ComposePost({ <SelectLangBtn /> <CharProgress count={graphemeLength} /> </View> - </View> - + </KeyboardStickyView> <Prompt.Basic control={discardPromptControl} title={_(msg`Discard draft?`)} @@ -575,7 +582,7 @@ export const ComposePost = observer(function ComposePost({ confirmButtonCta={_(msg`Discard`)} confirmButtonColor="negative" /> - </KeyboardAvoidingView> + </> ) }) |