OzBargain User Tags & Votes

Add customisable tags and track votes against users on OzBargain

// ==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();