Long-press the Faction chat button to pin chat to that window. Orange glow = pinned.
// ==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();
})();