diff --git a/native/chat/text-message-tooltip-modal.react.js b/native/chat/text-message-tooltip-modal.react.js --- a/native/chat/text-message-tooltip-modal.react.js +++ b/native/chat/text-message-tooltip-modal.react.js @@ -46,18 +46,18 @@ const spec = { entries: [ - { id: 'copy', text: 'Copy', onPress: onPressCopy }, { id: 'reply', text: 'Reply', onPress: onPressReply }, - { - id: 'report', - text: 'Report', - onPress: onPressReport, - }, { id: 'sidebar', text: 'Thread', onPress: navigateToSidebar, }, + { id: 'copy', text: 'Copy', onPress: onPressCopy }, + { + id: 'report', + text: 'Report', + onPress: onPressReport, + }, ], }; diff --git a/native/navigation/tooltip.react.js b/native/navigation/tooltip.react.js --- a/native/navigation/tooltip.react.js +++ b/native/navigation/tooltip.react.js @@ -1,5 +1,9 @@ // @flow +import { + useActionSheet, + type ShowActionSheetWithOptions, +} from '@expo/react-native-action-sheet'; import type { RouteProp } from '@react-navigation/native'; import * as Haptics from 'expo-haptics'; import invariant from 'invariant'; @@ -10,6 +14,7 @@ TouchableWithoutFeedback, Platform, TouchableOpacity, + Keyboard, } from 'react-native'; import Animated from 'react-native-reanimated'; import { useDispatch } from 'react-redux'; @@ -111,7 +116,11 @@ // withInputState +inputState: ?InputState, +chatContext: ?ChatContextType, + +showActionSheetWithOptions: ShowActionSheetWithOptions, + +actionSheetShown: boolean, + +setActionSheetShown: (actionSheetShown: boolean) => void, }; + function createTooltip< RouteName: $Keys<TooltipModalParamList>, BaseTooltipPropsType: BaseTooltipProps<RouteName> = BaseTooltipProps<RouteName>, @@ -138,6 +147,10 @@ size={16} /> ); + } else if (this.props.spec.id === 'more') { + icon = ( + <SWMansionIcon name="menu-vertical" style={styles.icon} size={16} /> + ); } return ( @@ -363,6 +376,9 @@ overlayContext, inputState, chatContext, + showActionSheetWithOptions, + actionSheetShown, + setActionSheetShown, ...navAndRouteForFlow } = this.props; @@ -391,6 +407,27 @@ ); }); + if (this.location === 'fixed' && entries.length > 3) { + items.splice(3); + + const moreSpec = { + id: 'more', + text: 'More', + onPress: this.onPressMore, + }; + + const moreTooltipItem = ( + <TooltipItem + key={entries.length} + spec={moreSpec} + onPress={moreSpec.onPress} + containerStyle={tooltipContainerStyle} + /> + ); + + items.push(moreTooltipItem); + } + let triangleStyle; const { route } = this.props; const { initialCoordinates } = route.params; @@ -434,6 +471,11 @@ itemsStyle.push(styles.itemsFixed); } + let tooltip = <View style={itemsStyle}>{items}</View>; + if (this.props.actionSheetShown) { + tooltip = null; + } + return ( <TouchableWithoutFeedback onPress={this.onPressBackdrop}> <View style={styles.container}> @@ -448,7 +490,7 @@ onLayout={this.onTooltipContainerLayout} > {triangleUp} - <View style={itemsStyle}>{items}</View> + {tooltip} {triangleDown} </AnimatedView> </View> @@ -477,6 +519,87 @@ ); }; + onPressMore = () => { + Keyboard.dismiss(); + this.props.setActionSheetShown(true); + + const { entries } = this; + const options = entries.map(entry => entry.text); + + const { + destructiveButtonIndex, + cancelButtonIndex, + } = this.getPlatformSpecificButtonIndices(options); + + // We're reversing options to populate the action sheet from bottom to + // top instead of the default (top to bottom) ordering. + options.reverse(); + + const containerStyle = { + paddingBottom: 24, + }; + + const icons = [ + <SWMansionIcon + key="report" + name="warning-circle" + style={styles.bottomSheetIcon} + size={16} + />, + <SWMansionIcon + key="copy" + name="copy" + style={styles.bottomSheetIcon} + size={16} + />, + <SWMansionIcon + key="thread" + name="message-circle-lines" + style={styles.bottomSheetIcon} + size={16} + />, + <CommIcon + key="reply" + name="reply" + style={styles.bottomSheetIcon} + size={12} + />, + ]; + + const onPressAction = (selectedIndex?: number) => { + if (selectedIndex === cancelButtonIndex) { + this.props.navigation.goBackOnce(); + return; + } + const index = entries.length - (selectedIndex ?? 0); + const entry = entries[Platform.OS === 'ios' ? index : index - 1]; + this.onPressEntry(entry); + }; + + this.props.showActionSheetWithOptions( + { + options, + cancelButtonIndex, + destructiveButtonIndex, + containerStyle, + icons, + }, + onPressAction, + ); + }; + + getPlatformSpecificButtonIndices = (options: Array<string>) => { + const destructiveButtonIndex = Platform.OS === 'ios' ? 1 : undefined; + const cancelButtonIndex = Platform.OS === 'ios' ? 0 : -1; + + // The "Cancel" action is iOS-specific + if (Platform.OS === 'ios') { + options.push('Cancel'); + } + + return { destructiveButtonIndex, cancelButtonIndex }; + }; + bindServerCall = <F>(serverCall: ActionFunc<F>): F => { const { cookie, @@ -515,6 +638,8 @@ return React.memo<BaseTooltipPropsType>(function ConnectedTooltip( props: BaseTooltipPropsType, ) { + const { showActionSheetWithOptions } = useActionSheet(); + const dimensions = useSelector(state => state.dimensions); const serverCallState = useSelector(serverCallStateSelector); const viewerID = useSelector( @@ -525,6 +650,11 @@ const overlayContext = React.useContext(OverlayContext); const inputState = React.useContext(InputStateContext); const chatContext = React.useContext(ChatContext); + + const [actionSheetShown, setActionSheetShown] = React.useState<boolean>( + false, + ); + return ( <Tooltip {...props} @@ -536,6 +666,9 @@ overlayContext={overlayContext} inputState={inputState} chatContext={chatContext} + showActionSheetWithOptions={showActionSheetWithOptions} + actionSheetShown={actionSheetShown} + setActionSheetShown={setActionSheetShown} /> ); }); @@ -550,6 +683,9 @@ right: 0, top: 0, }, + bottomSheetIcon: { + color: '#000000', + }, container: { flex: 1, }, diff --git a/native/root.react.js b/native/root.react.js --- a/native/root.react.js +++ b/native/root.react.js @@ -1,5 +1,6 @@ // @flow +import { ActionSheetProvider } from '@expo/react-native-action-sheet'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { useReduxDevToolsExtension } from '@react-navigation/devtools'; import { NavigationContainer } from '@react-navigation/native'; @@ -248,23 +249,25 @@ <RootContext.Provider value={rootContext}> <InputStateContainer> <SafeAreaProvider initialMetrics={initialWindowMetrics}> - <ChatContextProvider> - <SQLiteContextProvider> - <ConnectedStatusBar /> - <ReduxPersistGate persistor={getPersistor()}> - {gated} - </ReduxPersistGate> - <PersistedStateGate> - <Socket - detectUnsupervisedBackgroundRef={ - detectUnsupervisedBackgroundRef - } - /> - </PersistedStateGate> - {navigation} - <NavigationHandler /> - </SQLiteContextProvider> - </ChatContextProvider> + <ActionSheetProvider> + <ChatContextProvider> + <SQLiteContextProvider> + <ConnectedStatusBar /> + <ReduxPersistGate persistor={getPersistor()}> + {gated} + </ReduxPersistGate> + <PersistedStateGate> + <Socket + detectUnsupervisedBackgroundRef={ + detectUnsupervisedBackgroundRef + } + /> + </PersistedStateGate> + {navigation} + <NavigationHandler /> + </SQLiteContextProvider> + </ChatContextProvider> + </ActionSheetProvider> </SafeAreaProvider> </InputStateContainer> </RootContext.Provider>