@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
JavaScript
// 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