Ignore SimpCity Threads

Hide selected threads from forum listings and manage them from the forum navigation.

Terá de instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

Terá de instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Terá de instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Terá de instalar uma extensão como Tampermonkey para instalar este script.

Terá de instalar uma extensão de gestão de scripts de utilizador para instalar este script.

(Já tenho um gestor de scripts de utilizador, deixe-me instalá-lo!)

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

(Já tenho um gestor de estilos de utilizador, deixe-me instalá-lo!)

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