Sleazy Fork is available in English.
Hide selected threads from forum listings and manage them from the forum navigation.
// ==UserScript==
// @name Ignore SimpCity Threads
// @namespace lovetransgaping
// @author lovetransgaping
// @license MIT
// @version 0.1.0
// @description Hide selected threads from forum listings and manage them from the forum navigation.
// @match https://simpcity.cr/*
// @match https://www.simpcity.cr/*
// @grant GM_addStyle
// @grant GM_addValueChangeListener
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @grant GM_setValue
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict'
const STORAGE_KEY = 'simpcityIgnoredThreads.v1'
const ROW_SELECTOR = '.js-threadList > .structItem--thread'
const THREAD_PATH_PATTERN = /^\/threads\/([^/]+?)(?:\/|$)/
const THREAD_ID_PATTERN = /(?:^|\.)(\d+)$/
const UNDO_DURATION_MS = 5000
let ignoredThreads = normalizeStoredThreads(GM_getValue(STORAGE_KEY, {}))
let showIgnoredRows = false
let undoTimer = 0
let pendingUndo = null
let managerFilter = ''
let listRefreshQueued = false
GM_addStyle(`
.scti-row-title {
min-height: 1.6em;
padding-right: calc(5.2em + var(--scti-status-offset, 0px));
position: relative;
}
.scti-row-toggle {
background: hsla(var(--xf-buttonBg));
border: 1px solid hsla(var(--xf-borderColor));
border-radius: 3px;
color: hsla(var(--xf-buttonColor));
cursor: pointer;
font: inherit;
font-size: .78em;
height: 1.65em;
line-height: 1;
padding: 0 .55em;
position: absolute;
right: var(--scti-status-offset, 0px);
top: 0;
white-space: nowrap;
}
.scti-row-toggle:hover,
.scti-row-toggle:focus-visible {
filter: brightness(1.15);
}
.scti-hidden {
display: none !important;
}
.scti-ignored-revealed {
opacity: .55;
}
.scti-ignored-revealed:hover,
.scti-ignored-revealed:focus-within {
opacity: .8;
}
.scti-all-hidden {
align-items: center;
background: hsla(var(--xf-contentAltBg));
border-top: 1px solid hsla(var(--xf-borderColorLight));
color: hsla(var(--xf-textColorMuted));
display: flex;
gap: 12px;
justify-content: center;
min-height: 72px;
padding: 12px;
text-align: center;
}
.scti-thread-toggle {
appearance: none;
background: transparent;
border: 0;
cursor: pointer;
font: inherit;
}
.scti-thread-title-toggle {
margin-left: 10px;
vertical-align: middle;
}
.scti-manager-backdrop {
align-items: flex-start;
background: rgba(0, 0, 0, .68);
display: flex;
inset: 0;
justify-content: center;
overflow: auto;
padding: 8vh 20px 40px;
position: fixed;
z-index: 100000;
}
.scti-manager-backdrop[hidden] {
display: none;
}
.scti-manager {
background: hsla(var(--xf-contentBg));
border: 1px solid hsla(var(--xf-borderColor));
border-radius: 6px;
box-shadow: 0 12px 40px rgba(0, 0, 0, .5);
color: hsla(var(--xf-textColor));
max-width: 780px;
width: 100%;
}
.scti-manager-header {
align-items: center;
border-bottom: 1px solid hsla(var(--xf-borderColorLight));
display: flex;
justify-content: space-between;
padding: 14px 16px;
}
.scti-manager-title {
font-size: 20px;
font-weight: 600;
margin: 0;
}
.scti-manager-close {
appearance: none;
background: transparent;
border: 0;
color: inherit;
cursor: pointer;
font-size: 26px;
line-height: 1;
padding: 0 4px;
}
.scti-manager-body {
padding: 16px;
}
.scti-manager-filter {
background: hsla(var(--xf-inputBg));
border: 1px solid hsla(var(--xf-borderColor));
border-radius: 4px;
color: hsla(var(--xf-inputColor));
box-sizing: border-box;
font: inherit;
margin-bottom: 12px;
padding: 9px 10px;
width: 100%;
}
.scti-manager-list {
max-height: 60vh;
overflow: auto;
}
.scti-manager-item {
align-items: center;
border-top: 1px solid hsla(var(--xf-borderColorLight));
display: grid;
gap: 6px 14px;
grid-template-columns: minmax(0, 1fr) auto;
padding: 12px 2px;
}
.scti-manager-item:first-child {
border-top: 0;
}
.scti-manager-link {
font-weight: 600;
min-width: 0;
overflow-wrap: anywhere;
}
.scti-manager-meta {
color: hsla(var(--xf-textColorMuted));
font-size: 12px;
grid-column: 1;
}
.scti-manager-unignore {
grid-column: 2;
grid-row: 1 / span 2;
}
.scti-manager-empty {
color: hsla(var(--xf-textColorMuted));
padding: 28px 10px;
text-align: center;
}
.scti-toast {
align-items: center;
background: #202428;
border: 1px solid rgba(255, 255, 255, .2);
border-radius: 5px;
bottom: 24px;
box-shadow: 0 4px 18px rgba(0, 0, 0, .45);
color: #fff;
display: flex;
gap: 16px;
max-width: min(440px, calc(100vw - 32px));
padding: 10px 12px;
position: fixed;
right: 24px;
z-index: 100001;
}
.scti-toast[hidden] {
display: none;
}
.scti-toast button {
background: transparent;
border: 0;
color: #8fc7ff;
cursor: pointer;
font: inherit;
font-weight: 600;
padding: 2px;
}
`)
function normalizeStoredThreads(rawValue) {
const normalized = {}
const candidates = Array.isArray(rawValue)
? rawValue.map((id) => [String(id), { id: String(id) }])
: Object.entries(rawValue && typeof rawValue === 'object' ? rawValue : {})
for (const [key, value] of candidates) {
const id = String(value && value.id ? value.id : key).match(/^\d+$/)?.[0]
if (!id) continue
normalized[id] = {
id,
title: cleanText(value && value.title) || `Thread #${id}`,
url: canonicalThreadUrl(value && value.url, id),
ignoredAt: Number(value && value.ignoredAt) || Date.now()
}
}
return normalized
}
function cleanText(value) {
return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : ''
}
function threadIdFromUrl(value) {
try {
const url = new URL(value, location.origin)
const segment = url.pathname.match(THREAD_PATH_PATTERN)?.[1] || ''
return segment.match(THREAD_ID_PATTERN)?.[1] || ''
} catch {
return ''
}
}
function canonicalThreadUrl(value, expectedId = '') {
try {
const url = new URL(value || `/threads/${expectedId}/`, location.origin)
const segment = url.pathname.match(THREAD_PATH_PATTERN)?.[1] || ''
const id = segment.match(THREAD_ID_PATTERN)?.[1] || expectedId
if (!id) return ''
const canonicalSegment = segment && threadIdFromUrl(url.href) === id ? segment : id
return `${url.origin}/threads/${canonicalSegment}/`
} catch {
return expectedId ? `${location.origin}/threads/${expectedId}/` : ''
}
}
function cloneThreads(value = ignoredThreads) {
return Object.fromEntries(
Object.entries(value).map(([id, record]) => [id, { ...record }])
)
}
function saveThreads(nextThreads) {
ignoredThreads = normalizeStoredThreads(nextThreads)
GM_setValue(STORAGE_KEY, cloneThreads())
refreshAllUi()
}
function ignoreThread(record, message = 'Thread ignored') {
const id = String(record.id)
if (ignoredThreads[id]) return
const nextThreads = cloneThreads()
nextThreads[id] = {
id,
title: cleanText(record.title) || `Thread #${id}`,
url: canonicalThreadUrl(record.url, id),
ignoredAt: Date.now()
}
saveThreads(nextThreads)
showUndo(message, { type: 'ignore', record: nextThreads[id] })
}
function unignoreThread(id, message = 'Thread restored') {
const record = ignoredThreads[id]
if (!record) return
const nextThreads = cloneThreads()
delete nextThreads[id]
saveThreads(nextThreads)
showUndo(message, { type: 'unignore', record })
}
function refreshKnownRecord(record) {
const current = ignoredThreads[record.id]
if (!current) return false
const title = cleanText(record.title) || current.title
const url = canonicalThreadUrl(record.url, record.id) || current.url
if (current.title === title && current.url === url) return false
ignoredThreads[record.id] = { ...current, title, url }
return true
}
function recordFromRow(row) {
const classId = Array.from(row.classList)
.map((className) => className.match(/^js-threadListItem-(\d+)$/)?.[1])
.find(Boolean)
const titleContainer = row.querySelector('.structItem-title')
const titleLink = titleContainer?.querySelector('a[data-tp-primary], a[href*="/threads/"]')
const id = classId || threadIdFromUrl(titleLink?.href)
if (!id) return null
return {
id,
title: cleanText(titleLink?.textContent) || `Thread #${id}`,
url: canonicalThreadUrl(titleLink?.href, id)
}
}
function recordFromThreadPage() {
const contentKey = document.documentElement.dataset.contentKey || ''
const id = contentKey.match(/^thread-(\d+)$/)?.[1] || threadIdFromUrl(location.href)
if (!id) return null
const heading = document.querySelector('.p-title-value')
const headingCopy = heading?.cloneNode(true)
headingCopy?.querySelectorAll('.labelLink, .label-append, .scti-thread-toggle').forEach((node) => node.remove())
const canonicalLink = document.querySelector('link[rel="canonical"]')?.href
return {
id,
title: cleanText(headingCopy?.textContent) || `Thread #${id}`,
url: canonicalThreadUrl(canonicalLink || location.href, id)
}
}
function makeButton(text, className, onClick) {
const button = document.createElement('button')
button.type = 'button'
button.className = className
button.textContent = text
button.addEventListener('pointerdown', (event) => event.stopPropagation())
button.addEventListener('click', (event) => {
event.preventDefault()
event.stopPropagation()
onClick(event)
})
return button
}
function processForumRows() {
const rows = Array.from(document.querySelectorAll(ROW_SELECTOR))
let metadataChanged = false
for (const row of rows) {
const record = recordFromRow(row)
if (!record) continue
row.dataset.sctiThreadId = record.id
metadataChanged = refreshKnownRecord(record) || metadataChanged
const titleContainer = row.querySelector('.structItem-title')
if (!titleContainer) continue
titleContainer.classList.add('scti-row-title')
const statusWidth = Math.ceil(
row.querySelector('.structItem-statuses')?.getBoundingClientRect().width || 0
)
const statusOffset = statusWidth > 0 ? statusWidth + 6 : 0
titleContainer.style.setProperty('--scti-status-offset', `${statusOffset}px`)
let button = titleContainer.querySelector('.scti-row-toggle')
if (!button) {
button = makeButton('Ignore', 'scti-row-toggle', () => {
if (ignoredThreads[record.id]) {
unignoreThread(record.id)
} else {
ignoreThread(record)
}
})
button.dataset.threadId = record.id
titleContainer.append(button)
}
}
if (metadataChanged) GM_setValue(STORAGE_KEY, cloneThreads())
renderForumRows(rows)
}
function renderForumRows(rows = Array.from(document.querySelectorAll(ROW_SELECTOR))) {
let ignoredOnPage = 0
let visibleRows = 0
for (const row of rows) {
const record = recordFromRow(row)
if (!record) continue
const isIgnored = Boolean(ignoredThreads[record.id])
const button = row.querySelector('.scti-row-toggle')
row.classList.toggle('scti-hidden', isIgnored && !showIgnoredRows)
row.classList.toggle('scti-ignored-revealed', isIgnored && showIgnoredRows)
if (button) button.textContent = isIgnored ? 'Unignore' : 'Ignore'
if (isIgnored) ignoredOnPage += 1
if (!isIgnored || showIgnoredRows) visibleRows += 1
}
if (ignoredOnPage === 0) showIgnoredRows = false
renderShowIgnoredControl(ignoredOnPage)
renderAllHiddenMessage(rows.length > 0 && visibleRows === 0, ignoredOnPage)
}
function findMarkReadButton() {
return Array.from(document.querySelectorAll('.block-outer .buttonGroup > a, .block-outer .buttonGroup > button'))
.find((element) => cleanText(element.textContent).toLowerCase() === 'mark read')
}
function renderShowIgnoredControl(ignoredOnPage) {
let control = document.querySelector('.scti-show-toggle')
const markRead = findMarkReadButton()
if (!markRead || ignoredOnPage === 0) {
control?.remove()
return
}
if (!control) {
control = makeButton('', 'button button--link scti-show-toggle', () => {
showIgnoredRows = !showIgnoredRows
renderForumRows()
})
const text = document.createElement('span')
text.className = 'button-text'
control.append(text)
markRead.before(control)
}
control.querySelector('.button-text').textContent = `${showIgnoredRows ? 'Hide' : 'Show'} ignored (${ignoredOnPage})`
control.setAttribute('aria-pressed', String(showIgnoredRows))
}
function renderAllHiddenMessage(isAllHidden, ignoredOnPage) {
const list = document.querySelector('.js-threadList')
let message = document.querySelector('.scti-all-hidden')
if (!list || !isAllHidden) {
message?.remove()
return
}
if (!message) {
message = document.createElement('div')
message.className = 'scti-all-hidden'
const label = document.createElement('span')
label.textContent = 'All threads on this page are ignored.'
const showButton = makeButton('', 'button button--link', () => {
showIgnoredRows = true
renderForumRows()
})
const buttonText = document.createElement('span')
buttonText.className = 'button-text'
showButton.append(buttonText)
message.append(label, showButton)
list.after(message)
}
message.querySelector('.button-text').textContent = `Show ignored (${ignoredOnPage})`
}
function installThreadPageControl() {
const record = recordFromThreadPage()
if (!record) return
if (refreshKnownRecord(record)) GM_setValue(STORAGE_KEY, cloneThreads())
let control = document.querySelector('.scti-thread-toggle')
if (!control) {
control = makeButton('', 'tabs-tab scti-thread-toggle', () => {
if (ignoredThreads[record.id]) {
unignoreThread(record.id)
} else {
ignoreThread(record)
}
})
control.dataset.threadId = record.id
const sortByDate = Array.from(document.querySelectorAll('.tabs-tab'))
.find((tab) => cleanText(tab.textContent).toLowerCase() === 'sort by date')
if (sortByDate) {
sortByDate.before(control)
} else {
control.className = 'button button--link scti-thread-toggle scti-thread-title-toggle'
document.querySelector('.p-title-value')?.append(control)
}
}
renderThreadPageControl()
}
function renderThreadPageControl() {
const control = document.querySelector('.scti-thread-toggle')
if (!control) return
control.textContent = ignoredThreads[control.dataset.threadId] ? 'Unignore thread' : 'Ignore thread'
}
function installManagerNavigation() {
const searchLink = document.querySelector('.p-sectionLinks-list > li > .p-navEl > a[data-nav-id="searchForums"]')
if (!searchLink) return
let link = document.querySelector('.scti-manager-nav-link')
if (!link) {
const listItem = document.createElement('li')
const navElement = document.createElement('div')
navElement.className = 'p-navEl'
link = document.createElement('a')
link.href = '#'
link.className = 'p-navEl-link scti-manager-nav-link'
link.addEventListener('click', (event) => {
event.preventDefault()
openManager()
})
navElement.append(link)
listItem.append(navElement)
searchLink.closest('li').before(listItem)
}
renderManagerNavigation()
}
function renderManagerNavigation() {
const link = document.querySelector('.scti-manager-nav-link')
if (link) link.textContent = `Ignored threads (${Object.keys(ignoredThreads).length})`
}
function ensureManager() {
let backdrop = document.querySelector('.scti-manager-backdrop')
if (backdrop) return backdrop
backdrop = document.createElement('div')
backdrop.className = 'scti-manager-backdrop'
backdrop.hidden = true
backdrop.innerHTML = `
<section class="scti-manager" role="dialog" aria-modal="true" aria-labelledby="scti-manager-title">
<header class="scti-manager-header">
<h2 class="scti-manager-title" id="scti-manager-title">Ignored threads</h2>
<button type="button" class="scti-manager-close" aria-label="Close ignored threads manager">x</button>
</header>
<div class="scti-manager-body">
<input class="scti-manager-filter" type="search" placeholder="Filter by title or thread ID" aria-label="Filter ignored threads">
<div class="scti-manager-list"></div>
</div>
</section>
`
backdrop.addEventListener('click', (event) => {
if (event.target === backdrop) closeManager()
})
backdrop.querySelector('.scti-manager-close').addEventListener('click', closeManager)
backdrop.querySelector('.scti-manager-filter').addEventListener('input', (event) => {
managerFilter = event.target.value
renderManager()
})
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !backdrop.hidden) closeManager()
})
document.body.append(backdrop)
return backdrop
}
function openManager() {
const backdrop = ensureManager()
backdrop.hidden = false
renderManager()
backdrop.querySelector('.scti-manager-filter').focus()
}
function closeManager() {
const backdrop = document.querySelector('.scti-manager-backdrop')
if (backdrop) backdrop.hidden = true
}
function renderManager() {
const backdrop = document.querySelector('.scti-manager-backdrop')
if (!backdrop) return
const list = backdrop.querySelector('.scti-manager-list')
const query = cleanText(managerFilter).toLowerCase()
const records = Object.values(ignoredThreads)
.filter((record) => !query || record.id.includes(query) || record.title.toLowerCase().includes(query))
.sort((left, right) => right.ignoredAt - left.ignoredAt)
list.replaceChildren()
if (records.length === 0) {
const empty = document.createElement('div')
empty.className = 'scti-manager-empty'
empty.textContent = Object.keys(ignoredThreads).length === 0
? 'No threads are ignored.'
: 'No ignored threads match this filter.'
list.append(empty)
return
}
for (const record of records) {
const item = document.createElement('div')
item.className = 'scti-manager-item'
const link = document.createElement('a')
link.className = 'scti-manager-link'
link.href = record.url
link.target = '_blank'
link.rel = 'noopener noreferrer'
link.textContent = record.title
const meta = document.createElement('div')
meta.className = 'scti-manager-meta'
meta.textContent = `Thread #${record.id} | Ignored ${formatDate(record.ignoredAt)}`
const button = makeButton('Unignore', 'button button--link scti-manager-unignore', () => {
unignoreThread(record.id)
})
item.append(link, meta, button)
list.append(item)
}
}
function formatDate(timestamp) {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(timestamp))
}
function ensureToast() {
let toast = document.querySelector('.scti-toast')
if (toast) return toast
toast = document.createElement('div')
toast.className = 'scti-toast'
toast.hidden = true
toast.setAttribute('role', 'status')
const message = document.createElement('span')
message.className = 'scti-toast-message'
const undoButton = makeButton('Undo', '', performUndo)
toast.append(message, undoButton)
document.body.append(toast)
return toast
}
function showUndo(message, action) {
pendingUndo = action
const toast = ensureToast()
toast.querySelector('.scti-toast-message').textContent = message
toast.hidden = false
window.clearTimeout(undoTimer)
undoTimer = window.setTimeout(hideUndo, UNDO_DURATION_MS)
}
function hideUndo() {
pendingUndo = null
const toast = document.querySelector('.scti-toast')
if (toast) toast.hidden = true
}
function performUndo() {
if (!pendingUndo) return
const action = pendingUndo
hideUndo()
const nextThreads = cloneThreads()
if (action.type === 'ignore') {
delete nextThreads[action.record.id]
} else {
nextThreads[action.record.id] = action.record
}
saveThreads(nextThreads)
}
function refreshAllUi() {
processForumRows()
renderThreadPageControl()
renderManagerNavigation()
renderManager()
}
function queueListRefresh() {
if (listRefreshQueued) return
listRefreshQueued = true
queueMicrotask(() => {
listRefreshQueued = false
processForumRows()
})
}
function observeThreadList() {
const list = document.querySelector('.js-threadList')
if (!list) return
new MutationObserver(queueListRefresh).observe(list, { childList: true })
}
installManagerNavigation()
processForumRows()
installThreadPageControl()
observeThreadList()
ensureManager()
ensureToast()
if (typeof GM_registerMenuCommand === 'function') {
GM_registerMenuCommand('Manage ignored threads', openManager)
}
if (typeof GM_addValueChangeListener === 'function') {
GM_addValueChangeListener(STORAGE_KEY, (_name, _oldValue, newValue) => {
ignoredThreads = normalizeStoredThreads(newValue)
refreshAllUi()
})
}
})()