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.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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