diff --git a/native/chat/text-message.react.js b/native/chat/text-message.react.js index 265737999..14fd0becd 100644 --- a/native/chat/text-message.react.js +++ b/native/chat/text-message.react.js @@ -1,252 +1,256 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View } from 'react-native'; import { messageKey } from 'lib/shared/message-utils'; import { threadHasPermission, useCanCreateSidebarFromMessage, } from 'lib/shared/thread-utils'; import { threadPermissions } from 'lib/types/thread-types'; import { ChatContext, type ChatContextType } from '../chat/chat-context'; import { MarkdownContext } from '../markdown/markdown-context'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context'; import type { NavigationRoute } from '../navigation/route-names'; import { TextMessageTooltipModalRouteName } from '../navigation/route-names'; import { fixedTooltipHeight } from '../navigation/tooltip.react'; import type { ChatTextMessageInfoItemWithHeight } from '../types/chat-types'; import type { VerticalBounds } from '../types/layout-types'; import type { ChatNavigationProp } from './chat.react'; import ComposedMessage from './composed-message.react'; import { InnerTextMessage } from './inner-text-message.react'; import { MessagePressResponderContext, type MessagePressResponderContextType, } from './message-press-responder-context'; import textMessageSendFailed from './text-message-send-failed'; import { getMessageTooltipKey } from './utils'; type BaseProps = { ...React.ElementConfig, +item: ChatTextMessageInfoItemWithHeight, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, +focused: boolean, +toggleFocus: (messageKey: string) => void, +verticalBounds: ?VerticalBounds, }; type Props = { ...BaseProps, // Redux state +canCreateSidebarFromMessage: boolean, // withOverlayContext +overlayContext: ?OverlayContextType, // ChatContext +chatContext: ?ChatContextType, // MarkdownContext - +linkModalActive: boolean, + +isLinkModalActive: boolean, }; class TextMessage extends React.PureComponent { message: ?React.ElementRef; messagePressResponderContext: MessagePressResponderContextType; constructor(props: Props) { super(props); this.messagePressResponderContext = { onPressMessage: this.onPress, }; } render() { const { item, navigation, route, focused, toggleFocus, verticalBounds, overlayContext, chatContext, - linkModalActive, + isLinkModalActive, canCreateSidebarFromMessage, ...viewProps } = this.props; let swipeOptions = 'none'; const canReply = this.canReply(); const canNavigateToSidebar = this.canNavigateToSidebar(); - if (linkModalActive) { + if (isLinkModalActive) { swipeOptions = 'none'; } else if (canReply && canNavigateToSidebar) { swipeOptions = 'both'; } else if (canReply) { swipeOptions = 'reply'; } else if (canNavigateToSidebar) { swipeOptions = 'sidebar'; } return ( ); } messageRef = (message: ?React.ElementRef) => { this.message = message; }; canReply() { return threadHasPermission( this.props.item.threadInfo, threadPermissions.VOICED, ); } canNavigateToSidebar() { return ( this.props.item.threadCreatedFromMessage || this.props.canCreateSidebarFromMessage ); } visibleEntryIDs() { const result = ['copy']; if (this.canReply()) { result.push('reply'); } if ( this.props.item.threadCreatedFromMessage || this.props.canCreateSidebarFromMessage ) { result.push('sidebar'); } if (!this.props.item.messageInfo.creator.isViewer) { result.push('report'); } return result; } onPress = () => { const visibleEntryIDs = this.visibleEntryIDs(); if (visibleEntryIDs.length === 0) { return; } const { message, - props: { verticalBounds, linkModalActive }, + props: { verticalBounds, isLinkModalActive }, } = this; - if (!message || !verticalBounds || linkModalActive) { + if (!message || !verticalBounds || isLinkModalActive) { return; } const { focused, toggleFocus, item } = this.props; if (!focused) { toggleFocus(messageKey(item.messageInfo)); } const { overlayContext } = this.props; invariant(overlayContext, 'TextMessage should have OverlayContext'); overlayContext.setScrollBlockingModalStatus('open'); message.measure((x, y, width, height, pageX, pageY) => { const coordinates = { x: pageX, y: pageY, width, height }; const messageTop = pageY; const messageBottom = pageY + height; const boundsTop = verticalBounds.y; const boundsBottom = verticalBounds.y + verticalBounds.height; const belowMargin = 20; const belowSpace = fixedTooltipHeight + belowMargin; const { isViewer } = item.messageInfo.creator; const aboveMargin = isViewer ? 30 : 50; const aboveSpace = fixedTooltipHeight + aboveMargin; let margin = belowMargin; if ( messageBottom + belowSpace > boundsBottom && messageTop - aboveSpace > boundsTop ) { margin = aboveMargin; } const currentInputBarHeight = this.props.chatContext?.chatInputBarHeights.get(item.threadInfo.id) ?? 0; this.props.navigation.navigate<'TextMessageTooltipModal'>({ name: TextMessageTooltipModalRouteName, params: { presentedFrom: this.props.route.key, initialCoordinates: coordinates, verticalBounds, visibleEntryIDs, location: 'fixed', margin, item, chatInputBarHeight: currentInputBarHeight, }, key: getMessageTooltipKey(item), }); }); }; } const ConnectedTextMessage: React.ComponentType = React.memo( function ConnectedTextMessage(props: BaseProps) { const overlayContext = React.useContext(OverlayContext); const chatContext = React.useContext(ChatContext); + const markdownContext = React.useContext(MarkdownContext); + invariant(markdownContext, 'markdownContext should be set'); + + const { linkModalActive, clearMarkdownContextData } = markdownContext; + + const key = messageKey(props.item.messageInfo); + + // We check if there is an key in the object - if not, we + // default to false. The likely situation where the former statement + // evaluates to null is when the thread is opened for the first time. + const isLinkModalActive = linkModalActive[key] ?? false; - const [linkModalActive, setLinkModalActive] = React.useState(false); - const markdownContext = React.useMemo( - () => ({ - setLinkModalActive, - }), - [setLinkModalActive], - ); const canCreateSidebarFromMessage = useCanCreateSidebarFromMessage( props.item.threadInfo, props.item.messageInfo, ); + React.useEffect(() => clearMarkdownContextData, [clearMarkdownContextData]); + return ( - - - + ); }, ); export { ConnectedTextMessage as TextMessage }; diff --git a/native/markdown/markdown-context-provider.react.js b/native/markdown/markdown-context-provider.react.js new file mode 100644 index 000000000..3ee8fe21c --- /dev/null +++ b/native/markdown/markdown-context-provider.react.js @@ -0,0 +1,36 @@ +// @flow + +import * as React from 'react'; + +import { MarkdownContext } from './markdown-context.js'; + +type Props = { + +children: React.Node, +}; + +function MarkdownContextProvider(props: Props): React.Node { + const [linkModalActive, setLinkModalActive] = React.useState<{ + [key: string]: boolean, + }>({}); + + const clearMarkdownContextData = React.useCallback(() => { + setLinkModalActive({}); + }, []); + + const contextValue = React.useMemo( + () => ({ + setLinkModalActive, + linkModalActive, + clearMarkdownContextData, + }), + [setLinkModalActive, linkModalActive, clearMarkdownContextData], + ); + + return ( + + {props.children} + + ); +} + +export default MarkdownContextProvider; diff --git a/native/markdown/markdown-context.js b/native/markdown/markdown-context.js index cac1f8ea7..93c62b1b9 100644 --- a/native/markdown/markdown-context.js +++ b/native/markdown/markdown-context.js @@ -1,13 +1,17 @@ // @flow import * as React from 'react'; +import type { SetState } from 'lib/types/hook-types'; + export type MarkdownContextType = { - +setLinkModalActive: boolean => void, + +setLinkModalActive: SetState<{ [key: string]: boolean }>, + +linkModalActive: { [key: string]: boolean }, + +clearMarkdownContextData: () => void, }; const MarkdownContext: React.Context = React.createContext( null, ); export { MarkdownContext }; diff --git a/native/markdown/markdown-link.react.js b/native/markdown/markdown-link.react.js index c2a88e0f7..2b2ff4ff5 100644 --- a/native/markdown/markdown-link.react.js +++ b/native/markdown/markdown-link.react.js @@ -1,56 +1,68 @@ // @flow +import invariant from 'invariant'; import * as React from 'react'; import { Text, Linking, Alert } from 'react-native'; import { normalizeURL } from 'lib/utils/url-utils'; +import { TextMessageMarkdownContext } from '../chat/text-message-markdown-context'; import { MarkdownContext, type MarkdownContextType } from './markdown-context'; function useDisplayLinkPrompt( inputURL: string, - markdownContext: ?MarkdownContextType, + markdownContext: MarkdownContextType, + messageKey: ?string, ) { - const setLinkModalActive = markdownContext?.setLinkModalActive; + const { setLinkModalActive } = markdownContext; const onDismiss = React.useCallback(() => { - setLinkModalActive?.(false); - }, [setLinkModalActive]); + messageKey && setLinkModalActive({ [messageKey]: false }); + }, [setLinkModalActive, messageKey]); const url = normalizeURL(inputURL); const onConfirm = React.useCallback(() => { onDismiss(); Linking.openURL(url); }, [url, onDismiss]); let displayURL = url.substring(0, 64); if (url.length > displayURL.length) { displayURL += '…'; } return React.useCallback(() => { - setLinkModalActive && setLinkModalActive(true); + messageKey && setLinkModalActive({ [messageKey]: true }); Alert.alert( 'External link', `You sure you want to open this link?\n\n${displayURL}`, [ { text: 'Cancel', style: 'cancel', onPress: onDismiss }, { text: 'Open', onPress: onConfirm }, ], { cancelable: true, onDismiss }, ); - }, [setLinkModalActive, displayURL, onConfirm, onDismiss]); + }, [setLinkModalActive, messageKey, displayURL, onConfirm, onDismiss]); } type TextProps = React.ElementConfig; type Props = { +target: string, +children: React.Node, ...TextProps, }; function MarkdownLink(props: Props): React.Node { const { target, ...rest } = props; + const markdownContext = React.useContext(MarkdownContext); - const onPressLink = useDisplayLinkPrompt(target, markdownContext); + invariant(markdownContext, 'MarkdownContext should be set'); + + const textMessageMarkdownContext = React.useContext( + TextMessageMarkdownContext, + ); + const messageKey = textMessageMarkdownContext?.messageKey; + + const onPressLink = useDisplayLinkPrompt(target, markdownContext, messageKey); + return ; } export default MarkdownLink; diff --git a/native/root.react.js b/native/root.react.js index fd68fd26d..aed3a3ce3 100644 --- a/native/root.react.js +++ b/native/root.react.js @@ -1,290 +1,293 @@ // @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'; import type { PossiblyStaleNavigationState } from '@react-navigation/native'; import * as SplashScreen from 'expo-splash-screen'; import invariant from 'invariant'; import * as React from 'react'; import { Platform, UIManager, View, StyleSheet, LogBox } from 'react-native'; import Orientation from 'react-native-orientation-locker'; import { SafeAreaProvider, initialWindowMetrics, } from 'react-native-safe-area-context'; import { Provider } from 'react-redux'; import { PersistGate as ReduxPersistGate } from 'redux-persist/integration/react'; import { actionLogger } from 'lib/utils/action-logger'; import ChatContextProvider from './chat/chat-context-provider.react'; import PersistedStateGate from './components/persisted-state-gate'; import ConnectedStatusBar from './connected-status-bar.react'; import { SQLiteDataHandler } from './data/sqlite-data-handler'; import ErrorBoundary from './error-boundary.react'; import InputStateContainer from './input/input-state-container.react'; import LifecycleHandler from './lifecycle/lifecycle-handler.react'; +import MarkdownContextProvider from './markdown/markdown-context-provider.react'; import { defaultNavigationState } from './navigation/default-state'; import DisconnectedBarVisibilityHandler from './navigation/disconnected-bar-visibility-handler.react'; import { setGlobalNavContext } from './navigation/icky-global'; import { NavContext } from './navigation/navigation-context'; import NavigationHandler from './navigation/navigation-handler.react'; import { validNavState } from './navigation/navigation-utils'; import OrientationHandler from './navigation/orientation-handler.react'; import { navStateAsyncStorageKey } from './navigation/persistance'; import RootNavigator from './navigation/root-navigator.react'; import ConnectivityUpdater from './redux/connectivity-updater.react'; import { DimensionsUpdater } from './redux/dimensions-updater.react'; import { getPersistor } from './redux/persist'; import { store } from './redux/redux-setup'; import { useSelector } from './redux/redux-utils'; import { RootContext } from './root-context'; import Socket from './socket.react'; import { StaffContextProvider } from './staff/staff-context.provider.react'; import { DarkTheme, LightTheme } from './themes/navigation'; import ThemeHandler from './themes/theme-handler.react'; import './themes/fonts'; LogBox.ignoreLogs([ // react-native-reanimated 'Please report: Excessive number of pending callbacks', ]); if (Platform.OS === 'android') { UIManager.setLayoutAnimationEnabledExperimental && UIManager.setLayoutAnimationEnabledExperimental(true); } const navInitAction = Object.freeze({ type: 'NAV/@@INIT' }); const navUnknownAction = Object.freeze({ type: 'NAV/@@UNKNOWN' }); SplashScreen.preventAutoHideAsync().catch(console.log); function Root() { const navStateRef = React.useRef(); const navDispatchRef = React.useRef(); const navStateInitializedRef = React.useRef(false); const [navContext, setNavContext] = React.useState(null); const updateNavContext = React.useCallback(() => { if ( !navStateRef.current || !navDispatchRef.current || !navStateInitializedRef.current ) { return; } const updatedNavContext = { state: navStateRef.current, dispatch: navDispatchRef.current, }; setNavContext(updatedNavContext); setGlobalNavContext(updatedNavContext); }, []); const [initialState, setInitialState] = React.useState( __DEV__ ? undefined : defaultNavigationState, ); React.useEffect(() => { Orientation.lockToPortrait(); (async () => { let loadedState = initialState; if (__DEV__) { try { const navStateString = await AsyncStorage.getItem( navStateAsyncStorageKey, ); if (navStateString) { const savedState = JSON.parse(navStateString); if (validNavState(savedState)) { loadedState = savedState; } } } catch {} } if (!loadedState) { loadedState = defaultNavigationState; } if (loadedState !== initialState) { setInitialState(loadedState); } navStateRef.current = loadedState; updateNavContext(); actionLogger.addOtherAction('navState', navInitAction, null, loadedState); })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [updateNavContext]); const setNavStateInitialized = React.useCallback(() => { navStateInitializedRef.current = true; updateNavContext(); }, [updateNavContext]); const [rootContext, setRootContext] = React.useState(() => ({ setNavStateInitialized, })); const detectUnsupervisedBackgroundRef = React.useCallback( (detectUnsupervisedBackground: ?(alreadyClosed: boolean) => boolean) => { setRootContext(prevRootContext => ({ ...prevRootContext, detectUnsupervisedBackground, })); }, [], ); const frozen = useSelector(state => state.frozen); const queuedActionsRef = React.useRef([]); const onNavigationStateChange = React.useCallback( (state: ?PossiblyStaleNavigationState) => { invariant(state, 'nav state should be non-null'); const prevState = navStateRef.current; navStateRef.current = state; updateNavContext(); const queuedActions = queuedActionsRef.current; queuedActionsRef.current = []; if (queuedActions.length === 0) { queuedActions.push(navUnknownAction); } for (const action of queuedActions) { actionLogger.addOtherAction('navState', action, prevState, state); } if (!__DEV__ || frozen) { return; } (async () => { try { await AsyncStorage.setItem( navStateAsyncStorageKey, JSON.stringify(state), ); } catch (e) { console.log('AsyncStorage threw while trying to persist navState', e); } })(); }, [updateNavContext, frozen], ); const navContainerRef = React.useRef(); const containerRef = React.useCallback( (navContainer: ?React.ElementRef) => { navContainerRef.current = navContainer; if (navContainer && !navDispatchRef.current) { navDispatchRef.current = navContainer.dispatch; updateNavContext(); } }, [updateNavContext], ); useReduxDevToolsExtension(navContainerRef); const navContainer = navContainerRef.current; React.useEffect(() => { if (!navContainer) { return; } return navContainer.addListener('__unsafe_action__', event => { const { action, noop } = event.data; const navState = navStateRef.current; if (noop) { actionLogger.addOtherAction('navState', action, navState, navState); return; } queuedActionsRef.current.push({ ...action, type: `NAV/${action.type}`, }); }); }, [navContainer]); const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const theme = (() => { if (activeTheme === 'light') { return LightTheme; } else if (activeTheme === 'dark') { return DarkTheme; } return undefined; })(); const gated: React.Node = ( <> ); let navigation; if (initialState) { navigation = ( ); } return ( - - - - - {gated} - - - - - {navigation} - - + + + + + + {gated} + + + + + {navigation} + + + ); } const styles = StyleSheet.create({ app: { flex: 1, }, }); function AppRoot(): React.Node { return ( ); } export default AppRoot;