Torn Forum Notifier

Chathead-style forum reply notifications. Drag to reposition, tap to view.

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey, Greasemonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

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

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

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.

(I already have a user script manager, let me install it!)

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 Forum Notifier
// @namespace    zonure.scripts.tfn
// @version      1.0.0
// @description  Chathead-style forum reply notifications. Drag to reposition, tap to view.
// @author       Zonure
// @match        https://www.torn.com/*
// @grant        none
// @run-at       document-idle
// @noframes
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  /* === CONFIG === */
  const ID            = 'tfn';
  const POLL_MS       = 1 * 60 * 1000;
  const HOLD_MS       = 900;
  const CH_W          = 38;
  const EDGE_PEEK     = 5;
  const NOTIF_CAP     = 100;
  const API_DELAY_MS  = 650;             // between paginated calls
  const POST_DELAY_MS = 3 * 1000;        // between per-thread post fetches
  const THREAD_TTL_MS = 15 * 60 * 1000;
  const PDA_KEY       = '###PDA-APIKEY###';
  const DEBUG         = false;

  /* === STORAGE KEYS === */
  const K = {
    key:      `${ID}-apikey`,
    threads:  `${ID}-threads`,   // { [id]: { id, title, forum_id, lastTotal, source } }
    notifs:   `${ID}-notifs`,
    disabled: `${ID}-disabled`,
    unread:   `${ID}-unread`,
    chY:      `${ID}-ch-y`,
    chSide:   `${ID}-ch-side`,
    threadsTs:`${ID}-threads-ts`,
    inited:   `${ID}-inited`,
    lastPoll: `${ID}-last-poll`,
    profile:  `${ID}-profile`,
    newTab:   `${ID}-new-tab`,
  };

  /* === STATE === */
  const S = {
    apiKey:    null,
    threads:   {},
    notifs:    [],
    disabled:  new Set(),
    unread:    0,
    polling:   false,
    pollTimer: null,
    chathead:  null,
    dropOpen:  false,
    settingsOpen: false,
    footerTicker: null,
    callsThisMin: 0,
    callMinuteBucket: null,
    userId:    null,
    userName:  null,
    newTab:    true,
  };

  /* === UTILS === */
  const log  = (...a) => DEBUG && console.log('[TFN]', ...a);
  const sleep = ms => new Promise(r => setTimeout(r, ms));
  const esc   = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');

  const ls = {
    get: k  => { try { return JSON.parse(localStorage.getItem(k)); } catch { return null; } },
    set: (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); } catch { log('ls fail', k); } },
  };

  /* === API === */
  const BASE = 'https://api.torn.com/v2';

  async function fetchUrl(url) {
    const sep = url.includes('?') ? '&' : '?';
    const full = `${url}${sep}striptags=true&comment=%5BTornForumNotifier%5D&key=${S.apiKey}`;
    const r = await fetch(full);
    if (!r.ok) throw new Error(`HTTP ${r.status}`);
    const d = await r.json();
    if (d.error) throw new Error(d.error.error ?? 'API error');
    const bucket = Math.floor(Date.now() / 60000);
    if (bucket !== S.callMinuteBucket) { S.callMinuteBucket = bucket; S.callsThisMin = 0; }
    S.callsThisMin++;
    return d;
  }

  const apiFetch = path => fetchUrl(`${BASE}${path}`);

  async function fetchAllPages(firstPath, dataKey) {
    const items = [];
    const d = await apiFetch(firstPath);
    if (Array.isArray(d[dataKey])) items.push(...d[dataKey]);
    let next = d._metadata?.links?.next ?? null;
    while (next) {
      await sleep(API_DELAY_MS);
      const nd = await fetchUrl(next);
      if (Array.isArray(nd[dataKey])) items.push(...nd[dataKey]);
      next = nd._metadata?.links?.next ?? null;
    }
    return items;
  }

  /* === DATA PERSISTENCE === */
  function loadState() {
    // TornPDA injects key at install time
    if (PDA_KEY.charAt(0) !== '#') {
      S.apiKey = PDA_KEY;
      ls.set(K.key, S.apiKey);
    } else {
      S.apiKey = ls.get(K.key);
    }
    S.threads  = ls.get(K.threads)  ?? {};
    S.notifs   = ls.get(K.notifs)   ?? [];
    S.unread   = ls.get(K.unread)   ?? 0;
    S.disabled = new Set(ls.get(K.disabled) ?? []);
    const prof = ls.get(K.profile);
    S.userId   = prof?.id   ?? null;
    S.userName = prof?.name ?? null;
    S.newTab   = ls.get(K.newTab) ?? true;
  }

  const saveThreads  = () => ls.set(K.threads, S.threads);
  const saveDisabled = () => ls.set(K.disabled, [...S.disabled]);
  const saveUnread   = () => ls.set(K.unread, S.unread);
  const saveNotifs   = () => {
    if (S.notifs.length > NOTIF_CAP) S.notifs = S.notifs.slice(-NOTIF_CAP);
    ls.set(K.notifs, S.notifs);
  };

  /* === USER PROFILE === */
  async function fetchUserProfile() {
    const d = await apiFetch('/user/basic');
    const p = d.profile;
    S.userId   = p.id;
    S.userName = p.name;
    ls.set(K.profile, { id: p.id, name: p.name });
    log('Profile loaded:', S.userName, S.userId);
  }

  /* === THREAD LIST REFRESH === */
  async function refreshThreadList(toastFn) {
    toastFn?.('⏳ Fetching threads… first run may be slow');

    const own = await fetchAllPages('/user/forumthreads?limit=100', 'forumThreads');
    await sleep(API_DELAY_MS);
    const sub = await fetchAllPages('/user/forumsubscribedthreads', 'forumSubscribedThreads');

    const activeIds = new Set([...own.map(t => t.id), ...sub.map(t => t.id)]);
    for (const id of Object.keys(S.threads)) {
      if (!activeIds.has(Number(id))) delete S.threads[id];
    }

    for (const t of own) {
      const ex = S.threads[t.id];
      S.threads[t.id] = {
        id: t.id, title: t.title, forum_id: t.forum_id, source: 'own',
        lastTotal: ex ? ex.lastTotal : t.posts,
      };
    }

    // 'own' entries take precedence if a thread appears in both sources
    for (const t of sub) {
      const ex = S.threads[t.id];
      if (!ex || ex.source === 'sub') {
        S.threads[t.id] = {
          id: t.id, title: t.title, forum_id: t.forum_id, source: 'sub',
          lastTotal: ex ? ex.lastTotal : t.posts.total,
        };
      }
    }

    ls.set(K.inited, true);
    ls.set(K.threadsTs, Date.now());
    saveThreads();
    log('Threads loaded:', Object.keys(S.threads).length);
  }

  /* === POLL === */
  async function poll() {
    if (S.polling || !S.apiKey) return;
    S.polling = true;
    ls.set(K.lastPoll, Date.now());
    try {
      const own = await fetchAllPages('/user/forumthreads?limit=100', 'forumThreads');
      await sleep(API_DELAY_MS);
      const sub = await fetchAllPages('/user/forumsubscribedthreads', 'forumSubscribedThreads');

      const cur = {};
      for (const t of sub) cur[t.id] = t.posts.total;
      for (const t of own) cur[t.id] = t.posts;

      const toFetch = [];
      for (const [idStr, th] of Object.entries(S.threads)) {
        const id = Number(idStr);
        if (S.disabled.has(id)) continue;
        const total = cur[id];
        if (total !== undefined && total > th.lastTotal) {
          toFetch.push({ th, offset: th.lastTotal, newCount: total - th.lastTotal, newTotal: total });
        }
      }

      let newUnread = 0;
      for (const item of toFetch) {
        try {
          await sleep(POST_DELAY_MS);
          const fetchOffset = Math.max(item.offset, item.newTotal - 20);
          const d = await apiFetch(`/forum/${item.th.id}/posts?offset=${fetchOffset}`);
          const posts = d.posts ?? [];
          // Skip self-posts
          const otherPosts = S.userId
            ? posts.filter(p => p.author.id !== S.userId)
            : posts;
          const latest = otherPosts.length ? otherPosts[otherPosts.length - 1] : null;
          if (latest) {
            const existing = S.notifs.find(n => n.threadId === item.th.id);
            if (existing) {
              existing.author    = latest.author.username;
              existing.ts        = latest.created_time * 1000;
              existing.newCount += item.newCount;
              // linkStart preserved so link points to where unread began
            } else {
              S.notifs.push({
                threadId:    item.th.id,
                forumId:     item.th.forum_id,
                threadTitle: item.th.title,
                author:      latest.author.username,
                linkStart:   item.offset,
                newCount:    item.newCount,
                ts:          latest.created_time * 1000,
              });
            }
            newUnread += item.newCount;
          }
          S.threads[item.th.id].lastTotal = item.newTotal;
        } catch (e) {
          log('Post fetch err, thread', item.th.id, e);
        }
      }

      const fetchedIds = new Set(toFetch.map(f => f.th.id));
      for (const [idStr] of Object.entries(S.threads)) {
        const id = Number(idStr);
        if (cur[id] !== undefined && !fetchedIds.has(id)) {
          S.threads[id].lastTotal = cur[id];
        }
      }

      saveThreads();

      if (newUnread > 0) {
        S.unread += newUnread;
        saveUnread();
        saveNotifs();
        updateBadge();
        rebuildDropIfOpen();
      }
    } catch (e) {
      log('Poll error:', e);
    } finally {
      S.polling = false;
    }
  }

  function startPolling() {
    if (!S.apiKey || S.pollTimer) return;

    const elapsed = Date.now() - (ls.get(K.lastPoll) ?? 0);
    const waitMs  = Math.max(0, POLL_MS - elapsed);

    const kickoff = () => {
      poll();
      S.pollTimer = setInterval(poll, POLL_MS);
    };

    if (waitMs < 5000) {
      kickoff();
    } else {
      log(`Next poll in ${Math.round(waitMs / 1000)}s`);
      setTimeout(kickoff, waitMs);
    }
  }

  /* === TOAST === */
  function showToast(msg, ms = 4000) {
    document.getElementById(`${ID}-toast`)?.remove();
    const t = document.createElement('div');
    t.id = `${ID}-toast`;
    t.textContent = msg;
    Object.assign(t.style, {
      position: 'fixed', bottom: '80px', left: '50%',
      transform: 'translateX(-50%)',
      background: '#1a1c24', color: '#ccc',
      border: '1px solid #2a2a2a', borderRadius: '4px',
      padding: '8px 14px', fontSize: '12px',
      zIndex: '100000', whiteSpace: 'nowrap',
      boxShadow: '0 2px 12px rgba(0,0,0,0.6)',
      pointerEvents: 'none',
    });
    document.body.appendChild(t);
    setTimeout(() => t.remove(), ms);
  }

  /* === CHATHEAD === */

  (() => {
    const s = document.createElement('style');
    s.textContent = `@keyframes ${ID}-pulse {
      0%,100% { box-shadow: 0 0 0 0 rgba(0,201,167,0.5); }
      50%      { box-shadow: 0 0 0 10px rgba(0,201,167,0); }
    }`;
    document.head.appendChild(s);
  })();

  const snapX = side =>
    side === 'left' ? -EDGE_PEEK : window.innerWidth - CH_W + EDGE_PEEK;

  function updateBadgeSide(side) {
    const badge = document.getElementById(`${ID}-badge`);
    if (!badge) return;
    if (side === 'right') {
      badge.style.left = '-5px'; badge.style.right = '';
    } else {
      badge.style.right = '-5px'; badge.style.left = '';
    }
  }

  function buildChathead() {
    if (document.getElementById(`${ID}-ch`)) return;

    const ch = document.createElement('div');
    ch.id = `${ID}-ch`;

    const savedY    = ls.get(K.chY)    ?? 220;
    const savedSide = ls.get(K.chSide) ?? 'right';

    ch.innerHTML = `
      <svg viewBox="0 0 24 24" fill="none" width="17" height="17" style="pointer-events:none">
        <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
              stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
      </svg>
      <span id="${ID}-badge"></span>
    `;

    Object.assign(ch.style, {
      position:    'fixed',
      top:         `${savedY}px`,
      left:        `${snapX(savedSide)}px`,
      width:       `${CH_W}px`,
      height:      `${CH_W}px`,
      borderRadius:'50%',
      background:  '#0f1115',
      border:      '2px solid #00c9a7',
      display:     'flex', alignItems: 'center', justifyContent: 'center',
      color:       '#00c9a7',
      cursor:      'pointer',
      zIndex:      '99999',
      opacity:     '0.55',
      transition:  'opacity 0.2s, box-shadow 0.2s',
      userSelect:  'none',
      touchAction: 'none',
      boxShadow:   '0 2px 10px rgba(0,0,0,0.55)',
    });

    const badge = ch.querySelector(`#${ID}-badge`);
    Object.assign(badge.style, {
      position: 'absolute', top: '-5px',
      background: '#e05565', color: '#fff',
      borderRadius: '10px', fontSize: '10px', fontWeight: '700',
      minWidth: '16px', height: '16px', lineHeight: '16px',
      textAlign: 'center', padding: '0 3px',
      border: '1.5px solid #0f1115',
      display: 'none', pointerEvents: 'none',
    });

    ch.addEventListener('mouseenter', () => {
      if (dragState === 'idle') {
        ch.style.opacity = '1';
        ch.style.boxShadow = '0 2px 14px rgba(0,201,167,0.3)';
      }
    });
    ch.addEventListener('mouseleave', () => {
      if (dragState === 'idle' && !S.dropOpen) {
        ch.style.opacity = '0.55';
        ch.style.boxShadow = '0 2px 10px rgba(0,0,0,0.55)';
      }
    });

    /* === TAP / HOLD-TO-DRAG === */
    let dragState = 'idle';  // 'idle' | 'ready' | 'dragging'
    let isTap = false, holdTimer = null;
    let startCX = 0, startCY = 0;
    let dragOffsetX = 0, dragOffsetY = 0;
    let preDragSide = savedSide;

    const clamp  = (v, lo, hi) => Math.min(Math.max(v, lo), hi);
    const getSide = () => ls.get(K.chSide) ?? 'right';

    function enterDragReady() {
      dragState = 'ready';
      preDragSide = getSide();
      const fullLeft = preDragSide === 'right' ? window.innerWidth - CH_W - 6 : 6;
      ch.style.transition = 'left 0.2s ease';
      ch.style.left = `${fullLeft}px`;
      ch.style.opacity = '1';
      ch.style.animation = `${ID}-pulse 0.9s ease infinite`;
      setTimeout(() => { ch.style.transition = 'opacity 0.2s, box-shadow 0.2s'; }, 220);
    }

    function snapToEdge(releaseX) {
      const side = releaseX < window.innerWidth / 2 ? 'left' : 'right';
      ch.style.animation = '';
      ch.style.transition = 'left 0.28s cubic-bezier(0.25, 0.46, 0.45, 0.94)';
      ch.style.left = `${snapX(side)}px`;
      ls.set(K.chSide, side);
      ls.set(K.chY, parseInt(ch.style.top));
      updateBadgeSide(side);
      setTimeout(() => {
        ch.style.transition = 'opacity 0.2s, box-shadow 0.2s';
        if (!S.dropOpen) ch.style.opacity = '0.55';
      }, 300);
    }

    const onMove = e => {
      const cx = e.touches ? e.touches[0].clientX : e.clientX;
      const cy = e.touches ? e.touches[0].clientY : e.clientY;

      if (dragState === 'idle') {
        if (Math.hypot(cx - startCX, cy - startCY) > 12) {
          isTap = false;
          clearTimeout(holdTimer);
          holdTimer = null;
        }
        return;
      }

      if (dragState === 'ready') {
        if (Math.hypot(cx - startCX, cy - startCY) > 5) {
          dragState = 'dragging';
          ch.style.animation = '';
          ch.style.transition = 'none';
          // Track offset so chathead doesn't jump to cursor position
          dragOffsetX = cx - parseFloat(ch.style.left);
          dragOffsetY = cy - parseFloat(ch.style.top);
        }
        return;
      }

      if (dragState === 'dragging') {
        ch.style.left = `${clamp(cx - dragOffsetX, 0, window.innerWidth - CH_W)}px`;
        ch.style.top  = `${clamp(cy - dragOffsetY, 10, window.innerHeight - CH_W - 8)}px`;
      }
    };

    const onUp = e => {
      clearTimeout(holdTimer);
      holdTimer = null;
      document.removeEventListener('mousemove', onMove);
      document.removeEventListener('mouseup', onUp);
      document.removeEventListener('touchmove', onMove);
      document.removeEventListener('touchend', onUp);

      if (dragState === 'idle') {
        if (isTap) {
          toggleDropdown();
        } else {
          ch.style.opacity = S.dropOpen ? '1' : '0.55';
        }
      } else if (dragState === 'ready') {
        snapToEdge(preDragSide === 'right' ? window.innerWidth : 0);
      } else if (dragState === 'dragging') {
        const cx = e.changedTouches ? e.changedTouches[0].clientX : e.clientX;
        snapToEdge(cx);
      }

      dragState = 'idle';
      isTap = false;
    };

    ch.addEventListener('mousedown', e => {
      if (dragState !== 'idle') return;
      isTap = true;
      startCX = e.clientX; startCY = e.clientY;
      ch.style.opacity = '0.85';
      holdTimer = setTimeout(enterDragReady, HOLD_MS);
      document.addEventListener('mousemove', onMove);
      document.addEventListener('mouseup', onUp);
    });

    ch.addEventListener('touchstart', e => {
      e.preventDefault();
      if (dragState !== 'idle') return;
      isTap = true;
      startCX = e.touches[0].clientX; startCY = e.touches[0].clientY;
      ch.style.opacity = '0.85';
      holdTimer = setTimeout(enterDragReady, HOLD_MS);
      document.addEventListener('touchmove', onMove, { passive: true });
      document.addEventListener('touchend', onUp);
    });

    S.chathead = ch;
    document.body.appendChild(ch);
    updateBadgeSide(savedSide);
    updateBadge();
  }

  function updateBadge() {
    const badge = document.getElementById(`${ID}-badge`);
    if (!badge) return;
    badge.style.display = S.unread > 0 ? 'block' : 'none';
    badge.textContent = S.unread > 99 ? '99+' : String(S.unread);
  }

  /* === DROPDOWN === */
  function buildDropdown() {
    const ch   = S.chathead;
    const side = ls.get(K.chSide) ?? 'right';
    const rect = ch.getBoundingClientRect();
    const W    = 284;
    const maxH = Math.min(420, window.innerHeight - 24);

    let left = side === 'right' ? rect.left - W - 6 : rect.right + 6;
    left = Math.max(4, Math.min(left, window.innerWidth - W - 4));
    const top = Math.max(8, Math.min(rect.top - 8, window.innerHeight - maxH - 8));

    const dd = document.createElement('div');
    dd.id = `${ID}-dd`;
    Object.assign(dd.style, {
      position: 'fixed', top: `${top}px`, left: `${left}px`,
      width: `${W}px`, maxHeight: `${maxH}px`,
      background: 'var(--default-bg-panel-color, #141519)',
      border: '1px solid var(--torn-border-color, #252525)',
      borderRadius: '4px', zIndex: '99998',
      display: 'flex', flexDirection: 'column',
      boxShadow: '0 4px 20px rgba(0,0,0,0.65)',
      fontFamily: 'inherit', fontSize: '13px',
      color: 'var(--default-color, #ccc)',
      overflow: 'hidden',
    });

    const hdr = document.createElement('div');
    Object.assign(hdr.style, {
      display: 'flex', justifyContent: 'space-between', alignItems: 'center',
      padding: '8px 10px',
      borderBottom: '1px solid var(--torn-border-color, #252525)',
      flexShrink: '0',
    });
    hdr.innerHTML = `
      <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1.2px;color:#00c9a7">Forum Notifications</span>
      <div style="display:flex;gap:5px">
        <button id="${ID}-markread" style="${btn()}">Mark Read</button>
        <button id="${ID}-opensettings" style="${btn()}">⚙</button>
      </div>
    `;

    const list = document.createElement('div');
    Object.assign(list.style, {
      overflowY: 'auto', flex: '1',
      maxHeight: '340px',
    });

    const sorted = [...S.notifs].sort((a, b) => b.ts - a.ts);
    if (!sorted.length) {
      list.innerHTML = `<div style="padding:22px 12px;color:#555;text-align:center;font-size:12px">No notifications yet</div>`;
    } else {
      for (const n of sorted) {
        const a = document.createElement('a');
        a.href = `https://www.torn.com/forums.php#/p=threads&f=${n.forumId}&t=${n.threadId}&b=0&a=0&start=${n.linkStart}`;
        a.target = S.newTab ? '_blank' : '_self';
        Object.assign(a.style, {
          display: 'block', padding: '8px 10px',
          borderBottom: '1px solid rgba(255,255,255,0.04)',
          textDecoration: 'none', color: 'inherit',
          transition: 'background 0.1s',
        });
        a.addEventListener('mouseenter', () => a.style.background = 'rgba(0,201,167,0.06)');
        a.addEventListener('mouseleave', () => a.style.background = '');
        a.addEventListener('click', () => {
          const idx = S.notifs.findIndex(x => x.threadId === n.threadId);
          if (idx !== -1) S.notifs.splice(idx, 1);
          S.unread = Math.max(0, S.unread - n.newCount);
          saveNotifs();
          saveUnread();
          updateBadge();
          if (S.newTab) closeDropdown();
        });

        const time   = new Date(n.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
        const extra  = n.newCount > 1 ? ` <span style="color:#666">(+${n.newCount})</span>` : '';
        a.innerHTML  = `
          <div style="font-size:12px;color:#ccc;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"
               title="${esc(n.threadTitle)}">${esc(n.threadTitle)}</div>
          <div style="font-size:11px;margin-top:2px;display:flex;justify-content:space-between;align-items:center">
            <span style="color:#8fa898"><span style="color:#00c9a7">${esc(n.author)}</span> replied${extra}</span>
            <span style="color:#3a3a3a;font-size:10px">${time}</span>
          </div>
        `;
        list.appendChild(a);
      }
    }

    dd.appendChild(hdr);
    dd.appendChild(list);

    // Footer — poll status
    const ftr = document.createElement('div');
    ftr.id = `${ID}-dd-footer`;
    Object.assign(ftr.style, {
      padding: '6px 10px',
      borderTop: '1px solid var(--torn-border-color, #252525)',
      flexShrink: '0',
      display: 'flex', justifyContent: 'space-between', alignItems: 'center',
      gap: '6px',
    });
    dd.appendChild(ftr);

    function renderFooter() {
      const lastPoll = ls.get(K.lastPoll);
      const callBucket = Math.floor(Date.now() / 60000);
      if (callBucket !== S.callMinuteBucket) { S.callMinuteBucket = callBucket; S.callsThisMin = 0; }
      const callsPerMin = S.callsThisMin;

      let updatedStr = '—';
      let nextStr    = '—';

      if (lastPoll) {
        const secAgo = Math.floor((Date.now() - lastPoll) / 1000);
        updatedStr = secAgo < 60
          ? `${secAgo}s ago`
          : `${Math.floor(secAgo / 60)}m ago`;

        const secsUntil = Math.max(0, Math.ceil((lastPoll + POLL_MS - Date.now()) / 1000));
        nextStr = secsUntil > 0
          ? (secsUntil >= 60 ? `${Math.ceil(secsUntil / 60)}m` : `${secsUntil}s`)
          : 'now';
      }

      ftr.innerHTML = `
        <span style="font-size:10px;color:#666;white-space:nowrap">
          Updated <span style="color:#8fa898">${updatedStr}</span>
        </span>
        <span style="font-size:10px;color:#666;white-space:nowrap">
          Next <span style="color:#8fa898">${nextStr}</span>
        </span>
        <span style="font-size:10px;color:#666;white-space:nowrap">
          <span style="color:#8fa898">${callsPerMin}</span> calls/min
        </span>
      `;
    }

    renderFooter();
    S.footerTicker = setInterval(() => {
      if (document.getElementById(`${ID}-dd-footer`)) renderFooter();
      else { clearInterval(S.footerTicker); S.footerTicker = null; }
    }, 1000);

    document.body.appendChild(dd);

    dd.querySelector(`#${ID}-markread`).addEventListener('click', () => {
      S.unread = 0; saveUnread(); updateBadge(); closeDropdown();
    });
    dd.querySelector(`#${ID}-opensettings`).addEventListener('click', () => {
      closeDropdown(); openSettings();
    });
  }

  function toggleDropdown() {
    if (S.dropOpen) { closeDropdown(); return; }
    S.dropOpen = true;
    S.chathead.style.opacity = '1';
    buildDropdown();
    setTimeout(() => document.addEventListener('click', outsideClick), 50);
  }

  function closeDropdown() {
    S.dropOpen = false;
    document.getElementById(`${ID}-dd`)?.remove();
    document.removeEventListener('click', outsideClick);
    if (S.footerTicker) { clearInterval(S.footerTicker); S.footerTicker = null; }
    if (S.chathead) {
      S.chathead.style.opacity = '0.55';
      S.chathead.style.boxShadow = '0 2px 10px rgba(0,0,0,0.55)';
    }
  }

  function outsideClick(e) {
    const dd = document.getElementById(`${ID}-dd`);
    if (dd && !dd.contains(e.target) && !S.chathead.contains(e.target)) closeDropdown();
  }

  function rebuildDropIfOpen() {
    if (!S.dropOpen) return;
    document.getElementById(`${ID}-dd`)?.remove();
    buildDropdown();
  }

  /* === SETTINGS === */
  function openSettings() {
    if (S.settingsOpen) { closeSettings(); return; }
    S.settingsOpen = true;

    const mobile = window.innerWidth <= 600;
    const panel  = document.createElement('div');
    panel.id = `${ID}-settings`;
    Object.assign(panel.style, {
      position: 'fixed', zIndex: '99999',
      width: '300px', maxHeight: '540px',
      background: 'var(--default-bg-panel-color, #141519)',
      border: '1px solid var(--torn-border-color, #252525)',
      borderRadius: '4px',
      boxShadow: '0 6px 24px rgba(0,0,0,0.75)',
      fontFamily: 'inherit', fontSize: '13px',
      color: 'var(--default-color, #ccc)',
      display: 'flex', flexDirection: 'column', overflow: 'hidden',
      ...(mobile
        ? { top: '50%', left: '50%', transform: 'translate(-50%,-50%)' }
        : { top: '80px', right: '56px' }),
    });

    panel.innerHTML = `
      <div style="display:flex;justify-content:space-between;align-items:center;padding:10px 12px;border-bottom:1px solid var(--torn-border-color,#252525);flex-shrink:0">
        <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1.2px;color:#00c9a7">Torn Forum Notifier</span>
        <button id="${ID}-s-close" style="${btn()}">✕</button>
      </div>

      <div style="padding:10px 12px;border-bottom:1px solid var(--torn-border-color,#252525);flex-shrink:0">
        <div style="font-size:10px;color:#8fa898;text-transform:uppercase;letter-spacing:2px;margin-bottom:6px">API Key</div>
        <div style="display:flex;gap:6px">
          <input id="${ID}-s-key" type="password" placeholder="Paste API key here"
            value="${esc(S.apiKey ?? '')}"
            style="flex:1;min-width:0;background:#0d1017;border:1px solid #2a2a2a;color:#ccc;padding:5px 8px;border-radius:3px;font-size:12px;outline:none"/>
          <button id="${ID}-s-keysave" style="${btn('#00c9a7','#0d1017')}">Save</button>
        </div>
      </div>

      <div style="padding:8px 12px;border-bottom:1px solid var(--torn-border-color,#252525);flex-shrink:0;display:flex;align-items:center;justify-content:space-between">
        <span style="font-size:12px;color:#ccc">Open links in new tab</span>
        <input id="${ID}-s-newtab" type="checkbox" ${S.newTab ? 'checked' : ''}
          style="accent-color:#00c9a7;cursor:pointer;width:14px;height:14px;flex-shrink:0"/>
      </div>

      <div style="padding:8px 12px;border-bottom:1px solid var(--torn-border-color,#252525);flex-shrink:0">
        <div style="font-size:10px;color:#8fa898;text-transform:uppercase;letter-spacing:2px;margin-bottom:6px">Notify On Threads</div>
        <input id="${ID}-s-search" type="text" placeholder="Search threads…"
          style="width:100%;box-sizing:border-box;background:#0d1017;border:1px solid #2a2a2a;color:#ccc;padding:5px 8px;border-radius:3px;font-size:12px;outline:none;margin-bottom:6px"/>
        <div style="display:flex;gap:5px">
          <button id="${ID}-s-selall"   style="${btn()}">Select All</button>
          <button id="${ID}-s-deselall" style="${btn('#e05565')}">Deselect All</button>
        </div>
      </div>

      <div id="${ID}-s-list" style="overflow-y:auto;flex:1;min-height:60px;max-height:220px"></div>

      <div style="padding:8px 12px;border-top:1px solid var(--torn-border-color,#252525);flex-shrink:0;display:flex;gap:5px;justify-content:flex-end">
        <button id="${ID}-s-clear"   style="${btn('#e05565')}">Clear History</button>
        <button id="${ID}-s-refresh" style="${btn()}">Refresh Threads</button>
      </div>
    `;

    document.body.appendChild(panel);
    renderThreadList('');

    const search = () => panel.querySelector(`#${ID}-s-search`).value;

    panel.querySelector(`#${ID}-s-close`).addEventListener('click', closeSettings);
    panel.querySelector(`#${ID}-s-keysave`).addEventListener('click', doSaveKey);
    panel.querySelector(`#${ID}-s-search`).addEventListener('input', e => renderThreadList(e.target.value));
    panel.querySelector(`#${ID}-s-newtab`).addEventListener('change', e => {
      S.newTab = e.target.checked;
      ls.set(K.newTab, S.newTab);
    });

    panel.querySelector(`#${ID}-s-selall`).addEventListener('click', () => {
      S.disabled.clear(); saveDisabled(); renderThreadList(search());
    });
    panel.querySelector(`#${ID}-s-deselall`).addEventListener('click', () => {
      Object.keys(S.threads).forEach(id => S.disabled.add(Number(id)));
      saveDisabled(); renderThreadList(search());
    });

    panel.querySelector(`#${ID}-s-clear`).addEventListener('click', () => {
      S.notifs = []; S.unread = 0;
      saveNotifs(); saveUnread(); updateBadge();
      showToast('Notification history cleared');
    });

    panel.querySelector(`#${ID}-s-refresh`).addEventListener('click', async () => {
      const b = panel.querySelector(`#${ID}-s-refresh`);
      b.textContent = 'Fetching…'; b.disabled = true;
      try {
        await refreshThreadList(showToast);
        renderThreadList(search());
        showToast('✓ Thread list updated');
      } catch (e) {
        log('Refresh error:', e);
        showToast('⚠ Refresh failed — check API key');
      }
      b.textContent = 'Refresh Threads'; b.disabled = false;
    });
  }

  function closeSettings() {
    document.getElementById(`${ID}-settings`)?.remove();
    S.settingsOpen = false;
  }

  function renderThreadList(filter) {
    const container = document.getElementById(`${ID}-s-list`);
    if (!container) return;
    container.innerHTML = '';

    const all = Object.values(S.threads);
    const shown = filter
      ? all.filter(t => t.title.toLowerCase().includes(filter.toLowerCase()))
      : all;

    if (!shown.length) {
      container.innerHTML = `<div style="padding:16px 12px;color:#555;font-size:12px;text-align:center">
        ${filter ? 'No matching threads' : 'No threads found.<br>Enter your API key and tap Refresh Threads.'}
      </div>`;
      return;
    }

    for (const t of shown) {
      const row = document.createElement('label');
      Object.assign(row.style, {
        display: 'flex', alignItems: 'center', gap: '8px',
        padding: '6px 12px', cursor: 'pointer',
        borderBottom: '1px solid rgba(255,255,255,0.03)',
        transition: 'background 0.1s',
      });
      row.addEventListener('mouseenter', () => row.style.background = 'rgba(255,255,255,0.03)');
      row.addEventListener('mouseleave', () => row.style.background = '');

      const cb = document.createElement('input');
      cb.type = 'checkbox';
      cb.checked = !S.disabled.has(t.id);
      cb.style.cssText = 'accent-color:#00c9a7;flex-shrink:0;cursor:pointer';
      cb.addEventListener('change', () => {
        cb.checked ? S.disabled.delete(t.id) : S.disabled.add(t.id);
        saveDisabled();
      });

      const lbl = document.createElement('span');
      lbl.title = t.title;
      lbl.style.cssText = 'font-size:12px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#ccc';
      lbl.textContent = t.title;

      const tag = document.createElement('span');
      tag.style.cssText = `font-size:10px;padding:1px 5px;border-radius:2px;flex-shrink:0;font-weight:700;letter-spacing:0.5px;
        ${t.source === 'own'
          ? 'background:rgba(0,201,167,0.12);color:#00c9a7'
          : 'background:rgba(143,168,152,0.1);color:#8fa898'}`;
      tag.textContent = t.source === 'own' ? 'OWN' : 'SUB';

      row.append(cb, lbl, tag);
      container.appendChild(row);
    }
  }

  function doSaveKey() {
    const input = document.getElementById(`${ID}-s-key`);
    const key   = input?.value.trim();
    if (!key) return;

    S.apiKey = key;
    ls.set(K.key, key);
    S.userId = null; S.userName = null;
    fetchUserProfile().catch(e => log('Profile fetch error on key save:', e));

    const b = document.getElementById(`${ID}-s-keysave`);
    if (b) { b.textContent = '✓ Saved'; setTimeout(() => b.textContent = 'Save', 1600); }

    if (!Object.keys(S.threads).length) {
      refreshThreadList(showToast)
        .then(() => { renderThreadList(''); startPolling(); })
        .catch(e => { log('Key save init error:', e); showToast('⚠ Failed to load threads'); });
    } else {
      startPolling();
    }
  }

  /* === HELPERS === */
  function btn(bg = '#1c1e26', color = '#ccc') {
    const border = (bg === '#1c1e26') ? '#333' : bg;
    return `background:${bg};color:${color};border:1px solid ${border};padding:3px 8px;border-radius:3px;font-size:11px;text-transform:uppercase;letter-spacing:0.4px;cursor:pointer;white-space:nowrap;flex-shrink:0`;
  }

  /* === INIT === */
  function init() {
    if (document.getElementById(`${ID}-ch`)) return;
    loadState();
    buildChathead();

    if (!S.apiKey) {
      openSettings();
      return;
    }

    if (!S.userId) {
      fetchUserProfile().catch(e => log('Profile fetch error:', e));
    }

    const ts    = ls.get(K.threadsTs);
    const stale = !ts || (Date.now() - ts > THREAD_TTL_MS);

    if (stale || !Object.keys(S.threads).length) {
      refreshThreadList(showToast)
        .then(startPolling)
        .catch(e => log('Init error:', e));
    } else {
      startPolling();
    }
  }

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