diff --git a/native/chat/chat.react.js b/native/chat/chat.react.js index 89583f08a..ee1261da6 100644 --- a/native/chat/chat.react.js +++ b/native/chat/chat.react.js @@ -1,352 +1,355 @@ // @flow import { createMaterialTopTabNavigator, type MaterialTopTabNavigationProp, } from '@react-navigation/material-top-tabs'; import { createNavigatorFactory, useNavigationBuilder, type StackNavigationState, type StackOptions, type StackNavigationEventMap, type StackNavigatorProps, type ExtraStackNavigatorProps, type StackHeaderProps as CoreStackHeaderProps, type StackNavigationProp, type StackNavigationHelpers, type ParamListBase, } from '@react-navigation/native'; import { StackView, type StackHeaderProps } from '@react-navigation/stack'; import invariant from 'invariant'; import * as React from 'react'; -import { Platform, View } from 'react-native'; +import { Platform, View, useWindowDimensions } from 'react-native'; import { useSelector } from 'react-redux'; import ThreadDraftUpdater from 'lib/components/thread-draft-updater.react'; import { isLoggedIn } from 'lib/selectors/user-selectors'; import { threadIsPending, threadMembersWithoutAddedAshoat, } from 'lib/shared/thread-utils'; import { firstLine } from 'lib/utils/string-utils'; import KeyboardAvoidingView from '../components/keyboard-avoiding-view.react'; import SWMansionIcon from '../components/swmansion-icon.react'; import { InputStateContext } from '../input/input-state'; import CommunityDrawerButton from '../navigation/community-drawer-button.react'; import type { CommunityDrawerNavigationProp } from '../navigation/community-drawer-navigator.react'; import HeaderBackButton from '../navigation/header-back-button.react'; import { defaultStackScreenOptions } from '../navigation/options'; import { ComposeSubchannelRouteName, DeleteThreadRouteName, ThreadSettingsRouteName, MessageListRouteName, ChatThreadListRouteName, HomeChatThreadListRouteName, BackgroundChatThreadListRouteName, type ScreenParamList, type ChatParamList, type ChatTopTabsParamList, } from '../navigation/route-names'; import { useColors, useStyles } from '../themes/colors'; import BackgroundChatThreadList from './background-chat-thread-list.react'; import ChatHeader from './chat-header.react'; import ChatRouter, { type ChatRouterNavigationHelpers } from './chat-router'; import ComposeSubchannel from './compose-subchannel.react'; import ComposeThreadButton from './compose-thread-button.react'; import HomeChatThreadList from './home-chat-thread-list.react'; import MessageListContainer from './message-list-container.react'; import MessageListHeaderTitle from './message-list-header-title.react'; import MessageStorePruner from './message-store-pruner.react'; import DeleteThread from './settings/delete-thread.react'; import ThreadSettings from './settings/thread-settings.react'; import ThreadScreenPruner from './thread-screen-pruner.react'; import ThreadSettingsButton from './thread-settings-button.react'; const unboundStyles = { keyboardAvoidingView: { flex: 1, }, view: { flex: 1, backgroundColor: 'listBackground', }, threadListHeaderStyle: { elevation: 0, shadowOffset: { width: 0, height: 0 }, borderBottomWidth: 0, backgroundColor: 'tabBarBackground', }, }; export type ChatTopTabsNavigationProp< RouteName: $Keys = $Keys, > = MaterialTopTabNavigationProp; const homeChatThreadListOptions = { title: 'Focused', // eslint-disable-next-line react/display-name tabBarIcon: ({ color }) => ( ), }; const backgroundChatThreadListOptions = { title: 'Background', // eslint-disable-next-line react/display-name tabBarIcon: ({ color }) => ( ), }; const ChatThreadsTopTab = createMaterialTopTabNavigator(); function ChatThreadsComponent(): React.Node { const colors = useColors(); const { tabBarBackground, tabBarAccent } = colors; const screenOptions = React.useMemo( () => ({ tabBarShowIcon: true, tabBarStyle: { backgroundColor: tabBarBackground, }, tabBarItemStyle: { flexDirection: 'row', }, tabBarIndicatorStyle: { borderColor: tabBarAccent, borderBottomWidth: 2, }, }), [tabBarAccent, tabBarBackground], ); return ( ); } export type ChatNavigationHelpers = { ...$Exact>, ...ChatRouterNavigationHelpers, }; type ChatNavigatorProps = StackNavigatorProps>; function ChatNavigator({ initialRouteName, children, screenOptions, defaultScreenOptions, screenListeners, id, ...rest }: ChatNavigatorProps) { const { state, descriptors, navigation } = useNavigationBuilder(ChatRouter, { id, initialRouteName, children, screenOptions, defaultScreenOptions, screenListeners, }); // Clear ComposeSubchannel screens after each message is sent. If a user goes // to ComposeSubchannel to create a new thread, but finds an existing one and // uses it instead, we can assume the intent behind opening ComposeSubchannel // is resolved const inputState = React.useContext(InputStateContext); invariant(inputState, 'InputState should be set in ChatNavigator'); const clearComposeScreensAfterMessageSend = React.useCallback(() => { navigation.clearScreens([ComposeSubchannelRouteName]); }, [navigation]); React.useEffect(() => { inputState.registerSendCallback(clearComposeScreensAfterMessageSend); return () => { inputState.unregisterSendCallback(clearComposeScreensAfterMessageSend); }; }, [inputState, clearComposeScreensAfterMessageSend]); return ( ); } const createChatNavigator = createNavigatorFactory< StackNavigationState, StackOptions, StackNavigationEventMap, ChatNavigationHelpers<>, ExtraStackNavigatorProps, >(ChatNavigator); const header = (props: CoreStackHeaderProps) => { // Flow has trouble reconciling identical types between different libdefs, // and flow-typed has no way for one libdef to depend on another const castProps: StackHeaderProps = (props: any); return ; }; const messageListOptions = ({ navigation, route }) => { const isSearchEmpty = !!route.params.searching && threadMembersWithoutAddedAshoat(route.params.threadInfo).length === 1; const areSettingsEnabled = !threadIsPending(route.params.threadInfo.id) && !isSearchEmpty; return { // This is a render prop, not a component // eslint-disable-next-line react/display-name headerTitle: props => ( ), headerRight: Platform.OS === 'android' && areSettingsEnabled ? // This is a render prop, not a component // eslint-disable-next-line react/display-name () => ( ) : undefined, headerBackTitleVisible: false, }; }; const composeThreadOptions = { headerTitle: 'Compose chat', headerBackTitleVisible: false, }; const threadSettingsOptions = ({ route }) => ({ headerTitle: firstLine(route.params.threadInfo.uiName), headerBackTitleVisible: false, }); const deleteThreadOptions = { headerTitle: 'Delete chat', headerBackTitleVisible: false, }; export type ChatNavigationProp< RouteName: $Keys = $Keys, > = { ...StackNavigationProp, ...ChatRouterNavigationHelpers, }; const Chat = createChatNavigator< ScreenParamList, ChatParamList, ChatNavigationHelpers, >(); type Props = { +navigation: CommunityDrawerNavigationProp<'TabNavigator'>, ... }; export default function ChatComponent(props: Props): React.Node { const styles = useStyles(unboundStyles); const colors = useColors(); const loggedIn = useSelector(isLoggedIn); let draftUpdater = null; if (loggedIn) { draftUpdater = ; } const headerLeftButton = React.useCallback( headerProps => { if (headerProps.canGoBack) { return ; } return ; }, [props.navigation], ); + const { width: screenWidth } = useWindowDimensions(); const screenOptions = React.useMemo( () => ({ ...defaultStackScreenOptions, header, headerLeft: headerLeftButton, headerStyle: { backgroundColor: colors.tabBarBackground, borderBottomWidth: 1, }, + gestureEnabled: true, + gestureResponseDistance: screenWidth, }), - [colors.tabBarBackground, headerLeftButton], + [colors.tabBarBackground, headerLeftButton, screenWidth], ); const chatThreadListOptions = React.useCallback( ({ navigation }) => ({ headerTitle: 'Inbox', headerRight: Platform.OS === 'ios' ? () => : undefined, headerBackTitleVisible: false, headerStyle: styles.threadListHeaderStyle, }), [styles.threadListHeaderStyle], ); return ( {draftUpdater} ); } diff --git a/native/chat/swipeable-message.react.js b/native/chat/swipeable-message.react.js index 8a8abb765..9e009e228 100644 --- a/native/chat/swipeable-message.react.js +++ b/native/chat/swipeable-message.react.js @@ -1,366 +1,362 @@ // @flow import type { IconProps } from '@expo/vector-icons'; -import { GestureHandlerRefContext } from '@react-navigation/stack'; import * as Haptics from 'expo-haptics'; import * as React from 'react'; import { View } from 'react-native'; import { PanGestureHandler } from 'react-native-gesture-handler'; import Animated, { useAnimatedGestureHandler, useSharedValue, useAnimatedStyle, runOnJS, withSpring, interpolate, cancelAnimation, Extrapolate, type SharedValue, } from 'react-native-reanimated'; import tinycolor from 'tinycolor2'; import CommIcon from '../components/comm-icon.react'; import { colors } from '../themes/colors'; import type { ViewStyle } from '../types/styles'; import { useMessageListScreenWidth } from './composed-message-width'; const primaryThreshold = 40; const secondaryThreshold = 120; function dividePastDistance(value, distance, factor) { 'worklet'; const absValue = Math.abs(value); if (absValue < distance) { return value; } const absFactor = value >= 0 ? 1 : -1; return absFactor * (distance + (absValue - distance) / factor); } function makeSpringConfig(velocity) { 'worklet'; return { stiffness: 257.1370588235294, damping: 19.003038357561845, mass: 1, overshootClamping: true, restDisplacementThreshold: 0.001, restSpeedThreshold: 0.001, velocity, }; } function interpolateOpacityForViewerPrimarySnake(translateX) { 'worklet'; return interpolate(translateX, [-20, -5], [1, 0], Extrapolate.CLAMP); } function interpolateOpacityForNonViewerPrimarySnake(translateX) { 'worklet'; return interpolate(translateX, [5, 20], [0, 1], Extrapolate.CLAMP); } function interpolateTranslateXForViewerSecondarySnake(translateX) { 'worklet'; return interpolate(translateX, [-130, -120, -60, 0], [-130, -120, -5, 20]); } function interpolateTranslateXForNonViewerSecondarySnake(translateX) { 'worklet'; return interpolate(translateX, [0, 80, 120, 130], [0, 30, 120, 130]); } type SwipeSnakeProps = { +isViewer: boolean, +translateX: SharedValue, +color: string, +children: React.Element>>, +opacityInterpolator?: number => number, // must be worklet +translateXInterpolator?: number => number, // must be worklet }; function SwipeSnake( props: SwipeSnakeProps, ): React.Node { const { translateX, isViewer, opacityInterpolator, translateXInterpolator, } = props; const transformStyle = useAnimatedStyle(() => { const opacity = opacityInterpolator ? opacityInterpolator(translateX.value) : undefined; const translate = translateXInterpolator ? translateXInterpolator(translateX.value) : translateX.value; return { transform: [ { translateX: translate, }, ], opacity, }; }, [isViewer, translateXInterpolator, opacityInterpolator]); const animationPosition = isViewer ? styles.right0 : styles.left0; const animationContainerStyle = React.useMemo(() => { return [styles.animationContainer, animationPosition]; }, [animationPosition]); const iconPosition = isViewer ? styles.left0 : styles.right0; const swipeSnakeContainerStyle = React.useMemo(() => { return [styles.swipeSnakeContainer, transformStyle, iconPosition]; }, [transformStyle, iconPosition]); const iconAlign = isViewer ? styles.alignStart : styles.alignEnd; const screenWidth = useMessageListScreenWidth(); const { color } = props; const swipeSnakeStyle = React.useMemo(() => { return [ styles.swipeSnake, iconAlign, { width: screenWidth, backgroundColor: color, }, ]; }, [iconAlign, screenWidth, color]); const { children } = props; const iconColor = tinycolor(color).isDark() ? colors.dark.listForegroundLabel : colors.light.listForegroundLabel; const coloredIcon = React.useMemo( () => React.cloneElement(children, { color: iconColor, }), [children, iconColor], ); return ( {coloredIcon} ); } type Props = { +triggerReply?: () => void, +triggerSidebar?: () => void, +isViewer: boolean, +messageBoxStyle: ViewStyle, +threadColor: string, +children: React.Node, }; function SwipeableMessage(props: Props): React.Node { const { isViewer, triggerReply, triggerSidebar } = props; const secondaryActionExists = triggerReply && triggerSidebar; const onPassPrimaryThreshold = React.useCallback(() => { const impactStrength = secondaryActionExists ? Haptics.ImpactFeedbackStyle.Medium : Haptics.ImpactFeedbackStyle.Heavy; Haptics.impactAsync(impactStrength); }, [secondaryActionExists]); const onPassSecondaryThreshold = React.useCallback(() => { if (secondaryActionExists) { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy); } }, [secondaryActionExists]); const primaryAction = React.useCallback(() => { if (triggerReply) { triggerReply(); } else if (triggerSidebar) { triggerSidebar(); } }, [triggerReply, triggerSidebar]); const secondaryAction = React.useCallback(() => { if (triggerReply && triggerSidebar) { triggerSidebar(); } }, [triggerReply, triggerSidebar]); const translateX = useSharedValue(0); const swipeEvent = useAnimatedGestureHandler( { onStart: (event, ctx) => { ctx.translationAtStart = translateX.value; cancelAnimation(translateX.value); }, onActive: (event, ctx) => { const translationX = ctx.translationAtStart + event.translationX; const baseActiveTranslation = isViewer ? Math.min(translationX, 0) : Math.max(translationX, 0); translateX.value = dividePastDistance( baseActiveTranslation, primaryThreshold, 2, ); const absValue = Math.abs(translateX.value); const pastPrimaryThreshold = absValue >= primaryThreshold; if (pastPrimaryThreshold && !ctx.prevPastPrimaryThreshold) { runOnJS(onPassPrimaryThreshold)(); } ctx.prevPastPrimaryThreshold = pastPrimaryThreshold; const pastSecondaryThreshold = absValue >= secondaryThreshold; if (pastSecondaryThreshold && !ctx.prevPastSecondaryThreshold) { runOnJS(onPassSecondaryThreshold)(); } ctx.prevPastSecondaryThreshold = pastSecondaryThreshold; }, onEnd: event => { const absValue = Math.abs(translateX.value); if (absValue >= secondaryThreshold && secondaryActionExists) { runOnJS(secondaryAction)(); } else if (absValue >= primaryThreshold) { runOnJS(primaryAction)(); } translateX.value = withSpring(0, makeSpringConfig(event.velocityX)); }, }, [ isViewer, onPassPrimaryThreshold, onPassSecondaryThreshold, primaryAction, secondaryAction, secondaryActionExists, ], ); const transformMessageBoxStyle = useAnimatedStyle( () => ({ transform: [{ translateX: translateX.value }], }), [], ); const { messageBoxStyle, children } = props; - const reactNavGestureHandlerRef = React.useContext(GestureHandlerRefContext); - const waitFor = reactNavGestureHandlerRef ?? undefined; if (!triggerReply && !triggerSidebar) { return ( {children} ); } const threadColor = `#${props.threadColor}`; const tinyThreadColor = tinycolor(threadColor); const snakes = []; if (triggerReply) { const replySnakeOpacityInterpolator = isViewer ? interpolateOpacityForViewerPrimarySnake : interpolateOpacityForNonViewerPrimarySnake; snakes.push( , ); } if (triggerReply && triggerSidebar) { const sidebarSnakeTranslateXInterpolator = isViewer ? interpolateTranslateXForViewerSecondarySnake : interpolateTranslateXForNonViewerSecondarySnake; const darkerThreadColor = tinyThreadColor .darken(tinyThreadColor.isDark() ? 10 : 20) .toString(); snakes.push( , ); } else if (triggerSidebar) { const sidebarSnakeOpacityInterpolator = isViewer ? interpolateOpacityForViewerPrimarySnake : interpolateOpacityForNonViewerPrimarySnake; snakes.push( , ); } snakes.push( {children} , ); return snakes; } const styles = { swipeSnakeContainer: { marginHorizontal: 20, justifyContent: 'center', position: 'absolute', top: 0, bottom: 0, }, animationContainer: { position: 'absolute', top: 0, bottom: 0, }, swipeSnake: { paddingHorizontal: 15, flex: 1, borderRadius: 25, height: 30, justifyContent: 'center', maxHeight: 50, }, left0: { left: 0, }, right0: { right: 0, }, alignStart: { alignItems: 'flex-start', }, alignEnd: { alignItems: 'flex-end', }, }; export default SwipeableMessage; diff --git a/native/navigation/community-drawer-navigator.react.js b/native/navigation/community-drawer-navigator.react.js index d7e87ada6..bf43ecb9c 100644 --- a/native/navigation/community-drawer-navigator.react.js +++ b/native/navigation/community-drawer-navigator.react.js @@ -1,81 +1,83 @@ // @flow import { createDrawerNavigator, type DrawerNavigationHelpers, type DrawerNavigationProp, } from '@react-navigation/drawer'; import * as React from 'react'; -import { View } from 'react-native'; +import { View, useWindowDimensions } from 'react-native'; import { useStyles } from '../themes/colors'; import type { AppNavigationProp } from './app-navigator.react'; import CommunityDrawerContent from './community-drawer-content.react'; import { drawerSwipeEnabledSelector } from './nav-selectors'; import { NavContext } from './navigation-context'; import { TabNavigatorRouteName } from './route-names'; import type { NavigationRoute, ScreenParamList, CommunityDrawerParamList, } from './route-names'; import TabNavigator from './tab-navigator.react'; const communityDrawerContent = () => ; export type CommunityDrawerNavigationProp< RouteName: $Keys = $Keys, > = DrawerNavigationProp; const Drawer = createDrawerNavigator< ScreenParamList, CommunityDrawerParamList, DrawerNavigationHelpers, >(); type Props = { +navigation: AppNavigationProp<'CommunityDrawerNavigator'>, +route: NavigationRoute<'CommunityDrawerNavigator'>, }; // eslint-disable-next-line no-unused-vars function CommunityDrawerNavigator(props: Props): React.Node { const styles = useStyles(unboundStyles); const navContext = React.useContext(NavContext); const swipeEnabled = React.useMemo( () => drawerSwipeEnabledSelector(navContext), [navContext], ); + const { width: screenWidth } = useWindowDimensions(); const screenOptions = React.useMemo( () => ({ drawerStyle: styles.drawerStyle, headerShown: false, swipeEnabled, + swipeEdgeWidth: screenWidth, }), - [styles.drawerStyle, swipeEnabled], + [styles.drawerStyle, swipeEnabled, screenWidth], ); return ( ); } const unboundStyles = { drawerView: { flex: 1, }, drawerStyle: { width: '80%', }, }; export { CommunityDrawerNavigator }; diff --git a/native/profile/profile.react.js b/native/profile/profile.react.js index b865909c6..ff686c7e3 100644 --- a/native/profile/profile.react.js +++ b/native/profile/profile.react.js @@ -1,169 +1,172 @@ // @flow import { createStackNavigator, type StackNavigationProp, type StackNavigationHelpers, type StackHeaderProps, } from '@react-navigation/stack'; import * as React from 'react'; -import { View } from 'react-native'; +import { View, useWindowDimensions } from 'react-native'; import KeyboardAvoidingView from '../components/keyboard-avoiding-view.react'; import CommunityDrawerButton from '../navigation/community-drawer-button.react'; import type { CommunityDrawerNavigationProp } from '../navigation/community-drawer-navigator.react'; import HeaderBackButton from '../navigation/header-back-button.react'; import { ProfileScreenRouteName, EditPasswordRouteName, DeleteAccountRouteName, BuildInfoRouteName, DevToolsRouteName, AppearancePreferencesRouteName, PrivacyPreferencesRouteName, FriendListRouteName, DefaultNotificationsPreferencesRouteName, BlockListRouteName, type ScreenParamList, type ProfileParamList, } from '../navigation/route-names'; import { useStyles, useColors } from '../themes/colors'; import AppearancePreferences from './appearance-preferences.react'; import BuildInfo from './build-info.react'; import DefaultNotificationsPreferences from './default-notifications-preferences.react'; import DeleteAccount from './delete-account.react'; import DevTools from './dev-tools.react'; import EditPassword from './edit-password.react'; import PrivacyPreferences from './privacy-preferences.react'; import ProfileHeader from './profile-header.react'; import ProfileScreen from './profile-screen.react'; import RelationshipList from './relationship-list.react'; const header = (props: StackHeaderProps) => ; const profileScreenOptions = { headerTitle: 'Profile' }; const editPasswordOptions = { headerTitle: 'Change password' }; const deleteAccountOptions = { headerTitle: 'Delete account' }; const buildInfoOptions = { headerTitle: 'Build info' }; const devToolsOptions = { headerTitle: 'Developer tools' }; const appearanceOptions = { headerTitle: 'Appearance' }; const privacyOptions = { headerTitle: 'Privacy' }; const friendListOptions = { headerTitle: 'Friend list' }; const blockListOptions = { headerTitle: 'Block list' }; const defaultNotificationsOptions = { headerTitle: 'Default Notifications' }; export type ProfileNavigationProp< RouteName: $Keys = $Keys, > = StackNavigationProp; const Profile = createStackNavigator< ScreenParamList, ProfileParamList, StackNavigationHelpers, >(); type Props = { +navigation: CommunityDrawerNavigationProp<'TabNavigator'>, ... }; function ProfileComponent(props: Props): React.Node { const styles = useStyles(unboundStyles); const colors = useColors(); const headerLeftButton = React.useCallback( headerProps => headerProps.canGoBack ? ( ) : ( ), [props.navigation], ); + const { width: screenWidth } = useWindowDimensions(); const screenOptions = React.useMemo( () => ({ header, headerLeft: headerLeftButton, headerStyle: { backgroundColor: colors.tabBarBackground, shadowOpacity: 0, }, + gestureEnabled: true, + gestureResponseDistance: screenWidth, }), - [colors.tabBarBackground, headerLeftButton], + [colors.tabBarBackground, headerLeftButton, screenWidth], ); return ( ); } const unboundStyles = { keyboardAvoidingView: { flex: 1, }, view: { flex: 1, backgroundColor: 'panelBackground', }, }; export default ProfileComponent;