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|assets|staff).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,
	'hoverexpandsmedia': settings.hoverExpandsMedia,
	'hoverexpandsfollowcursor': settings.hoverExpandsFollowCursor,
	'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]));

repo_name: Marzisoft/jschan

license: mit


code:
/* eslint-disable no-unused-vars */
class Minimisable {
	constructor(elementSelector, buttons, storageKey, resizeUpdateCb) {
		this.elementSelector = elementSelector;
		this.buttons = buttons;
		this.storageKey = storageKey;
		this.resizeUpdateCb = resizeUpdateCb; //todo: refactor out code smell
		this.minimised = this.storageKey ? localStorage.getItem(this.storageKey) === 'true' : false;
	}

	init () {
		this.element = document.querySelector(this.elementSelector);
		this.resizable = this.element.classList.contains('resize'); //make an arg to minimisable and add in here?
		this.buttonElements = this.buttons.map(button => {
			const buttonElement = this.element.querySelector(button.selector);
			buttonElement.addEventListener('click', () => this.handleButtonClick(button));
			return { ...button, element: buttonElement };
		});
		this.updateVisibility();
		this.updateButtonTexts();
	}

	handleButtonClick (button) {
		if (typeof button.cb === 'string' && this[button.cb]) {
			this[button.cb]();
		} else if (typeof button.cb === 'function') {
			button.cb();
		} else {
			this.toggleMinimise();
		}
	}

	toggleMinimise () {
		this.minimised = !this.minimised;
		if (this.minimised && this.resizable && (this.element.style.width || this.element.style.height)) {
			this.element.style.height = '';
			this.element.style.width = '';
		}
		this.resizable && this.element.classList.toggle('resize', !this.minimised);
		this.storageKey && localStorage.setItem(this.storageKey, this.minimised);
		this.updateVisibility();
		this.updateButtonTexts();
	}

	toggleMaximise () {
		if (this.element.style.width === '100%' && this.element.style.height === '100%') {
			this.element.style.width = this.originalWidth;
			this.element.style.height = this.originalHeight;
			this.element.style.top = this.originalTop;
			this.element.style.left = this.originalLeft;
			this.resizable && this.element.classList.toggle('resize', true);
		} else {
			this.originalWidth = this.element.style.width;
			this.originalHeight = this.element.style.height;
			this.originalTop = this.element.style.top;
			this.originalLeft = this.element.style.left;
			//todo: maximised class w/important?
			this.element.style.maxWidth = '';
			this.element.style.maxHeight = '';
			this.element.style.top = '0';
			this.element.style.left = '0';
			this.element.style.width = '100%';
			this.element.style.height = '100%';
			this.resizable && this.element.classList.toggle('resize', false);
		}
		this.resizeUpdateCb && this.resizeUpdateCb();
		this.minimised = false;
		this.element.classList.remove('minimised');
		this.storageKey && localStorage.setItem(this.storageKey, this.minimised);
		this.updateButtonTexts();
	}

	maximise () {
		this.toggleMaximise(); // Use toggleMaximise for maximizing
	}

	updateVisibility () {
		this.element.classList.toggle('minimised', this.minimised);
	}

	updateButtonTexts () {
		this.buttonElements.forEach(button => {
			button.element.textContent = button.falseText
				? (this[button.textVar] ? button.trueText : button.falseText)
				: button.trueText;
		});
	}

	isMinimised () {
		return this.minimised;
	}
}


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 TegakiWrapper */
function showTegakiReplay(e) {
	e.preventDefault(); //prevent nojs download fallback
	TegakiWrapper.init();
	Tegaki.open({
		onCancel: () => {
			TegakiWrapper.remove();
		},
		onDone: () => {
			TegakiWrapper.remove();
		},
		target: TegakiWrapper.element,
		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);
	}
});