diff --git a/lib/tunnelbroker/tunnelbroker-context.js b/lib/tunnelbroker/tunnelbroker-context.js index 3dde8440f..5e351f380 100644 --- a/lib/tunnelbroker/tunnelbroker-context.js +++ b/lib/tunnelbroker/tunnelbroker-context.js @@ -1,402 +1,443 @@ // @flow import invariant from 'invariant'; +import _isEqual from 'lodash/fp/isEqual.js'; import * as React from 'react'; import uuid from 'uuid'; import type { SecondaryTunnelbrokerConnection } from './secondary-tunnelbroker-connection.js'; import { tunnnelbrokerURL } from '../facts/tunnelbroker.js'; import { peerToPeerMessageHandler } from '../handlers/peer-to-peer-message-handler.js'; import { IdentityClientContext } from '../shared/identity-client-context.js'; import { tunnelbrokerHeartbeatTimeout } from '../shared/timeouts.js'; +import { isWebPlatform } from '../types/device-types.js'; import type { MessageReceiveConfirmation } from '../types/tunnelbroker/message-receive-confirmation-types.js'; import type { MessageSentStatus } from '../types/tunnelbroker/message-to-device-request-status-types.js'; import type { MessageToDeviceRequest } from '../types/tunnelbroker/message-to-device-request-types.js'; import { type TunnelbrokerMessage, tunnelbrokerMessageTypes, tunnelbrokerMessageValidator, } from '../types/tunnelbroker/messages.js'; import { type PeerToPeerMessage, peerToPeerMessageValidator, } from '../types/tunnelbroker/peer-to-peer-message-types.js'; import type { AnonymousInitializationMessage, - TunnelbrokerInitializationMessage, ConnectionInitializationMessage, + TunnelbrokerInitializationMessage, + TunnelbrokerDeviceTypes, } from '../types/tunnelbroker/session-types.js'; import type { Heartbeat } from '../types/websocket/heartbeat-types.js'; +import { getConfig } from '../utils/config.js'; +import { getContentSigningKey } from '../utils/crypto-utils.js'; +import { useSelector } from '../utils/redux-utils.js'; export type ClientMessageToDevice = { +deviceID: string, +payload: string, }; export type TunnelbrokerSocketListener = ( message: TunnelbrokerMessage, ) => mixed; type PromiseCallbacks = { +resolve: () => void, +reject: (error: string) => void, }; type Promises = { [clientMessageID: string]: PromiseCallbacks }; type TunnelbrokerContextType = { +sendMessage: (message: ClientMessageToDevice) => Promise, +addListener: (listener: TunnelbrokerSocketListener) => void, +removeListener: (listener: TunnelbrokerSocketListener) => void, +connected: boolean, +isAuthorized: boolean, +setUnauthorizedDeviceID: (unauthorizedDeviceID: ?string) => void, }; const TunnelbrokerContext: React.Context = React.createContext(); type Props = { +children: React.Node, +shouldBeClosed?: boolean, +onClose?: () => mixed, - +initMessage: ?ConnectionInitializationMessage, +secondaryTunnelbrokerConnection?: SecondaryTunnelbrokerConnection, }; +function getTunnelbrokerDeviceType(): TunnelbrokerDeviceTypes { + return isWebPlatform(getConfig().platformDetails.platform) ? 'web' : 'mobile'; +} + function createAnonymousInitMessage( deviceID: string, ): AnonymousInitializationMessage { return ({ type: 'AnonymousInitializationMessage', deviceID, - deviceType: 'mobile', + deviceType: getTunnelbrokerDeviceType(), }: AnonymousInitializationMessage); } function TunnelbrokerProvider(props: Props): React.Node { - const { - children, - shouldBeClosed, - onClose, - initMessage: initMessageProp, - secondaryTunnelbrokerConnection, - } = props; - const [connected, setConnected] = React.useState(false); - const listeners = React.useRef>(new Set()); - const socket = React.useRef(null); - const promises = React.useRef({}); - const heartbeatTimeoutID = React.useRef(); + const { children, shouldBeClosed, onClose, secondaryTunnelbrokerConnection } = + props; + + const accessToken = useSelector(state => state.commServicesAccessToken); + const userID = useSelector(state => state.currentUserInfo?.id); + const [unauthorizedDeviceID, setUnauthorizedDeviceID] = React.useState(null); const isAuthorized = !unauthorizedDeviceID; - const identityContext = React.useContext(IdentityClientContext); - invariant(identityContext, 'Identity context should be set'); - const { identityClient } = identityContext; - - const initMessage = React.useMemo(() => { + const createInitMessage = React.useCallback(async () => { if (shouldBeClosed) { return null; } - if (!unauthorizedDeviceID) { - return initMessageProp; + + if (unauthorizedDeviceID) { + return createAnonymousInitMessage(unauthorizedDeviceID); + } + + if (!accessToken || !userID) { + return null; + } + + const deviceID = await getContentSigningKey(); + if (!deviceID) { + return null; } - return createAnonymousInitMessage(unauthorizedDeviceID); - }, [shouldBeClosed, unauthorizedDeviceID, initMessageProp]); + return ({ + type: 'ConnectionInitializationMessage', + deviceID, + accessToken, + userID, + deviceType: getTunnelbrokerDeviceType(), + }: ConnectionInitializationMessage); + }, [accessToken, shouldBeClosed, unauthorizedDeviceID, userID]); const previousInitMessage = - React.useRef(initMessage); - const initMessageChanged = initMessage !== previousInitMessage.current; - previousInitMessage.current = initMessage; + React.useRef(null); + + const [connected, setConnected] = React.useState(false); + const listeners = React.useRef>(new Set()); + const socket = React.useRef(null); + const promises = React.useRef({}); + const heartbeatTimeoutID = React.useRef(); + + const identityContext = React.useContext(IdentityClientContext); + invariant(identityContext, 'Identity context should be set'); + const { identityClient } = identityContext; const stopHeartbeatTimeout = React.useCallback(() => { if (heartbeatTimeoutID.current) { clearTimeout(heartbeatTimeoutID.current); heartbeatTimeoutID.current = null; } }, []); const resetHeartbeatTimeout = React.useCallback(() => { stopHeartbeatTimeout(); heartbeatTimeoutID.current = setTimeout(() => { socket.current?.close(); setConnected(false); }, tunnelbrokerHeartbeatTimeout); }, [stopHeartbeatTimeout]); // determine if the socket is active (not closed or closing) const isSocketActive = socket.current?.readyState === WebSocket.OPEN || socket.current?.readyState === WebSocket.CONNECTING; + const connectionChangePromise = React.useRef>(null); // The Tunnelbroker connection can have 4 states: // - DISCONNECTED: isSocketActive = false, connected = false // Should be in this state when initMessage is null // - CONNECTING: isSocketActive = true, connected = false // This lasts until Tunnelbroker sends ConnectionInitializationResponse // - CONNECTED: isSocketActive = true, connected = true // - DISCONNECTING: isSocketActive = false, connected = true // This lasts between socket.close() and socket.onclose() React.useEffect(() => { - // when initMessage changes, we need to close the socket and open a new one - if ( - (!initMessage || initMessageChanged) && - isSocketActive && - socket.current - ) { - socket.current?.close(); - return; - } - - // when we're already connected (or pending disconnection), - // or there's no init message to start with, we don't need to do anything - if (connected || !initMessage || socket.current) { - return; - } - - const tunnelbrokerSocket = new WebSocket(tunnnelbrokerURL); - - tunnelbrokerSocket.onopen = () => { - tunnelbrokerSocket.send(JSON.stringify(initMessage)); - }; - - tunnelbrokerSocket.onclose = () => { - // this triggers the effect hook again and reconnect - setConnected(false); - onClose?.(); - socket.current = null; - console.log('Connection to Tunnelbroker closed'); - }; - tunnelbrokerSocket.onerror = e => { - console.log('Tunnelbroker socket error:', e.message); - }; - tunnelbrokerSocket.onmessage = (event: MessageEvent) => { - if (typeof event.data !== 'string') { - console.log('socket received a non-string message'); - return; - } - let rawMessage; + connectionChangePromise.current = (async () => { + await connectionChangePromise.current; try { - rawMessage = JSON.parse(event.data); - } catch (e) { - console.log('error while parsing Tunnelbroker message:', e.message); - return; - } + const initMessage = await createInitMessage(); + const initMessageChanged = !_isEqual( + previousInitMessage.current, + initMessage, + ); + previousInitMessage.current = initMessage; + + // when initMessage changes, we need to close the socket + // and open a new one + if ( + (!initMessage || initMessageChanged) && + isSocketActive && + socket.current + ) { + socket.current?.close(); + return; + } - if (!tunnelbrokerMessageValidator.is(rawMessage)) { - console.log('invalid TunnelbrokerMessage'); - return; - } - const message: TunnelbrokerMessage = rawMessage; + // when we're already connected (or pending disconnection), + // or there's no init message to start with, we don't need + // to do anything + if (connected || !initMessage || socket.current) { + return; + } - resetHeartbeatTimeout(); + const tunnelbrokerSocket = new WebSocket(tunnnelbrokerURL); - for (const listener of listeners.current) { - listener(message); - } + tunnelbrokerSocket.onopen = () => { + tunnelbrokerSocket.send(JSON.stringify(initMessage)); + }; - if ( - message.type === - tunnelbrokerMessageTypes.CONNECTION_INITIALIZATION_RESPONSE - ) { - if (message.status.type === 'Success' && !connected) { - setConnected(true); - console.log( - 'session with Tunnelbroker created. isAuthorized:', - isAuthorized, - ); - } else if (message.status.type === 'Success' && connected) { - console.log( - 'received ConnectionInitializationResponse with status: Success for already connected socket', - ); - } else { + tunnelbrokerSocket.onclose = () => { + // this triggers the effect hook again and reconnect setConnected(false); - console.log( - 'creating session with Tunnelbroker error:', - message.status.data, - ); - } - } else if (message.type === tunnelbrokerMessageTypes.MESSAGE_TO_DEVICE) { - const confirmation: MessageReceiveConfirmation = { - type: tunnelbrokerMessageTypes.MESSAGE_RECEIVE_CONFIRMATION, - messageIDs: [message.messageID], + onClose?.(); + socket.current = null; + console.log('Connection to Tunnelbroker closed'); }; - socket.current?.send(JSON.stringify(confirmation)); - - let rawPeerToPeerMessage; - try { - rawPeerToPeerMessage = JSON.parse(message.payload); - } catch (e) { - console.log( - 'error while parsing Tunnelbroker peer-to-peer message:', - e.message, - ); - return; - } + tunnelbrokerSocket.onerror = e => { + console.log('Tunnelbroker socket error:', e.message); + }; + tunnelbrokerSocket.onmessage = (event: MessageEvent) => { + if (typeof event.data !== 'string') { + console.log('socket received a non-string message'); + return; + } + let rawMessage; + try { + rawMessage = JSON.parse(event.data); + } catch (e) { + console.log('error while parsing Tunnelbroker message:', e.message); + return; + } - if (!peerToPeerMessageValidator.is(rawPeerToPeerMessage)) { - console.log('invalid Tunnelbroker PeerToPeerMessage'); - return; - } - const peerToPeerMessage: PeerToPeerMessage = rawPeerToPeerMessage; - void peerToPeerMessageHandler(peerToPeerMessage, identityClient); - } else if ( - message.type === - tunnelbrokerMessageTypes.MESSAGE_TO_DEVICE_REQUEST_STATUS - ) { - for (const status: MessageSentStatus of message.clientMessageIDs) { - if (status.type === 'Success') { - promises.current[status.data]?.resolve(); - delete promises.current[status.data]; - } else if (status.type === 'Error') { - promises.current[status.data.id]?.reject(status.data.error); - delete promises.current[status.data.id]; - } else if (status.type === 'SerializationError') { - console.log('SerializationError for message: ', status.data); - } else if (status.type === 'InvalidRequest') { - console.log('Tunnelbroker recorded InvalidRequest'); + if (!tunnelbrokerMessageValidator.is(rawMessage)) { + console.log('invalid TunnelbrokerMessage'); + return; + } + const message: TunnelbrokerMessage = rawMessage; + + resetHeartbeatTimeout(); + + for (const listener of listeners.current) { + listener(message); + } + + if ( + message.type === + tunnelbrokerMessageTypes.CONNECTION_INITIALIZATION_RESPONSE + ) { + if (message.status.type === 'Success' && !connected) { + setConnected(true); + console.log( + 'session with Tunnelbroker created. isAuthorized:', + isAuthorized, + ); + } else if (message.status.type === 'Success' && connected) { + console.log( + 'received ConnectionInitializationResponse with status: Success for already connected socket', + ); + } else { + setConnected(false); + console.log( + 'creating session with Tunnelbroker error:', + message.status.data, + ); + } + } else if ( + message.type === tunnelbrokerMessageTypes.MESSAGE_TO_DEVICE + ) { + const confirmation: MessageReceiveConfirmation = { + type: tunnelbrokerMessageTypes.MESSAGE_RECEIVE_CONFIRMATION, + messageIDs: [message.messageID], + }; + socket.current?.send(JSON.stringify(confirmation)); + + let rawPeerToPeerMessage; + try { + rawPeerToPeerMessage = JSON.parse(message.payload); + } catch (e) { + console.log( + 'error while parsing Tunnelbroker peer-to-peer message:', + e.message, + ); + return; + } + + if (!peerToPeerMessageValidator.is(rawPeerToPeerMessage)) { + console.log('invalid Tunnelbroker PeerToPeerMessage'); + return; + } + const peerToPeerMessage: PeerToPeerMessage = rawPeerToPeerMessage; + void peerToPeerMessageHandler(peerToPeerMessage, identityClient); + } else if ( + message.type === + tunnelbrokerMessageTypes.MESSAGE_TO_DEVICE_REQUEST_STATUS + ) { + for (const status: MessageSentStatus of message.clientMessageIDs) { + if (status.type === 'Success') { + promises.current[status.data]?.resolve(); + delete promises.current[status.data]; + } else if (status.type === 'Error') { + promises.current[status.data.id]?.reject(status.data.error); + delete promises.current[status.data.id]; + } else if (status.type === 'SerializationError') { + console.log('SerializationError for message: ', status.data); + } else if (status.type === 'InvalidRequest') { + console.log('Tunnelbroker recorded InvalidRequest'); + } + } + } else if (message.type === tunnelbrokerMessageTypes.HEARTBEAT) { + const heartbeat: Heartbeat = { + type: tunnelbrokerMessageTypes.HEARTBEAT, + }; + socket.current?.send(JSON.stringify(heartbeat)); } - } - } else if (message.type === tunnelbrokerMessageTypes.HEARTBEAT) { - const heartbeat: Heartbeat = { - type: tunnelbrokerMessageTypes.HEARTBEAT, }; - socket.current?.send(JSON.stringify(heartbeat)); - } - }; - socket.current = tunnelbrokerSocket; + socket.current = tunnelbrokerSocket; + } catch (err) { + console.log('Tunnelbroker connection error:', err); + } + })(); }, [ connected, - initMessage, - initMessageChanged, isSocketActive, isAuthorized, resetHeartbeatTimeout, stopHeartbeatTimeout, identityClient, onClose, + createInitMessage, ]); const sendMessageToDeviceRequest: ( request: MessageToDeviceRequest, ) => Promise = React.useCallback( request => { return new Promise((resolve, reject) => { const socketActive = connected && socket.current; if (!shouldBeClosed && !socketActive) { throw new Error('Tunnelbroker not connected'); } promises.current[request.clientMessageID] = { resolve, reject, }; if (socketActive) { socket.current?.send(JSON.stringify(request)); } else { secondaryTunnelbrokerConnection?.sendMessage(request); } }); }, [connected, secondaryTunnelbrokerConnection, shouldBeClosed], ); const sendMessage: (message: ClientMessageToDevice) => Promise = React.useCallback( (message: ClientMessageToDevice) => { const clientMessageID = uuid.v4(); const messageToDevice: MessageToDeviceRequest = { type: tunnelbrokerMessageTypes.MESSAGE_TO_DEVICE_REQUEST, clientMessageID, deviceID: message.deviceID, payload: message.payload, }; return sendMessageToDeviceRequest(messageToDevice); }, [sendMessageToDeviceRequest], ); React.useEffect( () => secondaryTunnelbrokerConnection?.onSendMessage(message => { if (shouldBeClosed) { // We aren't supposed to be handling it return; } void (async () => { try { await sendMessageToDeviceRequest(message); secondaryTunnelbrokerConnection.setMessageStatus( message.clientMessageID, ); } catch (error) { secondaryTunnelbrokerConnection.setMessageStatus( message.clientMessageID, error, ); } })(); }), [ secondaryTunnelbrokerConnection, sendMessageToDeviceRequest, shouldBeClosed, ], ); React.useEffect( () => secondaryTunnelbrokerConnection?.onMessageStatus((messageID, error) => { if (error) { promises.current[messageID].reject(error); } else { promises.current[messageID].resolve(); } delete promises.current[messageID]; }), [secondaryTunnelbrokerConnection], ); const addListener = React.useCallback( (listener: TunnelbrokerSocketListener) => { listeners.current.add(listener); }, [], ); const removeListener = React.useCallback( (listener: TunnelbrokerSocketListener) => { listeners.current.delete(listener); }, [], ); const value: TunnelbrokerContextType = React.useMemo( () => ({ sendMessage, connected, isAuthorized, addListener, removeListener, setUnauthorizedDeviceID, }), [addListener, connected, removeListener, sendMessage, isAuthorized], ); return ( {children} ); } function useTunnelbroker(): TunnelbrokerContextType { const context = React.useContext(TunnelbrokerContext); invariant(context, 'TunnelbrokerContext not found'); return context; } export { TunnelbrokerProvider, useTunnelbroker }; diff --git a/native/root.react.js b/native/root.react.js index 0d73f1715..86d7721c3 100644 --- a/native/root.react.js +++ b/native/root.react.js @@ -1,391 +1,388 @@ // @flow import { ActionSheetProvider } from '@expo/react-native-action-sheet'; import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'; import AsyncStorage from '@react-native-async-storage/async-storage'; import type { PossiblyStaleNavigationState, UnsafeContainerActionEvent, GenericNavigationAction, } from '@react-navigation/core'; import { useReduxDevToolsExtension } from '@react-navigation/devtools'; import { NavigationContainer } from '@react-navigation/native'; import * as SplashScreen from 'expo-splash-screen'; import invariant from 'invariant'; import * as React from 'react'; import { Platform, UIManager, StyleSheet } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; 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/es/integration/react.js'; import { ChatMentionContextProvider } from 'lib/components/chat-mention-provider.react.js'; import { EditUserAvatarProvider } from 'lib/components/edit-user-avatar-provider.react.js'; import { ENSCacheProvider } from 'lib/components/ens-cache-provider.react.js'; import { FarcasterDataHandler } from 'lib/components/farcaster-data-handler.react.js'; import IntegrityHandler from 'lib/components/integrity-handler.react.js'; import KeyserverConnectionsHandler from 'lib/components/keyserver-connections-handler.js'; import { MediaCacheProvider } from 'lib/components/media-cache-provider.react.js'; import { NeynarClientProvider } from 'lib/components/neynar-client-provider.react.js'; import PrekeysHandler from 'lib/components/prekeys-handler.react.js'; import { StaffContextProvider } from 'lib/components/staff-provider.react.js'; import { IdentitySearchProvider } from 'lib/identity-search/identity-search-context.js'; import { CallKeyserverEndpointProvider } from 'lib/keyserver-conn/call-keyserver-endpoint-provider.react.js'; import { TunnelbrokerProvider } from 'lib/tunnelbroker/tunnelbroker-context.js'; import { actionLogger } from 'lib/utils/action-logger.js'; import { RegistrationContextProvider } from './account/registration/registration-context-provider.react.js'; import NativeEditThreadAvatarProvider from './avatars/native-edit-thread-avatar-provider.react.js'; import BackupHandler from './backup/backup-handler.js'; import { BottomSheetProvider } from './bottom-sheet/bottom-sheet-provider.react.js'; import ChatContextProvider from './chat/chat-context-provider.react.js'; import MessageEditingContextProvider from './chat/message-editing-context-provider.react.js'; import AccessTokenHandler from './components/access-token-handler.react.js'; import { FeatureFlagsProvider } from './components/feature-flags-provider.react.js'; import PersistedStateGate from './components/persisted-state-gate.js'; import ReportHandler from './components/report-handler.react.js'; import VersionSupportedChecker from './components/version-supported.react.js'; import ConnectedStatusBar from './connected-status-bar.react.js'; import { SQLiteDataHandler } from './data/sqlite-data-handler.js'; import ErrorBoundary from './error-boundary.react.js'; import IdentityServiceContextProvider from './identity-service/identity-service-context-provider.react.js'; import InputStateContainer from './input/input-state-container.react.js'; import LifecycleHandler from './lifecycle/lifecycle-handler.react.js'; import MarkdownContextProvider from './markdown/markdown-context-provider.react.js'; import { filesystemMediaCache } from './media/media-cache.js'; import { DeepLinksContextProvider } from './navigation/deep-links-context-provider.react.js'; import { defaultNavigationState } from './navigation/default-state.js'; import { setGlobalNavContext } from './navigation/icky-global.js'; import KeyserverReachabilityHandler from './navigation/keyserver-reachability-handler.js'; import { NavContext, type NavContextType, } from './navigation/navigation-context.js'; import NavigationHandler from './navigation/navigation-handler.react.js'; import { validNavState } from './navigation/navigation-utils.js'; import OrientationHandler from './navigation/orientation-handler.react.js'; import { navStateAsyncStorageKey } from './navigation/persistance.js'; import RootNavigator from './navigation/root-navigator.react.js'; import ConnectivityUpdater from './redux/connectivity-updater.react.js'; import { DimensionsUpdater } from './redux/dimensions-updater.react.js'; import { getPersistor } from './redux/persist.js'; import { store } from './redux/redux-setup.js'; import { useSelector } from './redux/redux-utils.js'; import { RootContext } from './root-context.js'; import { MessageSearchProvider } from './search/search-provider.react.js'; import Socket from './socket.react.js'; import { useLoadCommFonts } from './themes/fonts.js'; import { DarkTheme, LightTheme } from './themes/navigation.js'; import ThemeHandler from './themes/theme-handler.react.js'; import { provider } from './utils/ethers-utils.js'; import { neynarKey } from './utils/neynar-utils.js'; -import { useTunnelbrokerInitMessage } from './utils/tunnelbroker-utils.js'; // Add custom items to expo-dev-menu import './dev-menu.js'; import './types/message-types-validator.js'; 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 GenericNavigationAction), ) => void>(); const navStateInitializedRef = React.useRef(false); // We call this here to start the loading process // We gate the UI on the fonts loading in AppNavigator useLoadCommFonts(); 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(); void (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; } void (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 undefined; } return navContainer.addListener( '__unsafe_action__', (event: { +data: UnsafeContainerActionEvent, ... }) => { 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 tunnelbrokerInitMessage = useTunnelbrokerInitMessage(); - const gated: React.Node = ( <> ); let navigation; if (initialState) { navigation = ( ); } return ( - + {gated} {navigation} ); } const styles = StyleSheet.create({ app: { flex: 1, }, }); function AppRoot(): React.Node { return ( ); } export default AppRoot; diff --git a/native/utils/tunnelbroker-utils.js b/native/utils/tunnelbroker-utils.js deleted file mode 100644 index b132210c9..000000000 --- a/native/utils/tunnelbroker-utils.js +++ /dev/null @@ -1,38 +0,0 @@ -// @flow - -import * as React from 'react'; - -import type { ConnectionInitializationMessage } from 'lib/types/tunnelbroker/session-types.js'; - -import { commCoreModule } from '../native-modules.js'; -import { useSelector } from '../redux/redux-utils.js'; - -function useTunnelbrokerInitMessage(): ?ConnectionInitializationMessage { - const [deviceID, setDeviceID] = React.useState(); - const [userID, setUserID] = React.useState(); - const accessToken = useSelector(state => state.commServicesAccessToken); - - React.useEffect(() => { - void (async () => { - const { userID: identityUserID, deviceID: contentSigningKey } = - await commCoreModule.getCommServicesAuthMetadata(); - setDeviceID(contentSigningKey); - setUserID(identityUserID); - })(); - }, [accessToken]); - - return React.useMemo(() => { - if (!deviceID || !accessToken || !userID) { - return null; - } - return ({ - type: 'ConnectionInitializationMessage', - deviceID, - accessToken, - userID, - deviceType: 'mobile', - }: ConnectionInitializationMessage); - }, [accessToken, deviceID, userID]); -} - -export { useTunnelbrokerInitMessage }; diff --git a/web/app.react.js b/web/app.react.js index dfb45768d..0cb4e846c 100644 --- a/web/app.react.js +++ b/web/app.react.js @@ -1,565 +1,561 @@ // @flow import 'basscss/css/basscss.min.css'; import './theme.css'; import { config as faConfig } from '@fortawesome/fontawesome-svg-core'; import classnames from 'classnames'; import _isEqual from 'lodash/fp/isEqual.js'; import * as React from 'react'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { WagmiConfig } from 'wagmi'; import { fetchEntriesActionTypes, updateCalendarQueryActionTypes, } from 'lib/actions/entry-actions.js'; import { ChatMentionContextProvider } from 'lib/components/chat-mention-provider.react.js'; import { EditUserAvatarProvider } from 'lib/components/edit-user-avatar-provider.react.js'; import { ModalProvider, useModalContext, } from 'lib/components/modal-provider.react.js'; import { StaffContextProvider } from 'lib/components/staff-provider.react.js'; import { IdentitySearchProvider } from 'lib/identity-search/identity-search-context.js'; import { createLoadingStatusSelector, combineLoadingStatuses, } from 'lib/selectors/loading-selectors.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { extractMajorDesktopVersion } from 'lib/shared/version-utils.js'; import type { SecondaryTunnelbrokerConnection } from 'lib/tunnelbroker/secondary-tunnelbroker-connection.js'; import { TunnelbrokerProvider } from 'lib/tunnelbroker/tunnelbroker-context.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { WebNavInfo } from 'lib/types/nav-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import type { MessageToDeviceRequest } from 'lib/types/tunnelbroker/message-to-device-request-types.js'; import { getConfig, registerConfig } from 'lib/utils/config.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { infoFromURL } from 'lib/utils/url-utils.js'; import { AlchemyENSCacheProvider, wagmiConfig } from 'lib/utils/wagmi-utils.js'; import QrCodeLogin from './account/qr-code-login.react.js'; import AppThemeWrapper from './app-theme-wrapper.react.js'; import { authoritativeKeyserverID } from './authoritative-keyserver.js'; import WebEditThreadAvatarProvider from './avatars/web-edit-thread-avatar-provider.react.js'; import Calendar from './calendar/calendar.react.js'; import Chat from './chat/chat.react.js'; import { EditModalProvider } from './chat/edit-message-provider.js'; import { MemberListSidebarProvider } from './chat/member-list-sidebar/member-list-sidebar-provider.react.js'; import NavigationArrows from './components/navigation-arrows.react.js'; import { olmAPI } from './crypto/olm-api.js'; import { initOpaque } from './crypto/opaque-utils.js'; import electron from './electron.js'; import InputStateContainer from './input/input-state-container.react.js'; import InviteLinkHandler from './invite-links/invite-link-handler.react.js'; import InviteLinksRefresher from './invite-links/invite-links-refresher.react.js'; import LoadingIndicator from './loading-indicator.react.js'; import { MenuProvider } from './menu-provider.react.js'; import UpdateModalHandler from './modals/update-modal.react.js'; import SettingsSwitcher from './navigation-panels/settings-switcher.react.js'; import Topbar from './navigation-panels/topbar.react.js'; import useBadgeHandler from './push-notif/badge-handler.react.js'; import { PushNotificationsHandler } from './push-notif/push-notifs-handler.js'; import { updateNavInfoActionType } from './redux/action-types.js'; import DisconnectedBar from './redux/disconnected-bar.js'; import FocusHandler from './redux/focus-handler.react.js'; import { KeyserverReachabilityHandler } from './redux/keyserver-reachability-handler.js'; import { persistConfig } from './redux/persist.js'; import PolicyAcknowledgmentHandler from './redux/policy-acknowledgment-handler.js'; import { useSelector } from './redux/redux-utils.js'; import VisibilityHandler from './redux/visibility-handler.react.js'; import history from './router-history.js'; import { MessageSearchStateProvider } from './search/message-search-state-provider.react.js'; -import { createTunnelbrokerInitMessage } from './selectors/tunnelbroker-selectors.js'; import AccountSettings from './settings/account-settings.react.js'; import DangerZone from './settings/danger-zone.react.js'; import KeyserverSelectionList from './settings/keyserver-selection-list.react.js'; import { getCommSharedWorker } from './shared-worker/shared-worker-provider.js'; import CommunityPicker from './sidebar/community-picker.react.js'; import Splash from './splash/splash.react.js'; import './typography.css'; import css from './style.css'; import { TooltipProvider } from './tooltips/tooltip-provider.js'; import { canonicalURLFromReduxState, navInfoFromURL } from './url-utils.js'; import { useWebLock, TUNNELBROKER_LOCK_NAME } from './web-lock.js'; void initOpaque(); // We want Webpack's css-loader and style-loader to handle the Fontawesome CSS, // so we disable the autoAddCss logic and import the CSS file. Otherwise every // icon flashes huge for a second before the CSS is loaded. import '@fortawesome/fontawesome-svg-core/styles.css'; faConfig.autoAddCss = false; const desktopDetails = electron?.version ? { majorDesktopVersion: extractMajorDesktopVersion(electron?.version) } : null; registerConfig({ // We can't securely cache credentials on web resolveKeyserverSessionInvalidationUsingNativeCredentials: null, setSessionIDOnRequest: true, // Never reset the calendar range calendarRangeInactivityLimit: null, platformDetails: { platform: electron?.platform ?? 'web', codeVersion: 75, stateVersion: persistConfig.version, ...desktopDetails, }, authoritativeKeyserverID, olmAPI, }); const versionBroadcast = new BroadcastChannel('comm_version'); versionBroadcast.postMessage(getConfig().platformDetails.codeVersion); versionBroadcast.onmessage = (event: MessageEvent) => { if (event.data && event.data !== getConfig().platformDetails.codeVersion) { location.reload(); } }; // Start initializing the shared worker immediately void getCommSharedWorker(); type BaseProps = { +location: { +pathname: string, ... }, }; type Props = { ...BaseProps, // Redux state +navInfo: WebNavInfo, +entriesLoadingStatus: LoadingStatus, +loggedIn: boolean, +activeThreadCurrentlyUnread: boolean, // Redux dispatch functions +dispatch: Dispatch, +modals: $ReadOnlyArray, }; class App extends React.PureComponent { componentDidMount() { const { navInfo, location: { pathname }, loggedIn, } = this.props; const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn); if (pathname !== newURL) { history.replace(newURL); } } componentDidUpdate(prevProps: Props) { const { navInfo, location: { pathname }, loggedIn, } = this.props; if (!_isEqual(navInfo)(prevProps.navInfo)) { const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn); if (newURL !== pathname) { history.push(newURL); } } else if (pathname !== prevProps.location.pathname) { const urlInfo = infoFromURL(pathname); const newNavInfo = navInfoFromURL(urlInfo, { navInfo }); if (!_isEqual(newNavInfo)(navInfo)) { this.props.dispatch({ type: updateNavInfoActionType, payload: newNavInfo, }); } } else if (loggedIn !== prevProps.loggedIn) { const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn); if (newURL !== pathname) { history.replace(newURL); } } if (loggedIn !== prevProps.loggedIn) { electron?.clearHistory(); } } onWordmarkClicked = () => { this.props.dispatch({ type: updateNavInfoActionType, payload: { tab: 'chat' }, }); }; render(): React.Node { let content; if (this.props.loggedIn) { content = ( <> {this.renderMainContent()} {this.props.modals} ); } else { content = ( <> {this.renderLoginPage()} {this.props.modals} ); } return ( {content} ); } onHeaderDoubleClick = (): void => electron?.doubleClickTopBar(); stopDoubleClickPropagation: ?(SyntheticEvent) => void = electron ? e => e.stopPropagation() : null; renderLoginPage(): React.Node { const { loginMethod } = this.props.navInfo; if (loginMethod === 'qr-code') { return ; } return ; } renderMainContent(): React.Node { const mainContent = this.getMainContentWithSwitcher(); let navigationArrows = null; if (electron) { navigationArrows = ; } const headerClasses = classnames({ [css.header]: true, [css['electron-draggable']]: electron, }); const wordmarkClasses = classnames({ [css.wordmark]: true, [css['electron-non-draggable']]: electron, [css['wordmark-macos']]: electron?.platform === 'macos', }); return (

Comm

{navigationArrows}
{mainContent}
); } getMainContentWithSwitcher(): React.Node { const { tab, settingsSection } = this.props.navInfo; let mainContent: React.Node; if (tab === 'settings') { if (settingsSection === 'account') { mainContent = ; } else if (settingsSection === 'friend-list') { mainContent = null; } else if (settingsSection === 'block-list') { mainContent = null; } else if (settingsSection === 'keyservers') { mainContent = ; } else if (settingsSection === 'build-info') { mainContent = null; } else if (settingsSection === 'danger-zone') { mainContent = ; } return (
{mainContent}
); } if (tab === 'calendar') { mainContent = ; } else if (tab === 'chat') { mainContent = ; } const mainContentClass = classnames( css['main-content-container'], css['main-content-container-column'], ); return (
{mainContent}
); } } const WEB_TUNNELBROKER_CHANNEL = new BroadcastChannel('shared-tunnelbroker'); const WEB_TUNNELBROKER_MESSAGE_TYPES = Object.freeze({ SEND_MESSAGE: 'send-message', MESSAGE_STATUS: 'message-status', }); function useOtherTabsTunnelbrokerConnection(): SecondaryTunnelbrokerConnection { const onSendMessageCallbacks = React.useRef< Set<(MessageToDeviceRequest) => mixed>, >(new Set()); const onMessageStatusCallbacks = React.useRef< Set<(messageID: string, error: ?string) => mixed>, >(new Set()); React.useEffect(() => { const messageHandler = (event: MessageEvent) => { if (typeof event.data !== 'object' || !event.data) { console.log( 'Invalid message received from shared ' + 'tunnelbroker broadcast channel', event.data, ); return; } const data = event.data; if (data.type === WEB_TUNNELBROKER_MESSAGE_TYPES.SEND_MESSAGE) { if (typeof data.message !== 'object' || !data.message) { console.log( 'Invalid tunnelbroker message request received ' + 'from shared tunnelbroker broadcast channel', event.data, ); return; } // We know that the input was already validated const message: MessageToDeviceRequest = (data.message: any); for (const callback of onSendMessageCallbacks.current) { callback(message); } } else if (data.type === WEB_TUNNELBROKER_MESSAGE_TYPES.MESSAGE_STATUS) { if (typeof data.messageID !== 'string') { console.log( 'Missing message id in message status message ' + 'from shared tunnelbroker broadcast channel', ); return; } const messageID = data.messageID; if ( typeof data.error !== 'string' && data.error !== null && data.error !== undefined ) { console.log( 'Invalid error in message status message ' + 'from shared tunnelbroker broadcast channel', data.error, ); return; } const error = data.error; for (const callback of onMessageStatusCallbacks.current) { callback(messageID, error); } } else { console.log( 'Invalid message type ' + 'from shared tunnelbroker broadcast channel', data, ); } }; WEB_TUNNELBROKER_CHANNEL.addEventListener('message', messageHandler); return () => WEB_TUNNELBROKER_CHANNEL.removeEventListener('message', messageHandler); }, [onMessageStatusCallbacks, onSendMessageCallbacks]); return React.useMemo( () => ({ sendMessage: message => WEB_TUNNELBROKER_CHANNEL.postMessage({ type: WEB_TUNNELBROKER_MESSAGE_TYPES.SEND_MESSAGE, message, }), onSendMessage: callback => { onSendMessageCallbacks.current.add(callback); return () => { onSendMessageCallbacks.current.delete(callback); }; }, setMessageStatus: (messageID, error) => { WEB_TUNNELBROKER_CHANNEL.postMessage({ type: WEB_TUNNELBROKER_MESSAGE_TYPES.MESSAGE_STATUS, messageID, error, }); }, onMessageStatus: callback => { onMessageStatusCallbacks.current.add(callback); return () => { onMessageStatusCallbacks.current.delete(callback); }; }, }), [onMessageStatusCallbacks, onSendMessageCallbacks], ); } const fetchEntriesLoadingStatusSelector = createLoadingStatusSelector( fetchEntriesActionTypes, ); const updateCalendarQueryLoadingStatusSelector = createLoadingStatusSelector( updateCalendarQueryActionTypes, ); const ConnectedApp: React.ComponentType = React.memo( function ConnectedApp(props) { const activeChatThreadID = useSelector( state => state.navInfo.activeChatThreadID, ); const navInfo = useSelector(state => state.navInfo); const fetchEntriesLoadingStatus = useSelector( fetchEntriesLoadingStatusSelector, ); const updateCalendarQueryLoadingStatus = useSelector( updateCalendarQueryLoadingStatusSelector, ); const entriesLoadingStatus = combineLoadingStatuses( fetchEntriesLoadingStatus, updateCalendarQueryLoadingStatus, ); const loggedIn = useSelector(isLoggedIn); const activeThreadCurrentlyUnread = useSelector( state => !activeChatThreadID || !!state.threadStore.threadInfos[activeChatThreadID]?.currentUser.unread, ); useBadgeHandler(); const dispatch = useDispatch(); const modalContext = useModalContext(); const modals = React.useMemo( () => modalContext.modals.map(([modal, key]) => ( {modal} )), [modalContext.modals], ); - const tunnelbrokerInitMessage = useSelector(createTunnelbrokerInitMessage); - const { lockStatus, releaseLockOrAbortRequest } = useWebLock( TUNNELBROKER_LOCK_NAME, ); const secondaryTunnelbrokerConnection: SecondaryTunnelbrokerConnection = useOtherTabsTunnelbrokerConnection(); return ( ); }, ); function AppWithProvider(props: BaseProps): React.Node { return ( ); } export default AppWithProvider; diff --git a/web/selectors/tunnelbroker-selectors.js b/web/selectors/tunnelbroker-selectors.js deleted file mode 100644 index 25ef22c36..000000000 --- a/web/selectors/tunnelbroker-selectors.js +++ /dev/null @@ -1,30 +0,0 @@ -// @flow - -import { createSelector } from 'reselect'; - -import type { ConnectionInitializationMessage } from 'lib/types/tunnelbroker/session-types.js'; - -import type { AppState } from '../redux/redux-setup.js'; - -export const createTunnelbrokerInitMessage: AppState => ?ConnectionInitializationMessage = - createSelector( - (state: AppState) => state.cryptoStore?.primaryIdentityKeys?.ed25519, - (state: AppState) => state.commServicesAccessToken, - (state: AppState) => state.currentUserInfo?.id, - ( - deviceID: ?string, - accessToken: ?string, - userID: ?string, - ): ?ConnectionInitializationMessage => { - if (!deviceID || !accessToken || !userID) { - return null; - } - return ({ - type: 'ConnectionInitializationMessage', - deviceID, - accessToken, - userID, - deviceType: 'web', - }: ConnectionInitializationMessage); - }, - );