/**
* Message Content Processing Utilities
* Handles mention badges, URL links, and image previews
*/
/**
* Process message content to handle mentions, URLs, and images
* @param {string} content - Raw message content
* @returns {string} - Processed HTML content
*/
function processMessageContent(content) {
if (!content) return '';
// Check if content (minus mentions) is emoji-only BEFORE any processing
const emojiOnlyInfo = checkEmojiOnlyContent(content);
// First escape HTML to prevent XSS
let processed = escapeHtml(content);
// Process in order:
// 1. Convert @[Username] mentions to badges
processed = processMentions(processed);
// 2. Convert #channel to clickable links (only in channel context)
processed = processChannelLinks(processed);
// 3. Convert »quoted text« to styled quotes
processed = processQuotes(processed);
// 4. Convert URLs to links (and images to thumbnails)
processed = processUrls(processed);
// 5. If emoji-only, enlarge the emoji
if (emojiOnlyInfo.isEmojiOnly) {
processed = enlargeEmoji(processed, emojiOnlyInfo.hasMention);
}
return processed;
}
/**
* Check if content is emoji-only (excluding @[mentions])
* @param {string} text - Raw message content
* @returns {object} - { isEmojiOnly: boolean, hasMention: boolean }
*/
function checkEmojiOnlyContent(text) {
const hasMention = /@\[[^\]]+\]/.test(text);
// Remove @[...] patterns
const withoutMentions = text.replace(/@\[[^\]]+\]/g, '').trim();
if (!withoutMentions) {
return { isEmojiOnly: false, hasMention };
}
// Check if remaining is only emoji (using Unicode Extended_Pictographic)
// Matches emoji, modifiers, skin tones, ZWJ sequences, variation selectors, and whitespace
const emojiRegex = /^[\p{Extended_Pictographic}\p{Emoji_Modifier}\p{Emoji_Modifier_Base}\p{Emoji_Component}\uFE0F\u200D\s]+$/u;
const isEmojiOnly = emojiRegex.test(withoutMentions);
return { isEmojiOnly, hasMention };
}
/**
* Enlarge emoji in processed HTML
* @param {string} html - Processed HTML with mention badges
* @param {boolean} hasMention - Whether content has mentions
* @returns {string} - HTML with enlarged emoji
*/
function enlargeEmoji(html, hasMention) {
if (hasMention) {
// Add line break after mention badge, then wrap emoji in large class
// Pattern: closing of mention badge, optional whitespace, then emoji
html = html.replace(
/(<\/span>)\s*([\p{Extended_Pictographic}\p{Emoji_Modifier}\p{Emoji_Modifier_Base}\p{Emoji_Component}\uFE0F\u200D\s]+)$/u,
'$1
$2'
);
} else {
// Just wrap everything in large emoji class
html = `${html}`;
}
return html;
}
/**
* Convert @[Username] mentions to styled badges
* @param {string} text - HTML-escaped text
* @returns {string} - Text with mention badges
*/
function processMentions(text) {
// Match @[Username] pattern
// Note: text is already HTML-escaped, so we match escaped brackets
const mentionPattern = /@\[([^\]]+)\]/g;
return text.replace(mentionPattern, (_match, username) => {
// Create badge similar to Android Meshcore app
return `@${username}`;
});
}
/**
* Convert #channelname to clickable channel links
* Only active in channel context (when availableChannels exists)
* @param {string} text - HTML-escaped text
* @returns {string} - Text with channel links
*/
function processChannelLinks(text) {
// Only process in channel context (app.js provides availableChannels)
// In DM context (dm.js), availableChannels is undefined
if (typeof availableChannels === 'undefined') {
return text;
}
// Match #channelname pattern
// Valid: alphanumeric, underscore, hyphen
// Must be at least 2 characters after #
// Must be preceded by whitespace, start of string, or punctuation
const channelPattern = /(^|[\s.,!?:;()\[\]])#([a-zA-Z0-9_-]{2,})/g;
return text.replace(channelPattern, (_match, prefix, channelName) => {
const escapedName = escapeHtmlAttribute(channelName);
return `${prefix}#${channelName}`;
});
}
/**
* Convert »quoted text« to styled quote blocks
* @param {string} text - HTML-escaped text
* @returns {string} - Text with styled quotes
*/
function processQuotes(text) {
// Match »...« pattern (guillemets) including optional trailing whitespace
const quotePattern = /»([^«]+)«\s*/g;
return text.replace(quotePattern, (_match, quoted) => {
// Display without guillemets (styling is enough) + line break after
return `${quoted}
`;
});
}
/**
* Convert URLs to clickable links and images to thumbnails
* @param {string} text - HTML-escaped text
* @returns {string} - Text with links and image thumbnails
*/
function processUrls(text) {
// URL regex pattern (handles http:// and https://)
const urlPattern = /(https?:\/\/[^\s<>"{}|\\^`\[\]]+)/g;
return text.replace(urlPattern, (url) => {
// Check if URL is an image
if (isImageUrl(url)) {
return createImageThumbnail(url);
} else {
return createLink(url);
}
});
}
/**
* Check if URL points to an image
* @param {string} url - URL to check
* @returns {boolean} - True if URL is an image
*/
function isImageUrl(url) {
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
const urlLower = url.toLowerCase();
return imageExtensions.some(ext => urlLower.endsWith(ext));
}
/**
* Create a clickable link
* @param {string} url - URL to link to
* @returns {string} - HTML link element
*/
function createLink(url) {
return ``;
}
/**
* Create an image thumbnail with click-to-expand
* @param {string} url - Image URL
* @returns {string} - HTML image thumbnail
*/
function createImageThumbnail(url) {
// Escape URL for use in HTML attributes
const escapedUrl = escapeHtmlAttribute(url);
return `