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.
866 lines
25 KiB
JavaScript
866 lines
25 KiB
JavaScript
// 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, CLASSES, flagToLanguageAscii, RoomInfo, unreachable, workOnMainThread, onInteractive
|
|
} from './js/util.js';
|
|
|
|
// Hidden communities for transparency.
|
|
const filteredCommunities = {
|
|
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
|
|
"meme+c975", // illegal activity
|
|
"trader+c975", // illegal activity
|
|
"public-chinese+c975", // illegal material
|
|
"chat-for-gay-pervs+4d70", // illegal activity
|
|
"gay-porn+4d70", // illegal activity
|
|
"trans-porn+4d70", // illegal activity
|
|
"memepalace+22fd93fb", // illegal material
|
|
"brasil+ad34abde",
|
|
"cryptochinese+0e0d253d"
|
|
],
|
|
};
|
|
|
|
/**
|
|
* Hanging reference to preloaded images to avoid garbage collection.
|
|
*/
|
|
let preloadedImages = [];
|
|
|
|
/**
|
|
* Community ID currently displayed by modal.
|
|
*/
|
|
let shownCommunityId = "";
|
|
|
|
/**
|
|
* Create an interactive version of the Community join link.
|
|
* @param {string} join_link
|
|
* @returns {HTMLElement}
|
|
*/
|
|
const transformJoinURL = () => {
|
|
return element.button({
|
|
textContent: "Copy",
|
|
className: "copy_button",
|
|
title: "Click here to copy the join URL",
|
|
onclick: function () {
|
|
if (!(this instanceof HTMLButtonElement)) throw new Error("Not a button");
|
|
copyToClipboard(
|
|
dom.row_info(
|
|
this.closest(".room-row")
|
|
?? unreachable("No row parent found for button")
|
|
).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 initial URL hash and parameter to trigger actions on the page.
|
|
*/
|
|
function reactToURLParameters() {
|
|
const rawHash = location.hash;
|
|
if (rawHash == "") return;
|
|
|
|
const hash = decodeURIComponent(rawHash.slice(1));
|
|
|
|
if (hash.startsWith("q=")) {
|
|
useSearchTerm(decodeURIComponent(hash.slice(2)), true);
|
|
return;
|
|
}
|
|
|
|
if (!hash.includes("+") && !document.querySelector(`#${hash}`)) {
|
|
useSearchTerm(`#${hash}`, true);
|
|
return;
|
|
}
|
|
|
|
const communityIDPrefix = hash;
|
|
const row = dom.community_row(communityIDPrefix, true);
|
|
if (row == null || !(row instanceof HTMLTableRowElement)) {
|
|
return;
|
|
}
|
|
|
|
const communityID = dom.row_info(row).identifier;
|
|
if (communityID == null) { throw new Error("Unreachable"); }
|
|
|
|
// 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()
|
|
);
|
|
});
|
|
}
|
|
|
|
async function fetchTableRestFragment() {
|
|
if (location.pathname != "/") return;
|
|
const request = await fetch("/_fragment/rest/");
|
|
const html = await request.text();
|
|
const tableBody = dom.tbl_communities()?.querySelector("tbody")
|
|
?? unreachable("could not find table body");
|
|
tableBody.innerHTML += html;
|
|
}
|
|
|
|
/**
|
|
* Triggers all actions dependent on page load.
|
|
*/
|
|
async function onLoad() {
|
|
const timestamp = getTimestamp();
|
|
if (timestamp !== null) {
|
|
setLastChecked(timestamp);
|
|
}
|
|
hideBadCommunities();
|
|
await fetchTableRestFragment();
|
|
hideBadCommunities();
|
|
initializeSearch();
|
|
createJoinLinkUI();
|
|
markSortableColumns();
|
|
addQRModalHandlers();
|
|
preloadImages();
|
|
setInterval(() => {
|
|
preloadImages();
|
|
}, 60 * 60E3);
|
|
addInformativeInteractions();
|
|
Array.from(document.querySelectorAll('.enter-clicks')).forEach(element => {
|
|
// @ts-ignore
|
|
element.addEventListener('keydown', (/** @type {KeyboardEvent} */ ev) => {
|
|
if (ev.key == "Enter") {
|
|
ev.currentTarget.click();
|
|
}
|
|
})
|
|
})
|
|
await RoomInfo.fetchRooms();
|
|
reactToURLParameters();
|
|
addServerIconInteractions();
|
|
addSearchInteractions();
|
|
}
|
|
|
|
/**
|
|
* 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({
|
|
textContent: text.slice(0, 16),
|
|
className: `tag tag-${type} badge`,
|
|
title: description || `Tag: ${text}`
|
|
});
|
|
|
|
/**
|
|
* 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.");
|
|
}
|
|
|
|
shownCommunityId = communityID;
|
|
|
|
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(
|
|
...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();
|
|
shownCommunityId = "";
|
|
}
|
|
|
|
/**
|
|
* Adds handlers for details modal-related actions.
|
|
*/
|
|
function addQRModalHandlers() {
|
|
const rows = dom.tbl_communities_content_rows();
|
|
if (!rows) throw new Error("Rows not found");
|
|
|
|
// Ways to open the QR Modal
|
|
|
|
for (const row of rows) {
|
|
const communityID = row.getAttribute(ATTRIBUTES.ROW.IDENTIFIER) ?? unreachable("No community ID attribute");
|
|
for (const cell of ['.td_qr_code', '.td_description', '.td_language', '.td_users']) {
|
|
const cellElement = row.querySelector(cell) ?? unreachable(`Could not find ${cell}`);
|
|
cellElement.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 () {
|
|
const staffList = this.getAttribute(ATTRIBUTES.ROW.STAFF_DATA);
|
|
if (staffList == "") {
|
|
alert("No public moderators available for this Community.");
|
|
return;
|
|
}
|
|
/**
|
|
* @type {string[]}
|
|
*/
|
|
const staff = staffList.split(",");
|
|
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() {
|
|
shareOrCopyToClipboard(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 });
|
|
}
|
|
|
|
// Arrow-key navigation
|
|
document.documentElement.addEventListener("keyup", function (event) {
|
|
if (!dom.details_modal()?.open) return;
|
|
const isLeftArrowKey = event.key === "ArrowLeft";
|
|
const isRightArrowKey = event.key === "ArrowRight";
|
|
if (!isLeftArrowKey && !isRightArrowKey) return;
|
|
const communityRows = dom.tbl_communities_content_rows().map(dom.row_info);
|
|
const shownRowIndex = communityRows.findIndex(row => row.identifier == shownCommunityId);
|
|
const increment = isLeftArrowKey ? -1 : 1;
|
|
const newRowIndex = (shownRowIndex + increment + communityRows.length) % communityRows.length;
|
|
const newRowIdentifier = communityRows[newRowIndex].identifier;
|
|
displayQRModal(newRowIdentifier);
|
|
})
|
|
|
|
}
|
|
|
|
/**
|
|
* 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)?.split(":")?.[0]
|
|
)
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Places join link buttons and preview in the Community rows.
|
|
*/
|
|
function createJoinLinkUI() {
|
|
communityFullRowCache.forEach(({row, identifier}) => {
|
|
// Data attributes are more idiomatic and harder to change by accident in the DOM.
|
|
const container = row.querySelector('.td_join_url > div') ?? unreachable("Join URL cell empty");
|
|
const joinURLPreview = container.querySelector('span') ?? unreachable("Join URL preview missing");
|
|
// Do not wait on RoomInfo for layout rendering
|
|
joinURLPreview.textContent =
|
|
container.querySelector('a')?.getAttribute('href')?.slice(0, 29) + "...";
|
|
container.append(
|
|
transformJoinURL()
|
|
); // add interactive content
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Hides rows of communities deemed to be superfluous or unsuitable.
|
|
*/
|
|
function hideBadCommunities() {
|
|
let numberOfHiddenCommunities = 0;
|
|
|
|
for (const category of ['offensive']) {
|
|
numberOfHiddenCommunities +=
|
|
filteredCommunities[category]
|
|
.map(hideCommunity)
|
|
.reduce((a, b) => a + b);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes a Community by its ID and returns the number of elements removed.
|
|
*/
|
|
function hideCommunity(communityID) {
|
|
const element = dom.community_row(communityID, true);
|
|
element?.remove();
|
|
return element ? 1 : 0;
|
|
}
|
|
|
|
function shareOrCopyToClipboard(text, toastText) {
|
|
if (navigator.share) {
|
|
navigator.share({text});
|
|
} else {
|
|
copyToClipboard(text, toastText)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
if (!timestamp_element) throw new Error("Expected to find timestamp element");
|
|
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, public_key } = dom.row_info(row);
|
|
const serverIcon = row.querySelector('.td_server_icon');
|
|
if (!serverIcon) continue;
|
|
serverIcon.addEventListener('click', () => {
|
|
alert(`Host: ${hostname}\n\nPublic key:\n${public_key}`);
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {?boolean} setShown
|
|
*/
|
|
function toggleSearchBarVisibility(setShown = null) {
|
|
const container = dom.search_container();
|
|
const hadClass = container?.classList.contains(CLASSES.COMPONENTS.COLLAPSED);
|
|
if (setShown == null) {
|
|
container?.classList.toggle(CLASSES.COMPONENTS.COLLAPSED);
|
|
} else if (setShown == true) {
|
|
container?.classList.remove(CLASSES.COMPONENTS.COLLAPSED);
|
|
} else if (setShown == false) {
|
|
container?.classList.add(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' });
|
|
} else {
|
|
useSearchTerm("");
|
|
}
|
|
if (setShown == hadClass) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
function addSearchInteractions() {
|
|
// Remove JS notice
|
|
dom.search_container()?.removeAttribute("title");
|
|
dom.search_bar()?.removeAttribute("disabled");
|
|
|
|
dom.search_bar()?.addEventListener('keydown', function () {
|
|
setTimeout(() => useSearchTerm(this.value), 0);
|
|
})
|
|
|
|
dom.search_bar()?.addEventListener('keyup', function (ev) {
|
|
if (ev.key === "Enter") {
|
|
this.blur();
|
|
}
|
|
setTimeout(() => useSearchTerm(this.value), 0);
|
|
})
|
|
|
|
dom.btn_search()?.addEventListener('click', function() {
|
|
dom.search_bar()?.focus();
|
|
})
|
|
|
|
dom.btn_clear_search()?.addEventListener('click', function () {
|
|
useSearchTerm("", true);
|
|
dom.search_bar()?.focus();
|
|
})
|
|
|
|
dom.btn_random_search()?.addEventListener('click', function() {
|
|
const searchBar = dom.search_bar() ?? unreachable();
|
|
const currentSearchTerm = searchBar.value;
|
|
const randomSearches = [
|
|
"#new",
|
|
"#we're here",
|
|
"language",
|
|
"Australia",
|
|
"#chat",
|
|
"#official",
|
|
"#privacy",
|
|
"#android",
|
|
"#crypto",
|
|
"lang:en",
|
|
"lang:zh",
|
|
].filter(term => term != currentSearchTerm);
|
|
const randomSearch = randomSearches[~~(Math.random() * randomSearches.length)];
|
|
useSearchTerm(randomSearch, true);
|
|
})
|
|
|
|
dom.btn_share_search()?.addEventListener('click', function() {
|
|
const searchTerm = dom.search_bar()?.value;
|
|
if (!searchTerm) return;
|
|
const searchTermIsTag = searchTerm.startsWith('#') && !searchTerm.includes("+");
|
|
const hash = searchTermIsTag ? searchTerm : `#q=${searchTerm}`;
|
|
const newLocation = new URL(location.href);
|
|
newLocation.hash = hash;
|
|
shareOrCopyToClipboard(newLocation.href, "Share link copied to clipboard");
|
|
});
|
|
|
|
const tags = dom.tags();
|
|
for (const tag of tags) {
|
|
tag.classList.add('clickable');
|
|
tag.setAttribute('tabindex', "0");
|
|
tag.addEventListener('click', function(event) {
|
|
event.stopPropagation();
|
|
useSearchTerm("#" + this.innerText.replace(/ /g,"-"), true);
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 rowToSortable = COLUMN_TRANSFORMATION[column];
|
|
|
|
// Construct comparer using derived property to determine sort order.
|
|
const rowComparer = compareProp(
|
|
ascending ? compareAscending : compareDescending,
|
|
({identifier}) => rowToSortable(identifier)
|
|
);
|
|
|
|
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)
|
|
)
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @type {{row: HTMLTableRowElement, identifier: string}[]}
|
|
*/
|
|
const communityFullRowCache = [];
|
|
|
|
function getAllCachedRows() {
|
|
return communityFullRowCache.map(({row}) => row);
|
|
}
|
|
|
|
function initializeSearch() {
|
|
communityFullRowCache.push(...dom.tbl_communities_content_rows().map(row => ({
|
|
row,
|
|
identifier: row.getAttribute(ATTRIBUTES.ROW.IDENTIFIER) ?? unreachable()
|
|
})));
|
|
}
|
|
|
|
let lastSearchTerm = null;
|
|
/**
|
|
*
|
|
* @param {string} rawTerm
|
|
*/
|
|
async function useSearchTerm(rawTerm, fillSearchBarWithTerm = false) {
|
|
if (rawTerm === lastSearchTerm) return;
|
|
lastSearchTerm = rawTerm;
|
|
const searchBar = dom.search_bar();
|
|
|
|
if (searchBar === undefined || !(searchBar instanceof HTMLInputElement)) {
|
|
throw new Error("Could not find search bar input element");
|
|
}
|
|
|
|
if (!rawTerm) {
|
|
location.hash = "";
|
|
replaceRowsWith(getAllCachedRows());
|
|
dom.search_bar()?.classList.remove(CLASSES.SEARCH.NO_RESULTS);
|
|
} else {
|
|
location.hash = `q=${rawTerm}`;
|
|
const term = rawTerm.toLowerCase().replace(/lang:(\S+)|#$/g, "").trim();
|
|
const termTags = Array.from(rawTerm.matchAll(/#[^#\s]+/g)).map(match => match[0].slice(1).toLowerCase());
|
|
const termLanguage = rawTerm.match(/lang:(\S+)/)?.[1];
|
|
/**
|
|
* @param {{row: HTMLTableRowElement, identifier: string}} rowCache
|
|
*/
|
|
async function rowMatches(rowCache) {
|
|
const {identifier} = rowCache;
|
|
const languageFlag = RoomInfo.getRoomLanguageFlag(identifier);
|
|
const langAscii = languageFlag && flagToLanguageAscii(languageFlag).toLowerCase();
|
|
if (termLanguage && !langAscii.includes(termLanguage.toLowerCase())) {
|
|
return false;
|
|
}
|
|
const rowName = RoomInfo.getRoomName(identifier).toLowerCase();
|
|
const rowDesc = RoomInfo.getRoomDescription(identifier).toLowerCase();
|
|
if (rowName.includes(term) || rowDesc.includes(term)) {
|
|
return true;
|
|
}
|
|
const rowTags = RoomInfo.getRoomTags(identifier).map(({text}) => text.replace(/\s+/g, "-"));
|
|
for (const termTag of termTags) {
|
|
for (const rowTag of rowTags) {
|
|
if (rowTag.startsWith(termTag)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
const newRowMatches = communityFullRowCache.map(async (rowCache) => ({ rowCache, doesMatch: await rowMatches(rowCache) }));
|
|
const newRows = (await Promise.all(newRowMatches)).filter((row) => row.doesMatch).map(({rowCache}) => rowCache.row);
|
|
if (newRows.length === 0) {
|
|
searchBar.classList.add(CLASSES.SEARCH.NO_RESULTS);
|
|
} else {
|
|
searchBar.classList.remove(CLASSES.SEARCH.NO_RESULTS);
|
|
}
|
|
|
|
replaceRowsWith(newRows);
|
|
}
|
|
|
|
if (fillSearchBarWithTerm) {
|
|
searchBar.value = rawTerm;
|
|
}
|
|
|
|
sortTable();
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLTableRowElement[]} rows
|
|
*/
|
|
function replaceRowsWith(rows) {
|
|
const tableBody = dom.tbl_communities()?.querySelector("tbody");
|
|
if (!tableBody) throw new Error("Table body missing")
|
|
tableBody.replaceChildren(tableBody.rows[0], ...rows);
|
|
}
|
|
|
|
/**
|
|
* 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. Re-applies last sort if absent.
|
|
*/
|
|
function sortTable(column) {
|
|
const table = dom.tbl_communities();
|
|
if (!table) throw new Error("Table missing");
|
|
const sortState = getSortState(table);
|
|
const sortingAsBefore = column === undefined;
|
|
if (!sortState && sortingAsBefore) {
|
|
// No column supplied on first sort
|
|
return;
|
|
}
|
|
const sortingNewColumn = column !== sortState?.column;
|
|
const sortedColumn = column ?? sortState?.column ?? unreachable();
|
|
const ascending =
|
|
sortingAsBefore ?
|
|
sortState?.ascending ?? unreachable() : (
|
|
sortingNewColumn
|
|
? columnAscendingByDefault(column)
|
|
: !sortState?.ascending ?? unreachable()
|
|
);
|
|
const compare = makeRowComparer(sortedColumn, ascending);
|
|
const rows = dom.tbl_communities_content_rows().map(row => ({row, identifier: row.getAttribute(ATTRIBUTES.ROW.IDENTIFIER)}));
|
|
rows.sort(compare);
|
|
replaceRowsWith(rows.map(({row}) => row));
|
|
setSortState(table, { ascending, column: sortedColumn });
|
|
}
|
|
|
|
// `html.js` selector for styling purposes
|
|
document.documentElement.classList.add("js");
|
|
|
|
onInteractive(onLoad)
|