/**
* Message Content Processing Utilities
* Handles mention badges, URL links, and image previews
*/
/**
* Detect whether the primary input is touch-based (phones/tablets), as opposed
* to a device with a precise pointer (mouse/trackpad). Used to decide whether
* Enter should send a message or just insert a newline.
* @returns {boolean}
*/
function isTouchPrimaryDevice() {
return window.matchMedia('(pointer: coarse)').matches;
}
/**
* Process message content to handle mentions, URLs, images and share cards
* @param {string} content - Raw message content
* @param {object} [options] - { isOwn: boolean } — whether we sent this message.
* Only affects share cards (own ones get a "you
* shared this" header instead of an action button).
* @returns {string} - Processed HTML content
*/
function processMessageContent(content, options) {
if (!content) return '';
// Check if content (minus mentions) is emoji-only BEFORE any processing
const emojiOnlyInfo = checkEmojiOnlyContent(content);
// Lift share tokens out of the text BEFORE escaping. They cannot survive
// the passes below: escaping turns the '&' of a channel URI into '&',
// processChannelLinks would eat a '#channel' name, processMentions an '@'
// in a contact name, and processQuotes collides with the token's closing
// '>'. Each token leaves behind a private-use-area placeholder that no
// pattern here can match, and the finished card HTML is spliced back in
// last — see restoreShareCards().
const shareExtract = extractShareTokens(content, options);
// First escape HTML to prevent XSS
let processed = escapeHtml(shareExtract.text);
// 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);
}
// 6. Swap the placeholders for the pre-built card markup. Last, because
// this output is trusted HTML and must not be escaped again.
processed = restoreShareCards(processed, shareExtract.cards);
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. Two syntaxes are recognised:
* `>quoted line` (current, also what other MeshCore clients use) and
* `»quoted text«` (what mc-webui produced before 2.2.0 — still rendered so
* older messages keep their styling).
* @param {string} text - HTML-escaped text
* @returns {string} - Text with styled quotes
*/
function processQuotes(text) {
// Legacy guillemets: the markers are dropped (styling replaces them) and
// the pattern eats the space separating quote from reply, hence the .
const legacyPattern = /»([^«]+)«\s*/g;
text = text.replace(legacyPattern, (_match, quoted) => {
return `${quoted} `;
});
// `>quoted line`. The text arrives HTML-escaped, so a typed '>' shows up as
// '>' and the '>' of generated tags can never match. Anchored to the
// start of a line or to a leading @[mention] badge, so "5 > 3" mid
// sentence is left alone. The trailing newline stays in place — the message
// containers are pre-wrap and render it as the break.
const quotePattern = /(^|\n|<\/span>[ \t]*)>[ \t]*([^\n]*)/g;
return text.replace(quotePattern, (match, prefix, quoted) => {
if (!quoted.trim()) return match;
return `${prefix}${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 `
`;
}
/**
* 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 = `
${tHtml('chat.image_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, '>');
}
/**
* Serialize a value as a JS literal safe to splice into an inline event
* handler attribute.
*
* JSON.stringify() alone is not enough. Its output is dropped straight into
* markup, so a message containing an apostrophe closes the attribute early and
* a following '<' ends the whole tag — the rest of the button then leaks into
* the page as visible text (e.g. `title="Quote">` next to a broken icon), and
* the handler never runs. Escaping both quote characters and the angle
* brackets keeps the literal intact for the JS parser, which sees the
* attribute value only after HTML entities are decoded.
*
* @param {*} value - Value to pass to the handler
* @returns {string} - Escaped JSON literal, ready for an attribute
*/
function jsArg(value) {
return escapeHtmlAttribute(JSON.stringify(value === undefined ? null : value));
}
/* =============================================================================
Share Cards — contacts, channels and positions shared inside chat messages
=============================================================================
Wire formats and their parsing live in share-tokens.js (window.MCShare).
This section turns a parsed token into card markup and handles the clicks.
Pages plug in their own environment through window.MCShareHooks, because the
two chats live in different documents: the channel chat is the top-level
page, the DM chat is an iframe with no access to the parent's map modal or
contact caches. Every hook is optional — without them a card still renders,
it just falls back to the "not known yet" state, which is safe because the
add endpoints are idempotent.
============================================================================= */
// Private-use-area sentinels. Chosen because no pass in processMessageContent
// can match them and escapeHtml() leaves them untouched.
const SHARE_PH_OPEN = '\uE000';
const SHARE_PH_CLOSE = '\uE001';
const SHARE_PH_RE = /\uE000(\d+)\uE001/g;
/** Bootstrap Icons per numeric contact type, plus the two non-contact kinds. */
const SHARE_TYPE_ICONS = {
1: 'bi-person-fill',
2: 'bi-broadcast',
3: 'bi-door-open-fill',
4: 'bi-thermometer-half'
};
/**
* Replace every share token with a placeholder and pre-build its card.
* @param {string} content - Raw, un-escaped message body
* @param {object} [options] - { isOwn: boolean }
* @returns {{text: string, cards: string[]}}
*/
function extractShareTokens(content, options) {
if (typeof MCShare === 'undefined') return { text: content, cards: [] };
const tokens = MCShare.parse(content);
if (!tokens.length) return { text: content, cards: [] };
const ctx = {
isOwn: !!(options && options.isOwn),
standalone: MCShare.isTokenOnly(content)
};
let text = '';
let cursor = 0;
const cards = [];
tokens.forEach((token, i) => {
text += content.slice(cursor, token.index);
text += SHARE_PH_OPEN + i + SHARE_PH_CLOSE;
cards.push(buildShareCard(token, ctx));
cursor = token.index + token.length;
});
text += content.slice(cursor);
return { text: text, cards: cards };
}
/**
* Splice the finished card markup back over its placeholders.
* @param {string} html - Fully processed (escaped) message HTML
* @param {string[]} cards - Card markup, indexed by placeholder number
* @returns {string}
*/
function restoreShareCards(html, cards) {
if (!cards || !cards.length) return html;
return html.replace(SHARE_PH_RE, (match, idx) => {
const card = cards[parseInt(idx, 10)];
return card === undefined ? match : card;
});
}
/** Dispatch to the per-kind builder. */
function buildShareCard(token, ctx) {
switch (token.kind) {
case 'contact': return buildContactCard(token.data, ctx);
case 'channel': return buildChannelCard(token.data, ctx);
case 'location': return buildLocationCard(token.data, ctx);
default: return escapeHtml(token.raw);
}
}
/** Read a hook without assuming the page installed it. */
function shareHook(name) {
const hooks = window.MCShareHooks;
return (hooks && typeof hooks[name] === 'function') ? hooks[name] : null;
}
/**
* Node-type label. Spelled out as literal keys rather than a composed
* 'share.type.' + n, so scripts/i18n_check.py can see which keys are in use.
*/
function shareTypeLabel(type) {
switch (MCShare.normalizeContactType(type)) {
case 2: return t('share.type.2');
case 3: return t('share.type.3');
case 4: return t('share.type.4');
default: return t('share.type.1');
}
}
/** "You shared this …" banner, shown instead of an action on our own messages. */
function shareOwnHeader(key) {
return `
${tHtml(key)}
`;
}
/**
* Contact card. The action reflects what clicking would actually do, resolved
* before render so the user is never surprised by the outcome.
*/
function buildContactCard(data, ctx) {
const pubkey = data.public_key;
const name = data.name || MCShare.shortPubkey(pubkey);
const icon = SHARE_TYPE_ICONS[MCShare.normalizeContactType(data.type)] || 'bi-person-fill';
const attrs = `data-share-kind="contact" data-pubkey="${escapeHtmlAttribute(pubkey)}"` +
` data-name="${escapeHtmlAttribute(data.name || '')}"` +
` data-type="${MCShare.normalizeContactType(data.type)}"`;
// On our own messages the marker replaces the action: we already have
// whatever we shared, so there is nothing to add.
let header = '';
let footer = '';
if (ctx.isOwn) {
header = shareOwnHeader('share.card.you_shared_contact');
} else {
const lookup = shareHook('lookupContact');
const known = lookup ? lookup(pubkey) : null;
footer = contactCardAction(known, data.name);
}
return `
`;
}
/**
* Decide the contact button from what we already hold. The public key is the
* identity — a differing name means "rename", never a second contact.
*/
function contactCardAction(known, sharedName) {
if (!known) {
return shareCardButton('add-contact', 'share.card.add_contact', 'primary');
}
const sameName = (known.name || '') === (sharedName || '');
if (!sameName && sharedName) {
// upsert_contact overwrites a non-empty name, so this really is a rename.
return shareCardButton('add-contact', 'share.card.update_name', 'primary',
{ name: sharedName });
}
if (known.source === 'device') {
return shareCardButton(null, 'share.card.in_contacts', 'done');
}
// Known but cache-only: offer the promotion right here rather than sending
// the user off to the Contacts panel to do it.
return shareCardButton('push-contact', 'share.card.push_to_device', 'secondary',
null, 'share.card.in_cache');
}
/**
* Channel card. Joining needs the secret in firmware, so this always targets
* the device — a cache-only channel could not decrypt anything.
*/
function buildChannelCard(data, ctx) {
const attrs = `data-share-kind="channel" data-name="${escapeHtmlAttribute(data.name)}"` +
` data-secret="${escapeHtmlAttribute(data.secret || '')}"` +
` data-scope="${escapeHtmlAttribute(data.region_scope || '')}"`;
// Literal keys on both branches, so scripts/i18n_check.py can see them —
// a composed key hides the usage and lets a missing translation ship.
const kindLabel = data.kind === 'hashtag' ? tHtml('share.card.hashtag_channel')
: tHtml('share.card.private_channel');
// The URI carries the scope as "#pl"; the UI names regions without the hash.
let scopeRow = '';
if (data.region_scope) {
const scope = data.region_scope.replace(/^#/, '');
scopeRow = `
`;
}
function channelCardAction(known, data) {
if (!known) {
return shareCardButton('add-channel', 'share.card.add_channel', 'primary');
}
if (known.matched === 'secret') {
// Same key already in a slot — joining again would just burn a slot.
return shareCardButton(null, 'share.card.channel_joined', 'done');
}
// Name is taken by a different key. Joining would either fail or shadow the
// existing channel, so refuse and say why instead of silently overwriting.
return shareCardButton(null, 'share.card.channel_conflict', 'warning');
}
/** Location card. Actions stay available on both sides — you may well want the
* map for a position you sent yourself. */
function buildLocationCard(data, ctx) {
const lat = data.lat;
const lon = data.lon;
const coords = MCShare.serializeLocation(lat, lon);
const attrs = `data-share-kind="location" data-lat="${lat}" data-lon="${lon}"`;
// Distance and bearing from our own advertised position, when we have one.
let relRow = '';
const selfPos = shareHook('selfPosition');
const self = selfPos ? selfPos() : null;
if (self && MCShare.isValidLatLon(self.lat, self.lon)) {
const dist = MCShare.formatDistance(MCShare.distanceMeters(self.lat, self.lon, lat, lon));
const bearing = MCShare.compassBearing(self.lat, self.lon, lat, lon);
relRow = `
`;
}
const gLink = `https://www.google.com/maps/search/?api=1&query=${lat},${lon}`;
const oLink = `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=15/${lat}/${lon}`;
let external =
`` +
``;
// Apple Maps is only useful on Apple platforms; elsewhere it is dead weight.
if (/iPhone|iPad|iPod|Macintosh/.test(navigator.userAgent || '')) {
const aLink = `https://maps.apple.com/?ll=${lat},${lon}&q=${encodeURIComponent(coords)}`;
external += ``;
}
return `
` +
`
` +
`` +
`
` +
`
${escapeHtml(coords)}
` +
relRow +
`
` +
`
` +
`
` +
`` +
`` +
external +
`
` +
`
`;
}
/**
* Build a card action button.
* @param {?string} action - data-share-action, or null for a non-clickable state
* @param {string} labelKey - i18n key for the label
* @param {string} variant - primary | secondary | done | warning
* @param {?object} params - i18n params for the label
* @param {?string} noteKey - optional muted note rendered above the button
*/
function shareCardButton(action, labelKey, variant, params, noteKey) {
const note = noteKey
? `