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