Greasy Fork is available in English.
Modern chat replies for Torn. Swipe any message to quote-reply, with a tap-to-jump link back to the original and type @ to tag members, kept separate per chat and remembered across refreshes. Works in faction, company, and private chats. No API key, no setup, just install.
// ==UserScript==
// @name Torn Chat Reply & Tag
// @namespace https://greasyfork.org/users/Prantonia
// @version 1.7
// @description Modern chat replies for Torn. Swipe any message to quote-reply, with a tap-to-jump link back to the original and type @ to tag members, kept separate per chat and remembered across refreshes. Works in faction, company, and private chats. No API key, no setup, just install.
// @author Prantonia
// @license MIT
// @match https://www.torn.com/*
// @grant none
// @run-at document-idle
// @homepageURL https://greasyfork.org/en/scripts/590611-torn-chat-reply-tag
// @supportURL https://greasyfork.org/en/scripts/590611-torn-chat-reply-tag/feedback
// ==/UserScript==
// Torn Chat Reply & Tag
// Copyright (c) 2026 Prantonia
// Released under the MIT License. See the LICENSE file or https://opensource.org/licenses/MIT
(function () {
'use strict';
// Prevent a second copy from running (Torn PDA can inject userscripts twice,
// which would double every reply).
if (window.__tornChatReplyTagLoaded) return;
window.__tornChatReplyTagLoaded = true;
const CONFIG = {
messageSelector: '[class*="box___"]',
authorSelector: 'a[class*="sender___"]',
inputSelector: 'textarea[class*="textarea___"], [contenteditable="true"]',
// The chat window's title text (e.g. "Faction", "Company", or a DM name),
// used as the stable key for remembering each chat's names separately.
titleSelector: '[class*="title___"]',
swipeThreshold: 45,
maxDrag: 90,
replyMarker: '↪',
quoteMaxLen: 90,
maxSuggestions: 10
};
const ROSTER_KEY = 'tornSwipeReplyRostersByChat';
// Per-chat saved rosters: { "Faction": ["Dutch78", ...], "Company": [...] }
let saved = {};
try { saved = JSON.parse(localStorage.getItem(ROSTER_KEY) || '{}') || {}; } catch (e) { saved = {}; }
let saveTimer = null;
function persist() {
if (saveTimer) return;
saveTimer = setTimeout(() => {
saveTimer = null;
try { localStorage.setItem(ROSTER_KEY, JSON.stringify(saved)); } catch (e) {}
}, 800);
}
// Merge names into a chat's saved list (keyed by its title).
function rememberIn(title, names) {
if (!title || !names || !names.size) return;
const set = new Set(saved[title] || []);
let changed = false;
names.forEach(n => { if (!set.has(n)) { set.add(n); changed = true; } });
if (changed) { saved[title] = Array.from(set); persist(); }
}
const clean = s => (s || '').trim().replace(/[:\s]+$/, '');
function getAuthor(msgEl) {
// Regular chat: the sender link carries the name.
const senders = msgEl.querySelectorAll('a[class*="sender___"]');
for (const s of senders) if (s.textContent.trim()) return clean(s.textContent);
const links = msgEl.querySelectorAll('a[href*="profiles.php"], a[href*="XID="]');
for (const l of links) if (l.textContent.trim()) return clean(l.textContent);
// No name label (your own messages, and DM messages). Your messages sit on
// the right; the other side sits on the left — detect by position.
if (isSentByMe(msgEl)) return 'You';
// Left-aligned with no label → the partner in a DM (their name = chat title).
const title = chatTitleOf(chatScopeOf(msgEl));
if (title) return title;
// Last resort: a "Name:" prefix if present.
const m = msgEl.textContent.trim().match(/^([^:]{1,30}):/);
return m ? clean(m[1]) : 'them';
}
// Parse an rgb/rgba string to relative luminance 0..1 (null if unknown).
function luminance(rgb) {
const m = /rgba?\(([^)]+)\)/.exec(rgb || '');
if (!m) return null;
const p = m[1].split(',').map(s => parseFloat(s));
if (p.length >= 4 && p[3] === 0) return null; // fully transparent
return (0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]) / 255;
}
// Your messages are dark bubbles; the other person's are light. Read the actual
// background colour (walking up until we find a non-transparent one).
function isSentByMe(msgEl) {
let el = msgEl.querySelector('[class*="message___"]') || msgEl;
for (let i = 0; i < 4 && el; i++) {
const lum = luminance(getComputedStyle(el).backgroundColor);
if (lum != null) return lum < 0.5; // dark bubble => sent by me
el = el.parentElement;
}
return false; // unknown => treat as the other person's (shows their name)
}
// A DM has no "Name:" sender links on its messages (unlike faction/company).
function isDM(scope) {
return !!scope && !scope.querySelector('a[class*="sender___"]');
}
function getText(msgEl) {
const body = msgEl.querySelector('[class*="message___"]');
if (body && body.textContent.trim()) return body.textContent.trim();
const full = msgEl.textContent.trim();
const m = full.match(/^[^:]{1,30}:\s*([\s\S]*)$/);
return (m ? m[1] : full).trim();
}
// A real chat message has a message body (regular chat, and DMs which lack sender links).
const isChatMessage = el => el.querySelector && el.querySelector('[class*="message___"]');
// ---------- Chat-window scoping (the key to no cross-chat leaks) ----------
// A chat window contains exactly ONE chat input. The shared container that
// holds several open windows contains several. So the window is the largest
// ancestor of `el` that still contains only one input. This is reliable even
// for DMs, whose messages carry no username labels.
function chatScopeOf(el) {
let node = el, best = null;
while (node && node.parentElement) {
const parent = node.parentElement;
const count = parent.querySelectorAll(CONFIG.inputSelector).length;
if (count <= 1) { best = parent; node = parent; }
else break;
}
return best || (el.parentElement || el);
}
// The chat window's title text, used as its persistence key.
function chatTitleOf(scope) {
if (!scope) return null;
const t = scope.querySelector(CONFIG.titleSelector);
if (t && t.textContent.trim()) return t.textContent.trim().slice(0, 40);
return null;
}
function authorsInScope(scope) {
const set = new Set();
if (!scope) return set;
scope.querySelectorAll(CONFIG.messageSelector).forEach(m => {
if (isChatMessage(m)) { const a = getAuthor(m); if (a && a !== 'them' && a !== 'You') set.add(a); }
});
return set;
}
// ---------- React-safe input writing ----------
function nativeSetValue(input, value) {
const proto = input.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
Object.getOwnPropertyDescriptor(proto, 'value').set.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
function setReplyBlock(input, block) {
if (!input) return;
// Replace any quote already pending in the input (keep the reply you typed),
// then put the new quote at the top. This swaps the quote instead of stacking.
if (input.isContentEditable) {
input.focus();
const kept = stripReplyPrefix(input.textContent || '');
input.textContent = kept ? block + kept : block;
input.dispatchEvent(new InputEvent('input', { bubbles: true }));
} else {
const kept = stripReplyPrefix(input.value || '');
const val = kept ? block + kept : block;
nativeSetValue(input, val);
input.focus();
try { input.setSelectionRange(val.length, val.length); } catch (e) {}
}
}
function findInputFor(msgEl) {
const scope = chatScopeOf(msgEl);
if (scope) { const i = scope.querySelector(CONFIG.inputSelector); if (i) return i; }
return document.querySelector(CONFIG.inputSelector);
}
// ---------- Swipe → reply ----------
// Remove any "↪ Name [quote]" block from a message (wherever it sits) so that
// replying to a reply quotes only the real text — no nesting. Handles blocks at
// the start, mid-message (if text was typed before swiping), nested, or truncated.
function stripReplyPrefix(text) {
let t = text.trim();
let guard = 0;
while (guard++ < 8) {
const mk = t.indexOf(CONFIG.replyMarker);
if (mk === -1) break;
const open = t.indexOf('[', mk);
if (open === -1) break;
let depth = 0, close = -1;
for (let i = open; i < t.length; i++) {
if (t[i] === '[') depth++;
else if (t[i] === ']') { depth--; if (depth === 0) { close = i; break; } }
}
if (close === -1) {
// Truncated/unbalanced: remove from the marker to the last ']'.
const last = t.lastIndexOf(']');
if (last > mk) { t = (t.slice(0, mk) + ' ' + t.slice(last + 1)).replace(/\s+/g, ' ').trim(); continue; }
break;
}
t = (t.slice(0, mk) + ' ' + t.slice(close + 1)).replace(/\s+/g, ' ').trim();
}
return t;
}
let lastReplyAt = 0;
function doReply(msgEl) {
// Guard against a single swipe firing twice (e.g. pointercancel + pointerup
// both completing, or synthetic mouse events after touch on mobile).
const now = Date.now();
if (now - lastReplyAt < 500) return;
lastReplyAt = now;
const author = getAuthor(msgEl);
const raw = getText(msgEl).replace(/\s+/g, ' ');
let q = stripReplyPrefix(getText(msgEl)).replace(/\s+/g, ' ') || raw;
if (q.length > CONFIG.quoteMaxLen) q = q.slice(0, CONFIG.quoteMaxLen) + '…';
// Stacked with a blank line before your reply, e.g.:
// ↪ griffioen
// [Morning everyone 🌞]
//
// Fine morning @griffioen
const block = `${CONFIG.replyMarker} ${author}\n[${q}]\n\n`;
setReplyBlock(findInputFor(msgEl), block);
flash(msgEl);
}
function flash(el) {
const bg = el.style.backgroundColor, tr = el.style.transition;
el.style.transition = 'background-color 0.15s';
el.style.backgroundColor = 'rgba(80,200,120,0.35)';
setTimeout(() => { el.style.backgroundColor = bg; el.style.transition = tr; }, 250);
}
function ensureIndicator(msgEl) {
let ind = msgEl.querySelector('.ttr-reply-indicator');
if (!ind) {
if (getComputedStyle(msgEl).position === 'static') msgEl.style.position = 'relative';
msgEl.dataset.ttrOverflow = msgEl.style.overflow || '';
msgEl.style.overflow = 'visible';
ind = document.createElement('span');
ind.className = 'ttr-reply-indicator';
ind.textContent = '↩';
Object.assign(ind.style, { position: 'absolute', left: '8px', top: '50%',
transform: 'translateY(-50%) scale(0.4)', opacity: '0', fontSize: '22px',
fontWeight: 'bold', color: '#3a7d44', zIndex: '10', pointerEvents: 'none',
textShadow: '0 0 3px rgba(255,255,255,0.9)' });
msgEl.appendChild(ind);
}
return ind;
}
function updateIndicator(msgEl, p) {
const ind = ensureIndicator(msgEl), c = Math.min(p, 1);
ind.style.opacity = String(Math.max(c, 0.25));
ind.style.transform = `translateY(-50%) scale(${0.5 + c * 0.6})`;
ind.style.color = p >= 1 ? '#2e8b57' : '#7aa38a';
}
function clearIndicator(msgEl) {
const i = msgEl.querySelector('.ttr-reply-indicator');
if (i) i.remove();
if (msgEl.dataset.ttrOverflow !== undefined) { msgEl.style.overflow = msgEl.dataset.ttrOverflow; delete msgEl.dataset.ttrOverflow; }
}
let active = null;
const closestMessage = el => el && el.closest ? el.closest(CONFIG.messageSelector) : null;
function onDown(e) {
if (e.button != null && e.button !== 0) return;
const msgEl = closestMessage(e.target);
if (!msgEl) return;
active = { el: msgEl, startX: e.clientX, startY: e.clientY, decided: false, horizontal: false };
}
function onMove(e) {
if (!active) return;
const dx = e.clientX - active.startX, dy = e.clientY - active.startY;
if (!active.decided) {
if (Math.abs(dx) < 6 && Math.abs(dy) < 6) return;
active.horizontal = Math.abs(dx) > Math.abs(dy);
active.decided = true;
if (!active.horizontal) { active = null; return; }
active.el.style.transition = 'none';
}
if (active.horizontal && dx > 0) {
if (e.cancelable) e.preventDefault();
active.lastDx = dx;
active.el.style.transform = `translateX(${Math.min(dx, CONFIG.maxDrag)}px)`;
updateIndicator(active.el, dx / CONFIG.swipeThreshold);
}
}
function onUp(e) {
if (!active) return;
const el = active.el, dx = e.clientX - active.startX;
el.style.transition = 'transform 0.18s ease';
el.style.transform = 'translateX(0)';
if (active.horizontal && dx >= CONFIG.swipeThreshold) doReply(el);
setTimeout(() => clearIndicator(el), 200);
active = null;
}
// If the webview still cancels the gesture after we've swiped past the
// threshold, complete the reply anyway (the cancel isn't the user giving up).
function onCancel() {
if (!active) return;
const el = active.el;
el.style.transition = 'transform 0.18s ease';
el.style.transform = 'translateX(0)';
if (active.horizontal && (active.lastDx || 0) >= CONFIG.swipeThreshold) doReply(el);
setTimeout(() => clearIndicator(el), 200);
active = null;
}
if (window.PointerEvent) {
document.addEventListener('pointerdown', onDown, { passive: true });
document.addEventListener('pointermove', onMove, { passive: false });
document.addEventListener('pointerup', onUp, { passive: true });
document.addEventListener('pointercancel', onCancel, { passive: true });
} else {
document.addEventListener('touchstart', e => onDown(e.touches[0]), { passive: true });
document.addEventListener('touchmove', e => { if (active) onMove(e.touches[0]); }, { passive: false });
document.addEventListener('touchend', e => onUp(e.changedTouches[0]), { passive: true });
document.addEventListener('touchcancel', onCancel, { passive: true });
document.addEventListener('mousedown', onDown, { passive: true });
document.addEventListener('mousemove', onMove, { passive: false });
document.addEventListener('mouseup', onUp, { passive: true });
}
// ============================================================
// @-mention autocomplete (scoped to the current chat window)
// ============================================================
let box = null, items = [], sel = -1, targetInput = null;
function buildBox() {
box = document.createElement('div');
box.className = 'ttr-mention-box';
Object.assign(box.style, { position: 'fixed', zIndex: 2147483647, minWidth: '160px',
maxHeight: '220px', overflowY: 'auto', background: '#1e1e1e', color: '#eee',
border: '1px solid #555', borderRadius: '6px', fontSize: '14px',
fontFamily: 'sans-serif', boxShadow: '0 4px 16px rgba(0,0,0,0.6)', display: 'none' });
document.body.appendChild(box);
}
function hideBox() { if (box) box.style.display = 'none'; sel = -1; items = []; }
function getQueryToken(input) {
let text, caret;
if (input.isContentEditable) {
const s = window.getSelection();
caret = (s && s.anchorOffset) || 0;
text = (input.textContent || '').slice(0, caret);
} else {
caret = input.selectionStart;
text = (input.value || '').slice(0, caret);
}
const m = text.match(/@([\w-]*)$/);
if (!m) return null;
return { query: m[1], atPos: caret - m[0].length + 1, caret };
}
function showSuggestions(input) {
const tok = getQueryToken(input);
if (!tok) { hideBox(); return; }
targetInput = input;
if (!box) buildBox();
// Only names from THIS chat window — never other chats.
const scope = chatScopeOf(input);
if (isDM(scope)) { hideBox(); return; } // no @ mentions in private chats
const title = chatTitleOf(scope);
const live = authorsInScope(scope);
rememberIn(title, live); // save anyone currently visible
const names = new Set(live);
if (title && saved[title]) saved[title].forEach(n => names.add(n)); // add remembered
const q = tok.query.toLowerCase();
let list = Array.from(names).filter(n => n.toLowerCase().includes(q));
list.sort((a, b) => {
const as = a.toLowerCase().startsWith(q) ? 0 : 1;
const bs = b.toLowerCase().startsWith(q) ? 0 : 1;
return as - bs || a.localeCompare(b);
});
list = list.slice(0, CONFIG.maxSuggestions);
if (!list.length) {
if (names.size === 0) showInfo('No names in this chat yet.');
else showInfo('No match for "' + tok.query + '"');
return;
}
items = list; sel = 0;
renderItems(tok);
positionBox(input);
box.style.display = 'block';
}
function showInfo(msg) {
box.innerHTML = '';
const row = document.createElement('div');
row.textContent = msg;
Object.assign(row.style, { padding: '8px 12px', color: '#aaa', fontStyle: 'italic', fontSize: '12px' });
box.appendChild(row);
positionBox(targetInput);
box.style.display = 'block';
items = []; sel = -1;
}
function renderItems(tok) {
box.innerHTML = '';
items.forEach((name, i) => {
const row = document.createElement('div');
row.textContent = name;
Object.assign(row.style, { padding: '7px 12px', cursor: 'pointer', background: i === sel ? '#3a7d44' : 'transparent' });
row.onmouseenter = () => { sel = i; highlight(); };
row.onmousedown = e => { e.preventDefault(); pick(tok, name); };
box.appendChild(row);
});
}
function highlight() { Array.from(box.children).forEach((c, i) => c.style.background = i === sel ? '#3a7d44' : 'transparent'); }
function positionBox(input) {
if (!input) return;
const r = input.getBoundingClientRect();
const width = Math.max(160, r.width);
let left = r.left;
if (left + width > window.innerWidth - 8) left = window.innerWidth - width - 8;
if (left < 8) left = 8;
box.style.left = left + 'px';
box.style.width = width + 'px';
if (r.top > 120) { box.style.bottom = (window.innerHeight - r.top + 4) + 'px'; box.style.top = 'auto'; }
else { box.style.top = (r.bottom + 4) + 'px'; box.style.bottom = 'auto'; }
}
function pick(tok, name) {
const input = targetInput;
if (!input) return;
if (input.isContentEditable) {
const full = input.textContent || '';
input.textContent = full.slice(0, tok.atPos - 1) + '@' + name + ' ' + full.slice(tok.caret);
input.dispatchEvent(new InputEvent('input', { bubbles: true }));
input.focus();
} else {
const full = input.value || '';
const before = full.slice(0, tok.atPos - 1);
nativeSetValue(input, before + '@' + name + ' ' + full.slice(tok.caret));
input.focus();
const caret = (before + '@' + name + ' ').length;
try { input.setSelectionRange(caret, caret); } catch (e) {}
}
hideBox();
}
document.addEventListener('input', e => {
const input = e.target;
if (input && input.matches && input.matches(CONFIG.inputSelector)) showSuggestions(input);
}, true);
document.addEventListener('keydown', e => {
if (!box || box.style.display === 'none' || !items.length) return;
if (e.key === 'ArrowDown') { e.preventDefault(); sel = (sel + 1) % items.length; highlight(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); sel = (sel - 1 + items.length) % items.length; highlight(); }
else if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault(); e.stopPropagation();
const tok = getQueryToken(targetInput);
if (tok && items[sel]) pick(tok, items[sel]);
} else if (e.key === 'Escape') { hideBox(); }
}, true);
document.addEventListener('click', e => { if (box && !box.contains(e.target)) hideBox(); }, true);
// ---------- Click a reply → jump to the original ----------
function highlightMessage(el) {
const bg = el.style.backgroundColor, tr = el.style.transition;
el.style.transition = 'background-color 0.2s';
el.style.backgroundColor = 'rgba(255,215,0,0.45)';
setTimeout(() => { el.style.backgroundColor = bg; el.style.transition = tr; }, 1400);
}
// Find the scrollable chat container by walking up from a message.
function findScroller(fromEl) {
let el = fromEl;
while (el && el !== document.body) {
const s = getComputedStyle(el);
if ((s.overflowY === 'auto' || s.overflowY === 'scroll') && el.scrollHeight > el.clientHeight + 10) return el;
el = el.parentElement;
}
return null;
}
let jumping = false;
function jumpToOriginal(scope, author, quote, fromEl) {
// Use the whole quoted text (not just a prefix) for a precise match.
const needle = quote.replace(/…$/, '').trim().toLowerCase();
const root = scope || document;
const find = () => {
const matches = [];
for (const m of root.querySelectorAll(CONFIG.messageSelector)) {
if (!isChatMessage(m) || m === fromEl) continue;
if (getAuthor(m).toLowerCase() !== author.toLowerCase()) continue;
const t = stripReplyPrefix(getText(m)).replace(/\s+/g, ' ').toLowerCase();
if (needle && t.startsWith(needle)) matches.push(m);
}
if (!matches.length) return null;
if (!fromEl) return matches[0];
// Prefer the match closest ABOVE the reply (replies refer to recent
// messages). querySelectorAll is in document order, so the last match
// that precedes the reply is the nearest one.
let best = null;
for (const m of matches) {
if (m.compareDocumentPosition(fromEl) & Node.DOCUMENT_POSITION_FOLLOWING) best = m;
}
return best || matches[matches.length - 1];
};
const land = m => { m.scrollIntoView({ behavior: 'smooth', block: 'center' }); highlightMessage(m); };
// Already loaded? Jump immediately.
let target = find();
if (target) { land(target); return; }
// Not loaded — scroll up in steps to load older messages, searching as we go.
const scroller = findScroller(fromEl);
if (!scroller || jumping) { if (fromEl) flash(fromEl); return; }
jumping = true;
const startTop = scroller.scrollTop;
let steps = 0;
const MAX_STEPS = 25; // ~ up to 25 scroll-ups
const STEP_DELAY = 300; // wait for each lazy load (ms)
(function step() {
target = find();
if (target) { jumping = false; land(target); return; }
if (steps++ >= MAX_STEPS || scroller.scrollTop <= 0) {
scroller.scrollTop = startTop; // restore position
jumping = false;
if (fromEl) flash(fromEl); // couldn't find it in range
return;
}
scroller.scrollTop = Math.max(0, scroller.scrollTop - Math.max(200, scroller.clientHeight * 0.8));
scroller.dispatchEvent(new Event('scroll', { bubbles: true })); // nudge lazy-loaders
setTimeout(step, STEP_DELAY);
})();
}
// Which text offset did the click land on? (browser caret-from-point APIs)
function caretInfoFromPoint(x, y) {
if (document.caretRangeFromPoint) {
const r = document.caretRangeFromPoint(x, y);
return r ? { node: r.startContainer, offset: r.startOffset } : null;
}
if (document.caretPositionFromPoint) {
const p = document.caretPositionFromPoint(x, y);
return p ? { node: p.offsetNode, offset: p.offset } : null;
}
return null;
}
// Absolute character offset of (node, offset) within `root`'s text.
function absoluteOffset(root, node, offset) {
if (!root.contains(node)) return null;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
let total = 0, n;
while ((n = walker.nextNode())) {
if (n === node) return total + offset;
total += n.textContent.length;
}
return null;
}
// True only if the click landed on the quote lines (the "↪ Name [quote]" part),
// not the reply text below it. Falls back to true if we can't tell.
function clickIsOnQuote(e, msg) {
const body = msg.querySelector('[class*="message___"]');
if (!body) return true;
const full = body.textContent;
const mk = full.indexOf(CONFIG.replyMarker);
const open = full.indexOf('[', mk);
if (mk === -1 || open === -1) return true;
let depth = 0, close = -1;
for (let i = open; i < full.length; i++) {
if (full[i] === '[') depth++;
else if (full[i] === ']') { depth--; if (depth === 0) { close = i; break; } }
}
if (close === -1) close = full.indexOf(']', open);
if (close === -1) return true;
const info = caretInfoFromPoint(e.clientX, e.clientY);
if (!info) return true; // no caret API -> allow
const off = absoluteOffset(body, info.node, info.offset);
if (off === null) return true; // couldn't map -> allow
return off <= close + 1; // within the quote block
}
document.addEventListener('click', e => {
if (!e.target.closest) return;
if (e.target.closest('a')) return;
if (box && box.contains(e.target)) return;
const msg = e.target.closest(CONFIG.messageSelector);
if (!msg || !isChatMessage(msg)) return;
const text = getText(msg).trim();
const mk = text.indexOf(CONFIG.replyMarker);
if (mk === -1 || text.indexOf('[', mk) === -1) return; // not a reply
if (!clickIsOnQuote(e, msg)) return; // only the quote jumps
// Parse "↪ Author" then the quoted text inside [ ] — robust even if
// Torn flattened the line breaks into one line.
const afterMarker = text.slice(mk + CONFIG.replyMarker.length).trim();
const br = afterMarker.indexOf('[');
const author = (br >= 0 ? afterMarker.slice(0, br) : afterMarker.split('\n')[0]).trim();
const qm = text.slice(mk).match(/\[([^\]]+)\]/);
const quote = qm ? qm[1] : '';
if (author && quote) jumpToOriginal(chatScopeOf(msg), author, quote, msg);
}, true);
// ---------- Seed saved rosters from every open chat (so refresh remembers) ----------
function seedAllChats() {
document.querySelectorAll(CONFIG.inputSelector).forEach(inp => {
const scope = chatScopeOf(inp);
if (isDM(scope)) return; // don't persist DM names
const title = chatTitleOf(scope);
if (title) rememberIn(title, authorsInScope(scope));
});
}
// ---------- Reserve horizontal swipes for us on mobile ----------
// Must be set BEFORE the touch starts, or webviews (Torn PDA/Android) cancel
// the gesture. Scoped to chat messages only, so the rest of Torn is untouched.
function applyChatTouchAction() {
document.querySelectorAll(CONFIG.inputSelector).forEach(inp => {
const scope = chatScopeOf(inp);
if (!scope) return;
scope.querySelectorAll(CONFIG.messageSelector).forEach(m => {
if (m.style.touchAction !== 'pan-y' && isChatMessage(m)) m.style.touchAction = 'pan-y';
});
});
}
seedAllChats();
applyChatTouchAction();
setInterval(seedAllChats, 15000); // pick up new speakers periodically
setInterval(applyChatTouchAction, 2000); // tag new messages for swiping quickly
console.log('[Torn Swipe Reply] active — swipe to reply; @ suggests people from this chat only; click a reply to jump to the original.');
})();