Enhanced NanI

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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.1.1
// @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;
  };
  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 inThread = () => location.search.includes("thread=");
  var NaniEvents = {
    ThreadsCacheUpdate: "threadsCacheUpdate",
    TitlesCacheUpdate: "titlesCacheUpdate",
    PostsCacheUpdate: "postsCacheUpdate",
    UrlChanged: "urlChanged",
  };
  var NaniCache = class {
    threads;
    posts = /* @__PURE__ */ new Map();
    titles = /* @__PURE__ */ new Map();
    #threadCache = /* @__PURE__ */ new Map();
    getThread(id) {
      const cached = this.#threadCache.get(id);
      if (cached) {
        return cached;
      }
      if (!this.threads) return;
      for (const thread of this.threads) {
        this.#threadCache.set(thread.id.toString(), thread);
      }
      return this.#threadCache.get(id);
    }
  };
  var Nani = class {
    cache = new NaniCache();
    events = new EventEmitter();
    constructor() {
      this.#prepare();
    }
    #prepareFetch() {
      const this_ = this;
      const orig = unsafeWindow.fetch;
      const fetch = async function (...args) {
        const res = await orig.call(this, ...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;
      };
      unsafeWindow.fetch = fetch;
    }
    #prepareHistoryReplaceState() {
      const this_ = this;
      const orig = history.replaceState;
      const replaceState = function (...args) {
        try {
          const url = args[2];
          if (url) {
            const oldUrl = new URL(location.href);
            const newUrl = new URL(url, location.href);
            this_.events.emit(NaniEvents.UrlChanged, oldUrl, newUrl);
          }
        } catch {}
        return orig.call(this, ...args);
      };
      unsafeWindow.history.replaceState = replaceState;
    }
    #prepare() {
      this.#prepareFetch();
      this.#prepareHistoryReplaceState();
    }
  };

  // src/plugins/feature/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 viewImagesButton = document.getElementById("icon-images");
      const imagePanelList = document.getElementById("image-panel-list");
      if (!viewImagesButton || !imagePanelList) return;
      viewImagesButton.addEventListener("click", () => {
        if (inThread()) return;
        if (!ctx2.nani.cache.threads) return;
        imagePanelList.replaceChildren();
        const images = [];
        const holder2 = h("div", {
          style: {
            display: "flex",
            gap: "0.5rem",
            flexDirection: "column",
          },
        });
        const gallery = h("div", {
          style: {
            display: "grid",
            gap: "0.25rem",
            gridTemplateColumns: "1fr 1fr 1fr 1fr 1fr",
          },
        });
        for (const thread of ctx2.nani.cache.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 label = h("strong", void 0, `画像: ${images.length}件`);
        for (const [i, image2] of images
          .sort((a, b) => b.timestamp - a.timestamp)
          .entries()) {
          const children = [
            h("span", void 0, `${images.length - i}.`),
            h(
              "a",
              {
                href: image2.url,
                target: "_blank",
                rel: "noopener noreferrer",
              },
              h(
                "div",
                {
                  style: {
                    width: "90px",
                    height: "90px",
                  },
                },
                h("img", {
                  src: image2.url,
                  loading: "lazy",
                  decoding: "async",
                  style: {
                    display: "block",
                    width: "auto",
                    height: "auto",
                    maxWidth: "90px",
                    maxHeight: "90px",
                    contentVisibility: "auto",
                  },
                }),
              ),
            ),
          ];
          children.push(
            h(
              "a",
              { href: `?board=${image2.board}&thread=${image2.thread}` },
              "スレに移動",
            ),
          );
          gallery.append(
            h(
              "div",
              {
                style: {
                  display: "flex",
                  gap: "0.1rem",
                  flexDirection: "column",
                  alignItems: "flex-start",
                },
              },
              ...children,
            ),
          );
        }
        holder2.append(label, gallery);
        imagePanelList.append(holder2);
      });
    },
  });

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

  // 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];
  var forEach = (func) => (source) => {
    for (const value of source) {
      func(value);
    }
  };
  var enumerate = function* (source) {
    let i = 0;
    for (const value of source) {
      yield [i++, value];
    }
  };

  // src/plugins/feature/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 fixMosaic = (ctx2) => {
    const toggleMosaicButton = document.getElementById("toggle-mosaic-btn");
    if (!toggleMosaicButton) {
      console.warn("toggleMosaicButton not found");
      return;
    }
    const toggled = /* @__PURE__ */ new Set();
    const postsObs = new MutationObserver((recs) => {
      pipe(
        recs,
        flatMap((rec) => rec.addedNodes),
        filter((node) => node instanceof Element),
        forEach((postEl) => {
          for (const [i, img] of pipe(
            postEl.getElementsByTagName("img"),
            enumerate,
          )) {
            const id = `${postEl.id}_${i}`;
            img.dataset.userscriptImageId = id;
            if (toggled.has(id)) {
              img.style.transition = "unset";
              img.classList.toggle("unblurred");
            }
          }
        }),
      );
    });
    postsObs.observe(ctx2.elems.posts, {
      childList: true,
    });
    ctx2.elems.posts.addEventListener("click", (ev) => {
      if (!(ev.target instanceof HTMLImageElement)) return;
      const id = ev.target.dataset.userscriptImageId;
      if (!id) return;
      if (toggled.has(id)) {
        toggled.delete(id);
      } else {
        toggled.add(id);
      }
    });
    toggleMosaicButton.addEventListener("click", () => {
      toggled.clear();
    });
    ctx2.nani.events.on(NaniEvents.UrlChanged, (old, new_) => {
      if (!old.searchParams.has("board")) return;
      if (old.search === new_.search) return;
      toggled.clear();
    });
  };
  var fixUi = () => ({
    documentEnd: async (ctx2) => {
      fixMosaic(ctx2);
      ctx2.nani.events.on(NaniEvents.PostsCacheUpdate, () => {
        if (ctx2.elems.listView.style.display !== "none") {
          ctx2.elems.listView.style.display = "none";
        }
      });
      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": {
          maxHeight: "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/plugins/feature/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: "1rem",
            flexDirection: "column",
            display: "flex",
            gap: "0.5rem",
            backgroundColor: "#eee",
          },
        });
        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,
                  })),
                ),
              ),
              filter((v) => {
                if (!ctx2.includeNgword) return true;
                if (
                  ctx2.includeNgword(v.post.content) ||
                  ctx2.includeNgword(v.thread.title)
                )
                  return false;
                return true;
              }),
              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/plugins/feature/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 history2 = () => ({
    documentStart: async () => {
      await mergeToOriginal();
    },
  });

  // src/plugins/feature/image.ts
  var image = () => ({
    documentEnd: (ctx2) => {
      const handleClick = (ev) => {
        const img = ev.target;
        if (!(img instanceof HTMLImageElement)) return;
        if (!img.classList.contains("unblurred")) return;
        ev.preventDefault();
        ev.stopPropagation();
        const backdrop = h(
          "div",
          {
            style: {
              display: "flex",
              position: "fixed",
              top: "0",
              left: "0",
              alignItems: "center",
              justifyContent: "center",
              backgroundColor: "rgba(0, 0, 0, 0.75)",
              zIndex: "99999",
              width: "100svw",
              height: "100svh",
            },
            onclick: () => {
              backdrop.remove();
            },
          },
          h("img", {
            src: img.src,
            onclick: (ev2) => {
              ev2.stopPropagation();
              const a = h("a", {
                href: img.src,
                rel: "noopener noreferrer",
                target: "_blank",
                style: {
                  maxWidth: "80%",
                  maxHeight: "80%",
                },
              });
              a.click();
            },
            style: {
              cursor: "pointer",
              display: "block",
              maxWidth: "80%",
              maxHeight: "80%",
              width: "auto",
              height: "auto",
              verticalAlign: "middle",
            },
          }),
        );
        document.body.append(backdrop);
      };
      const obs = new MutationObserver((recs) => {
        for (const rec of recs) {
          for (const postEl of rec.addedNodes) {
            if (!(postEl instanceof Element)) continue;
            for (const img of postEl.getElementsByTagName("img")) {
              img.style.cursor = "zoom-in";
              img.addEventListener("click", handleClick);
            }
          }
        }
      });
      obs.observe(ctx2.elems.posts, {
        childList: true,
      });
    },
  });

  // src/plugins/feature/ngword.ts
  var savedNgwords = await GM.getValue("ngwords", "");
  var compileNgwords = (ngwords) => {
    const list = ngwords
      .split("\n")
      .map((l) => l.trim())
      .filter((l) => !!l);
    if (list.length <= 0) return;
    const cache = /* @__PURE__ */ new Map();
    return (input) => {
      const cached = cache.get(input);
      if (cached !== void 0) {
        return cached;
      }
      const includes = list.some((word) => input.includes(word));
      cache.set(input, includes);
      return includes;
    };
  };
  var parsePost = (postEl) => {
    const nameEl = postEl.querySelector('[class^="name-"]');
    const bodyEl = postEl.getElementsByClassName("post-body")[0];
    if (!nameEl || !bodyEl || !(bodyEl instanceof HTMLElement)) return;
    const name = nameEl.textContent.replace(/:$/, "");
    const body = bodyEl.innerText;
    return {
      name,
      body,
    };
  };
  var ngword = () => ({
    documentEnd: (ctx2) => {
      const postsAppendChild = ctx2.elems.posts.appendChild;
      ctx2.elems.posts.appendChild = function (...args) {
        patch: try {
          if (!ctx2.includeNgword) break patch;
          const node = args[0];
          if (!(node instanceof HTMLElement)) break patch;
          if (!node.classList.contains("post")) break patch;
          const post = parsePost(node);
          if (!post) break patch;
          if (ctx2.includeNgword(post.body) || ctx2.includeNgword(post.name)) {
            node.style.display = "none";
          }
        } catch (err) {
          console.error(err);
        }
        return postsAppendChild.call(this, ...args);
      };
      const updateThreads = (threadEls) => {
        if (!ctx2.includeNgword) return;
        for (const threadEl of threadEls) {
          const info = getThreadInfo(threadEl);
          if (!info) continue;
          threadEl.dataset.userscriptThreadId = info.thread;
          const thread = ctx2.nani.cache.getThread(info.thread);
          const firstPost = thread?.posts[0];
          if (!firstPost) continue;
          if (
            !ctx2.includeNgword(firstPost.content) &&
            !ctx2.includeNgword(thread.title)
          )
            continue;
          threadEl.style.display = "none";
        }
      };
      const input = h("textarea", {
        value: savedNgwords,
        placeholder: "【画像】\n過疎",
        onchange: async () => {
          await GM.setValue("ngwords", input.value);
        },
        oninput: () => {
          ctx2.includeNgword = compileNgwords(input.value);
          if (inThread()) {
            updateThreads(
              pipe(
                ctx2.elems.threadBox.children,
                filter((el) => el instanceof HTMLElement),
              ),
            );
          }
        },
      });
      const label = h(
        "label",
        {
          style: {
            display: "inline-flex",
            flexDirection: "column",
            gap: "0.5rem",
            alignItems: "flex-start",
          },
        },
        h("strong", void 0, "NGワード (改行区切り): "),
        input,
      );
      ctx2.includeNgword = compileNgwords(input.value);
      ctx2.elems.extraSettings.append(label);
      const threadBoxObs = new MutationObserver((recs) => {
        updateThreads(
          pipe(
            recs,
            flatMap((rec) => rec.addedNodes),
            filter((node) => node instanceof HTMLElement),
          ),
        );
      });
      threadBoxObs.observe(ctx2.elems.threadBox, {
        childList: true,
      });
    },
  });

  // src/plugins/feature/notification.ts
  var SOUND = {
    // https://qiita.com/kurokky/items/f18341c17ad846332569
    coin: (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);
    },
    pico: (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);
    },
  };
  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.nani.cache.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/plugins/feature/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",
        },
        ".activePeriod": {
          backgroundColor: "#ddd",
        },
      });
      let currentValue = savedPeriod;
      const options = h(
        "div",
        {
          style: {
            display: "inline-flex",
            alignItems: "center",
            gap: "0.5rem",
            marginBottom: "8px",
          },
        },
        h("strong", void 0, "期間: "),
        ...[
          { value: "all", label: "全て" },
          { value: PERIOD_1H.toString(), label: "1時間" },
          { value: PERIOD_1D.toString(), label: "1日" },
        ].map((v) => {
          const button = h(
            "button",
            {
              class: ["smallbtn"],
              onclick: async () => {
                for (const active of document.getElementsByClassName(
                  "activePeriod",
                )) {
                  active.classList.remove("activePeriod");
                }
                button.classList.add("activePeriod");
                currentValue = v.value;
                await GM.setValue("period", currentValue);
                updateAll(ctx2.elems.threadBox.children);
              },
            },
            v.label,
          );
          if (v.value === savedPeriod) {
            button.classList.add("activePeriod");
          }
          return button;
        }),
      );
      ctx2.elems.sortOptions.parentNode?.insertBefore(
        options,
        ctx2.elems.sortOptions.nextSibling,
      );
      const updateAll = (threadEls) => {
        const now = /* @__PURE__ */ new Date();
        for (const threadEl of threadEls) {
          update(threadEl, now);
        }
      };
      const update = (threadEl, now) => {
        if (currentValue === "all") {
          threadEl.classList.remove("periodHidden");
          return;
        }
        const info = getThreadInfo(threadEl);
        if (!info) return;
        const thread = ctx2.nani.cache.getThread(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(currentValue, 10);
        const diff = now.getTime() - lastPostDate.getTime();
        if (diff >= period2) {
          threadEl.classList.add("periodHidden");
        } else {
          threadEl.classList.remove("periodHidden");
        }
      };
      const obs = new MutationObserver((records) => {
        updateAll(
          pipe(
            records,
            flatMap((r) => r.addedNodes),
            filter((n) => n instanceof Element),
          ),
        );
      });
      obs.observe(ctx2.elems.threadBox, {
        childList: true,
      });
      updateAll(ctx2.elems.threadBox.children);
    },
  });

  // src/plugins/middleware/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/plugins/middleware/defined-elements.ts
  var definedElements = () => ({
    documentEnd: (ctx2) => {
      const settingsPanel = document.getElementById("unhide-panel");
      const threadBox = document.getElementById("thread-box");
      const posts = document.getElementById("posts");
      const listView = document.getElementById("list-view");
      const sortOptions = document.getElementsByClassName("sort-options")[0];
      if (!sortOptions || !settingsPanel || !threadBox || !posts || !listView) {
        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 = {
        sortOptions,
        settingsPanel,
        threadBox,
        posts,
        appendNav,
        appendNavThread,
        extraSettings,
        listView,
      };
    },
  });

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

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