@trycourier/courier-ui-inbox
Version:
Inbox components for the Courier web UI
1,294 lines (1,291 loc) • 239 kB
JavaScript
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 = {
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'"
};
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