ChatLock

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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

})();