diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a82ae61a..b1835b984 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Fixes +- Fixed inbox HTML messages forwarding unknown `iterable://` links to `urlHandler`. `iterable://` is reserved for `iterable://delete` and `iterable://dismiss`; any other `iterable://` link now closes the message without calling a handler. Use `action://name` for custom actions (`customActionHandler`) and your app's own scheme for deep links (`urlHandler`). In inbox messages, `http(s)` links open with `Linking.openURL` and do not call `urlHandler`. - Fixed `Iterable.trackPushOpenWithCampaignId` on Android so the `appAlreadyRunning` argument is forwarded to the native SDK instead of always being sent as `false`. - `Iterable.inAppManager.showMessage` now rejects on iOS when the message id is empty or no longer in the native queue, matching Android. Previously the promise never settled. diff --git a/src/inbox/components/IterableInboxMessageDisplay.test.tsx b/src/inbox/components/IterableInboxMessageDisplay.test.tsx new file mode 100644 index 000000000..a936e0139 --- /dev/null +++ b/src/inbox/components/IterableInboxMessageDisplay.test.tsx @@ -0,0 +1,258 @@ +import { View } from 'react-native'; +import { + act, + fireEvent, + render, + type RenderResult, + waitFor, +} from '@testing-library/react-native'; + +import { MockLinking } from '../../__mocks__/MockLinking'; +import { MockRNIterableAPI } from '../../__mocks__/MockRNIterableAPI'; +import { Iterable } from '../../core/classes/Iterable'; +import { IterableActionContext } from '../../core/classes/IterableActionContext'; +import { IterableConfig } from '../../core/classes/IterableConfig'; +import { IterableEdgeInsets } from '../../core/classes/IterableEdgeInsets'; +import { IterableActionSource } from '../../core/enums/IterableActionSource'; +import { IterableHtmlInAppContent } from '../../inApp/classes/IterableHtmlInAppContent'; +import { IterableInAppMessage } from '../../inApp/classes/IterableInAppMessage'; +import { IterableInAppTrigger } from '../../inApp/classes/IterableInAppTrigger'; +import { IterableInboxMetadata } from '../../inApp/classes/IterableInboxMetadata'; +import { IterableInAppTriggerType } from '../../inApp/enums/IterableInAppTriggerType'; +import { IterableInAppCloseSource } from '../../inApp/enums/IterableInAppCloseSource'; +import { IterableInAppLocation } from '../../inApp/enums/IterableInAppLocation'; +import type { IterableInboxRowViewModel } from '../types'; +import { IterableInboxMessageDisplay } from './IterableInboxMessageDisplay'; + +describe('IterableInboxMessageDisplay', () => { + const messageId = 'msg-1'; + + const rowViewModel: IterableInboxRowViewModel = { + title: 'T', + read: false, + inAppMessage: new IterableInAppMessage( + messageId, + 1, + new IterableInAppTrigger(IterableInAppTriggerType.never), + undefined, + undefined, + true, + new IterableInboxMetadata('Hello', undefined, undefined), + undefined, + false, + 0 + ), + }; + + const htmlContent = new IterableHtmlInAppContent( + new IterableEdgeInsets(0, 0, 0, 0), + '' + ); + + let deleteRow: jest.Mock; + let returnToInbox: jest.Mock; + let urlHandler: jest.Mock; + let customActionHandler: jest.Mock; + let trackInAppClickSpy: jest.SpyInstance; + let trackInAppCloseSpy: jest.SpyInstance; + + beforeEach(() => { + deleteRow = jest.fn(); + returnToInbox = jest.fn((callback?: () => void) => { + callback?.(); + }); + urlHandler = jest.fn(); + customActionHandler = jest.fn(); + + const config = new IterableConfig(); + config.urlHandler = urlHandler; + config.customActionHandler = customActionHandler; + Iterable.savedConfig = config; + + trackInAppClickSpy = jest + .spyOn(Iterable, 'trackInAppClick') + .mockImplementation(() => {}); + trackInAppCloseSpy = jest + .spyOn(Iterable, 'trackInAppClose') + .mockImplementation(() => {}); + + MockRNIterableAPI.trackInAppClick.mockClear(); + MockRNIterableAPI.trackInAppClose.mockClear(); + MockLinking.openURL.mockClear(); + }); + + afterEach(() => { + trackInAppClickSpy.mockRestore(); + trackInAppCloseSpy.mockRestore(); + Iterable.savedConfig = new IterableConfig(); + }); + + async function renderWithContent(): Promise { + const utils = render( + + ); + await act(async () => { + await Promise.resolve(); + }); + return utils; + } + + /** Global setup mocks `WebView` as `View`; pick the node wired with `onMessage`. */ + function postWebViewMessage(utils: RenderResult, url: string) { + const candidates = utils.UNSAFE_getAllByType(View); + const webView = candidates.find( + (node) => typeof node.props.onMessage === 'function' + ); + expect(webView).toBeDefined(); + const onMessage = webView!.props.onMessage as ( + e: { nativeEvent: { data: string } } + ) => void; + onMessage({ nativeEvent: { data: url } }); + } + + it('should open http(s) in the system browser and return to the inbox', async () => { + const utils = await renderWithContent(); + const url = 'https://example.com/path'; + + postWebViewMessage(utils, url); + + expect(trackInAppClickSpy).toHaveBeenCalledWith( + rowViewModel.inAppMessage, + IterableInAppLocation.inbox, + url + ); + expect(trackInAppCloseSpy).toHaveBeenCalledWith( + rowViewModel.inAppMessage, + IterableInAppLocation.inbox, + IterableInAppCloseSource.link, + url + ); + expect(returnToInbox).toHaveBeenCalled(); + expect(MockLinking.openURL).toHaveBeenCalledWith(url); + expect(deleteRow).not.toHaveBeenCalled(); + expect(urlHandler).not.toHaveBeenCalled(); + }); + + it('should delete the row and return to the inbox for iterable://delete', async () => { + const utils = await renderWithContent(); + + postWebViewMessage(utils, 'iterable://delete'); + + expect(deleteRow).toHaveBeenCalledWith(messageId); + expect(returnToInbox).toHaveBeenCalled(); + expect(urlHandler).not.toHaveBeenCalled(); + expect(customActionHandler).not.toHaveBeenCalled(); + expect(MockLinking.openURL).not.toHaveBeenCalled(); + }); + + it('should return to the inbox for iterable://dismiss without deleting', async () => { + const utils = await renderWithContent(); + + postWebViewMessage(utils, 'iterable://dismiss'); + + expect(returnToInbox).toHaveBeenCalled(); + expect(deleteRow).not.toHaveBeenCalled(); + expect(urlHandler).not.toHaveBeenCalled(); + expect(customActionHandler).not.toHaveBeenCalled(); + }); + + it('should invoke customActionHandler with the action type for action:// URLs', async () => { + const utils = await renderWithContent(); + const url = 'action://foo'; + + postWebViewMessage(utils, url); + + await waitFor(() => { + expect(customActionHandler).toHaveBeenCalled(); + }); + + expect(customActionHandler).toHaveBeenCalledWith( + expect.objectContaining({ type: 'foo' }), + expect.any(IterableActionContext) + ); + const [, context] = customActionHandler.mock.calls[0]; + expect(context.source).toBe(IterableActionSource.inApp); + expect(returnToInbox).toHaveBeenCalled(); + expect(urlHandler).not.toHaveBeenCalled(); + }); + + it('should close the message for unknown iterable:// URLs without calling any handler', async () => { + const utils = await renderWithContent(); + const url = 'iterable://foo'; + + postWebViewMessage(utils, url); + + expect(returnToInbox).toHaveBeenCalledTimes(1); + expect(trackInAppClickSpy).toHaveBeenCalledTimes(1); + expect(trackInAppCloseSpy).toHaveBeenCalledTimes(1); + expect(trackInAppCloseSpy).toHaveBeenCalledWith( + rowViewModel.inAppMessage, + IterableInAppLocation.inbox, + IterableInAppCloseSource.link, + url + ); + expect(urlHandler).not.toHaveBeenCalled(); + expect(customActionHandler).not.toHaveBeenCalled(); + expect(MockLinking.openURL).not.toHaveBeenCalled(); + expect(deleteRow).not.toHaveBeenCalled(); + }); + + it('should pass other URLs to urlHandler', async () => { + const utils = await renderWithContent(); + const url = 'myapp://screen'; + + postWebViewMessage(utils, url); + + await waitFor(() => { + expect(urlHandler).toHaveBeenCalledWith(url, expect.any(IterableActionContext)); + }); + expect(returnToInbox).toHaveBeenCalled(); + expect(MockLinking.openURL).not.toHaveBeenCalled(); + }); + + it('should forward click tracking to the native bridge', async () => { + trackInAppClickSpy.mockRestore(); + trackInAppCloseSpy.mockRestore(); + + const utils = await renderWithContent(); + const url = 'https://example.com/x'; + + postWebViewMessage(utils, url); + + expect(MockRNIterableAPI.trackInAppClick).toHaveBeenCalledWith( + messageId, + IterableInAppLocation.inbox, + url + ); + expect(MockRNIterableAPI.trackInAppClose).toHaveBeenCalledWith( + messageId, + IterableInAppLocation.inbox, + IterableInAppCloseSource.link, + url + ); + }); + + it('should track close when the back button is pressed', async () => { + trackInAppClickSpy.mockRestore(); + trackInAppCloseSpy.mockRestore(); + + const { getByText } = await renderWithContent(); + + fireEvent.press(getByText('Inbox')); + + expect(returnToInbox).toHaveBeenCalled(); + expect(MockRNIterableAPI.trackInAppClose).toHaveBeenCalledWith( + messageId, + IterableInAppLocation.inbox, + IterableInAppCloseSource.back, + undefined + ); + }); +}); diff --git a/src/inbox/components/IterableInboxMessageDisplay.tsx b/src/inbox/components/IterableInboxMessageDisplay.tsx index d42306a04..59c90e084 100644 --- a/src/inbox/components/IterableInboxMessageDisplay.tsx +++ b/src/inbox/components/IterableInboxMessageDisplay.tsx @@ -163,6 +163,9 @@ export const IterableInboxMessageDisplay = ({ const source = IterableActionSource.inApp; const context = new IterableActionContext(action, source); + // Like native HTML in-apps, every link click sends click and close(link) + // and then closes the message. Unknown iterable:// paths close without + // calling urlHandler or customActionHandler. Iterable.trackInAppClick( rowViewModel.inAppMessage, IterableInAppLocation.inbox, @@ -180,6 +183,10 @@ export const IterableInboxMessageDisplay = ({ returnToInbox(() => deleteRow(rowViewModel.inAppMessage.messageId)); //handle dismiss action } else if (URL === 'iterable://dismiss') { + returnToInbox(); + // iterable:// is reserved: only delete/dismiss are SDK actions (SDK-633). + // Any other path closes the message without calling a handler, like native. + } else if (URL.startsWith('iterable://')) { returnToInbox(); //handle external link } else if (URL.slice(0, 4) === 'http') {