Torn Forum Notifier

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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