您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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.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); } }); })();