// 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 } 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 "RU-STEROID+e093" // drug trading? ], }; // This can be achieved with `text-overflow: ellipsis` instead // and generated entirely server-side. const transformJoinURL = (join_link) => { return element.button({ textContent: "Copy", className: "copy_button", title: "Click here to copy the join URL", onclick: () => copyToClipboard(join_link) }); } 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; } 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(); preloadQRCodes(); } const tagBody = ({text, type, description}) => element.span({ // todo: truncate textContent: text, className: `room-label room-label-${type} badge`, title: description }); function displayQRModal(communityID) { 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); modal.showModal(); } function hideQRModal(communityID) { dom.details_modal().close(); } 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) ); } 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(); } }); 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}`, 'Copied staff ID to clipboard.'); } ) 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 }); } } function preloadQRCodes() { const rows = dom.tbl_communities_content_rows(); const identifiers = rows.map( rowElement => rowElement.getAttribute(ATTRIBUTES.ROW.IDENTIFIER) ); for (const identifier of identifiers) { (new Image()).src = communityQRCodeURL(identifier); } } 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 }); } function hideBadCommunities() { let numberOfHiddenCommunities = 0; for (const category of ['tests', 'offensive']) { numberOfHiddenCommunities += filteredCommunities[category] .map(hideElementByID) .reduce((a, b) => a + b); } const summary = dom.servers_hidden(); summary.innerText = `(${numberOfHiddenCommunities} hidden)`; } /** * Removes an element by its ID and returns the number of elements removed. */ function hideElementByID(id) { const element = document.getElementById(id); 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) { navigator.clipboard.writeText(text); // Find snackbar element const snackbar = dom.snackbar(); if (!snackbar) { throw new DOMException("Could not find snackbar"); } snackbar.textContent = toastText; snackbar.classList.add('show') // After 3 seconds, hide the snackbar. setTimeout(() => snackbar.classList.remove('show'), 3000); } /** * 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