Torn Chat Reply & Tag

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.

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 or Violentmonkey 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.

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

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!)

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