diff --git a/lib/identity-search/identity-search-context.js b/lib/identity-search/identity-search-context.js index 944f2eb48..d3e60603c 100644 --- a/lib/identity-search/identity-search-context.js +++ b/lib/identity-search/identity-search-context.js @@ -1,206 +1,282 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; +import uuid from 'uuid'; import { identitySearchURL } from '../facts/identity-search.js'; -import { identitySearchHeartbeatTimeout } from '../shared/timeouts.js'; +import { + clientRequestVisualTimeout, + identitySearchHeartbeatTimeout, +} from '../shared/timeouts.js'; import type { IdentitySearchAuthMessage } from '../types/identity-search/auth-message-types.js'; import { type IdentitySearchMessageToClient, identitySearchMessageToClientTypes, identitySearchMessageToServerTypes, identitySearchMessageToClientValidator, } from '../types/identity-search/messages.js'; +import { + type IdentitySearchQuery, + type IdentitySearchPrefix, +} from '../types/identity-search/search-query-types.js'; +import { + type IdentitySearchUser, + identitySearchResponseValidator, +} from '../types/identity-search/search-response-types.js'; import type { Heartbeat } from '../types/websocket/heartbeat-types.js'; import { useGetIdentitySearchAuthMessage } from '../utils/identity-search-utils.js'; +import sleep from '../utils/sleep.js'; export type IdentitySearchSocketListener = ( message: IdentitySearchMessageToClient, ) => mixed; +type PromiseCallbacks = { + +resolve: (hits: $ReadOnlyArray) => void, + +reject: (error: string) => void, +}; +type Promises = { [queryID: string]: PromiseCallbacks }; + type IdentitySearchContextType = { - +addListener: (listener: IdentitySearchSocketListener) => void, - +removeListener: (listener: IdentitySearchSocketListener) => void, + +sendPrefixQuery: ( + usernamePrefix: string, + ) => Promise<$ReadOnlyArray>, + +addListener: (listener: IdentitySearchSocketListener) => mixed, + +removeListener: (listener: IdentitySearchSocketListener) => mixed, +connected: boolean, }; +const timeout = async (): Promise<$ReadOnlyArray> => { + await sleep(clientRequestVisualTimeout); + throw new Error('search request timed out'); +}; + const IdentitySearchContext: React.Context = React.createContext(); type Props = { +children: React.Node, }; function IdentitySearchProvider(props: Props): React.Node { const { children } = props; const [connected, setConnected] = React.useState(false); const listeners = React.useRef>(new Set()); const getIdentitySearchAuthMessage = useGetIdentitySearchAuthMessage(); const [identitySearchAuthMessage, setIdentitySearchAuthMessage] = React.useState(null); const socket = React.useRef(null); + const promises = React.useRef({}); const heartbeatTimeoutID = React.useRef(); const previousAuthMessage = React.useRef( identitySearchAuthMessage, ); 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); }, identitySearchHeartbeatTimeout); }, [stopHeartbeatTimeout]); React.useEffect(() => { void (async () => { const newAuthMessage = await getIdentitySearchAuthMessage(); setIdentitySearchAuthMessage(newAuthMessage); })(); }, [getIdentitySearchAuthMessage]); React.useEffect(() => { const isSocketActive = socket.current?.readyState === WebSocket.OPEN || socket.current?.readyState === WebSocket.CONNECTING; const identitySearchAuthMessageChanged = identitySearchAuthMessage !== previousAuthMessage.current; previousAuthMessage.current = identitySearchAuthMessage; if ( (!identitySearchAuthMessage || identitySearchAuthMessageChanged) && isSocketActive ) { socket.current?.close(); return; } if (connected || !identitySearchAuthMessage) { return; } const identitySearchSocket = new WebSocket(identitySearchURL); identitySearchSocket.onopen = () => { identitySearchSocket.send(JSON.stringify(identitySearchAuthMessage)); }; identitySearchSocket.onclose = () => { setConnected(false); }; identitySearchSocket.onerror = e => { setConnected(false); console.log('Identity Search socket error', e.message); }; identitySearchSocket.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 Identity Search message:', e.message); return; } if (!identitySearchMessageToClientValidator.is(rawMessage)) { console.log('invalid Identity Search message'); return; } const message: IdentitySearchMessageToClient = rawMessage; resetHeartbeatTimeout(); for (const listener of listeners.current) { listener(message); } if ( message.type === identitySearchMessageToClientTypes.CONNECTION_INITIALIZATION_RESPONSE ) { if (message.status.type === 'Success' && !connected) { setConnected(true); } 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 Identity Search error:', message.status.data, ); } + } else if ( + message.type === identitySearchMessageToClientTypes.SUCCESS || + message.type === identitySearchMessageToClientTypes.ERROR + ) { + if (!identitySearchResponseValidator.is(message)) { + console.log('Invalid search response message'); + return; + } + if (message.type === identitySearchMessageToClientTypes.SUCCESS) { + promises.current[message.data.id]?.resolve(message.data.hits); + delete promises.current[message.data.id]; + } else { + promises.current[message.data.id]?.reject(message.data.error); + delete promises.current[message.data.id]; + } } else if ( message.type === identitySearchMessageToClientTypes.HEARTBEAT ) { const heartbeat: Heartbeat = { type: identitySearchMessageToServerTypes.HEARTBEAT, }; socket.current?.send(JSON.stringify(heartbeat)); } }; socket.current = identitySearchSocket; }, [ connected, identitySearchAuthMessage, resetHeartbeatTimeout, stopHeartbeatTimeout, ]); + const sendPrefixQuery: ( + usernamePrefix: string, + ) => Promise<$ReadOnlyArray> = React.useCallback( + (usernamePrefix: string) => { + if (!connected || !socket.current) { + return Promise.reject(new Error('Socket is not connected')); + } + + const queryID = uuid.v4(); + const prefixQuery: IdentitySearchPrefix = { + type: identitySearchMessageToServerTypes.IDENTITY_SEARCH_PREFIX, + prefix: usernamePrefix, + }; + + const searchQuery: IdentitySearchQuery = { + type: identitySearchMessageToServerTypes.IDENTITY_SEARCH_QUERY, + id: queryID, + searchMethod: prefixQuery, + }; + + const requestPromise: Promise<$ReadOnlyArray> = + new Promise((resolve, reject) => { + promises.current[queryID] = { resolve, reject }; + }); + + socket.current?.send(JSON.stringify(searchQuery)); + + return Promise.race([requestPromise, timeout()]); + }, + [connected], + ); + const addListener = React.useCallback( (listener: IdentitySearchSocketListener) => { listeners.current.add(listener); }, [], ); const removeListener = React.useCallback( (listener: IdentitySearchSocketListener) => { listeners.current.delete(listener); }, [], ); const value: IdentitySearchContextType = React.useMemo( () => ({ + sendPrefixQuery, connected, addListener, removeListener, }), - [connected, addListener, removeListener], + [connected, addListener, removeListener, sendPrefixQuery], ); return ( {children} ); } function useIdentitySearch(): IdentitySearchContextType { const context = React.useContext(IdentitySearchContext); invariant(context, 'IdentitySearchContext not found'); return context; } export { IdentitySearchProvider, useIdentitySearch };