OzBargain User Tags & Votes

Add customisable tags and track votes against users on OzBargain

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name		OzBargain User Tags & Votes
// @namespace	nategasm
// @version		1.12
// @description	Add customisable tags and track votes against users on OzBargain
// @author		nategasm
// @license		MIT
// @include		https://www.ozbargain.com.au/*
// @icon		https://www.ozbargain.com.au/favicon.ico
// @run-at		document-end
// @grant		GM_setValue
// @grant		GM_getValue
// @grant		GM_addStyle
// ==/UserScript==

GM_addStyle(`
	.user-tag {
		margin-left: 3px;
	}
	.user-tag-name {
		padding: 1px 3px;
		margin-right: 3px;
		border-radius: 4px;
		font-weight: normal;
	}
	.user-tag .fa-tag {
		cursor: pointer;
	}
	.user-tag .fa-tag:hover {
		color: color-mix(in srgb, var(--shade2-bg) 90%, black) !important;
	}
	.user-tag .user-tag-votes {
		margin-right: 3px;
	}
	#tooltip.user-tag-modal {
		display: block;
		position: absolute;
		z-index: 10000;
		transition: opacity 0.3s ease;
		opacity: 1;
	}
	#tooltip.user-tag-modal.fade-out {
		opacity: 0;
	}
	.submitted strong:has(.user-tag),
	.submitted strong:has(.user-tag) a,
	.submitted strong:has(.user-tag-name) a,
	.submitted strong:has(.user-tag) .fa-tag {
		margin-right: 0px !important
	}
	.node .submitted strong:not(:has(.user-tag-name)) a {
		margin-right: 3px !important
	}
	.user-tag-modal input[type="color"] {
		width: 40px;
		height: 20px;
		padding: 2px;
		cursor: pointer;
	}
	#tooltip.user-tag-modal.left-offset::before {
		left: var(--arrow-offset, 50%);
	}
	#tooltip.user-tag-modal.above-icon::before {
		border-bottom: none;
		border-top-color: var(--tooltip-clr);
		top: 100%;
	}
`);

const DEFAULT_BG_COLOR = '#FFA500';
const DEFAULT_TEXT_COLOR = '#000000';
const TAG_STORAGE_KEY = 'userTagsById';
const VOTE_TRACKING_KEY = 'userTagsVoteTracking';
const VOTE_RULES_KEY = 'userTagsVoteRules';
const voteCooldown = new Set();

async function getTags() {
	return (await GM_getValue(TAG_STORAGE_KEY)) || {};
}
async function saveTags(tags) {
	await GM_setValue(TAG_STORAGE_KEY, tags);
}

async function isVoteTrackingEnabled() {
	return (await GM_getValue(VOTE_TRACKING_KEY)) !== false; //Default to true
}
async function setVoteTrackingEnabled(enabled) {
	await GM_setValue(VOTE_TRACKING_KEY, enabled);
}

async function isVoteRulesEnabled() {
	return (await GM_getValue(VOTE_RULES_KEY)) !== false; //Default to true
}
async function setVoteRulesEnabled(enabled) {
	await GM_setValue(VOTE_RULES_KEY, enabled);
}

async function setTag(userId, tag, bgColor, textColor, src) {
	const tags = await getTags();
	tags[userId] = {
		...(tags[userId] || {}),
		tag: tag?.trim() || undefined,
		bg: bgColor || undefined,
		txt: textColor || undefined,
		src: src || undefined
	};
	if (!tags[userId].tag && (!tags[userId].pV || tags[userId].pV === 0)
		&& (!tags[userId].nV || tags[userId].nV === 0)) {
		delete tags[userId];
	}
	await saveTags(tags);
}

async function clearAllTags() {
	if (confirm('Are you sure you want to delete tags for all users? \nThis cannot be reversed unless you have exported a backup')) {
	  await saveTags({});
	  alert('All tags deleted. Refresh to see changes');
	}
}

function extractUserId(anchor) {
	const match = anchor.getAttribute('href').match(/\/user\/(\d+)/);
	return match ? match[1] : null;
}

function closeExistingModal() {
	const existing = document.querySelector('.user-tag-modal');
	if (existing) existing.remove();
	//Close site tooltips
	const tooltip = document.querySelector('#tooltip');
	if (tooltip && getComputedStyle(tooltip).display !== 'none') {
		tooltip.querySelector('#tooltip-close')?.click();
	}
}

async function createTagElement(userId, tagData = {}, username = '', source, voteTracking, nodeGrid) {
	const wrapper = document.createElement('span');
	wrapper.classList.add('user-tag');
	let userTag;
	let userVotes;
	//Get votes
	if (voteTracking && (tagData.pV || tagData.nV)) {
		const voteCount = (tagData.pV || 0) - (tagData.nV || 0);
		userVotes = document.createElement('span');
		userVotes.classList.add('user-tag-votes');
		userVotes.textContent = `${nodeGrid ? ' ' : ''}[${voteCount >= 0 ? '+' : ''}${voteCount}]`;
		userVotes.title = `${tagData.pV || 0} upvotes, ${tagData.nV || 0} downvotes`;
	}
	//Get tag
	if (tagData.tag) {
		userTag = document.createElement('span');
		userTag.classList.add('user-tag-name');
		userTag.textContent = tagData.tag;
		userTag.style.backgroundColor = tagData.bg || DEFAULT_BG_COLOR;
		userTag.style.color = tagData.txt || DEFAULT_TEXT_COLOR;
		if (nodeGrid && !userVotes) userTag.style.marginLeft = '3px';
	}

	const icon = document.createElement('span');
	icon.classList.add('fa','fa-tag');
	icon.title = 'Edit tag';
	icon.addEventListener('click', async (e) => {
		e.stopPropagation();
		closeExistingModal();

		const modal = document.createElement('div');
		modal.setAttribute('id', 'tooltip');
		modal.classList.add('user-tag-modal');

		const head = document.createElement('div');
		head.setAttribute('id', 'tooltip-head');
		const headClose = document.createElement('i');
		headClose.setAttribute('id', 'tooltip-close');
		headClose.classList.add('fa','fa-times');
		headClose.onclick = () => fadeOutAndRemove(modal);
		const headTitle = document.createElement('div');
		headTitle.setAttribute('id', 'tooltip-title');
		headTitle.textContent = 'User Tag Editor';
		head.appendChild(headClose);
		head.appendChild(headTitle);

		const label = document.createElement('label');
		label.innerHTML = `<b>${username}</b>`;
		let sourceLink;
		if (tagData.tag && source) {
			sourceLink = document.createElement('a');
			sourceLink.classList.add('internal');
			sourceLink.href = '/' + source;
			sourceLink.textContent = '[Src]';
			sourceLink.title = 'Source where tag was created';
			sourceLink.style.marginLeft = '3px';
		}
		label.innerHTML = `<b>${username}</b>`;
		const input = document.createElement('input');
		input.type = 'text';
		input.value = tagData.tag || '';
		input.style.marginTop = '5px';
		input.style.marginBottom = '7px';
		input.style.width = '100%';
		input.style.boxSizing = 'border-box';
		input.style.textAlign = 'center';
		input.placeholder = 'Enter user tag';

		const bgColorInput = document.createElement('input');
		bgColorInput.type = 'color';
		bgColorInput.value = /^#[0-9A-Fa-f]{6}$/.test(tagData.bg) ? tagData.bg : DEFAULT_BG_COLOR;
		bgColorInput.style.marginTop = '8px';
		bgColorInput.style.marginBottom = '8px';

		const textColorInput = document.createElement('input');
		textColorInput.type = 'color';
		textColorInput.value = /^#[0-9A-Fa-f]{6}$/.test(tagData.txt) ? tagData.txt : DEFAULT_TEXT_COLOR;
		textColorInput.style.marginBottom = '8px';

		const preview = document.createElement('span');
		preview.textContent = input.value;
		preview.style.padding = '2px 4px';
		preview.style.borderRadius = '4px';
		preview.style.backgroundColor = bgColorInput.value;
		preview.style.color = textColorInput.value;
		preview.style.fontSize = 'smaller';

		function updatePreview() {
			preview.textContent = input.value;
			preview.style.backgroundColor = bgColorInput.value;
			preview.style.color = textColorInput.value;
		}

		input.addEventListener('input', updatePreview);
		bgColorInput.addEventListener('input', updatePreview);
		textColorInput.addEventListener('input', updatePreview);

		const resetBtn = document.createElement('button');
		resetBtn.textContent = 'Reset Colour';
		resetBtn.classList.add('btn');
		resetBtn.style.marginRight = '5px';
		resetBtn.style.padding = '0px 8px';
		resetBtn.onclick = () => {
			bgColorInput.value = DEFAULT_BG_COLOR;
			textColorInput.value = DEFAULT_TEXT_COLOR;
			updatePreview();
		};

		const saveBtn = document.createElement('button');
		saveBtn.textContent = 'Save Tag';
		saveBtn.classList.add('btn','btn-primary');
		saveBtn.style.padding = '0px 8px';
		saveBtn.onclick = async () => {
			let bg = bgColorInput.value;
			let txt = textColorInput.value;
			if (!input.value) {
				source = undefined;
				bg = undefined;
				txt = undefined;
			}
			await setTag(userId, input.value, bg, txt, source);
			refreshUser(userId, voteTracking);
			fadeOutAndRemove(modal);
		};

		input.addEventListener('keydown', async (e) => {
			if (e.key === 'Enter') {
				e.preventDefault();
				await saveBtn.onclick();
			}
		});

		const toggleVotesCheckbox = document.createElement('input');
		toggleVotesCheckbox.type = 'checkbox';
		toggleVotesCheckbox.id = 'toggleVotes';
		toggleVotesCheckbox.style.marginBottom = '6px';
		toggleVotesCheckbox.checked = await isVoteTrackingEnabled();
		toggleVotesCheckbox.style.cursor = 'pointer';
		toggleVotesCheckbox.addEventListener('change', async () => {
			await setVoteTrackingEnabled(toggleVotesCheckbox.checked);
			alert('Vote tracking setting saved. Refresh page to apply');
		});
		toggleVotesCheckbox.title = 'Disabling will stop local vote tracking per user and remove them from display';

		const toggleVotesLabel = document.createElement('label');
		toggleVotesLabel.htmlFor = 'toggleVotes';
		toggleVotesLabel.textContent = 'Vote tracking';
		toggleVotesLabel.title = toggleVotesCheckbox.title;

		const toggleVoteRulesCheckbox = document.createElement('input');
		toggleVoteRulesCheckbox.type = 'checkbox';
		toggleVoteRulesCheckbox.id = 'toggleVoteRules';
		toggleVoteRulesCheckbox.style.marginBottom = '6px';
		toggleVoteRulesCheckbox.checked = await isVoteRulesEnabled();
		toggleVoteRulesCheckbox.style.cursor = 'pointer';
		toggleVoteRulesCheckbox.addEventListener('change', async () => {
			await setVoteRulesEnabled(toggleVoteRulesCheckbox.checked);
		});
		toggleVoteRulesCheckbox.title = `Disabling will allow local vote tracking where Ozbargain does not normally allow voting. This happens when a deal or comment is too old, for revoked votes, and when a comment hasn't been posted for negative deal votes`;

		const toggleRulesLabel = document.createElement('label');
		toggleRulesLabel.htmlFor = 'toggleVoteRules';
		toggleRulesLabel.textContent = '\u00A0\u00A0Voting rules';
		toggleRulesLabel.title = toggleVoteRulesCheckbox.title;

		const voteInfo = document.createElement('div');
		const net = (tagData.pV || 0) - (tagData.nV || 0);
		voteInfo.innerHTML = `<b>Votes:</b> +${tagData.pV || 0} / -${tagData.nV || 0} [<b>${net > 0 ? '+' : ''}${net}</b>]`;

		const resetVotesBtn = document.createElement('button');
		resetVotesBtn.textContent = 'Reset Votes';
		resetVotesBtn.classList.add('btn');
		resetVotesBtn.style.fontSize = '13px';
		resetVotesBtn.style.padding = '0px 4px';
		resetVotesBtn.style.marginLeft = '10px';
		resetVotesBtn.onclick = async () => {
			if (!tagData.pV && !tagData.nV) return;
			if (confirm(`Are you sure you want to reset votes for ${username}?`)) {
				tagData.pV = 0;
				tagData.nV = 0;
				const tags = await getTags();
				tags[userId] = tagData;
				if (!tags[userId].tag && (!tags[userId].pV || tags[userId].pV === 0)
					&& (!tags[userId].nV || tags[userId].nV === 0)) {
					delete tags[userId];
				}
				await saveTags(tags);
				refreshUser(userId, voteTracking);
				fadeOutAndRemove(modal);
			}
		};
		voteInfo.appendChild(resetVotesBtn);

		const exportBtn = document.createElement('button');
		exportBtn.textContent = 'Export Data';
		exportBtn.classList.add('btn');
		exportBtn.style.fontSize = '13px';
		exportBtn.style.padding = '0px 4px';
		exportBtn.style.marginRight = '5px';
		exportBtn.onclick = async () => {
			const tags = await getTags();
			const blob = new Blob([JSON.stringify(tags, null, 2)], { type: 'application/json' });
			const url = URL.createObjectURL(blob);
			const a = document.createElement('a');
			const currentDate = new Date();
			let month;
			if (currentDate.getMonth() < 9) {
				month = '0' + (currentDate.getMonth() + 1);
			} else {
				month = currentDate.getMonth() + 1;
			}
			let day;
			if (currentDate.getDate() < 10) {
				day = '0' + currentDate.getDate();
			} else {
				day = currentDate.getDate();
			}
			a.href = url;
			a.download = 'OzB_Usertags_Backup_' + currentDate.getFullYear() + month + day + '.json';
			a.click();
		};

		const importBtn = document.createElement('button');
		importBtn.textContent = 'Import Data';
		importBtn.classList.add('btn');
		importBtn.style.fontSize = '13px';
		importBtn.style.padding = '0px 4px';
		importBtn.style.marginRight = '5px';
		importBtn.onclick = () => {
			if (confirm(`Are you sure you want to import data? \nThis will overwrite existing user tags if they are also in the import but votes will be merged/added together \nNew users from Import will be added. Existing users not in import will be kept`)) {
				const input = document.createElement('input');
				input.type = 'file';
				input.accept = 'application/json';
				input.onchange = async (e) => {
					const file = e.target.files[0];
					if (file) {
						const text = await file.text();
						try {
							const importedTags = JSON.parse(text);
							const existingTags = await getTags();
							//Overwrite tag,bg,txt,src. Add pV,nV
							for (const userId in importedTags) {
								const imported = importedTags[userId];
								const existing = existingTags[userId] || {};
								existingTags[userId] = {
									tag: imported.tag !== undefined ? imported.tag : existing.tag,
									bg: imported.bg !== undefined ? imported.bg : existing.bg,
									txt: imported.txt !== undefined ? imported.txt : existing.txt,
									src: imported.src !== undefined ? imported.src : existing.src,
									pV: (existing.pV || 0) + (imported.pV || 0),
									nV: (existing.nV || 0) + (imported.nV || 0),
								};
							}
							await saveTags(existingTags);
							alert('Tags imported and merged! \nRefresh the page to see changes');
							addTags(); //Refresh tags on the page
						} catch (err) {
							alert('Error importing: Invalid JSON');
						}
					}
				};
				input.click();
			}
		};
		const deleteAllBtn = document.createElement('button');
		deleteAllBtn.textContent = 'Delete All';
		deleteAllBtn.classList.add('btn');
		deleteAllBtn.style.fontSize = '13px';
		deleteAllBtn.style.padding = '0px 4px';
		deleteAllBtn.onclick = clearAllTags;

		modal.appendChild(head);
		const tooltip = document.createElement('div');
		tooltip.classList.add('tooltip','tooltipuser');
		tooltip.style.textAlign = 'center';
		tooltip.appendChild(label);
		if (tagData.tag && source) tooltip.appendChild(sourceLink);
		tooltip.appendChild(document.createElement('br'));
		tooltip.appendChild(input);
		tooltip.appendChild(document.createElement('br'));
		tooltip.appendChild(preview);
		tooltip.appendChild(document.createElement('br'));
		tooltip.appendChild(document.createTextNode('BG: '));
		tooltip.appendChild(bgColorInput);
		tooltip.appendChild(document.createTextNode('\u00A0\u00A0Text: '));
		tooltip.appendChild(textColorInput);
		tooltip.appendChild(document.createElement('br'));
		tooltip.appendChild(resetBtn);
		tooltip.appendChild(saveBtn);
		tooltip.appendChild(document.createElement('hr'));
		tooltip.appendChild(toggleVotesLabel);
		tooltip.appendChild(toggleVotesCheckbox);
		tooltip.appendChild(toggleRulesLabel);
		tooltip.appendChild(toggleVoteRulesCheckbox);
		tooltip.appendChild(voteInfo);
		tooltip.appendChild(document.createElement('hr'));
		tooltip.appendChild(exportBtn);
		tooltip.appendChild(importBtn);
		tooltip.appendChild(deleteAllBtn);
		modal.appendChild(tooltip);
		document.body.appendChild(modal);
		positionModalUnderIcon(modal, e.currentTarget);
	});
	if (nodeGrid) {
		wrapper.appendChild(icon);
		if (userVotes) wrapper.appendChild(userVotes);
		if (userTag) wrapper.appendChild(userTag);
	} else {
		if (userTag) wrapper.appendChild(userTag);
		if (userVotes) wrapper.appendChild(userVotes);
		wrapper.appendChild(icon);
	}
	return wrapper;
}

function positionModalUnderIcon(modal, iconElement, modalWidth = 253, modalHeight = 310, verticalOffset = 10) {
	const iconRect = iconElement.getBoundingClientRect();
	let top = iconRect.bottom + window.scrollY + verticalOffset;
	let left = iconRect.left + window.scrollX + (iconRect.width / 2) - (modalWidth / 2);
	const viewportWidth = document.documentElement.clientWidth;
	const viewportHeight = document.documentElement.clientHeight;
	// Adjust right overflow
	if (left + modalWidth > window.scrollX + viewportWidth) {
		left = window.scrollX + viewportWidth - modalWidth - 10;
		modal.classList.add('left-offset');
		const arrowOffset = iconRect.left + iconRect.width / 2 - left;
		modal.style.setProperty('--arrow-offset', `${arrowOffset}px`);
	}
	// Adjust left overflow
	if (left < window.scrollX) {
		left = window.scrollX + 10;
		modal.classList.add('left-offset');
		const arrowOffset = iconRect.left + iconRect.width / 2 - left;
		modal.style.setProperty('--arrow-offset', `${arrowOffset}px`);
	}
	// Adjust bottom overflow (show above icon)
	if (top + modalHeight > window.scrollY + viewportHeight) {
		top = iconRect.top + window.scrollY - modalHeight - verticalOffset;
		modal.classList.add('above-icon');
	}
	modal.style.top = `${top}px`;
	modal.style.left = `${left}px`;
}

function fadeOutAndRemove(el) {
	if (!el) return;
	el.classList.add('fade-out');
	setTimeout(() => el.remove(), 300); //300ms matches the CSS transition
}

async function handleVoteClick(voteBtn, delta) {
	const container = voteBtn.closest('.c-vote, .n-vote');
	if (!container) return;
	const meta = voteBtn.closest('.meta, .node');
	if (!meta) return;
	const voteRules = await isVoteRulesEnabled();
	if (voteRules && meta.classList.contains('meta')) { //Check aged comments
		const commentDateStr = meta?.querySelector('.c-link')?.textContent?.trim();
		if (isPostOlderThan(commentDateStr,30)) return;
	}
	const hasPending = container.classList.contains('pending');
	//Stop repeat votes when already voted
	if (hasPending && container.classList.contains('voteup') && delta > 0) return;
	if (hasPending && container.classList.contains('votedown') && delta < 0) return;
	const hasInact = container.classList.contains('inact');
	let anchor;
	if (meta.classList.contains('node-page')) { //Detect grid nodes
		anchor = meta.querySelector('.submitted a[href^="/user/"]');
	} else {
		anchor = meta.querySelector('.submitted strong a[href^="/user/"]');
	}
	const userId = extractUserId(anchor);
	if (!userId || voteCooldown.has(userId)) return;
	//Wait and see if deal votes get accepted due to negative comment requirements, revoked votes and aged deals
	if (voteRules && meta.classList.contains('node')) {
		setTimeout(() => {
			const newContainer = voteBtn.closest('.n-vote');
			if (newContainer.classList.contains('inact')) return;
			processVoteResult(userId, delta, hasPending, hasInact);
		}, 300); //Update time is inconsistent
	} else {
		processVoteResult(userId, delta, hasPending, hasInact);
	}
}

async function processVoteResult(userId, delta, hasPending, hasInact) {
	const tags = await getTags();
	tags[userId] = tags[userId] || {};
	// Vote change logic
	if (hasPending) {
		// User is changing their vote
		if (delta > 0) {
			// Changing from negative to positive
			tags[userId].nV = Math.max((tags[userId].nV || 1) - 1, 0);
			tags[userId].pV = (tags[userId].pV || 0) + 1;
		} else if (delta < 0) {
			// Changing from positive to negative
			tags[userId].pV = Math.max((tags[userId].pV || 1) - 1, 0);
			tags[userId].nV = (tags[userId].nV || 0) + 1;
		} else {
			// Same vote clicked again, no changes
			return;
		}
	} else if (hasInact) {
		// First time voting
		if (delta > 0) tags[userId].pV = (tags[userId].pV || 0) + 1;
		else tags[userId].nV = (tags[userId].nV || 0) + 1;
	} else {
		// Already voted and no change allowed (or multiple clicks)
		return;
	}
	await saveTags(tags);
	refreshUser(userId, true, tags);
	voteCooldown.add(userId);
	setTimeout(() => voteCooldown.delete(userId), 100); //debounce
}

async function observeMobileVoteTooltip() {
	if (!document.body.classList.contains('m')) return;
	if (!await isVoteTrackingEnabled()) return;
	const processTooltip = (node) => {
		const aTags = node.querySelectorAll('a[onclick*="voteComment"]');
		const upLi = node.querySelector('a[onclick*="voteComment"] span.cvb.voteup')?.closest('li');
		const downLi = node.querySelector('a[onclick*="voteComment"] span.cvb.votedown')?.closest('li');
		const canChangeVote = upLi && downLi;
		if (!canChangeVote) return;
		const alreadyVoted = [...node.querySelectorAll('.tooltip-menu li a')]
		.find(link => link.textContent.toLowerCase().includes('already voted'));
		//Track what was already voted to prevent it being voted again
		let alreadyVotedDelta;
		if (alreadyVoted) {
			const span = alreadyVoted.querySelector('span');
			if (span?.classList.contains('voteup')) {
				alreadyVotedDelta = 1;
			} else if (span?.classList.contains('votedown')) {
				alreadyVotedDelta = -1;
			}
		}
		aTags.forEach((aTag) => {
			const voteMatch = aTag.getAttribute('onclick')?.match(/\bvoteComment\((\d+),\s*(-?1)\)/);
			if (!voteMatch) return;
			const [, commentId, vote] = voteMatch;
			const delta = parseInt(vote, 10);
			if (!commentId || !delta) return;
			if (alreadyVotedDelta === delta) return;
			if (aTag.dataset.listenerAdded) return;
			aTag.dataset.listenerAdded = 'true';
			aTag.addEventListener('click', async () => {
				const meta = document.querySelector(`.meta .c-link[data-cid="${commentId}"]`)?.closest('.meta');
				const voteRules = await isVoteRulesEnabled();
				if (voteRules) {
					const commentDateStr = meta?.querySelector('.c-link')?.textContent?.trim();
					if (isPostOlderThan(commentDateStr,30)) return;
				}
				const container = meta?.querySelector('.c-vote, .n-vote');
				if (!container && !container.classList.contains('inact') && !container.classList.contains('pending')) return;
				const anchor = meta.querySelector('.submitted strong a[href^="/user/"]');
				const userId = extractUserId(anchor);
				if (!userId || voteCooldown.has(userId)) return;
				processVoteResult(userId, delta, alreadyVoted, !alreadyVoted);
			}, true)
		});
	};

	let debounceTimeout = null;
	const tooltipStyleObserver = new MutationObserver((mutations) => {
		if (debounceTimeout) return;
		debounceTimeout = setTimeout(() => {
			debounceTimeout = null; //Reset debounce
			for (const mutation of mutations) {
				if (mutation.attributeName === 'style') {
					const tooltip = mutation.target;
					if (tooltip.style.display !== 'none') {
						processTooltip(tooltip);
					}
				}
			}
		}, 100); //Run after 100ms pause to account for burst style changes
	});

	const mainObserver = new MutationObserver((mutations) => {
		for (const mutation of mutations) {
			mutation.addedNodes.forEach((node) => {
				if (node.nodeType === 1 && node.id === 'tooltip') {
					//First-time tooltip insertion
					tooltipStyleObserver.observe(node, {
						attributes: true,
						attributeFilter: ['style']
					});
					if (node.style.display !== 'none') {
						processTooltip(node);
					}
				}
			});
		}
	});
	mainObserver.observe(document.body, { childList: true, subtree: false });
}

function isPostOlderThan(dateStr, days) {
	if (!dateStr) return false;
	//If there is 'ago' then the comment is always less than 30 days old
	if (/ago$/i.test(dateStr)) {
		return false;
	}
	const match = dateStr.match(/(\d{2})\/(\d{2})\/(\d{4})/);
	if (!match) return false;
	const [ , day, month, year ] = match;
	const commentDate = new Date(`${year}-${month}-${day}T00:00:00`);
	const now = new Date();
	const diffInDays = (now - commentDate) / (1000 * 60 * 60 * 24);
	return diffInDays > days;
}

async function addTags() {
	const tags = await getTags();
	const voteTracking = await isVoteTrackingEnabled();
	if (location.href.indexOf('/user/') > 0) {
		const userId = location.pathname.split("/")[2];
		if (!userId) return;
		if (!document.querySelector('.user-tag')) {
			const title = document.querySelector('h1#title');
			const username = title?.textContent?.match(/^(.+?)\s*»/)?.[1];
			const source = tags[userId]?.src || 'user/' + userId;
			const tagEl = await createTagElement(userId, tags[userId], username, source, voteTracking);
			tagEl.style.position = 'relative';
			tagEl.style.top = '-3px';
			title.style.display = 'inline-block';
			title.style.marginRight = '5px';
			title.insertAdjacentElement('afterend', tagEl);
		}
	} else {
		const anchors = document.querySelectorAll('.submitted a[href^="/user/"]');
		for (const a of anchors) {
			const userId = extractUserId(a);
			if (!userId) continue;
			if (!a.parentElement.querySelector('.user-tag')) {
				const username = a.textContent.trim();
				const node = a.closest('.meta, .node');
				const nodeGrid = node?.parentElement.classList.contains('nodegrid');
				const source = tags[userId]?.src || getTagSource(node);
				const tagEl = await createTagElement(userId, tags[userId], username, source, voteTracking, nodeGrid);
				if (nodeGrid) a.parentElement.appendChild(document.createElement('br'));
				a.parentElement.appendChild(tagEl);
			}
		}
	}
	//Add click listeners to all clickable votes if enabled
	if (voteTracking) {
		document.querySelectorAll('.c-vote.inact .cvb.voteup, .n-vote.inact .nvb.voteup').forEach(btn => {
			if (!btn.dataset.listenerAdded) {
				btn.dataset.listenerAdded = 'true';
				btn.addEventListener('click', () => handleVoteClick(btn, 1));
			}
		});
		document.querySelectorAll('.c-vote.inact .cvb.votedown, .n-vote.inact .nvb.votedown').forEach(btn => {
			if (!btn.dataset.listenerAdded) {
				btn.dataset.listenerAdded = 'true';
				btn.addEventListener('click', () => handleVoteClick(btn, -1));
			}
		});
	}
}

async function refreshUser(userId, voteTracking, tags) {
	if (!tags) {
		tags = await getTags();
	}
	if (location.href.indexOf('/user/') > 0) {
		const title = document.querySelector('h1#title');
		const existingTag = title.parentElement.querySelector('.user-tag');
		if (existingTag) existingTag.remove();
		const username = title?.getAttribute('data-title')?.match(/^(.+?)\s+\(#\d+\)/)?.[1];
		const source = tags[userId]?.src || 'user/' + userId;
		const tagEl = await createTagElement(userId, tags[userId], username, source, voteTracking);
		tagEl.style.position = 'relative';
		tagEl.style.top = '-3px';
		title.style.display = 'inline-block';
		title.style.marginRight = '5px';
		title.insertAdjacentElement('afterend', tagEl);
	} else {
		const anchors = document.querySelectorAll(`.submitted a[href="/user/${userId}"]`);
		for (const a of anchors) {
			const existingTag = a.parentElement.querySelector('.user-tag');
			if (existingTag) existingTag.remove();
			const username = a.textContent.trim();
			const node = a.closest('.meta, .node');
			const nodeGrid = node?.parentElement.classList.contains('nodegrid');
			const source = tags[userId]?.src || getTagSource(node);
			const tagEl = await createTagElement(userId, tags[userId], username, source, voteTracking, nodeGrid);
			a.parentElement.appendChild(tagEl);
		}
	}
}

function getTagSource(node) {
	if (node?.classList.contains('node-forum')) {
		return 'node/' + location.href.match(/\/node\/(\d+)/)?.[1];
	} else if (node?.classList.contains('node')) {
		return 'node/' + node.id.match(/^node(\d+)$/)?.[1];
	} else if (node?.classList.contains('meta')) {
		return 'comment/' + node.querySelector('.c-link')?.dataset.cid + '/redir';
	} else if (!node) { // Private messages
		return 'privatemsg/view/' + document.querySelector('.horizontal-participants')?.id.match(/\d+$/)?.[0];
	}
}

function observePages() { //Observe pages that can dynamically load nodes/comments
	const infScroll = document.querySelector(".infscrollbtn"); //Pages with infinite scroll
	const hiddenNode = document.querySelector(".comment.hidden"); //Pages with hidden commments
	if (infScroll || hiddenNode) {
		const callback = (mutationList, observer) => {
			for (const mutation of mutationList) {
				if (mutation.addedNodes.length > 0 && mutation.addedNodes[0].className && (mutation.addedNodes[0].classList.contains("comment") || mutation.addedNodes[0].classList.contains("node")) ) {
					addTags();
				}
			}
		};
		const observer = new MutationObserver(callback);
		observer.observe(document.body, { childList: true, subtree: true });
	}
}

addTags();
observePages();
observeMobileVoteTooltip();