/** * 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 `${url}`; } /** * 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 `
Image
${url}
`; } /** * Show image in modal * @param {string} url - Image URL to display */ function showImageModal(url) { // Create modal if it doesn't exist let modal = document.getElementById('imagePreviewModal'); if (!modal) { modal = createImageModal(); document.body.appendChild(modal); } // Set image source const img = modal.querySelector('#imagePreviewImg'); if (img) { img.src = url; } // Show modal using Bootstrap const bsModal = new bootstrap.Modal(modal); bsModal.show(); } /** * Create image preview modal element * @returns {HTMLElement} - Modal element */ function createImageModal() { const modal = document.createElement('div'); modal.id = 'imagePreviewModal'; modal.className = 'modal fade'; modal.tabIndex = -1; modal.setAttribute('aria-labelledby', 'imagePreviewModalLabel'); modal.setAttribute('aria-hidden', 'true'); modal.innerHTML = ` `; return modal; } /** * Escape HTML to prevent XSS * @param {string} text - Text to escape * @returns {string} - Escaped text */ function escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /** * Escape HTML attribute to prevent XSS in attributes * @param {string} text - Text to escape * @returns {string} - Escaped text safe for HTML attributes */ function escapeHtmlAttribute(text) { if (!text) return ''; return text .replace(/&/g, '&') .replace(/"/g, '"') .replace(/'/g, ''') .replace(//g, '>'); } /** * Initialize image click handlers using event delegation * This should be called after DOM content is loaded */ function initializeImageHandlers() { // Use event delegation on document to handle dynamically added images document.addEventListener('click', function(e) { if (e.target.classList.contains('message-image-thumbnail')) { const url = e.target.getAttribute('data-image-url'); if (url) { showImageModal(url); } } }); } /** * Handle channel link click - switch to or join channel * @param {string} channelName - Channel name without # prefix */ async function handleChannelLinkClick(channelName) { // Normalize name (add # if not present for comparison) const normalizedName = channelName.startsWith('#') ? channelName : '#' + channelName; // Check if channel exists in availableChannels const existingChannel = availableChannels.find( ch => ch.name.toLowerCase() === normalizedName.toLowerCase() ); if (existingChannel) { switchToChannel(existingChannel.index, existingChannel.name); } else { await joinAndSwitchToChannel(normalizedName); } } /** * Switch to an existing channel via the channel selector * @param {number} channelIdx - Channel index * @param {string} channelName - Channel name for notification */ function switchToChannel(channelIdx, channelName) { const selector = document.getElementById('channelSelector'); if (selector) { selector.value = channelIdx; // Trigger change event to update state and load messages selector.dispatchEvent(new Event('change')); } } /** * Join a channel via API when clicking channel link, then switch to it * @param {string} channelName - Channel name (with #) */ async function joinAndSwitchToChannel(channelName) { try { const response = await fetch('/api/channels/join', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: channelName }) }); const data = await response.json(); if (data.success) { showNotification(`Joined channel "${channelName}"!`, 'success'); // Show warning if applicable (e.g., exceeding channel limit) if (data.warning) { setTimeout(() => { showNotification(data.warning, 'warning'); }, 2000); } // Reload channels and switch to new channel await loadChannels(); switchToChannel(data.channel.index, channelName); } else { showNotification('Failed to join channel: ' + data.error, 'danger'); } } catch (error) { console.error('Error joining channel via link:', error); showNotification('Failed to join channel', 'danger'); } } /** * Initialize channel link click handlers using event delegation */ function initializeChannelLinkHandlers() { document.addEventListener('click', function(e) { if (e.target.classList.contains('channel-link')) { e.preventDefault(); const channelName = e.target.getAttribute('data-channel-name'); if (channelName) { // Add loading state e.target.classList.add('loading'); handleChannelLinkClick(channelName).finally(() => { e.target.classList.remove('loading'); }); } } }); } // Auto-initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function() { initializeImageHandlers(); initializeChannelLinkHandlers(); }); } else { // DOM already loaded initializeImageHandlers(); initializeChannelLinkHandlers(); }