diff --git a/App.tsx b/App.tsx index 613426dc..7101c1bf 100644 --- a/App.tsx +++ b/App.tsx @@ -222,8 +222,12 @@ const App = () => { }, []); const fetchServerStatusState = useCallback(async () => { - const response = await fetch(STATUS_URL, {cache: 'no-cache'}); - if (!response.ok) { + let response: Response | null = null + try { + response = await fetch(STATUS_URL, {cache: 'no-cache'}); + } catch (e) {}; + + if (response === null || !response.ok) { // If even the status server is down, things are *very* not-okay. But odds // are it can't be contacted because the user has a crappy internet // connection. The "You're offline" notice should still provide some diff --git a/components/inbox-tab.tsx b/components/inbox-tab.tsx index ef65856a..4a163c91 100644 --- a/components/inbox-tab.tsx +++ b/components/inbox-tab.tsx @@ -27,7 +27,7 @@ import { TopNavBarButton } from './top-nav-bar-button'; import { inboxOrder, inboxSection } from '../kv-storage/inbox'; import { signedInUser } from '../App'; import { Notice } from './notice'; -import { listen } from '../events/events'; +import { listen, lastEvent } from '../events/events'; const Stack = createNativeStackNavigator(); @@ -311,21 +311,10 @@ const InboxTab_ = ({navigation}) => { return ( - - - {'Inbox' + (showArchive ? ' (Archive)' : '')} - - - + {inbox === null && @@ -349,6 +338,48 @@ const InboxTab_ = ({navigation}) => { ); }; +const InboxTabNavBar = ({ + showArchive, + onPressArchiveButton, +}) => { + const [isOnline, setIsOnline] = useState(lastEvent('xmpp-is-online') ?? false); + + useEffect(() => { + return listen('xmpp-is-online', setIsOnline); + }, []); + + return ( + + + + {'Inbox' + (showArchive ? ' (Archive)' : '')} + + {!isOnline && + + } + + + + ); +}; + const styles = StyleSheet.create({ safeAreaView: { flex: 1 diff --git a/components/stream-error-modal.tsx b/components/stream-error-modal.tsx index 5f3dd12c..b41dae6d 100644 --- a/components/stream-error-modal.tsx +++ b/components/stream-error-modal.tsx @@ -1,4 +1,5 @@ import { + Platform, Text, View, } from 'react-native'; @@ -18,6 +19,10 @@ const StreamErrorModal = () => { return <>; }; + if (Platform.OS !== 'web') { + return <>; + } + return ( { return isValidUuid(uuid) ? uuid : ''; } +const safeSend = async (element: Element) => { + if (!_xmpp.current) { + return; + } + + if (_xmpp.current.client.status !== 'online') { + return; + } + + await _xmpp.current.client.send(element).catch(console.error); +}; + // TODO: Catch more exceptions. If a network request fails, that shouldn't crash the app. // TODO: Update match percentages when user answers some questions @@ -83,6 +102,11 @@ type Message = { timestamp: Date }; +type Pong = { + preferredInterval: number + preferredTimeout: number +}; + type Conversation = { personId: number personUuid: string @@ -420,7 +444,10 @@ const select1 = (query: string, stanza: Element): xpath.SelectedValue => { return xpath.select1(query, doc); }; -const login = async (username: string, password: string) => { +const login = async ( + username: string, + password: string, +) => { if (_xmpp.current) { return; // Already logged in } @@ -434,47 +461,59 @@ const login = async (username: string, password: string) => { resource: await deviceId(), }; - _xmpp.current = client(options); + _xmpp.current = { + client: client(options), + username, + password, + }; + + // The default is 1 second. We set it to 3 seconds here, but it's a shame + // xmpp.js doesn't support exponential backoff + _xmpp.current.client.reconnect.delay = 3 * 1000; - _xmpp.current.on("error", (err) => { + _xmpp.current.client.on("error", async (err) => { console.error(err); if (err.message === "conflict - Replaced by new connection") { notify('stream-error'); + await logout(); } }); - _xmpp.current.on("offline", () => notify('xmpp-is-online', false)); - _xmpp.current.on("connecting", () => notify('xmpp-is-online', false)); - _xmpp.current.on("opening", () => notify('xmpp-is-online', false)); - _xmpp.current.on("closing", () => notify('xmpp-is-online', false)); - _xmpp.current.on("close", () => notify('xmpp-is-online', false)); - _xmpp.current.on("disconnecting", () => notify('xmpp-is-online', false)); - _xmpp.current.on("disconnect", () => notify('xmpp-is-online', false)); + _xmpp.current.client.on("offline", () => notify('xmpp-is-online', false)); + _xmpp.current.client.on("connecting", () => notify('xmpp-is-online', false)); + _xmpp.current.client.on("opening", () => notify('xmpp-is-online', false)); + _xmpp.current.client.on("closing", () => notify('xmpp-is-online', false)); + _xmpp.current.client.on("close", () => notify('xmpp-is-online', false)); + _xmpp.current.client.on("disconnecting", () => notify('xmpp-is-online', false)); + _xmpp.current.client.on("disconnect", () => notify('xmpp-is-online', false)); + + _xmpp.current.client.on("online", async () => { + if (!_xmpp.current) { + return; + } - _xmpp.current.on("online", async () => { - if (_xmpp.current) { - notify('xmpp-is-online', true); + _xmpp.reconnecting = false; - refreshInbox(); + notify('xmpp-is-online', true); - await registerForPushNotificationsAsync(); - } + refreshInbox(); + + await registerForPushNotificationsAsync(); }); - _xmpp.current.on("input", async (input: Element) => { + _xmpp.current.client.on("input", async (input: Element) => { notify('xmpp-input', input); }); - _xmpp.current.on("stanza", async (stanza: Element) => { + _xmpp.current.client.on("stanza", async (stanza: Element) => { notify('xmpp-stanza', stanza) }); - await _xmpp.current.start(); + await _xmpp.current.client.start(); } catch (e) { - _xmpp.current = undefined; - notify('xmpp-is-online', false); - console.error(e); + + await logout(); } }; @@ -488,14 +527,14 @@ const markDisplayed = async (message: Message) => { `); - await _xmpp.current.send(stanza); + await safeSend(stanza).catch(console.warn); setInboxDisplayed(jidToBareJid(message.from)); }; const _sendMessage = ( recipientPersonUuid: string, message: string, - callback: (messageStatus: Omit) => void, + callback: (messageStatus: MessageStatus) => void, ): void => { const id = getRandomString(40); const fromJid = ( @@ -566,7 +605,9 @@ const _sendMessage = ( const removeListener = listen('xmpp-input', messageStatusListener); - _xmpp.current.send(messageXml); + setTimeout(removeListener, messageTimeout); + + safeSend(messageXml).catch(console.warn); }; const sendMessage = async ( @@ -578,7 +619,7 @@ const sendMessage = async ( _sendMessage(recipientPersonUuid, message, resolve) ); - return await withTimeout(30000, __sendMessage); + return await withReconnectOnTimeout(messageTimeout, __sendMessage); }; const conversationsToInbox = (conversations: Conversation[]): Inbox => { @@ -797,7 +838,10 @@ const _fetchConversation = async ( const removeListener1 = listen('xmpp-stanza', maybeCollect); const removeListener2 = listen('xmpp-stanza', maybeFin); - await _xmpp.current.send(queryStanza).catch(console.warn); + setTimeout(removeListener1, fetchConversationTimeout); + setTimeout(removeListener2, fetchConversationTimeout); + + await safeSend(queryStanza).catch(console.warn); }; const fetchConversation = async ( @@ -809,7 +853,7 @@ const fetchConversation = async ( _fetchConversation(withPersonUuid, resolve, beforeId) ); - return await withTimeout(30000, __fetchConversation); + return await withReconnectOnTimeout(fetchConversationTimeout, __fetchConversation); }; const _fetchInboxPage = async ( @@ -947,15 +991,22 @@ const _fetchInboxPage = async ( const removeListener1 = listen('xmpp-stanza', maybeCollect); const removeListener2 = listen('xmpp-stanza', maybeFin); - await _xmpp.current.send(queryStanza).catch(console.warn); + setTimeout(removeListener1, fetchInboxTimeout); + setTimeout(removeListener2, fetchInboxTimeout); + + await safeSend(queryStanza).catch(console.warn); }; const fetchInboxPage = async ( endTimestamp: Date | null = null, pageSize: number | null = null, -): Promise => { - return new Promise((resolve) => - _fetchInboxPage(resolve, endTimestamp, pageSize)); +): Promise => { + const __fetchInboxPage = new Promise( + (resolve: (inbox: Inbox | undefined) => void) => + _fetchInboxPage(resolve, endTimestamp, pageSize) + ); + + return await withReconnectOnTimeout(fetchInboxTimeout, __fetchInboxPage); }; const refreshInbox = async (): Promise => { @@ -964,6 +1015,10 @@ const refreshInbox = async (): Promise => { while (true) { const page = await fetchInboxPage(inbox.endTimestamp); + if (page === 'timeout') { + continue; + } + const isEmptyPage = ( !page || !page.archive.conversations.length && @@ -987,12 +1042,17 @@ const refreshInbox = async (): Promise => { }; const logout = async () => { - if (_xmpp.current) { - notify('xmpp-is-online', false); - await _xmpp.current.stop().catch(console.error); - notify('inbox', null); - _xmpp.current = undefined; + if (!_xmpp.current) { + return; } + + _xmpp.current.client.reconnect.stop(); + await _xmpp.current.client.stop().catch(console.warn); + + notify('xmpp-is-online', false); + notify('inbox', null); + + _xmpp.current = null; }; const registerPushToken = async (token: string | null) => { @@ -1004,13 +1064,137 @@ const registerPushToken = async (token: string | null) => { const stanza = parse(xmlStr); - await _xmpp.current.send(stanza); + await safeSend(stanza).catch(console.warn); +}; + +const _pingServer = (resolve: (result: Pong | null | 'timeout') => void) => { + if (!_xmpp.current) { + resolve(null); + return; + } + + if (_xmpp.current.client.status !== 'online') { + resolve(null); + return; + } + + if (_xmpp.reconnecting) { + resolve(null); + return; + } + + const listenerRemovers: (() => void)[] = []; + + const removeListners = () => listenerRemovers.forEach(lr => lr()); + + const stanza = parse(''); + + const resolveTimeout = () => { + resolve('timeout'); + removeListners(); + }; + + const timeout = setTimeout(() => resolve('timeout'), pingTimeout); + + const maybeClearPingTimeout = (input: string) => { + let stanza: Element | null = null; + + try { + stanza = parse(input); + } catch (e) { + return; + } + + if (stanza.name !== 'duo_pong') { + return; + } + + const duo_pong: Pong = { + preferredInterval: parseInt(stanza.getAttr('preferred_interval')), + preferredTimeout: parseInt(stanza.getAttr('preferred_timeout')), + }; + + clearTimeout(timeout); + resolve(duo_pong); + removeListners(); + }; + + listenerRemovers.push(listen('xmpp-input', maybeClearPingTimeout)); + + safeSend(stanza).catch(console.warn); +}; + +const pingServer = async (timeout?: number): Promise => { + const __pingServer = new Promise( + (resolve: (result: Pong | null | 'timeout') => void) => + _pingServer(resolve) + ); + + return await withReconnectOnTimeout(timeout ?? pingTimeout, __pingServer); +}; + + +const pingServerForever = async () => { + let pong: Pong = { + preferredInterval: 10000, + preferredTimeout: pingTimeout, + }; + + while (true) { + const maybePong = await pingServer(pong.preferredTimeout); + + if (maybePong !== 'timeout' && maybePong !== null) { + pong = maybePong; + } + + await delay(pong.preferredInterval); + }; +}; + +const recreateChatClient = async () => { + if (!_xmpp.current) { + return; // No current session to recreate + }; + + if (_xmpp.reconnecting) { + // If we're already attempting to reconnect, we don't want to interrupt that + // attempt, otherwise we risk creating two streams at once and triggering + // the exception "conflict - Replaced by new connection" + return; + } + + _xmpp.reconnecting = true; + + const { username, password } = _xmpp.current; + + await logout(); + await login(username, password); +}; + +const withReconnectOnTimeout = async (ms: number, promise: Promise): Promise => { + // If we received other traffic during the time this promise timed-out, we're + // still connected, we we'd rather not call `recreateChatClient`. + var recievedAnyInput = false; + + const removeInputListener = listen( + 'xmpp-input', + () => recievedAnyInput = true, + ); + + const result = await withTimeout(ms, promise); + + removeInputListener(); + + if (result === 'timeout' && !recievedAnyInput) { + recreateChatClient(); + } + + return result; }; const onChangeAppState = (state: AppStateStatus) => { - const hasInitialInbox = lastEvent('inbox'); - if (Platform.OS !== 'web' && state === 'active' && hasInitialInbox) { - refreshInbox(); + if (state === 'active') { + pingServer(); } }; @@ -1020,6 +1204,11 @@ AppState.addEventListener('change', onChangeAppState); // Update the inbox upon receiving a message onReceiveMessage(); +// Triggers reconnection if the server goes away. This should be handled by +// xmpp.js, though I think we're affected by this bug: +// https://github.com/xmppjs/xmpp.js/issues/902 +pingServerForever(); + export { Conversation, Conversations,