MilkyWay Idle — Persistent/Longer Chat History

Capture and persist chat messages with minimal lag.

// ==UserScript==
// @name         MilkyWay Idle — Persistent/Longer Chat History
// @namespace    mwi_chat_longer_history
// @version      2.1.1
// @description  Capture and persist chat messages with minimal lag.
// @author       Silky-Panda
// @license      MIT
// @match        https://www.milkywayidle.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_listValues
// @grant        GM_deleteValue
// @grant        GM_registerMenuCommand
// @grant        GM_addStyle
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';
  const CHAT_CONTAINER_SELECTOR = '[class^="ChatHistory_chatHistory"]';

  const MAX_VISIBLE_PER_TAB = 200;

  const MAX_SAVED_PER_TAB = 5000;

  const SAVE_DEBOUNCE_MS = 800;

  const NS = 'mwi.chat.persist.v1';

  const USER_CHAR_ALIAS_KEY = `${NS}::userCharacterAlias`;

  const observers = new Map();
  const cache = new Map();
  const initializedContainers = new WeakSet();
  const restoringFor = new WeakSet();

  const log = (...args) => console.log('[MWI-Chat]', ...args);
  const warn = (...args) => console.warn('[MWI-Chat]', ...args);

  function normalizeTabForStorage(tabId) {
    if (tabId === 'tab:#UwU') {
      return 'tab:General';
    }
    return tabId;
  }



  function debounceSave(storageKey) {
    const entry = cache.get(storageKey);
    if (!entry) return;

    entry.dirty = true;
    if (entry.saveTimer) return;

    entry.saveTimer = setTimeout(() => {
      entry.saveTimer = null;
      if (!entry.dirty) return;
      entry.dirty = false;

      if (entry.messages.length > MAX_SAVED_PER_TAB) {
        entry.messages = entry.messages.slice(-MAX_SAVED_PER_TAB);
      }
      try {
        GM_setValue(storageKey, JSON.stringify(entry.messages));
      } catch (e) {
        warn('Failed to save chat log for', storageKey, e);
      }
    }, SAVE_DEBOUNCE_MS);
  }

  function getStorageKey(characterId, tabId) {
    const account = 'acc';
    return `${NS}::${account}::${characterId}::${tabId}`;
  }

  function ensureCache(storageKey) {
    if (cache.has(storageKey)) return cache.get(storageKey);
    let messages = [];
    try {
      const raw = GM_getValue(storageKey, '[]');
      messages = JSON.parse(raw);
      if (!Array.isArray(messages)) messages = [];
    } catch (e) {
      warn('Failed to parse stored messages for', storageKey, e);
      messages = [];
    }
    const entry = { messages, dirty: false, saveTimer: null };
    cache.set(storageKey, entry);
    return entry;
  }

  function textOf(el) {
    return el.outerHTML || el.textContent || '';
  }

  function getUserCharacterAlias() {
    try {
      const alias = GM_getValue(USER_CHAR_ALIAS_KEY, '');
      return (alias && typeof alias === 'string') ? alias.trim() : '';
    } catch {
      return '';
    }
  }

  function setUserCharacterAlias(alias) {
    try {
      GM_setValue(USER_CHAR_ALIAS_KEY, (alias || '').trim());
    } catch (e) {
      warn('Failed to set character alias', e);
    }
  }

  function detectCharacterId() {
    const alias = getUserCharacterAlias();
    if (alias) return `char:${alias}`;

    const candidates = [];
    const queryList = [
      '[class*="Character"][class*="Name"]',
      '[class*="character"][class*="name"]',
      '[data-testid*="character"][data-testid*="name"]',
      '[aria-label*="Character"]',
      '[class*="Profile"] [class*="name"]',
      '[class*="Header"] [class*="name"]',
    ];
    for (const q of queryList) {
      document.querySelectorAll(q).forEach(el => candidates.push(el));
    }
    if (candidates.length === 0) {
      const top = document.querySelector('header, [class*="TopBar"], [class*="Navbar"]');
      if (top) {
        top.querySelectorAll('*').forEach(el => {
          const txt = (el.textContent || '').trim();
          if (txt && txt.length >= 3 && txt.length <= 24) candidates.push(el);
        });
      }
    }
    for (const el of candidates) {
      const name = (el.textContent || '').trim();
      if (name && /^[\w\s\-'.]+$/.test(name) && name.length <= 24) {
        return `char:${name}`;
      }
    }

    return 'char:unknown';
  }

  function detectTabId(chatContainer) {
    const tabPanel = chatContainer.closest('.TabPanel_tabPanel__tXMJF');
    const panelsContainer = tabPanel && tabPanel.parentElement;
    if (tabPanel && panelsContainer) {
      const panels = Array.from(panelsContainer.querySelectorAll(':scope > .TabPanel_tabPanel__tXMJF'));
      const panelIndex = panels.indexOf(tabPanel);

      if (panelIndex >= 0) {
        const tabsRoot = panelsContainer.closest('.TabsComponent_tabsComponent__3PqGp, .Chat_tabsComponentContainer__3ZoKe');
        if (tabsRoot) {
          const tablist = tabsRoot.querySelector('[role="tablist"]');
          if (tablist) {
            const tabs = Array.from(tablist.querySelectorAll('button[role="tab"]'));
            const tab = tabs[panelIndex] || null;
            if (tab) {
              const badge = tab.querySelector('span.MuiBadge-root');
              if (badge && badge.childNodes && badge.childNodes.length) {
                for (let i = 0; i < badge.childNodes.length; i++) {
                  const n = badge.childNodes[i];
                  if (n.nodeType === Node.TEXT_NODE) {
                    const name = (n.textContent || '').trim();
                    if (name) return `tab:${name}`;
                  }
                }
              }
              const raw = (tab.textContent || '').trim();
              const label = raw.split(/\s*(?=\d)/)[0].trim() || raw;
              if (label) return `tab:${label}`;
            }
          }
        }
      }
    }
    try {
      const selected = document.querySelector('[role="tab"][aria-selected="true"]');
      if (selected) {
        const badge = selected.querySelector('span.MuiBadge-root');
        if (badge && badge.childNodes) {
          for (let i = 0; i < badge.childNodes.length; i++) {
            const n = badge.childNodes[i];
            if (n.nodeType === Node.TEXT_NODE) {
              const name = (n.textContent || '').trim();
              if (name) return `tab:${name}`;
            }
          }
        }
        const raw = (selected.textContent || '').trim();
        const label = raw.split(/\s*(?=\d)/).trim() || raw;
        if (label) return `tab:${label}`;
      }
    } catch (e) {
    }

    const all = Array.from(document.querySelectorAll(CHAT_CONTAINER_SELECTOR));
    const idx = Math.max(0, all.indexOf(chatContainer));
    return `tab:index${idx}`;
  }



  function trimVisible(container) {
    while (container.children.length > MAX_VISIBLE_PER_TAB) {
      container.removeChild(container.firstElementChild);
    }
  }

  function handleNewNodes(container, storageKey, nodes) {
    const entry = ensureCache(storageKey);
    const seen = new Set(entry.messages);
    let added = 0;
    for (const node of nodes) {
      if (node.nodeType !== 1) continue;
      const html = textOf(node);
      if (html && !seen.has(html)) {
        entry.messages.push(html);
        seen.add(html);
        added++;
      }
    }
    if (added) {
      debounceSave(storageKey);
      trimVisible(container);
    }
  }


  function attachObserver(chatContainer, characterId, tabId) {
    const effectiveTabId = normalizeTabForStorage(tabId);
    const storageKey = getStorageKey(characterId, effectiveTabId);
    if (observers.has(chatContainer)) return;

    const observer = new MutationObserver(mutations => {
      if (restoringFor.has(chatContainer)) return;
      const added = [];
      for (const m of mutations) {
        m.addedNodes && added.push(...m.addedNodes);
      }
      if (added.length) handleNewNodes(chatContainer, storageKey, added);
    });

    observer.observe(chatContainer, { childList: true });
    observers.set(chatContainer, observer);

    const entry = ensureCache(storageKey);
    if (entry.messages.length) {
      restoringFor.add(chatContainer);
      try {
        const existingSet = new Set();
        chatContainer.querySelectorAll(':scope > *').forEach(node => {
          existingSet.add(node.outerHTML);
        });

        const frag = document.createDocumentFragment();
        const toShow = entry.messages.slice(-MAX_VISIBLE_PER_TAB);
        let restoredCount = 0;

        for (const html of toShow) {
          if (!existingSet.has(html)) {
            const wrapper = document.createElement('div');
            wrapper.innerHTML = html;
            while (wrapper.firstChild) frag.appendChild(wrapper.firstChild);
            restoredCount++;
          }
        }

        if (restoredCount > 0) {
          chatContainer.insertBefore(frag, chatContainer.firstChild);
          log(`Restored ${restoredCount} messages without duplicates.`);
        }
      } catch (e) {
        warn('Failed restoring chat DOM for', storageKey, e);
      } finally {
        restoringFor.delete(chatContainer);
      }

      trimVisible(chatContainer);
    }

  }

  function initContainer(chatContainer) {
    if (!chatContainer || initializedContainers.has(chatContainer)) return;
    initializedContainers.add(chatContainer);

    const characterId = detectCharacterId();
    const tabId = detectTabId(chatContainer);
    const effectiveTabId = normalizeTabForStorage(tabId);
    const storageKey = getStorageKey(characterId, effectiveTabId);
    const entry = ensureCache(storageKey);

    log('Init chat container', { characterId, effectiveTabId, storageKey, entry });
    const seen = new Set(entry.messages);
    let added = 0;
    chatContainer.querySelectorAll(':scope > *').forEach(node => {
      const html = textOf(node);
      if (html && !seen.has(html)) {
        entry.messages.push(html);
        seen.add(html);
        added++;
      }
    });

    if (added) {
      log(`Merged ${added} existing DOM messages for ${effectiveTabId}`);
      debounceSave(storageKey);
    }
    attachObserver(chatContainer, characterId, tabId);
  }

  function scanAndInit() {
    document.querySelectorAll(CHAT_CONTAINER_SELECTOR).forEach(initContainer);
  }

  function installRootObserver() {
    const rootObs = new MutationObserver(() => scanAndInit());
    rootObs.observe(document.body, { childList: true, subtree: true });
    scanAndInit();
  }
  function exportAll() {
    const data = {};
    for (const key of GM_listValues()) {
      if (!key.startsWith(NS)) continue;
      try {
        data[key] = JSON.parse(GM_getValue(key, '[]'));
      } catch {
        data[key] = GM_getValue(key, null);
      }
    }
    const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = 'mwi-chat-export.json';
    a.click();
    URL.revokeObjectURL(url);
  }

  function clearScope(predicate, label) {
    let count = 0;
    for (const key of GM_listValues()) {
      if (predicate(key)) {
        GM_deleteValue(key);
        count++;
      }
    }
    alert(`Cleared ${count} stored item(s) for ${label}.`);
  }

  function currentKeysFor(characterId) {
    const keys = [];
    for (const key of GM_listValues()) {
      if (key.startsWith(`${NS}::`) && key.includes(`::${characterId}::`)) {
        keys.push(key);
      }
    }
    return keys;
  }

  function registerMenus() {
    GM_registerMenuCommand('Set Character Alias (override auto-detect)', () => {
      const cur = getUserCharacterAlias();
      const next = prompt('Enter a short alias/name for this character (used to namespace chat logs):', cur || '');
      if (next != null) {
        setUserCharacterAlias(next);
        alert('Alias saved. Reload the page so this character ID is used going forward.');
      }
    });

    GM_registerMenuCommand('Export All Chat Logs (JSON)', exportAll);

    GM_registerMenuCommand('Clear ALL Stored Chat Logs', () => {
      if (confirm('Delete ALL stored chat logs for this script?')) {
        clearScope(k => k.startsWith(NS), 'ALL chats');
      }
    });

    GM_registerMenuCommand('Clear CURRENT Character Logs', () => {
      const charId = detectCharacterId();
      if (confirm(`Delete stored chat logs for current character?\n\n${charId}`)) {
        const keys = currentKeysFor(charId);
        keys.forEach(GM_deleteValue);
        alert(`Cleared ${keys.length} tab(s) for ${charId}. Reload recommended.`);
      }
    });
  }
  function start() {
    registerMenus();
    installRootObserver();
    log('Persistent chat ready.');
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', start);
  } else {
    start();
  }
})();