const simplexDirectoryDataURL = 'https://directory.simplex.chat/data/'; // const simplexDirectoryDataURL = 'http://localhost:8080/directory-data/'; const simplexUsersGroup = 'SimpleX users group'; (function() { 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"' } function renderMarkdown(fts) { let html = ''; for (const ft of fts) { const { format, text } = ft; if (!format) { html += escapeHtml(text); continue; } try { switch (format.type) { case 'bold': html += `${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 '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}`; } } if (!document.location.pathname.startsWith('/directory')) return; let allEntries = []; let filteredEntries = []; let currentSortMode = ''; let currentSearch = ''; let currentPage = 1; async function initDirectory() { const listing = await fetchJSON(simplexDirectoryDataURL + 'listing.json') const liveBtn = document.querySelector('#top-pagination .live'); const newBtn = document.querySelector('#top-pagination .new'); const topBtn = document.querySelector('#top-pagination .top'); const searchInput = document.getElementById('search'); allEntries = listing.entries 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, comparator, btn, search = ''; switch (hash) { case '#active': mode = 'live'; comparator = byActiveAtDesc; btn = liveBtn; break; case '#new': mode = 'new'; comparator = byCreatedAtDesc; btn = newBtn; break; default: mode = 'top'; comparator = 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, comparator, btn, search); } function renderEntries(mode, comparator, btn, search = '') { if (currentSortMode === mode && search == currentSearch) return; currentSortMode = mode; const hash = search ? '#q=' + encodeURIComponent(search) : mode === 'live' ? '#active' : mode === 'new' ? '#new' : ''; const url = hash || (location.pathname + location.search); history.replaceState(null, '', url); liveBtn.classList.remove('active'); newBtn.classList.remove('active'); topBtn.classList.remove('active'); if (search == '') { currentSearch = ''; currentPage = 1; searchInput.value = ''; btn.classList.add('active'); } else { currentSearch = search; } filteredEntries = filterEntries(mode, search ?? '').sort(comparator); renderDirectoryPage(); } } function renderDirectoryPage() { const currentEntries = addPagination(filteredEntries); displayEntries(currentEntries); } function filterEntries(mode, s) { if (s === '' && mode == 'top') return allEntries.slice(); const query = s.toLowerCase(); return allEntries.filter(entry => ( mode === 'top' || (mode === 'new' && entry.createdAt) || (mode === 'live' && entry.activeAt) ) && ( query === '' || (entry.displayName || '').toLowerCase().includes(query) || includesQuery(entry.shortDescr, query) || includesQuery(entry.welcomeMessage, query) || simplexNameIncludesQuery(entry.simplexName, query) ) ); } function simplexNameIncludesQuery(name, query) { if (!name) return false; const q = normalizeSimplexName(query); return q !== '' && normalizeSimplexName(name).includes(q); } function normalizeSimplexName(s) { s = s.toLowerCase(); if (s.startsWith('#') || s.startsWith('@')) s = s.slice(1); if (s.endsWith('.simplex')) s = s.slice(0, -'.simplex'.length); return s; } function includesQuery(field, query) { return field && Array.isArray(field) && field.some(ft => { switch (ft.format?.type) { case 'uri': return uriIncludesQuery(ft.text, query); case 'hyperLink': return textIncludesQuery(ft.format.showText, query) || uriIncludesQuery(ft.format.linkUri, query); case 'simplexLink': return textIncludesQuery(ft.format.showText, query); default: return textIncludesQuery(ft.text, query); } }); } function textIncludesQuery(text, query) { return text ? text.toLowerCase().includes(query) : false } function uriIncludesQuery(uri, query) { if (!uri) return false; uri = uri.toLowerCase(); return !uri.includes('simplex') && uri.includes(query); } async function fetchJSON(url) { try { const response = await fetch(url) if (!response.ok) throw new Error(`HTTP status: ${response.status}`) return await response.json() } catch (e) { console.error(e) } } function bySortPriority(entry1, entry2) { return entrySortPriority(entry2) - entrySortPriority(entry1); } function byActiveAtDesc(entry1, entry2) { return (roundedTs(entry2.activeAt) - roundedTs(entry1.activeAt)) * 10 + Math.sign(bySortPriority(entry1, entry2)); } function byCreatedAtDesc(entry1, entry2) { return (roundedTs(entry2.createdAt) - roundedTs(entry1.createdAt)) * 10 + Math.sign(bySortPriority(entry1, entry2)); } function roundedTs(s) { try { return new Date(s).valueOf(); } catch { return 0; } } function entrySortPriority(entry) { return entry.displayName === simplexUsersGroup ? Number.MAX_VALUE : entryMemberCount(entry) } function entryMemberCount(entry) { return entry.entryType.type == 'group' ? (entry.entryType.summary?.publicMemberCount ?? entry.entryType.summary?.currentMembers ?? 0) : 0 } const now = new Date(); const nowVal = now.valueOf(); const today = new Date(now); today.setHours(0, 0, 0, 0); const todayVal = today.valueOf(); const todayYear = today.getFullYear(); const dateFormatter = Intl?.DateTimeFormat?.(undefined, {month: '2-digit', day: '2-digit'}); const dateYearFormatter = Intl?.DateTimeFormat?.(undefined, {year: 'numeric', month: '2-digit', day: '2-digit'}); function showDate(d) { return dateFormatter && d.getFullYear() == todayYear ? dateFormatter.format(d) : dateYearFormatter?.format(d) ?? d.toLocaleDateString(); } 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) const ago = nowVal - d.valueOf(); if (ago <= 1200000) return 'Active now'; // 20 minutes if (ago <= 10800000) return 'Active recently'; // 3 hours d.setHours(0, 0, 0, 0); return 'Active' + (d.valueOf() === todayVal ? ' today' : ' on ' + showDate(d)); } function displayEntries(entries) { const directory = document.getElementById('directory'); directory.innerHTML = ''; for (let entry of entries) { try { const { entryType, displayName, simplexName, groupLink, shortDescr, welcomeMessage, imageFile } = entry; const entryDiv = document.createElement('div'); entryDiv.className = 'entry w-full flex flex-col items-start md:flex-row rounded-[4px] overflow-hidden shadow-[0px_20px_30px_rgba(0,0,0,0.12)] dark:shadow-none bg-white dark:bg-[#0B2A59] mb-8'; const textContainer = document.createElement('div'); textContainer.className = 'text-container'; const nameElement = document.createElement('h2'); nameElement.textContent = displayName; nameElement.className = 'text-grey-black dark:text-white !text-lg md:!text-xl font-bold'; textContainer.appendChild(nameElement); if (simplexName) { const simplexNameElement = document.createElement('p'); simplexNameElement.textContent = simplexName; simplexNameElement.className = 'text-sm font-medium'; textContainer.appendChild(simplexNameElement); } const welcomeMessageHTML = welcomeMessage ? renderMarkdown(welcomeMessage) : undefined; const shortDescrHTML = shortDescr ? renderMarkdown(shortDescr) : undefined; if (shortDescrHTML && welcomeMessageHTML?.includes(shortDescrHTML) !== true) { const descrElement = document.createElement('p'); descrElement.innerHTML = renderMarkdown(shortDescr); textContainer.appendChild(descrElement); } if (welcomeMessageHTML) { const messageElement = document.createElement('p'); messageElement.innerHTML = welcomeMessageHTML; textContainer.appendChild(messageElement); const readMore = document.createElement('p'); readMore.textContent = 'Read more'; readMore.className = 'read-more'; readMore.style.display = 'none'; textContainer.appendChild(readMore); setTimeout(() => { const computedStyle = window.getComputedStyle(messageElement); const lineHeight = parseFloat(computedStyle.lineHeight); const maxLines = 5; const maxHeight = maxLines * lineHeight const maxHeightPx = `${maxHeight}px`; messageElement.style.maxHeight = maxHeightPx; messageElement.style.overflow = 'hidden'; if (messageElement.scrollHeight > maxHeight + 4) { readMore.style.display = 'block'; readMore.addEventListener('click', () => { if (messageElement.style.maxHeight === maxHeightPx) { messageElement.style.maxHeight = 'none'; readMore.className = 'read-less'; readMore.innerHTML = '▲'; } else { messageElement.style.maxHeight = maxHeightPx; readMore.className = 'read-more'; readMore.textContent = 'Read more'; } }); } }, 0); } if (entryType?.groupType) { const noteElement = document.createElement('p'); noteElement.innerHTML = 'You need SimpleX Chat app v6.5 to join.'; noteElement.className = 'text-sm'; textContainer.appendChild(noteElement); } const entryTimestamp = currentSortMode === 'new' && entry.createdAt ? showCreatedOn(entry.createdAt) : entry.activeAt ? showActiveOn(entry.activeAt) : ''; if (entryTimestamp) { timestampElement = document.createElement('p'); timestampElement.textContent = entryTimestamp; timestampElement.className = 'text-sm'; textContainer.appendChild(timestampElement); } const memberCount = entryMemberCount(entry); if (typeof memberCount == 'number' && memberCount > 0) { const memberCountElement = document.createElement('p'); const isChannel = entryType?.groupType === 'channel'; memberCountElement.textContent = `${memberCount} ${isChannel ? 'subscribers' : 'members'}`; memberCountElement.className = 'text-sm'; textContainer.appendChild(memberCountElement); } if (entryType?.admission?.review === "all") { const knockingElement = document.createElement('p'); knockingElement.textContent = 'New members are reviewed by admins'; knockingElement.className = 'text-sm'; textContainer.appendChild(knockingElement); } const imgLinkElement = document.createElement('a'); imgLinkElement.className = 'img-link'; const groupLinkUri = groupLink.connShortLink ?? groupLink.connFullLink try { imgLinkElement.href = platformSimplexUri(groupLinkUri); } catch(e) { console.log(e); imgLinkElement.href = groupLinkUri; } imgLinkElement.target = "_blank"; imgLinkElement.title = `Join ${displayName}`; const imgElement = document.createElement('img'); imgElement.src = imageFile ? simplexDirectoryDataURL + imageFile : '/img/group.svg'; imgElement.alt = displayName; imgElement.addEventListener('error', () => imgElement.src = '/img/group.svg'); imgLinkElement.appendChild(imgElement); entryDiv.appendChild(imgLinkElement); entryDiv.appendChild(textContainer); directory.appendChild(entryDiv); } catch (e) { console.log(e); } } for (let el of document.querySelectorAll('.secret')) { el.addEventListener('click', () => el.classList.toggle('visible')); } directory.style.height = ''; } function goToPage(p) { currentPage = p; renderDirectoryPage(); } function addPagination(entries) { const entriesPerPage = 10; const totalPages = Math.ceil(entries.length / entriesPerPage); if (currentPage < 1) currentPage = 1; if (currentPage > totalPages) currentPage = totalPages; const startIndex = (currentPage - 1) * entriesPerPage; const endIndex = Math.min(startIndex + entriesPerPage, entries.length); const currentEntries = entries.slice(startIndex, endIndex); // addPaginationElements('top-pagination') addPaginationElements('bottom-pagination') return currentEntries; function addPaginationElements(paginationId) { const pagination = document.getElementById(paginationId); if (!pagination) { return currentEntries; } pagination.innerHTML = ''; try { let startPage, endPage; const pageButtonCount = 8 if (totalPages <= pageButtonCount) { startPage = 1; endPage = totalPages; } else { startPage = Math.max(1, currentPage - 4); endPage = Math.min(totalPages, startPage + pageButtonCount - 1); if (endPage - startPage + 1 < pageButtonCount) { startPage = Math.max(1, endPage - pageButtonCount + 1); } } // if (currentPage > 1 && startPage > 1) { // const firstBtn = document.createElement('button'); // firstBtn.textContent = 'First'; // firstBtn.classList.add('text-btn'); // firstBtn.addEventListener('click', () => goToPage(1)); // pagination.appendChild(firstBtn); // } if (currentPage > 1) { const prevBtn = document.createElement('button'); prevBtn.textContent = 'Prev'; prevBtn.classList.add('text-btn'); prevBtn.addEventListener('click', () => goToPage(currentPage - 1)); pagination.appendChild(prevBtn); } for (let p = startPage; p <= endPage; p++) { const pageBtn = document.createElement('button'); pageBtn.textContent = p.toString(); if (p === currentPage) { pageBtn.classList.add('active'); } else if (p === currentPage - 1 || p === currentPage + 1 || (currentPage === 1 && p === 3) || (currentPage === totalPages && p === totalPages - 2)) { pageBtn.classList.add('neighbor'); } pageBtn.addEventListener('click', () => goToPage(p)); pagination.appendChild(pageBtn); } if (currentPage < totalPages) { const nextBtn = document.createElement('button'); nextBtn.textContent = 'Next'; nextBtn.classList.add('text-btn'); nextBtn.addEventListener('click', () => goToPage(currentPage + 1)); pagination.appendChild(nextBtn); } // if (endPage < totalPages) { // const lastBtn = document.createElement('button'); // lastBtn.textContent = 'Last'; // lastBtn.classList.add('text-btn'); // lastBtn.addEventListener('click', () => goToPage(totalPages)); // pagination.appendChild(lastBtn); // } } catch (e) { console.log(e); } } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initDirectory); } else { initDirectory(); } })();