Enhanced NanI

A userscript that improves existing features of NanI and adds new ones.

// ==UserScript==
// @name            Enhanced NanI
// @name:ja         なんI+
// @description     A userscript that improves existing features of NanI and adds new ones.
// @description:ja  なんIの機能を改善したり新たに機能を追加したりするユーザースクリプトです。
// @version         2.0.0
// @namespace       65c9f364-2ddd-44f5-bbc4-716f44f91335
// @author          MaxTachibana
// @license         MIT
// @match           https://openlive2ch.pages.dev/*
// @grant           GM.setValue
// @grant           GM.getValue
// @grant           GM.deleteValue
// @grant           unsafeWindow
// @run-at          document-start
// ==/UserScript==

(async () => {
  "use strict";

  // src/h.ts
  var h = (tag, attrs, ...children) => {
    const el = document.createElement(tag);
    if (attrs) {
      const { style, class: classes, ...extra } = attrs;
      Object.assign(el.style, style);
      if (classes) {
        for (const cls of classes) {
          el.classList.add(cls);
        }
      }
      for (const prop in extra) {
        const val = extra[prop];
        if (prop.startsWith("on")) {
          el.addEventListener(prop.slice(2), val);
          continue;
        }
        el[prop] = val;
      }
    }
    el.append(...children);
    return el;
  };
  function q(query, parent) {
    if (query[0] === "#") {
      return document.getElementById(query.slice(1));
    }
    return parent?.getElementsByClassName(query.slice(1));
  }
  var compileStyle = (style) => {
    let css2 = "";
    for (const k in style) {
      const prop = k.replaceAll(
        /(.)([A-Z])/g,
        (_m, a, b) => `${a}-${b.toLowerCase()}`,
      );
      css2 += `${prop}: ${style[k]}; `;
    }
    return css2;
  };
  var compileCss = (rules) => {
    let css2 = "";
    for (const selector in rules) {
      const style = rules[selector];
      const rule = compileStyle(style);
      css2 += `${selector} { ${rule} }`;
    }
    return css2;
  };
  var css = (rules, styleProps) => {
    const css2 = compileCss(rules);
    const s = h("style", styleProps, css2);
    document.head.append(s);
    return s;
  };

  // src/event.ts
  var EventEmitter = class {
    #listenersMap = /* @__PURE__ */ new Map();
    #initListeners(event) {
      const listeners = /* @__PURE__ */ new Map();
      this.#listenersMap.set(event, listeners);
      return listeners;
    }
    on(event, listener, signal) {
      const listeners =
        this.#listenersMap.get(event) ?? this.#initListeners(event);
      if (listeners.has(listener)) return;
      let data;
      if (signal) {
        const abortHandler = () => {
          this.off(event, listener);
        };
        signal.addEventListener("abort", abortHandler, {
          once: true,
        });
        data = {
          signal,
          abortHandler,
        };
      }
      listeners.set(listener, data);
    }
    off(event, listener) {
      const listeners = this.#listenersMap.get(event);
      if (!listeners) return;
      const data = listeners.get(listener);
      if (data) {
        data.signal.removeEventListener("abort", data.abortHandler);
      }
      listeners.delete(listener);
      if (listeners.size <= 0) {
        this.#listenersMap.delete(event);
      }
    }
    emit(event, ...args) {
      const listeners = this.#listenersMap.get(event);
      if (!listeners) return;
      for (const listener of listeners.keys()) {
        try {
          listener(...args);
        } catch {}
      }
    }
  };

  // src/nani.ts
  var getThreadInfo = (threadEl) => {
    let params;
    if (threadEl) {
      const a = threadEl.getElementsByTagName("a")[0];
      params = new URL(a.href).searchParams;
    } else {
      params = new URLSearchParams(location.search);
    }
    const thread = params.get("thread");
    const board = params.get("board");
    if (!thread || !board) {
      return;
    }
    return {
      thread,
      board,
    };
  };
  var BOARD_PARAM = /board=(\w+)/;
  var getCurrentBoard = () => location.search.match(BOARD_PARAM)?.[1];
  var NaniEvents = {
    ThreadsCacheUpdate: "threadsCacheUpdate",
    TitlesCacheUpdate: "titlesCacheUpdate",
    PostsCacheUpdate: "postsCacheUpdate",
  };
  var Nani = class {
    cache = {
      threads: void 0,
      posts: /* @__PURE__ */ new Map(),
      titles: /* @__PURE__ */ new Map(),
    };
    events = new EventEmitter();
    constructor() {
      this.#prepare();
    }
    #prepare() {
      const origFetch = unsafeWindow.fetch;
      const fetch = async (...args) => {
        const res = await origFetch(...args);
        const cloned = res.clone();
        (async () => {
          const url = new URL(res.url);
          if (
            !url.hostname.endsWith(".supabase.co") ||
            args[1]?.method !== "GET"
          )
            return;
          if (url.pathname === "/rest/v1/threads") {
            const id = url.searchParams.get("id")?.replace(/^eq\./, "");
            const json = await cloned.json();
            if (id) {
              this.cache.titles.set(id, json.title);
              this.events.emit(NaniEvents.TitlesCacheUpdate, id, json.title);
            } else {
              this.cache.threads = json;
              this.events.emit(NaniEvents.ThreadsCacheUpdate);
            }
            return;
          }
          if (
            url.pathname === "/rest/v1/posts" &&
            url.searchParams.get("select") === "id,content,created_at"
          ) {
            const id = url.searchParams.get("thread_id")?.replace(/^eq\./, "");
            if (!id) return;
            const json = await cloned.json();
            this.cache.posts.set(id, json);
            this.events.emit(NaniEvents.PostsCacheUpdate, id, json);
            return;
          }
        })();
        return res;
      };
      Object.defineProperty(unsafeWindow, "fetch", {
        ...Object.getOwnPropertyDescriptor(unsafeWindow, "fetch"),
        value: fetch,
      });
    }
  };

  // src/features/all-image.ts
  var IMAGE_URL =
    /https:\/\/[a-z]+\.supabase\.co\/storage\/v1\/object\/public\/images\/\S+\.\S+|https:\/\/i\.imgur\.com\/\w+\.\w+/g;
  var allImage = () => ({
    documentEnd: (ctx2) => {
      const button = h("button", void 0, "⛰️画像一覧");
      const button2 = button.cloneNode(true);
      const handleClick = () => {
        const gallery = h("div", {
          style: {
            display: "grid",
            gap: "0.25rem",
            gridTemplateColumns: "1fr 1fr 1fr 1fr 1fr",
          },
        });
        const images = [];
        const info = getThreadInfo();
        if (info) {
          const posts = ctx2.threadCache.posts.get(info.thread);
          if (!posts) return;
          for (const post of posts) {
            const urls = post.content.match(IMAGE_URL);
            if (!urls) continue;
            for (const url of urls) {
              images.push({
                board: info.board,
                thread: info.thread,
                timestamp: new Date(post.created_at).getTime(),
                url,
              });
            }
          }
        } else if (ctx2.threadCache.threads) {
          for (const thread of ctx2.threadCache.threads) {
            for (const post of thread.posts) {
              const urls = post.content.match(IMAGE_URL);
              if (!urls) continue;
              for (const url of urls) {
                images.push({
                  board: thread.board,
                  thread: thread.id.toString(),
                  timestamp: new Date(post.created_at).getTime(),
                  url,
                });
              }
            }
          }
        }
        const labelPrefix = info ? `スレッド内の画像` : "画像";
        const label = h("strong", void 0, `${labelPrefix}: ${images.length}件`);
        for (const [i, image] of images
          .sort((a, b) => b.timestamp - a.timestamp)
          .entries()) {
          const children = [
            h("span", void 0, `${images.length - i}.`),
            h(
              "a",
              {
                href: image.url,
                target: "_blank",
                rel: "noopener noreferrer",
              },
              h("img", {
                src: image.url,
                style: {
                  display: "block",
                  width: "100%",
                  height: "auto",
                },
              }),
            ),
          ];
          if (!info) {
            children.push(
              h(
                "a",
                { href: `?board=${image.board}&thread=${image.thread}` },
                "スレに移動",
              ),
            );
          }
          gallery.append(
            h(
              "div",
              {
                style: {
                  display: "flex",
                  gap: "0.1rem",
                  flexDirection: "column",
                  alignItems: "flex-start",
                },
              },
              ...children,
            ),
          );
        }
        ctx2.dialog.content.append(label);
        ctx2.dialog.content.append(gallery);
        ctx2.dialog.open(() => {
          label.remove();
          gallery.remove();
        });
      };
      button.addEventListener("click", handleClick);
      button2.addEventListener("click", handleClick);
      ctx2.elems.appendNav.append(button);
      ctx2.elems.appendNavThread.append(button2);
    },
  });

  // src/features/defined-elements.ts
  var definedElements = () => ({
    documentEnd: (ctx2) => {
      const settingsPanel = q("#unhide-panel");
      const threadBox = q("#thread-box");
      const posts = q("#posts");
      if (!settingsPanel || !threadBox || !posts) {
        throw new TypeError("element not found");
      }
      const extraSettings = h("div", {
        style: {
          display: "flex",
          alignItems: "center",
          gap: "1rem",
          flexDirection: "column",
          padding: "0.5rem",
        },
      });
      settingsPanel.append(h("hr"));
      settingsPanel.append(extraSettings);
      const appendNav = h("div", {
        style: {
          display: "flex",
          gap: "0.5rem",
          alignItems: "center",
          marginBottom: "8px",
        },
      });
      const appendNavThread = h("div", {
        style: {
          display: "flex",
          gap: "0.5rem",
          alignItems: "center",
          marginBottom: "8px",
        },
      });
      threadBox.parentNode?.insertBefore(appendNav, threadBox);
      posts.parentNode?.insertBefore(appendNavThread, posts);
      ctx2.elems = {
        settingsPanel,
        threadBox,
        posts,
        appendNav,
        appendNavThread,
        extraSettings,
      };
    },
  });

  // src/features/history.ts
  var mergeToOriginal = async () => {
    const originalHistoryItem = localStorage.getItem("threadHistoryMap");
    if (!originalHistoryItem) return;
    const originalHistory = JSON.parse(originalHistoryItem);
    const savedHistory = JSON.parse(await GM.getValue("history", "{}"));
    for (const info in savedHistory) {
      const [board, thread] = info.split("_");
      const orig = originalHistory[thread];
      const saved = savedHistory[info];
      if (!orig) {
        originalHistory[thread] = {
          lastViewed: saved.timestamp,
          title: saved.title,
          url: `?board=${board}&thread=${thread}`,
        };
        continue;
      }
      if (saved.timestamp > orig.lastViewed) {
        orig.lastViewed = saved.timestamp;
      }
    }
    localStorage.setItem("threadHistoryMap", JSON.stringify(originalHistory));
    await GM.deleteValue("history");
  };
  var history = () => ({
    documentStart: async () => {
      await mergeToOriginal();
    },
  });

  // src/features/ngword.ts
  var savedNgwords = await GM.getValue("ngwords", "");
  var splitNgwords = (ngwords) =>
    ngwords
      .split("\n")
      .map((l) => l.trim())
      .filter((l) => !!l);
  var ngword = () => ({
    documentEnd: (ctx2) => {
      css({
        ".ngwordHidden": {
          display: "none",
        },
      });
      const input = h("textarea", {
        value: savedNgwords,
        placeholder: "【画像】\n過疎",
        onchange: async () => {
          await GM.setValue("ngwords", input.value);
        },
        oninput: () => {
          const ngwords = splitNgwords(input.value);
          for (const threadEl of ctx2.elems.threadBox.children) {
            updateDisplay(threadEl, ngwords);
          }
        },
      });
      const label = h(
        "label",
        {
          style: {
            display: "inline-flex",
            flexDirection: "column",
            gap: "0.5rem",
            alignItems: "flex-start",
          },
        },
        h("strong", void 0, "NGワード (改行区切り): "),
        input,
      );
      ctx2.elems.extraSettings.append(label);
      const updateDisplay = (threadElem, ngwords) => {
        const info = getThreadInfo(threadElem);
        if (!info) return;
        const thread = ctx2.threadCache.threads?.find(
          (th) => th.board === info.board && th.id.toString() === info.thread,
        );
        if (!thread) return;
        if (ngwords.some((ngword2) => thread.title.includes(ngword2))) {
          threadElem.classList.add("ngwordHidden");
          return;
        }
        const firstPost = thread.posts[0];
        if (!firstPost) return;
        if (ngwords.some((ngword2) => firstPost.content.includes(ngword2))) {
          threadElem.classList.add("ngwordHidden");
          return;
        }
        threadElem.classList.remove("ngwordHidden");
      };
      const threadObs = new MutationObserver((recs) => {
        const ngwords = splitNgwords(input.value);
        if (ngwords.length <= 0) return;
        for (const rec of recs) {
          for (const threadElem of rec.addedNodes) {
            if (!(threadElem instanceof HTMLElement)) continue;
            updateDisplay(threadElem, ngwords);
          }
        }
      });
      threadObs.observe(ctx2.elems.threadBox, {
        childList: true,
      });
      const postObs = new MutationObserver((recs) => {
        const ngwords = splitNgwords(input.value);
        if (ngwords.length <= 0) return;
        for (const rec of recs) {
          for (const postEl of rec.addedNodes) {
            if (!(postEl instanceof HTMLElement)) continue;
            const nameEl = postEl.querySelector('[class^="name-"]');
            if (!nameEl) continue;
            if (!(nameEl instanceof HTMLElement)) continue;
            postEl.classList.remove("ngwordHidden");
            if (ngwords.some((ngword2) => nameEl.innerText.includes(ngword2))) {
              postEl.classList.add("ngwordHidden");
              continue;
            }
            const bodyEl = postEl.getElementsByClassName("post-body")[0];
            if (!bodyEl) continue;
            if (!(bodyEl instanceof HTMLElement)) continue;
            if (ngwords.some((ngword2) => bodyEl.innerText.includes(ngword2))) {
              postEl.classList.add("ngwordHidden");
            }
          }
        }
      });
      postObs.observe(ctx2.elems.posts, {
        childList: true,
      });
    },
  });

  // src/features/period.ts
  var PERIOD_1H = 1e3 * 60 * 60;
  var PERIOD_1D = 1e3 * 60 * 60 * 24;
  var savedPeriod = await GM.getValue("period", "all");
  var period = () => ({
    documentEnd: (ctx2) => {
      css({
        ".periodHidden": {
          display: "none",
        },
      });
      const select = h(
        "select",
        {
          value: savedPeriod,
          onchange: async (ev) => {
            if (!(ev.target instanceof HTMLSelectElement)) return;
            const period2 = ev.target.value;
            await GM.setValue("period", period2);
            const now = /* @__PURE__ */ new Date();
            for (const threadEl of ctx2.elems.threadBox.children) {
              updateDisplay(threadEl, now);
            }
          },
        },
        h("option", { value: "all" }, "全て"),
        h("option", { value: PERIOD_1H.toString() }, "1時間"),
        h("option", { value: PERIOD_1D.toString() }, "1日"),
      );
      const label = h(
        "label",
        {
          style: {
            display: "inline-flex",
            alignItems: "center",
            gap: "0.5rem",
            marginBottom: "8px",
          },
        },
        h("span", void 0, "期間: "),
        select,
      );
      select.value = savedPeriod;
      ctx2.elems.threadBox.parentNode?.insertBefore(
        label,
        ctx2.elems.threadBox,
      );
      const updateDisplay = (threadElem, now) => {
        if (select.value === "all") {
          threadElem.classList.remove("periodHidden");
          return;
        }
        const info = getThreadInfo(threadElem);
        if (!info) return;
        const thread = ctx2.threadCache.threads?.find(
          (th) => th.board === info.board && th.id.toString() === info.thread,
        );
        if (!thread) return;
        const lastPost = thread.posts.at(-1);
        if (!lastPost) return;
        const lastPostDate = new Date(lastPost.created_at);
        const period2 = Number.parseInt(select.value, 10);
        const diff = now.getTime() - lastPostDate.getTime();
        if (diff >= period2) {
          threadElem.classList.add("periodHidden");
        } else {
          threadElem.classList.remove("periodHidden");
        }
      };
      const obs = new MutationObserver((records) => {
        const now = /* @__PURE__ */ new Date();
        for (const record of records) {
          for (const threadElem of record.addedNodes) {
            if (!(threadElem instanceof HTMLElement)) continue;
            updateDisplay(threadElem, now);
          }
        }
      });
      obs.observe(ctx2.elems.threadBox, {
        childList: true,
      });
    },
  });

  // src/features/thread-cache.ts
  var threadCache = () => ({
    documentStart: (ctx2) => {
      ctx2.threadCache = {
        threads: void 0,
        posts: /* @__PURE__ */ new Map(),
        titles: /* @__PURE__ */ new Map(),
      };
      const origFetch = unsafeWindow.fetch;
      const interceptFetch = async (...args) => {
        const res = await origFetch(...args);
        const url = new URL(res.url);
        try {
          if (
            url.hostname.endsWith(".supabase.co") &&
            args[1]?.method === "GET"
          ) {
            if (url.pathname === "/rest/v1/threads") {
              const id = url.searchParams.get("id")?.slice(3);
              if (id) {
                const json = await res.clone().json();
                ctx2.threadCache.titles.set(id, json.title);
              } else {
                ctx2.threadCache.threads = await res.clone().json();
              }
            } else if (
              url.pathname === "/rest/v1/posts" &&
              url.searchParams.get("select") === "id,content,created_at"
            ) {
              const threadId = url.searchParams
                .get("thread_id")
                ?.replace(/^eq\./, "");
              if (threadId) {
                ctx2.threadCache.posts.set(threadId, await res.clone().json());
              }
            }
          }
        } catch (err) {
          console.warn(err);
        }
        return res;
      };
      Object.defineProperty(unsafeWindow, "fetch", {
        ...Object.getOwnPropertyDescriptor(unsafeWindow, "fetch"),
        value: interceptFetch,
      });
    },
  });

  // src/features/audio-context.ts
  var audioContext = () => ({
    documentEnd: (ctx2) => {
      document.body.addEventListener(
        "click",
        () => {
          const audioCtx = new AudioContext();
          const src = audioCtx.createBufferSource();
          src.start();
          src.stop();
          ctx2.audioContext = audioCtx;
        },
        {
          once: true,
        },
      );
    },
  });

  // src/features/dialog.ts
  var dialog = () => ({
    documentEnd: (ctx2) => {
      const content = h("div", {
        style: {
          flex: "1",
          overflow: "auto",
        },
      });
      const dialog2 = h(
        "div",
        {
          style: {
            display: "none",
            gap: "0.5rem",
            flexDirection: "column",
            zIndex: "99999",
            border: "1px solid #000000",
            backgroundColor: "inherit",
            width: "80%",
            height: "80%",
            position: "fixed",
            top: "calc(50svh - 40%)",
            left: "calc(50svw - 40%)",
            boxSizing: "border-box",
            borderRadius: "5px",
            padding: "0.5rem",
          },
        },
        h(
          "button",
          {
            onclick: () => {
              ctx2.dialog.close();
            },
          },
          "閉じる",
        ),
        content,
      );
      let controller;
      const destructors = /* @__PURE__ */ new Set();
      ctx2.dialog = {
        isOpen: () => dialog2.style.display !== "none",
        open: (destructor) => {
          dialog2.style.display = "flex";
          requestAnimationFrame(() => {
            controller = new AbortController();
            document.body.addEventListener(
              "click",
              (ev) => {
                if (!ev.composedPath().includes(dialog2)) {
                  ctx2.dialog.close();
                }
              },
              {
                signal: controller.signal,
              },
            );
          });
          if (destructor) {
            destructors.add(destructor);
          }
        },
        close: () => {
          dialog2.style.display = "none";
          controller?.abort();
          for (const f of destructors) {
            f();
            destructors.delete(f);
          }
        },
        content,
      };
      document.body.append(dialog2);
    },
  });

  // src/features/display-poster.ts
  var displayPoster = () => ({
    documentStart: async () => {
      await GM.deleteValue("displayPoster");
    },
  });

  // src/features/fix-ui.ts
  var holder = () => {
    const labels = [];
    return {
      checkbox: (label_, inputProps) => {
        const input = h("input", {
          type: "checkbox",
          ...inputProps,
        });
        const label = h(
          "label",
          {
            style: {
              display: "flex",
              gap: "0.5rem",
              alignItems: "center",
            },
          },
          h("span", void 0, `${label_}: `),
          input,
        );
        labels.push(label);
        return input;
      },
      [Symbol.iterator]() {
        return labels.values();
      },
    };
  };
  var toggleStyle = (rules) => {
    const id = `a${Math.random().toString(36).slice(2)}`;
    let s;
    const obj = {
      enable: () => {
        if (s?.parentNode) return;
        if (s) {
          document.head.append(s);
          return;
        }
        s = css(rules, {
          id,
        });
      },
      disable: () => {
        s?.remove();
      },
      update: (enabled) => {
        if (enabled) {
          obj.enable();
        } else {
          obj.disable();
        }
      },
    };
    return obj;
  };
  var fixUi = () => ({
    documentEnd: async (ctx2) => {
      const checkboxes = holder();
      const savedBgcolor = await GM.getValue("bgcolor", "#ffffff");
      const bgcolorInput = h("input", {
        type: "color",
        value: savedBgcolor,
        oninput: async (ev) => {
          if (!ev.target) return;
          if (!(ev.target instanceof HTMLInputElement)) return;
          const bgcolor = ev.target.value;
          document.body.style.backgroundColor = bgcolor;
          await GM.setValue("bgcolor", bgcolor);
        },
      });
      const bgcolorInputLabel = h(
        "label",
        {
          style: {
            display: "inline-flex",
            gap: "0.5rem",
            alignItems: "center",
          },
        },
        h("span", void 0, "背景色: "),
        bgcolorInput,
      );
      document.body.style.backgroundColor = savedBgcolor;
      const fixedPanelHeightStyle = toggleStyle({
        ".nav-panel": {
          height: "50%",
          overflow: "auto",
        },
        ".panel-header": {
          top: "0",
          left: "0",
          position: "sticky",
          backgroundColor: "inherit",
        },
      });
      const fixedPanelHeight = checkboxes.checkbox(
        "ナビパネルのサイズを固定する",
        {
          checked: await GM.getValue("fixedPanelHeight", true),
          onchange: async () => {
            await GM.setValue("fixedPanelHeight", fixedPanelHeight.checked);
            fixedPanelHeightStyle.update(fixedPanelHeight.checked);
          },
        },
      );
      fixedPanelHeightStyle.update(fixedPanelHeight.checked);
      const navPanelSizingStyle = toggleStyle({
        ".nav-panel": {
          boxSizing: "border-box",
        },
      });
      const navPanelSizing = checkboxes.checkbox(
        "設定パネルのはみ出しを抑える",
        {
          checked: await GM.getValue("navPanelSizing", true),
          onchange: async () => {
            await GM.setValue("navPanelSizing", navPanelSizing.checked);
            navPanelSizingStyle.update(navPanelSizing.checked);
          },
        },
      );
      navPanelSizingStyle.update(navPanelSizing.checked);
      const paddingNavStyle = toggleStyle({
        "#bottom-nav": {
          padding: "0.5rem",
        },
        ".nav-panel": {
          bottom: "calc(60px + 1rem)",
        },
      });
      const paddingNav = checkboxes.checkbox("下部ナビに余白を付ける", {
        checked: await GM.getValue("paddingNav", true),
        onchange: async () => {
          await GM.setValue("paddingNav", paddingNav.checked);
          paddingNavStyle.update(paddingNav.checked);
        },
      });
      paddingNavStyle.update(paddingNav.checked);
      const hideOnlineCountStyle = toggleStyle({
        "#viewers-count": {
          display: "none",
        },
      });
      const hideOnlineCount = checkboxes.checkbox("閲覧者数を非表示にする", {
        checked: await GM.getValue("hideOnlineCount", false),
        onchange: async () => {
          await GM.setValue("hideOnlineCount", hideOnlineCount.checked);
          hideOnlineCountStyle.update(hideOnlineCount.checked);
        },
      });
      hideOnlineCountStyle.update(hideOnlineCount.checked);
      const uiFixSettings = h(
        "div",
        {
          style: {
            display: "flex",
            flexDirection: "column",
            gap: "0.5rem",
          },
        },
        h("strong", void 0, "UIの調整"),
        bgcolorInputLabel,
        ...checkboxes,
      );
      ctx2.elems.extraSettings.append(uiFixSettings);
    },
  });

  // src/iter-tools.ts
  function pipe(value, ...ops) {
    let ret = value;
    for (const op of ops) {
      ret = op(ret);
    }
    return ret;
  }
  var flatMap = (func) =>
    function* (source) {
      for (const value of source) {
        yield* func(value);
      }
    };
  function filter(predicate) {
    return function* (source) {
      for (const value of source) {
        if (predicate(value)) {
          yield value;
        }
      }
    };
  }
  var map = (func) =>
    function* (source) {
      for (const value of source) {
        yield func(value);
      }
    };
  var toArray = (source) => [...source];

  // src/features/headline.ts
  var dateCache = /* @__PURE__ */ new Map();
  var getDate = (time) => {
    let date = dateCache.get(time);
    if (!date) {
      date = new Date(time);
      dateCache.set(time, date);
    }
    return date;
  };
  var headline = () => ({
    documentEnd: async (ctx2) => {
      const savedHeadline = await GM.getValue("headline", true);
      let destructor;
      const enable = () => {
        const controller = new AbortController();
        const headline2 = h("div", {
          style: {
            fontSize: "0.9em",
            border: "1px solid #000000",
            marginBottom: "8px",
            height: "150px",
            overflow: "auto",
            padding: "0.5rem",
            flexDirection: "column",
            display: "flex",
            gap: "0.5rem",
          },
        });
        ctx2.elems.threadBox.parentNode?.insertBefore(
          headline2,
          ctx2.elems.threadBox,
        );
        ctx2.nani.events.on(
          NaniEvents.ThreadsCacheUpdate,
          () => {
            if (!ctx2.nani.cache.threads) return;
            const currentBoard = getCurrentBoard();
            if (!currentBoard) return;
            const posts = pipe(
              ctx2.nani.cache.threads,
              filter((th) => th.board === currentBoard),
              flatMap((th) =>
                pipe(
                  th.posts,
                  map((p) => ({
                    thread: th,
                    post: p,
                  })),
                ),
              ),
              toArray,
            );
            const latestPosts = posts
              .sort(
                (a, b) =>
                  getDate(b.post.created_at).getTime() -
                  getDate(a.post.created_at).getTime(),
              )
              .slice(0, 10);
            headline2.replaceChildren();
            for (const { thread, post } of latestPosts) {
              const res = h(
                "div",
                {
                  style: {
                    display: "flex",
                    flexDirection: "column",
                    gap: "0.3rem",
                  },
                },
                h(
                  "a",
                  {
                    href: `?board=${thread.board}&thread=${thread.id}`,
                  },
                  `${thread.title} (${thread.posts.length})`,
                ),
                h(
                  "span",
                  {
                    style: {
                      textOverflow: "ellipsis",
                      overflow: "hidden",
                      whiteSpace: "nowrap",
                    },
                  },
                  post.content,
                ),
              );
              headline2.append(res);
            }
          },
          controller.signal,
        );
        destructor = () => {
          headline2.remove();
          controller.abort();
        };
      };
      const disable = () => {
        destructor?.();
        destructor = void 0;
      };
      const update = () => {
        if (headlineCheckbox.checked) {
          enable();
        } else {
          disable();
        }
      };
      const headlineCheckbox = h("input", {
        type: "checkbox",
        checked: savedHeadline,
        onchange: async () => {
          await GM.setValue("headline", headlineCheckbox.checked);
          update();
        },
      });
      const headlineCheckboxLabel = h(
        "label",
        {
          style: {
            display: "inline-flex",
            gap: "0.5rem",
            alignItems: "center",
          },
        },
        h("span", void 0, "ヘッドラインを有効化: "),
        headlineCheckbox,
      );
      const headlineSettings = h(
        "div",
        {
          style: {
            display: "flex",
            flexDirection: "column",
            gap: "0.5rem",
          },
        },
        h("strong", void 0, "ヘッドライン"),
        headlineCheckboxLabel,
      );
      ctx2.elems.extraSettings.append(headlineSettings);
      update();
    },
  });

  // src/features/nani.ts
  var nani = () => ({
    documentStart: (ctx2) => {
      ctx2.nani = new Nani();
    },
  });

  // src/audio.ts
  var playCoin = (ctx2) => {
    const osc = ctx2.createOscillator();
    const gain = ctx2.createGain();
    osc.type = "square";
    osc.frequency.setValueAtTime(987.766, ctx2.currentTime);
    osc.frequency.setValueAtTime(1318.51, ctx2.currentTime + 0.09);
    gain.gain.value = 0.05;
    gain.gain.linearRampToValueAtTime(0, ctx2.currentTime + 0.7);
    osc.connect(gain).connect(ctx2.destination);
    osc.start();
    osc.stop(ctx2.currentTime + 0.5);
  };
  var playPico = (ctx2) => {
    const osc = ctx2.createOscillator();
    const gain = ctx2.createGain();
    osc.type = "square";
    osc.frequency.setValueAtTime(1200, ctx2.currentTime);
    gain.gain.setValueAtTime(0.2, ctx2.currentTime);
    gain.gain.exponentialRampToValueAtTime(1e-3, ctx2.currentTime + 0.15);
    osc.connect(gain).connect(ctx2.destination);
    osc.start();
    osc.stop(ctx2.currentTime + 0.2);
  };

  // src/features/notification.ts
  var SOUND = {
    coin: playCoin,
    pico: playPico,
  };
  var notification = () => ({
    documentEnd: async (ctx2) => {
      const play = () => {
        if (!ctx2.audioContext) return;
        SOUND[soundSelect.value](ctx2.audioContext);
      };
      const update = () => {
        if (postNotificationEnabled.checked) {
          obs.observe(ctx2.elems.posts, {
            childList: true,
          });
        } else {
          obs.disconnect();
        }
      };
      const postNotificationEnabled = h("input", {
        type: "checkbox",
        checked: await GM.getValue("postNotificationEnabled", false),
        onchange: async () => {
          await GM.setValue(
            "postNotificationEnabled",
            postNotificationEnabled.checked,
          );
          update();
        },
      });
      const postNotificationEnabledLabel = h(
        "label",
        {
          style: {
            display: "inline-flex",
            alignItems: "center",
            gap: "0.5rem",
          },
        },
        h("span", void 0, "新着レスを通知: "),
        postNotificationEnabled,
      );
      const soundSelect = h(
        "select",
        {
          onchange: async () => {
            await GM.setValue("notificationSound", soundSelect.value);
          },
        },
        h("option", { value: "coin" }, "コイン"),
        h("option", { value: "pico" }, "ピコ"),
      );
      const soundSelectLabel = h(
        "div",
        {
          style: {
            display: "inline-flex",
            gap: "0.5rem",
            alignItems: "center",
          },
        },
        h(
          "label",
          {
            style: {
              display: "inline-flex",
              alignItems: "center",
              gap: "0.5rem",
            },
          },
          h("span", void 0, "通知音: "),
          soundSelect,
        ),
        h(
          "button",
          {
            class: ["smallbtn"],
            onclick: () => {
              play();
            },
          },
          "再生",
        ),
      );
      soundSelect.value = await GM.getValue("notificationSound", "coin");
      const notificationSettings = h(
        "div",
        {
          style: {
            display: "flex",
            flexDirection: "column",
            gap: "0.5rem",
          },
        },
        h("strong", void 0, "通知"),
        postNotificationEnabledLabel,
        soundSelectLabel,
      );
      ctx2.elems.extraSettings.append(notificationSettings);
      let prevPostsId;
      let prevPosts;
      const obs = new MutationObserver(() => {
        if (!postNotificationEnabled.checked) return;
        const info = getThreadInfo();
        if (!info) return;
        const posts = ctx2.threadCache.posts.get(info.thread);
        if (!posts) return;
        if (prevPosts && prevPostsId !== info.thread.toString()) {
          prevPostsId = void 0;
          prevPosts = void 0;
        }
        if (!prevPosts) {
          prevPostsId = info.thread.toString();
          prevPosts = posts;
          return;
        }
        if (posts.length <= prevPosts.length) return;
        prevPosts = posts;
        play();
      });
      update();
    },
  });

  // src/index.ts
  var features = [
    nani(),
    audioContext(),
    definedElements(),
    threadCache(),
    dialog(),
    displayPoster(),
    period(),
    allImage(),
    ngword(),
    history(),
    fixUi(),
    notification(),
    headline(),
  ];
  var ctx = {};
  for (const f of features) {
    await f.documentStart?.(ctx);
  }
  document.addEventListener("DOMContentLoaded", async () => {
    for (const f of features) {
      await f.documentEnd?.(ctx);
    }
  });
})();