Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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": "customerio/gist-web#9988768ba2f07151c3c2bad22e2bd25734211430",
"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
164 changes: 164 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,164 @@
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.length === 0) {
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[]) => {
console.log('onUpdates handler called with messages:', gistMessages)
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)
}
},
}
}
30 changes: 30 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,19 @@ export function InAppPlugin(settings: InAppPluginSettings): Plugin {
return customerio
}

function _handleInboxMessageAction(
analyticsInstance: Analytics,
message: GistInboxMessage,
action: string
) {
if (action === 'opened' && message?.deliveryId !== '') {
void analyticsInstance.track(JourneysEvents.Metric, {
deliveryId: message.deliveryId,
metric: JourneysEvents.Opened,
})
}
}

function _error(msg: string) {
console.error(`[Customer.io In-App Plugin] ${msg}`)
}
8 changes: 4 additions & 4 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: "customerio/gist-web#9988768ba2f07151c3c2bad22e2bd25734211430"
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":
"customerio-gist-web@customerio/gist-web#9988768ba2f07151c3c2bad22e2bd25734211430":
version: 3.16.10
resolution: "customerio-gist-web@npm:3.16.10"
resolution: "customerio-gist-web@https://github.com/customerio/gist-web.git#commit=9988768ba2f07151c3c2bad22e2bd25734211430"
dependencies:
uuid: ^8.3.2
checksum: 7d6a3c1e4ea0c945c66ea9dee4bdc927715b539380820cc65b641660285025233d28f49789db47a2e90adda70720219bbad7e9acad29b408eaf0a502f172be56
checksum: f6028d8d7c7a0188b0e5dfd519d710be47ab8d5d4961f88561f6e1288372ea31340ea24847bb37ba180dc168be9a8b62a733dcde7c93fed3519ed97b5f242273
languageName: node
linkType: hard

Expand Down