您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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();