mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-05-14 21:15:37 +00:00
website: directory page (#6283)
* website: directory page * core: use markdown in directory entries * render markdown on directory page * update markdown * toggle secrets on click * update listings asynchronously * add group links to the listing * cleanup * better directory layout with pagination * script to run website * update page navigation * search * readable markdown colors, better "read less" * core: atomic update of directory listings, to avoid files unavailable * fix symlink, sort entries on page with new first * update listings every 15 min, add activeAt time * fix sorting in the page and listing url * replace simplex:/ links on desktop
This commit is contained in:
@@ -0,0 +1,439 @@
|
||||
const directoryDataURL = 'https://directory.simplex.chat/data/';
|
||||
|
||||
// const directoryDataURL = 'http://localhost:8080/directory-data/';
|
||||
|
||||
let allEntries = [];
|
||||
|
||||
let sortedEntries = [];
|
||||
|
||||
let filteredEntries = [];
|
||||
|
||||
let currentSortMode = '';
|
||||
|
||||
async function initDirectory() {
|
||||
const listing = await fetchJSON(directoryDataURL + '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
|
||||
renderSortedEntries('top', byMemberCountDesc, topBtn)
|
||||
window.addEventListener('hashchange', renderDirectoryPage);
|
||||
searchInput.addEventListener('input', (e) => renderFilteredEntries(e.target.value));
|
||||
|
||||
liveBtn.addEventListener('click', () => renderSortedEntries('live', byActiveAtDesc, liveBtn));
|
||||
newBtn.addEventListener('click', () => renderSortedEntries('new', byCreatedAtDesc, newBtn));
|
||||
topBtn.addEventListener('click', () => renderSortedEntries('top', byMemberCountDesc, topBtn));
|
||||
|
||||
function renderSortedEntries(mode, comparator, btn) {
|
||||
if (currentSortMode === mode) return;
|
||||
currentSortMode = mode;
|
||||
if (location.hash) location.hash = '';
|
||||
liveBtn.classList.remove('active');
|
||||
newBtn.classList.remove('active');
|
||||
topBtn.classList.remove('active');
|
||||
btn.classList.add('active');
|
||||
sortedEntries = allEntries.slice().sort(comparator);
|
||||
renderFilteredEntries(searchInput.value);
|
||||
}
|
||||
}
|
||||
|
||||
function renderDirectoryPage() {
|
||||
const currentEntries = addPagination(filteredEntries);
|
||||
displayEntries(currentEntries);
|
||||
}
|
||||
|
||||
function renderFilteredEntries(s) {
|
||||
const query = s.toLowerCase().trim();
|
||||
if (query === '') {
|
||||
filteredEntries = sortedEntries.slice();
|
||||
} else {
|
||||
filteredEntries = sortedEntries.filter(entry =>
|
||||
(entry.displayName || '').toLowerCase().includes(query)
|
||||
|| includesQuery(entry.shortDescr, query)
|
||||
|| includesQuery(entry.welcomeMessage, query)
|
||||
);
|
||||
}
|
||||
renderDirectoryPage();
|
||||
}
|
||||
|
||||
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) {
|
||||
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 error! Status: ${response.status}`)
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error('Error fetching JSON:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function byMemberCountDesc(entry1, entry2) {
|
||||
return entryMemberCount(entry2) - entryMemberCount(entry1);
|
||||
}
|
||||
|
||||
function byActiveAtDesc(entry1, entry2) {
|
||||
return (roundedTs(entry2.activeAt) - roundedTs(entry1.activeAt)) * 10
|
||||
+ Math.sign(byMemberCountDesc(entry1, entry2));
|
||||
}
|
||||
|
||||
function byCreatedAtDesc(entry1, entry2) {
|
||||
return (roundedTs(entry2.createdAt) - roundedTs(entry1.createdAt)) * 10
|
||||
+ Math.sign(byMemberCountDesc(entry1, entry2));
|
||||
}
|
||||
|
||||
function roundedTs(s) {
|
||||
try {
|
||||
// rounded to 15 minutes, which is the frequency of listing update
|
||||
return Math.floor(new Date(s).valueOf() / 900000);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function entryMemberCount(entry) {
|
||||
return entry.entryType.type == 'group'
|
||||
? (entry.entryType.summary?.currentMembers ?? 0)
|
||||
: 0
|
||||
}
|
||||
|
||||
function displayEntries(entries) {
|
||||
const directory = document.getElementById('directory');
|
||||
directory.innerHTML = '';
|
||||
|
||||
for (let entry of entries) {
|
||||
try {
|
||||
const { entryType, displayName, 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-[#11182F] 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);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const memberCount = entryMemberCount(entry);
|
||||
if (typeof memberCount == 'number' && memberCount > 0) {
|
||||
const memberCountElement = document.createElement('p');
|
||||
memberCountElement.innerText = `${memberCount} members`;
|
||||
memberCountElement.classList = ['text-sm'];
|
||||
textContainer.appendChild(memberCountElement);
|
||||
}
|
||||
|
||||
const imgElement = document.createElement('a');
|
||||
imgSource =
|
||||
imageFile
|
||||
? directoryDataURL + imageFile
|
||||
: "/img/group.svg";
|
||||
imgElement.innerHTML = `<img src="${imgSource}" alt="${displayName}">`;
|
||||
imgElement.href = platformSimplexUri(groupLink.connShortLink ?? groupLink.connFullLink);
|
||||
if (!isCurrentSite(imgElement.href)) imgElement.target = "_blank";
|
||||
imgElement.title = `Join ${displayName}`;
|
||||
entryDiv.appendChild(imgElement);
|
||||
|
||||
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) {
|
||||
location.hash = p.toString();
|
||||
}
|
||||
|
||||
function addPagination(entries) {
|
||||
const entriesPerPage = 10;
|
||||
const totalPages = Math.ceil(entries.length / entriesPerPage);
|
||||
let currentPage = parseInt(location.hash.slice(1)) || 1;
|
||||
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) {
|
||||
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();
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
.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';
|
||||
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 platformSimplexUri(uri) {
|
||||
if (isMobile.any()) return uri;
|
||||
if (uri.startsWith('simplex:/g#')) {
|
||||
const prefixLength = 'simplex:/g#'.length;
|
||||
const fragment = uri.substring(prefixLength);
|
||||
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}:/g#${newFragment}`;
|
||||
} else if(uri.startsWith('simplex:/')) {
|
||||
const prefixLength = 'simplex:/'.length;
|
||||
return 'https://simplex.chat/' + uri.substring(prefixLength);
|
||||
} else {
|
||||
return uri;
|
||||
}
|
||||
}
|
||||
|
||||
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 += `<strong>${escapeHtml(text)}</strong>`;
|
||||
break;
|
||||
case 'italic':
|
||||
html += `<em>${escapeHtml(text)}</em>`;
|
||||
break;
|
||||
case 'strikeThrough':
|
||||
html += `<s>${escapeHtml(text)}</s>`;
|
||||
break;
|
||||
case 'snippet':
|
||||
html += `<span style="font-family: monospace;">${escapeHtml(text)}</span>`;
|
||||
break;
|
||||
case 'secret':
|
||||
html += `<span class="secret">${escapeHtml(text)}</span>`;
|
||||
break;
|
||||
case 'colored':
|
||||
html += `<span class="${format.color}">${escapeHtml(text)}</span>`;
|
||||
break;
|
||||
case 'uri':
|
||||
let href = text.startsWith('http://') || text.startsWith('https://') || text.startsWith('simplex:/') ? text : 'https://' + text;
|
||||
html += `<a href="${href}"${targetBlank(href)}>${escapeHtml(text)}</a>`;
|
||||
break;
|
||||
case 'hyperLink': {
|
||||
const { showText, linkUri } = format;
|
||||
html += `<a href="${linkUri}"${targetBlank(linkUri)}>${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)}">${linkText} <em>(${viaHost(smpHosts)})</em></a>`;
|
||||
break;
|
||||
}
|
||||
case 'command':
|
||||
html += `<span style="font-family: monospace;">${escapeHtml(text)}</span>`;
|
||||
break;
|
||||
case 'mention':
|
||||
html += `<strong>${escapeHtml(text)}</strong>`;
|
||||
break;
|
||||
case 'email':
|
||||
html += `<a href="mailto:${text}">${escapeHtml(text)}</a>`;
|
||||
break;
|
||||
case 'phone':
|
||||
html += `<a href="tel:${text}">${escapeHtml(text)}</a>`;
|
||||
break;
|
||||
case 'unknown':
|
||||
html += escapeHtml(text);
|
||||
break;
|
||||
default:
|
||||
html += escapeHtml(text);
|
||||
}
|
||||
} catch {
|
||||
html += escapeHtml(text);
|
||||
}
|
||||
}
|
||||
return html;
|
||||
}
|
||||
@@ -26,7 +26,8 @@ const uniqueSwiper = new Swiper('.unique-swiper', {
|
||||
|
||||
const isMobile = {
|
||||
Android: () => navigator.userAgent.match(/Android/i),
|
||||
iOS: () => navigator.userAgent.match(/iPhone|iPad|iPod/i)
|
||||
iOS: () => navigator.userAgent.match(/iPhone|iPad|iPod/i),
|
||||
any: () => navigator.userAgent.match(/Android|iPhone|iPad|iPod/i)
|
||||
};
|
||||
|
||||
const privateSwiper = new Swiper('.private-swiper', {
|
||||
|
||||
Reference in New Issue
Block a user