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.
		
		
		
		
		
			
		
			
				
	
	
		
			845 lines
		
	
	
		
			25 KiB
		
	
	
	
		
			JavaScript
		
	
			
		
		
	
	
			845 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
 | 
						|
	],
 | 
						|
};
 | 
						|
 | 
						|
/**
 | 
						|
 * 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()
 | 
						|
		);
 | 
						|
	});
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * Triggers all actions dependent on page load.
 | 
						|
 */
 | 
						|
async function onLoad() {
 | 
						|
	const timestamp = getTimestamp();
 | 
						|
	if (timestamp !== null) {
 | 
						|
		setLastChecked(timestamp);
 | 
						|
	}
 | 
						|
	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);
 | 
						|
		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 () {
 | 
						|
			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);
 | 
						|
	}
 | 
						|
 | 
						|
	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, 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");
 | 
						|
	});
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * 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)
 |