From 29c82a2ba8db7f4be3e3f709955bd173e3632fe4 Mon Sep 17 00:00:00 2001 From: Mike Miller Date: Fri, 28 Aug 2026 15:10:59 -0700 Subject: [PATCH] feat: re-land markAsClicked on InboxAction and bump to 6.0.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API landed in #65, but that PR targeted `master` — a branch ~10 commits behind `main` that the deploy workflow never publishes from. The change never reached npm, and the 5.6.18 bump it carried belonged to master's old version line. This puts the same code on `main` with a bump off 6.0.4. An action carries its own tracking id in `data.trackingId`, separate from the message's click tracking id, so a click can be attributed to the button the user actually pressed. `CourierInboxView` renders through the native SDKs, which report the click themselves, so nothing is added there — doing so would count each press twice. This is the API for apps that render their own action buttons. An action from a template that opted out of tracking arrives without an id and is a no-op. Also ships the Courier_iOS 5.8.6 podspec pin from #66, which merged after 6.0.4 was published and so has been sitting on `main` unreleased. Co-Authored-By: Claude Opus 5 --- package.json | 2 +- src/__tests__/inbox-action.test.tsx | 60 +++++++++++++++++++++++++++++ src/models/InboxAction.tsx | 32 +++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 955bc5e..9fce3aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@trycourier/courier-react-native", - "version": "6.0.4", + "version": "6.0.5", "description": "Inbox, Push Notifications, and Preferences for React Native", "main": "lib/commonjs/index", "module": "lib/module/index", diff --git a/src/__tests__/inbox-action.test.tsx b/src/__tests__/inbox-action.test.tsx index 56748d8..a4307cf 100644 --- a/src/__tests__/inbox-action.test.tsx +++ b/src/__tests__/inbox-action.test.tsx @@ -1,6 +1,24 @@ import { InboxAction } from '../models/InboxAction'; +const mockClick = jest.fn(() => Promise.resolve()); +const mockGetClient = jest.fn(() => + Promise.resolve({ inbox: { click: mockClick } }) +); + +jest.mock('../index', () => ({ + __esModule: true, + default: { + get shared() { + return { getClient: mockGetClient }; + }, + }, +})); + describe('InboxAction', () => { + beforeEach(() => { + mockClick.mockClear(); + mockGetClient.mockClear(); + }); describe('constructor', () => { it('stores all fields', () => { const action = new InboxAction('Click me', 'https://example.com', { @@ -43,4 +61,46 @@ describe('InboxAction', () => { expect(() => InboxAction.fromJson('not json')).toThrow(); }); }); + describe('trackingId', () => { + it('reads the id off data', () => { + const action = new InboxAction('View', null, { trackingId: 'trk-1' }); + expect(action.trackingId).toBe('trk-1'); + }); + + it('is undefined when the action carries no data', () => { + expect(new InboxAction().trackingId).toBeUndefined(); + }); + + it('is undefined for an empty or non-string id', () => { + expect( + new InboxAction('View', null, { trackingId: '' }).trackingId + ).toBeUndefined(); + expect( + new InboxAction('View', null, { trackingId: 7 }).trackingId + ).toBeUndefined(); + }); + }); + + describe('markAsClicked', () => { + it('reports the click against the action id', async () => { + const action = new InboxAction('View', null, { trackingId: 'trk-1' }); + await action.markAsClicked('msg-1'); + expect(mockClick).toHaveBeenCalledWith({ + messageId: 'msg-1', + trackingId: 'trk-1', + }); + }); + + it('is a no-op when the action has no tracking id', async () => { + await new InboxAction('View').markAsClicked('msg-1'); + expect(mockGetClient).not.toHaveBeenCalled(); + expect(mockClick).not.toHaveBeenCalled(); + }); + + it('does not throw when no client is signed in', async () => { + mockGetClient.mockResolvedValueOnce(null as any); + const action = new InboxAction('View', null, { trackingId: 'trk-1' }); + await expect(action.markAsClicked('msg-1')).resolves.toBeUndefined(); + }); + }); }); diff --git a/src/models/InboxAction.tsx b/src/models/InboxAction.tsx index 4c5409f..702d106 100644 --- a/src/models/InboxAction.tsx +++ b/src/models/InboxAction.tsx @@ -1,3 +1,5 @@ +import Courier from '..'; + export class InboxAction { readonly content?: string | null; readonly href?: string | null; @@ -13,6 +15,36 @@ export class InboxAction { this.data = data; } + /** + * The id Courier uses to attribute a click to this action. + * + * It travels on the action rather than on the message, so a click is recorded against the + * button the user actually pressed. A template that opts out of tracking arrives without one. + */ + get trackingId(): string | undefined { + const value = this.data?.trackingId; + return typeof value === 'string' && value.length > 0 ? value : undefined; + } + + /** + * Reports a click on this action. + * + * `CourierInboxView` already does this for you — the native inbox reports the click when an + * action is pressed. Call this yourself when you render your own action buttons. A no-op when + * the action carries no tracking id. + * @param messageId - The ID of the message this action belongs to. + */ + async markAsClicked(messageId: string): Promise { + const trackingId = this.trackingId; + + if (!trackingId) { + return; + } + + const client = await Courier.shared.getClient(); + await client?.inbox.click({ messageId, trackingId }); + } + static fromJson(jsonString: string): InboxAction { try { const parsed = JSON.parse(jsonString);