Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

### Fixes

- Inbox message HTML links: unknown `iterable://` URLs no longer fall through to `urlHandler`; only `iterable://delete` and `iterable://dismiss` are handled as SDK actions (SDK-615, SDK-633).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes behaviour for anyone who used iterable://… links as deep links in inbox HTML. The bullet should tell them what to use instead. action://name is already documented on customActionHandler in IterableConfig.ts.

Suggested change
- Inbox message HTML links: unknown `iterable://` URLs no longer fall through to `urlHandler`; only `iterable://delete` and `iterable://dismiss` are handled as SDK actions (SDK-615, SDK-633).
- 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 `https` or your app's own scheme for deep links (`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.

### Updates

- Added unit tests for inbox message link handling in `IterableInboxMessageDisplay` (SDK-615).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment as in #937

Suggested change
- Added unit tests for inbox message link handling in `IterableInboxMessageDisplay` (SDK-615).

- Added optional placement IDs to `Iterable.embeddedManager.syncMessages()` so apps can sync embedded messages for specific placements only (SDK-751). When omitted, null, or empty, behavior matches the previous sync-all call.
- **Android:** Scoped sync (`syncMessages([id, …])`) matches the pinned iterableapi behavior and may remove cached messages for placements not included in the sync response (SDK-798). iOS preserves unrequested placements. Prefer `syncMessages()` without placement IDs on Android until the native fix ships and the Android SDK pin is bumped.
- Added `IterableInAppMessage.jsonOnly` so `inAppHandler` and in-app message fetches can distinguish JSON-only messages from HTML in-apps (SDK-750).
Expand Down
246 changes: 246 additions & 0 deletions src/inbox/components/IterableInboxMessageDisplay.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
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),
'<html><body></body></html>'
);

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<RenderResult> {
const utils = render(
<IterableInboxMessageDisplay
rowViewModel={rowViewModel}
inAppContentPromise={Promise.resolve(htmlContent)}
returnToInbox={returnToInbox}
deleteRow={deleteRow}
contentWidth={320}
isPortrait
/>
);
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(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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest adding expect(customActionHandler).not.toHaveBeenCalled(); here, and in the iterable://delete test (after line 151). As it is, a branch that forwarded reserved URLs to customActionHandler would pass all three reserved-URL tests.

});

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 swallow unknown iterable:// URLs without calling urlHandler or returning to inbox', async () => {
const utils = await renderWithContent();

postWebViewMessage(utils, 'iterable://foo');

expect(returnToInbox).not.toHaveBeenCalled();
expect(urlHandler).not.toHaveBeenCalled();
expect(MockLinking.openURL).not.toHaveBeenCalled();
expect(deleteRow).not.toHaveBeenCalled();
});
Comment on lines +184 to +193

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test locks in the open-message behaviour, and it never checks customActionHandler (SDK-633: "Do not forward unknown iterable:// to customActionHandler"). With the fix proposed in IterableInboxMessageDisplay.tsx's comments:

Suggested change
it('should swallow unknown iterable:// URLs without calling urlHandler or returning to inbox', async () => {
const utils = await renderWithContent();
postWebViewMessage(utils, 'iterable://foo');
expect(returnToInbox).not.toHaveBeenCalled();
expect(urlHandler).not.toHaveBeenCalled();
expect(MockLinking.openURL).not.toHaveBeenCalled();
expect(deleteRow).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
);
});
});
6 changes: 6 additions & 0 deletions src/inbox/components/IterableInboxMessageDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ export const IterableInboxMessageDisplay = ({
const source = IterableActionSource.inApp;
const context = new IterableActionContext(action, source);

// Track before routing: aligns with native HTML in-app (click/close first,
// then iterable:// handling). Unknown iterable:// paths still get these
// events while skipping urlHandler / returnToInbox below.
Comment on lines +166 to +168

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment says unknown paths skip returnToInbox. Native closes the message (see the comment on line 189).

Suggested change
// Track before routing: aligns with native HTML in-app (click/close first,
// then iterable:// handling). Unknown iterable:// paths still get these
// events while skipping urlHandler / returnToInbox below.
// Track before routing, like native HTML in-apps: every link click sends
// click and close(link), then the message closes. Unknown iterable://
// paths close without calling urlHandler or customActionHandler.

Iterable.trackInAppClick(
rowViewModel.inAppMessage,
IterableInAppLocation.inbox,
Expand All @@ -181,6 +184,9 @@ export const IterableInboxMessageDisplay = ({
//handle dismiss action
} else if (URL === 'iterable://dismiss') {
returnToInbox();
// iterable:// is reserved β€” only delete/dismiss are SDK actions (SDK-633)
} else if (URL.startsWith('iterable://')) {
return;
Comment on lines +187 to +189

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch tracks a close but leaves the message open. Lines 169–179 already sent trackInAppClick and trackInAppClose(link), and return then keeps the message on screen. When the user taps Inbox, line 219 sends a second close (back) for the same view.

Native in-apps close the message after every link click, including an unknown iterable://. Only the action is skipped:

  • Android 3.10.1 IterableInAppFragmentHTMLNotification.onUrlClicked: click, close(LINK), then hideWebView().
  • iOS 6.7.5 IterableHtmlMessageViewController decidePolicyFor: trackClickOnDismiss, then animateWhileLeaving. close(link) is sent on disappear.

So "swallow, same as native" in SDK-633 means: close the message and call no handler.

Suggested change
// iterable:// is reserved β€” only delete/dismiss are SDK actions (SDK-633)
} else if (URL.startsWith('iterable://')) {
return;
// 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();

SDK-615 AC4 ("no navigation") should then read "no urlHandler or customActionHandler call".

//handle external link
} else if (URL.slice(0, 4) === 'http') {
returnToInbox(() => Linking.openURL(URL));
Expand Down
Loading