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

560 lines
16 KiB
JavaScript

1 year ago
// Hello reader!
// This project can be found at:
// https://codeberg.com/gravel/sessioncommunities.online
1 year ago
/**
* This JavaScript file uses the JSDoc commenting style.
* Learn more: https://jsdoc.app/
*/
1 year ago
// Nudge TypeScript plugins to type-check using JSDoc comments.
// @ts-check
1 year ago
// Early prevention for bugs introduced by lazy coding.
'use strict';
// Import magic numbers and data
import {
1 year ago
dom, COLUMN, COLUMN_LITERAL, COMPARISON, ATTRIBUTES,
columnAscendingByDefault, columnIsSortable, COLUMN_TRANSFORMATION,
element, JOIN_URL_PASTE, communityQRCodeURL, STAFF_ID_PASTE, IDENTIFIER_PASTE
1 year 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
1 year ago
"test+fe93", // 测试Test)
"xyz+7908", // XYZ Room
1 year 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
1 year ago
],
};
/**
* Hanging reference to preloaded images to avoid garbage collection.
*/
const preloadedImages = [];
1 year ago
/**
* 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)
});
}
1 year 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 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;
}
try {
displayQRModal(communityID);
} catch (e) {
console.error("Could not navigate to community " + communityID);
console.error(e);
}
}
1 year ago
/**
* Triggers all actions dependent on page load.
*/
function onLoad() {
const timestamp = getTimestamp();
if (timestamp !== null) {
setLastChecked(timestamp);
}
1 year ago
hideBadCommunities();
// Sort by server to show off new feature & align colors.
sortTable(COLUMN.SERVER_ICON);
1 year ago
createJoinLinkButtons();
markSortableColumns();
addQRModalHandlers();
addServerIconInteractions();
preloadImages();
reactToURLParameters();
1 year 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.
* @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}`;
// manual scrolling to prevent jumping after every modal open
row.scrollIntoView({
behavior: "smooth"
});
modal.showModal();
1 year ago
}
1 year ago
/**
* Hides the Community details modal.
*/
function hideQRModal() {
dom.details_modal().close();
1 year 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);
}
)
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 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);
}
}
1 year ago
/**
* Places join link buttons in the Community rows.
*/
1 year 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
1 year ago
});
}
1 year ago
/**
* Hides rows of communities deemed to be superflous or unsuitable.
*/
1 year ago
function hideBadCommunities() {
let numberOfHiddenCommunities = 0;
for (const category of ['tests', 'offensive']) {
numberOfHiddenCommunities +=
filteredCommunities[category]
.map(hideCommunity)
.reduce((a, b) => a + b);
1 year ago
}
const summary = dom.servers_hidden();
summary.innerText = `(${numberOfHiddenCommunities} hidden)`;
1 year 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;
1 year 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.
1 year 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.";
}
1 year ago
// Find snackbar element
const snackbar = dom.snackbar();
if (!snackbar) {
throw new DOMException("Could not find snackbar");
}
snackbar.textContent = toastText;
1 year ago
snackbar.classList.add('show')
// After 5 seconds, hide the snackbar.
setTimeout(() => snackbar.classList.remove('show'), 5000);
1 year 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 =
1 year 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`;
1 year 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}`);
});
}
}
1 year 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.
1 year 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.
1 year 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.
1 year 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`);
}
1 year 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.
1 year ago
const rowComparer = compareProp(
ascending ? compareAscending : compareDescending,
row => columnToSortable(row.children[column])
1 year ago
);
1 year 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);
1 year 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)
)
};
1 year ago
}
/**
* Sorts the default communities table according the given column.
* Sort direction is determined by defaults; successive sorts
1 year ago
* 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());