code:
/* globals __ __n modal Tegaki grecaptcha hcaptcha captchaController appendLocalStorageArray socket isThread setLocalStorage forceUpdate captchaController uploaditem */
async function videoThumbnail(file) {
	return new Promise((resolve, reject) => {
		const hiddenVideo = document.createElement('video');
		hiddenVideo.setAttribute('src', URL.createObjectURL(file));
		hiddenVideo.load();
		hiddenVideo.addEventListener('error', err => {
			reject(err);
		});
		hiddenVideo.addEventListener('loadedmetadata', () => {
			//apparently 'loadedmetadata' is too early -.-
			setTimeout(() => {
				hiddenVideo.currentTime = 0;
			}, 500);
			hiddenVideo.addEventListener('seeked', () => {
				const canvas = document.createElement('canvas');
				canvas.width = hiddenVideo.videoWidth;
				canvas.height = hiddenVideo.videoHeight;
				const ctx = canvas.getContext('2d');
				ctx.drawImage(hiddenVideo, 0, 0, canvas.width, canvas.height);
				ctx.canvas.toBlob(blob => {
					resolve(blob);
				});
			});
		});
	});
}

function doModal(data, postcallback, loadcallback) {
	try {
		const modalHtml = modal({ modal: data });
		let checkInterval;
		document.body.insertAdjacentHTML('afterbegin', modalHtml);
		const modals = document.getElementsByClassName('modal');
		const modalBgs = document.getElementsByClassName('modal-bg');
		window.dispatchEvent(new CustomEvent('showModal', {
			detail: {
				modal: modals[0],
			}
		}));
		if (modals.length > 1) {
			const latestModalIndex = parseInt(document.defaultView.getComputedStyle(modals[1], null).getPropertyValue('z-index'));
			//from appeals, or holding enter. make sure they show up above the previous modal
			modals[0].style.zIndex = latestModalIndex + 3;
			modalBgs[0].style.zIndex = latestModalIndex + 2;
		}
		if (loadcallback != null) {
			loadcallback();
		}
		document.getElementById('modalclose').onclick = () => {
			removeModal();
			clearInterval(checkInterval);
		};
		document.getElementsByClassName('modal-bg')[0].onclick = () => {
			removeModal();
			clearInterval(checkInterval);
		};
		const modalframe = document.getElementById('modalframe');
		if (modalframe) {
			if (localStorage.getItem('theme') === 'default') {
				modalframe.onload = () => {
					//if theres a modal frame and user has default theme, style it
					const currentTheme = document.head.querySelector('#theme').href;
					modalframe.contentDocument.styleSheets[1].ownerNode.href = currentTheme;
				};
			}
			if (postcallback) {
				checkInterval = setInterval(() => {
					if (modalframe && modalframe.contentDocument.title == __('Success')) {
						clearInterval(checkInterval);
						removeModal();
						postcallback();
					}
				}, 100);
			}
		}
	} catch (e) {
		console.error(e);
	}
}

const modalClasses = ['modal', 'modal-bg'];
function removeModal() {
	modalClasses.forEach(c => document.getElementsByClassName(c)[0].remove());
}

let recaptchaResponse = null;
function recaptchaCallback(response) { // eslint-disable-line
	recaptchaResponse = response;
}

let tegakiWidth = localStorage.getItem('tegakiwidth-setting');
let tegakiHeight = localStorage.getItem('tegakiheight-setting');

class postFormHandler {

	constructor(form) {
		this.form = form;
		this.resetOnSubmit = this.form.dataset.resetOnSubmit == 'true';
		this.enctype = this.form.getAttribute('enctype');
		this.messageBox = this.form.querySelector('#message');
		this.recordTegaki = this.form.elements.tegakireplay;
		this.minimal = this.form.elements.minimal;
		this.files = [];
		this.submit = form.querySelector('input[type="submit"]');
		if (this.submit) {
			this.originalSubmitText = this.submit.value;
		}

		//get different element for diffeent captcha types
		this.captchaField = form.querySelector('.captchafield')
			|| form.querySelector('.g-recaptcha')
			|| form.querySelector('.h-captcha');

		//if tegaki button, attach the listener to open tegaki
		this.tegakiButton = form.querySelector('.tegaki-button');
		if (this.tegakiButton) {
			this.tegakiButton.addEventListener('click', () => this.doTegaki());
		}

		//if web3 signature button, attach the listeners for message signing
		this.web3SignButton = form.querySelector('.web3-sign');
		if (this.web3SignButton) {
			this.web3SignButton.addEventListener('click', () => this.doWeb3Sign());
		}

		//if web3 login button, do login procedure on click
		this.web3LoginButton = form.querySelector('.web3-login');
		if (this.web3LoginButton) {
			this.web3LoginButton.addEventListener('click', (e) => this.doWeb3Login(e));
		}

		//if file input, attach listeners for adding files, drag+drop, etc
		this.fileInput = form.querySelector('input[type="file"]');
		if (this.fileInput) {
			this.fileRequired = this.fileInput.required;
			this.fileLabel = this.fileInput.previousSibling;
			this.fileUploadList = this.fileInput.nextSibling;
			this.multipleFiles = this.fileLabel.parentNode.previousSibling.firstChild.textContent.endsWith('s');
			this.fileLabelText = this.fileLabel.childNodes[0];
			this.fileLabel.addEventListener('dragover', e => this.fileLabelDrag(e));
			this.fileLabel.addEventListener('drop', e => this.fileLabelDrop(e));
			this.fileInput.addEventListener('change', e => this.fileInputChange(e));
			this.fileLabel.addEventListener('auxclick', e => this.fileLabelAuxclick(e));
			form.addEventListener('paste', e => this.paste(e));
		}

		//if custom flag select, attach listener and set from local storage if saved
		this.customFlagInput = this.form.elements.customflag;
		this.selectedFlagImage = document.getElementById('selected-flag');
		if (this.customFlagInput && this.selectedFlagImage) {
			this.customFlagInput.addEventListener('change', () => this.updateFlagField(), false);
			this.updateFlagField();
		}

		//allow control+enter to submit when in message input
		if (this.messageBox) {
			this.messageBox.addEventListener('keydown', e => this.controlEnterSubmit(e));
		}

		form.addEventListener('submit', e => this.formSubmit(e));
		form.addEventListener('messageBoxChange', () => this.handleMessageChange());
	}

	reset() {
		this.form.reset();
		this.updateFlagField();
		this.updateMessageBox();
		this.files = [];
		this.updateFilesText();
		const captcha = this.form.querySelector('.captcharefresh');
		if (captcha) {
			captcha.dispatchEvent(new Event('click'));
		}
	}

	doTegaki() {
		const saveReplay = this.recordTegaki && this.recordTegaki.checked;
		Tegaki.open({
			saveReplay,
			onCancel: () => {},
			onDone: () => {
				const now = Date.now();
				let replayBlob;
				if (saveReplay) {
					replayBlob = Tegaki.replayRecorder.toBlob();
				}
				//add tegaki image
				Tegaki.flatten().toBlob(imageBlob => {
					this.addFile(new File([imageBlob], `${now}-tegaki.png`, { type: 'image/png' }), { stripFilenames: false });
					//add replay file
					replayBlob && this.addFile(new File([replayBlob], `${now}-tegaki.tgkr`, { type: 'tegaki/replay' }), { stripFilenames: false });
				}, 'image/png');
				//update file list
				this.updateFilesText();
				//reset tegaki state
				Tegaki.resetLayers();
				Tegaki.destroy();
			},
			width: tegakiWidth,
			height: tegakiHeight,
		});
		Tegaki.resetLayers();
		Tegaki.setColorPalette(2); //picks a better default color palette
	}

	handleMessageChange() {
		if (!this.messageBox) { return; }
		const emptyMessage = this.messageBox.value.length === 0;
		if (this.web3SignButton) {
			this.form.elements.signature.value = '';
			this.web3SignButton.disabled = emptyMessage;
		}
	}

	async doWeb3Login(e) {
		e.target.style.pointerEvents = 'none'; //way of disabling dummy button to prevent double click
		try {
			const accounts = await window.jschanweb3.eth.requestAccounts();
			const nonceResponse = await fetch(`/nonce/${encodeURIComponent(accounts[0])}.json`)
				.then(res => res.json());
			const nonce = nonceResponse && nonceResponse.nonce;
			if (!nonce) { throw Error('Nonce request failed'); }
			const signingMesssage = `Nonce: ${nonce}`;
			const signature = await window.jschanweb3.currentProvider.request({
				method: 'personal_sign',
				params: [signingMesssage, accounts[0]],
			});
			this.form.elements.signature.value = signature;
			this.form.elements.address.value = accounts[0];
			this.form.elements.nonce.value = nonce;
			this.form.requestSubmit();
		} catch (e) {
			console.warn(e);
		} finally {
			e.target.style.pointerEvents = 'auto';
		}
	}

	async doWeb3Sign() {
		if (!this.messageBox.value || this.messageBox.value.length === 0) {
			return;
		}
		const messageContent = this.messageBox.value;
		try {
			const accounts = await window.jschanweb3.eth.requestAccounts();
			const signature = await window.jschanweb3.currentProvider.request({
				method: 'personal_sign',
				params: [messageContent, accounts[0]],
			});
			this.form.elements.signature.value = signature;
		} catch (e) {
			console.warn(e);
		}
	}

	updateFlagField() {
		if (this.customFlagInput && this.customFlagInput.options.selectedIndex !== -1) {
			this.selectedFlagImage.src = this.customFlagInput.options[this.customFlagInput.options.selectedIndex].dataset.src || '';
		}
	}

	controlEnterSubmit(e) {
		if (e.ctrlKey && e.key === 'Enter') {
			this.formSubmit(e);
		}
	}

	formSubmit(e) {

		//get the captcha response if any recaptcha
		const captchaResponse = recaptchaResponse;

		//build the form data based on form enctype
		let postData;
		if (this.enctype === 'multipart/form-data') {
			this.fileInput && (this.fileInput.disabled = true);
			postData = new FormData(this.form);
			if (captchaResponse) {
				postData.append('captcha', captchaResponse);
			}
			this.fileInput && (this.fileInput.disabled = false);
			if (this.files && this.files.length > 0) {
				/* add each file to data individually, since we handle multiple files in multiple sessions of
					selecting, not just the last time (see addFile()) */
				for (let i = 0; i < this.files.length; i++) {
					postData.append('file', this.files[i]);
				}
			}
		} else {
			postData = new URLSearchParams([...(new FormData(this.form))]);
			if (captchaResponse) {
				postData.set('captcha', captchaResponse);
			}
		}

		/* if it is a "minimal" form (used in framed bypases) or ticked the "edit" box in post actions,
			dont preventDefault because we just want to use non-js form submission */
		if (this.minimal
			|| (postData instanceof URLSearchParams && postData.get('edit') === '1')) {
			return true;
		} else {
			e.preventDefault();
		}

		//prepare new request
		const xhr = new XMLHttpRequest();

		if (this.submit) {
			//disable submit button to prevent submitting while one in progress
			this.submit.disabled = true;
			//update the text on the submit button, and show upload progress if form has files
			this.submit.value = 'Processing...';
			if (this.files && this.files.length > 0) {
				xhr.onloadstart = () => {
					this.submit.value = '0%';
				};
				xhr.upload.onprogress = (ev) => {
					const progress = Math.floor((ev.loaded / ev.total) * 100);
					this.submit.value = `${progress}%`;
				};
				xhr.onload = () => {
					this.submit.value = this.originalSubmitText;
				};
			}
		}

		xhr.onreadystatechange = () => {

			//request finished
			if (xhr.readyState === 4) {

				//if the google/hcaptcha/yandex was filled, reset it now
				if (captchaResponse && grecaptcha) {
					grecaptcha.reset();
				} else if (captchaResponse && hcaptcha) {
					hcaptcha.reset();
				} else if (captchaResponse && window.smartCaptcha) {
					window.smartCaptcha.reset();
				}

				//remove captcha if server says it is no longer enabled	(submitting one when not needed doesnt cause any problem)
				if (xhr.getResponseHeader('x-captcha-enabled') === 'false') {
					captchaController.removeCaptcha();
					this.captchaField = null;
				}

				//re-enable the submit button now, and set the submit button text back to original value
				if (this.submit) {
					this.submit.disabled = false;
					this.submit.value = this.originalSubmitText;
				}

				//try and parse json from the response if there is a body
				let json;
				if (xhr.responseText) {
					try {
						json = JSON.parse(xhr.responseText);
					} catch (e) {
						//wasnt json response
					}
				}

				if (json) {
					if (xhr.status == 200) {

						//response had a postId from successful post so set here for scrolling to new posts
						if (json.postId) {
							window.myPostId = json.postId;
						}

						//get board and postId to add to (you)s
						if (json.redirect) {
							const redirectBoard = json.redirect.split('/')[1];
							const redirectPostId = json.redirect.split('#')[1];
							if (redirectBoard && redirectPostId) {
								appendLocalStorageArray('yous', `${redirectBoard}-${redirectPostId}`);
							}
						}

						//do modal for errors/messages
						if (json.message || json.messages || json.error || json.errors) {
							doModal(json);
						} else if (socket && socket.connected) {
							//set hash to scroll to your post if you are connected to the socket (it will be in the DOM by this point)
							window.location.hash = json.postId;
						} else {
							//if we are not in a thread so follow the redirect to open the new thread
							if (!isThread) {
								return window.location = json.redirect;
							}
							//otherwise save the postId for you tracking after forceUpdate() finishes
							setLocalStorage('myPostId', json.postId);
							//not connected to socket, so force fetch the JSON
							forceUpdate();
						}

						//if the form has data attribute to reset on submission, clear it now (reset() handled stuff like saved name, flag, etc)
						if (this.resetOnSubmit) {
							this.reset();
						}

					} else {

						//not a 200 so probably error
						if (!this.captchaField && json.message === __('Incorrect captcha answer')) {
							/* add missing captcha field if we got an error about it and the form has no captcha field
								(must have been enabeld after we loaded the page) */
							captchaController.addMissingCaptcha();
							this.captchaField = true;
						} else if (json.message === __('Captcha expired')) {
							//if captcha is expired, just refresh the captcha
							const captcha = this.form.querySelector('.captcharefresh');
							if (captcha) {
								captcha.dispatchEvent(new Event('click'));
							}
						} else if (json.bans) {
							//if user is banned, display their bans table and appeal form in a special modal
							doModal(json, null, () => {
								const modalBanned = document.getElementById('modalbanned');
								const modalBanForm = modalBanned.querySelector('form');
								const modalAppealHandler = new postFormHandler(modalBanForm);
								for (let modalFormElement of modalBanForm.querySelectorAll('input[name="checkedbans"]')) {
									//for ease of appeal, pre-check all the bans in this case.
									modalFormElement.checked = true;
								}
								const appealCaptcha = modalAppealHandler.captchaField;
								if (appealCaptcha) {
									captchaController.setupCaptchaField(appealCaptcha);
								}
							});
						} else {
							/* otherwise just show modal with errors/messages, and callback will be optionally used
								for 403 if they have to do a bypass and submit the iframe */
							doModal(json, () => {
								this.formSubmit(e);
							});
						}

					}
				} else if (xhr.responseURL
					&& xhr.responseURL !== `${location.origin}${this.form.getAttribute('action')}`) {
					//not an json and a redirect not to current url (which would be edits), so redirect to new location.
					return window.location = xhr.responseURL;
				} else if (xhr.status === 413) {
					//413 but not a json, must be nginx so show generic error
					doModal({
						'title': 'Payload Too Large',
						'message': 'Your upload was too large',
					});
				} else {
					//something is completely wrong, usually no connection or server down
					doModal({
						'title': 'Error',
						'message': 'Something broke'
					});
				}

				if (this.submit) {
					this.submit.value = this.originalSubmitText;
				}

			}
		};

		//gives a useless error, so once again show generic "something broke"
		xhr.onerror = (err) => {
			console.error(err);
			doModal({
				'title': 'Error',
				'message': 'Something broke'
			});
			if (this.submit) {
				this.submit.disabled = false;
				this.submit.value = this.originalSubmitText;
			}
		};

		//open the request
		xhr.open(this.form.getAttribute('method'), this.form.getAttribute('action'), true);

		//if not a minimal form, send special header so server knows to send json in dynamicResponse()
		if (!this.minimal) {
			xhr.setRequestHeader('x-using-xhr', true);
		}

		//if using live and connected, send special header so server knows to
		const isLive = localStorage.getItem('live') == 'true' && socket && socket.connected;
		if (isLive) {
			xhr.setRequestHeader('x-using-live', true);
		}

		//if not multipart form set correct header
		if (this.enctype !== 'multipart/form-data') {
			xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
		}

		//send the request
		xhr.send(postData);

	}

	//forcefully update message box of form for character counter used e.g. in reset() because input event would not be fired
	updateMessageBox() {
		this.messageBox && this.messageBox.dispatchEvent(new Event('input'));
	}

	removeFile(fileElem, name, size) {
		fileElem.remove();
		let fileIndex;
		this.files.find((f, index) => {
			if (f.name === name && f.size === size) {
				fileIndex = index;
			}
		});
		this.files.splice(fileIndex, 1);
		this.updateFilesText();
	}

	async addFile(file, fileOptions = {}) {
		if (this.fileRequired) { //prevent drag+drop issues by removing required
			this.fileInput.removeAttribute('required');
		}
		this.files.push(file);
		console.log('got file', file.name);
		let fileHash;
		if (window.crypto.subtle) {
			let fileBuffer;
			if (file.arryaBuffer) {
				fileBuffer = await file.arrayBuffer();
			} else {
				//can old browsers just fuck off please?
				const bufferFileReader = new FileReader();
				await new Promise(res => {
					bufferFileReader.addEventListener('loadend', res);
					bufferFileReader.readAsArrayBuffer(file);
				});
				if (bufferFileReader.result) {
					fileBuffer = bufferFileReader.result;
				}
			}
			const fileDigest = await window.crypto.subtle.digest('SHA-256', fileBuffer);
			fileHash = Array.from(new Uint8Array(fileDigest))
				.map(c => c.toString(16).padStart(2, '0'))
				.join('');
			console.log('file hash', fileHash);
		}
		const item = {
			spoilers: this.fileUploadList.dataset.spoilers === 'true',
			stripFilenames: this.fileUploadList.dataset.stripFilenames === 'true',
			name: file.name,
			hash: fileHash,
			...fileOptions,
		};
		switch (file.type.split('/')[0]) {
			case 'image':
				item.url = URL.createObjectURL(file);
				break;
			case 'audio':
				item.url = '/file/audio.png';
				break;
			case 'video':
				try {
					const thumbnailBlob = await videoThumbnail(file);
					item.url = URL.createObjectURL(thumbnailBlob);
				} catch (err) {
					//couldnt create video thumb for some reason
					item.url = '/file/video.png';
				}
				break;
			default:
				item.url = '/file/attachment.png';
				break;
		}
		const uploadItemHtml = uploaditem({ uploaditem: item });
		this.fileUploadList.insertAdjacentHTML('beforeend', uploadItemHtml);
		const fileElem = this.fileUploadList.lastChild;
		const lastClose = fileElem.querySelector('.close');
		lastClose.addEventListener('click', () => {
			this.removeFile(fileElem, file.name, file.size);
		});
		this.fileUploadList.style.display = 'unset';
	}

	//show number of files on new label
	updateFilesText() {
		if (!this.fileLabelText) {
			return;
		}
		if (this.files && this.files.length === 0) {
			this.fileUploadList.textContent = '';
			this.fileUploadList.style.display = 'none';
			this.fileLabelText.nodeValue = __n('Select/Drop/Paste files', this.multipleFiles ? 2 : 1);
		} else {
			this.fileLabelText.nodeValue =  __n('%s files selected', this.files.length);
		}
		this.fileInput.value = null;
	}

	//remove all files from this form
	clearFiles() {
		if (!this.fileInput) {
			return;
		}
		this.files = []; //empty file list
		this.fileInput.value = null; //remove the files for real
		if (this.fileRequired) { //reset to required if clearing files
			this.fileInput.setAttribute('required', true);
		}
		this.updateFilesText();
	}

	//paste files from clipboard
	paste(e) {
		const clipboard = e.clipboardData;
		if (clipboard.items && clipboard.items.length > 0) {
			const items = clipboard.items;
			for (let i = 0; i < items.length; i++) {
				const item = items[i];
				if (item.kind === 'file') {
					const file = new File([item.getAsFile()], 'ClipboardImage.png', { type: item.type });
					this.addFile(file);
				}
			}
			this.updateFilesText();
		}
	}

	//change cursor on hover when drag+dropping files
	fileLabelDrag(e) {
		e.stopPropagation();
		e.preventDefault();
		e.dataTransfer.dropEffect = 'copy';
	}

	//add file on drag+drop
	fileLabelDrop(e) {
		e.stopPropagation();
		e.preventDefault();
		const newFiles = e.dataTransfer.files;
		for (let i = 0; i < newFiles.length; i++) {
			this.addFile(newFiles[i]);
		}
		this.updateFilesText();
	}

	//add file by normal file form, but add instead of replacing files
	fileInputChange() {
		const newFiles = this.fileInput.files;
		for (let i = 0; i < newFiles.length; i++) {
			this.addFile(newFiles[i]);
		}
		this.updateFilesText();
	}

	//middle click to clear files
	fileLabelAuxclick(e) {
		if (e.button !== 1) { //middle click only
			return;
		}
		this.clearFiles();
	}

}

window.addEventListener('DOMContentLoaded', () => {

	const myPostId = localStorage.getItem('myPostId');
	if (myPostId) {
		window.location.hash = myPostId;
		localStorage.removeItem('myPostId');
	}

	window.addEventListener('addPost', (e) => {
		if (e.detail.hover) {
			return; //dont need to handle hovered posts for this
		}
		if (window.myPostId == e.detail.postId) {
			window.location.hash = e.detail.postId;
			e.detail.post.previousSibling.scrollIntoView();
		}
	});

});

window.addEventListener('settingsReady', () => {

	const forms = document.getElementsByTagName('form');

	for (let i = 0; i < forms.length; i++) {
		if (forms[i].method === 'post') {
			new postFormHandler(forms[i]);
		}
	}

	const tegakiWidthSetting = document.getElementById('tegakiwidth-setting');
	const changeTegakiWidthSetting = (e) => {
		tegakiWidth = parseInt(e.target.value);
		setLocalStorage('tegakiwidth-setting', tegakiWidth);
	};
	tegakiWidthSetting.value = tegakiWidth;
	tegakiWidthSetting.addEventListener('change', changeTegakiWidthSetting, false);

	const tegakiHeightSetting = document.getElementById('tegakiheight-setting');
	const changeTegakiHeightSetting = (e) => {
		tegakiHeight = parseInt(e.target.value);
		setLocalStorage('tegakiheight-setting', tegakiHeight);
	};
	tegakiHeightSetting.value = tegakiHeight;
	tegakiHeightSetting.addEventListener('change', changeTegakiHeightSetting, false);

});


code:
document.querySelectorAll('input[type="file"]').forEach(fileInput => {
	//not using display: none because we still want to show the browser prompt for a "required" file
	fileInput.style.position = 'absolute';
	fileInput.style.border = 'none';
	fileInput.style.height = '1px';
	fileInput.style.width = '1px';
//	fileInput.style.opacity = '0'; // same effect as display:none in some browsers, ugh...
});

repo_name: diegolegion/algochan

license: mit


code:
/* globals __ setLocalStorage */
let imageSources = new Set(JSON.parse(localStorage.getItem('hiddenimages')));
let imageSourcesList;

const toggleAllHidden = (state) => imageSources.forEach(i => toggleSource(i, state));

const toggleSource = (source, state) => {
	const images = document.querySelectorAll(`img.file-thumb[src="${source}"], img.catalog-thumb[src="${source}"]`);
	images.forEach(i => i.classList[state?'add':'remove']('vh'));
	const buttons = document.querySelectorAll(`a.hide-image[data-src="${source}"]`);
	buttons.forEach(b => b.textContent = state ? __('Show') : __('Hide'));
};

toggleAllHidden(true);

const toggleHandler = (e) => {
	const thumbSource = e.target.dataset.src;
	const hidden = imageSources.has(thumbSource);
	imageSources[hidden?'delete':'add'](thumbSource);
	imageSourcesList.value = [...imageSources];
	setLocalStorage('hiddenimages', JSON.stringify([...imageSources]));
	toggleSource(thumbSource, !hidden);
};

Array.from(document.getElementsByClassName('hide-image')).forEach(el => {
	el.addEventListener('click', toggleHandler, false);
});

const handleHiddenImages = (e) => {
	//hide any images from this post that should already be hidden
	e.detail.json.files.forEach(f => {
		let hideFilename = '/file/';
		if (f.hasThumb) {
			hideFilename += `thumb/${f.hash}${f.thumbextension}`;
		} else {
			hideFilename += f.filename;
		}
		if (imageSources.has(hideFilename)) {
			toggleSource(hideFilename, true);
		}
	});
	//add the hide toggle link and event listener
	if (!e.detail.hover) {
		const hideButtons = e.detail.post.querySelectorAll('.hide-image');
		for (let i = 0; i < hideButtons.length; i++) {
			hideButtons[i].addEventListener('click', toggleHandler, false);
		}
	}
};

window.addEventListener('addPost', handleHiddenImages, false);

window.addEventListener('settingsReady', () => {

	imageSourcesList = document.getElementById('hiddenimages-setting');
	imageSourcesList.value = [...imageSources];
	const imageSourcesListClearButton = document.getElementById('hiddenimages-clear');
	const clearImageSources = () => {
		toggleAllHidden(false);
		imageSources = new Set();
		imageSourcesList.value = '';
		setLocalStorage('hiddenimages', '[]');
		console.log('cleared hidden images list');
	};
	imageSourcesListClearButton.addEventListener('click', clearImageSources, false);

});


code:
/* globals isManage isModView extraLocals deleteStartsWith setLocalStorage post */
window.addEventListener('DOMContentLoaded', () => {

	const quotes = document.getElementsByClassName('quote');
	let hoverLoading = {};
	let hovering = false;
	let lastHover;

	const toggleDottedUnderlines = (hoveredPost, id) => {
		let uniqueQuotes = new Set();
		hoveredPost.querySelectorAll('.post-message .quote').forEach(q => uniqueQuotes.add(q.href));
		if (uniqueQuotes.size > 1) {
			const matchingQuotes = hoveredPost.querySelectorAll(`.post-message .quote[href$="${id}"]`);
			for (let i = 0; i < matchingQuotes.length; i++) {
				const mq = matchingQuotes[i];
				mq.style.borderBottom = mq.style.borderBottom == '' ? '1px dashed' : '';
				mq.style.textDecoration = mq.style.textDecoration == '' ? 'none' : '';
			}
		}
	};

	const isVisible = (e) => {
		const top = e.getBoundingClientRect().top;
		const bottom = e.getBoundingClientRect().bottom;
		const height = window.innerHeight;
		return top >= 38 && bottom <= height;
	};

	const setFloatPos = (quote, float, xpos, ypos) => {
		const quotepos = quote.getBoundingClientRect();
		const post = float.firstChild;
		const iw = document.body.offsetWidth-10;
		const ih = window.innerHeight;
		const left = xpos < iw/2;
		if (left) {
			float.style.left = `${quotepos.right+10}px`;
			if (quotepos.right+10+post.offsetWidth >= iw) {
				float.style.right = '5px';
			}
		} else {
			float.style.right = `${iw-quotepos.left+15}px`;
			if (quotepos.left-15 < post.offsetWidth) {
				float.style.left = '5px';
			}
		}
		const top = ypos < ih/2;
		if (top && quotepos.bottom+post.offsetHeight < ih) {
			float.style.top = `${quotepos.top}px`;
		} else if (!top && post.offsetHeight < ypos) {
			float.style.top = `${quotepos.bottom-post.offsetHeight}px`;
		} else {
			float.style.top = '42px';
		}
	};

	const floatPost = (quote, post, xpos, ypos) => {
		const clone = document.createElement('div');
		clone.id = 'float';
		clone.classList.remove('hoverhighlighted');
		clone.appendChild(post.cloneNode(true));
		document.body.appendChild(clone);
		setFloatPos(quote, clone, xpos, ypos);
		return clone;
	};

	const toggleHighlightPost = async function (e) {
		hovering = e.type === 'mouseover';
		let jsonParts = this.pathname.replace(/\.html$/, '.json').split('/');
		let jsonPath;
		if ((isManage || isModView) && jsonParts.length === 5) {
			jsonParts.splice(2,1); //remove manage from json url
		}
		jsonPath = jsonParts.join('/');
		if (!this.hash) {
			return; //non-post number board quote
		}
		const float = document.getElementById('float');
		if (float) {
			document.body.removeChild(float);
		}
		const parentPost = this.closest('.post-container');
		let thisId = 0;
		if (parentPost) {
			thisId = parentPost.dataset.postId;
		}
		const loading = Date.now();
		lastHover = loading;
		const hash = this.hash.substring(1);
		const anchor = document.getElementById(hash);
		let hoveredPost, postJson;
		if (anchor
			&& jsonPath.split('/')[1] === anchor.nextSibling.dataset.board) {
			hoveredPost = anchor.nextSibling;
		} else {
			let hovercache = localStorage.getItem(`hovercache-${jsonPath}`);
			if (hovercache) {
				hovercache = JSON.parse(hovercache);
				if (hovercache.postId == hash) {
					postJson = hovercache;
				} else if (hovercache.replies.length > 0) {
					postJson = hovercache.replies.find(r => r.postId == hash);
				}
			}
			if (!postJson) { //wasnt cached or cache outdates
				this.style.cursor = 'wait';
				let json;
				try {
					if (!hoverLoading[jsonPath]) {
						hoverLoading[jsonPath] = fetch(jsonPath).then(res => res.json());
					}
					json = await hoverLoading[jsonPath];
				} catch (e) {
					return console.error(e);
				} finally {
					this.style.cursor = '';
				}
				if (json) {
					setLocalStorage(`hovercache-${jsonPath}`, JSON.stringify(json));
					hoverCacheList.value = Object.keys(localStorage).filter(k => k.startsWith('hovercache'));
					if (json.postId == hash) {
						postJson = json;
					} else {
						postJson = json.replies.find(r => r.postId == hash);
					}
				} else {
					return localStorage.removeItem(`hovercache-${jsonPath}`); //thread deleted
				}
			}
			if (lastHover !== loading) {
				return; //dont show for ones not hovering
			}
			if (!postJson) {
				return; //post was deleted or missing
			}
			const postHtml = post({
				post: postJson,
				...extraLocals,
			});
			const wrap = document.createElement('div');
			wrap.innerHTML = postHtml;
			hoveredPost = wrap.firstChild.nextSibling;
		}
		if (hovering && !isVisible(hoveredPost)) {
			hoveredPost = floatPost(this, hoveredPost, e.clientX, e.clientY);
		} else {
			hovering ? hoveredPost.classList.add('hoverhighlighted') : hoveredPost.classList.remove('hoverhighlighted');
		}
		if (postJson) {
			//need this event so handlers like post hiding still apply to hover introduced posts
			const newPostEvent = new CustomEvent('addPost', {
				detail: {
					json: postJson,
					post: hoveredPost,
					postId: postJson.postId,
					hover: true,
					nonotify: true,
				}
			});
			window.dispatchEvent(newPostEvent);
		}
		if (hovering || isVisible(hoveredPost)) {
			toggleDottedUnderlines(hoveredPost, thisId);
		}
	};

	for (let i = 0; i < quotes.length; i++) {
		quotes[i].addEventListener('mouseover', toggleHighlightPost, false);
		quotes[i].addEventListener('mouseout', toggleHighlightPost, false);
	}

	window.addEventListener('addPost', function(e) {
		if (e.detail.hover) {
			return; //dont need to handle hovered posts for this
		}
		const newquotes = document.getElementsByClassName('quote'); //to get backlinks from replying posts. just an easy way. could make more efficient and only do necessary ones later.
		for (let i = 0; i < newquotes.length; i++) {
			newquotes[i].removeEventListener('mouseover', toggleHighlightPost);
			newquotes[i].removeEventListener('mouseout', toggleHighlightPost);
			newquotes[i].addEventListener('mouseover', toggleHighlightPost, false);
			newquotes[i].addEventListener('mouseout', toggleHighlightPost, false);
		}
	});

	window.addEventListener('updatePostMessage', function(e) {
		const newquotes = e.detail.post.getElementsByClassName('quote');
		for (let i = 0; i < newquotes.length; i++) {
			newquotes[i].addEventListener('mouseover', toggleHighlightPost, false);
			newquotes[i].addEventListener('mouseout', toggleHighlightPost, false);
		}
	});

});

let hoverCacheList;
window.addEventListener('settingsReady', function() {
	hoverCacheList = document.getElementById('hovercachelist-setting');
	hoverCacheList.value = Object.keys(localStorage).filter(k => k.startsWith('hovercache'));
	const hoverCacheListClearButton = document.getElementById('hovercachelist-clear');
	const clearHoverCacheList = () => {
		deleteStartsWith('hovercache');
		hoverCacheList.value = '';
		console.log('cleared cache');
	};
	hoverCacheListClearButton.addEventListener('click', clearHoverCacheList, false);
});


code:
/* eslint-disable no-unused-vars */
/* globals TRANSLATIONS */

const pluralMap = {
	1: 'one',
	// two, three, few, many, ...
};

//simple translation
const __ = (key, replacement=null) => {
	const translation = TRANSLATIONS[key] || key;
	return replacement !== null ? translation.replace('%s', replacement) : translation;
};

//pluralisation
const __n = (key, count) => {
	const pluralKey = pluralMap[count] || 'other';
	const translationObj = TRANSLATIONS[key];
	if (!translationObj) {
		return key;
	}
	const translationPlural = translationObj[pluralKey] || translationObj['other'];
	return translationPlural.replace('%s', count);
};


code:
/* globals setLocalStorage */
window.addEventListener('settingsReady', () => {

	const settingNames = ['volume','loop','imageloadingbars','live','scroll','localtime','relative','24hour','notifications','hiddenimages', 'threadwatcher'
		,'notification-yous-only','yous-setting','filters1','name','theme','codetheme','customcss','disableboardcss','hiderecursive', 'watchlist'
		,'heightlimit','crispimages','hidethumbnails','noncolorids','alwaysshowspoilers','hidedeletedpostcontent','hidepoststubs','smoothscrolling'
		,'tegakiheight-setting','tegakiwidth-setting'];

	const importExportText = document.getElementById('import-export-setting');

	const exportSettingsButton = document.getElementById('export-setting');
	const exportSettings = () => {
		const settingsData = settingNames.reduce((acc, val) => {
			acc[val] = localStorage.getItem(val);
			return acc;
		}, {});
		importExportText.value = JSON.stringify(settingsData);
	};
	exportSettingsButton.addEventListener('click', exportSettings, false);

	const importSettingsButton = document.getElementById('import-setting');
	const importSettings = () => {
		if (importExportText.value.length > 0) {
			try {
				const importedSettings = JSON.parse(importExportText.value);
				Object.entries(importedSettings).forEach(entry => {
					setLocalStorage(entry[0], entry[1]);
				});
				//should we have an update listener one day to import without refresh?
				location.reload();
			} catch (e) {
				//bad data
				console.error(e);
			}
		}
	};
	importSettingsButton.addEventListener('click', importSettings, false);

});


code:
/* globals __ isRecent banmessage isGlobalRecent isThread post extraLocals isModView io setLocalStorage */
let liveEnabled = localStorage.getItem('live') == 'true';
let scrollEnabled = localStorage.getItem('scroll') == 'true';
let socket;
let socketPingInterval;
let forceUpdate;
let newPost;

window.addEventListener('settingsReady', function() { //after domcontentloaded

	let supportsWebSockets = 'WebSocket' in window || 'MozWebSocket' in window;
	const livecolor = document.getElementById('livecolor');
	const liveElem = document.getElementById('livetext');
	const livetext = (isThread || isRecent) && liveElem ? liveElem.childNodes[1] : null;
	let room = liveElem && liveElem.dataset.room;
	const viewRawIp = liveElem && liveElem.dataset.viewRawIp === 'true';
	const updateButton = document.getElementById('updatepostsbutton');
	const updateLive = (message, color) => {
		livecolor.style.backgroundColor = color;
		livetext.nodeValue = `${message}`;
	};
	let lastPostIds = {};
	let liveTimeout;
	let retrySocketTimeout;
	const postContainers = document.getElementsByClassName('post-container');
	for (let i = 0; i < postContainers.length; i++) {
		const postContainer = postContainers[i];
		const { board, postId } = postContainer.dataset;
		lastPostIds[board] = Math.max((lastPostIds[board] || 0), postId);
	}

	//add text before post-info to show posts deleted, moved, etc
	const markPost = (data) => {
		console.log('got mark post message', data);
		const anchor = document.getElementById(data.postId);
		const postContainer = anchor.nextSibling;
		const isOp = postContainer && postContainer.classList.contains('op');
		let dataMark;
		let applyToReplies = false;
		let disableReplies = false;
		switch (data.type) {
			case 'delete':
				dataMark = __('Deleted');
				applyToReplies = true;
				disableReplies = true;
				break;
			case 'move':
				dataMark = __('Moved');
				applyToReplies = true;
				disableReplies = true;
				break;
			case 'banmessage':
			case 'edit': //opting for no data mark, already has the usual "edited x ago"
				break;
			default:
				return;
		}
		if (dataMark) {
			//mark with a red text
			postContainer.classList.add('marked');
			postContainer.setAttribute('data-mark', dataMark);
		}
		if (isOp && applyToReplies === true) {
			//moved or delete OPs then apply to whole thread
			const postContainers = document.getElementsByClassName('post-container');
			Array.from(postContainers).forEach(e => {
				e.classList.add('marked');
				e.setAttribute('data-mark', dataMark);
			});
		}
		if (isOp && disableReplies === true) {
			//remove new reply buttons and postform
			document.getElementById('postform').remove();
			const postButtons = document.getElementsByClassName('post-button');
			Array.from(postButtons).forEach(e => e.remove());
			//and disconnect socket
			if (socket.connected === true) {
				socket.disconnect();
			}
		}
		if (data.type === 'edit') {
			let insertPoint = postContainer.nextSibling;
			let insertPosition = 'beforeBegin';
			if (!insertPoint) {
				//No next sibling, this is the last post in a thread
				insertPoint = postContainer.parentElement;
				insertPosition = 'beforeEnd';
			}
			anchor.remove();
			postContainer.remove();
			newPost(data, {
				nonotify: true, //should we notify of edits in open threads, maybe just for OP? idk
				insertPoint,
				insertPosition,
			});
		} else if (data.type === 'banmessage') {
			const banMessageHtml = banmessage({
				banmessage: data.banmessage,
			});
			const existingBanMessage = postContainer.querySelector('.ban');
			if (existingBanMessage) {
				existingBanMessage.insertAdjacentHTML('afterend', banMessageHtml);
				existingBanMessage.remove();
			} else {
				postContainer.insertAdjacentHTML('beforeend', banMessageHtml);
			}
		}
	};

	newPost = (data, options = {}) => {
		//insert at end of thread, but insert at top for globalmanage
		//console.log('got new post', data);
		const postData = data;
		lastPostIds[postData.board] = Math.max(lastPostIds[postData.board] || 0, postData.postId);
		//create a new post
		const postHtml = post({
			viewRawIp,
			post: postData,
			modview: isModView,
			manage: (isRecent && !isGlobalRecent),
			globalmanage: isGlobalRecent,
			upLevel: isThread,
			...extraLocals,
		});
		let insertPoint;
		if (options.insertPoint) {
			insertPoint = options.insertPoint;
		} else if (isRecent) {
			const firstHr = document.querySelector('hr');
			const newHr = document.createElement('hr');
			const threadWrapper = document.createElement('div');
			threadWrapper.classList.add('thread');
			insertPoint = threadWrapper;
			firstHr.insertAdjacentElement('beforebegin', newHr);
			newHr.insertAdjacentElement('afterend', threadWrapper);
		} else {
			insertPoint = document.querySelector('.thread');
		}
		insertPoint.insertAdjacentHTML(options.insertPosition || 'beforeend', postHtml);
		if (isRecent) {
			//cap the recent pages to 20 posts so they dont grow to infinity
			Array.from(document.getElementsByClassName('thread'))
				.slice(20)
				.forEach(el => {
					el.previousSibling.remove();
					el.remove();
				});
		}
		//add backlink to quoted posts
		for (let j = 0; j < postData.quotes.length; j++) {
			const quoteData = postData.quotes[j];
			const quotedPost = document.querySelector(`.post-container[data-post-id="${quoteData.postId}"][data-board="${postData.board}"]`);
			if (quotedPost) { //check if exists, it wont necessarily, e.g. global/manage recent pages
				let replies = quotedPost.querySelector('.replies');
				if (!replies) {
					const quotedPostData = quotedPost.querySelector('.post-data');
					const newRepliesDiv = document.createElement('div');
					newRepliesDiv.textContent = 'Replies: ';
					newRepliesDiv.classList.add('replies', 'mt-5', 'ml-5');
					quotedPostData.appendChild(newRepliesDiv);
					replies = newRepliesDiv;
				}
				if (new RegExp(`>>${postData.postId}(\\s|$)`).test(replies.innerText)) {
					//reply link already exists (probably from a late catch up)
					continue;
				}
				const newReply = document.createElement('a');
				const space = document.createTextNode(' ');
				newReply.href = `/${postData.board}/${(isModView || isRecent) ? 'manage/' : ''}thread/${postData.thread || postData.postId}.html#${postData.postId}`;
				newReply.textContent = `>>${postData.postId}`;
				newReply.classList.add('quote');
				replies.appendChild(newReply);
				replies.appendChild(space);
			}
		}
		const newPostAnchor = document.getElementById(postData.postId);
		const newPostElement = newPostAnchor.nextSibling;
		if (scrollEnabled) {
			if (isGlobalRecent) {
				window.scrollTo(0, 0); //recent pages are reverse sort, so just go to top
			} else {
				newPostAnchor.scrollIntoView(); //scroll to post if enabled;
			}
		}
		const newPostEvent = new CustomEvent('addPost', {
			detail: {
				nonotify: options.nonotify,
				post: newPostElement,
				postId: postData.postId,
				json: postData
			}
		});
		//dispatch the event so quote click handlers, image expand, etc can be added in separate scripts by listening to the event
		setTimeout(() => {
			window.dispatchEvent(newPostEvent);
		}, 50);
	};

	let jsonParts = window.location.pathname.replace(/\.html$/, '.json').split('/');
	let jsonPath;
	if (isModView) {
		jsonParts.splice(2,1); //remove manage from json url
	}
	jsonPath = jsonParts.join('/');
	const fetchNewPosts = async () => {
		console.log('fetching posts from api');
		updateLive(__('Fetching posts...'), 'yellow');
		let json;
		let newPosts = [];
		try {
			json = await fetch(jsonPath).then(res => res.json());
		} catch (e) {
			console.error(e);
		}
		const postsList = (json && json.replies) ? json.replies : json;
		if (postsList && Array.isArray(postsList) && postsList.length > 0) {
			newPosts = postsList.filter(r => {
				return r.postId > (lastPostIds[r.board] || 0);
			}); //filter to only newer posts
			if (newPosts.length > 0) {
				for (let i = 0; i < newPosts.length; i++) {
					newPost(newPosts[i]);
				}
			}
		}
		updateLive(__('Updated'), 'green');
		return newPosts.length;
	};

	let interval = 5000;
	let intervalStart;
	forceUpdate = async () => {
		updateButton.disabled = true;
		clearTimeout(liveTimeout);
		if ((await fetchNewPosts()) > 0) {
			interval = 5000;
		} else {
			interval = Math.min(interval*2, 90000);
		}
		setTimeout(() => {
			updateButton.disabled = false;
		}, 10000);
		if (liveEnabled) {
			intervalStart = Date.now();
			liveTimeout = setTimeout(forceUpdate, interval);
		}
	};

	setInterval(() => {
		if (liveEnabled && intervalStart) {
			const remaining = Math.abs((interval - (Date.now() - intervalStart))/1000);
			updateButton.value = `Update (${remaining.toFixed(0)}s)`;
		}
	}, 1000);

	const enableLive = () => {
		if (supportsWebSockets) {
			updateButton.style.display = 'none';
			if (!room) {
				const roomParts = window.location.pathname.replace(/\.html$/, '').split('/');
				room = `${roomParts[1]}-${roomParts[roomParts.length-1]}`;
			}
			socket = io({
				transports: ['websocket'],
				reconnectionAttempts: 3,
				reconnectionDelay: 3000,
				reconnectionDelayMax: 15000,
			});
			const socketPing = () => {
				const pingStart = Date.now();
				socket.volatile.emit('ping', () => {
					const latency = Date.now() - pingStart;
					updateLive(__('Connected for live posts (%sms)', latency), '#0de600');
				});
			};
			const fallbackToPolling = () => {
				console.log('falling back to polling');
				socket.close();
				supportsWebSockets = false;
				enableLive();
				clearTimeout(retrySocketTimeout);
				retrySocketTimeout = setTimeout(() => {
					supportsWebSockets = true;
					enableLive();
				}, 6000);
			};
			socket.on('connect', async () => {
				console.log('socket connected');
				await fetchNewPosts();
				socket.emit('room', room);
				clearInterval(socketPingInterval);
				socketPingInterval = setInterval(socketPing, 20000);
			});
			socket.on('message', (message) => {
				console.log(message, room);
				if (message === 'joined') {
					updateLive(__('Connected for live posts'), '#0de600');
					socketPing();
				}
			});
			socket.on('reconnect_attempt', () => {
				updateLive(__('Attempting to reconnect...'), 'yellow');
			});
			socket.on('disconnect', () => {
				console.log('lost connection to room');
				updateLive(__('Disconnected'), 'red');
			});
			socket.on('reconnect', () => {
				console.log('reconnected to room');
				fetchNewPosts();
			});
			socket.on('error', (e) => {
				updateLive(__('Socket error'), 'orange');
				console.error(e);
			});
			socket.on('connect_error', (e) => {
				updateLive(__('Error connecting'), 'orange');
				console.error(e);
				fallbackToPolling();
			});
			socket.on('reconnect_error', (e) => {
				updateLive(__('Error reconnecting'), 'orange');
				console.error(e);
			});
			socket.on('reconnect_failed', (e) => {
				updateLive(__('Failed reconnecting'), 'orange');
				console.error(e);
				fallbackToPolling();
			});
			socket.on('newPost', newPost);
			socket.on('markPost', markPost);
		} else {
			//websocket not supported, update with polling to api
			updateButton.removeAttribute('style');
			forceUpdate();
		}
	};

	const disableLive = () => {
		updateButton.value = 'Update';
		updateButton.removeAttribute('style');
		clearTimeout(liveTimeout);
		if (socket && supportsWebSockets) {
			socket.disconnect();
		}
		updateLive(__('Live posts off'), 'darkgray');
	};

	const liveSetting = document.getElementById('live-setting');
	const toggleLive = () => {
		liveEnabled = !liveEnabled;
		liveEnabled ? enableLive() : disableLive();
		console.log('toggling live posts', liveEnabled);
		setLocalStorage('live', liveEnabled);
	};
	liveSetting.checked = liveEnabled;
	liveSetting.addEventListener('change', toggleLive, false);

	const scrollSetting = document.getElementById('scroll-setting');
	const toggleScroll = () => {
		scrollEnabled = !scrollEnabled;
		console.log('toggling post scrolling', scrollEnabled);
		setLocalStorage('scroll', scrollEnabled);
	};
	scrollSetting.checked = scrollEnabled;
	scrollSetting.addEventListener('change', toggleScroll, false);

	if ((isThread || isRecent) && updateButton) {
		updateButton.addEventListener('click', forceUpdate);
		liveEnabled ? enableLive() : disableLive();
	}

});


code:
/* eslint-disable no-unused-vars */
/* globals setDefaultLocalStorage settings */
const isCatalog = /^\/(\w+\/(manage\/)?)?catalog.html/.test(window.location.pathname);
const isThread = /\/\w+\/thread\/\d+.html/.test(window.location.pathname);
const isModView = /\/\w+\/manage\/(thread\/)?(index|\d+).html/.test(window.location.pathname);
const isManage = /\/(\w+\/manage|globalmanage)\/(recent|reports|bans|boards|(global)?logs|settings|banners|accounts|roles|news|filters|custompages).html/.test(window.location.pathname);
const isGlobalRecent = window.location.pathname === '/globalmanage/recent.html';
const isRecent = isGlobalRecent || window.location.pathname.endsWith('/manage/recent.html');

function setLocalStorage(key, value) {
	try {
		localStorage.setItem(key, value);
	} catch (e) {
		deleteStartsWith();
	} finally {
		localStorage.setItem(key, value);
	}
}

function appendLocalStorageArray(key, value) {
	const storedArray = JSON.parse(localStorage.getItem(key));
	storedArray.push(value);
	setLocalStorage(key, JSON.stringify(storedArray));
}

function deleteStartsWith(startString='hovercache') {
	//clears cache when localstorage gets full
	const hoverCaches = Object.keys(localStorage).filter(k => k.startsWith(startString));
	for (let i = 0; i < hoverCaches.length; i++) {
		localStorage.removeItem(hoverCaches[i]);
	}
}

function setDefaultLocalStorage(key, value) {
	if (!localStorage.getItem(key)) {
		setLocalStorage(key, value);
	}
}

//todo: just make the localstorage name match the names of settings and put a loop
const localStorageDefaults = {
	'volume': settings.defaultVolume,
	'loop': settings.loop,
	'imageloadingbars': settings.imageLoadingBars,
	'live': settings.live,
	'scroll': settings.scrollToPosts,
	'localtime': settings.localTime,
	'relative': settings.relativeTime,
	'24hour': settings.hour24Time,
	'notifications': settings.notificationsEnabled,
	'notification-yous-only': settings.notificationsYousOnly,
	'yous-setting': settings.showYous,
	'threadwatcher': settings.threadWatcher,
	'threadwatcher-minimise': false,
	'disableboardcss': false,
	'tegakiwidth-setting': settings.tegakiWidth,
	'tegakiheight-setting': settings.tegakiHeight,
	'filters1': '[]',
	'yous': '[]',
	'watchlist': '[]',
	'name': '',
	'theme': 'default',
	'codetheme': 'default',
	'customcss': '',
	'overboardsettings': '{"add":"","rem":"","include_default":true}',
};

Object
	.entries(localStorageDefaults)
	.map(entry => setDefaultLocalStorage(entry[0], entry[1]));


code:
/* globals setLocalStorage hoverCacheList newPost */
window.addEventListener('DOMContentLoaded', () => {

	let loading = {};

	const hideOmitted = (e) => {
		e.target.nextSibling.style.display = 'unset';
		const thread = e.target.closest('.thread');
		const posts = Array.from(thread.querySelectorAll('.post-container'));
		let replies = posts.slice(1);
		if (e.target.dataset.shown > 0) {
			replies = replies.slice(0, -parseInt(e.target.dataset.shown));
		}
		replies.forEach(r => {
			r.previousSibling.remove();
			r.remove();
		});
		e.target.removeAttribute('data-open');
		e.target.src = '/file/plus.png';
	};

	const expandOmitted = async (e) => {
		const threadId = e.target.dataset.thread;
		const board = e.target.dataset.board;
		const parentPost = e.target.closest('.post-container');
		const firstPreviewReply = parentPost.nextSibling && parentPost.nextSibling.nextSibling; //the first preview reply
		const jsonPath = `/${board}/thread/${threadId}.json`;
		let hovercache = localStorage.getItem(`hovercache-${jsonPath}`);
		let replies;
		if (hovercache) {
			hovercache = JSON.parse(hovercache);
			//if we have the first preview reply in cache, the cache is fresh enough to show the omitted posts
			if (firstPreviewReply && hovercache.replies.find(r => r.postId == firstPreviewReply.dataset.postId)) {
				replies = hovercache.replies;
			}
			//note: will always fetch (on new page load) for sticky preview replies = 0
		}
		if (!replies) {
			e.target.style.cursor = 'wait';
			e.target.classList.add('spin');
			let json;
			try {
				//same as hovering post replies
				if (!loading[jsonPath]) {
					loading[jsonPath] = fetch(jsonPath).then(res => res.json());
				}
				json = await loading[jsonPath];
			} catch (e) {
				return console.error(e);
			} finally {
				e.target.style.cursor = '';
				e.target.classList.remove('spin');
			}
			if (json) {
				setLocalStorage(`hovercache-${jsonPath}`, JSON.stringify(json));
				hoverCacheList.value = Object.keys(localStorage).filter(k => k.startsWith('hovercache'));
				replies = [...json.replies];
			} else {
				return localStorage.removeItem(`hovercache-${jsonPath}`); //thread deleted
			}
		}
		if (!replies) {
			return;
		}
		const opReplies = parentPost.querySelector('.replies');
		if (opReplies && opReplies.innerText.includes('+')) {
			const earlierLink = opReplies.lastElementChild;
			earlierLink.previousSibling.remove();
			earlierLink.remove();
		}
		replies = replies.reverse();
		e.target.nextSibling.style.display = 'none';
		e.target.src = '/file/minus.png';
		e.target.dataset.open = true;
		if (firstPreviewReply) {
			replies = replies.filter(r => r.postId < firstPreviewReply.dataset.postId);
		}
		replies.forEach(r => {
			newPost(r, {
				nonotify: true,
				insertPoint: firstPreviewReply ? parentPost.nextSibling : parentPost,
				insertPosition: firstPreviewReply ? 'beforebegin' : 'afterend',
			});
		});
	};

	const handleExpandClick = (e) => {
		if (e.target.dataset.open) {
			hideOmitted(e);
		} else {
			expandOmitted(e);
		}
	};

	const expandOmittedButtons = document.getElementsByClassName('expand-omitted');

	for (let i = 0; i < expandOmittedButtons.length; i++) {
		expandOmittedButtons[i].addEventListener('click', handleExpandClick, false);
	}

});


code:
/* globals setDefaultLocalStorage setLocalStorage */
const charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/';
const generatePassword = () => {
	try {
		if (window.crypto) {
			const buf = new Uint8Array(20); //8 keeps charcodes within range
			window.crypto.getRandomValues(buf);
			return btoa(String.fromCharCode.apply(null, buf));
		}
	} catch (e) { /* Uncaught DOMException: The operation failed for an operation-specific reason, thanks firefox */ }
	return new Array(20)
		.fill(null)
		.map(() => charset[Math.floor(Math.random()*charset.length)])
		.join('');
};

setDefaultLocalStorage('postpassword', generatePassword());

class syncedField {
	constructor(selector, key, oneWay=false, persistent=true) {
		this.fields = [];
		this.selector = selector;
		this.key = key;
		this.oneWay = oneWay;
		this.persistent = persistent;
		this.init();
	}
	init() {
		const settingsFields = document.getElementById('settingsmodal').querySelectorAll(this.selector);
		this.fields = this.fields.concat([...settingsFields]);

		const postForm = document.getElementById('postform');
		if (postForm) {
			const postformFields = postForm.querySelectorAll(this.selector);
			this.fields = this.fields.concat([...postformFields]);
		}

		const actionForm = document.getElementById('actionform');
		if (actionForm) {
			const actionFields = actionForm.querySelectorAll(this.selector);
			this.fields = this.fields.concat([...actionFields]);
		}

		if (this.oneWay) {
			settingsFields[0].addEventListener('input', (e) => { this.update(e); }, false);
		}
		for (let field of this.fields) {
			!this.oneWay && field.addEventListener('input', (e) => { this.update(e); }, false);
			field.form && field.form.addEventListener('reset', () => {
				setTimeout(() => {
					this.set(localStorage.getItem(this.key));
				}, 0);
			}, false);
		}
		this.set(localStorage.getItem(this.key));
	}
	update(e) {
		if (this.persistent) {
			setLocalStorage(this.key, e.target.value);
		}
		this.set(e.target.value);
	}
	set(val) {
		if (val === null) { return; }
		for (let field of this.fields) {
			field.value = val;
			if (field.tagName === 'SELECT') {
				//necessary to set selectedIndex here?
				const changeEvent = new Event('change');
				field.dispatchEvent(changeEvent);
			}
		}
	}

}

window.addEventListener('settingsReady', () => {

	new syncedField('input[name="postpassword"]', 'postpassword');
	new syncedField('input[name="name"]', 'name');

	const boardUri = window.location.pathname.split('/')[1];
	new syncedField('select[name="customflag"]', `customflag-${boardUri}`);

});


code:
/* globals __ */
//https://github.com/ussaohelcim/ptchina-playlist/tree/bookmarklet-let
async function threadToPlaylist(board, postId) {
	async function getThread() {
		let link = `${window.location.origin}/${board}/thread/${postId}.json`;
		return await fetch(link).then(res => res.json());
	}
	function isAudioOrVideo(file) {
		const mimeTypes = ['video', 'audio'];
		const fileType = file.mimetype.split('/')[0];
		return fileType === mimeTypes[0] || fileType === mimeTypes[1];
	}
	async function getMedia(thread) {
		const files = [];
		thread.files.forEach((file) => {
			if (isAudioOrVideo(file)) {
				files.push(file);
			}
		});
		for (let i = 0; i < thread.replies.length; i++) {
			const replyFiles = thread.replies[i].files;
			replyFiles.forEach((file) => {
				if (isAudioOrVideo(file)) {
					files.push(file);
				}
			});
		}
		return files;
	}
	function createPlaylist(medias) {
		const lines = [];
		lines.push('#EXTM3U');
		for (let i = 0; i < medias.length; i++) {
			const media = medias[i];
			lines.push(`#EXTINF:${media.duration}, ${media.originalFilename}`);
			lines.push(`${location.origin}/file/${media.filename}`);
		}
		let playlist = lines.join('\n');
		return playlist;
	}
	function downloadPlaylist(filename, playlist) {
		const blob = new Blob([playlist], { type: 'application/mpegurl' });
		const url = window.URL.createObjectURL(blob);
		const a = document.createElement('a');
		a.href = url;
		a.target = '_blank';
		a.download = filename;
		a.style.display = 'none';
		document.body.appendChild(a);
		a.click();
		document.body.removeChild(a);
	}
	try {
		const thread = await getThread();
		const files = await getMedia(thread);
		const playlist = await createPlaylist(files);
		if (playlist.split('\n').length > 1) {
			downloadPlaylist(`${thread.board}-${thread.postId}.m3u`, playlist);
		} else {
			alert(__('No video/audio files in this thread.'));
		}
	} catch (error) {
		console.log(error);
	}
}
window.addEventListener('createPlaylist', (e) => {
	threadToPlaylist(e.detail.board, e.detail.postId);
});


code:
/* globals isThread setLocalStorage */
window.addEventListener('DOMContentLoaded', () => {

	const postForm = document.querySelector('#postform');
	const topPostButton = document.querySelector('a[href="#postform"]');
	const bottomPostButton = document.querySelector('.bottom-reply');
	const messageBox = document.getElementById('message');

	const openPostForm = (e) => {
		if (e) {
			e.preventDefault();
		}
		if (!postForm) {
			return;
		}
		history.replaceState({}, '', '#postform');
		postForm.style.display = 'flex';
		topPostButton.style.visibility = 'hidden';
		if (bottomPostButton) {
			bottomPostButton.style.display = 'none';
		}
		if (messageBox) {
			messageBox.focus();
		}
		postForm.dispatchEvent(new Event('opened'));
	};
	const closePostForm = (e) => {
		e.preventDefault();
		history.replaceState({}, '', location.pathname);
		postForm.style.display = 'none';
		topPostButton.style.visibility = 'visible';
		if (bottomPostButton) {
			bottomPostButton.style.display = '';
		}
	};
	if (postForm) {
		const closeButton = postForm ? postForm.querySelector('.close') : null;
		topPostButton.addEventListener('click', openPostForm, false);
		if (bottomPostButton) {
			bottomPostButton.addEventListener('click', openPostForm, false);
		}
		closeButton.addEventListener('click', closePostForm, false);
	}

	const addToMessageBox = (str) => {
		const index = messageBox.selectionStart;
		messageBox.value = `${messageBox.value.substr(0,index)}${str}${messageBox.value.substr(index)}`;
		messageBox.setSelectionRange(index+str.length, index+str.length); //this scroll anyway, no need to set scrolltop
	};

	const addQuote = function(number) {
		let quoteText = `>>${number}\n`;
		let selection;
		if (window.getSelection) {
			selection = window.getSelection().toString();
		} else if (document.getSelection) {
			selection = document.getSelection().toString();
		} else if (document.selection) {
			selection = document.selection.createRange().text;
		}
		if (selection && selection.length > 0) {
			const quotedSelection = selection.split(/\r?\n/) //split by lines
				.map(line => line.trim().length > 0 ? `>${line}` : line) //make non empty lines greentext
				.join('\n'); //join it back together and newline
			quoteText += `${quotedSelection}\n`;
		}
		openPostForm();
		addToMessageBox(quoteText);
		messageBox.focus();
		messageBox.dispatchEvent(new Event('input'));
	};

	const quote = function(e) {
		const quoteNum = this.textContent.trim();
		if (isThread && !e.ctrlKey) {
			addQuote(quoteNum);
		} else {
			setLocalStorage('clickedQuote', quoteNum);
		}
	};

	//on loading page open with js method if user has scripts
	if (location.hash === '#postform') {
		openPostForm();
	}
	if (isThread) {
		//add quote to postform if link clicked with quote
		const quoteNum = localStorage.getItem('clickedQuote');
		if (quoteNum != null) {
			addQuote(quoteNum);
			//scroll to the post you quoted
			const quotingPost = document.getElementById(quoteNum);
			if (quotingPost) {
				quotingPost.scrollIntoView();
			}
		}
		localStorage.removeItem('clickedQuote');
	}

	const addQuoteListeners = (l) => {
		for (let i = 0; i < l.length; i++) {
			l[i].addEventListener('click', quote, false);
		}
	};

	const links = document.getElementsByClassName('post-quoters');
	addQuoteListeners(links);

	window.addEventListener('addPost', function(e) {
		if (e.detail.hover) {
			return; //dont need to handle hovered posts for this
		}
		const post = e.detail.post;
		const newlinks = post.getElementsByClassName('post-quoters');
		addQuoteListeners(newlinks);
	});

});


code:
/* globals Web3 */

if (window.ethereum) {
	window.jschanweb3 = new Web3(window.ethereum);
} else {
	document.querySelectorAll('.web3')
		.forEach(elem => elem.remove());
}


code:
/* globals setLocalStorage */
const overboardLink = document.getElementById('overboardlink');
if (overboardLink) {
	const overboardLinkPathname = new URL(overboardLink.href).pathname;
	const updateOverboardLink = () => {
		const overboardSettings = JSON.parse(localStorage.getItem('overboardsettings'));
		if (overboardSettings.add.length > 0 || overboardSettings.rem.length > 0 || !overboardSettings.include_default) {
			overboardLink.setAttribute('href', `${overboardLinkPathname}?${new URLSearchParams(overboardSettings)}`);
		}
	};
	updateOverboardLink();
	const overboardForm = document.getElementById('overboardform');
	if (overboardForm) {
		const saveOverboardSettings = () => {
			const newOverboardSettings = {
				add: overboardForm.elements.add.value,
				rem: overboardForm.elements.rem.value,
				include_default: overboardForm.elements.include_default.checked,
			};
			if (newOverboardSettings.add.length === 0 && !newOverboardSettings.include_default) {
				newOverboardSettings.include_default = true; //nice
			}
			setLocalStorage('overboardsettings', JSON.stringify(newOverboardSettings));
		};
		overboardForm.addEventListener('submit', saveOverboardSettings, false);
	}
}


code:
/* globals modal themes codeThemes */
window.addEventListener('DOMContentLoaded', () => {

	let settingsModal;
	let settingsBg;

	const hideSettings = () => {
		settingsModal.style.display = 'none';
		settingsBg.style.display = 'none';
	};

	const openSettings = () => {
		settingsModal.style.display = 'unset';
		settingsBg.style.display = 'unset';
	};

	const modalHtml = modal({
		modal: {
			title: 'Settings',
			settings: {
				themes,
				codeThemes,
			},
			hidden: true,
		}
	});

	document.body.insertAdjacentHTML('afterbegin', modalHtml);
	settingsBg = document.getElementsByClassName('modal-bg')[0];
	settingsModal = document.getElementsByClassName('modal')[0];

	settingsBg.onclick = hideSettings;
	settingsModal.getElementsByClassName('close')[0].onclick = hideSettings;

	const settings = document.getElementById('settings');
	if (settings) { //can be false if we are in minimal view
		settings.onclick = openSettings;
	}

	window.dispatchEvent(new CustomEvent('settingsReady'));

});


code:
/* globals Tegaki */
function showTegakiReplay(e) {
	e.preventDefault(); //prevent nojs download fallback
	Tegaki.open({
		replayMode: true,
		replayURL: e.target.href,
	});
	/* set color palette to same as what the replay should start at (not a recorded event because we
		set it programmatically when calling tegaki.open for drawing). It looks like the replay uses colors
		from the wrong palette otherwise. */
	Tegaki.setColorPalette(2);
}

function addReplayListeners(elem) {
	const replayLinks = Array.from(elem.getElementsByClassName('replay-tegaki'));
	replayLinks.forEach(replayLink => {
		replayLink.addEventListener('click', showTegakiReplay, false);
	});
}

window.addEventListener('DOMContentLoaded', () => {
	addReplayListeners(document);
});

window.addEventListener('addPost', e => {
	if (!e.detail.hover) {
		addReplayListeners(e.detail.post);
	}
});