diff --git a/README.md b/README.md index 4ff49a49..0233def2 100644 --- a/README.md +++ b/README.md @@ -1345,6 +1345,54 @@ This should be called before any user login takes place. --- +### `Intercom.setUserJwtProvider(provider)` + +Registers a provider Intercom calls when it needs a fresh user JWT, because the current one is about to expire or +Intercom rejected a request because it has expired. Requests that hit an expired JWT are retried once with the new +token instead of failing, and open Messenger screens recover without being reopened. + +The provider is only called while the app is in the foreground, and never more than once at a time. Resolve with a +fresh JWT, or with `null` if you can't supply one. Intercom waits up to 10 seconds for an answer. If the provider +fails or doesn't answer in time, Intercom waits 30 seconds before asking again. Calling `setUserJwt` while a request +is pending also completes it. + +Register the provider as early as possible after the app starts. Requests that expire before it is registered fail +until a new JWT is set. + +```javascript +Intercom.setUserJwtProvider(async () => { + const response = await fetch('https://your-backend.example.com/intercom-jwt'); + if (!response.ok) { + return null; + } + const { jwt } = await response.json(); + return jwt; +}); +``` + +### Options + +| Name | Type | Required | +| -------- | ------------------------------ | -------- | +| provider | `() => Promise` | yes | + +### Returns + +`Promise` + +--- + +### `Intercom.removeUserJwtProvider()` + +Removes the provider registered with `setUserJwtProvider`. Requests rejected for an expired JWT then fail until a new +JWT is set with `setUserJwt`. + +### Returns + +`Promise` + +--- + ### `Intercom.setAuthTokens(authTokens)` Provide Intercom with your auth tokens which can be used for functionality such as Custom Actions with Data Connectors. You can provide multiple diff --git a/__tests__/userJwtProvider.test.ts b/__tests__/userJwtProvider.test.ts new file mode 100644 index 00000000..b9ada630 --- /dev/null +++ b/__tests__/userJwtProvider.test.ts @@ -0,0 +1,92 @@ +import { DeviceEventEmitter } from 'react-native'; + +jest.mock('../src/nativeModules', () => ({ + IntercomModule: { + setUserJwtProvider: jest.fn(() => Promise.resolve(true)), + removeUserJwtProvider: jest.fn(() => Promise.resolve(true)), + completeUserJwtRequest: jest.fn(), + }, + IntercomEventEmitter: { + USER_JWT_REQUESTED_NOTIFICATION: 'IntercomUserJwtRequested', + addListener: jest.fn(), + removeListeners: jest.fn(), + }, +})); + +const { IntercomModule } = jest.requireMock('../src/nativeModules'); +const Intercom = require('../src').default; + +const requestJwt = async (requestId: string) => { + DeviceEventEmitter.emit('IntercomUserJwtRequested', { requestId }); + await new Promise((resolve) => setImmediate(resolve)); +}; + +describe('setUserJwtProvider', () => { + beforeEach(() => jest.clearAllMocks()); + afterEach(() => Intercom.removeUserJwtProvider()); + + test('completes the request with the JWT the provider resolves', async () => { + await Intercom.setUserJwtProvider(() => Promise.resolve('fresh-jwt')); + await requestJwt('1'); + + expect(IntercomModule.setUserJwtProvider).toHaveBeenCalledTimes(1); + expect(IntercomModule.completeUserJwtRequest).toHaveBeenCalledWith( + '1', + 'fresh-jwt' + ); + }); + + test.each([ + ['resolves null', () => Promise.resolve(null)], + ['resolves an empty string', () => Promise.resolve('')], + ['rejects', () => Promise.reject(new Error('offline'))], + ])('fails the request when the provider %s', async (_, provider) => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + await Intercom.setUserJwtProvider(provider); + await requestJwt('2'); + + expect(IntercomModule.completeUserJwtRequest).toHaveBeenCalledWith( + '2', + null + ); + }); + + test('only the latest provider answers', async () => { + const first = jest.fn(() => Promise.resolve('first')); + await Intercom.setUserJwtProvider(first); + await Intercom.setUserJwtProvider(() => Promise.resolve('second')); + await requestJwt('3'); + + expect(first).not.toHaveBeenCalled(); + expect(IntercomModule.completeUserJwtRequest).toHaveBeenCalledTimes(1); + expect(IntercomModule.completeUserJwtRequest).toHaveBeenCalledWith( + '3', + 'second' + ); + }); + + test('stops listening when native rejects the registration', async () => { + const provider = jest.fn(() => Promise.resolve('jwt')); + IntercomModule.setUserJwtProvider.mockImplementationOnce(() => + Promise.reject(new Error('Intercom has been initialized incorrectly')) + ); + await expect(Intercom.setUserJwtProvider(provider)).rejects.toThrow( + 'initialized incorrectly' + ); + await requestJwt('5'); + + expect(provider).not.toHaveBeenCalled(); + expect(IntercomModule.completeUserJwtRequest).not.toHaveBeenCalled(); + }); + + test('stops answering after removeUserJwtProvider', async () => { + const provider = jest.fn(() => Promise.resolve('jwt')); + await Intercom.setUserJwtProvider(provider); + await Intercom.removeUserJwtProvider(); + await requestJwt('4'); + + expect(IntercomModule.removeUserJwtProvider).toHaveBeenCalledTimes(1); + expect(provider).not.toHaveBeenCalled(); + expect(IntercomModule.completeUserJwtRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/android/build.gradle b/android/build.gradle index 839ab472..884c7c9f 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -84,6 +84,6 @@ dependencies { //noinspection GradleDynamicVersion implementation "com.facebook.react:react-native:+" // From node_modules implementation "com.google.firebase:firebase-messaging:24.1.2" - implementation 'io.intercom.android:intercom-sdk:18.9.3' - implementation 'io.intercom.android:intercom-sdk-ui:18.9.3' + implementation 'io.intercom.android:intercom-sdk:18.10.0' + implementation 'io.intercom.android:intercom-sdk-ui:18.10.0' } diff --git a/android/src/main/java/com/intercom/reactnative/IntercomErrorCodes.java b/android/src/main/java/com/intercom/reactnative/IntercomErrorCodes.java index 5e28f01d..80fed2e8 100644 --- a/android/src/main/java/com/intercom/reactnative/IntercomErrorCodes.java +++ b/android/src/main/java/com/intercom/reactnative/IntercomErrorCodes.java @@ -12,6 +12,8 @@ public class IntercomErrorCodes { public static final String SET_USER_JWT = "109"; public static final String SET_AUTH_TOKENS = "110"; public static final String INITIALIZE_ERROR = "111"; + public static final String SET_USER_JWT_PROVIDER = "112"; + public static final String REMOVE_USER_JWT_PROVIDER = "113"; public static final String DISPLAY_MESSENGER = "201"; public static final String DISPLAY_MESSENGER_COMPOSER = "202"; public static final String DISPLAY_CONTENT = "203"; diff --git a/android/src/main/java/com/intercom/reactnative/IntercomEventEmitter.java b/android/src/main/java/com/intercom/reactnative/IntercomEventEmitter.java index fd272f4b..d5d1da66 100644 --- a/android/src/main/java/com/intercom/reactnative/IntercomEventEmitter.java +++ b/android/src/main/java/com/intercom/reactnative/IntercomEventEmitter.java @@ -23,6 +23,7 @@ public class IntercomEventEmitter extends ReactContextBaseJavaModule { public static final String NAME = "IntercomEventEmitter"; private static final String UNREAD_COUNT_CHANGE_NOTIFICATION = "IntercomUnreadConversationCountDidChangeNotification"; + static final String USER_JWT_REQUESTED_NOTIFICATION = "IntercomUserJwtRequested"; private int activeListenersCount = 0; private final UnreadConversationCountListener unreadConversationCountListener = new UnreadConversationCountListener() { @Override @@ -80,6 +81,7 @@ public String getName() { public Map getConstants() { final Map constants = new HashMap<>(); constants.put("UNREAD_COUNT_CHANGE_NOTIFICATION", UNREAD_COUNT_CHANGE_NOTIFICATION); + constants.put("USER_JWT_REQUESTED_NOTIFICATION", USER_JWT_REQUESTED_NOTIFICATION); return constants; } diff --git a/android/src/main/java/com/intercom/reactnative/IntercomUserJwtProviderBridge.java b/android/src/main/java/com/intercom/reactnative/IntercomUserJwtProviderBridge.java new file mode 100644 index 00000000..eb5992bb --- /dev/null +++ b/android/src/main/java/com/intercom/reactnative/IntercomUserJwtProviderBridge.java @@ -0,0 +1,81 @@ +package com.intercom.reactnative; + +import android.util.Log; + +import androidx.annotation.Nullable; + +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.modules.core.DeviceEventManagerModule; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +import io.intercom.android.sdk.Intercom; +import io.intercom.android.sdk.IntercomUserJwtCallback; + +class IntercomUserJwtProviderBridge { + private static final String TAG = "IntercomUserJwtProvider"; + private static final Map pendingRequests = new ConcurrentHashMap<>(); + private static final AtomicLong nextRequestId = new AtomicLong(); + + static void setProvider(ReactApplicationContext reactContext) { + Intercom.client().setUserJwtProvider(callback -> { + String requestId = String.valueOf(nextRequestId.incrementAndGet()); + pendingRequests.put(requestId, callback); + if (!emitRequest(reactContext, requestId)) { + complete(requestId, null); + } + }); + } + + static void removeProvider() { + Intercom.client().removeUserJwtProvider(); + failPendingRequests(); + } + + static void complete(String requestId, @Nullable String jwt) { + IntercomUserJwtCallback callback = pendingRequests.remove(requestId); + if (callback == null) { + return; + } + if (jwt == null || jwt.isEmpty()) { + callback.onFailure(); + } else { + callback.onSuccess(jwt); + } + } + + static void invalidate() { + try { + removeProvider(); + } catch (Exception err) { + failPendingRequests(); + } + } + + private static void failPendingRequests() { + for (String requestId : pendingRequests.keySet()) { + complete(requestId, null); + } + } + + private static boolean emitRequest(ReactApplicationContext reactContext, String requestId) { + if (!reactContext.hasActiveReactInstance()) { + return false; + } + try { + WritableMap params = Arguments.createMap(); + params.putString("requestId", requestId); + reactContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) + .emit(IntercomEventEmitter.USER_JWT_REQUESTED_NOTIFICATION, params); + return true; + } catch (Exception err) { + Log.e(TAG, "Unable to request a user JWT: " + err); + return false; + } + } +} diff --git a/android/src/newarch/IntercomModule.java b/android/src/newarch/IntercomModule.java index d220fa83..bd9604b3 100644 --- a/android/src/newarch/IntercomModule.java +++ b/android/src/newarch/IntercomModule.java @@ -607,6 +607,43 @@ public void setUserJwt(String jwt, Promise promise) { } } + @ReactMethod + public void setUserJwtProvider(Promise promise) { + try { + IntercomUserJwtProviderBridge.setProvider(getReactApplicationContext()); + Log.d(NAME, "setUserJwtProvider"); + promise.resolve(true); + } catch (Exception err) { + Log.e(NAME, "setUserJwtProvider error:"); + Log.e(NAME, err.toString()); + promise.reject(IntercomErrorCodes.SET_USER_JWT_PROVIDER, err.toString()); + } + } + + @ReactMethod + public void removeUserJwtProvider(Promise promise) { + try { + IntercomUserJwtProviderBridge.removeProvider(); + Log.d(NAME, "removeUserJwtProvider"); + promise.resolve(true); + } catch (Exception err) { + Log.e(NAME, "removeUserJwtProvider error:"); + Log.e(NAME, err.toString()); + promise.reject(IntercomErrorCodes.REMOVE_USER_JWT_PROVIDER, err.toString()); + } + } + + @ReactMethod + public void completeUserJwtRequest(String requestId, @Nullable String jwt) { + IntercomUserJwtProviderBridge.complete(requestId, jwt); + } + + @Override + public void invalidate() { + IntercomUserJwtProviderBridge.invalidate(); + super.invalidate(); + } + @ReactMethod public void setAuthTokens(ReadableMap authTokens, Promise promise) { try { diff --git a/android/src/oldarch/IntercomModule.java b/android/src/oldarch/IntercomModule.java index 09fbacc8..56d0febb 100644 --- a/android/src/oldarch/IntercomModule.java +++ b/android/src/oldarch/IntercomModule.java @@ -585,6 +585,43 @@ public void setUserJwt(String jwt, Promise promise) { } } + @ReactMethod + public void setUserJwtProvider(Promise promise) { + try { + IntercomUserJwtProviderBridge.setProvider(getReactApplicationContext()); + Log.d(NAME, "setUserJwtProvider"); + promise.resolve(true); + } catch (Exception err) { + Log.e(NAME, "setUserJwtProvider error:"); + Log.e(NAME, err.toString()); + promise.reject(IntercomErrorCodes.SET_USER_JWT_PROVIDER, err.toString()); + } + } + + @ReactMethod + public void removeUserJwtProvider(Promise promise) { + try { + IntercomUserJwtProviderBridge.removeProvider(); + Log.d(NAME, "removeUserJwtProvider"); + promise.resolve(true); + } catch (Exception err) { + Log.e(NAME, "removeUserJwtProvider error:"); + Log.e(NAME, err.toString()); + promise.reject(IntercomErrorCodes.REMOVE_USER_JWT_PROVIDER, err.toString()); + } + } + + @ReactMethod + public void completeUserJwtRequest(String requestId, @Nullable String jwt) { + IntercomUserJwtProviderBridge.complete(requestId, jwt); + } + + @Override + public void invalidate() { + IntercomUserJwtProviderBridge.invalidate(); + super.invalidate(); + } + @ReactMethod public void setAuthTokens(ReadableMap authTokens, Promise promise) { try { diff --git a/examples/example/ios/Podfile.lock b/examples/example/ios/Podfile.lock index e3e4b668..109dda1b 100644 --- a/examples/example/ios/Podfile.lock +++ b/examples/example/ios/Podfile.lock @@ -8,15 +8,15 @@ PODS: - hermes-engine (0.81.1): - hermes-engine/Pre-built (= 0.81.1) - hermes-engine/Pre-built (0.81.1) - - Intercom (19.8.1) - - intercom-react-native (10.7.1): + - Intercom (19.9.0) + - intercom-react-native (10.8.0): - boost - DoubleConversion - fast_float - fmt - glog - hermes-engine - - Intercom (~> 19.8.1) + - Intercom (~> 19.9.0) - RCT-Folly - RCT-Folly/Fabric - RCTRequired @@ -2560,8 +2560,8 @@ SPEC CHECKSUMS: fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 hermes-engine: 4f8246b1f6d79f625e0d99472d1f3a71da4d28ca - Intercom: 325645361ecde0014b6d1a0dacde2097f6828218 - intercom-react-native: 0b576e71fab9223d0f08bd27e3b3b1577803448f + Intercom: 4a35bcead5f437a8445a7113a0fe4991f36559fc + intercom-react-native: e384d183f9319923f0e472a47fb018508b98093c RCT-Folly: 59ec0ac1f2f39672a0c6e6cecdd39383b764646f RCTDeprecation: c4b9e2fd0ab200e3af72b013ed6113187c607077 RCTRequired: e97dd5dafc1db8094e63bc5031e0371f092ae92a diff --git a/examples/example/src/App.tsx b/examples/example/src/App.tsx index 6c98df6f..078f4bd4 100644 --- a/examples/example/src/App.tsx +++ b/examples/example/src/App.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Alert, Image, @@ -51,6 +51,11 @@ export default function App() { const [launcherVisibility, setLauncherVisibility] = useState(false); const [user, setUser] = useState({ email: '' }); const [currentTheme, setCurrentTheme] = useState(ThemeMode.SYSTEM); + const [userJwt, setUserJwt] = useState(''); + const [providerJwt, setProviderJwt] = useState(''); + const providerJwtRef = useRef(''); + const [jwtProviderEnabled, setJwtProviderEnabled] = useState(false); + const [jwtProviderCalls, setJwtProviderCalls] = useState(0); const [conversationId, setConversationId] = useState( CONVERSATION_ID @@ -192,6 +197,11 @@ export default function App() { {`Logged In: ${loggedUser ? 'Yes' : 'No'}`} Unread messages count: {count} + + {`JWT provider: ${ + jwtProviderEnabled ? 'On' : 'Off' + } (calls: ${jwtProviderCalls})`} +