import getURL from "discourse/lib/get-url";
import { emojiUnescape } from "discourse/lib/text";
import { i18n } from "discourse-i18n";
/** @type {RichEditorExtension} */
const extension = {
nodeSpec: {
chat: {
attrs: {
messageId: {},
username: {},
datetime: {},
channel: {},
channelId: {},
html: {},
rawContent: {},
multiQuote: {},
chained: {},
threadId: { default: null },
threadTitle: { default: null },
threadHtml: { default: null },
},
group: "block",
isolating: true,
selectable: true,
parseDOM: [{ tag: "div.chat-transcript" }],
toDOM(node) {
// NOTE: This HTML representation of the node in a lot of ways is duplicated from
// the chat transcript markdown-it rule, this is unavoidable unless we completely
// decouple the token generation from the HTML generation, which is a lot of work,
// so this is acceptable for now.
const wrapperElement = document.createElement("div");
wrapperElement.classList.add("chat-transcript");
if (node.attrs.chained) {
wrapperElement.classList.add("chat-transcript-chained");
}
let metaElement;
let channelLinkElement;
if (node.attrs.channel) {
if (node.attrs.multiQuote) {
metaElement = document.createElement("div");
metaElement.classList.add("chat-transcript-meta");
const channelLink = node.attrs.channelId
? getURL(`/chat/c/-/${node.attrs.channelId}`)
: null;
metaElement.innerHTML = i18n("chat.quote.original_channel", {
channel: emojiUnescape(node.attrs.channel),
channelLink,
});
} else {
channelLinkElement = document.createElement("a");
channelLinkElement.classList.add("chat-transcript-channel");
channelLinkElement.href = getURL(
`/chat/c/-/${node.attrs.channelId}`
);
channelLinkElement.innerHTML = `#${emojiUnescape(
node.attrs.channel
)}`;
}
}
const userElement = document.createElement("div");
userElement.classList.add("chat-transcript-user");
// TODO (martin) Need to use current user's timezone here when we have
// that available.
const formattedDateTime = moment(node.attrs.datetime).format(
i18n("dates.long_no_year")
);
userElement.innerHTML = `
${node.attrs.username}
${formattedDateTime}
`;
const messagesElement = document.createElement("div");
messagesElement.classList.add("chat-transcript-messages");
messagesElement.innerHTML = node.attrs.html;
if (metaElement) {
wrapperElement.appendChild(metaElement);
}
if (node.attrs.threadId) {
const threadDetailsElement = document.createElement("details");
const threadSummaryElement = document.createElement("summary");
const threadElement = document.createElement("div");
threadElement.classList.add("chat-transcript-thread");
const threadHeaderElement = document.createElement("div");
threadHeaderElement.classList.add("chat-transcript-thread-header");
threadHeaderElement.innerHTML = `
`;
const threadTitleElement = document.createElement("span");
threadTitleElement.classList.add(
"chat-transcript-thread-header__title"
);
threadTitleElement.innerHTML = node.attrs.threadTitle
? emojiUnescape(node.attrs.threadTitle)
: i18n("chat.quote.default_thread_title");
threadHeaderElement.appendChild(threadTitleElement);
threadElement.appendChild(threadHeaderElement);
threadElement.appendChild(userElement);
if (channelLinkElement) {
userElement.appendChild(channelLinkElement);
}
threadElement.appendChild(messagesElement);
threadSummaryElement.appendChild(threadElement);
threadDetailsElement.appendChild(threadSummaryElement);
if (node.attrs.threadHtml) {
threadDetailsElement.innerHTML += node.attrs.threadHtml;
}
wrapperElement.appendChild(threadDetailsElement);
} else {
wrapperElement.appendChild(userElement);
if (channelLinkElement) {
userElement.appendChild(channelLinkElement);
}
wrapperElement.appendChild(messagesElement);
}
return wrapperElement;
},
},
},
serializeNode({ utils: { buildBBCodeAttrs } }) {
return {
chat(state, node) {
let bbCodeAttrs = `quote="${node.attrs.username};${node.attrs.messageId};${node.attrs.datetime}"`;
bbCodeAttrs +=
" " +
buildBBCodeAttrs(node.attrs, {
skipAttrs: [
"username",
"messageId",
"datetime",
"rawContent",
"html",
"threadHtml",
],
});
state.write(`[chat ${bbCodeAttrs}]\n`);
state.write(node.attrs.rawContent);
state.write("\n[/chat]\n");
},
};
},
parse: {
div_chat_transcript_wrap_open(state, token, tokens, i) {
// The slice here makes sure we get the html_raw content
// only for the current [chat] bbcode block based on the
// token index.
const nextHtmlRaw = tokens
.slice(i)
.find((t) => t.type === "html_raw" && t.nesting === 1);
const messagesHtml = nextHtmlRaw?.content;
let threadHtmlRaw;
if (token.attrGet("data-thread-id")) {
threadHtmlRaw = tokens
.slice(tokens.indexOf(nextHtmlRaw) + 1)
.find((t) => t.type === "html_raw" && t.nesting === 1);
}
// So this content and the whole wrap_open happens for every single instance of
// the `[chat]` bbcode, including nested which is the case for threads.
//
// Only the first one will have multiQuote and chained
//
// Multiquote is > 1 message
// Chained is > 1 message by different users
state.openNode(state.schema.nodes.chat, {
messageId: token.attrGet("data-message-id"),
username: token.attrGet("data-username"),
datetime: token.attrGet("data-datetime"),
channel: token.attrGet("data-channel-name"),
channelId: token.attrGet("data-channel-id"),
chained: token.attrGet("data-chained"),
multiQuote: token.attrGet("data-multiquote"),
threadId: token.attrGet("data-thread-id"),
threadTitle: token.attrGet("data-thread-title"),
threadHtml: threadHtmlRaw ? threadHtmlRaw.content : null,
html: messagesHtml,
rawContent: token.content,
});
return true;
},
div_chat_transcript_wrap_close(state) {
state.closeNode();
return true;
},
div_chat_transcript_user: { ignore: true },
div_chat_transcript_user_avatar: { ignore: true },
div_chat_transcript_username: { ignore: true },
div_chat_transcript_datetime: { ignore: true },
div_chat_transcript_messages: { ignore: true },
div_chat_transcript_reaction: { ignore: true },
// Reaction-related tokens are not used in the live preview,
// they are only used when archiving a channel, not needed here.
div_chat_transcript_reactions: { ignore: true },
div_chat_transcript_meta: { ignore: true },
// Thread-related tokens
details_chat_transcript_wrap: { ignore: true },
summary_chat_transcript: { ignore: true },
div_thread: { ignore: true },
div_thread_header: { ignore: true },
svg_thread_header: { ignore: true },
use_svg_thread: { ignore: true },
span_thread_title: { ignore: true },
html_raw: { ignore: true, noCloseToken: true },
span_open(state) {
if (state.top().type.name === "chat") {
return true;
}
},
span_close(state) {
if (state.top().type.name === "chat") {
return true;
}
},
},
};
export default extension;