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 `
+
+

+
+
+ `;
+}
+
+/**
+ * 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 = `
+
+
+
+
+
![Preview]()
+
+
+
+ `;
+
+ 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 @@
+
+
+