Trade Chat Timer on Button for Chat 3.0

Show a timer that shows the time left to post next message

// ==UserScript==
// @name         Trade Chat Timer on Button for Chat 3.0
// @namespace    http://tampermonkey.net/
// @version      3.0
// @description  Show a timer that shows the time left to post next message
// @match        https://www.torn.com/*
// ==/UserScript==

(() => {
  const STORAGE_KEY = 'tornTradeTimerEnd';
  const TIMER_DURATION = 62000;
  let animFrameId = null;
  let svgEl = null, rectEl = null, pathLength = 0;
  let observer = null;

  const createSVGElement = (type, attributes = {}) => {
    const el = document.createElementNS('http://www.w3.org/2000/svg', type);
    Object.entries(attributes).forEach(([key, value]) => el.setAttribute(key, value));
    return el;
  };

  const setupOverlay = (btn) => {
    const { width, height } = btn.getBoundingClientRect();

    if (!svgEl) {
      svgEl = createSVGElement('svg', {
        id: 'trade-timer-overlay',
        style: 'position:absolute;top:0;left:0;z-index:1000;pointer-events:none'
      });

      rectEl = createSVGElement('rect', {
        x: '1.5',
        y: '1.5',
        fill: 'none',
        'stroke-width': '3'
      });

      svgEl.appendChild(rectEl);
      btn.appendChild(svgEl);
    }

    Object.entries({ width, height }).forEach(([key, value]) => svgEl.setAttribute(key, value));
    Object.entries({ width: width - 3, height: height - 3 }).forEach(([key, value]) => rectEl.setAttribute(key, value));

    pathLength = 2 * (width + height - 6);
    rectEl.setAttribute('stroke-dasharray', pathLength);
  };

  const updateTimerVisual = (remainingMs) => {
    if (!rectEl) return;

    const isComplete = remainingMs <= 0;
    rectEl.setAttribute('stroke', isComplete ? 'green' : 'red');
    rectEl.setAttribute('stroke-dasharray', isComplete ? 'none' : pathLength);

    if (!isComplete) {
      const offset = pathLength * (1 - remainingMs / TIMER_DURATION);
      rectEl.setAttribute('stroke-dashoffset', offset);
    }
  };

  const cleanupTimer = (btn) => {
    if (animFrameId) {
      cancelAnimationFrame(animFrameId);
      animFrameId = null;
    }
    if (svgEl) {
      svgEl.remove();
      svgEl = null;
      rectEl = null;
    }
    if (btn) btn.style.border = '3px solid green';
    localStorage.removeItem(STORAGE_KEY);
  };

  const runTimer = (btn) => {
    if (!btn) return;

    if (animFrameId) cancelAnimationFrame(animFrameId);
    btn.style.border = 'none';

    // Force reinitialize SVG elements
    if (svgEl) {
      svgEl.remove();
      svgEl = null;
      rectEl = null;
    }
    setupOverlay(btn);

    const animate = () => {
      const endTime = parseInt(localStorage.getItem(STORAGE_KEY));
      const remaining = endTime - Date.now();

      if (remaining <= 0) {
        cleanupTimer(btn);
      } else {
        updateTimerVisual(remaining);
        animFrameId = requestAnimationFrame(animate);
      }
    };

    const endTime = parseInt(localStorage.getItem(STORAGE_KEY));
    updateTimerVisual(endTime - Date.now());
    animFrameId = requestAnimationFrame(animate);
  };

  const startTimer = (btn) => {
    if (!btn) return;
    localStorage.setItem(STORAGE_KEY, Date.now() + TIMER_DURATION);
    runTimer(btn);
  };

  const handleSend = (ta, btn) => {
    if (!ta || !btn) return;
    const checkAndStart = () => !ta.value.trim() && startTimer(btn);
    checkAndStart() || setTimeout(checkAndStart, 200);
  };

  const initializeTradeButton = (tradeBtn) => {
    if (!tradeBtn) return;

    if (getComputedStyle(tradeBtn).position === 'static') {
      tradeBtn.style.position = 'relative';
    }

    const stored = parseInt(localStorage.getItem(STORAGE_KEY));
    stored && stored > Date.now() ? runTimer(tradeBtn) : (tradeBtn.style.border = '3px solid green');
  };

  const setupChat = () => {
    const tradeBtn = document.getElementById('channel_panel_button:public_trade');
    const ta = document.querySelector('textarea.textarea___V8HsV');
    const send = document.querySelector('button.iconWrapper___tyRRU');

    if (tradeBtn) initializeTradeButton(tradeBtn);

    if (tradeBtn && ta && send) {
      const handlers = {
        keydown: (e) => e.key === 'Enter' && (e.preventDefault(), handleSend(ta, tradeBtn)),
        input: () => !ta.value.trim() && handleSend(ta, tradeBtn),
        click: () => handleSend(ta, tradeBtn)
      };

      Object.entries(handlers).forEach(([event, handler]) => {
        const prop = `_timer${event}Handler`;
        if (ta[prop]) ta.removeEventListener(event, ta[prop]);
        if (send[prop]) send.removeEventListener(event, send[prop]);

        const element = event === 'click' ? send : ta;
        element[prop] = handler;
        element.addEventListener(event, handler);
      });
    }
  };

  const setupChatObserver = () => {
    if (observer) observer.disconnect();

    const chatRoot = document.getElementById('chatRoot');
    if (!chatRoot) {
      const docObserver = new MutationObserver(() => {
        const chatRoot = document.getElementById('chatRoot');
        if (chatRoot) {
          docObserver.disconnect();
          setupChatObserver();
        }
      });

      docObserver.observe(document.body, {
        childList: true,
        subtree: true,
        attributes: true,
        attributeFilter: ['id', 'class', 'style']
      });
      return;
    }

    observer = new MutationObserver((mutations) => {
      const hasRelevantChanges = mutations.some(mutation =>
        mutation.type === 'childList' ||
        (mutation.type === 'attributes' && ['class', 'style'].includes(mutation.attributeName))
      );

      if (hasRelevantChanges) setTimeout(setupChat, 100);
    });

    setupChat();
    observer.observe(chatRoot, {
      childList: true,
      subtree: true,
      attributes: true,
      attributeFilter: ['class', 'style']
    });
  };

  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'visible') {
      const tradeBtn = document.getElementById('channel_panel_button:public_trade');
      if (tradeBtn) {
        const stored = parseInt(localStorage.getItem(STORAGE_KEY));
        if (stored && stored > Date.now()) {
          runTimer(tradeBtn);
        } else {
          initializeTradeButton(tradeBtn);
        }
        setupChat();
      }
    }
  });

  window.addEventListener('storage', (e) => {
    if (e.key === STORAGE_KEY) {
      const tradeBtn = document.getElementById('channel_panel_button:public_trade');
      if (!tradeBtn) return;

      const val = parseInt(e.newValue);
      val && val > Date.now() ? runTimer(tradeBtn) : cleanupTimer(tradeBtn);
    }
  });

  document.readyState === 'loading'
    ? document.addEventListener('DOMContentLoaded', setupChatObserver)
    : setupChatObserver();
})();