Skip to content
Merged
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: 1 addition & 1 deletion packages/browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/browser/src/core/analytics/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions packages/browser/src/core/analytics/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -88,6 +89,13 @@ export interface AnalyticsCore extends CoreAnalytics {
deregister(...plugins: string[]): Promise<Context>
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
}

/**
Expand Down
6 changes: 6 additions & 0 deletions packages/browser/src/core/buffer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends PreInitMethodName>(methodName: T) {
Expand Down
163 changes: 163 additions & 0 deletions packages/browser/src/plugins/in-app-plugin/inbox_messages.ts
Original file line number Diff line number Diff line change
@@ -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<void>

/**
* Marks this message as unopened
* @returns Promise that resolves when the message is marked as unopened
*/
markUnopened(): Promise<void>

/**
* Marks this message as deleted
* @returns Promise that resolves when the message is deleted
*/
markDeleted(): Promise<void>
}

export interface InboxAPI {
/**
* Returns the total number of messages
* @returns Promise that resolves to the total count of messages
*/
total(): Promise<number>

/**
* Returns the count of unopened messages
* @returns Promise that resolves to the count of unopened messages
*/
totalUnopened(): Promise<number>

/**
* Returns all inbox messages
* @returns Promise that resolves to an array of inbox messages
*/
messages(): Promise<InboxMessage[]>

/**
* 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<GistInboxMessage[]> {
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<InboxMessage[]> => {
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)
}
},
}
}
31 changes: 31 additions & 0 deletions packages/browser/src/plugins/in-app-plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
})
Copy link

Choose a reason for hiding this comment

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

Bug: Incorrect condition allows undefined action values through

The condition params?.action !== '' incorrectly evaluates to true when params.action is undefined or null, since undefined !== '' and null !== '' both return true. This means _handleInboxMessageAction gets called with an undefined action value. While the function happens to guard against this in its own check (action === 'opened'), the condition doesn't match the apparent intent to only proceed when action is a non-empty string.

Fix in Cursor Fix in Web


Gist.events.on('messageAction', (params: any) => {
const deliveryId: string = params?.message?.properties?.gist?.campaignId
if (settings.events) {
Expand Down Expand Up @@ -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

Expand All @@ -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}`)
}
10 changes: 5 additions & 5 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down