diff --git a/packages/browser/package.json b/packages/browser/package.json index fb26d84a7..8e0d6979f 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -52,7 +52,7 @@ "@lukeed/uuid": "^2.0.0", "@segment/analytics.js-video-plugins": "^0.2.1", "@segment/facade": "^3.4.9", - "customerio-gist-web": "3.16.10", + "customerio-gist-web": "3.17.0", "dset": "^3.1.2", "js-cookie": "3.0.1", "node-fetch": "^2.6.7", diff --git a/packages/browser/src/core/analytics/index.ts b/packages/browser/src/core/analytics/index.ts index 6727a85bf..3c7759f97 100644 --- a/packages/browser/src/core/analytics/index.ts +++ b/packages/browser/src/core/analytics/index.ts @@ -131,6 +131,7 @@ export class Analytics integrations: Integrations options: InitOptions queue: EventQueue + inbox?: (...topics: string[]) => import('../../plugins/in-app-plugin').InboxAPI constructor( settings: AnalyticsSettings, diff --git a/packages/browser/src/core/analytics/interfaces.ts b/packages/browser/src/core/analytics/interfaces.ts index 0ec274784..bfcfc0fb8 100644 --- a/packages/browser/src/core/analytics/interfaces.ts +++ b/packages/browser/src/core/analytics/interfaces.ts @@ -12,6 +12,7 @@ import type { Context } from '../context' import type { CustomerioEvent } from '../events' import type { Group, User } from '../user' import type { LegacyIntegration } from '../../plugins/ajs-destination/types' +import type { InboxAPI } from '../../plugins/in-app-plugin' import { CoreAnalytics } from '@customerio/cdp-analytics-core' // we can define a contract because: @@ -88,6 +89,13 @@ export interface AnalyticsCore extends CoreAnalytics { deregister(...plugins: string[]): Promise user(): User readonly VERSION: string + /** + * Access inbox functionality provided by the Customer.io In-App Plugin. + * Only available when the In-App Plugin is loaded. + * @param topics - Optional topics to filter inbox messages + * @returns InboxAPI interface for managing inbox messages + */ + inbox?(...topics: string[]): InboxAPI } /** diff --git a/packages/browser/src/core/buffer/index.ts b/packages/browser/src/core/buffer/index.ts index d7b42edb0..2843c2e54 100644 --- a/packages/browser/src/core/buffer/index.ts +++ b/packages/browser/src/core/buffer/index.ts @@ -241,6 +241,12 @@ export class AnalyticsBuffered register = this._createMethod('register') deregister = this._createMethod('deregister') user = this._createMethod('user') + inbox(...topics: string[]): import('../../plugins/in-app-plugin').InboxAPI { + if (this.instance?.inbox) { + return this.instance.inbox(...topics) + } + throw new Error('Customer.io In-App Plugin is not loaded yet. Ensure the plugin is initialized before calling inbox().') + } readonly VERSION = version private _createMethod(methodName: T) { diff --git a/packages/browser/src/plugins/in-app-plugin/inbox_messages.ts b/packages/browser/src/plugins/in-app-plugin/inbox_messages.ts new file mode 100644 index 000000000..41694fef1 --- /dev/null +++ b/packages/browser/src/plugins/in-app-plugin/inbox_messages.ts @@ -0,0 +1,163 @@ +export interface GistInboxMessage { + messageType: string + expiry: string + priority: number + topics?: string[] + properties: { [key: string]: any } + queueId: string + userToken: string + deliveryId: string + sentAt: string + opened: boolean +} + +export interface InboxMessage { + // Unique identifier for this messeage + readonly messageId: string + + // If the message has been marked opened + readonly opened: boolean + + // The properties payload of the message + readonly properties: { [key: string]: any } + + // When the message was sent + readonly sentAt: string + + /** + * Marks this message as opened + * @returns Promise that resolves when the message is marked as opened + */ + markOpened(): Promise + + /** + * Marks this message as unopened + * @returns Promise that resolves when the message is marked as unopened + */ + markUnopened(): Promise + + /** + * Marks this message as deleted + * @returns Promise that resolves when the message is deleted + */ + markDeleted(): Promise +} + +export interface InboxAPI { + /** + * Returns the total number of messages + * @returns Promise that resolves to the total count of messages + */ + total(): Promise + + /** + * Returns the count of unopened messages + * @returns Promise that resolves to the count of unopened messages + */ + totalUnopened(): Promise + + /** + * Returns all inbox messages + * @returns Promise that resolves to an array of inbox messages + */ + messages(): Promise + + /** + * Subscribe to inbox message updates + * @param callback - Function called with array of InboxMessage objects when the message list changes. + * @returns Unsubscribe function to remove the listener + */ + onUpdates(callback: (messages: InboxMessage[]) => void): () => void +} + +function createInboxMessage( + gist: any, + gistMessage: GistInboxMessage +): InboxMessage { + return { + sentAt: gistMessage.sentAt, + messageId: gistMessage.queueId, + opened: gistMessage?.opened === true, + properties: gistMessage.properties, + markOpened: async () => { + await gist.updateInboxMessageOpenState(gistMessage.queueId, true) + }, + markUnopened: async () => { + await gist.updateInboxMessageOpenState(gistMessage.queueId, false) + }, + markDeleted: async () => { + await gist.removeInboxMessage(gistMessage.queueId) + }, + } +} + +async function getFilteredMessages( + gist: any, + topics: string[], + messages: GistInboxMessage[] | null +): Promise { + let allMessages = messages + if (!allMessages || !Array.isArray(allMessages)) { + allMessages = (await gist.getInboxMessages()) as GistInboxMessage[] + } + + if (!allMessages || !Array.isArray(allMessages)) { + return [] + } + + if (topics.length === 0) { + return allMessages + } + + return allMessages.filter((message) => { + const messageTopics = message.topics + if (!messageTopics || messageTopics.length === 0) { + return false + } + return messageTopics.some((messageTopic) => topics.includes(messageTopic)) + }) +} + +export function createInboxAPI(gist: any, topics: string[]): InboxAPI { + return { + total: async () => { + const messages = await getFilteredMessages(gist, topics, null) + return messages.length + }, + totalUnopened: async () => { + const messages = await getFilteredMessages(gist, topics, null) + return messages.filter((message) => { + return message?.opened !== true + }).length + }, + messages: async (): Promise => { + const messages = await getFilteredMessages(gist, topics, null) + return messages.map((msg) => createInboxMessage(gist, msg)) + }, + onUpdates: (callback: (messages: InboxMessage[]) => void): (() => void) => { + const handler = async (gistMessages: GistInboxMessage[]) => { + try { + const filteredMessages = await getFilteredMessages( + gist, + topics, + gistMessages + ) + const inboxMessages = filteredMessages.map((msg) => + createInboxMessage(gist, msg) + ) + + callback(inboxMessages) + } catch (error) { + console.error('Error processing inbox updates:', error) + } + } + + gist.events.on('messageInboxUpdated', handler) + + // Return unsubscribe function + return () => { + gist.events.off('messageInboxUpdated', handler) + } + }, + } +} diff --git a/packages/browser/src/plugins/in-app-plugin/index.ts b/packages/browser/src/plugins/in-app-plugin/index.ts index 81887385f..b799d656c 100644 --- a/packages/browser/src/plugins/in-app-plugin/index.ts +++ b/packages/browser/src/plugins/in-app-plugin/index.ts @@ -11,8 +11,11 @@ import { ContentType, } from './events' import Gist from 'customerio-gist-web' +import type { InboxAPI, InboxMessage, GistInboxMessage } from './inbox_messages' +import { createInboxAPI } from './inbox_messages' export { InAppEvents } +export type { InboxAPI, InboxMessage } export type InAppPluginSettings = { siteId: string | undefined @@ -96,6 +99,12 @@ export function InAppPlugin(settings: InAppPluginSettings): Plugin { } }) + Gist.events.on('inboxMessageAction', (params: any) => { + if (params?.message && params?.action !== '') { + _handleInboxMessageAction(_analytics, params.message, params.action) + } + }) + Gist.events.on('messageAction', (params: any) => { const deliveryId: string = params?.message?.properties?.gist?.campaignId if (settings.events) { @@ -214,6 +223,14 @@ export function InAppPlugin(settings: InAppPluginSettings): Plugin { await syncUserToken(ctx) attachListeners() + ;(instance as any).inbox = (...topics: string[]): InboxAPI => { + if (!_pluginLoaded) { + throw new Error( + 'Customer.io In-App Plugin is not loaded yet. Ensure the plugin is initialized before calling inbox().' + ) + } + return createInboxAPI(Gist, topics) + } _pluginLoaded = true @@ -236,6 +253,20 @@ export function InAppPlugin(settings: InAppPluginSettings): Plugin { return customerio } +function _handleInboxMessageAction( + analyticsInstance: Analytics, + message: GistInboxMessage, + action: string +) { + const deliveryId = message?.deliveryId + if (action === 'opened' && typeof deliveryId !== 'undefined' && deliveryId !== '') { + void analyticsInstance.track(JourneysEvents.Metric, { + deliveryId: message.deliveryId, + metric: JourneysEvents.Opened, + }) + } +} + function _error(msg: string) { console.error(`[Customer.io In-App Plugin] ${msg}`) } diff --git a/yarn.lock b/yarn.lock index 7a300de2a..6b7c43921 100644 --- a/yarn.lock +++ b/yarn.lock @@ -777,7 +777,7 @@ __metadata: aws-sdk: ^2.814.0 circular-dependency-plugin: ^5.2.2 compression-webpack-plugin: ^8.0.1 - customerio-gist-web: 3.16.10 + customerio-gist-web: 3.17.0 dset: ^3.1.2 execa: ^4.1.0 flat: ^5.0.2 @@ -5497,12 +5497,12 @@ __metadata: languageName: node linkType: hard -"customerio-gist-web@npm:3.16.10": - version: 3.16.10 - resolution: "customerio-gist-web@npm:3.16.10" +"customerio-gist-web@npm:3.17.0": + version: 3.17.0 + resolution: "customerio-gist-web@npm:3.17.0" dependencies: uuid: ^8.3.2 - checksum: 7d6a3c1e4ea0c945c66ea9dee4bdc927715b539380820cc65b641660285025233d28f49789db47a2e90adda70720219bbad7e9acad29b408eaf0a502f172be56 + checksum: 981d3aaa93fe2048e70cc4c231451cea85204fb8e17adf079de8e5dd0e0e98bd3bc62ebb656bcf45111781beee6b6e3a5d807ae09f6315c2c52c5fc8dd29815c languageName: node linkType: hard