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);