mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-03-28 17:42:45 +01:00
- Add sender name above outgoing messages in group chat - Display emoji-only messages in larger font size - Fix leading space before text in quoted replies Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
392 lines
13 KiB
JavaScript
392 lines
13 KiB
JavaScript
/**
|
|
* 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 </span> 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<br><span class="emoji-large">$2</span>'
|
|
);
|
|
} else {
|
|
// Just wrap everything in large emoji class
|
|
html = `<span class="emoji-large">${html}</span>`;
|
|
}
|
|
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 `<span class="mention-badge">@${username}</span>`;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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}<a href="#" class="channel-link" data-channel-name="${escapedName}">#${channelName}</a>`;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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 `<span class="quote-text">${quoted}</span><br>`;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|