UNPKG

@trycourier/courier-ui-inbox

Version:

Inbox components for the Courier web UI

1 lines 371 kB
{"version":3,"file":"index.mjs","sources":["../src/utils/utils.ts","../src/utils/sanitize-html.ts","../src/components/courier-inbox-list-item-menu.ts","../src/datastore/inbox-dataset.ts","../src/datastore/inbox-datastore.ts","../src/components/courier-inbox-list-item.ts","../src/components/courier-inbox-skeleton-list-item.ts","../src/components/courier-inbox-skeleton-list.ts","../src/components/courier-inbox-pagination-list-item.ts","../src/utils/extensions.ts","../src/types/inbox-defaults.ts","../src/components/courier-inbox-list.ts","../src/components/courier-unread-count-badge.ts","../src/components/courier-inbox-feed-button.ts","../src/components/courier-inbox-tabs.ts","../src/components/courier-inbox-option-menu-item.ts","../src/components/courier-inbox-option-menu.ts","../src/components/courier-inbox-header.ts","../src/datastore/datastore-listener.ts","../src/types/courier-inbox-theme.ts","../src/types/courier-inbox-theme-manager.ts","../src/components/courier-inbox.ts","../src/components/courier-inbox-menu-button.ts","../src/components/courier-inbox-popup-menu.ts","../src/datastore/datatore-events.ts","../src/index.ts"],"sourcesContent":["import { InboxMessage, InboxAction } from \"@trycourier/courier-js\";\nimport { InboxDataSet } from \"../types/inbox-data-set\";\n\n/**\n * Copy a message\n * @param message - The message to copy\n * @returns A copy of the message\n */\nexport function copyMessage(message: InboxMessage): InboxMessage {\n const copy = {\n ...message,\n };\n\n if (message.actions) {\n copy.actions = message.actions.map(action => copyInboxAction(action));\n }\n\n if (message.data) {\n copy.data = JSON.parse(JSON.stringify(message.data));\n }\n\n if (message.tags) {\n copy.tags = [...message.tags];\n }\n\n if (message.trackingIds) {\n copy.trackingIds = { ...message.trackingIds };\n }\n\n return copy;\n}\n\n/**\n * Copy an inbox action\n * @param action - The inbox action to copy\n * @returns A copy of the inbox action\n */\nexport function copyInboxAction(action: InboxAction): InboxAction {\n const copy = {\n ...action,\n };\n\n if (action.data) {\n copy.data = JSON.parse(JSON.stringify(action.data));\n }\n\n return copy;\n}\n\n/**\n * Copy an inbox data set\n * @param dataSet - The inbox data set to copy\n * @returns A copy of the inbox data set\n */\nexport function copyInboxDataSet(dataSet?: InboxDataSet): InboxDataSet | undefined {\n\n if (!dataSet) {\n return undefined;\n }\n\n return {\n ...dataSet,\n messages: dataSet.messages.map(message => copyMessage(message)),\n };\n\n}\n\n/**\n * Compare the mutable fields of two InboxMessages.\n * @param message1 - The first inbox message to compare\n * @param message2 - The second inbox message to compare\n * @returns True if the mutable fields are equal, false otherwise\n */\nexport function mutableInboxMessageFieldsEqual(message1: InboxMessage, message2: InboxMessage): boolean {\n // Compare only mutable state fields\n if (message1.archived !== message2.archived) {\n return false;\n }\n if (message1.read !== message2.read) {\n return false;\n }\n if (message1.opened !== message2.opened) {\n return false;\n }\n\n return true;\n}\n\nexport function getMessageTime(message: InboxMessage): string {\n if (!message.created) {\n return 'Now';\n }\n\n const now = new Date();\n const messageDate = new Date(message.created);\n const diffInSeconds = Math.floor((now.getTime() - messageDate.getTime()) / 1000);\n\n if (diffInSeconds < 5) {\n return 'Now';\n }\n if (diffInSeconds < 60) {\n return `${diffInSeconds}s`;\n }\n if (diffInSeconds < 3600) {\n return `${Math.floor(diffInSeconds / 60)}m`;\n }\n if (diffInSeconds < 86400) {\n return `${Math.floor(diffInSeconds / 3600)}h`;\n }\n if (diffInSeconds < 604800) {\n return `${Math.floor(diffInSeconds / 86400)}d`;\n }\n if (diffInSeconds < 31536000) {\n return `${Math.floor(diffInSeconds / 604800)}w`;\n }\n return `${Math.floor(diffInSeconds / 31536000)}y`;\n}\n","/**\n * Escapes HTML special characters for safe text content.\n */\nfunction escapeHtml(text: string): string {\n const map: Record<string, string> = {\n '&': '&amp;',\n '<': '&lt;',\n '>': '&gt;',\n '\"': '&quot;',\n \"'\": '&#039;',\n };\n return String(text).replace(/[&<>\"']/g, (c) => map[c] ?? c);\n}\n\n/**\n * Escapes a string for safe use in an HTML attribute (e.g. href).\n */\nfunction escapeAttr(value: string): string {\n return escapeHtml(value).replace(/\\n/g, ' ');\n}\n\n/**\n * Returns true if the string looks like it contains HTML (e.g. from markdown link conversion).\n */\nexport function looksLikeHtml(str: string): boolean {\n if (!str || typeof str !== 'string') return false;\n return /<[a-z][\\s\\S]*>/i.test(str);\n}\n\n/** Class and cursor for subtitle/title links; full styling from theme via list item CSS (inbox.list.item.subtitleLink). */\nconst LINK_ATTRS = ' class=\"courier-inbox-subtitle-link\" style=\"cursor: pointer;\"';\n\n/**\n * Converts plain text into HTML by making links clickable:\n * - Markdown links [link text](https://url) become <a> tags\n * - Bare http(s) URLs become <a> tags\n * Non-link text is escaped. Use with sanitizeHtmlForInbox for safe display.\n */\nexport function linkifyPlainText(text: string): string {\n if (typeof text !== 'string' || !text) return '';\n\n // Match either markdown link or bare URL (markdown first so we don't double-wrap)\n const combinedRegex = /\\[([^\\]]*)\\]\\((https?:\\/\\/[^\\s)]+)\\)|(https?:\\/\\/[^\\s<>\"']+)/gi;\n const parts: string[] = [];\n let lastIndex = 0;\n let match: RegExpExecArray | null;\n combinedRegex.lastIndex = 0;\n while ((match = combinedRegex.exec(text)) !== null) {\n parts.push(escapeHtml(text.slice(lastIndex, match.index)));\n const mdText = match[1];\n const mdUrl = match[2];\n const bareUrl = match[3];\n if (mdUrl !== undefined) {\n // Markdown link [text](url)\n const safeUrl = escapeAttr(mdUrl);\n const safeText = escapeHtml(mdText ?? mdUrl);\n parts.push(`<a href=\"${safeUrl}\" target=\"_blank\" rel=\"noopener noreferrer\"${LINK_ATTRS}>${safeText}</a>`);\n } else if (bareUrl !== undefined) {\n // Bare URL\n const safeUrl = escapeAttr(bareUrl);\n parts.push(`<a href=\"${safeUrl}\" target=\"_blank\" rel=\"noopener noreferrer\"${LINK_ATTRS}>${escapeHtml(bareUrl)}</a>`);\n }\n lastIndex = match.index + match[0].length;\n }\n parts.push(escapeHtml(text.slice(lastIndex)));\n return parts.join('');\n}\n\n/**\n * Normalizes malformed preview HTML (markdown in href, broken target/rel) before parsing.\n */\nfunction normalizePreviewHtml(html: string): string {\n let out = html;\n out = out.replace(\n /href\\s*=\\s*[\"']?\\[[^\\]]*\\]\\s*\\(\\s*(https?:\\/\\/[^\\s)]+)\\s*\\)/gi,\n (_: string, url: string) => `href=\"${url}\"`\n );\n out = out.replace(/target\\s*=\\s*[\"']?\\+?blank[\"']?/gi, 'target=\"_blank\"');\n out = out.replace(/rel\\s*=\\s*[\"']?noopener\\s+no\\s*referrer[\"']?/gi, 'rel=\"noopener noreferrer\"');\n out = out.replace(/rel\\s*=\\s*[\"']?noopener\\s*noreferrer[\"']?/gi, 'rel=\"noopener noreferrer\"');\n out = out.replace(/rel\\s*=\\s*[\"']?noopener[\"']?/gi, 'rel=\"noopener noreferrer\"');\n return out;\n}\n\n/**\n * Sanitizes HTML for safe display in the inbox. Only allows <a> tags with http(s) href.\n * Normalizes malformed preview HTML first (e.g. markdown in href, target=\"+blank\").\n * All other tags are stripped; their content is preserved as escaped text.\n */\nexport function sanitizeHtmlForInbox(html: string): string {\n if (typeof html !== 'string') return '';\n if (!html.trim()) return '';\n\n const normalized = normalizePreviewHtml(html);\n\n try {\n const parser = typeof DOMParser !== 'undefined' ? new DOMParser() : null;\n if (!parser) return escapeHtml(html);\n\n const doc = parser.parseFromString(normalized, 'text/html');\n\n function walk(node: Node): string {\n if (node.nodeType === Node.TEXT_NODE) {\n return escapeHtml(node.textContent ?? '');\n }\n if (node.nodeType !== Node.ELEMENT_NODE) return '';\n\n const el = node as Element;\n const tagName = el.tagName.toUpperCase();\n\n if (tagName === 'A') {\n const href = el.getAttribute('href') ?? '';\n if (/^https?:\\/\\//i.test(href)) {\n const safeHref = escapeAttr(href);\n const inner = Array.from(el.childNodes).map(walk).join('');\n return `<a href=\"${safeHref}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"courier-inbox-subtitle-link\" style=\"cursor: pointer;\">${inner}</a>`;\n }\n }\n\n return Array.from(el.childNodes).map(walk).join('');\n }\n\n return Array.from(doc.body.childNodes).map(walk).join('');\n } catch {\n return escapeHtml(normalized);\n }\n}\n","import { CourierBaseElement, CourierIconButton, registerElement } from '@trycourier/courier-ui-core';\nimport { CourierInboxIconTheme, CourierInboxTheme } from '../types/courier-inbox-theme';\n\nexport type CourierInboxListItemActionMenuOption = {\n id: string;\n icon: CourierInboxIconTheme;\n onClick: () => void;\n};\n\nexport class CourierInboxListItemMenu extends CourierBaseElement {\n\n static get id(): string {\n return 'courier-inbox-list-item-menu';\n }\n\n // State\n private _theme: CourierInboxTheme;\n private _options: CourierInboxListItemActionMenuOption[] = [];\n\n constructor(theme: CourierInboxTheme) {\n super();\n this._theme = theme;\n }\n\n onComponentMounted() {\n const menu = document.createElement('ul');\n menu.className = 'menu';\n this.appendChild(menu);\n }\n\n static getStyles(theme: CourierInboxTheme): string {\n\n const menu = theme.inbox?.list?.item?.menu;\n const transition = menu?.animation;\n const initialTransform = transition?.initialTransform ?? 'translate3d(0, 0, 0)';\n const visibleTransform = transition?.visibleTransform ?? 'translate3d(0, 0, 0)';\n\n return `\n ${CourierInboxListItemMenu.id} {\n display: none;\n position: absolute;\n background: ${menu?.backgroundColor ?? 'red'};\n border: ${menu?.border ?? '1px solid red'};\n border-radius: ${menu?.borderRadius ?? '0px'};\n box-shadow: ${menu?.shadow ?? '0 2px 8px red'};\n user-select: none;\n opacity: 0;\n pointer-events: none;\n transition: ${transition?.transition ?? 'all 0.2s ease'};\n overflow: hidden;\n transform: ${initialTransform};\n will-change: transform, opacity;\n }\n\n ${CourierInboxListItemMenu.id}.visible {\n opacity: 1;\n pointer-events: auto;\n transform: ${visibleTransform};\n }\n\n ${CourierInboxListItemMenu.id} ul.menu {\n list-style: none;\n margin: 0;\n padding: 0;\n display: flex;\n flex-direction: row;\n }\n\n ${CourierInboxListItemMenu.id} li.menu-item {\n display: flex;\n align-items: center;\n justify-content: center;\n cursor: pointer;\n border-bottom: none;\n background: transparent;\n touch-action: none;\n }\n `;\n }\n\n setOptions(options: CourierInboxListItemActionMenuOption[]) {\n this._options = options;\n this.renderMenu();\n }\n\n private renderMenu() {\n // Clear existing menu items\n const menu = this.querySelector('ul.menu');\n if (!menu) return;\n menu.innerHTML = '';\n const menuTheme = this._theme.inbox?.list?.item?.menu;\n\n // Prevent click events from propagating outside of this menu\n const cancelEvent = (e: Event) => {\n e.stopPropagation();\n // Only preventDefault on touchstart to prevent context menu and other default behaviors\n // touchmove doesn't need preventDefault since CSS touch-action handles scrolling\n if (e.type === 'touchstart' || e.type === 'mousedown') {\n e.preventDefault();\n }\n };\n\n // Create new menu items\n this._options.forEach((opt) => {\n const icon = new CourierIconButton(opt.icon.svg, opt.icon.color, menuTheme?.backgroundColor, menuTheme?.item?.hoverBackgroundColor, menuTheme?.item?.activeBackgroundColor, menuTheme?.item?.borderRadius);\n\n // Handle both click and touch events\n const handleInteraction = (e: Event) => {\n e.stopPropagation();\n opt.onClick();\n };\n\n // Add click handler for desktop\n icon.addEventListener('click', handleInteraction);\n\n // Add touch handlers for mobile\n // touchstart needs preventDefault for context menu prevention, so use passive: false\n icon.addEventListener('touchstart', cancelEvent, { passive: false });\n icon.addEventListener('touchend', handleInteraction, { passive: true });\n\n // touchmove can be passive since CSS touch-action: none prevents scrolling\n icon.addEventListener('touchmove', (e: Event) => {\n e.stopPropagation();\n }, { passive: true });\n\n // Prevent mouse events from interfering\n icon.addEventListener('mousedown', cancelEvent);\n icon.addEventListener('mouseup', cancelEvent);\n\n menu.appendChild(icon);\n });\n }\n\n show() {\n // Set display first\n this.style.display = 'block';\n this.classList.remove('visible');\n\n // Trigger transition on next frame\n requestAnimationFrame(() => {\n requestAnimationFrame(() => {\n this.classList.add('visible');\n });\n });\n }\n\n hide() {\n // Remove visible class to trigger transition\n this.classList.remove('visible');\n\n // Wait for transition to complete, then set display none\n const handleTransitionEnd = (e: TransitionEvent) => {\n if (e.target !== this) return;\n if (!this.classList.contains('visible')) {\n this.style.display = 'none';\n this.removeEventListener('transitionend', handleTransitionEnd);\n }\n };\n\n this.addEventListener('transitionend', handleTransitionEnd);\n }\n}\n\nregisterElement(CourierInboxListItemMenu);","import { Courier, InboxMessage } from \"@trycourier/courier-js\";\nimport { copyMessage, mutableInboxMessageFieldsEqual } from \"../utils/utils\";\nimport { CourierInboxDatasetFilter, InboxDataSet } from \"../types/inbox-data-set\";\nimport { CourierGetInboxMessagesQueryFilter } from \"@trycourier/courier-js/dist/types/inbox\";\nimport { CourierInboxDataStoreListener } from \"./datastore-listener\";\nimport { CourierInboxDatastore } from \"./inbox-datastore\";\n\nexport class CourierInboxDataset {\n /** The unique ID for this dataset, provided by the consumer to later identify this set of messages. */\n private _id: string;\n\n /** The set of messages in this dataset. */\n private _messages: InboxMessage[] = [];\n\n /**\n * True if the first fetch of messages has completed successfully.\n *\n * This marker is used to distinguish if _messages can be returned when cached messages\n * are acceptable, since an empty array of messages could indicate they weren't\n * ever fetched or that they were fetched but there are currently none in the dataset.\n */\n private _firstFetchComplete: boolean = false;\n\n /** True if the fetched dataset sets hasNextPage to true. */\n private _hasNextPage: boolean = false;\n\n /**\n * The pagination cursor to pass to subsequent fetch requests\n * or null if this is the first request or a response has indicated\n * there is no next page.\n */\n private _lastPaginationCursor?: string;\n\n private readonly _filter: CourierInboxDatasetFilter;\n private readonly _datastoreListeners: CourierInboxDataStoreListener[] = [];\n\n /**\n * The total unread count loaded before messages are fetched.\n * Used to show unread badge counts on tabs before the user clicks into them.\n *\n * Total unread count is maintained manually (rather than derived from _messages) because:\n *\n * 1. We load unread counts for all tabs in view before their messages are loaded.\n * 2. The set of loaded messages may not fully reflect the unread count for a tab.\n * Messages are paginated, so unread messages may be present on the server but\n * but not on the client.\n */\n private _totalUnreadCount: number = 0;\n\n public constructor(\n id: string,\n filter: CourierInboxDatasetFilter,\n ) {\n this._id = id;\n\n // Make a copy of the input filters so this dataset's filters are immutable.\n this._filter = {\n tags: filter.tags ? [...(filter.tags)] : undefined,\n archived: filter.archived || false,\n status: filter.status\n };\n }\n\n /** Get the current total unread count. */\n get totalUnreadCount(): number {\n return this._totalUnreadCount;\n }\n\n /** Private setter for unread count. */\n private set totalUnreadCount(count: number) {\n this._totalUnreadCount = count > 0 ? count : 0;\n }\n\n /**\n * Set the unread count explicitly.\n * Used for batch loading unread counts for all datasets before messages are fetched.\n */\n public setUnreadCount(count: number): void {\n this.totalUnreadCount = count;\n this._datastoreListeners.forEach(listener => {\n listener.events.onUnreadCountChange?.(count, this._id);\n listener.events.onTotalUnreadCountChange?.(CourierInboxDatastore.shared.totalUnreadCount);\n });\n }\n\n /**\n * Get the filter configuration for this dataset.\n * Used for batch loading unread counts.\n */\n public getFilter(): CourierGetInboxMessagesQueryFilter {\n return {\n tags: this._filter.tags,\n archived: this._filter.archived,\n status: this._filter.status,\n };\n }\n\n /**\n * Add a message to the dataset if it qualifies based on the dataset's filters.\n *\n * @param message the message to add\n * @returns true if the message was added, otherwise false\n */\n addMessage(message: InboxMessage, insertIndex: number = 0): boolean {\n const messageCopy = copyMessage(message);\n if (this.messageQualifiesForDataset(messageCopy)) {\n this._messages.splice(insertIndex, 0, messageCopy);\n\n if (!messageCopy.read) {\n this.totalUnreadCount += 1;\n }\n\n this._datastoreListeners.forEach(listener => {\n listener.events.onMessageAdd?.(messageCopy, insertIndex, this._id);\n listener.events.onUnreadCountChange?.(this.totalUnreadCount, this._id);\n listener.events.onTotalUnreadCountChange?.(CourierInboxDatastore.shared.totalUnreadCount);\n });\n\n return true;\n }\n\n return false;\n }\n\n /**\n * Update the messages and unread count for the dataset based on a change in a message.\n *\n * Based on a message's change (unread -> read, archived -> unarchived, etc) this method\n * inserts, updates, removes, or excludes it from the dataset. Given the before/existing\n * and after states, it updates the unread count.\n *\n * The before state identifies messages that would qualify for the dataset\n * before the dataset (or a particular message in the dataset) has been loaded.\n * These messages may not be explicitly removed from the dataset since they aren't\n * yet present, but may have an effect on the unread count.\n *\n * @param beforeMessage the message before the change\n * @param afterMessage the message after the change\n * @returns true if afterMessage qualifies for the dataset and was inserted or updated, false if the message was removed\n */\n updateWithMessageChange(beforeMessage: InboxMessage, afterMessage: InboxMessage): boolean {\n const index = this.indexOfMessage(afterMessage);\n const existingMessage = this._messages[index];\n const newMessage = copyMessage(afterMessage);\n\n // The message was already inserted or updated\n // Exit early to prevent double-counting changes to the unread count\n if (existingMessage && mutableInboxMessageFieldsEqual(existingMessage, newMessage)) {\n return true;\n }\n\n // Message is already in dataset but hasn't been updated yet\n if (existingMessage) {\n\n // Message still qualifies for dataset after mutation\n // Update it in place and modify unread count based on the state change\n if (this.messageQualifiesForDataset(newMessage)) {\n const unreadChange = this.calculateUnreadChange(existingMessage, newMessage);\n\n this._messages.splice(index, 1, newMessage);\n this.totalUnreadCount += unreadChange;\n\n this._datastoreListeners.forEach(listener => {\n listener.events.onMessageUpdate?.(newMessage, index, this._id);\n listener.events.onUnreadCountChange?.(this.totalUnreadCount, this._id);\n listener.events.onTotalUnreadCountChange?.(CourierInboxDatastore.shared.totalUnreadCount);\n });\n\n return true;\n }\n\n // Message no longer qualifies for dataset\n // Remove it, which may also update unread count\n this.removeMessage(existingMessage);\n return false;\n }\n\n // Message is not yet in the dataset\n // Check if the after-mutation message qualifies for this dataset\n if (this.messageQualifiesForDataset(afterMessage)) {\n\n // Add the message to the dataset\n // We re-implement the addMessage logic here since the unreadCount change logic differs\n // from the public method\n const insertIndex = this.findInsertIndex(afterMessage);\n this._messages.splice(insertIndex, 0, copyMessage(afterMessage));\n\n // Calculate unread count change based on the transition\n const beforeQualifies = this.messageQualifiesForDataset(beforeMessage);\n const unreadChange = beforeQualifies\n // If beforeMessage qualified but wasn't present, this is a state change and could either\n // increment or decrement the unread count\n ? this.calculateUnreadChange(beforeMessage, afterMessage)\n\n // If beforeMessage didn't qualify, this is a new message to this dataset\n // Update unread count based on afterMessage's read state\n : (!afterMessage.read ? 1 : 0);\n\n this.totalUnreadCount += unreadChange;\n\n this._datastoreListeners.forEach(listener => {\n listener.events.onMessageAdd?.(afterMessage, insertIndex, this._id);\n listener.events.onUnreadCountChange?.(this.totalUnreadCount, this._id);\n listener.events.onTotalUnreadCountChange?.(CourierInboxDatastore.shared.totalUnreadCount);\n });\n\n return true;\n }\n\n // At this point the message was neither updated, removed, nor added.\n // We know afterMessage does NOT qualify for this dataset (checked above).\n // We must still determine if the mutation affects the unread count for this dataset.\n //\n // Consider the scenario where the unread count for this dataset has been loaded, but its messages have not.\n // In another dataset, a message which affects the unread count here is mutated (marked read, archived, etc).\n // We should update the unread count, even though the message hasn't been loaded here yet.\n\n const beforeQualifies = this.messageQualifiesForDataset(beforeMessage);\n if (beforeQualifies) {\n\n // beforeMessage qualified for this dataset but afterMessage does not.\n // If beforeMessage was unread, it contributed to the count and should be decremented.\n if (!beforeMessage.read) {\n this.totalUnreadCount -= 1;\n }\n\n this._datastoreListeners.forEach(listener => {\n listener.events.onUnreadCountChange?.(this.totalUnreadCount, this._id);\n listener.events.onTotalUnreadCountChange?.(CourierInboxDatastore.shared.totalUnreadCount);\n });\n }\n\n return false;\n }\n\n private calculateUnreadChange(beforeMessage: InboxMessage, afterMessage: InboxMessage): number {\n // Message transitioned from read to unread\n if (beforeMessage.read && !afterMessage.read) {\n return 1;\n }\n\n // Message transitioned from unread to read\n if (!beforeMessage.read && afterMessage.read) {\n return -1;\n }\n\n // Message did not change read states\n return 0;\n }\n\n /**\n * Remove the specified message from this dataset, if it's present.\n *\n * @param message the message to remove from this dataset\n * @returns true if the message was removed, else false\n */\n removeMessage(message: InboxMessage): boolean {\n const indexToRemove = this.indexOfMessage(message);\n if (indexToRemove > -1) {\n this._messages.splice(indexToRemove, 1);\n\n if (!message.read) {\n this.totalUnreadCount -= 1;\n }\n\n this._datastoreListeners.forEach(listener => {\n listener.events.onMessageRemove?.(message, indexToRemove, this._id);\n listener.events.onUnreadCountChange?.(this.totalUnreadCount, this._id);\n listener.events.onTotalUnreadCountChange?.(CourierInboxDatastore.shared.totalUnreadCount);\n });\n\n return true;\n }\n\n return false;\n }\n\n getMessage(messageId: string): InboxMessage | undefined {\n return this._messages.find(message => message.messageId === messageId);\n }\n\n async loadDataset(canUseCache: boolean): Promise<void> {\n // Returned cached data if it's requested and available\n if (canUseCache && this._firstFetchComplete) {\n this._datastoreListeners.forEach(listener => {\n listener.events.onDataSetChange?.(this.toInboxDataset());\n listener.events.onUnreadCountChange?.(this.totalUnreadCount, this._id);\n listener.events.onTotalUnreadCountChange?.(CourierInboxDatastore.shared.totalUnreadCount);\n });\n return;\n }\n\n const fetchedDataset = await this.fetchMessages();\n\n // Unpack response and call listeners\n this._messages = [...fetchedDataset.messages];\n this.totalUnreadCount = fetchedDataset.unreadCount;\n this._hasNextPage = fetchedDataset.canPaginate;\n this._lastPaginationCursor = fetchedDataset.paginationCursor ?? undefined;\n this._firstFetchComplete = true;\n\n this._datastoreListeners.forEach(listener => {\n listener.events.onDataSetChange?.(this.toInboxDataset());\n listener.events.onUnreadCountChange?.(this.totalUnreadCount, this._id);\n listener.events.onTotalUnreadCountChange?.(CourierInboxDatastore.shared.totalUnreadCount);\n });\n }\n\n async fetchNextPageOfMessages(): Promise<InboxDataSet | null> {\n if (!this._hasNextPage) {\n return null;\n }\n\n const fetchedDataset = await this.fetchMessages(this._lastPaginationCursor);\n\n // Unpack response and call listeners\n this._messages = [...this._messages, ...fetchedDataset.messages];\n this._hasNextPage = fetchedDataset.canPaginate;\n this._lastPaginationCursor = fetchedDataset.paginationCursor ?? undefined;\n this._firstFetchComplete = true;\n\n this._datastoreListeners.forEach(listener => {\n listener.events.onDataSetChange?.(this.toInboxDataset());\n listener.events.onUnreadCountChange?.(this.totalUnreadCount, this._id);\n listener.events.onPageAdded?.(fetchedDataset);\n listener.events.onTotalUnreadCountChange?.(CourierInboxDatastore.shared.totalUnreadCount);\n });\n\n return fetchedDataset;\n }\n\n addDatastoreListener(listener: CourierInboxDataStoreListener): void {\n this._datastoreListeners.push(listener);\n }\n\n removeDatastoreListener(listener: CourierInboxDataStoreListener): void {\n const index = this._datastoreListeners.indexOf(listener);\n\n if (index > -1) {\n this._datastoreListeners.splice(index, 1);\n }\n }\n\n toInboxDataset(): InboxDataSet {\n return {\n id: this._id,\n messages: [...this._messages],\n unreadCount: this.totalUnreadCount,\n canPaginate: this._hasNextPage,\n paginationCursor: this._lastPaginationCursor ?? null\n };\n }\n\n private async fetchMessages(startCursor?: string): Promise<InboxDataSet> {\n const client = Courier.shared.client;\n\n if (!client?.options.userId) {\n throw new Error('User is not signed in');\n }\n\n const response = await client.inbox.getMessages({\n paginationLimit: Courier.shared.paginationLimit,\n startCursor,\n filter: this.getFilter(),\n });\n\n return {\n id: this._id,\n messages: [...(response.data?.messages?.nodes ?? [])],\n unreadCount: response.data?.unreadCount ?? 0,\n canPaginate: response.data?.messages?.pageInfo?.hasNextPage ?? false,\n paginationCursor: response.data?.messages?.pageInfo?.startCursor ?? null,\n }\n }\n\n private indexOfMessage(message: InboxMessage): number {\n return this._messages.findIndex(m => m.messageId === message.messageId);\n }\n\n /**\n * Find the insert index for a new message in a data set\n * @param newMessage - The new message to insert\n * @param dataSet - The data set to insert the message into\n * @returns The index to insert the message at\n */\n private findInsertIndex(newMessage: InboxMessage): number {\n const messages = this._messages;\n\n for (let i = 0; i < messages.length; i++) {\n const message = messages[i];\n if (message.created && newMessage.created && message.created < newMessage.created) {\n return i;\n }\n }\n\n return messages.length;\n }\n\n private messageQualifiesForDataset(message: InboxMessage): boolean {\n // Is the message archived state compatible with the dataset?\n if (message.archived && !this._filter.archived ||\n !message.archived && this._filter.archived) {\n return false;\n }\n\n // Is the message read state compatible with the dataset?\n if (message.read && this._filter.status === 'unread' ||\n !message.read && this._filter.status === 'read') {\n return false;\n }\n\n // At this point, the message and dataset have compatible\n // read and archived states.\n\n // If the dataset requires tags, does the message have tags?\n if (this._filter.tags && !message.tags) {\n return false;\n }\n\n // Does one of the message's tags match this dataset's tags?\n if (this._filter.tags && message.tags) {\n for (const tag of this._filter.tags) {\n if (message.tags.includes(tag)) {\n return true;\n }\n }\n return false;\n }\n\n // Either:\n // - dataset and message have no tags\n // - dataset doesn't require tags and\n // the dataset and message have compatible read and archived states\n return true;\n }\n\n /**\n * Restore this dataset from a snapshot.\n *\n * Note: _firstFetchComplete does not need to be restored\n * as it indicates specific lifecycle stages for the dataset.\n */\n public restoreFromSnapshot(snapshot: InboxDataSet): void {\n this._messages = snapshot.messages.map(m => copyMessage(m));\n\n this.totalUnreadCount = snapshot.unreadCount;\n this._hasNextPage = snapshot.canPaginate;\n this._lastPaginationCursor = snapshot.paginationCursor ?? undefined;\n\n this._datastoreListeners.forEach(listener => {\n listener.events.onDataSetChange?.(snapshot);\n listener.events.onUnreadCountChange?.(this.totalUnreadCount, this._id);\n listener.events.onTotalUnreadCountChange?.(CourierInboxDatastore.shared.totalUnreadCount);\n });\n }\n}\n","import { Courier, InboxMessage, InboxMessageEvent, InboxMessageEventEnvelope } from \"@trycourier/courier-js\";\nimport { CourierGetInboxMessagesQueryFilter } from \"@trycourier/courier-js/dist/types/inbox\";\nimport { CourierInboxDatasetFilter, CourierInboxFeed, InboxDataSet } from \"../types/inbox-data-set\";\nimport { CourierInboxDataset } from \"./inbox-dataset\";\nimport { CourierInboxDataStoreListener } from \"./datastore-listener\";\nimport { copyInboxDataSet, copyMessage } from \"../utils/utils\";\n\n/**\n * Snapshot of a single dataset's state for rollback purposes\n */\ninterface DatasetSnapshot {\n id: string;\n dataset: InboxDataSet;\n}\n\n/**\n * Snapshot of the entire datastore state for rollback purposes\n */\ninterface DatastoreSnapshot {\n datasets: DatasetSnapshot[];\n globalMessages: Map<string, InboxMessage>;\n}\n\n/**\n * Shared datastore for Inbox components.\n *\n * CourierInboxDatastore is a singleton. Use `CourierInboxDatastore.shared`\n * to access the shared instance.\n *\n * @public\n */\nexport class CourierInboxDatastore {\n private static readonly TAG = \"CourierInboxDatastore\";\n private static readonly OPEN_BATCH_DELAY_MS = 100;\n private static readonly OPEN_BATCH_MAX_SIZE = 50;\n\n private static instance: CourierInboxDatastore;\n\n private _datasets: Map<string, CourierInboxDataset> = new Map();\n private _listeners: CourierInboxDataStoreListener[] = [];\n private _removeMessageEventListener?: () => void;\n private _pendingOpenMessageIds = new Set<string>();\n private _openBatchTimer: ReturnType<typeof setTimeout> | null = null;\n\n /**\n * Global message store is a map of Message ID to Message for all messages\n * that have been loaded.\n *\n * This acts as the source of truth to apply messages mutations to a message\n * given its ID and propagate those mutations to individual datasets.\n */\n private _globalMessages = new Map<string, InboxMessage>();\n\n /** Access CourierInboxDatastore through {@link CourierInboxDatastore.shared} */\n private constructor() { }\n\n /**\n * Instantiate the datastore with the feeds specified.\n *\n * Feeds are added to the datastore as datasets. Each feed has a respective\n * dataset. Existing datasets will be cleared before the feeds specified are added.\n *\n * @param feeds - The feeds with which to instantiate the datastore\n */\n public registerFeeds(feeds: CourierInboxFeed[]): void {\n const datasets = new Map<string, CourierInboxDatasetFilter>(\n feeds.flatMap(feed => feed.tabs).map(tab => [tab.datasetId, tab.filter])\n );\n\n this.createDatasetsFromFilters(datasets);\n }\n\n private createDatasetsFromFilters(filters: Map<string, CourierInboxDatasetFilter>): void {\n this.clearDatasets();\n\n for (let [id, filter] of filters) {\n const dataset = new CourierInboxDataset(id, filter);\n\n // Re-attach all existing listeners to the new dataset\n for (let listener of this._listeners) {\n dataset.addDatastoreListener(listener);\n }\n\n this._datasets.set(id, dataset);\n }\n }\n\n /**\n * Add a message to the datastore.\n *\n * The message will be added to any datasets for which it qualifies.\n *\n * @param message - The message to add\n */\n public addMessage(message: InboxMessage) {\n // Add to global store\n this._globalMessages.set(message.messageId, message);\n\n // Add to all qualifying datasets\n for (let dataset of this._datasets.values()) {\n dataset.addMessage(message);\n }\n }\n\n private updateDatasetsWithMessageChange(beforeMessage: InboxMessage, afterMessage: InboxMessage) {\n for (let dataset of this._datasets.values()) {\n dataset.updateWithMessageChange(beforeMessage, afterMessage);\n }\n }\n\n /**\n * Listen for real-time message updates from the Courier backend.\n *\n * If an existing WebSocket connection is open, it will be re-used. If not,\n * a new connection will be opened.\n */\n public async listenForUpdates() {\n const socket = Courier.shared.client?.inbox.socket;\n\n if (!socket) {\n Courier.shared.client?.options.logger?.info('CourierInbox socket not available');\n return;\n }\n\n try {\n // Remove any existing listener before adding a new one.\n // This both prevents multiple listeners from being added to the same WebSocket client\n // and makes sure the listener is on the current WebSocket client (rather than maintaining\n // one from a stale client).\n if (this._removeMessageEventListener) {\n this._removeMessageEventListener();\n }\n\n this._removeMessageEventListener = socket.addMessageEventListener(event => this.handleMessageEvent(event));\n\n // If the socket is already connecting or open, return early\n if (socket.isConnecting || socket.isOpen) {\n Courier.shared.client?.options.logger?.info(`Inbox socket already connecting or open for client ID: [${Courier.shared.client?.options.connectionId}]`);\n return;\n }\n\n // Connect to the socket. By default, the socket will subscribe to all events for the user after opening.\n await socket.connect();\n Courier.shared.client?.options.logger?.info(`Inbox socket connected for client ID: [${Courier.shared.client?.options.connectionId}]`);\n } catch (error) {\n Courier.shared.client?.options.logger?.error('Failed to connect socket:', error);\n }\n }\n\n /**\n * Load unread counts for multiple tabs in a single GraphQL query.\n * This populates tab badges without loading messages.\n * @param tabIds - Array of tab IDs to load counts for\n */\n public async loadUnreadCountsForTabs(tabIds: string[]): Promise<void> {\n const client = Courier.shared.client;\n if (!client) {\n return;\n }\n\n // Build filters map for the specified tabs\n const filtersMap: Record<string, CourierGetInboxMessagesQueryFilter> = {};\n for (const tabId of tabIds) {\n const dataset = this._datasets.get(tabId);\n if (dataset) {\n filtersMap[tabId] = dataset.getFilter();\n }\n }\n\n if (Object.keys(filtersMap).length === 0) {\n return;\n }\n\n const counts = await client.inbox.getUnreadCounts(filtersMap);\n\n // Update datasets with the fetched counts\n for (const [tabId, count] of Object.entries(counts)) {\n const dataset = this._datasets.get(tabId);\n\n // If datasets changed out while the request was in progress,\n // we'll update a dataset with the same ID, but otherwise pass through\n if (dataset) {\n dataset.setUnreadCount(count);\n }\n }\n }\n\n /**\n * Add a datastore listener, whose callbacks will be called in response to various message events.\n * @param listener - The listener instance to add\n */\n public addDataStoreListener(listener: CourierInboxDataStoreListener): void {\n this._listeners.push(listener);\n\n for (let dataset of this._datasets.values()) {\n dataset.addDatastoreListener(listener);\n }\n }\n\n /**\n * Remove a datastore listener.\n * @param listener - The listener instance to remove\n */\n public removeDataStoreListener(listener: CourierInboxDataStoreListener): void {\n this._listeners = this._listeners.filter(l => l !== listener);\n\n for (let dataset of this._datasets.values()) {\n dataset.removeDatastoreListener(listener);\n }\n }\n\n /**\n * Mark a message as read.\n * @param message - The message to mark as read\n */\n public async readMessage({ message }: { message: InboxMessage }): Promise<void> {\n // Don't mark as read if already read\n if (message.read) {\n return;\n }\n\n const beforeMessage = this._globalMessages.get(message.messageId);\n if (!beforeMessage) {\n return;\n }\n\n await this.executeWithRollback(async () => {\n // Mutate in global store\n const afterMessage = copyMessage(beforeMessage);\n afterMessage.read = CourierInboxDatastore.getISONow();\n this._globalMessages.set(message.messageId, afterMessage);\n\n // Update all datasets\n this.updateDatasetsWithMessageChange(beforeMessage, afterMessage);\n\n // Apply the read to the server\n await Courier.shared.client?.inbox.read({ messageId: message.messageId });\n });\n }\n\n /**\n * Mark a message as unread.\n * @param message - The message to mark as unread\n */\n public async unreadMessage({ message }: { message: InboxMessage }): Promise<void> {\n // Don't mark as unread if already unread\n if (!message.read) {\n return;\n }\n\n const beforeMessage = this._globalMessages.get(message.messageId);\n if (!beforeMessage) {\n return;\n }\n\n await this.executeWithRollback(async () => {\n // Mutate in global store\n const afterMessage = copyMessage(beforeMessage);\n afterMessage.read = undefined;\n this._globalMessages.set(message.messageId, afterMessage);\n\n // Update all datasets\n this.updateDatasetsWithMessageChange(beforeMessage, afterMessage);\n\n // Apply the unread to the server\n await Courier.shared.client?.inbox.unread({ messageId: message.messageId });\n });\n }\n\n /**\n * Mark a message as opened.\n *\n * The local state is updated optimistically and the server call is\n * batched: multiple opens arriving within a short window\n * are collected and flushed as a single GraphQL request.\n *\n * @param message - The message to mark as opened\n */\n public openMessage({ message }: { message: InboxMessage }): void {\n if (message.opened) {\n return;\n }\n\n const beforeMessage = this._globalMessages.get(message.messageId);\n if (!beforeMessage || beforeMessage.opened) {\n return;\n }\n\n // Optimistic update\n const afterMessage = copyMessage(beforeMessage);\n afterMessage.opened = CourierInboxDatastore.getISONow();\n this._globalMessages.set(message.messageId, afterMessage);\n this.updateDatasetsWithMessageChange(beforeMessage, afterMessage);\n\n // Queue for batched server call\n this._pendingOpenMessageIds.add(message.messageId);\n this.scheduleBatchOpen();\n }\n\n private scheduleBatchOpen(): void {\n if (this._openBatchTimer !== null) {\n clearTimeout(this._openBatchTimer);\n }\n\n this._openBatchTimer = setTimeout(() => {\n this.flushBatchOpen();\n }, CourierInboxDatastore.OPEN_BATCH_DELAY_MS);\n }\n\n private async flushBatchOpen(): Promise<void> {\n this._openBatchTimer = null;\n\n const messageIds = Array.from(this._pendingOpenMessageIds);\n this._pendingOpenMessageIds.clear();\n\n if (messageIds.length === 0) return;\n\n const maxSize = CourierInboxDatastore.OPEN_BATCH_MAX_SIZE;\n const chunks: string[][] = [];\n for (let i = 0; i < messageIds.length; i += maxSize) {\n chunks.push(messageIds.slice(i, i + maxSize));\n }\n\n try {\n await Promise.all(\n chunks.map(chunk => Courier.shared.client?.inbox.batchOpen(chunk))\n );\n } catch (error) {\n Courier.shared.client?.options.logger?.error(\n `[${CourierInboxDatastore.TAG}] Error batch opening messages:`, error\n );\n\n this._listeners.forEach(listener => {\n listener.events.onError?.(error as Error);\n });\n }\n }\n\n /**\n * Unarchive a message.\n * @param message - The message to unarchive\n */\n public async unarchiveMessage({ message }: { message: InboxMessage }): Promise<void> {\n // Don't unarchive if already unarchived\n if (!message.archived) {\n return;\n }\n\n const beforeMessage = this._globalMessages.get(message.messageId);\n if (!beforeMessage) {\n return;\n }\n\n await this.executeWithRollback(async () => {\n // Mutate in global store\n const afterMessage = copyMessage(beforeMessage);\n afterMessage.archived = undefined;\n this._globalMessages.set(message.messageId, afterMessage);\n\n // Update all datasets\n this.updateDatasetsWithMessageChange(beforeMessage, afterMessage);\n\n // Apply the unarchive to the server\n await Courier.shared.client?.inbox.unarchive({ messageId: message.messageId });\n });\n }\n\n /**\n * Archive a message.\n * @param message - The message to archive\n */\n public async archiveMessage({ message }: { message: InboxMessage }): Promise<void> {\n // Don't archive if already archived\n if (message.archived) {\n return;\n }\n\n const beforeMessage = this._globalMessages.get(message.messageId);\n if (!beforeMessage) {\n return;\n }\n\n await this.executeWithRollback(async () => {\n // Mutate in global store\n const afterMessage = copyMessage(beforeMessage);\n afterMessage.archived = CourierInboxDatastore.getISONow();\n this._globalMessages.set(message.messageId, afterMessage);\n\n // Update all datasets\n this.updateDatasetsWithMessageChange(beforeMessage, afterMessage);\n\n // Apply the archive to the server\n await Courier.shared.client?.inbox.archive({ messageId: message.messageId });\n });\n }\n\n /**\n * Track a click event for a message.\n * @param message - The message that was clicked\n */\n public async clickMessage({ message }: { message: InboxMessage }): Promise<void> {\n // Clicking a message does not mutate it locally, but we still want error handling\n if (message.trackingIds?.clickTrackingId) {\n try {\n await Courier.shared.client?.inbox.click({\n messageId: message.messageId,\n trackingId: message.trackingIds.clickTrackingId\n });\n } catch (error) {\n // Log error\n Courier.shared.client?.options.logger?.error(`[${CourierInboxDatastore.TAG}] Error clicking message:`, error);\n\n // Notify listeners of error\n this._listeners.forEach(listener => {\n listener.events.onError?.(error as Error);\n });\n\n // Do NOT re-throw - swallow the error\n }\n }\n }\n\n /**\n * Archive all messages for the specified dataset.\n */\n public async archiveAllMessages(): Promise<void> {\n await this.executeWithRollback(async () => {\n const archiveDate = CourierInboxDatastore.getISONow();\n\n // Mutate all messages in global store that aren't already archived\n for (const [messageId, beforeMessage] of this._globalMessages.entries()) {\n if (!beforeMessage.archived) {\n const afterMessage = copyMessage(beforeMessage);\n afterMessage.archived = archiveDate;\n this._globalMessages.set(messageId, afterMessage);\n this.updateDatasetsWithMessageChange(beforeMessage, afterMessage);\n }\n }\n\n // Force non-archived dataset unread counts to 0.\n // The loop above only decrements for messages in _globalMessages, but\n // _totalUnreadCount may include server-reported counts for unloaded pages.\n for (const dataset of this._datasets.values()) {\n if (!dataset.getFilter().archived) {\n dataset.setUnreadCount(0);\n }\n }\n\n // Apply the archive to the server\n await Courier.shared.client?.inbox.archiveAll();\n });\n }\n\n /**\n * Mark all messages read across all datasets.\n */\n public async readAllMessages(): Promise<void> {\n await this.executeWithRollback(async () => {\n const readDate = CourierInboxDatastore.getISONow();\n\n // Mutate all messages in global store that aren't already read\n for (const [messageId, beforeMessage] of this._globalMessages.entries()) {\n if (!beforeMessage.read) {\n const afterMessage = copyMessage(beforeMessage);\n afterMessage.read = readDate;\n this._globalMessages.set(messageId, afterMessage);\n this.updateDatasetsWithMessageChange(beforeMessage, afterMessage);\n }\n }\n\n // Force all dataset unread counts to 0.\n // The loop above only decrements for messages in _globalMessages, but\n // _totalUnreadCount may include server-reported counts for unloaded pages.\n for (const dataset of this._datasets.values()) {\n dataset.setUnreadCount(0);\n }\n\n // Apply the read to the server\n await Courier.shared.client?.inbox.readAll();\n });\n }\n\n /**\n * Archive all read messages for the specified dataset.\n */\n public async archiveReadMessages(): Promise<void> {\n await this.executeWithRollback(async () => {\n const archiveDate = CourierInboxDatastore.getISONow();\n\n // Mutate all read messages in global store that aren't already archived\n for (const [messageId, beforeMessage] of this._globalMessages.entries()) {\n if (beforeMessage.read && !beforeMessage.archived) {\n const afterMessage = copyMessage(beforeMessage);\n afterMessage.archived = archiveDate;\n this._globalMessages.set(messageId, afterMessage);\n this.updateDatasetsWithMessageChange(beforeMessage, afterMessage);\n }\n }\n\n // Apply the archive to the server\n await Courier.shared.client?.inbox.archiveRead();\n });\n }\n\n /**\n * Load datasets from the backend.\n *\n * Props:\n * - canUseCache: If true and the dataset has already been loaded once, this will return the dataset from memory.\n * - datasetIds: Optional: The set of dataset IDs to load. If unset, all known datasets will be loaded.\n *\n * @param props - Options to load datasets, see method documentation\n */\n public async load(props?: { canUseCache: boolean, datasetIds?: string[] }): Promise<void> {\n const client = Courier.shared.client;\n\n if (!client?.options.userId) {\n throw new Error('[Datastore] User is not signed in');\n }\n\n const canUseCache = props?.canUseCache ?? true;\n\n if (props?.datasetIds) {\n // flatMap asserts all members are defined\n const datasets: CourierInboxDataset[] = props.datasetIds.flatMap(id => {\n const dataset = this._datasets.get(id);\n return dataset ? [dataset] : [];\n });\n\n return await this.loadDatasets({ canUseCache, datasets });\n }\n\n return await this.loadDatasets({\n canUseCache,\n datasets: Array.from(this._datasets.values()),\n });\n }\n\n private async loadDatasets(props: { canUseCache: boolean, datasets: CourierInboxDataset[] }): Promise<void> {\n await Promise.all(props.datasets.map(async (dataset) => {\n await dataset.loadDataset(pr