|
|
// Hello reader!
|
|
|
// This project can be found at:
|
|
|
// https://lokilocker.com/someguy/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",
|
|
|
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();
|
|
|
}
|
|
|
|
|
|
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]);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
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);
|
|
|
row.querySelector('.td_qr_code').addEventListener(
|
|
|
'click',
|
|
|
() => 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 <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());
|