mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-07-21 09:03:02 +02:00
feat: Add mention badges, clickable URLs, and image previews to messages
Implemented comprehensive message content enhancements for both channel messages and DMs: - **Mention badges**: @[Username] mentions now display as styled badges similar to Android Meshcore app - **Clickable URLs**: http:// and https:// URLs are automatically converted to clickable links that open in new tabs - **Image thumbnails**: URLs ending in .jpg, .jpeg, .png, .gif, .webp display as thumbnails with click-to-expand modal preview - **Mobile responsive**: Image thumbnails adapt size for mobile screens - **Security**: Proper XSS protection with HTML escaping and attribute sanitization Technical implementation: - Created shared message-utils.js module for content processing - Added CSS styles for badges, links, images, and modal preview - Updated both app.js and dm.js to use new content processor - Used event delegation for image click handlers to support dynamic content - Responsive design with mobile-optimized image sizes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -343,7 +343,7 @@ function createMessageElement(msg) {
|
||||
<span class="message-sender">${escapeHtml(msg.sender)}</span>
|
||||
<span class="message-time">${time}</span>
|
||||
</div>
|
||||
<p class="message-content">${escapeHtml(msg.content)}</p>
|
||||
<p class="message-content">${processMessageContent(msg.content)}</p>
|
||||
${metaInfo ? `<div class="message-meta">${metaInfo}</div>` : ''}
|
||||
${!msg.is_own ? `
|
||||
<div class="mt-1">
|
||||
|
||||
+1
-1
@@ -415,7 +415,7 @@ function displayMessages(messages) {
|
||||
<span class="text-muted">${formatTime(msg.timestamp)}</span>
|
||||
${statusIcon}
|
||||
</div>
|
||||
<div>${escapeHtml(msg.content)}</div>
|
||||
<div>${processMessageContent(msg.content)}</div>
|
||||
${meta}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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 `<span class="mention-badge">@${username}</span>`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 `<a href="${url}" target="_blank" rel="noopener noreferrer" class="message-link">${url}</a>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 `
|
||||
<div class="message-image-container">
|
||||
<img src="${escapedUrl}"
|
||||
alt="Image"
|
||||
class="message-image-thumbnail"
|
||||
data-image-url="${escapedUrl}"
|
||||
loading="lazy"
|
||||
onerror="this.onerror=null; this.src='data:image/svg+xml,%3Csvg xmlns=\\'http://www.w3.org/2000/svg\\' width=\\'100\\' height=\\'100\\'%3E%3Crect fill=\\'%23ddd\\' width=\\'100\\' height=\\'100\\'/%3E%3Ctext x=\\'50%25\\' y=\\'50%25\\' dominant-baseline=\\'middle\\' text-anchor=\\'middle\\' fill=\\'%23999\\'%3EError%3C/text%3E%3C/svg%3E';">
|
||||
<div class="message-image-url">
|
||||
<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer" class="message-link">${url}</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = `
|
||||
<div class="modal-dialog modal-dialog-centered modal-xl">
|
||||
<div class="modal-content bg-dark">
|
||||
<div class="modal-header border-0">
|
||||
<h5 class="modal-title text-white" id="imagePreviewModalLabel">Image Preview</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body text-center p-0">
|
||||
<img id="imagePreviewImg" src="" alt="Preview" class="img-fluid" style="max-height: 80vh; width: auto;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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, '<')
|
||||
.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();
|
||||
}
|
||||
@@ -267,6 +267,9 @@
|
||||
<!-- Bootstrap 5 JS Bundle -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- Message Content Processing Utilities (must load before app.js and dm.js) -->
|
||||
<script src="{{ url_for('static', filename='js/message-utils.js') }}"></script>
|
||||
|
||||
<!-- Custom JS -->
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
|
||||
|
||||
@@ -163,6 +163,9 @@
|
||||
<!-- Bootstrap 5 JS Bundle -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- Message Content Processing Utilities (must load before dm.js) -->
|
||||
<script src="{{ url_for('static', filename='js/message-utils.js') }}"></script>
|
||||
|
||||
<!-- Custom JS -->
|
||||
<script src="{{ url_for('static', filename='js/dm.js') }}"></script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user