You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sessioncommunities.online/output/main.js

699 lines
20 KiB
JavaScript

2 years ago
// Hello reader!
// This project can be found at:
// https://codeberg.com/gravel/sessioncommunities.online
2 years ago
/**
* This JavaScript file uses the JSDoc commenting style.
* Learn more: https://jsdoc.app/
*/
2 years ago
// Nudge TypeScript plugins to type-check using JSDoc comments.
// @ts-check
2 years ago
// Early prevention for bugs introduced by lazy coding.
'use strict';
// Import magic numbers and data
import {
2 years ago
dom, COLUMN, COLUMN_LITERAL, COMPARISON, ATTRIBUTES,
columnAscendingByDefault, columnIsSortable, COLUMN_TRANSFORMATION,
element, JOIN_URL_PASTE, communityQRCodeURL, STAFF_ID_PASTE, IDENTIFIER_PASTE, DETAILS_LINK_PASTE, CLASSES, flagToLanguageAscii
2 years ago
} from './js/constants.js';
// Hidden communities for transparency.
const filteredCommunities = {
tests: [
"fishing+8e2e", // Example group from PySOGS documentation
"test+118d", // Testing 1, 2, 3
"test+13f6", // Testing room2
2 years ago
"test+fe93", // 测试Test)
"xyz+7908", // XYZ Room
2 years ago
],
offensive: [
"aiunlimited+fc30", // illegal material
"AlexMed+e093", // drug trading?
"gore+e5e0", // illegal material
"internet+70d0", // illegal activity
"k9training+fdcb", // illegal material
"dogmen+fdcb", // illegal material
"RU-STEROID+e093", // drug trading?
"thestart+e4b1", // drug trading
"deutschclub+e4b1", // drug trading?
"cocaine+e4b1", // drug trading
"chigua+4567", // illegal material
"A4hanguo+4567", // illegal material
2 years ago
],
};
/**
* Hanging reference to preloaded images to avoid garbage collection.
*/
let preloadedImages = [];
1 year ago
/**
* Create an interactive version of the Community join link.
1 year ago
* @param {string} join_link
1 year ago
* @returns {HTMLElement}
*/
const transformJoinURL = (join_link) => {
return element.button({
textContent: "Copy",
className: "copy_button",
title: "Click here to copy the join URL",
onclick: () => copyToClipboard(join_link)
});
}
2 years ago
1 year ago
/**
* Fetches the last modification timestamp from the DOM.
* @returns {?number}
*/
function getTimestamp() {
const timestampRaw = dom.meta_timestamp()
?.getAttribute('content');
if (!timestampRaw) return null;
const timestamp = parseInt(timestampRaw);
if (Number.isNaN(timestamp)) return null;
return timestamp;
}
/**
* Processes initial URL hash and parameter to trigger actions on the page.
*/
function reactToURLParameters() {
const hash = location.hash;
if (hash == "") return;
const communityID = hash.slice(1);
const row = dom.community_row(communityID);
if (row == null || !(row instanceof HTMLTableRowElement)) {
return;
}
// manual scrolling to prevent jumping after every modal open
row.scrollIntoView({
behavior: "smooth"
});
try {
displayQRModal(communityID);
} catch (e) {
console.error("Could not navigate to community " + communityID);
console.error(e);
}
}
function addInformativeInteractions() {
const moreSitesInfoButton = document.getElementById('more-sites-info-button');
moreSitesInfoButton?.addEventListener('click', () => {
alert(
`Lokinet Gitea and session.directory compile lists of
Session Closed Groups and Communities, and are linked
in recognition of their importance.
However, sessioncommunities.online already includes Communities
from these sources on this page.
`.replace(/\s+/g, " ").trim()
);
});
}
1 year ago
/**
* Triggers all actions dependent on page load.
*/
function onLoad() {
const timestamp = getTimestamp();
if (timestamp !== null) {
setLastChecked(timestamp);
}
2 years ago
hideBadCommunities();
// Sort by server to show off new feature & align colors.
sortTable(COLUMN.SERVER_ICON);
12 months ago
initializeSearch();
2 years ago
createJoinLinkButtons();
markSortableColumns();
addQRModalHandlers();
addServerIconInteractions();
12 months ago
addSearchInteractions();
preloadImages();
setInterval(() => {
preloadImages();
}, 60 * 60E3);
reactToURLParameters();
addInformativeInteractions();
12 months ago
Array.from(document.querySelectorAll('.enter-clicks')).forEach(element => {
// @ts-ignore
element.addEventListener('keydown', (/** @type {KeyboardEvent} */ ev) => {
if (ev.key == "Enter") {
ev.currentTarget.click();
}
})
})
2 years ago
}
1 year ago
/**
* Construct room tag DOM from its description.
* @param {Object} param0
* @param {string} param0.text Tag name
* @param {"user"|"reserved"} param0.type Tag classification
* @param {string} param0.description Tag details
* @returns HTMLElement
*/
const tagBody = ({text, type, description}) => element.span({
// todo: truncate
textContent: text,
className: `room-label room-label-${type} badge`,
title: description
});
1 year ago
/**
* Shows the details modal hydrated with the given community's details.
1 year ago
* @param {string} communityID
1 year ago
* @param {number} pane Pane number to display in modal
*/
function displayQRModal(communityID, pane = 0) {
const modal = dom.details_modal();
if (!modal) {
throw new DOMException("Modal element not found.");
}
const row = dom.community_row(communityID);
if (!row) {
throw new DOMException("Community row not found.");
}
const rowInfo = dom.row_info(row);
for (const element of modal.querySelectorAll(`[${ATTRIBUTES.HYDRATION.CONTENT}]`)) {
const attributes = element.getAttribute(ATTRIBUTES.HYDRATION.CONTENT);
if (!attributes) continue;
for (const attribute of attributes.split(';')) {
const [property, targetProperty] = attribute.includes(':')
? attribute.split(":")
: [attribute, 'textContent'];
if (!Object.getOwnPropertyNames(rowInfo).includes(property)) {
console.error(`Unknown rowInfo property: ${property}`);
continue;
}
if (targetProperty === 'textContent') {
element.textContent = rowInfo[property];
} else {
element.setAttribute(targetProperty, rowInfo[property]);
}
}
}
const tagContainer = dom.details_modal_tag_container();
tagContainer.innerHTML = "";
tagContainer.append(
12 months ago
...rowInfo.tags.map(tag => tagBody(tag))
);
dom.details_modal_qr_code().src = communityQRCodeURL(communityID);
document.getElementById('details-modal-panes').setAttribute('data-pane', pane);
location.hash=`#${communityID}`;
modal.showModal();
2 years ago
}
1 year ago
/**
* Hides the Community details modal.
*/
function hideQRModal() {
dom.details_modal().close();
2 years ago
}
1 year ago
/**
* Adds handlers for details modal-related actions.
*/
function addQRModalHandlers() {
const rows = dom.tbl_communities_content_rows();
if (!rows) throw new Error("Rows not found");
for (const row of rows) {
const communityID = row.getAttribute(ATTRIBUTES.ROW.IDENTIFIER);
for (const cell of ['.td_qr_code', '.td_description', '.td_language', '.td_users']) {
row.querySelector(cell).addEventListener(
'click',
() => displayQRModal(communityID, cell == '.td_qr_code' ? 1 : 0)
);
}
row.addEventListener(
'click',
(e) => {
if (e.target != row) { return; }
displayQRModal(communityID);
}
)
row.querySelector('.td_name').addEventListener(
'click',
(e) => {
e.preventDefault();
displayQRModal(communityID);
}
);
}
const closeButton =
dom.details_modal().querySelector('#details-modal-close');
closeButton.addEventListener(
'click',
() => hideQRModal()
);
dom.details_modal().addEventListener('click', function (e) {
if (this == e.target) {
this.close();
}
});
for (const button of document.querySelectorAll('.details-modal-pane-button')) {
button.addEventListener(
'click',
function () {
const targetPane = this.getAttribute('data-pane');
document.getElementById('details-modal-panes')?.setAttribute('data-pane', targetPane);
}
)
}
document.querySelector('#details-modal-copy-button').addEventListener(
'click',
function () {
copyToClipboard(this.getAttribute('data-href'));
}
)
document.querySelector('#details-modal-copy-staff-id')?.addEventListener(
'click',
function () {
/**
* @type {string[]}
*/
const staff = JSON.parse(this.getAttribute(ATTRIBUTES.ROW.STAFF_DATA));
if (staff.length == 0) {
alert("No public moderators available for this Community.");
return;
}
const staffId = staff[~~(staff.length * Math.random())];
copyToClipboard(`@${staffId}`, STAFF_ID_PASTE);
}
)
document.querySelector('#details-modal-copy-room-id')?.addEventListener(
'click',
function () {
const identifier = this.getAttribute(ATTRIBUTES.ROW.IDENTIFIER);
copyToClipboard(identifier, IDENTIFIER_PASTE);
}
)
document.querySelector('#details-modal-copy-room-details-link')?.addEventListener(
'click',
function() {
copyToClipboard(location.href, DETAILS_LINK_PASTE);
}
)
for (const anchor of dom.qr_code_buttons()) {
// Disable QR code links
anchor.setAttribute("href", "#");
anchor.removeAttribute("target");
anchor.addEventListener('click', (e) => { e.preventDefault(); return false });
}
}
1 year ago
/**
* Prefetches images used in the page to prevent tracking.
*/
function preloadImages() {
const preloadedImagesNew = [];
const rows = dom.tbl_communities_content_rows();
const identifiers = rows.map(
rowElement => rowElement.getAttribute(ATTRIBUTES.ROW.IDENTIFIER)
);
const icons = rows.map(
rowElement => rowElement.getAttribute(ATTRIBUTES.ROW.ROOM_ICON)
)
for (const identifier of identifiers) {
const image = new Image();
image.src = communityQRCodeURL(identifier);
preloadedImages.push(image);
}
for (const icon of icons) {
if (!icon) {
continue;
}
const image = new Image();
image.src = icon;
preloadedImagesNew.push(image);
}
preloadedImages = preloadedImagesNew;
}
1 year ago
/**
* Places join link buttons in the Community rows.
*/
2 years ago
function createJoinLinkButtons() {
const join_URLs = dom.join_urls();
Array.from(join_URLs).forEach((td_url) => {
// Data attributes are more idiomatic and harder to change by accident in the DOM.
const join_link = td_url.getAttribute('data-url');
td_url.append(transformJoinURL(join_link)); // add interactive content
2 years ago
});
}
1 year ago
/**
1 year ago
* Hides rows of communities deemed to be superfluous or unsuitable.
1 year ago
*/
2 years ago
function hideBadCommunities() {
let numberOfHiddenCommunities = 0;
for (const category of ['tests', 'offensive']) {
numberOfHiddenCommunities +=
filteredCommunities[category]
.map(hideCommunity)
.reduce((a, b) => a + b);
2 years ago
}
const summary = dom.servers_hidden();
summary.innerText = `(${numberOfHiddenCommunities} hidden)`;
2 years ago
}
/**
* Removes a Community by its ID and returns the number of elements removed.
*/
function hideCommunity(communityID) {
const element = dom.community_row(communityID);
element?.remove();
return element ? 1 : 0;
2 years ago
}
/**
* Copies text to clipboard and shows an informative toast.
* @param {string} text - Text to copy to clipboard.
* @param {string} [toastText] - Text shown by toast.
2 years ago
*/
function copyToClipboard(text, toastText = JOIN_URL_PASTE) {
if (typeof navigator.clipboard !== "undefined") {
navigator.clipboard.writeText(text);
} else {
toastText = "Can not copy to clipboard in insecure context.";
}
2 years ago
// Find snackbar element
const snackbar = dom.snackbar();
if (!snackbar) {
throw new DOMException("Could not find snackbar");
}
snackbar.textContent = toastText;
2 years ago
snackbar.classList.add('show')
// After 5 seconds, hide the snackbar.
setTimeout(() => snackbar.classList.remove('show'), 5000);
2 years ago
}
/**
* Sets the "last checked indicator" based on a timestamp.
* @param {number} last_checked - Timestamp of last community list update.
*/
function setLastChecked(last_checked) {
const seconds_now = Math.floor(Date.now() / 1000); // timestamp in seconds
const time_passed_in_seconds = seconds_now - last_checked;
const time_passed_in_minutes =
2 years ago
Math.floor(time_passed_in_seconds / 60); // time in minutes, rounded down
const timestamp_element = dom.last_checked();
timestamp_element.innerText = `${time_passed_in_minutes} minutes ago`;
2 years ago
}
// TODO: Move info into dynamic modal.
function addServerIconInteractions() {
const rows = dom.tbl_communities_content_rows();
for (const row of rows) {
const hostname = row.getAttribute(ATTRIBUTES.ROW.HOSTNAME);
const publicKey = row.getAttribute(ATTRIBUTES.ROW.PUBLIC_KEY);
const serverIcon = row.querySelector('.td_server_icon');
serverIcon.addEventListener('click', () => {
alert(`Host: ${hostname}\n\nPublic key:\n${publicKey}`);
});
}
}
12 months ago
function addSearchInteractions() {
dom.btn_toggle_search()?.addEventListener('click', function (ev) {
location.hash="#";
const container = dom.search_container();
container?.classList.toggle(CLASSES.COMPONENTS.COLLAPSED);
if (!container?.classList.contains(CLASSES.COMPONENTS.COLLAPSED)) {
const searchBar = dom.search_bar();
searchBar?.focus();
// Inconsistent; attempt to align search bar to top to make more space for results.
searchBar?.scrollIntoView({ behavior: 'smooth', inline: 'start' });
12 months ago
} else {
useSearchTerm("");
}
})
dom.search_bar()?.addEventListener('keydown', function () {
setTimeout(() => useSearchTerm(this.value), 0);
})
dom.search_bar()?.addEventListener('keyup', function (ev) {
if (ev.key === "Enter") {
this.blur();
}
12 months ago
useSearchTerm(this.value);
})
dom.btn_clear_search()?.addEventListener('click', function () {
useSearchTerm("");
const searchBar = dom.search_bar();
searchBar?.focus();
searchBar.value = "";
12 months ago
})
Array.from(dom.sample_searches()).forEach(button => button.addEventListener('click', function() {
const targetSearch = button.getAttribute(ATTRIBUTES.SEARCH.TARGET_SEARCH);
useSearchTerm(targetSearch);
dom.search_bar().value = targetSearch;
}))
12 months ago
}
2 years ago
/**
* Function comparing two elements.
*
* @callback comparer
* @param {*} fst - First value to compare.
* @param {*} snd - Second value to compare.
* @returns 1 if fst is to come first, -1 if snd is, 0 otherwise.
2 years ago
*/
/**
* Performs a comparison on two arbitrary values. Treats "" as Infinity.
* @param {*} fst - First value to compare.
* @param {*} snd - Second value to compare.
* @returns 1 if fst > snd, -1 if fst < snd, 0 otherwise.
2 years ago
*/
function compareAscending(fst, snd) {
// Triple equals to avoid "" == 0.
if (fst === "") return COMPARISON.GREATER;
if (snd === "") return COMPARISON.SMALLER;
return (fst > snd) - (fst < snd);
}
/**
* Performs a comparison on two arbitrary values. Treats "" as Infinity.
* @param {*} fst - First value to compare.
* @param {*} snd - Second value to compare.
* @returns -1 if fst > snd, 1 if fst < snd, 0 otherwise.
2 years ago
*/
function compareDescending(fst, snd) {
return -compareAscending(fst, snd);
}
/**
* Produces a comparer dependent on a derived property of the compared elements.
* @param {comparer} comparer - Callback comparing derived properties.
* @param {Function} getProp - Callback to retrieve derived property.
* @returns {comparer} Function comparing elements based on derived property.
*/
function compareProp(comparer, getProp) {
return (fst, snd) => comparer(getProp(fst), getProp(snd));
}
/**
* Produces a comparer for table rows based on given sorting parameters.
* @param {number} column - Numeric ID of column to be sorted.
* @param {boolean} ascending - Sort ascending if true, descending otherwise.
* @returns {comparer}
*/
function makeRowComparer(column, ascending) {
if (!columnIsSortable(column)) {
throw new Error(`Column ${column} is not sortable`);
}
2 years ago
// Callback to obtain sortable content from cell text.
const columnToSortable = COLUMN_TRANSFORMATION[column] ?? ((el) => el.innerText.trim());
// Construct comparer using derived property to determine sort order.
2 years ago
const rowComparer = compareProp(
ascending ? compareAscending : compareDescending,
row => columnToSortable(row.children[column])
2 years ago
);
2 years ago
return rowComparer;
}
/**
* @typedef {Object} SortState
* @property {number} column - Column ID being sorted.
* @property {boolean} ascending - Whether the column is sorted ascending.
*/
/**
* Retrieves a table's sort settings from the DOM.
* @param {HTMLElement} table - Table of communities being sorted.
* @returns {?SortState}
*/
function getSortState(table) {
if (!table.hasAttribute(ATTRIBUTES.SORTING.ACTIVE)) return null;
const directionState = table.getAttribute(ATTRIBUTES.SORTING.ASCENDING);
// This is not pretty, but the least annoying.
// Checking for classes would be more idiomatic.
const ascending = directionState.toString() === "true";
const columnState = table.getAttribute(ATTRIBUTES.SORTING.COLUMN);
const column = parseInt(columnState);
if (!Number.isInteger(column)) {
throw new Error(`Invalid column number read from table: ${columnState}`)
}
return { ascending, column };
}
/**
* Sets a table's sort settings using the DOM.
* @param {HTMLElement} table - Table of communities being sorted.
* @param {SortState} sortState - Sorting settings being applied.
*/
function setSortState(table, { ascending, column }) {
if (!table.hasAttribute(ATTRIBUTES.SORTING.ACTIVE)) {
table.setAttribute(ATTRIBUTES.SORTING.ACTIVE, true);
}
table.setAttribute(ATTRIBUTES.SORTING.ASCENDING, ascending);
table.setAttribute(ATTRIBUTES.SORTING.COLUMN, column);
// No way around this for brief CSS.
const headers = table.querySelectorAll("th");
headers.forEach((th, colno) => {
th.removeAttribute(ATTRIBUTES.SORTING.ACTIVE);
});
headers[column].setAttribute(ATTRIBUTES.SORTING.ACTIVE, true);
2 years ago
}
// This is best done in JS, as it would require <noscript> styles otherwise.
function markSortableColumns() {
const table = dom.tbl_communities();
const header_cells = table.querySelectorAll('th');
for (let colno = 0; colno < header_cells.length; colno++) {
if (!columnIsSortable(colno)) continue;
header_cells[colno].classList.add('sortable');
header_cells[colno].addEventListener(
'click',
() => sortTable(colno)
)
};
2 years ago
}
12 months ago
/**
* @type {HTMLTableRowElement[]}
*/
const communityFullRowCache = [];
function initializeSearch() {
communityFullRowCache.push(...dom.tbl_communities_content_rows());
}
/**
*
* @param {string} [rawTerm]
*/
function useSearchTerm(rawTerm) {
if (!rawTerm) {
replaceRowsWith(communityFullRowCache);
dom.search_bar()?.classList.remove(CLASSES.SEARCH.NO_RESULTS);
12 months ago
} else {
const term = rawTerm.toLowerCase().replace(/lang:(\S+)/g, "").trim();
12 months ago
const termTags = Array.from(rawTerm.matchAll(/#[^#\s]+/g)).map(match => match[0].slice(1).toLowerCase());
const termLanguage = rawTerm.match(/lang:(\S+)/)?.[1];
12 months ago
const newRows = communityFullRowCache.filter(
row => {
const rowInfo = dom.row_info(row);
const langAscii = rowInfo.language_flag && flagToLanguageAscii(rowInfo.language_flag).toLowerCase();
const rowName = rowInfo.name.toLowerCase();
const rowDesc = rowInfo.description.toLowerCase();
const rowTags = rowInfo.tags.map(({text}) => text.replace(/\s+/g, "-"));
if (termLanguage && !langAscii.includes(termLanguage.toLowerCase())) {
return false;
}
if (termTags.length >= 1) {
if (termTags.some(tag => rowTags.some(rowTag => rowTag.includes(tag)))) {
return true;
}
}
return rowName.includes(term) || rowDesc.includes(term);
12 months ago
}
);
if (newRows.length === 0) {
dom.search_bar()?.classList.add(CLASSES.SEARCH.NO_RESULTS);
} else {
dom.search_bar()?.classList.remove(CLASSES.SEARCH.NO_RESULTS);
}
replaceRowsWith(newRows);
}
sortTable();
}
function replaceRowsWith(rows) {
dom.tbl_communities_content_rows().forEach(row => row.remove());
dom.tbl_communities().querySelector("tbody").append(...rows);
}
2 years ago
/**
* Sorts the default communities table according the given column.
* Sort direction is determined by defaults; successive sorts
2 years ago
* on the same column reverse the sort direction.
12 months ago
* @param {number} [column] - Numeric ID of column being sorted. Re-applies last sort if absent.
2 years ago
*/
function sortTable(column) {
const table = dom.tbl_communities();
const sortState = getSortState(table);
12 months ago
const sortingAsBefore = column === undefined;
2 years ago
const sortingNewColumn = column !== sortState?.column;
12 months ago
const sortedColumn = column ?? sortState?.column;
const ascending =
sortingAsBefore ?
sortState.ascending : (
sortingNewColumn
? columnAscendingByDefault(column)
: !sortState.ascending
);
const compare = makeRowComparer(sortedColumn, ascending);
2 years ago
const rows = Array.from(table.rows).slice(1);
rows.sort(compare);
12 months ago
replaceRowsWith(rows);
setSortState(table, { ascending, column: sortedColumn });
2 years ago
}
// `html.js` selector for styling purposes
document.documentElement.classList.add("js");
document.addEventListener('DOMContentLoaded', () => onLoad());