const isMobile = { Android: () => navigator.userAgent.match(/Android/i), iOS: () => navigator.userAgent.match(/iPhone|iPad|iPod/i), any: () => navigator.userAgent.match(/Android|iPhone|iPad|iPod/i) }; function escapeHtml(text) { return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'") .replace(/\n/g, "
"); } function escapeAttr(text) { return text .replace(/&/g, "&") .replace(/"/g, """) .replace(/'/g, "'") .replace(//g, ">"); } const SAFE_URI_SCHEME = /^(https?:|simplex:|mailto:|tel:)/i; function safeHref(uri) { if (SAFE_URI_SCHEME.test(uri)) return escapeAttr(uri); return escapeAttr(`javascript:void(alert('Potentially malicious link blocked:\\n'+${JSON.stringify(uri)}))`); } function getSimplexLinkDescr(linkType) { switch (linkType) { case 'contact': return 'SimpleX contact address'; case 'invitation': return 'SimpleX one-time invitation'; case 'group': return 'SimpleX group link'; case 'channel': return 'SimpleX channel link'; case 'relay': return 'SimpleX relay link'; default: return 'SimpleX link'; } } function viaHost(smpHosts) { const first = smpHosts[0] ?? '?'; return `via ${first}`; } function isCurrentSite(uri) { return uri.startsWith("https://simplex.chat") || uri.startsWith("https://www.simplex.chat") } function targetBlank(uri) { return isCurrentSite(uri) ? '' : ' target="_blank" rel="noopener"' } const MAX_LIST_DEPTH = 7; const MAX_TABLE_CELLS = 1000; const COPY_BUTTON = ''; // navigator.clipboard is undefined outside secure contexts; fall back to execCommand function copyToClipboard(text) { if (navigator.clipboard && navigator.clipboard.writeText) { return navigator.clipboard.writeText(text); } return new Promise(function(resolve, reject) { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.top = '-9999px'; document.body.appendChild(ta); ta.select(); let ok = false; try { ok = document.execCommand('copy'); } catch (e) {} document.body.removeChild(ta); ok ? resolve() : reject(new Error('copy failed')); }); } function handleCodeClick(e) { const card = e.target.closest('.md-code'); if (!card) return; const button = e.target.closest('.md-copy'); if (!button) { card.classList.toggle('tapped'); return; } const pre = card.querySelector('pre'); if (!pre) return; copyToClipboard(pre.innerText).then(() => { button.classList.add('copied'); setTimeout(() => button.classList.remove('copied'), 1500); }).catch(() => {}); } function validBlocks(fts, blocks) { let pos = 0; const range = (r) => { const valid = !!r && Number.isInteger(r.from) && Number.isInteger(r.to) && pos <= r.from && r.from <= r.to && r.to <= fts.length; if (valid) pos = r.to; return valid; }; const entries = (items, depth) => Array.isArray(items) && (items.length === 0 || depth <= MAX_LIST_DEPTH) && items.every((e) => !!e && range(e.content) && entries(e.items, depth + 1)); const rows = (rs) => Array.isArray(rs) && rs.every((row) => Array.isArray(row) && row.every(range)); return Array.isArray(blocks) && blocks.every((b) => { switch (b && b.type) { case 'paragraph': case 'heading': case 'codeBlock': case 'quote': return range(b.content); case 'list': return entries(b.items, 1); case 'table': return Array.isArray(b.columns) && rows([b.header]) && rows(b.rows); case 'rule': return range(b.mark); default: return false; } }); } function renderBlocks(fts, blocks) { let html = ''; let pos = 0; const segments = (r) => renderSegments(fts.slice(r.from, r.to), true); const isParagraphBreak = (ft) => ft.format && ft.format.type === 'markup' && ft.format.token && ft.format.token.type === 'paragraphBreak'; const gap = (to) => { if (html && fts.slice(pos, to).some(isParagraphBreak)) html += '
'; pos = Math.max(pos, to); }; const block = (r, open, close) => { gap(r.from); html += open + segments(r) + close; pos = r.to; }; const entry = (e, level) => { html += `
${escapeHtml(String(e.number ?? '•'))}${segments(e.content)}
`; pos = e.content.to; e.items.forEach((item) => entry(item, level + 1)); }; const table = (b) => { const rows = [b.header].concat(b.rows); const width = b.columns.length * rows.length <= MAX_TABLE_CELLS ? b.columns.length : 0; const align = (c) => (b.columns[c] === 'center' || b.columns[c] === 'right' ? b.columns[c] : 'left'); const cell = (tag, c, r) => `<${tag} style="text-align: ${align(c)};">${r ? segments(r) : ''}`; const row = (cells, tag) => '' + cells.map((r, c) => cell(tag, c, r)).join('') + Array.from({ length: Math.max(0, width - cells.length) }, (_, k) => cell(tag, cells.length + k, null)).join('') + ''; gap(b.header.length > 0 ? b.header[0].from : pos); html += '
' + row(b.header, 'th') + b.rows.map((r) => row(r, 'td')).join('') + '
'; const last = rows[rows.length - 1]; pos = last.length > 0 ? last[last.length - 1].to : pos; }; for (const b of blocks) { switch (b.type) { case 'paragraph': block(b.content, '
', '
'); break; case 'heading': block(b.content, `
`, '
'); break; case 'codeBlock': block(b.content, '
', '
' + COPY_BUTTON + '
'); break; case 'quote': block(b.content, '
', '
'); break; case 'list': if (b.items.length > 0) gap(b.items[0].content.from); b.items.forEach((item) => entry(item, 0)); break; case 'table': table(b); break; case 'rule': block(b.mark, '
', ''); break; } } return html; } function markupHtml(token) { switch (token && token.type) { case 'softBreak': return ' '; case 'lineBreak': case 'hardBreak': return '
'; default: return ''; } } function renderMarkdown(fts, blocks) { return validBlocks(fts, blocks) ? renderBlocks(fts, blocks) : renderSegments(fts, false); } function renderSegments(fts, withBlocks) { let html = ''; for (const ft of fts) { const { format, text } = ft; if (!format) { html += escapeHtml(text); continue; } try { switch (format.type) { case 'bold': html += withBlocks && format.singleStar ? `${escapeHtml(text)}` : `${escapeHtml(text)}`; break; case 'italic': html += `${escapeHtml(text)}`; break; case 'strikeThrough': html += `${escapeHtml(text)}`; break; case 'snippet': html += `${escapeHtml(text)}`; break; case 'secret': html += `${escapeHtml(text)}`; break; case 'small': html += `${escapeHtml(text)}`; break; case 'colored': html += `${escapeHtml(text)}`; break; case 'uri': { let href = text.startsWith('http://') || text.startsWith('https://') || text.startsWith('simplex:/') ? text : 'https://' + text; html += `${escapeHtml(text)}`; break; } case 'hyperLink': { const { showText, linkUri } = format; html += `${escapeHtml(showText ?? linkUri)}`; break; } case 'simplexLink': { const { showText, linkType, simplexUri, smpHosts } = format; const linkText = showText ? escapeHtml(showText) : getSimplexLinkDescr(linkType); html += `${linkText} (${escapeHtml(viaHost(smpHosts))})`; break; } case 'command': html += `${escapeHtml(text)}`; break; case 'mention': html += `${escapeHtml(text)}`; break; case 'email': html += `${escapeHtml(text)}`; break; case 'phone': html += `${escapeHtml(text)}`; break; case 'escape': break; case 'markup': html += withBlocks ? markupHtml(format.token) : escapeHtml(text); break; case 'unknown': html += escapeHtml(text); break; default: html += escapeHtml(text); } } catch(e) { console.log(e); html += escapeHtml(text); } } return html; } const simplexAddressRegexp = /^simplex:\/([a-z]+)#(.+)/i; const simplexShortLinkTypes = ["a", "c", "g", "i", "r"]; function platformSimplexUri(uri) { if (isMobile.any()) return uri; const res = uri.match(simplexAddressRegexp); if (!res || !Array.isArray(res) || res.length < 3) return uri; const linkType = res[1]; const fragment = res[2]; if (simplexShortLinkTypes.includes(linkType)) { const queryIndex = fragment.indexOf('?'); if (queryIndex === -1) return uri; const hashPart = fragment.substring(0, queryIndex); const queryStr = fragment.substring(queryIndex + 1); const params = new URLSearchParams(queryStr); const host = params.get('h'); if (!host) return uri; params.delete('h'); let newFragment = hashPart; const remainingParams = params.toString(); if (remainingParams) newFragment += '?' + remainingParams; return `https://${host}:/${linkType}#${newFragment}`; } else { return `https://simplex.chat/${linkType}#${fragment}`; } }