ChatLock

Long-press the Faction chat button to pin chat to that window. Orange glow = pinned.

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

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

Advertisement:

// ==UserScript==
// @name         ChatLock
// @namespace    https://torn.com
// @version      3.7.0
// @description  Long-press the Faction chat button to pin chat to that window. Orange glow = pinned.
// @author       Prodigal
// @match        https://www.torn.com/*
// @grant        none
// @run-at       document-start
// ==/UserScript==

(function () {
  'use strict';

  const LS_KEY      = 'ChatLock_owner';
  const HB_KEY      = 'ChatLock_heartbeat';
  const SS_KEY      = 'ChatLock_tabId';
  const PARAM       = 'chat';
  const HOLD_MS     = 600;
  const HB_INTERVAL = 3000;
  const HB_STALE    = 8000;

  // Hides chat panels on non-owner windows
  const HIDE_CSS = '#chatRoot > div > div:first-child { display: none !important; }';

  // Pinned highlight — injected as a <style> tag, NOT a class on the button.
  // React re-renders wipe classes but can't touch <style> tags, so this persists.
  const PINNED_CSS = `
    [id^="channel_panel_button:faction-"] {
      background: linear-gradient(#7a3a00, #c47d10) !important;
    }
    [id^="channel_panel_button:faction-"]:hover {
      background: linear-gradient(#9a4a00, #f5a623) !important;
    }
    [id^="channel_panel_button:faction-"] svg {
      filter: brightness(10) !important;
    }
  `;

  // Charging ring — class-based is fine here, only lasts during the hold
  const CHARGING_CSS = `
    [id^="channel_panel_button:faction-"].cl-charging {
      outline: 2px solid rgba(245,166,35,0.85) !important;
      outline-offset: 2px !important;
    }
  `;

  // ── Tab identity ────────────────────────────────────────────────────────────
  // Always generate a fresh ID on page load to avoid duplicated tab collisions.
  // sessionStorage persists across same-tab navigation so we reuse the ID if
  // this tab already has one — that's how we survive page changes within Torn.
  let myTabId = sessionStorage.getItem(SS_KEY);
  if (!myTabId) {
    myTabId = `${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
    sessionStorage.setItem(SS_KEY, myTabId);
  }

  // If ?chat=1 is in the URL, mark this tab as owner and store the flag in
  // sessionStorage so it survives subsequent page navigations within this tab.
  if (new URLSearchParams(window.location.search).get(PARAM) === '1') {
    localStorage.setItem(LS_KEY, myTabId);
    sessionStorage.setItem(SS_KEY + '_owner', '1');
  }

  // If sessionStorage says this tab is the owner (persisted across nav),
  // re-assert ownership in localStorage in case it was lost on page change.
  if (sessionStorage.getItem(SS_KEY + '_owner') === '1') {
    localStorage.setItem(LS_KEY, myTabId);
  }

  // ── Core helpers ────────────────────────────────────────────────────────────
  const isChatOwner = () => localStorage.getItem(LS_KEY) === myTabId;

  function injectStyle(id, css) {
    if (document.getElementById(id)) return;
    const el = document.createElement('style');
    el.id = id;
    el.textContent = css;
    (document.head || document.documentElement).appendChild(el);
  }

  // Suppress panels only when there IS an owner and this tab isn't it
  function applyState() {
    const hasOwner = !!localStorage.getItem(LS_KEY);
    if (isChatOwner() || !hasOwner) {
      document.getElementById('cl-hide')?.remove();
    } else {
      injectStyle('cl-hide', HIDE_CSS);
    }
  }

  // Apply or remove orange highlight via <style> tag — React-proof
  function applyHighlight() {
    if (isChatOwner()) {
      injectStyle('cl-pinned', PINNED_CSS);
    } else {
      document.getElementById('cl-pinned')?.remove();
    }
  }

  // Inject immediately before React renders
  applyState();
  applyHighlight();

  // ── Heartbeat ───────────────────────────────────────────────────────────────
  let heartbeatTimer = null;

  function startHeartbeat() {
    stopHeartbeat();
    localStorage.setItem(HB_KEY, Date.now());
    heartbeatTimer = setInterval(() => localStorage.setItem(HB_KEY, Date.now()), HB_INTERVAL);
  }

  function stopHeartbeat() {
    clearInterval(heartbeatTimer);
    heartbeatTimer = null;
  }

  function clearLock() {
    localStorage.removeItem(LS_KEY);
    localStorage.removeItem(HB_KEY);
    applyState();
    applyHighlight();
  }

  if (isChatOwner()) startHeartbeat();

  // ── beforeunload — instant cleanup on clean close ───────────────────────────
  window.addEventListener('beforeunload', () => {
    if (isChatOwner()) {
      stopHeartbeat();
      localStorage.removeItem(LS_KEY);
      localStorage.removeItem(HB_KEY);
    }
  });

  // ── Pin / release ───────────────────────────────────────────────────────────
  function navigate(addParam) {
    const url = new URL(window.location.href);
    addParam ? url.searchParams.set(PARAM, '1') : url.searchParams.delete(PARAM);
    window.location.replace(url);
  }

  function claimPin() {
    localStorage.setItem(LS_KEY, myTabId);
    sessionStorage.setItem(SS_KEY + '_owner', '1');
    startHeartbeat();
    navigate(true);
  }

  function releasePin() {
    stopHeartbeat();
    sessionStorage.removeItem(SS_KEY + '_owner');
    localStorage.removeItem(LS_KEY);
    localStorage.removeItem(HB_KEY);
    navigate(false);
  }

  // ── Button tooltip ──────────────────────────────────────────────────────────
  // Highlight is CSS-driven — we only need to update the tooltip on the button
  function applyTooltip(btn) {
    btn.title = isChatOwner() ? 'Faction (pinned — hold to release)' : 'Faction';
  }

  // ── Long-press ──────────────────────────────────────────────────────────────
  function attachLongPress(btn) {
    if (btn.dataset.clAttached) return;
    btn.dataset.clAttached = '1';

    let timer = null;
    let fired = false;

    btn.addEventListener('mousedown', (e) => {
      if (e.button !== 0) return;
      fired = false;
      btn.classList.add('cl-charging');
      timer = setTimeout(() => {
        fired = true;
        btn.classList.remove('cl-charging');
        isChatOwner() ? releasePin() : claimPin();
      }, HOLD_MS);
    });

    const cancel = () => { clearTimeout(timer); btn.classList.remove('cl-charging'); };
    btn.addEventListener('mouseup',    cancel);
    btn.addEventListener('mouseleave', cancel);

    btn.addEventListener('click', (e) => { if (fired) { e.stopImmediatePropagation(); fired = false; } }, true);
  }

  // ── Init ────────────────────────────────────────────────────────────────────
  function init(btn) {
    injectStyle('cl-charging-styles', CHARGING_CSS);
    applyTooltip(btn);
    attachLongPress(btn);
  }

  // Persistent watcher — re-inits when Torn's SPA replaces the faction button
  function watchBtn() {
    let currentBtn = null;

    const obs = new MutationObserver(() => {
      const btn = document.querySelector('[id^="channel_panel_button:faction-"]');
      if (btn && btn !== currentBtn) {
        currentBtn = btn;
        init(btn);
      }
    });

    obs.observe(document.body, { childList: true, subtree: true });

    const btn = document.querySelector('[id^="channel_panel_button:faction-"]');
    if (btn) { currentBtn = btn; init(btn); }
  }

  // ── Cross-tab sync ──────────────────────────────────────────────────────────
  window.addEventListener('storage', (e) => {
    if (e.key === HB_KEY) {
      if (!isChatOwner() && localStorage.getItem(LS_KEY)) {
        const last = parseInt(localStorage.getItem(HB_KEY) || '0', 10);
        if (Date.now() - last > HB_STALE) clearLock();
      }
      return;
    }

    if (e.key !== LS_KEY) return;
    applyState();
    applyHighlight();
    const btn = document.querySelector('[id^="channel_panel_button:faction-"]');
    if (btn) applyTooltip(btn);
    if (isChatOwner()) startHeartbeat(); else stopHeartbeat();
  });

  document.readyState === 'loading'
    ? document.addEventListener('DOMContentLoaded', watchBtn)
    : watchBtn();

})();