UNPKG

@liveblocks/emails

Version:

A set of functions and utilities to make sending emails based on Liveblocks notification events easy. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.

1,489 lines (1,473 loc) 44.7 kB
// src/index.ts import { detectDupes } from "@liveblocks/core"; // src/version.ts var PKG_NAME = "@liveblocks/emails"; var PKG_VERSION = "3.19.2"; var PKG_FORMAT = "esm"; // src/text-mention-notification.tsx import { html, htmlSafe, MENTION_CHARACTER } from "@liveblocks/core"; // src/lexical-editor.ts import { assertNever } from "@liveblocks/core"; import * as Y from "yjs"; // src/lib/utils.ts var isString = (value) => { return typeof value === "string"; }; var isMentionNodeAttributeId = (value) => { return isString(value) && value.startsWith("in_"); }; var exists = (input) => { return input !== null && input !== void 0; }; // src/lexical-editor.ts function createSerializedLexicalMapNode(item) { const type = item.get("__type"); if (typeof type !== "string") { throw new Error( `Expected ${item.constructor.name} to include type attribute` ); } const attributes = Object.fromEntries(item.entries()); if (type === "linebreak") { return { type, attributes, group: "linebreak" }; } return { type, attributes, text: "", group: "text" }; } function createSerializedLexicalDecoratorNode(item) { const type = item.getAttribute("__type"); if (typeof type !== "string") { throw new Error( `Expected ${item.constructor.name} to include type attribute` ); } const attributes = item.getAttributes(); return { type, attributes, group: "decorator" }; } function createSerializedLexicalElementNode(item) { const type = item.getAttribute("__type"); if (typeof type !== "string") { throw new Error( `Expected ${item.constructor.name} to include type attribute` ); } const attributes = item.getAttributes(); let start = item._start; const children = []; while (start !== null) { if (start.deleted) { start = start.right; continue; } if (start.content instanceof Y.ContentType) { const content = start.content.type; if (content instanceof Y.XmlText) { children.push(createSerializedLexicalElementNode(content)); } else if (content instanceof Y.Map) { children.push(createSerializedLexicalMapNode(content)); } else if (content instanceof Y.XmlElement) { children.push( createSerializedLexicalDecoratorNode(content) ); } } else if (start.content instanceof Y.ContentString) { if (children.length > 0) { const last = children[children.length - 1]; if (last && last.group === "text") { last.text += start.content.str; } } } start = start.right; } return { type, attributes, children, group: "element" }; } function createSerializedLexicalRootNode(root) { try { const children = []; let start = root._start; while (start !== null && start !== void 0) { if (start.deleted) { start = start.right; continue; } if (start.content instanceof Y.ContentType) { const content = start.content.type; if (content instanceof Y.XmlText) { children.push(createSerializedLexicalElementNode(content)); } else if (content instanceof Y.XmlElement) { children.push( createSerializedLexicalDecoratorNode(content) ); } } start = start.right; } return { children, type: "root", attributes: root.getAttributes() }; } catch (err) { console.error(err); return { children: [], type: "root", attributes: root.getAttributes() }; } } function getSerializedLexicalState({ buffer, key }) { const update = new Uint8Array(buffer); const document = new Y.Doc(); Y.applyUpdate(document, update); const root = document.get(key, Y.XmlText); const state = createSerializedLexicalRootNode(root); document.destroy(); return state; } var isSerializedLineBreakNode = (node) => { return node.group === "linebreak"; }; var isSerializedElementNode = (node) => { return node.group === "element" && node.children !== void 0; }; var isEmptySerializedElementNode = (node) => { return isSerializedElementNode(node) && node.children.length === 0; }; var isMentionNodeType = (type) => { return type === "lb-mention"; }; var isMentionNodeAttributeType = (type) => { return isString(type) && type === "lb-mention"; }; var isSerializedMentionNode = (node) => { const attributes = node.attributes; return isMentionNodeType(node.type) && isMentionNodeAttributeType(attributes.__type) && isMentionNodeAttributeId(attributes.__id) && isString(attributes.__userId); }; var isGroupMentionNodeType = (type) => { return type === "lb-group-mention"; }; var isGroupMentionNodeAttributeType = (type) => { return isString(type) && type === "lb-group-mention"; }; var isSerializedGroupMentionNode = (node) => { const attributes = node.attributes; return isGroupMentionNodeType(node.type) && isGroupMentionNodeAttributeType(attributes.__type) && isMentionNodeAttributeId(attributes.__id) && isString(attributes.__groupId) && (attributes.__userIds === void 0 || Array.isArray(attributes.__userIds)); }; var isFlattenedLexicalElementNodeMarker = (node) => { return node.group === "element-marker"; }; var flattenLexicalTree = (nodes) => { let flattenNodes = []; for (const node of nodes) { if (["text", "linebreak", "decorator"].includes(node.group) || isEmptySerializedElementNode(node)) { flattenNodes = [...flattenNodes, node]; } else if (node.group === "element") { flattenNodes = [ ...flattenNodes, { group: "element-marker", marker: "start" }, ...flattenLexicalTree(node.children), { group: "element-marker", marker: "end" } ]; } } return flattenNodes; }; function findLexicalMentionNodeWithContext({ root, textMentionId }) { const nodes = flattenLexicalTree(root.children); let mentionNodeIndex = -1; for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; if (node.group === "decorator" && (isSerializedMentionNode(node) || isSerializedGroupMentionNode(node)) && node.attributes.__id === textMentionId) { mentionNodeIndex = i; break; } } if (mentionNodeIndex === -1) { return null; } const mentionNode = nodes[mentionNodeIndex]; const beforeNodes = []; const afterNodes = []; for (let i = mentionNodeIndex - 1; i >= 0; i--) { const node = nodes[i]; if (isFlattenedLexicalElementNodeMarker(node) || isSerializedLineBreakNode(node)) { break; } if (node.group === "decorator" && !isMentionNodeType(node.type)) { break; } beforeNodes.unshift(node); } for (let i = mentionNodeIndex + 1; i < nodes.length; i++) { const node = nodes[i]; if (isFlattenedLexicalElementNodeMarker(node) || isSerializedLineBreakNode(node)) { break; } if (node.group === "decorator" && !isMentionNodeType(node.type)) { break; } afterNodes.push(node); } return { before: beforeNodes, after: afterNodes, mention: mentionNode }; } function getMentionDataFromLexicalNode(node) { if (isSerializedMentionNode(node)) { return { kind: "user", id: node.attributes.__userId }; } else if (isSerializedGroupMentionNode(node)) { return { kind: "group", id: node.attributes.__groupId, userIds: node.attributes.__userIds }; } assertNever(node, "Unknown mention kind"); } // src/lib/batch-resolvers.ts import { Promise_withResolvers, warnOnce } from "@liveblocks/core"; function getResolvedForId(id, ids, results) { const index = ids.indexOf(id); return results?.[index]; } var BatchResolver = class { ids = /* @__PURE__ */ new Set(); results = /* @__PURE__ */ new Map(); isResolved = false; promise; resolvePromise; missingCallbackWarning; callback; constructor(callback, missingCallbackWarning) { this.callback = callback; const { promise, resolve } = Promise_withResolvers(); this.promise = promise; this.resolvePromise = resolve; this.missingCallbackWarning = missingCallbackWarning; } /** * Add IDs to the batch and return a promise that resolves when the entire batch is resolved. * It can't be called after the batch is resolved. */ async get(ids) { if (this.isResolved) { throw new Error("Batch has already been resolved."); } ids.forEach((id) => this.ids.add(id)); await this.promise; return ids.map((id) => this.results.get(id)); } #resolveBatch() { this.isResolved = true; this.resolvePromise(); } /** * Resolve all the IDs in the batch. * It can only be called once. */ async resolve() { if (this.isResolved) { throw new Error("Batch has already been resolved."); } if (!this.callback) { warnOnce(this.missingCallbackWarning); this.#resolveBatch(); return; } const ids = Array.from(this.ids); try { const results = this.callback ? await this.callback(ids) : void 0; if (results !== void 0) { if (!Array.isArray(results)) { throw new Error("Callback must return an array."); } else if (ids.length !== results.length) { throw new Error( `Callback must return an array of the same length as the number of provided items. Expected ${ids.length}, but got ${results.length}.` ); } } ids.forEach((id, index) => { this.results.set(id, results?.[index]); }); } catch (error) { this.#resolveBatch(); throw error; } this.#resolveBatch(); } }; function createBatchUsersResolver({ resolveUsers, callerName }) { return new BatchResolver( resolveUsers ? (userIds) => resolveUsers({ userIds }) : void 0, `Set "resolveUsers" in "${callerName}" to specify users info` ); } function createBatchGroupsInfoResolver({ resolveGroupsInfo, callerName }) { return new BatchResolver( resolveGroupsInfo ? (groupIds) => resolveGroupsInfo({ groupIds }) : void 0, `Set "resolveGroupsInfo" in "${callerName}" to specify groups info` ); } // src/lib/css-properties.ts var VENDORS_PREFIXES = new RegExp(/^(webkit|moz|ms|o)-/); var UNITLESS_PROPERTIES = [ "animationIterationCount", "aspectRatio", "borderImageOutset", "borderImageSlice", "borderImageWidth", "boxFlex", "boxFlexGroup", "boxOrdinalGroup", "columnCount", "columns", "flex", "flexGrow", "flexPositive", "flexShrink", "flexNegative", "flexOrder", "gridArea", "gridRow", "gridRowEnd", "gridRowSpan", "gridRowStart", "gridColumn", "gridColumnEnd", "gridColumnSpan", "gridColumnStart", "fontWeight", "lineClamp", "lineHeight", "opacity", "order", "orphans", "scale", "tabSize", "widows", "zIndex", "zoom", "fillOpacity", "floodOpacity", "stopOpacity", "strokeDasharray", "strokeDashoffset", "strokeMiterlimit", "strokeOpacity", "strokeWidth", "MozAnimationIterationCount", "MozBoxFlex", "MozBoxFlexGroup", "MozLineClamp", "msAnimationIterationCount", "msFlex", "msZoom", "msFlexPositive", "msGridColumns", "msGridRows", "WebkitAnimationIterationCount", "WebkitBoxFlex", "WebKitBoxFlexGroup", "WebkitBoxOrdinalGroup", "WebkitColumnCount", "WebkitColumns", "WebkitFlex", "WebkitFlexGrow", "WebkitFlexPositive", "WebkitFlexShrink", "WebkitLineClamp" ]; function toInlineCSSString(styles) { const entries = Object.entries(styles); const inline = entries.map(([key, value]) => { if (value === null || typeof value === "boolean" || value === "" || typeof value === "undefined") { return ""; } let property = key.replace(/([A-Z])/g, "-$1").toLowerCase(); if (VENDORS_PREFIXES.test(property)) { property = `-${property}`; } if (typeof value === "number" && !UNITLESS_PROPERTIES.includes(key)) { return `${property}:${value}px;`; } return `${property}:${String(value).trim()};`; }).filter(Boolean).join(""); return inline; } // src/tiptap-editor.ts import { assertNever as assertNever2 } from "@liveblocks/core"; import { yXmlFragmentToProsemirrorJSON } from "y-prosemirror"; import * as Y2 from "yjs"; function getSerializedTiptapState({ buffer, key }) { const update = new Uint8Array(buffer); const document = new Y2.Doc(); Y2.applyUpdate(document, update); const fragment = document.getXmlFragment(key); const state = yXmlFragmentToProsemirrorJSON(fragment); document.destroy(); return state; } var isSerializedEmptyParagraphNode = (node) => { return node.type === "paragraph" && typeof node.content === "undefined"; }; var isSerializedHardBreakNode = (node) => { return node.type === "hardBreak" && typeof node.content === "undefined"; }; var isSerializedTextNode = (node) => { return node.type === "text"; }; var isSerializedMentionNode2 = (node) => { return node.type === "liveblocksMention" && isMentionNodeAttributeId(node.attrs.notificationId); }; var isSerializedGroupMentionNode2 = (node) => { return node.type === "liveblocksGroupMention" && isMentionNodeAttributeId(node.attrs.notificationId); }; var isSerializedParagraphNode = (node) => { return node.type === "paragraph" && typeof node.content !== "undefined"; }; var isFlattenedTiptapParagraphNodeMarker = (node) => { return node.type === "paragraph-marker"; }; var flattenTiptapTree = (nodes) => { let flattenNodes = []; for (const node of nodes) { if (isSerializedEmptyParagraphNode(node) || isSerializedHardBreakNode(node) || isSerializedTextNode(node) || isSerializedMentionNode2(node) || isSerializedGroupMentionNode2(node)) { flattenNodes = [...flattenNodes, node]; } else if (isSerializedParagraphNode(node)) { flattenNodes = [ ...flattenNodes, { type: "paragraph-marker", marker: "start" }, ...flattenTiptapTree(node.content), { type: "paragraph-marker", marker: "end" } ]; } } return flattenNodes; }; function findTiptapMentionNodeWithContext({ root, textMentionId }) { const nodes = flattenTiptapTree(root.content); let mentionNodeIndex = -1; for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; if (!isFlattenedTiptapParagraphNodeMarker(node) && (isSerializedMentionNode2(node) || isSerializedGroupMentionNode2(node)) && node.attrs.notificationId === textMentionId) { mentionNodeIndex = i; break; } } if (mentionNodeIndex === -1) { return null; } const mentionNode = nodes[mentionNodeIndex]; const beforeNodes = []; const afterNodes = []; for (let i = mentionNodeIndex - 1; i >= 0; i--) { const node = nodes[i]; if (isFlattenedTiptapParagraphNodeMarker(node) || isSerializedEmptyParagraphNode(node) || isSerializedHardBreakNode(node)) { break; } beforeNodes.unshift(node); } for (let i = mentionNodeIndex + 1; i < nodes.length; i++) { const node = nodes[i]; if (isFlattenedTiptapParagraphNodeMarker(node) || isSerializedEmptyParagraphNode(node) || isSerializedHardBreakNode(node)) { break; } afterNodes.push(node); } return { before: beforeNodes, after: afterNodes, mention: mentionNode }; } function deserializeGroupUserIds(userIds) { if (typeof userIds !== "string") { return void 0; } try { const parsedUserIds = JSON.parse(userIds); if (Array.isArray(parsedUserIds)) { return parsedUserIds; } return void 0; } catch { return void 0; } } function getMentionDataFromTiptapNode(node) { if (isSerializedMentionNode2(node)) { return { kind: "user", id: node.attrs.id }; } else if (isSerializedGroupMentionNode2(node)) { return { kind: "group", id: node.attrs.id, userIds: deserializeGroupUserIds(node.attrs.userIds) }; } assertNever2(node, "Unknown mention kind"); } // src/liveblocks-text-editor.ts var baseLiveblocksTextEditorTextFormat = { bold: false, italic: false, strikethrough: false, code: false }; var IS_LEXICAL_BOLD = 1; var IS_LEXICAL_ITALIC = 1 << 1; var IS_LEXICAL_STRIKETHROUGH = 1 << 2; var IS_LEXICAL_CODE = 1 << 4; var transformLexicalTextNodeFormatBitwiseInteger = (node) => { const attributes = node.attributes; if ("__format" in attributes && typeof attributes.__format === "number") { const format = attributes.__format; return { bold: (format & IS_LEXICAL_BOLD) !== 0, italic: (format & IS_LEXICAL_ITALIC) !== 0, strikethrough: (format & IS_LEXICAL_STRIKETHROUGH) !== 0, code: (format & IS_LEXICAL_CODE) !== 0 }; } return baseLiveblocksTextEditorTextFormat; }; var transformLexicalMentionNodeWithContext = (mentionNodeWithContext) => { const textEditorNodes = []; const { before, after, mention } = mentionNodeWithContext; const transform = (nodes) => { for (const node of nodes) { if (node.group === "text") { const format = transformLexicalTextNodeFormatBitwiseInteger(node); textEditorNodes.push({ type: "text", text: node.text, ...format }); } else if (node.group === "decorator" && isSerializedMentionNode(node)) { textEditorNodes.push({ type: "mention", kind: "user", id: node.attributes.__userId }); } else if (node.group === "decorator" && isSerializedGroupMentionNode(node)) { textEditorNodes.push({ type: "mention", kind: "group", id: node.attributes.__groupId }); } } }; transform(before); textEditorNodes.push({ type: "mention", kind: mention.type === "lb-group-mention" ? "group" : "user", id: mention.type === "lb-group-mention" ? mention.attributes.__groupId : mention.attributes.__userId }); transform(after); return textEditorNodes; }; var hasTiptapSerializedTextNodeMark = (marks, type) => marks.findIndex((mark) => mark.type === type) !== -1; var transformTiptapTextNodeFormatMarks = (node) => { if (!node.marks) { return baseLiveblocksTextEditorTextFormat; } const marks = node.marks; return { bold: hasTiptapSerializedTextNodeMark(marks, "bold"), italic: hasTiptapSerializedTextNodeMark(marks, "italic"), strikethrough: hasTiptapSerializedTextNodeMark(marks, "strike"), code: hasTiptapSerializedTextNodeMark(marks, "code") }; }; var transformTiptapMentionNodeWithContext = (mentionNodeWithContext) => { const textEditorNodes = []; const { before, after, mention } = mentionNodeWithContext; const transform = (nodes) => { for (const node of nodes) { if (node.type === "text") { const format = transformTiptapTextNodeFormatMarks(node); textEditorNodes.push({ type: "text", text: node.text, ...format }); } else if (isSerializedMentionNode2(node)) { textEditorNodes.push({ type: "mention", kind: "user", id: node.attrs.id }); } else if (isSerializedGroupMentionNode2(node)) { textEditorNodes.push({ type: "mention", kind: "group", id: node.attrs.id }); } } }; transform(before); textEditorNodes.push({ type: "mention", kind: mention.type === "liveblocksGroupMention" ? "group" : "user", id: mention.attrs.id }); transform(after); return textEditorNodes; }; function transformAsLiveblocksTextEditorNodes(transformableMention) { switch (transformableMention.editor) { case "lexical": { return transformLexicalMentionNodeWithContext( transformableMention.mention ); } case "tiptap": { return transformTiptapMentionNodeWithContext( transformableMention.mention ); } } } var resolveMentionsInLiveblocksTextEditorNodes = async (nodes, resolveUsers, resolveGroupsInfo) => { const resolvedUsers = /* @__PURE__ */ new Map(); const resolvedGroupsInfo = /* @__PURE__ */ new Map(); if (!resolveUsers && !resolveGroupsInfo) { return { users: resolvedUsers, groups: resolvedGroupsInfo }; } const mentionedUserIds = /* @__PURE__ */ new Set(); const mentionedGroupIds = /* @__PURE__ */ new Set(); for (const node of nodes) { if (node.type === "mention") { if (node.kind === "user") { mentionedUserIds.add(node.id); } else if (node.kind === "group") { mentionedGroupIds.add(node.id); } } } const userIds = Array.from(mentionedUserIds); const groupIds = Array.from(mentionedGroupIds); const [users, groups] = await Promise.all([ resolveUsers && userIds.length > 0 ? resolveUsers({ userIds }) : void 0, resolveGroupsInfo && groupIds.length > 0 ? resolveGroupsInfo({ groupIds }) : void 0 ]); if (users) { for (const [index, userId] of userIds.entries()) { const user = users[index]; if (user) { resolvedUsers.set(userId, user); } } } if (groups) { for (const [index, groupId] of groupIds.entries()) { const group = groups[index]; if (group) { resolvedGroupsInfo.set(groupId, group); } } } return { users: resolvedUsers, groups: resolvedGroupsInfo }; }; // src/text-mention-content.tsx async function convertTextMentionContent(nodes, options) { const { users: resolvedUsers, groups: resolvedGroupsInfo } = await resolveMentionsInLiveblocksTextEditorNodes( nodes, options?.resolveUsers, options?.resolveGroupsInfo ); const blocks = nodes.map((node, index) => { switch (node.type) { case "mention": { return options.elements.mention( { node, user: node.kind === "user" ? resolvedUsers.get(node.id) : void 0, group: node.kind === "group" ? resolvedGroupsInfo.get(node.id) : void 0 }, index ); } case "text": { return options.elements.text({ node }, index); } } }); return options.elements.container({ children: blocks }); } // src/text-mention-notification.tsx import { jsx, jsxs } from "react/jsx-runtime"; var extractTextMentionNotificationData = async ({ client, event }) => { const { roomId, userId, inboxNotificationId } = event.data; const [room, inboxNotification] = await Promise.all([ client.getRoom(roomId), client.getInboxNotification({ inboxNotificationId, userId }) ]); if (inboxNotification.kind !== "textMention") { console.warn('Inbox notification is not of kind "textMention"'); return null; } const isUnread = inboxNotification.readAt === null || inboxNotification.notifiedAt > inboxNotification.readAt; if (!isUnread) { return null; } const textEditor = room.experimental_textEditor; if (!textEditor) { console.warn(`Room "${room.id}" does not a text editor associated with it`); return null; } const mentionCreatedAt = inboxNotification.notifiedAt; const mentionAuthorUserId = inboxNotification.createdBy; const buffer = await client.getYjsDocumentAsBinaryUpdate(roomId); const editorKey = textEditor.rootKey; const key = Array.isArray(editorKey) ? editorKey[0] : editorKey; switch (textEditor.type) { case "lexical": { const state = getSerializedLexicalState({ buffer, key }); const mentionNodeWithContext = findLexicalMentionNodeWithContext({ root: state, textMentionId: inboxNotification.mentionId }); if (mentionNodeWithContext === null) { return null; } const mentionData = getMentionDataFromLexicalNode( mentionNodeWithContext.mention ); return { editor: "lexical", mentionNodeWithContext, mentionData, createdAt: mentionCreatedAt, createdBy: mentionAuthorUserId }; } case "tiptap": { const state = getSerializedTiptapState({ buffer, key }); const mentionNodeWithContext = findTiptapMentionNodeWithContext({ root: state, textMentionId: inboxNotification.mentionId }); if (mentionNodeWithContext === null) { return null; } const mentionData = getMentionDataFromTiptapNode( mentionNodeWithContext.mention ); return { editor: "tiptap", mentionNodeWithContext, mentionData, createdAt: mentionCreatedAt, createdBy: mentionAuthorUserId }; } } }; async function prepareTextMentionNotificationEmail(client, event, options, elements, callerName) { const { roomId, mentionId } = event.data; const data = await extractTextMentionNotificationData({ client, event }); if (data === null) { return null; } const roomInfo = options.resolveRoomInfo ? await options.resolveRoomInfo({ roomId: event.data.roomId }) : void 0; const resolvedRoomInfo = { ...roomInfo, name: roomInfo?.name ?? event.data.roomId }; const batchUsersResolver = createBatchUsersResolver({ resolveUsers: options.resolveUsers, callerName }); const batchGroupsInfoResolver = createBatchGroupsInfoResolver({ resolveGroupsInfo: options.resolveGroupsInfo, callerName }); const authorsIds = [data.createdBy]; const authorsInfoPromise = batchUsersResolver.get(authorsIds); let textEditorNodes = []; switch (data.editor) { case "lexical": { textEditorNodes = transformAsLiveblocksTextEditorNodes({ editor: "lexical", mention: data.mentionNodeWithContext }); break; } case "tiptap": { textEditorNodes = transformAsLiveblocksTextEditorNodes({ editor: "tiptap", mention: data.mentionNodeWithContext }); break; } } const contentPromise = convertTextMentionContent( textEditorNodes, { resolveUsers: ({ userIds }) => batchUsersResolver.get(userIds), resolveGroupsInfo: ({ groupIds }) => batchGroupsInfoResolver.get(groupIds), elements } ); await batchUsersResolver.resolve(); await batchGroupsInfoResolver.resolve(); const [authorsInfo, content] = await Promise.all([ authorsInfoPromise, contentPromise ]); const authorInfo = getResolvedForId(data.createdBy, authorsIds, authorsInfo); return { mention: { ...data.mentionData, textMentionId: mentionId, roomId, author: { id: data.createdBy, info: authorInfo ?? { name: data.createdBy } }, content, createdAt: data.createdAt }, roomInfo: resolvedRoomInfo }; } var baseComponents = { Container: ({ children }) => /* @__PURE__ */ jsx("div", { children }), Mention: ({ element, user, group }) => /* @__PURE__ */ jsxs("span", { "data-mention": true, children: [ MENTION_CHARACTER, user?.name ?? group?.name ?? element.id ] }), Text: ({ element }) => { let children = element.text; if (element.bold) { children = /* @__PURE__ */ jsx("strong", { children }); } if (element.italic) { children = /* @__PURE__ */ jsx("em", { children }); } if (element.strikethrough) { children = /* @__PURE__ */ jsx("s", { children }); } if (element.code) { children = /* @__PURE__ */ jsx("code", { children }); } return /* @__PURE__ */ jsx("span", { children }); } }; async function prepareTextMentionNotificationEmailAsReact(client, event, options = {}) { const Components = { ...baseComponents, ...options.components }; const data = await prepareTextMentionNotificationEmail( client, event, { resolveRoomInfo: options.resolveRoomInfo, resolveUsers: options.resolveUsers }, { container: ({ children }) => /* @__PURE__ */ jsx(Components.Container, { children }, "lb-text-editor-container"), mention: ({ node, user }, index) => /* @__PURE__ */ jsx( Components.Mention, { element: node, user }, `lb-text-editor-mention-${index}` ), text: ({ node }, index) => /* @__PURE__ */ jsx(Components.Text, { element: node }, `lb-text-editor-text-${index}`) }, "prepareTextMentionNotificationEmailAsReact" ); if (data === null) { return null; } return data; } var baseStyles = { container: { fontSize: "14px" }, strong: { fontWeight: 500 }, code: { fontFamily: 'ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Mono", "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Consolas", "Courier New", monospace', backgroundColor: "rgba(0,0,0,0.05)", border: "solid 1px rgba(0,0,0,0.1)", borderRadius: "4px" }, mention: { color: "blue" } }; async function prepareTextMentionNotificationEmailAsHtml(client, event, options = {}) { const styles = { ...baseStyles, ...options.styles }; const data = await prepareTextMentionNotificationEmail( client, event, { resolveRoomInfo: options.resolveRoomInfo, resolveUsers: options.resolveUsers }, { container: ({ children }) => { const content = [ // prettier-ignore html`<div style="${toInlineCSSString(styles.container)}">${htmlSafe(children.join(""))}</div>` ]; return content.join("\n"); }, mention: ({ node, user, group }) => { return html`<span data-mention style="${toInlineCSSString(styles.mention)}">${MENTION_CHARACTER}${user?.name ? html`${user?.name}` : group?.name ? html`${group?.name}` : node.id}</span>`; }, text: ({ node }) => { let children = node.text; if (!children) { return html`${children}`; } if (node.bold) { children = html`<strong style="${toInlineCSSString(styles.strong)}">${children}</strong>`; } if (node.italic) { children = html`<em>${children}</em>`; } if (node.strikethrough) { children = html`<s>${children}</s>`; } if (node.code) { children = html`<code style="${toInlineCSSString(styles.code)}">${children}</code>`; } return html`${children}`; } }, "prepareTextMentionNotificationEmailAsHtml" ); if (data === null) { return null; } return data; } // src/thread-notification.tsx import { generateUrl, getMentionsFromCommentBody, html as html2, htmlSafe as htmlSafe2, MENTION_CHARACTER as MENTION_CHARACTER2 } from "@liveblocks/core"; // src/comment-body.tsx import { isCommentBodyLink, isCommentBodyMention, isCommentBodyText, resolveMentionsInCommentBody, sanitizeUrl } from "@liveblocks/core"; async function convertCommentBody(body, options) { const { users: resolvedUsers, groups: resolvedGroupsInfo } = await resolveMentionsInCommentBody( body, options?.resolveUsers, options?.resolveGroupsInfo ); const blocks = body.content.map((block, index) => { switch (block.type) { case "paragraph": { const children = block.children.map((inline, inlineIndex) => { if (isCommentBodyMention(inline)) { return options.elements.mention( { element: inline, user: inline.kind === "user" ? resolvedUsers.get(inline.id) : void 0, group: inline.kind === "group" ? resolvedGroupsInfo.get(inline.id) : void 0 }, inlineIndex ); } if (isCommentBodyLink(inline)) { const href = sanitizeUrl(inline.url); if (href === null) { return options.elements.text( { element: { text: inline.text ?? inline.url } }, inlineIndex ); } return options.elements.link( { element: inline, href }, inlineIndex ); } if (isCommentBodyText(inline)) { return options.elements.text({ element: inline }, inlineIndex); } return null; }).filter(exists); return options.elements.paragraph( { element: block, children }, index ); } default: console.warn( `Unsupported comment body block type: "${JSON.stringify(block.type)}"` ); return null; } }).filter(exists); return options.elements.container({ children: blocks }); } // src/comment-with-body.ts var isCommentDataWithBody = (comment) => { return comment.body !== void 0 && comment.deletedAt === void 0; }; function filterCommentsWithBody(comments) { const commentsWithBody = []; for (const comment of comments) { if (isCommentDataWithBody(comment)) { commentsWithBody.push(comment); } } return commentsWithBody; } // src/thread-notification.tsx import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime"; var getUnreadComments = ({ comments, inboxNotification, notificationTriggerAt, userId }) => { const commentsWithBody = filterCommentsWithBody(comments); const notAuthoredComments = commentsWithBody.filter( (c) => c.userId !== userId ); const readAt = inboxNotification.readAt; return notAuthoredComments.filter((c) => { if (readAt !== null) { return c.createdAt > readAt && c.createdAt >= notificationTriggerAt && c.createdAt <= inboxNotification.notifiedAt; } return c.createdAt >= notificationTriggerAt && c.createdAt <= inboxNotification.notifiedAt; }); }; async function getAllUserGroups(client, userId) { const groups = /* @__PURE__ */ new Map(); let cursor = void 0; while (true) { const { nextCursor, data } = await client.getUserGroups({ userId, startingAfter: cursor }); for (const group of data) { groups.set(group.id, group); } if (!nextCursor) { break; } cursor = nextCursor; } return groups; } var getLastUnreadCommentWithMention = ({ comments, groups, mentionedUserId }) => { if (!comments.length) { return null; } for (let i = comments.length - 1; i >= 0; i--) { const comment = comments[i]; if (comment.userId === mentionedUserId) { continue; } const mentions = getMentionsFromCommentBody(comment.body); for (const mention of mentions) { if (mention.kind === "user" && mention.id === mentionedUserId) { return comment; } if (mention.kind === "group" && mention.userIds?.includes(mentionedUserId)) { return comment; } if (mention.kind === "group" && mention.userIds === void 0) { const group = groups.get(mention.id); if (group?.members.some((member) => member.id === mentionedUserId)) { return comment; } } } } return null; }; var extractThreadNotificationData = async ({ client, event }) => { const { threadId, roomId, userId, inboxNotificationId } = event.data; const [thread, inboxNotification] = await Promise.all([ client.getThread({ roomId, threadId }), client.getInboxNotification({ inboxNotificationId, userId }) ]); const notificationTriggerAt = new Date(event.data.triggeredAt); const unreadComments = getUnreadComments({ comments: thread.comments, inboxNotification, userId, notificationTriggerAt }); if (unreadComments.length <= 0) { return null; } const userGroups = await getAllUserGroups(client, userId); const lastUnreadCommentWithMention = getLastUnreadCommentWithMention({ comments: unreadComments, groups: userGroups, mentionedUserId: userId }); if (lastUnreadCommentWithMention !== null) { return { type: "unreadMention", comment: lastUnreadCommentWithMention }; } return { type: "unreadReplies", comments: unreadComments }; }; function generateCommentUrl({ roomUrl, commentId }) { if (!roomUrl) { return; } return generateUrl(roomUrl, void 0, commentId); } async function prepareThreadNotificationEmail(client, event, options, elements, callerName) { const data = await extractThreadNotificationData({ client, event }); if (data === null) { return null; } const roomInfo = options.resolveRoomInfo ? await options.resolveRoomInfo({ roomId: event.data.roomId }) : void 0; const resolvedRoomInfo = { ...roomInfo, name: roomInfo?.name ?? event.data.roomId }; const batchUsersResolver = createBatchUsersResolver({ resolveUsers: options.resolveUsers, callerName }); const batchGroupsInfoResolver = createBatchGroupsInfoResolver({ resolveGroupsInfo: options.resolveGroupsInfo, callerName }); switch (data.type) { case "unreadMention": { const { comment } = data; const authorsIds = [comment.userId]; const authorsInfoPromise = batchUsersResolver.get(authorsIds); const commentBodyPromise = convertCommentBody(comment.body, { resolveUsers: ({ userIds }) => batchUsersResolver.get(userIds), resolveGroupsInfo: ({ groupIds }) => batchGroupsInfoResolver.get(groupIds), elements }); await batchUsersResolver.resolve(); await batchGroupsInfoResolver.resolve(); const [authorsInfo, commentBody] = await Promise.all([ authorsInfoPromise, commentBodyPromise ]); const authorInfo = getResolvedForId( comment.userId, authorsIds, authorsInfo ); const url = roomInfo?.url ? generateCommentUrl({ roomUrl: roomInfo?.url, commentId: comment.id }) : void 0; return { type: "unreadMention", comment: { id: comment.id, threadId: comment.threadId, roomId: comment.roomId, author: { id: comment.userId, info: authorInfo ?? { name: comment.userId } }, createdAt: comment.createdAt, url, body: commentBody }, roomInfo: resolvedRoomInfo }; } case "unreadReplies": { const { comments } = data; const authorsIds = comments.map((c) => c.userId); const authorsInfoPromise = batchUsersResolver.get(authorsIds); const commentBodiesPromises = comments.map( (c) => convertCommentBody(c.body, { resolveUsers: ({ userIds }) => batchUsersResolver.get(userIds), resolveGroupsInfo: ({ groupIds }) => batchGroupsInfoResolver.get(groupIds), elements }) ); await batchUsersResolver.resolve(); await batchGroupsInfoResolver.resolve(); const [authorsInfo, ...commentBodies] = await Promise.all([ authorsInfoPromise, ...commentBodiesPromises ]); return { type: "unreadReplies", comments: comments.map((comment, index) => { const authorInfo = getResolvedForId( comment.userId, authorsIds, authorsInfo ); const commentBody = commentBodies[index]; const url = generateCommentUrl({ roomUrl: roomInfo?.url, commentId: comment.id }); return { id: comment.id, threadId: comment.threadId, roomId: comment.roomId, author: { id: comment.userId, info: authorInfo ?? { name: comment.userId } }, createdAt: comment.createdAt, url, body: commentBody }; }), roomInfo: resolvedRoomInfo }; } } } var baseStyles2 = { paragraph: { fontSize: "14px" }, strong: { fontWeight: 500 }, code: { fontFamily: 'ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Mono", "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Consolas", "Courier New", monospace', backgroundColor: "rgba(0,0,0,0.05)", border: "solid 1px rgba(0,0,0,0.1)", borderRadius: "4px" }, mention: { color: "blue" }, link: { textDecoration: "underline" } }; async function prepareThreadNotificationEmailAsHtml(client, event, options = {}) { const styles = { ...baseStyles2, ...options?.styles }; const data = await prepareThreadNotificationEmail( client, event, { resolveUsers: options.resolveUsers, resolveGroupsInfo: options.resolveGroupsInfo, resolveRoomInfo: options.resolveRoomInfo }, { container: ({ children }) => children.join("\n"), paragraph: ({ children }) => { const unsafe = children.join(""); return unsafe ? html2`<p style="${toInlineCSSString(styles.paragraph)}">${htmlSafe2(unsafe)}</p>` : unsafe; }, text: ({ element }) => { let children = element.text; if (!children) { return html2`${children}`; } if (element.bold) { children = html2`<strong style="${toInlineCSSString(styles.strong)}">${children}</strong>`; } if (element.italic) { children = html2`<em>${children}</em>`; } if (element.strikethrough) { children = html2`<s>${children}</s>`; } if (element.code) { children = html2`<code style="${toInlineCSSString(styles.code)}">${children}</code>`; } return html2`${children}`; }, link: ({ element, href }) => { return html2`<a href="${href}" target="_blank" rel="noopener noreferrer" style="${toInlineCSSString(styles.link)}">${element.text ? html2`${element.text}` : element.url}</a>`; }, mention: ({ element, user, group }) => { return html2`<span data-mention style="${toInlineCSSString(styles.mention)}">${MENTION_CHARACTER2}${user?.name ? html2`${user?.name}` : group?.name ? html2`${group?.name}` : element.id}</span>`; } }, "prepareThreadNotificationEmailAsHtml" ); if (data === null) { return null; } return data; } var baseComponents2 = { Container: ({ children }) => /* @__PURE__ */ jsx2("div", { children }), Paragraph: ({ children }) => /* @__PURE__ */ jsx2("p", { children }), Text: ({ element }) => { let children = element.text; if (element.bold) { children = /* @__PURE__ */ jsx2("strong", { children }); } if (element.italic) { children = /* @__PURE__ */ jsx2("em", { children }); } if (element.strikethrough) { children = /* @__PURE__ */ jsx2("s", { children }); } if (element.code) { children = /* @__PURE__ */ jsx2("code", { children }); } return /* @__PURE__ */ jsx2("span", { children }); }, Link: ({ element, href }) => /* @__PURE__ */ jsx2("a", { href, target: "_blank", rel: "noopener noreferrer", children: element.text ?? element.url }), Mention: ({ element, user, group }) => /* @__PURE__ */ jsxs2("span", { "data-mention": true, children: [ MENTION_CHARACTER2, user?.name ?? group?.name ?? element.id ] }) }; async function prepareThreadNotificationEmailAsReact(client, event, options = {}) { const Components = { ...baseComponents2, ...options?.components }; const data = await prepareThreadNotificationEmail( client, event, { resolveUsers: options.resolveUsers, resolveGroupsInfo: options.resolveGroupsInfo, resolveRoomInfo: options.resolveRoomInfo }, { container: ({ children }) => /* @__PURE__ */ jsx2(Components.Container, { children }, "lb-comment-body-container"), paragraph: ({ children }, index) => /* @__PURE__ */ jsx2(Components.Paragraph, { children }, `lb-comment-body-paragraph-${index}`), text: ({ element }, index) => /* @__PURE__ */ jsx2( Components.Text, { element }, `lb-comment-body-text-${index}` ), link: ({ element, href }, index) => /* @__PURE__ */ jsx2( Components.Link, { element, href }, `lb-comment-body-link-${index}` ), mention: ({ element, user, group }, index) => element.id ? /* @__PURE__ */ jsx2( Components.Mention, { element, user, group }, `lb-comment-body-mention-${index}` ) : null }, "prepareThreadNotificationEmailAsReact" ); if (data === null) { return null; } return data; } // src/index.ts detectDupes(PKG_NAME, PKG_VERSION, PKG_FORMAT); export { prepareTextMentionNotificationEmailAsHtml, prepareTextMentionNotificationEmailAsReact, prepareThreadNotificationEmailAsHtml, prepareThreadNotificationEmailAsReact }; //# sourceMappingURL=index.js.map