Files
simplex-manager/web/index.html
Jon 5c80ac310f Initial commit: bots, AI-parameterised support bot, web frontend
- simplex-deadmans-bot: Dead Man's Switch Haskell bot
- simplexxx-directory: private SimpleXXX directory bot (fork of simplex-directory-service)
- simplex-support-bot: support triage bot with configurable AI backend
  - --ai-url and --ai-model flags for any OpenAI-compatible provider
  - works with Grok, Ollama, OpenAI, LM Studio, etc.
  - AI_API_KEY env var (GROK_API_KEY still accepted as alias)
- web: SimpleXXX directory frontend (Groups/Channels tabs, matches simplex.chat/directory style)
- manager/: placeholder for Python profile manager (coming soon)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 00:39:08 +01:00

795 lines
27 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SimpleXXX Directory</title>
<meta name="description" content="Find communities on the SimpleXXX network">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%230053D0'/%3E%3Cg transform='translate(50,50) rotate(45)'%3E%3Crect x='-34' y='-9' width='68' height='18' fill='%2302C0FF'/%3E%3Crect x='-9' y='-34' width='18' height='68' fill='%2302C0FF'/%3E%3Crect x='-20' y='-5' width='40' height='10' fill='%230053D0'/%3E%3Crect x='-5' y='-20' width='10' height='40' fill='%230053D0'/%3E%3C/g%3E%3C/svg%3E">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f5f5f7;
--card-bg: #ffffff;
--text: #1d1d1f;
--muted: #6e6e73;
--accent: #0053D0;
--border: #e0e0e5;
--shadow: 0px 20px 30px rgba(0,0,0,0.12);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111827;
--card-bg: #0B2A59;
--text: #f5f5f7;
--muted: #9ca3af;
--accent: #70F0F9;
--border: #1e3a5f;
--shadow: none;
}
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
header {
background: var(--card-bg);
border-bottom: 1px solid var(--border);
padding: 14px 24px;
position: sticky;
top: 0;
z-index: 10;
}
.header-inner {
max-width: 860px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 10px;
}
.logo-text {
font-size: 18px;
font-weight: 700;
color: var(--accent);
letter-spacing: -0.5px;
}
.container {
max-width: 860px;
margin: 0 auto;
padding: 40px 20px 60px;
}
h1 {
font-size: clamp(28px, 5vw, 38px);
font-weight: 700;
text-align: center;
color: var(--accent);
margin-bottom: 20px;
}
/* ── Section tabs: Groups / Channels ── */
.section-tabs {
display: flex;
border-bottom: 2px solid var(--border);
margin-bottom: 20px;
}
.sec-btn {
padding: 10px 24px;
background: transparent;
border: none;
border-bottom: 3px solid transparent;
margin-bottom: -2px;
font-size: 15px;
font-weight: 600;
font-family: inherit;
color: var(--muted);
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.sec-btn:hover { color: var(--accent); }
.sec-btn.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.tab-count {
font-size: 11px;
font-weight: 600;
background: var(--border);
color: var(--muted);
border-radius: 10px;
padding: 1px 6px;
margin-left: 6px;
}
.sec-btn.active .tab-count {
background: var(--accent);
color: #fff;
}
@media (prefers-color-scheme: dark) {
.sec-btn.active .tab-count { color: #000; }
}
/* ── Search + sort controls (inline, matching original) ── */
.search-container {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
#search {
flex-grow: 1;
padding: 8px 12px 8px 36px;
font-size: 15px;
font-family: inherit;
border: none;
border-radius: 10px;
background: var(--card-bg);
color: var(--text);
outline: solid 1px rgba(0,0,0,0.08);
transition: box-shadow 0.2s;
background-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGcgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZT0iIzg4ODg4OCI+CjxjaXJjbGUgY3g9IjEwLjUiIGN5PSIxMC41IiByPSI3LjUiIC8+CjxsaW5lIHgxPSIxNiIgeTE9IjE2IiB4Mj0iMjEiIHkyPSIyMSIgLz4KPC9nPgo8L3N2Zz4=');
background-position: 8px center;
background-repeat: no-repeat;
background-size: 18px;
}
#search::placeholder { color: #8e8e93; }
@media (prefers-color-scheme: dark) {
#search {
background-color: #0B2A50;
color: #fff;
outline: none;
background-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGcgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZT0iI2JiYmJiYiI+CjxjaXJjbGUgY3g9IjEwLjUiIGN5PSIxMC41IiByPSI3LjUiIC8+CjxsaW5lIHgxPSIxNiIgeTE9IjE2IiB4Mj0iMjEiIHkyPSIyMSIgLz4KPC9nPgo8L3N2Zz4=');
}
#search::placeholder { color: #8e8e93; }
}
/* Sort buttons styled like the original pagination text-btns */
.sort-tabs {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.sort-tabs button {
padding: 8px 16px;
border: none;
background: transparent;
color: #374151;
cursor: pointer;
border-radius: 20px;
font-size: 14px;
font-family: inherit;
height: 40px;
display: flex;
align-items: center;
transition: background 0.2s;
}
.sort-tabs button:hover { background: #f3f4f6; }
@media (prefers-color-scheme: dark) {
.sort-tabs button { color: #70F0F9; }
.sort-tabs button:hover { background: #0B2A50; }
}
.sort-tabs button.active { font-weight: bold; }
/* ── Entry cards — matching original exactly ── */
#directory .entry {
display: flex;
align-items: flex-start;
margin-bottom: 32px;
padding: 16px;
word-break: break-word;
border-radius: 4px;
overflow: hidden;
box-shadow: var(--shadow);
background: var(--card-bg);
}
#directory .entry a.img-link {
order: -1;
flex-shrink: 0;
margin-right: 16px;
margin-bottom: 16px;
}
#directory .entry a.img-link img {
min-width: 104px;
min-height: 104px;
width: 104px;
height: 104px;
border-radius: 24px;
object-fit: cover;
display: block;
}
#directory .entry .text-container { flex: 1; min-width: 0; }
#directory .entry h2 {
font-size: 18px;
font-weight: 700;
margin: 0 0 5px 0;
}
#directory .entry p {
font-size: 14px;
line-height: 1.55;
margin: 0 0 5px 0;
color: var(--muted);
}
#directory .entry p a { color: var(--accent); }
#directory .entry .secret { filter: blur(5px); cursor: pointer; transition: filter 0.1s; user-select: none; }
#directory .entry .secret.visible { filter: none; user-select: auto; }
#directory .entry .read-more { color: #0053D0; text-decoration: underline; cursor: pointer; }
#directory .entry .read-less { color: darkgray; cursor: pointer; }
@media (prefers-color-scheme: dark) {
#directory .entry .read-more { color: #70F0F9; }
}
#directory .entry .small-text { font-size: 0.8em; color: #888; }
@media (prefers-color-scheme: dark) { #directory .entry .small-text { color: #999; } }
#directory .entry .red { color: #DD0000; }
#directory .entry .green { color: #20BD3D; }
#directory .entry .blue { color: #0053d0; }
#directory .entry .cyan { color: #0AC4D1; }
#directory .entry .yellow { color: #DEBD00; }
#directory .entry .magenta { color: magenta; }
@media (prefers-color-scheme: dark) {
#directory .entry .green { color: #4DDA67; }
#directory .entry .blue { color: #00A2FF; }
#directory .entry .cyan { color: #70F0F9; }
#directory .entry .yellow { color: #FFD700; }
}
#status {
text-align: center;
color: var(--muted);
font-size: 13px;
padding: 12px;
}
/* ── Pagination — matching original ── */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 4px;
margin-top: 20px;
padding: 10px 0;
}
.pagination button {
padding: 8px 12px;
border: none;
background: transparent;
color: #374151;
cursor: pointer;
border-radius: 50%;
font-size: 14px;
font-family: inherit;
min-width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.pagination button:hover { background: #f3f4f6; }
@media (prefers-color-scheme: dark) {
.pagination button { color: #70F0F9; }
.pagination button:hover { background: #0B2A50; }
}
.pagination button.active { font-weight: bold; color: #0B2A59; }
@media (prefers-color-scheme: dark) {
.pagination button.active { color: #70F0F9; }
}
.pagination button.text-btn {
border-radius: 20px;
min-width: auto;
height: 40px;
padding: 8px 16px;
}
.pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
@media (max-width: 640px) {
#directory .entry { flex-direction: column; }
#directory .entry a.img-link { margin-right: 0; }
#directory .entry a.img-link img { width: 72px; height: 72px; min-width: 72px; min-height: 72px; border-radius: 16px; }
.search-container { flex-direction: column; align-items: stretch; }
.sort-tabs { justify-content: center; }
}
</style>
</head>
<body>
<header>
<div class="header-inner">
<span class="logo-text">SimpleXXX Directory</span>
</div>
</header>
<div class="container">
<h1>SimpleXXX Directory</h1>
<!-- Groups / Channels tabs -->
<div class="section-tabs">
<button class="sec-btn active" data-section="group">
Groups <span class="tab-count" id="count-group">0</span>
</button>
<button class="sec-btn" data-section="channel">
Channels <span class="tab-count" id="count-channel">0</span>
</button>
</div>
<!-- Search + sort inline -->
<div class="search-container">
<input id="search" autocomplete="off" placeholder="Search…">
<div class="sort-tabs">
<button class="live">Active</button>
<button class="new">New</button>
<button class="top">All</button>
</div>
</div>
<div id="status"></div>
<div id="directory"></div>
<div id="bottom-pagination" class="pagination"></div>
</div>
<script>
(function () {
const DATA_URL = './data/';
let allEntries = [];
let sectionEntries = [];
let filteredEntries = [];
let currentSection = 'group';
let currentSortMode = '';
let currentSearch = '';
let currentPage = 1;
async function init() {
const listing = await fetchJSON(DATA_URL + 'listing.json');
if (!listing) {
document.getElementById('status').textContent = 'Failed to load directory data.';
return;
}
allEntries = listing.entries || [];
const groupCount = allEntries.filter(e => entrySection(e) === 'group').length;
const channelCount = allEntries.filter(e => entrySection(e) === 'channel').length;
document.getElementById('count-group').textContent = groupCount;
document.getElementById('count-channel').textContent = channelCount;
document.querySelectorAll('.sec-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.sec-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentSection = btn.dataset.section;
currentSortMode = ''; currentSearch = ''; currentPage = 1;
document.getElementById('search').value = '';
topBtn.click();
});
});
const liveBtn = document.querySelector('.sort-tabs .live');
const newBtn = document.querySelector('.sort-tabs .new');
const topBtn = document.querySelector('.sort-tabs .top');
const searchInput = document.getElementById('search');
applyHash();
searchInput.addEventListener('input', e =>
renderEntries('top', bySortPriority, topBtn, e.target.value.trim(), true));
liveBtn.addEventListener('click', () => renderEntries('live', byActiveAtDesc, liveBtn));
newBtn.addEventListener('click', () => renderEntries('new', byCreatedAtDesc, newBtn));
topBtn.addEventListener('click', () => renderEntries('top', bySortPriority, topBtn));
window.addEventListener('popstate', applyHash);
function applyHash() {
const hash = location.hash;
let mode, cmp, btn, search = '';
switch (hash) {
case '#active': mode='live'; cmp=byActiveAtDesc; btn=liveBtn; break;
case '#new': mode='new'; cmp=byCreatedAtDesc; btn=newBtn; break;
default:
mode='top'; cmp=bySortPriority; btn=topBtn;
try {
if (hash.startsWith('#q=')) {
search = decodeURIComponent(hash.slice(3));
if (search) searchInput.value = search;
}
} catch(e) {}
}
currentSortMode = ''; currentSearch = ''; currentPage = 1;
renderEntries(mode, cmp, btn, search);
}
function renderEntries(mode, cmp, btn, search = '', fromInput = false) {
if (currentSortMode === mode && search === currentSearch && !fromInput) return;
currentSortMode = mode;
const hash = search ? '#q=' + encodeURIComponent(search)
: mode === 'live' ? '#active'
: mode === 'new' ? '#new' : '';
history.replaceState(null, '', hash || location.pathname + location.search);
document.querySelectorAll('.sort-tabs button').forEach(b => b.classList.remove('active'));
if (!search) {
currentSearch = ''; currentPage = 1;
searchInput.value = '';
btn.classList.add('active');
} else {
currentSearch = search; currentPage = 1;
}
sectionEntries = allEntries.filter(e => entrySection(e) === currentSection);
filteredEntries = filterEntries(sectionEntries, mode, search).sort(cmp);
renderPage();
}
}
function entrySection(e) {
const t = e.entryType;
if (!t) return 'group';
if (t.type === 'channel') return 'channel';
if (t.groupType === 'channel') return 'channel';
return 'group';
}
function renderPage() {
const entries = addPagination(filteredEntries);
displayEntries(entries);
}
function filterEntries(entries, mode, s) {
const q = s.toLowerCase();
return entries.filter(e =>
(mode === 'top' || (mode === 'new' && e.createdAt) || (mode === 'live' && e.activeAt)) &&
(q === '' ||
(e.displayName || '').toLowerCase().includes(q) ||
includesQuery(e.shortDescr, q) ||
includesQuery(e.welcomeMessage, q))
);
}
function includesQuery(field, q) {
if (!field || !Array.isArray(field)) return false;
return field.some(ft => {
switch (ft.format?.type) {
case 'uri': return !ft.text?.toLowerCase().includes('simplex') && ft.text?.toLowerCase().includes(q);
case 'hyperLink': return ft.format.showText?.toLowerCase().includes(q) || ft.format.linkUri?.toLowerCase().includes(q);
case 'simplexLink': return ft.format.showText?.toLowerCase().includes(q);
default: return ft.text?.toLowerCase().includes(q);
}
});
}
function entryMemberCount(e) {
return (e.entryType?.type === 'group' || e.entryType?.groupType)
? (e.entryType.summary?.publicMemberCount ?? e.entryType.summary?.currentMembers ?? 0)
: 0;
}
function bySortPriority(a, b) { return entryMemberCount(b) - entryMemberCount(a); }
function roundedTs(s) { try { return new Date(s).valueOf(); } catch { return 0; } }
function byActiveAtDesc(a, b) { return (roundedTs(b.activeAt) - roundedTs(a.activeAt)) * 10 + Math.sign(bySortPriority(a, b)); }
function byCreatedAtDesc(a, b) { return (roundedTs(b.createdAt) - roundedTs(a.createdAt)) * 10 + Math.sign(bySortPriority(a, b)); }
const now = new Date(), nowVal = now.valueOf();
const today = new Date(now); today.setHours(0,0,0,0);
const todayVal = today.valueOf(), todayYear = today.getFullYear();
const dateFmt = new Intl.DateTimeFormat(undefined, {month:'2-digit', day:'2-digit'});
const dateYearFmt = new Intl.DateTimeFormat(undefined, {year:'numeric', month:'2-digit', day:'2-digit'});
function showDate(d) { return d.getFullYear() === todayYear ? dateFmt.format(d) : dateYearFmt.format(d); }
function showCreatedOn(s) { const d = new Date(s); d.setHours(0,0,0,0); return 'Created' + (d.valueOf() === todayVal ? ' today' : ' on ' + showDate(d)); }
function showActiveOn(s) {
const d = new Date(s), ago = nowVal - d.valueOf();
if (ago <= 1200000) return 'Active now';
if (ago <= 10800000) return 'Active recently';
d.setHours(0,0,0,0);
return 'Active' + (d.valueOf() === todayVal ? ' today' : ' on ' + showDate(d));
}
function displayEntries(entries) {
const dir = document.getElementById('directory');
const status = document.getElementById('status');
dir.innerHTML = '';
const label = currentSection === 'channel' ? 'channels' : 'groups';
if (!filteredEntries.length) {
status.textContent = sectionEntries.length ? `No ${label} match your search.` : `No ${label} listed yet.`;
return;
}
status.textContent = '';
for (const entry of entries) {
try {
const { entryType, displayName, groupLink, shortDescr, welcomeMessage, imageFile, activeAt, createdAt } = entry;
const isChannel = entryType?.groupType === 'channel';
const entryDiv = document.createElement('div');
entryDiv.className = 'entry';
// Image link (left)
const imgLink = document.createElement('a');
imgLink.className = 'img-link';
const uri = groupLink?.connShortLink ?? groupLink?.connFullLink ?? '#';
try { imgLink.href = platformSimplexUri(uri); } catch(e) { imgLink.href = uri; }
imgLink.target = '_blank';
imgLink.title = `Join ${displayName}`;
const img = document.createElement('img');
img.src = imageFile ? DATA_URL + imageFile : fallbackSvg;
img.alt = displayName;
img.addEventListener('error', () => { img.src = fallbackSvg; });
imgLink.appendChild(img);
entryDiv.appendChild(imgLink);
// Text container (right)
const textContainer = document.createElement('div');
textContainer.className = 'text-container';
const nameEl = document.createElement('h2');
nameEl.textContent = displayName;
textContainer.appendChild(nameEl);
const welcomeMessageHTML = welcomeMessage ? renderMarkdown(welcomeMessage) : undefined;
const shortDescrHTML = shortDescr ? renderMarkdown(shortDescr) : undefined;
if (shortDescrHTML && welcomeMessageHTML?.includes(shortDescrHTML) !== true) {
const p = document.createElement('p');
p.innerHTML = shortDescrHTML;
textContainer.appendChild(p);
}
if (welcomeMessageHTML) {
const msgEl = document.createElement('p');
msgEl.innerHTML = welcomeMessageHTML;
textContainer.appendChild(msgEl);
const readMore = document.createElement('p');
readMore.textContent = 'Read more';
readMore.className = 'read-more';
readMore.style.display = 'none';
textContainer.appendChild(readMore);
setTimeout(() => {
const lh = parseFloat(getComputedStyle(msgEl).lineHeight);
const maxH = 5 * lh;
const maxHpx = maxH + 'px';
msgEl.style.maxHeight = maxHpx;
msgEl.style.overflow = 'hidden';
if (msgEl.scrollHeight > maxH + 4) {
readMore.style.display = 'block';
readMore.addEventListener('click', () => {
if (msgEl.style.maxHeight === maxHpx) {
msgEl.style.maxHeight = 'none';
readMore.className = 'read-less';
readMore.innerHTML = '&#9650;';
} else {
msgEl.style.maxHeight = maxHpx;
readMore.className = 'read-more';
readMore.textContent = 'Read more';
}
});
}
}, 0);
}
// "Requires v6.5" note for relay-based entries (groupType present)
if (entryType?.groupType) {
const noteEl = document.createElement('p');
noteEl.innerHTML = 'You need <a href="https://simplex.chat/downloads/" target="_blank">SimpleX Chat app v6.5</a> to join.';
noteEl.className = 'small-text';
textContainer.appendChild(noteEl);
}
const ts = currentSortMode === 'new' && createdAt
? showCreatedOn(createdAt)
: activeAt ? showActiveOn(activeAt) : '';
if (ts) {
const p = document.createElement('p');
p.textContent = ts;
p.className = 'small-text';
textContainer.appendChild(p);
}
const mc = entryMemberCount(entry);
if (mc > 0) {
const p = document.createElement('p');
p.textContent = `${mc} ${isChannel ? 'subscribers' : 'members'}`;
p.className = 'small-text';
textContainer.appendChild(p);
}
if (entryType?.admission?.review === 'all') {
const p = document.createElement('p');
p.textContent = 'New members are reviewed by admins';
p.className = 'small-text';
textContainer.appendChild(p);
}
entryDiv.appendChild(textContainer);
dir.appendChild(entryDiv);
} catch(e) { console.error(e); }
}
for (const el of document.querySelectorAll('.secret')) {
el.addEventListener('click', () => el.classList.toggle('visible'));
}
}
function addPagination(entries) {
const perPage = 10;
const total = Math.ceil(entries.length / perPage);
if (currentPage < 1) currentPage = 1;
if (currentPage > total) currentPage = Math.max(1, total);
const slice = entries.slice((currentPage - 1) * perPage, currentPage * perPage);
const pg = document.getElementById('bottom-pagination');
pg.innerHTML = '';
if (total <= 1) return slice;
const count = 8;
let s = Math.max(1, currentPage - 4);
let e = Math.min(total, s + count - 1);
if (e - s + 1 < count) s = Math.max(1, e - count + 1);
function mkBtn(label, page, cls) {
const b = document.createElement('button');
b.textContent = label;
if (cls) b.className = cls;
if (page === currentPage) b.classList.add('active');
else if (page === currentPage - 1 || page === currentPage + 1) b.classList.add('neighbor');
b.addEventListener('click', () => { currentPage = page; renderPage(); window.scrollTo(0, 0); });
return b;
}
if (currentPage > 1) pg.appendChild(mkBtn('Prev', currentPage - 1, 'text-btn'));
for (let p = s; p <= e; p++) pg.appendChild(mkBtn(p, p));
if (currentPage < total) pg.appendChild(mkBtn('Next', currentPage + 1, 'text-btn'));
return slice;
}
async function fetchJSON(url) {
try {
const r = await fetch(url);
if (!r.ok) throw new Error('HTTP ' + r.status);
return await r.json();
} catch(e) {
console.error('fetchJSON', url, e);
return null;
}
}
function escapeHtml(t) {
return (t ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/'/g,'&#039;').replace(/\n/g,'<br>');
}
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';
default: return 'SimpleX link';
}
}
function viaHost(smpHosts) { return `via ${smpHosts?.[0] ?? '?'}`; }
function isCurrentSite(uri) {
return uri.startsWith('https://simplex.chat') || uri.startsWith('https://www.simplex.chat');
}
function renderMarkdown(fts) {
if (!fts) return '';
let html = '';
for (const { format, text } of fts) {
if (!format) { html += escapeHtml(text); continue; }
try {
switch (format.type) {
case 'bold': html += `<strong>${escapeHtml(text)}</strong>`; break;
case 'italic': html += `<em>${escapeHtml(text)}</em>`; break;
case 'strikeThrough': html += `<s>${escapeHtml(text)}</s>`; break;
case 'snippet':
case 'command': html += `<span style="font-family:monospace">${escapeHtml(text)}</span>`; break;
case 'secret': html += `<span class="secret">${escapeHtml(text)}</span>`; break;
case 'small': html += `<span class="small-text">${escapeHtml(text)}</span>`; break;
case 'colored': html += `<span class="${escapeHtml(format.color)}">${escapeHtml(text)}</span>`; break;
case 'uri': {
const href = /^https?:|^simplex:/.test(text) ? text : 'https://' + text;
const tb = isCurrentSite(href) ? '' : ' target="_blank"';
html += `<a href="${href}"${tb}>${escapeHtml(text)}</a>`; break;
}
case 'hyperLink': {
const { showText, linkUri } = format;
const tb = isCurrentSite(linkUri) ? '' : ' target="_blank"';
html += `<a href="${linkUri}"${tb}>${escapeHtml(showText ?? linkUri)}</a>`; break;
}
case 'simplexLink': {
const { showText, linkType, simplexUri, smpHosts } = format;
const linkText = showText ? escapeHtml(showText) : getSimplexLinkDescr(linkType);
html += `<a href="${platformSimplexUri(simplexUri)}" target="_blank">${linkText} <em>(${viaHost(smpHosts)})</em></a>`; break;
}
case 'mention': html += `<strong>${escapeHtml(text)}</strong>`; break;
case 'email': html += `<a href="mailto:${escapeHtml(text)}">${escapeHtml(text)}</a>`; break;
case 'phone': html += `<a href="tel:${escapeHtml(text)}">${escapeHtml(text)}</a>`; break;
default: html += escapeHtml(text);
}
} catch(e) { html += escapeHtml(text); }
}
return html;
}
const simplexRe = /^simplex:\/([a-z]+)#(.+)/i;
const shortTypes = ['a','c','g','i','r'];
function platformSimplexUri(uri) {
if (!uri) return '#';
if (/Android|iPhone|iPad|iPod/i.test(navigator.userAgent)) return uri;
const m = uri.match(simplexRe);
if (!m) return uri;
const [, type, frag] = m;
if (shortTypes.includes(type)) {
const qi = frag.indexOf('?');
if (qi === -1) return uri;
const hash = frag.slice(0, qi);
const params = new URLSearchParams(frag.slice(qi + 1));
const host = params.get('h');
if (!host) return uri;
params.delete('h');
const rest = params.toString();
return `https://${host}:/${type}#${hash}${rest ? '?' + rest : ''}`;
}
return `https://simplex.chat/${type}#${frag}`;
}
const fallbackSvg = 'data:image/svg+xml,' + encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 104 104">` +
`<rect width="104" height="104" rx="24" fill="#e8eaf0"/>` +
`<circle cx="52" cy="40" r="16" fill="#bbbcc8"/>` +
`<path d="M20 80c0-17.7 14.3-32 32-32s32 14.3 32 32" fill="#bbbcc8"/>` +
`</svg>`
);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
</script>
</body>
</html>