diff --git a/app/static/css/style.css b/app/static/css/style.css index 2df6e5c..ac028b6 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -484,3 +484,93 @@ main { .dm-messages-container::-webkit-scrollbar-thumb:hover { background: #aaa; } + +/* ============================================================================= + Message Content Enhancements (Mentions, Links, Images) + ============================================================================= */ + +/* Mention Badge (similar to Android Meshcore app) */ +.mention-badge { + display: inline-block; + background-color: #0d6efd; + color: white; + padding: 0.15rem 0.5rem; + border-radius: 0.75rem; + font-size: 0.85em; + font-weight: 500; + margin: 0 0.1rem; + white-space: nowrap; +} + +.message.own .mention-badge { + background-color: #084298; +} + +/* Clickable Links in Messages */ +.message-link { + color: #0d6efd; + text-decoration: underline; + word-break: break-all; +} + +.message-link:hover { + color: #0a58ca; + text-decoration: underline; +} + +.message.own .message-link { + color: #084298; +} + +.message.own .message-link:hover { + color: #052c65; +} + +/* Image Thumbnails in Messages */ +.message-image-container { + margin-top: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.message-image-thumbnail { + max-width: 300px; + max-height: 200px; + border-radius: 0.5rem; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; + border: 1px solid #dee2e6; +} + +.message-image-thumbnail:hover { + transform: scale(1.02); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); +} + +.message-image-url { + font-size: 0.75rem; + word-break: break-all; +} + +/* Responsive image sizes on mobile */ +@media (max-width: 576px) { + .message-image-thumbnail { + max-width: 200px; + max-height: 150px; + } +} + +/* Image Preview Modal */ +#imagePreviewModal .modal-content { + background-color: rgba(0, 0, 0, 0.95); +} + +#imagePreviewModal .modal-body { + padding: 1rem; +} + +#imagePreviewImg { + border-radius: 0.5rem; + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3); +} diff --git a/app/static/js/app.js b/app/static/js/app.js index b29e185..8c91afd 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -343,7 +343,7 @@ function createMessageElement(msg) { ${escapeHtml(msg.sender)} ${time} -

${escapeHtml(msg.content)}

+

${processMessageContent(msg.content)}

${metaInfo ? `
${metaInfo}
` : ''} ${!msg.is_own ? `
diff --git a/app/static/js/dm.js b/app/static/js/dm.js index 5380457..2b43cfd 100644 --- a/app/static/js/dm.js +++ b/app/static/js/dm.js @@ -415,7 +415,7 @@ function displayMessages(messages) { ${formatTime(msg.timestamp)} ${statusIcon}
-
${escapeHtml(msg.content)}
+
${processMessageContent(msg.content)}
${meta} `; diff --git a/app/static/js/message-utils.js b/app/static/js/message-utils.js new file mode 100644 index 0000000..5cf8d37 --- /dev/null +++ b/app/static/js/message-utils.js @@ -0,0 +1,208 @@ +/** + * 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 ''; + + // First escape HTML to prevent XSS + let processed = escapeHtml(content); + + // Process in order: + // 1. Convert @[Username] mentions to badges + processed = processMentions(processed); + + // 2. Convert URLs to links (and images to thumbnails) + processed = processUrls(processed); + + return processed; +} + +/** + * 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 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); + } + } + }); +} + +// Auto-initialize when DOM is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializeImageHandlers); +} else { + // DOM already loaded + initializeImageHandlers(); +} diff --git a/app/templates/base.html b/app/templates/base.html index a950c9d..15de2bf 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -267,6 +267,9 @@ + + + diff --git a/app/templates/dm.html b/app/templates/dm.html index e254ac2..ef158ad 100644 --- a/app/templates/dm.html +++ b/app/templates/dm.html @@ -163,6 +163,9 @@ + + +