From 6dcb6d8b896226f63ed3c6b0fb018abf4cfa5be1 Mon Sep 17 00:00:00 2001 From: Mike Miller Date: Wed, 5 Aug 2026 16:10:38 -0400 Subject: [PATCH] feat(inbox): type trackingIds and add clickTrackingId for SDK parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not a bug fix — worth saying plainly, because it looked like one. Unlike courier-flutter, this SDK was never broken. `InboxMessage` already declared `trackingIds`, and `fromJson` already passes `parsed.trackingIds` into the constructor, so the field the native SDKs send has always reached JS. The high-level `Courier.shared.clickMessage({ messageId })` also resolves the tracking id natively, so click tracking worked regardless. What was missing was reachability. `trackingIds` was typed `{ [key: string]: any }`, so a caller using the lower-level `client.inbox.click({ messageId, trackingId })` had to guess key names off an untyped map with no autocomplete and no compiler check. And `clickTrackingId` — a convenience that exists on courier-ios, courier-android and now courier-flutter — had no equivalent here, making this the only SDK where you had to reach through the map by hand. - `InboxMessageTrackingIds` covering all seven ids the server publishes (archive, channel, click, deliver, open, read, unread), exported as a type - `trackingIds` field and constructor parameter typed to it - `clickTrackingId` getter, matching the other SDKs Reads from the root of the message, which is where both the GraphQL read and the `iwpv=v2` socket publish it. Nothing here depends on the protocol version or on a native SDK release, so this is independent of the pending pin bump. Tests: 5 new cases (all seven ids parsed, clickTrackingId present/absent/partial, and that a nested `data.trackingIds` copy — the v1/legacy shape — is not read). 130/130 passing, `tsc --noEmit` clean, eslint clean. Co-Authored-By: Claude Opus 5 (1M context) --- src/__tests__/inbox-message.test.tsx | 81 ++++++++++++++++++++++++++++ src/index.tsx | 1 + src/models/InboxMessage.tsx | 35 +++++++++++- 3 files changed, 115 insertions(+), 2 deletions(-) diff --git a/src/__tests__/inbox-message.test.tsx b/src/__tests__/inbox-message.test.tsx index 631fa52..20307a6 100644 --- a/src/__tests__/inbox-message.test.tsx +++ b/src/__tests__/inbox-message.test.tsx @@ -193,4 +193,85 @@ describe('InboxMessage', () => { expect(Modules.Shared.clickMessage).toHaveBeenCalledWith('m-5'); }); }); + + /** + * trackingIds is published at the root of the message by both the GraphQL read and + * the iwpv=v2 socket, and reaches JS through the native toJson(). These pin the + * parse path and the clickTrackingId accessor, which exists for parity with the + * ios / android / flutter SDKs. + */ + describe('trackingIds', () => { + it('parses trackingIds from the native payload', () => { + const msg = InboxMessage.fromJson( + JSON.stringify({ + messageId: 'm-6', + title: 'Welcome', + trackingIds: { + archiveTrackingId: 'archive-1', + channelTrackingId: 'channel-1', + clickTrackingId: 'click-1', + deliverTrackingId: 'deliver-1', + openTrackingId: 'open-1', + readTrackingId: 'read-1', + unreadTrackingId: 'unread-1', + }, + }) + ); + + expect(msg.trackingIds?.archiveTrackingId).toBe('archive-1'); + expect(msg.trackingIds?.channelTrackingId).toBe('channel-1'); + expect(msg.trackingIds?.clickTrackingId).toBe('click-1'); + expect(msg.trackingIds?.deliverTrackingId).toBe('deliver-1'); + expect(msg.trackingIds?.openTrackingId).toBe('open-1'); + expect(msg.trackingIds?.readTrackingId).toBe('read-1'); + expect(msg.trackingIds?.unreadTrackingId).toBe('unread-1'); + }); + + it('exposes clickTrackingId, the id client.inbox.click needs', () => { + const msg = InboxMessage.fromJson( + JSON.stringify({ + messageId: 'm-7', + trackingIds: { clickTrackingId: 'click-1' }, + }) + ); + + expect(msg.clickTrackingId).toBe('click-1'); + }); + + it('clickTrackingId is null when the message carries no tracking ids', () => { + const msg = InboxMessage.fromJson(JSON.stringify({ messageId: 'm-8' })); + + // fromJson passes undefined into a parameter defaulting to null, so the field + // lands as null rather than staying absent. + expect(msg.trackingIds).toBeNull(); + expect(msg.clickTrackingId).toBeNull(); + }); + + // Which ids get minted depends on the send, so a partial object is normal. + it('clickTrackingId is null when present but without a click id', () => { + const msg = InboxMessage.fromJson( + JSON.stringify({ + messageId: 'm-9', + trackingIds: { readTrackingId: 'read-1' }, + }) + ); + + expect(msg.clickTrackingId).toBeNull(); + expect(msg.trackingIds?.readTrackingId).toBe('read-1'); + }); + + // v2 publishes trackingIds at the root only; a nested copy is the v1/legacy shape. + it('does not read a nested data.trackingIds copy', () => { + const msg = InboxMessage.fromJson( + JSON.stringify({ + messageId: 'm-10', + data: { + trackingIds: { clickTrackingId: 'nested-should-be-ignored' }, + }, + }) + ); + + expect(msg.clickTrackingId).toBeNull(); + }); + }); }); diff --git a/src/index.tsx b/src/index.tsx index 81fc8cd..aa333b1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -48,6 +48,7 @@ export { CourierInfoViewStyle } from './models/CourierInfoViewStyle'; export { iOS_CourierCell } from './models/iOS_CourierCell'; export { iOS_CourierSheet } from './models/iOS_CourierSheet'; export { InboxMessage } from './models/InboxMessage'; +export type { InboxMessageTrackingIds } from './models/InboxMessage'; export { InboxMessageFeed } from './models/InboxMessageFeed'; export { InboxMessageEvent } from './models/InboxMessageEvent'; export { diff --git a/src/models/InboxMessage.tsx b/src/models/InboxMessage.tsx index 28c15a8..0d95cf8 100644 --- a/src/models/InboxMessage.tsx +++ b/src/models/InboxMessage.tsx @@ -1,6 +1,25 @@ import Courier from '..'; import { InboxAction } from './InboxAction'; +/** + * Tracking ids for an inbox message, as published at the root of the message by both + * the GraphQL read and the `iwpv=v2` socket. + * + * These were previously reachable only as an untyped map, so a caller using the + * lower-level `client.inbox.click({ messageId, trackingId })` had to guess key names. + * The high-level `Courier.shared.clickMessage({ messageId })` resolves the id + * natively and is unaffected. + */ +export interface InboxMessageTrackingIds { + archiveTrackingId?: string | null; + channelTrackingId?: string | null; + clickTrackingId?: string | null; + deliverTrackingId?: string | null; + openTrackingId?: string | null; + readTrackingId?: string | null; + unreadTrackingId?: string | null; +} + export class InboxMessage { readonly messageId: string; readonly title?: string | null; @@ -14,7 +33,7 @@ export class InboxMessage { readonly archived?: boolean | null; readonly subtitle?: string | null; readonly time?: string; - readonly trackingIds?: { [key: string]: any } | null; + readonly trackingIds?: InboxMessageTrackingIds | null; constructor( messageId: string, @@ -29,7 +48,7 @@ export class InboxMessage { archived: boolean | null = null, subtitle: string | null = null, time: string = '', - trackingIds: { [key: string]: any } | null = null + trackingIds: InboxMessageTrackingIds | null = null ) { this.messageId = messageId; this.title = title; @@ -58,6 +77,18 @@ export class InboxMessage { return this.archived !== null; } + /** + * Tracking id for a click on this message, for parity with the other SDKs + * (`InboxMessage.clickTrackingId` exists on ios, android and flutter). + * + * Only needed when calling the lower-level `client.inbox.click({ messageId, + * trackingId })`. The high-level {@link markAsClicked} resolves the id natively and + * does not need it. + */ + get clickTrackingId(): string | null { + return this.trackingIds?.clickTrackingId ?? null; + } + static fromJson(jsonString: string): InboxMessage { try { const parsed = JSON.parse(jsonString);