UNPKG

@trycourier/courier-ui-inbox

Version:

Inbox components for the Courier web UI

1,294 lines (1,291 loc) 239 kB
var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); import { Courier, InboxMessageEvent } from "@trycourier/courier-js"; import { Courier as Courier2, DEFAULT_COURIER_API_URLS, EU_COURIER_API_URLS, getCourierApiUrls, getCourierApiUrlsForRegion } from "@trycourier/courier-js"; import { registerElement, CourierBaseElement, CourierIconButton, CourierIcon, CourierIconSVGs, CourierButton, injectGlobalStyle, CourierFactoryElement, CourierInfoState, CourierColors, COURIER_DEFAULT_PRIMARY_COLOR, CourierThemeManager } from "@trycourier/courier-ui-core"; function copyMessage(message) { const copy = { ...message }; if (message.actions) { copy.actions = message.actions.map((action) => copyInboxAction(action)); } if (message.data) { copy.data = JSON.parse(JSON.stringify(message.data)); } if (message.tags) { copy.tags = [...message.tags]; } if (message.trackingIds) { copy.trackingIds = { ...message.trackingIds }; } return copy; } function copyInboxAction(action) { const copy = { ...action }; if (action.data) { copy.data = JSON.parse(JSON.stringify(action.data)); } return copy; } function copyInboxDataSet(dataSet) { if (!dataSet) { return void 0; } return { ...dataSet, messages: dataSet.messages.map((message) => copyMessage(message)) }; } function mutableInboxMessageFieldsEqual(message1, message2) { if (message1.archived !== message2.archived) { return false; } if (message1.read !== message2.read) { return false; } if (message1.opened !== message2.opened) { return false; } return true; } function getMessageTime(message) { if (!message.created) { return "Now"; } const now = /* @__PURE__ */ new Date(); const messageDate = new Date(message.created); const diffInSeconds = Math.floor((now.getTime() - messageDate.getTime()) / 1e3); if (diffInSeconds < 5) { return "Now"; } if (diffInSeconds < 60) { return `${diffInSeconds}s`; } if (diffInSeconds < 3600) { return `${Math.floor(diffInSeconds / 60)}m`; } if (diffInSeconds < 86400) { return `${Math.floor(diffInSeconds / 3600)}h`; } if (diffInSeconds < 604800) { return `${Math.floor(diffInSeconds / 86400)}d`; } if (diffInSeconds < 31536e3) { return `${Math.floor(diffInSeconds / 604800)}w`; } return `${Math.floor(diffInSeconds / 31536e3)}y`; } function escapeHtml(text) { const map = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;" }; return String(text).replace(/[&<>"']/g, (c) => map[c] ?? c); } function escapeAttr(value) { return escapeHtml(value).replace(/\n/g, " "); } function looksLikeHtml(str) { if (!str || typeof str !== "string") return false; return /<[a-z][\s\S]*>/i.test(str); } const LINK_ATTRS = ' class="courier-inbox-subtitle-link" style="cursor: pointer;"'; function linkifyPlainText(text) { if (typeof text !== "string" || !text) return ""; const combinedRegex = /\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s<>"']+)/gi; const parts = []; let lastIndex = 0; let match; combinedRegex.lastIndex = 0; while ((match = combinedRegex.exec(text)) !== null) { parts.push(escapeHtml(text.slice(lastIndex, match.index))); const mdText = match[1]; const mdUrl = match[2]; const bareUrl = match[3]; if (mdUrl !== void 0) { const safeUrl = escapeAttr(mdUrl); const safeText = escapeHtml(mdText ?? mdUrl); parts.push(`<a href="${safeUrl}" target="_blank" rel="noopener noreferrer"${LINK_ATTRS}>${safeText}</a>`); } else if (bareUrl !== void 0) { const safeUrl = escapeAttr(bareUrl); parts.push(`<a href="${safeUrl}" target="_blank" rel="noopener noreferrer"${LINK_ATTRS}>${escapeHtml(bareUrl)}</a>`); } lastIndex = match.index + match[0].length; } parts.push(escapeHtml(text.slice(lastIndex))); return parts.join(""); } function normalizePreviewHtml(html) { let out = html; out = out.replace( /href\s*=\s*["']?\[[^\]]*\]\s*\(\s*(https?:\/\/[^\s)]+)\s*\)/gi, (_, url) => `href="${url}"` ); out = out.replace(/target\s*=\s*["']?\+?blank["']?/gi, 'target="_blank"'); out = out.replace(/rel\s*=\s*["']?noopener\s+no\s*referrer["']?/gi, 'rel="noopener noreferrer"'); out = out.replace(/rel\s*=\s*["']?noopener\s*noreferrer["']?/gi, 'rel="noopener noreferrer"'); out = out.replace(/rel\s*=\s*["']?noopener["']?/gi, 'rel="noopener noreferrer"'); return out; } function sanitizeHtmlForInbox(html) { if (typeof html !== "string") return ""; if (!html.trim()) return ""; const normalized = normalizePreviewHtml(html); try { let walk = function(node) { if (node.nodeType === Node.TEXT_NODE) { return escapeHtml(node.textContent ?? ""); } if (node.nodeType !== Node.ELEMENT_NODE) return ""; const el = node; const tagName = el.tagName.toUpperCase(); if (tagName === "A") { const href = el.getAttribute("href") ?? ""; if (/^https?:\/\//i.test(href)) { const safeHref = escapeAttr(href); const inner = Array.from(el.childNodes).map(walk).join(""); return `<a href="${safeHref}" target="_blank" rel="noopener noreferrer" class="courier-inbox-subtitle-link" style="cursor: pointer;">${inner}</a>`; } } return Array.from(el.childNodes).map(walk).join(""); }; const parser = typeof DOMParser !== "undefined" ? new DOMParser() : null; if (!parser) return escapeHtml(html); const doc = parser.parseFromString(normalized, "text/html"); return Array.from(doc.body.childNodes).map(walk).join(""); } catch { return escapeHtml(normalized); } } class CourierInboxListItemMenu extends CourierBaseElement { constructor(theme) { super(); // State __publicField(this, "_theme"); __publicField(this, "_options", []); this._theme = theme; } static get id() { return "courier-inbox-list-item-menu"; } onComponentMounted() { const menu = document.createElement("ul"); menu.className = "menu"; this.appendChild(menu); } static getStyles(theme) { var _a, _b, _c; const menu = (_c = (_b = (_a = theme.inbox) == null ? void 0 : _a.list) == null ? void 0 : _b.item) == null ? void 0 : _c.menu; const transition = menu == null ? void 0 : menu.animation; const initialTransform = (transition == null ? void 0 : transition.initialTransform) ?? "translate3d(0, 0, 0)"; const visibleTransform = (transition == null ? void 0 : transition.visibleTransform) ?? "translate3d(0, 0, 0)"; return ` ${CourierInboxListItemMenu.id} { display: none; position: absolute; background: ${(menu == null ? void 0 : menu.backgroundColor) ?? "red"}; border: ${(menu == null ? void 0 : menu.border) ?? "1px solid red"}; border-radius: ${(menu == null ? void 0 : menu.borderRadius) ?? "0px"}; box-shadow: ${(menu == null ? void 0 : menu.shadow) ?? "0 2px 8px red"}; user-select: none; opacity: 0; pointer-events: none; transition: ${(transition == null ? void 0 : transition.transition) ?? "all 0.2s ease"}; overflow: hidden; transform: ${initialTransform}; will-change: transform, opacity; } ${CourierInboxListItemMenu.id}.visible { opacity: 1; pointer-events: auto; transform: ${visibleTransform}; } ${CourierInboxListItemMenu.id} ul.menu { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: row; } ${CourierInboxListItemMenu.id} li.menu-item { display: flex; align-items: center; justify-content: center; cursor: pointer; border-bottom: none; background: transparent; touch-action: none; } `; } setOptions(options) { this._options = options; this.renderMenu(); } renderMenu() { var _a, _b, _c; const menu = this.querySelector("ul.menu"); if (!menu) return; menu.innerHTML = ""; const menuTheme = (_c = (_b = (_a = this._theme.inbox) == null ? void 0 : _a.list) == null ? void 0 : _b.item) == null ? void 0 : _c.menu; const cancelEvent = (e) => { e.stopPropagation(); if (e.type === "touchstart" || e.type === "mousedown") { e.preventDefault(); } }; this._options.forEach((opt) => { var _a2, _b2, _c2; const icon = new CourierIconButton(opt.icon.svg, opt.icon.color, menuTheme == null ? void 0 : menuTheme.backgroundColor, (_a2 = menuTheme == null ? void 0 : menuTheme.item) == null ? void 0 : _a2.hoverBackgroundColor, (_b2 = menuTheme == null ? void 0 : menuTheme.item) == null ? void 0 : _b2.activeBackgroundColor, (_c2 = menuTheme == null ? void 0 : menuTheme.item) == null ? void 0 : _c2.borderRadius); const handleInteraction = (e) => { e.stopPropagation(); opt.onClick(); }; icon.addEventListener("click", handleInteraction); icon.addEventListener("touchstart", cancelEvent, { passive: false }); icon.addEventListener("touchend", handleInteraction, { passive: true }); icon.addEventListener("touchmove", (e) => { e.stopPropagation(); }, { passive: true }); icon.addEventListener("mousedown", cancelEvent); icon.addEventListener("mouseup", cancelEvent); menu.appendChild(icon); }); } show() { this.style.display = "block"; this.classList.remove("visible"); requestAnimationFrame(() => { requestAnimationFrame(() => { this.classList.add("visible"); }); }); } hide() { this.classList.remove("visible"); const handleTransitionEnd = (e) => { if (e.target !== this) return; if (!this.classList.contains("visible")) { this.style.display = "none"; this.removeEventListener("transitionend", handleTransitionEnd); } }; this.addEventListener("transitionend", handleTransitionEnd); } } registerElement(CourierInboxListItemMenu); class CourierInboxDataset { constructor(id, filter) { /** The unique ID for this dataset, provided by the consumer to later identify this set of messages. */ __publicField(this, "_id"); /** The set of messages in this dataset. */ __publicField(this, "_messages", []); /** * True if the first fetch of messages has completed successfully. * * This marker is used to distinguish if _messages can be returned when cached messages * are acceptable, since an empty array of messages could indicate they weren't * ever fetched or that they were fetched but there are currently none in the dataset. */ __publicField(this, "_firstFetchComplete", false); /** True if the fetched dataset sets hasNextPage to true. */ __publicField(this, "_hasNextPage", false); /** * The pagination cursor to pass to subsequent fetch requests * or null if this is the first request or a response has indicated * there is no next page. */ __publicField(this, "_lastPaginationCursor"); __publicField(this, "_filter"); __publicField(this, "_datastoreListeners", []); /** * The total unread count loaded before messages are fetched. * Used to show unread badge counts on tabs before the user clicks into them. * * Total unread count is maintained manually (rather than derived from _messages) because: * * 1. We load unread counts for all tabs in view before their messages are loaded. * 2. The set of loaded messages may not fully reflect the unread count for a tab. * Messages are paginated, so unread messages may be present on the server but * but not on the client. */ __publicField(this, "_totalUnreadCount", 0); this._id = id; this._filter = { tags: filter.tags ? [...filter.tags] : void 0, archived: filter.archived || false, status: filter.status }; } /** Get the current total unread count. */ get totalUnreadCount() { return this._totalUnreadCount; } /** Private setter for unread count. */ set totalUnreadCount(count) { this._totalUnreadCount = count > 0 ? count : 0; } /** * Set the unread count explicitly. * Used for batch loading unread counts for all datasets before messages are fetched. */ setUnreadCount(count) { this.totalUnreadCount = count; this._datastoreListeners.forEach((listener) => { var _a, _b, _c, _d; (_b = (_a = listener.events).onUnreadCountChange) == null ? void 0 : _b.call(_a, count, this._id); (_d = (_c = listener.events).onTotalUnreadCountChange) == null ? void 0 : _d.call(_c, CourierInboxDatastore.shared.totalUnreadCount); }); } /** * Get the filter configuration for this dataset. * Used for batch loading unread counts. */ getFilter() { return { tags: this._filter.tags, archived: this._filter.archived, status: this._filter.status }; } /** * Add a message to the dataset if it qualifies based on the dataset's filters. * * @param message the message to add * @returns true if the message was added, otherwise false */ addMessage(message, insertIndex = 0) { const messageCopy = copyMessage(message); if (this.messageQualifiesForDataset(messageCopy)) { this._messages.splice(insertIndex, 0, messageCopy); if (!messageCopy.read) { this.totalUnreadCount += 1; } this._datastoreListeners.forEach((listener) => { var _a, _b, _c, _d, _e, _f; (_b = (_a = listener.events).onMessageAdd) == null ? void 0 : _b.call(_a, messageCopy, insertIndex, this._id); (_d = (_c = listener.events).onUnreadCountChange) == null ? void 0 : _d.call(_c, this.totalUnreadCount, this._id); (_f = (_e = listener.events).onTotalUnreadCountChange) == null ? void 0 : _f.call(_e, CourierInboxDatastore.shared.totalUnreadCount); }); return true; } return false; } /** * Update the messages and unread count for the dataset based on a change in a message. * * Based on a message's change (unread -> read, archived -> unarchived, etc) this method * inserts, updates, removes, or excludes it from the dataset. Given the before/existing * and after states, it updates the unread count. * * The before state identifies messages that would qualify for the dataset * before the dataset (or a particular message in the dataset) has been loaded. * These messages may not be explicitly removed from the dataset since they aren't * yet present, but may have an effect on the unread count. * * @param beforeMessage the message before the change * @param afterMessage the message after the change * @returns true if afterMessage qualifies for the dataset and was inserted or updated, false if the message was removed */ updateWithMessageChange(beforeMessage, afterMessage) { const index = this.indexOfMessage(afterMessage); const existingMessage = this._messages[index]; const newMessage = copyMessage(afterMessage); if (existingMessage && mutableInboxMessageFieldsEqual(existingMessage, newMessage)) { return true; } if (existingMessage) { if (this.messageQualifiesForDataset(newMessage)) { const unreadChange = this.calculateUnreadChange(existingMessage, newMessage); this._messages.splice(index, 1, newMessage); this.totalUnreadCount += unreadChange; this._datastoreListeners.forEach((listener) => { var _a, _b, _c, _d, _e, _f; (_b = (_a = listener.events).onMessageUpdate) == null ? void 0 : _b.call(_a, newMessage, index, this._id); (_d = (_c = listener.events).onUnreadCountChange) == null ? void 0 : _d.call(_c, this.totalUnreadCount, this._id); (_f = (_e = listener.events).onTotalUnreadCountChange) == null ? void 0 : _f.call(_e, CourierInboxDatastore.shared.totalUnreadCount); }); return true; } this.removeMessage(existingMessage); return false; } if (this.messageQualifiesForDataset(afterMessage)) { const insertIndex = this.findInsertIndex(afterMessage); this._messages.splice(insertIndex, 0, copyMessage(afterMessage)); const beforeQualifies2 = this.messageQualifiesForDataset(beforeMessage); const unreadChange = beforeQualifies2 ? this.calculateUnreadChange(beforeMessage, afterMessage) : !afterMessage.read ? 1 : 0; this.totalUnreadCount += unreadChange; this._datastoreListeners.forEach((listener) => { var _a, _b, _c, _d, _e, _f; (_b = (_a = listener.events).onMessageAdd) == null ? void 0 : _b.call(_a, afterMessage, insertIndex, this._id); (_d = (_c = listener.events).onUnreadCountChange) == null ? void 0 : _d.call(_c, this.totalUnreadCount, this._id); (_f = (_e = listener.events).onTotalUnreadCountChange) == null ? void 0 : _f.call(_e, CourierInboxDatastore.shared.totalUnreadCount); }); return true; } const beforeQualifies = this.messageQualifiesForDataset(beforeMessage); if (beforeQualifies) { if (!beforeMessage.read) { this.totalUnreadCount -= 1; } this._datastoreListeners.forEach((listener) => { var _a, _b, _c, _d; (_b = (_a = listener.events).onUnreadCountChange) == null ? void 0 : _b.call(_a, this.totalUnreadCount, this._id); (_d = (_c = listener.events).onTotalUnreadCountChange) == null ? void 0 : _d.call(_c, CourierInboxDatastore.shared.totalUnreadCount); }); } return false; } calculateUnreadChange(beforeMessage, afterMessage) { if (beforeMessage.read && !afterMessage.read) { return 1; } if (!beforeMessage.read && afterMessage.read) { return -1; } return 0; } /** * Remove the specified message from this dataset, if it's present. * * @param message the message to remove from this dataset * @returns true if the message was removed, else false */ removeMessage(message) { const indexToRemove = this.indexOfMessage(message); if (indexToRemove > -1) { this._messages.splice(indexToRemove, 1); if (!message.read) { this.totalUnreadCount -= 1; } this._datastoreListeners.forEach((listener) => { var _a, _b, _c, _d, _e, _f; (_b = (_a = listener.events).onMessageRemove) == null ? void 0 : _b.call(_a, message, indexToRemove, this._id); (_d = (_c = listener.events).onUnreadCountChange) == null ? void 0 : _d.call(_c, this.totalUnreadCount, this._id); (_f = (_e = listener.events).onTotalUnreadCountChange) == null ? void 0 : _f.call(_e, CourierInboxDatastore.shared.totalUnreadCount); }); return true; } return false; } getMessage(messageId) { return this._messages.find((message) => message.messageId === messageId); } async loadDataset(canUseCache) { if (canUseCache && this._firstFetchComplete) { this._datastoreListeners.forEach((listener) => { var _a, _b, _c, _d, _e, _f; (_b = (_a = listener.events).onDataSetChange) == null ? void 0 : _b.call(_a, this.toInboxDataset()); (_d = (_c = listener.events).onUnreadCountChange) == null ? void 0 : _d.call(_c, this.totalUnreadCount, this._id); (_f = (_e = listener.events).onTotalUnreadCountChange) == null ? void 0 : _f.call(_e, CourierInboxDatastore.shared.totalUnreadCount); }); return; } const fetchedDataset = await this.fetchMessages(); this._messages = [...fetchedDataset.messages]; this.totalUnreadCount = fetchedDataset.unreadCount; this._hasNextPage = fetchedDataset.canPaginate; this._lastPaginationCursor = fetchedDataset.paginationCursor ?? void 0; this._firstFetchComplete = true; this._datastoreListeners.forEach((listener) => { var _a, _b, _c, _d, _e, _f; (_b = (_a = listener.events).onDataSetChange) == null ? void 0 : _b.call(_a, this.toInboxDataset()); (_d = (_c = listener.events).onUnreadCountChange) == null ? void 0 : _d.call(_c, this.totalUnreadCount, this._id); (_f = (_e = listener.events).onTotalUnreadCountChange) == null ? void 0 : _f.call(_e, CourierInboxDatastore.shared.totalUnreadCount); }); } async fetchNextPageOfMessages() { if (!this._hasNextPage) { return null; } const fetchedDataset = await this.fetchMessages(this._lastPaginationCursor); this._messages = [...this._messages, ...fetchedDataset.messages]; this._hasNextPage = fetchedDataset.canPaginate; this._lastPaginationCursor = fetchedDataset.paginationCursor ?? void 0; this._firstFetchComplete = true; this._datastoreListeners.forEach((listener) => { var _a, _b, _c, _d, _e, _f, _g, _h; (_b = (_a = listener.events).onDataSetChange) == null ? void 0 : _b.call(_a, this.toInboxDataset()); (_d = (_c = listener.events).onUnreadCountChange) == null ? void 0 : _d.call(_c, this.totalUnreadCount, this._id); (_f = (_e = listener.events).onPageAdded) == null ? void 0 : _f.call(_e, fetchedDataset); (_h = (_g = listener.events).onTotalUnreadCountChange) == null ? void 0 : _h.call(_g, CourierInboxDatastore.shared.totalUnreadCount); }); return fetchedDataset; } addDatastoreListener(listener) { this._datastoreListeners.push(listener); } removeDatastoreListener(listener) { const index = this._datastoreListeners.indexOf(listener); if (index > -1) { this._datastoreListeners.splice(index, 1); } } toInboxDataset() { return { id: this._id, messages: [...this._messages], unreadCount: this.totalUnreadCount, canPaginate: this._hasNextPage, paginationCursor: this._lastPaginationCursor ?? null }; } async fetchMessages(startCursor) { var _a, _b, _c, _d, _e, _f, _g, _h, _i; const client = Courier.shared.client; if (!(client == null ? void 0 : client.options.userId)) { throw new Error("User is not signed in"); } const response = await client.inbox.getMessages({ paginationLimit: Courier.shared.paginationLimit, startCursor, filter: this.getFilter() }); return { id: this._id, messages: [...((_b = (_a = response.data) == null ? void 0 : _a.messages) == null ? void 0 : _b.nodes) ?? []], unreadCount: ((_c = response.data) == null ? void 0 : _c.unreadCount) ?? 0, canPaginate: ((_f = (_e = (_d = response.data) == null ? void 0 : _d.messages) == null ? void 0 : _e.pageInfo) == null ? void 0 : _f.hasNextPage) ?? false, paginationCursor: ((_i = (_h = (_g = response.data) == null ? void 0 : _g.messages) == null ? void 0 : _h.pageInfo) == null ? void 0 : _i.startCursor) ?? null }; } indexOfMessage(message) { return this._messages.findIndex((m) => m.messageId === message.messageId); } /** * Find the insert index for a new message in a data set * @param newMessage - The new message to insert * @param dataSet - The data set to insert the message into * @returns The index to insert the message at */ findInsertIndex(newMessage) { const messages = this._messages; for (let i = 0; i < messages.length; i++) { const message = messages[i]; if (message.created && newMessage.created && message.created < newMessage.created) { return i; } } return messages.length; } messageQualifiesForDataset(message) { if (message.archived && !this._filter.archived || !message.archived && this._filter.archived) { return false; } if (message.read && this._filter.status === "unread" || !message.read && this._filter.status === "read") { return false; } if (this._filter.tags && !message.tags) { return false; } if (this._filter.tags && message.tags) { for (const tag of this._filter.tags) { if (message.tags.includes(tag)) { return true; } } return false; } return true; } /** * Restore this dataset from a snapshot. * * Note: _firstFetchComplete does not need to be restored * as it indicates specific lifecycle stages for the dataset. */ restoreFromSnapshot(snapshot) { this._messages = snapshot.messages.map((m) => copyMessage(m)); this.totalUnreadCount = snapshot.unreadCount; this._hasNextPage = snapshot.canPaginate; this._lastPaginationCursor = snapshot.paginationCursor ?? void 0; this._datastoreListeners.forEach((listener) => { var _a, _b, _c, _d, _e, _f; (_b = (_a = listener.events).onDataSetChange) == null ? void 0 : _b.call(_a, snapshot); (_d = (_c = listener.events).onUnreadCountChange) == null ? void 0 : _d.call(_c, this.totalUnreadCount, this._id); (_f = (_e = listener.events).onTotalUnreadCountChange) == null ? void 0 : _f.call(_e, CourierInboxDatastore.shared.totalUnreadCount); }); } } const _CourierInboxDatastore = class _CourierInboxDatastore { /** Access CourierInboxDatastore through {@link CourierInboxDatastore.shared} */ constructor() { __publicField(this, "_datasets", /* @__PURE__ */ new Map()); __publicField(this, "_listeners", []); __publicField(this, "_removeMessageEventListener"); __publicField(this, "_pendingOpenMessageIds", /* @__PURE__ */ new Set()); __publicField(this, "_openBatchTimer", null); /** * Global message store is a map of Message ID to Message for all messages * that have been loaded. * * This acts as the source of truth to apply messages mutations to a message * given its ID and propagate those mutations to individual datasets. */ __publicField(this, "_globalMessages", /* @__PURE__ */ new Map()); } /** * Instantiate the datastore with the feeds specified. * * Feeds are added to the datastore as datasets. Each feed has a respective * dataset. Existing datasets will be cleared before the feeds specified are added. * * @param feeds - The feeds with which to instantiate the datastore */ registerFeeds(feeds) { const datasets = new Map( feeds.flatMap((feed) => feed.tabs).map((tab) => [tab.datasetId, tab.filter]) ); this.createDatasetsFromFilters(datasets); } createDatasetsFromFilters(filters) { this.clearDatasets(); for (let [id, filter] of filters) { const dataset = new CourierInboxDataset(id, filter); for (let listener of this._listeners) { dataset.addDatastoreListener(listener); } this._datasets.set(id, dataset); } } /** * Add a message to the datastore. * * The message will be added to any datasets for which it qualifies. * * @param message - The message to add */ addMessage(message) { this._globalMessages.set(message.messageId, message); for (let dataset of this._datasets.values()) { dataset.addMessage(message); } } updateDatasetsWithMessageChange(beforeMessage, afterMessage) { for (let dataset of this._datasets.values()) { dataset.updateWithMessageChange(beforeMessage, afterMessage); } } /** * Listen for real-time message updates from the Courier backend. * * If an existing WebSocket connection is open, it will be re-used. If not, * a new connection will be opened. */ async listenForUpdates() { var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k; const socket = (_a = Courier.shared.client) == null ? void 0 : _a.inbox.socket; if (!socket) { (_c = (_b = Courier.shared.client) == null ? void 0 : _b.options.logger) == null ? void 0 : _c.info("CourierInbox socket not available"); return; } try { if (this._removeMessageEventListener) { this._removeMessageEventListener(); } this._removeMessageEventListener = socket.addMessageEventListener((event) => this.handleMessageEvent(event)); if (socket.isConnecting || socket.isOpen) { (_f = (_d = Courier.shared.client) == null ? void 0 : _d.options.logger) == null ? void 0 : _f.info(`Inbox socket already connecting or open for client ID: [${(_e = Courier.shared.client) == null ? void 0 : _e.options.connectionId}]`); return; } await socket.connect(); (_i = (_g = Courier.shared.client) == null ? void 0 : _g.options.logger) == null ? void 0 : _i.info(`Inbox socket connected for client ID: [${(_h = Courier.shared.client) == null ? void 0 : _h.options.connectionId}]`); } catch (error) { (_k = (_j = Courier.shared.client) == null ? void 0 : _j.options.logger) == null ? void 0 : _k.error("Failed to connect socket:", error); } } /** * Load unread counts for multiple tabs in a single GraphQL query. * This populates tab badges without loading messages. * @param tabIds - Array of tab IDs to load counts for */ async loadUnreadCountsForTabs(tabIds) { const client = Courier.shared.client; if (!client) { return; } const filtersMap = {}; for (const tabId of tabIds) { const dataset = this._datasets.get(tabId); if (dataset) { filtersMap[tabId] = dataset.getFilter(); } } if (Object.keys(filtersMap).length === 0) { return; } const counts = await client.inbox.getUnreadCounts(filtersMap); for (const [tabId, count] of Object.entries(counts)) { const dataset = this._datasets.get(tabId); if (dataset) { dataset.setUnreadCount(count); } } } /** * Add a datastore listener, whose callbacks will be called in response to various message events. * @param listener - The listener instance to add */ addDataStoreListener(listener) { this._listeners.push(listener); for (let dataset of this._datasets.values()) { dataset.addDatastoreListener(listener); } } /** * Remove a datastore listener. * @param listener - The listener instance to remove */ removeDataStoreListener(listener) { this._listeners = this._listeners.filter((l) => l !== listener); for (let dataset of this._datasets.values()) { dataset.removeDatastoreListener(listener); } } /** * Mark a message as read. * @param message - The message to mark as read */ async readMessage({ message }) { if (message.read) { return; } const beforeMessage = this._globalMessages.get(message.messageId); if (!beforeMessage) { return; } await this.executeWithRollback(async () => { var _a; const afterMessage = copyMessage(beforeMessage); afterMessage.read = _CourierInboxDatastore.getISONow(); this._globalMessages.set(message.messageId, afterMessage); this.updateDatasetsWithMessageChange(beforeMessage, afterMessage); await ((_a = Courier.shared.client) == null ? void 0 : _a.inbox.read({ messageId: message.messageId })); }); } /** * Mark a message as unread. * @param message - The message to mark as unread */ async unreadMessage({ message }) { if (!message.read) { return; } const beforeMessage = this._globalMessages.get(message.messageId); if (!beforeMessage) { return; } await this.executeWithRollback(async () => { var _a; const afterMessage = copyMessage(beforeMessage); afterMessage.read = void 0; this._globalMessages.set(message.messageId, afterMessage); this.updateDatasetsWithMessageChange(beforeMessage, afterMessage); await ((_a = Courier.shared.client) == null ? void 0 : _a.inbox.unread({ messageId: message.messageId })); }); } /** * Mark a message as opened. * * The local state is updated optimistically and the server call is * batched: multiple opens arriving within a short window * are collected and flushed as a single GraphQL request. * * @param message - The message to mark as opened */ openMessage({ message }) { if (message.opened) { return; } const beforeMessage = this._globalMessages.get(message.messageId); if (!beforeMessage || beforeMessage.opened) { return; } const afterMessage = copyMessage(beforeMessage); afterMessage.opened = _CourierInboxDatastore.getISONow(); this._globalMessages.set(message.messageId, afterMessage); this.updateDatasetsWithMessageChange(beforeMessage, afterMessage); this._pendingOpenMessageIds.add(message.messageId); this.scheduleBatchOpen(); } scheduleBatchOpen() { if (this._openBatchTimer !== null) { clearTimeout(this._openBatchTimer); } this._openBatchTimer = setTimeout(() => { this.flushBatchOpen(); }, _CourierInboxDatastore.OPEN_BATCH_DELAY_MS); } async flushBatchOpen() { var _a, _b; this._openBatchTimer = null; const messageIds = Array.from(this._pendingOpenMessageIds); this._pendingOpenMessageIds.clear(); if (messageIds.length === 0) return; const maxSize = _CourierInboxDatastore.OPEN_BATCH_MAX_SIZE; const chunks = []; for (let i = 0; i < messageIds.length; i += maxSize) { chunks.push(messageIds.slice(i, i + maxSize)); } try { await Promise.all( chunks.map((chunk) => { var _a2; return (_a2 = Courier.shared.client) == null ? void 0 : _a2.inbox.batchOpen(chunk); }) ); } catch (error) { (_b = (_a = Courier.shared.client) == null ? void 0 : _a.options.logger) == null ? void 0 : _b.error( `[${_CourierInboxDatastore.TAG}] Error batch opening messages:`, error ); this._listeners.forEach((listener) => { var _a2, _b2; (_b2 = (_a2 = listener.events).onError) == null ? void 0 : _b2.call(_a2, error); }); } } /** * Unarchive a message. * @param message - The message to unarchive */ async unarchiveMessage({ message }) { if (!message.archived) { return; } const beforeMessage = this._globalMessages.get(message.messageId); if (!beforeMessage) { return; } await this.executeWithRollback(async () => { var _a; const afterMessage = copyMessage(beforeMessage); afterMessage.archived = void 0; this._globalMessages.set(message.messageId, afterMessage); this.updateDatasetsWithMessageChange(beforeMessage, afterMessage); await ((_a = Courier.shared.client) == null ? void 0 : _a.inbox.unarchive({ messageId: message.messageId })); }); } /** * Archive a message. * @param message - The message to archive */ async archiveMessage({ message }) { if (message.archived) { return; } const beforeMessage = this._globalMessages.get(message.messageId); if (!beforeMessage) { return; } await this.executeWithRollback(async () => { var _a; const afterMessage = copyMessage(beforeMessage); afterMessage.archived = _CourierInboxDatastore.getISONow(); this._globalMessages.set(message.messageId, afterMessage); this.updateDatasetsWithMessageChange(beforeMessage, afterMessage); await ((_a = Courier.shared.client) == null ? void 0 : _a.inbox.archive({ messageId: message.messageId })); }); } /** * Track a click event for a message. * @param message - The message that was clicked */ async clickMessage({ message }) { var _a, _b, _c, _d; if ((_a = message.trackingIds) == null ? void 0 : _a.clickTrackingId) { try { await ((_b = Courier.shared.client) == null ? void 0 : _b.inbox.click({ messageId: message.messageId, trackingId: message.trackingIds.clickTrackingId })); } catch (error) { (_d = (_c = Courier.shared.client) == null ? void 0 : _c.options.logger) == null ? void 0 : _d.error(`[${_CourierInboxDatastore.TAG}] Error clicking message:`, error); this._listeners.forEach((listener) => { var _a2, _b2; (_b2 = (_a2 = listener.events).onError) == null ? void 0 : _b2.call(_a2, error); }); } } } /** * Archive all messages for the specified dataset. */ async archiveAllMessages() { await this.executeWithRollback(async () => { var _a; const archiveDate = _CourierInboxDatastore.getISONow(); for (const [messageId, beforeMessage] of this._globalMessages.entries()) { if (!beforeMessage.archived) { const afterMessage = copyMessage(beforeMessage); afterMessage.archived = archiveDate; this._globalMessages.set(messageId, afterMessage); this.updateDatasetsWithMessageChange(beforeMessage, afterMessage); } } for (const dataset of this._datasets.values()) { if (!dataset.getFilter().archived) { dataset.setUnreadCount(0); } } await ((_a = Courier.shared.client) == null ? void 0 : _a.inbox.archiveAll()); }); } /** * Mark all messages read across all datasets. */ async readAllMessages() { await this.executeWithRollback(async () => { var _a; const readDate = _CourierInboxDatastore.getISONow(); for (const [messageId, beforeMessage] of this._globalMessages.entries()) { if (!beforeMessage.read) { const afterMessage = copyMessage(beforeMessage); afterMessage.read = readDate; this._globalMessages.set(messageId, afterMessage); this.updateDatasetsWithMessageChange(beforeMessage, afterMessage); } } for (const dataset of this._datasets.values()) { dataset.setUnreadCount(0); } await ((_a = Courier.shared.client) == null ? void 0 : _a.inbox.readAll()); }); } /** * Archive all read messages for the specified dataset. */ async archiveReadMessages() { await this.executeWithRollback(async () => { var _a; const archiveDate = _CourierInboxDatastore.getISONow(); for (const [messageId, beforeMessage] of this._globalMessages.entries()) { if (beforeMessage.read && !beforeMessage.archived) { const afterMessage = copyMessage(beforeMessage); afterMessage.archived = archiveDate; this._globalMessages.set(messageId, afterMessage); this.updateDatasetsWithMessageChange(beforeMessage, afterMessage); } } await ((_a = Courier.shared.client) == null ? void 0 : _a.inbox.archiveRead()); }); } /** * Load datasets from the backend. * * Props: * - canUseCache: If true and the dataset has already been loaded once, this will return the dataset from memory. * - datasetIds: Optional: The set of dataset IDs to load. If unset, all known datasets will be loaded. * * @param props - Options to load datasets, see method documentation */ async load(props) { const client = Courier.shared.client; if (!(client == null ? void 0 : client.options.userId)) { throw new Error("[Datastore] User is not signed in"); } const canUseCache = (props == null ? void 0 : props.canUseCache) ?? true; if (props == null ? void 0 : props.datasetIds) { const datasets = props.datasetIds.flatMap((id) => { const dataset = this._datasets.get(id); return dataset ? [dataset] : []; }); return await this.loadDatasets({ canUseCache, datasets }); } return await this.loadDatasets({ canUseCache, datasets: Array.from(this._datasets.values()) }); } async loadDatasets(props) { await Promise.all(props.datasets.map(async (dataset) => { await dataset.loadDataset(props.canUseCache); this.syncDatasetMessagesToGlobalStore(dataset); })); } /** * Sync messages from a dataset to the global message store. * This is called after datasets load messages to ensure the global store has all messages. */ syncDatasetMessagesToGlobalStore(dataset) { const datasetState = dataset.toInboxDataset(); for (const message of datasetState.messages) { if (!this._globalMessages.has(message.messageId)) { this._globalMessages.set(message.messageId, message); } } } /** * Fetch the next page of messages for the specified feed or datasetId. * * feedType is deprecated and will be removed in the next major release. * Please migrate to pass the same identifier as datasetId. * While both options are present, exactly one is required. * * @param props - Options to fetch the next page of messages, see method documetation */ async fetchNextPageOfMessages(props) { var _a; const client = Courier.shared.client; if (!(client == null ? void 0 : client.options.userId)) { throw new Error("User is not signed in"); } let datasetIdToFetch; if (props.feedType && !props.datasetId) { (_a = Courier.shared.client) == null ? void 0 : _a.options.logger.warn(`[${_CourierInboxDatastore.TAG}] feedType is deprecated andwill be removed in the next major version. Please update callers to use datasetIds.`); datasetIdToFetch = props.feedType; } else if (props.datasetId) { datasetIdToFetch = props.datasetId; } else { throw new Error(`[${_CourierInboxDatastore.TAG}] Exactly one of feedType or datasetId is required to call fetchNextPageOfMessages.`); } const datasetToFetch = this._datasets.get(datasetIdToFetch); if (!datasetToFetch) { throw new Error(`[${_CourierInboxDatastore.TAG}] Attempted to fetch next page of messages for dataset ${datasetIdToFetch}, but the dataset does not exist.`); } return this.fetchNextPageForDataset({ dataset: datasetToFetch }); } /** * Get the {@link InboxDataSet} representation of the dataset ID specified. * @param datasetId - The dataset ID to get */ getDatasetById(datasetId) { var _a; return (_a = this._datasets.get(datasetId)) == null ? void 0 : _a.toInboxDataset(); } /** * Get datasets currently in the datastore. * @returns A record mapping dataset IDs to their InboxDataSet representations */ getDatasets() { const datasets = {}; for (const [id, dataset] of this._datasets.entries()) { datasets[id] = dataset.toInboxDataset(); } return datasets; } /** * Get the total unread count across all datasets. */ get totalUnreadCount() { let unreadCount = 0; for (let dataset of this._datasets.values()) { unreadCount += dataset.totalUnreadCount; } return unreadCount; } async fetchNextPageForDataset(props) { const result = await props.dataset.fetchNextPageOfMessages(); if (result) { this.syncDatasetMessagesToGlobalStore(props.dataset); } return result; } handleMessageEvent(envelope) { var _a, _b; const event = envelope.event; if (event === InboxMessageEvent.NewMessage) { const message = envelope.data; this._globalMessages.set(message.messageId, message); for (let dataset of this._datasets.values()) { dataset.addMessage(message); } return; } const isAllMessagesEvent = event === InboxMessageEvent.ArchiveAll || event === InboxMessageEvent.ArchiveRead || event === InboxMessageEvent.MarkAllRead; if (isAllMessagesEvent) { this.updateAllMessages(event); return; } const messageId = envelope.messageId; if (messageId) { this.updateMessage(messageId, event); return; } (_b = (_a = Courier.shared.client) == null ? void 0 : _a.options.logger) == null ? void 0 : _b.warn(`[${_CourierInboxDatastore.TAG}] Received unexpected event: ${event}`); } /** * Update all messages across all datasets from an InboxMessageEvent. * This only handles InboxMessageEvents that do not specify a messageId * and mutate all messages. * * Related: {@link CourierInboxDatastore.updateMessage} */ updateAllMessages(event) { const timestamp = _CourierInboxDatastore.getISONow(); for (const [messageId, beforeMessage] of this._globalMessages.entries()) { let afterMessage = null; switch (event) { case InboxMessageEvent.MarkAllRead: if (!beforeMessage.read) { afterMessage = copyMessage(beforeMessage); afterMessage.read = timestamp; } break; case InboxMessageEvent.ArchiveAll: if (!beforeMessage.archived) { afterMessage = copyMessage(beforeMessage); afterMessage.archived = timestamp; } break; case InboxMessageEvent.ArchiveRead: if (beforeMessage.read && !beforeMessage.archived) { afterMessage = copyMessage(beforeMessage); afterMessage.archived = timestamp; } break; } if (afterMessage) { this._globalMessages.set(messageId, afterMessage); this.updateDatasetsWithMessageChange(beforeMessage, afterMessage); } } if (event === InboxMessageEvent.MarkAllRead) { for (const dataset of this._datasets.values()) { dataset.setUnreadCount(0); } } else if (event === InboxMessageEvent.ArchiveAll) { for (const dataset of this._datasets.values()) { if (!dataset.getFilter().archived) { dataset.setUnreadCount(0); } } } } /** * Update a single message across all datasets from an InboxMessageEvent. * This only handles InboxMessageEvents that specify a messageId. * * Related: {@link CourierInboxDatastore.updateAllMessages} */ updateMessage(messageId, event) { const beforeMessage = this._globalMessages.get(messageId); if (!beforeMessage) { return; } let afterMessage; switch (event) { case InboxMessageEvent.Archive: afterMessage = copyMessage(beforeMessage); afterMessage.archived = _CourierInboxDatastore.getISONow(); break; case InboxMessageEvent.Opened: afterMessage = copyMessage(beforeMessage); afterMessage.opened = _CourierInboxDatastore.getISONow(); break; case InboxMessageEvent.Read: afterMessage = copyMessage(beforeMessage); afterMessage.read = _CourierInboxDatastore.getISONow(); break; case InboxMessageEvent.Unarchive: afterMessage = copyMessage(beforeMessage); afterMessage.archived = void 0; break; case InboxMessageEvent.Unread: afterMessage = copyMessage(beforeMessage); afterMessage.read = void 0; break; case InboxMessageEvent.Clicked: case InboxMessageEvent.Unopened: default: return; } this._globalMessages.set(messageId, afterMessage); this.updateDatasetsWithMessageChange(beforeMessage, afterMessage); } clearDatasets() { this._datasets.clear(); this._globalMessages.clear(); } static getISONow() { return (/* @__PURE__ */ new Date()).toISOString(); } /** * Create a snapshot of all datasets and global messages for rollback purposes. * This captures the current state of all messages and metadata. */ createDatastoreSnapshot() { const snapshots = []; for (const [id, dataset] of this._datasets.entries()) { const datasetState = dataset.toInboxDataset(); if (datasetState) { const copy = copyInboxDataSet(datasetState); if (copy) { snapshots.push({ id, dataset: copy }); } } } const globalMessagesSnapshot = /* @__PURE__ */ new Map(); for (const [messageId, message] of this._globalMessages.entries()) { globalMessagesSnapshot.set(messageId, copyMessage(message)); } return { datasets: snapshots, globalMessages: globalMessagesSnapshot }; } /** * Restore all datasets and global messages from a snapshot, reverting any mutations. * This is used for rollback when API calls or updates to downstream datasets fail. */ restoreDatastoreSnapshot(snapshot) { this._globalMessages.clear(); for (const [messageId, message] of snapshot.globalMessages.entries()) { this._globalMessages.set(messageId, copyMessage(message)); } for (const datasetSnapshot of snapshot.datasets) { const dataset = this._datasets.get(datasetSnapshot.id); if (dataset) { dataset.restoreFromSnapshot(datasetSnapshot.dataset); } } } /** * Execute an operation with automatic rollback on failure. * Snapshots all datasets before the operation and restores them if the operation throws. * * Note: Errors are caught and logged, but not re-thrown to match the behavior * for backwards compatibility with the legacy (inbox/archive) datastore implementation. * * Note: This method exists at the datastore level (rather than dataset) to handle * errors from API calls. */ async executeWithRollback(operation) { var _a, _b; const snapshot = this.createDatastoreSnapshot(); try { return await operation(); } catch (error) { (_b = (_a = Courier.shared.client) == null ? void 0 : _a.options.logger) == null ? void 0 : _b.error(`[${_CourierInboxDatastore.TAG}] Error during operation:`, error); this.restoreDatastoreSnapshot(snapshot); this._listeners.forEach((listener) => { var _a2, _b2; (_b2 = (_a2 = listener.events).onError) == null ? void 0 : _b2.call(_a2, error); }); } } /** * Get the shared instance of CourierInboxDatastore. * * CourierInboxDatastore is a singleton. Instance methods should be accessed * through this `shared` static accessor. */ static get shared() { if (!_CourierInboxDatastore.instance) { _CourierInboxDatastore.instance = new _CourierInboxDatastore(); } return _CourierInboxDatastore.instance; } }; __publicField(_CourierInboxDatastore, "TAG", "CourierInboxDatas