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

568 lines
16 KiB
JavaScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// Hello reader!
// This project can be found at:
// https://codeberg.com/gravel/sessioncommunities.online
/**
* This JavaScript file uses the JSDoc commenting style.
* Learn more: https://jsdoc.app/
*/
// Nudge TypeScript plugins to type-check using JSDoc comments.
// @ts-check
// Early prevention for bugs introduced by lazy coding.
'use strict';
// Import magic numbers and data
import {
dom, COLUMN, COLUMN_LITERAL, COMPARISON, ATTRIBUTES,
columnAscendingByDefault, columnIsSortable, COLUMN_TRANSFORMATION,
element, JOIN_URL_PASTE, communityQRCodeURL, STAFF_ID_PASTE, IDENTIFIER_PASTE, DETAILS_LINK_PASTE
} 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
"test+fe93", // 测试Test)
"xyz+7908", // XYZ Room
],
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
],
};
/**
* Hanging reference to preloaded images to avoid garbage collection.
*/
const preloadedImages = [];
/**
* Create an interactive version of the Community join link.
* @param {string} join_link
* @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)
});
}
/**
* 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 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);
}
}
/**
* Triggers all actions dependent on page load.
*/
function onLoad() {
const timestamp = getTimestamp();
if (timestamp !== null) {
setLastChecked(timestamp);
}
hideBadCommunities();
// Sort by server to show off new feature & align colors.
sortTable(COLUMN.SERVER_ICON);
createJoinLinkButtons();
markSortableColumns();
addQRModalHandlers();
addServerIconInteractions();
preloadImages();
reactToURLParameters();
}
/**
* 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
});
/**
* Shows the details modal hydrated with the given community's details.
* @param {string} communityID
* @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(
...JSON.parse(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();
}
/**
* Hides the Community details modal.
*/
function hideQRModal() {
dom.details_modal().close();
}
/**
* 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 });
}
}
/**
* Prefetches images used in the page to prevent tracking.
*/
function preloadImages() {
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;
preloadedImages.push(image);
}
}
/**
* Places join link buttons in the Community rows.
*/
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
});
}
/**
* Hides rows of communities deemed to be superflous or unsuitable.
*/
function hideBadCommunities() {
let numberOfHiddenCommunities = 0;
for (const category of ['tests', 'offensive']) {
numberOfHiddenCommunities +=
filteredCommunities[category]
.map(hideCommunity)
.reduce((a, b) => a + b);
}
const summary = dom.servers_hidden();
summary.innerText = `(${numberOfHiddenCommunities} hidden)`;
}
/**
* 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;
}
/**
* Copies text to clipboard and shows an informative toast.
* @param {string} text - Text to copy to clipboard.
* @param {string} [toastText] - Text shown by toast.
*/
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.";
}
// Find snackbar element
const snackbar = dom.snackbar();
if (!snackbar) {
throw new DOMException("Could not find snackbar");
}
snackbar.textContent = toastText;
snackbar.classList.add('show')
// After 5 seconds, hide the snackbar.
setTimeout(() => snackbar.classList.remove('show'), 5000);
}
/**
* 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 =
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`;
}
// 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}`);
});
}
}
/**
* 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.
*/
/**
* 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.
*/
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.
*/
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`);
}
// 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.
const rowComparer = compareProp(
ascending ? compareAscending : compareDescending,
row => columnToSortable(row.children[column])
);
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);
}
// 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)
)
};
}
/**
* Sorts the default communities table according the given column.
* Sort direction is determined by defaults; successive sorts
* on the same column reverse the sort direction.
* @param {number} column - Numeric ID of column being sorted.
*/
function sortTable(column) {
const table = dom.tbl_communities();
const sortState = getSortState(table);
const sortingNewColumn = column !== sortState?.column;
const ascending = sortingNewColumn
? columnAscendingByDefault(column)
: !sortState.ascending;
const compare = makeRowComparer(column, ascending);
const rows = Array.from(table.rows).slice(1);
rows.sort(compare);
rows.forEach((row) => row.remove());
table.querySelector("tbody").append(...rows);
setSortState(table, { ascending, column });
}
// `html.js` selector for styling purposes
document.documentElement.classList.add("js");
document.addEventListener('DOMContentLoaded', () => onLoad());