// ==UserScript== // @name Bangumi 社区助手 preview // @version 0.1.5 // @namespace b38.dev // @description 社区助手预览版 // @author 神戸小鳥 @vickscarlet // @license MIT // @icon https://bgm.tv/img/favicon.ico // @homepage https://github.com/bangumi/scripts/blob/master/vickscarlet/bangumi_community.user.js // @match *://bgm.tv/* // @match *://chii.in/* // @match *://bangumi.tv/* // @run-at document-start // @downloadURL none // ==/UserScript== (async () => { /**merge:js=_common.dom.utils.js**/ async function waitElement(parent, id, timeout = 1000) { return new Promise(resolve => { let isDone = false; const done = (fn) => { if (isDone) return; isDone = true; fn(); }; const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node.id == id) { done(() => { observer.disconnect(); resolve(node); }); return; } } } }); observer.observe(parent, { childList: true, subtree: true }); const node = parent.querySelector('#' + id); if (node) return done(() => { observer.disconnect(); resolve(node); }); setTimeout(() => done(() => { observer.disconnect(); resolve(parent.querySelector('#' + id)); }), timeout); }); } function observeChildren(element, callback) { new MutationObserver((mutations) => { for (const mutation of mutations) for (const node of mutation.addedNodes) if (node.nodeType === Node.ELEMENT_NODE) callback(node); }).observe(element, { childList: true }); for (const child of Array.from(element.children)) callback(child); } /**merge**/ /**merge:js=_common.dom.script.js**/ class LoadScript { static #loaded = new Set(); static #pedding = new Map(); static async load(src) { if (this.#loaded.has(src)) return; const list = this.#pedding.get(src) ?? []; const pedding = new Promise(resolve => list.push(resolve)); if (!this.#pedding.has(src)) { this.#pedding.set(src, list); const script = create('script', { src, type: 'text/javascript' }); script.onload = () => { this.#loaded.add(src); list.forEach(resolve => resolve()); }; document.body.appendChild(script); } return pedding } } /**merge**/ /**merge:js=_common.dom.style.js**/ function addStyle(...styles) { const style = document.createElement('style'); style.append(document.createTextNode(styles.join('\n'))); document.head.appendChild(style); return style; } /**merge**/ /**merge:js=_common.dom.js**/ function setEvents(element, events) { for (const [event, listener] of events) { element.addEventListener(event, listener); } return element; } function setProps(element, props) { if (!props || typeof props !== 'object') return element; const events = []; for (const [key, value] of Object.entries(props)) { if (typeof value === 'boolean') { element[key] = value; continue; } if (key === 'events') { if (Array.isArray(value)) { events.push(...value); } else { for (const event in value) { events.push([event, value[event]]); } } } else if (key === 'class') { addClass(element, value); } else if (key === 'style' && typeof value === 'object') { setStyle(element, value); } else if (key.startsWith('on')) { events.push([key.slice(2).toLowerCase(), value]); } else { element.setAttribute(key, value); } } setEvents(element, events); return element; } function addClass(element, value) { element.classList.add(...[value].flat()); return element; } function setStyle(element, styles) { for (let [k, v] of Object.entries(styles)) { if (v && typeof v === 'number' && !['zIndex', 'fontWeight'].includes(k)) v += 'px'; element.style[k] = v; } return element; } function create(name, props, ...childrens) { if (name == null) return null; if (name === 'svg') return createSVG(name, props, ...childrens); const element = name instanceof Element ? name : document.createElement(name); if (props === undefined) return element; if (Array.isArray(props) || props instanceof Node || typeof props !== 'object') return append(element, props, ...childrens); return append(setProps(element, props), ...childrens); } function append(element, ...childrens) { if (element.name === 'svg') return appendSVG(element, ...childrens); for (const child of childrens) { if (Array.isArray(child)) element.append(create(...child)); else if (child instanceof Node) element.appendChild(child); else element.append(document.createTextNode(child)); } return element; } function createSVG(name, props, ...childrens) { const element = document.createElementNS('http://www.w3.org/2000/svg', name); if (name === 'svg') element.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink'); if (props === undefined) return element; if (Array.isArray(props) || props instanceof Node || typeof props !== 'object') return append(element, props, ...childrens); return appendSVG(setProps(element, props), ...childrens) } function appendSVG(element, ...childrens) { for (const child of childrens) { if (Array.isArray(child)) element.append(createSVG(...child)); else if (child instanceof Node) element.appendChild(child); else element.append(document.createTextNode(child)); } return element; } function removeAllChildren(element) { while (element.firstChild) element.removeChild(element.firstChild); return element; } function createTextSVG(text, fontClass) { const testWidthElement = create('span', { class: fontClass, style: { fontSize: '10px', position: 'absolute', opacity: 0 } }, text); append(document.body, testWidthElement); const w = testWidthElement.offsetWidth; testWidthElement.remove(); return createSVG('svg', { class: fontClass, fill: 'currentColor', viewBox: `0 0 ${w} 10` }, ['text', { 'font-size': 10 }, text]); } async function newTab(href) { create('a', { href, target: '_blank' }).click(); } /**merge**/ /**merge:js=_common.util.js**/ function callWhenDone(fn) { let done = true; return async () => { if (!done) return; done = false; await fn(); done = true; } } function callNow(fn) { fn(); return fn; } function map(list, fn, ret = []) { let i = 0; for (const item of list) { const result = fn(item, i, list); ret.push(result); i++; } return ret } /**merge**/ /**merge:js=_common.database.js**/ class Cache { constructor({ hot, last }) { this.#hotLimit = hot ?? 0; this.#lastLimit = last ?? 0; this.#cacheLimit = this.#hotLimit + this.#lastLimit; } #hotLimit; #lastLimit; #cacheLimit; #hotList = []; #hot = new Set(); #last = new Set(); #pedding = new Set(); #cache = new Map(); #times = new Map(); #cHot(key) { if (!this.#hotLimit) return false; const counter = this.#times.get(key) || { key, cnt: 0 }; counter.cnt++; this.#times.set(key, counter); if (this.#hot.size == 0) { this.#hotList.push(counter); this.#hot.add(key); this.#pedding.delete(key); return true; } const i = this.#hotList.indexOf(counter); if (i == 0) return true; if (i > 0) { const up = this.#hotList[i - 1]; if (counter.cnt > up.cnt) this.#hotList.sort((a, b) => b.cnt - a.cnt); return true; } if (this.#hot.size < this.#hotLimit) { this.#hotList.push(counter); this.#hot.add(key); this.#pedding.delete(key); return true; } const min = this.#hotList.at(-1); if (counter.cnt <= min.cnt) return false; this.#hotList.pop(); this.#hot.delete(min.key); if (!this.#last.has(min.key)) this.#pedding.add(min.key); this.#hotList.push(counter); this.#hot.add(key); this.#pedding.delete(key); return true; } #cLast(key) { if (!this.#lastLimit) return false; this.#last.delete(key); this.#last.add(key); this.#pedding.delete(key); if (this.#last.size <= this.#lastLimit) return true; const out = this.#last.values().next().value; this.#last.delete(out); if (!this.#hot.has(out)) this.#pedding.add(out); return true; } async get(key, query) { const data = this.#cache.get(key) ?? await query(); const inHot = this.#cHot(key); const inLast = this.#cLast(key); if (inHot || inLast) this.#cache.set(key, data); let i = this.#cache.size - this.#cacheLimit; if (!i) return data; for (const key of this.#pedding) { if (!i) return data; this.#cache.delete(key); this.#pedding.delete(key); i--; } return data; } update(key, value) { if (!this.#cache.has(key)) this.#cache.set(key, value); } clear() { this.#cache.clear(); } } class Collection { constructor(master, { collection, options, indexes, cache }) { this.#master = master; this.#collection = collection; this.#options = options; this.#indexes = indexes; if (cache && cache.enabled) { this.#cache = new Cache(cache); } } #master; #collection; #options; #indexes; #cache = null; get collection() { return this.#collection } get options() { return this.#options } get indexes() { return this.#indexes } async transaction(handler, mode) { return this.#master.transaction(this.#collection, async store => { const request = await handler(store); return new Promise((resolve, reject) => { request.addEventListener('error', e => reject(e)); request.addEventListener('success', () => resolve(request.result)); }) }, mode) } async get(key, index = '') { const handler = () => this.transaction(store => (index ? store.index(index) : store).get(key)); if (this.#cache && this.#options.keyPath && !index) return this.#cache.get(key, handler); return handler(); } async put(data) { if (this.#cache) { let key; if (Array.isArray(this.#options.keyPath)) { key = []; for (const path of this.#options.keyPath) { key.push(data[path]); } key = key.join('/'); } else { key = data[this.#options.keyPath]; } this.#cache.update(key, data); } return this.transaction(store => store.put(data), 'readwrite').then(_ => true); } async clear() { if (this.#cache) this.#cache.clear(); return this.transaction(store => store.clear(), 'readwrite').then(_ => true); } } class Database { constructor({ dbName, version, collections, blocked }) { this.#dbName = dbName; this.#version = version; this.#blocked = blocked || { alert: false }; for (const options of collections) { this.#collections.set(options.collection, new Collection(this, options)); } } #dbName; #version; #collections = new Map(); #db; #blocked; async init() { this.#db = await new Promise((resolve, reject) => { const request = window.indexedDB.open(this.#dbName, this.#version); request.addEventListener('error', () => reject({ type: 'error', message: request.error })); request.addEventListener('blocked', () => { const message = this.#blocked?.message || 'indexedDB is blocked'; if (this.#blocked?.alert) alert(message); reject({ type: 'blocked', message }); }); request.addEventListener('success', () => resolve(request.result)); request.addEventListener('upgradeneeded', () => { for (const c of this.#collections.values()) { const { collection, options, indexes } = c; let store; if (!request.result.objectStoreNames.contains(collection)) store = request.result.createObjectStore(collection, options); else store = request.transaction.objectStore(collection); if (!indexes) continue; for (const { name, keyPath, unique } of indexes) { if (store.indexNames.contains(name)) continue; store.createIndex(name, keyPath, { unique }); } } }); }); return this; } async transaction(collection, handler, mode = 'readonly') { if (!this.#db) await this.init(); return new Promise(async (resolve, reject) => { const transaction = this.#db.transaction(collection, mode); const store = transaction.objectStore(collection); const result = await handler(store); transaction.addEventListener('error', e => reject(e)); transaction.addEventListener('complete', () => resolve(result)); }); } async get(collection, key, index) { return this.#collections.get(collection).get(key, index); } async put(collection, data) { return this.#collections.get(collection).put(data); } async clear(collection) { return this.#collections.get(collection).clear(); } async clearAll() { for (const c of this.#collections.values()) await c.clear(); return true; } } /**merge**/ /**merge:js=_common.event.js**/ class Event { static #listeners = new Map(); static on(event, listener) { if (!this.#listeners.has(event)) this.#listeners.set(event, new Set()); this.#listeners.get(event).add(listener); } static emit(event, ...args) { if (!this.#listeners.has(event)) return; for (const listener of this.#listeners.get(event).values()) listener(...args); } static off(event, listener) { if (!this.#listeners.has(event)) return; this.#listeners.get(event).delete(listener); } } /**merge**/ /**merge:js=_common.bangumi.js**/ function whoami() { let nid; try { nid = window.CHOBITS_UID ?? window.parent.CHOBITS_UID ?? CHOBITS_UID ?? 0; } catch (e) { nid = 0; } const dockA = window.parent.document.querySelector('#dock li.first a'); if (dockA) { const id = dockA.href.split('/').pop(); return { id, nid }; } const bannerAvatar = window.parent.document.querySelector('.idBadgerNeue> .avatar'); if (bannerAvatar) { const id = bannerAvatar.href.split('/').pop(); return { id, nid }; } return null; } /**merge**/ addStyle( /**merge:css=bangumi_community.user.keyframes.css**/`@keyframes loading-spine {to{transform: rotate(.5turn)}}`/**merge**/, /**merge:css=bangumi_community.user.colors.light.css**/`html {--color-base: #ffffff;--color-base-2: #e8e8e8;--color-base-bg: #eaeffba0;--color-base-font: #282828;--color-gray-1: #e8e8e8;--color-gray-2: #cccccc;--color-gray-3: #aaaaaa;--color-gray-4: #969696;--color-gray-11: #cccccc;--color-bangumi-2: #AB515D;--color-bangumi-font: rgb(from var(--color-bangumi) calc(r - 50) calc(g - 50) calc(b - 50));--color-yellow-font: rgb(from var(--color-yellow) calc(r - 50) calc(g - 50) calc(b - 50));--color-purple-font: rgb(from var(--color-purple) calc(r - 50) calc(g - 50) calc(b - 50));--color-blue-font: rgb(from var(--color-blue) calc(r - 50) calc(g - 50) calc(b - 50));--color-green-font: rgb(from var(--color-green) calc(r - 50) calc(g - 50) calc(b - 50));--color-red-font: rgb(from var(--color-red) calc(r - 50) calc(g - 50) calc(b - 50));}`/**merge**/, /**merge:css=bangumi_community.user.colors.dark.css**/`html[data-theme='dark'] {--color-base: #000000;--color-base-2: #1f1f1f;--color-base-bg: #23262ba0;--color-base-font: #e8e8e8;--color-gray-1: #444444;--color-gray-2: #555555;--color-gray-3: #6a6a6a;--color-gray-4: #888888;--color-gray-11: #cccccc;--color-bangumi-2: #ffb6bd;--color-bangumi-font: rgb(from var(--color-bangumi) calc(r + 50) calc(g + 50) calc(b + 50));--color-yellow-font: rgb(from var(--color-yellow) calc(r + 50) calc(g + 50) calc(b + 50));--color-purple-font: rgb(from var(--color-purple) calc(r + 50) calc(g + 50) calc(b + 50));--color-blue-font: rgb(from var(--color-blue) calc(r + 50) calc(g + 50) calc(b + 50));--color-green-font: rgb(from var(--color-green) calc(r + 50) calc(g + 50) calc(b + 50));--color-red-font: rgb(from var(--color-red) calc(r + 50) calc(g + 50) calc(b + 50));}`/**merge**/, /**merge:css=bangumi_community.user.colors.css**/`html {--color-bangumi: #fd8a96;--color-white: #ffffff;--color-black: #000000;--color-yellow: #f9c74c;--color-purple: #a54cf9;--color-blue: #02a3fb;--color-green: #95eb89;--color-red: #f94144;--color-skyblue: #7ed2ff;--color-dock-sp: var(--color-gray-2);--color-switch-border: var(--color-gray-2);--color-switch-on: var(--color-green);--color-switch-off: var(--color-gray-4);--color-switch-bar-border: var(--color-white);--color-switch-bar-inner: var(--color-gray-11);--color-hover: var(--color-blue);--color-icon-btn-bg: rgb(from var(--color-bangumi) r g b / .25);--color-icon-btn-color: var(--color-white);--color-reply-sp: var(--color-gray-1);--color-reply-tips: var(--color-gray-3);--color-reply-normal: var(--color-bangumi);--color-reply-owner: var(--color-yellow);--color-reply-floor: var(--color-purple);--color-reply-friend: var(--color-green);--color-reply-self: var(--color-blue);--color-sicky-bg: rgb(from var(--color-base) r g b / .125);--color-sicky-border: rgb(from var(--color-bangumi) r g b / .25);--color-sicky-shadow: rgb(from var(--color-base) r g b / .05);--color-sicky-textarea: rgb(from var(--color-base) r g b / .8);--color-sicky-hover-bg: rgb(from var(--color-bangumi) r g b / .125);--color-sicky-hover-border: var(--color-bangumi);--color-sicky-hover-shadow: var(--color-bangumi);--color-primary: var(--color-bangumi);--color-secondary: var(--color-blue);--color-success: var(--color-green);--color-info: var(--color-blue);--color-important: var(--color-purple);--color-warning: var(--color-yellow);--color-danger: var(--color-red);}`/**merge**/, /**merge:css=bangumi_community.user.1.css**/`html, html[data-theme='dark'] {#dock {li {position: relative;height: 18px;display: flex;align-items: center;justify-content: center;}li:not(:last-child) {border-right: 1px solid var(--color-dock-sp);}}.columns {> #columnInSubjectB {> * { margin: 0; }display: flex;gap: 10px;flex-direction: column;position: sticky;top: 0;align-self: flex-start;max-height: 100vh;overflow-y: auto;}}*:has(>#comment_list) {.postTopic {border-bottom: none;.inner.tips {display: flex;height: 40px;align-items: center;gap: 8px;color: var(--color-reply-tips);}}.avatar:not(.tinyCover) {img,.avatarNeue {border-radius: 50% !important;}}.clearit:not(.message) {transition: all 0.3s ease;box-sizing: border-box;border-bottom: none !important;border-top: 1px dashed var(--color-reply-sp);.inner.tips {display: flex;height: 40px;align-items: center;gap: 8px;color: var(--color-reply-tips);}.sub_reply_collapse .inner.tips { height: auto; }--color-reply: var(--color-bangumi);}.clearit.friend { --color-reply: var(--color-green); }.clearit.owner { --color-reply: var(--color-yellow); }.clearit.floor { --color-reply: var(--color-purple); }.clearit.self { --color-reply: var(--color-blue); }.clearit.friend, .clearit.owner, .clearit.floor, .clearit.self {border-top: 1px solid var(--color-reply) !important;background: linear-gradient(rgb(from var(--color-reply) r g b / .125) 1px, #00000000 60px) !important;> .inner > :first-child > strong::before, > .inner > strong::before {padding: 1px 4px;margin-right: 4px;border-radius: 2px;background: rgb(from var(--color-bangumi) r g b /.5);}}.clearit:not(:has(.clearit:not(.message):hover), .message):hover {border-top: 1px solid var(--color-reply) !important;background: linear-gradient(rgb(from var(--color-reply) r g b / .125) 1px, #00000000 60px) !important;box-shadow: 0 0 4px rgb(from var(--color-reply) r g b / .5);}.clearit.self { > .inner > :first-child > strong::before, > .inner > strong::before { content: '自'; } }.clearit.friend { > .inner > :first-child > strong::before, > .inner > strong::before { content: '友'; } }.clearit.owner { > .inner > :first-child > strong::before, > .inner > strong::before { content: '楼'; } }.clearit.floor { > .inner > :first-child > strong::before, > .inner > strong::before { content: '层'; } }.clearit.friend.owner { > .inner > :first-child > strong::before, > .inner > strong::before { content: '友 楼'; } }.clearit.friend.floor { > .inner > :first-child > strong::before, > .inner > strong::before { content: '友 层'; } }.clearit.owner.floor { > .inner > :first-child > strong::before, > .inner > strong::before { content: '楼 层'; } }.clearit.self.owner { > .inner > :first-child > strong::before, > .inner > strong::before { content: '自 楼'; } }.clearit.self.floor { > .inner > :first-child > strong::before, > .inner > strong::before { content: '自 层'; } }.clearit.friend.owner.floor { > .inner > :first-child > strong::before, > .inner > strong::before { content: '友 楼 层'; } }.clearit.self.owner.floor { > .inner > :first-child > strong::before, > .inner > strong::before { content: '自 楼 层'; } }}#comment_list {box-sizing: border-box;.row:nth-child(odd), .row:nth-child(even) { background: transparent; }> .clearit:first-child { border-top: 1px solid transparent; }div.reply_collapse { padding: 5px 10px; }}@media (max-width: 640px) {.columns { > .column:last-child { align-self: auto !important; } }}}`/**merge**/, /**merge:css=bangumi_community.user.2.css**/`html, html[data-theme='dark'] {.tip-item,.svg-icon {position: relative;display: flex !important;align-items: center !important;justify-content: center !important;cursor: pointer;.tip, span {visibility: hidden;position: absolute;top: 0;left: 50%;transform: translate(-50%, calc(-100% - 10px));padding: 2px 5px;border-radius: 5px;background: rgb(from var(--color-black) r g b / 0.6);white-space: nowrap;color: var(--color-white);}.tip::after, span::after {content: '';position: absolute !important;bottom: 0;left: 50%;border-top: 5px solid rgb(from var(--color-black) r g b / 0.6);border-right: 5px solid transparent;border-left: 5px solid transparent;backdrop-filter: blur(5px);transform: translate(-50%, 100%);}}.tip-item:hover, .svg-icon:hover { .tip, span { visibility: visible; } }.switch {display: inline-block;position: relative;cursor: pointer;border-radius: 50px;height: 12px;width: 40px;border: 1px solid var(--color-switch-border);}.switch::before {content: '';display: block;position: absolute;pointer-events: none;height: 12px;width: 40px;top: 0px;border-radius: 24px;background-color: var(--color-switch-off);}.switch::after {content: '';display: block;position: absolute;pointer-events: none;top: 0;left: 0;height: 12px;width: 24px;border-radius: 24px;box-sizing: border-box;background-color: var(--color-switch-bar-inner);border: 5px solid var(--color-switch-bar-border);}.switch[switch="1"]::before {background-color: var(--color-switch-on);}.switch[switch="1"]::after {left: 16px;}.topic-box {#comment_list {.icon {color: var(--color-gray-11);}}.block {display: none;}.sicky-reply {background-color: var(--color-sicky-bg);border: 1px solid var(--color-sicky-border);box-shadow: 0px 0px 0px 2px var(--color-sicky-shadow);textarea {background-color: var(--color-sicky-textarea);}}.sicky-reply:has(:focus),.sicky-reply:hover {grid-template-rows: 1fr;background-color: var(--color-sicky-hover-bg);border: 1px solid var(--color-sicky-hover-border);box-shadow: 0 0 4px var(--color-sicky-hover-shadow);}#reply_wrapper {position: relative;padding: 5px;min-height: 50px;margin: 0;textarea.reply {width: 100% !important;}.switch {position: absolute;right: 10px;top: 10px;}.tip.rr + .switch {top: 35px;}}.sicky-reply {position: sticky;top: 0;z-index: 2;display: grid;height: auto;grid-template-rows: 0fr;border-radius: 4px;backdrop-filter: blur(5px);transition: all 0.3s ease;width: calc(100% - 1px);overflow: hidden;#slider {position: absolute;right: 5px;top: 13px;max-width: 100%;}}.svg-box {display: flex;justify-content: center;align-items: center;}}.vcomm {ul {white-space: nowrap;justify-content: center;align-items: center;}a {display: flex;align-items: center;gap: 0.5em;}}}`/**merge**/, /**merge:css=bangumi_community.user.3.css**/`html, html[data-theme='dark'] {.vc-serif {font-family: source-han-serif-sc, source-han-serif-japanese, 宋体, 新宋体;font-weight: 900;}#community-helper-user-panel {position: fixed !important;z-index: 9999;display: grid;place-items: center;top: 0;right: 0;bottom: 0;left: 0;> .close-mask {position: absolute;z-index: -100;display: grid;place-items: center;top: 0;right: 0;bottom: 0;left: 0;background: rgb(from var(--color-base) r g b / 0.5);cursor: pointer;backdrop-filter: blur(5px);}> .container {max-width: 1280px;min-height: 390px;max-height: 600px;width: calc(100% - 60px);height: calc(100vh - 60px);> fieldset.board {padding-top: 24px;legend {position: absolute;font-weight: bold;top: 5px;left: 5px;padding: 0;line-height: 12px;font-size: 12px;display: flex;align-items: center;gap: 0.5em;color: var(--color-bangumi-font);> svg {width: 14px;height: 14px;}}}> .tags-field {ul {display: flex;flex-wrap: wrap;gap: 4px;li {padding: 0 5px;border-radius: 50px;background: rgb(from var(--color-font) r g b / .25);border: 1px solid var(--color-font);box-sizing: border-box;white-space: pre;}}}display: grid;grid-template-columns: auto auto auto 1fr auto;grid-template-rows: 180px 34px 36px 40px calc(100% - 310px);gap: 5px 5px;padding: 30px 5px 5px 5px;margin-bottom: 25px;grid-template-areas:"avatar note note note bio""actions note note note bio""stats note note note bio""chart note note note bio""usedname usedname tags tags bio";> .board {--loading-size: 50px;--color-font: var(--color-bangumi-font);--color-from: var(--color-base-2);--color-to: var(--color-base-2);--color-alpha: 0.05;color: var(--color-font);padding: 10px;position: relative;border-radius: 4px;> .actions,> .action {color: var(--color-base-font);position: absolute;top: 5px;right: 5px;cursor: pointer;}> .actions{cursor: none;display: flex;gap: 8px;> .action { cursor: pointer; }}}> .board::after,> .board::before {content: '';position: absolute;border-radius: 4px;top: 0;left: 0;right: 0;bottom: 0;background-size: cover;z-index: -10;}> .board::before { opacity: 0.2; }> .failed,> .loading::before,> .board::after {opacity: 1;background: linear-gradient(150deg, rgb(from var(--color-from) r g b / var(--color-alpha)), rgb(from var(--color-to) r g b / var(--color-alpha)) 75%);box-shadow: 0 0 1px rgb(from var(--color-bangumi-2) r g b / .5);backdrop-filter: blur(10px);}> .loading::after,> .failed::before,> .failed::after {background: none !important;box-shadow: none !important;backdrop-filter: none !important;}> .loading::after {width: var(--loading-size);height: var(--loading-size);top: calc(50% - calc(var(--loading-size) / 2));left: calc(50% - calc(var(--loading-size) / 2));aspect-ratio: 1;border-radius: 50%;border: calc(var(--loading-size) / 6.25) solid;box-sizing: border-box;border-color: var(--color-bangumi) transparent;animation: loading-spine 1s infinite;}> .failed::before,> .failed::after {width: var(--loading-size);;height: calc(var(--loading-size) / 6.25);top: calc(50% - calc(var(--loading-size) / 2));left: calc(50% - calc(var(--loading-size) / 12.5));}> .failed::after { transform: rotate(45deg); }> .failed::before { transform: rotate(-45deg); }> .editable:not(.editing) {.edit, textarea { display: none !important; }}> .editable.editing {.normal { display: none !important; }textarea {width: 100%;height: 100%;resize: vertical;border: none;padding: 0;box-sizing: border-box;background: rgb(from var(--color-base) r g b / .1);border-radius: 4px;max-height: 100%;font-size: 12px;line-height: 18px;color: var(--color-font);overscroll-behavior: contain;}textarea:focus, textarea:hover {border: none;box-shadow: 0 0 1px rgb(from var(--color-bangumi) r g b / .5);}}> .avatar {grid-area: avatar;--color-font: var(--color-bangumi);--color-from: var(--color-bangumi);--color-alpha: .25;min-width: 120px;max-width: 280px;display: flex;flex-direction: column;justify-content: center;align-items: center;gap: 5px;img {width: 100px;height: 100px;border-radius: 100px;object-fit: cover;}span {position: absolute;top: 5px;right: 0;transform: translate(100%) rotate(90deg);transform-origin: 0% 0%;}span::before {content: '@';}svg {width: 100%;height: 50px;text {transform: translate(50%, 0.18em);text-anchor: middle;dominant-baseline: hanging;}}}> .actions {grid-area: actions;--loading-size: 24px;--color-from: var(--color-yellow);--color-font: var(--color-yellow-font);display: grid;padding: 0;grid-template-columns: repeat(4, 1fr);grid-template-areas: "one two three four";> * {position: relative;display: grid;place-items: center;width: 100%;padding: 10px 0;}> .home { grid-area: one; }> .pm { grid-area: two; }> .friend { grid-area: three; }> .block { grid-area: four; }> *:not(.block)::after {position: absolute;content: '';width: 2px;height: calc(100% - 10px);top: 5px;right: -1px;background: rgb(from var(--color-from) r g b / .25);}}> .stats {grid-area: stats;--loading-size: 24px;--color-font: var(--color-base-font);padding: 0;display: grid;grid-template-columns: repeat(3, 1fr);grid-template-rows: repeat(2, 1fr);> .stat {line-height: 14px;font-size: 14px;font-weight: bold;padding: 2px 5px;background: rgb(from var(--color-stat) r g b / .25);}> .stat:hover { background: rgb(from var(--color-stat) r g b / .5); }> .stat:first-child {border-radius: 4px 0 0 0;}> .stat:nth-child(3) {border-radius: 0 4px 0 0;}> .stat:last-child {border-radius: 0 0 4px 0;}> .stat:nth-child(4) {border-radius: 0 0 0 4px;}> .coll {--color-stat: var(--color-bangumi);}> .done {--color-stat: var(--color-green);}> .rate {--color-stat: var(--color-skyblue);}> .avg {--color-stat: var(--color-yellow);}> .std {--color-stat: var(--color-purple);}> .cnt {--color-stat: var(--color-blue);}}> .chart {grid-area: chart;--loading-size: 24px;--color-font: var(--color-base-font);padding: 0;display: grid;grid-template-rows: repeat(10, 4px);> * {display: flex;justify-content: flex-start !important;width: 100%;.bar {height: 2px;background: rgb(from var(--color-bangumi) r g b / .65);transition: all 0.3s ease;}}> *:first-child::before, *:first-child>.bar { border-radius: 4px 4px 0 0; }> *:last-child::before, *:last-child>.bar { border-radius: 0 0 4px 4px; }> *::before {content: '';position: absolute;top: 1px;left: 0;width: 100%;height: 2px;background: rgb(from var(--color-bangumi) r g b / .15);z-index: -1;transition: all 0.3s ease;}> *:hover::before { background: rgb(from var(--color-bangumi) r g b / .3); }> *:hover > .bar { background: rgb(from var(--color-bangumi) r g b / 1); }}> .tags {grid-area: tags;min-width: 200px;--color-from: var(--color-blue);--color-font: var(--color-blue-font);> .wrapper {height: 100%;> * { max-height: 100%; }}}> .note {grid-area: note;min-width: 200px;--color-from: var(--color-green);--color-font: var(--color-green-font);white-space: pre-wrap;> .wrapper {height: 100%;> * { max-height: 100%; }}}> .usedname {grid-area: usedname;--color-from: var(--color-purple);--color-font: var(--color-purple-font);max-width: 400px;min-width: 200px;> ul { max-height: 100%; }}> .bio {grid-area: bio;--color-from: var(--color-bangumi);--color-font: var(--color-base-font);max-width: 505px;min-width: 300px;max-height: calc(100% - 34px);> div { height: calc(100% + 2px); }}}}@media (max-width: 850px) {#community-helper-user-panel > .container {grid-template-columns: auto auto auto 1fr;grid-template-rows: 180px 34px 36px 40px auto auto;max-height: 900px;grid-template-areas:"avatar note note note""actions note note note""stats note note note""chart note note note""usedname usedname tags tags""bio bio bio bio";> .tags,> .usedname {max-height: 300px;}> .bio {max-width: 100%;max-height: 100%;}}}@media (max-width: 520px) {#community-helper-user-panel > .container {grid-template-columns: 1fr;grid-template-rows: 180px 34px 36px 40px auto auto auto auto;max-height: 1100px;grid-template-areas:"avatar""actions""stats""chart""note""usedname""tags""bio";> .board { min-width: 130px; width: calc(100% - 20px); max-width: calc(100% - 20px); }> .actions, > .stats, > .chart { width: 100%; max-width: 100%; }> .note { max-height: 200px; }> .tags, > .usedname { max-height: 150px; }> .bio { max-height: 100%; }}}}`/**merge**/, ) const db = new Database({ dbName: 'VCommunity', version: 4, collections: [ { collection: 'values', options: { keyPath: 'id' }, indexes: [{ name: 'id', keyPath: 'id', unique: true }] }, { collection: 'friends', options: { keyPath: 'id' }, indexes: [{ name: 'id', keyPath: 'id', unique: true }], cache: { enabled: true, last: 1 } }, { collection: 'users', options: { keyPath: 'id' }, indexes: [{ name: 'id', keyPath: 'id', unique: true }], cache: { enabled: true, last: 5, hot: 5 } }, { collection: 'images', options: { keyPath: 'uri' }, indexes: [{ name: 'uri', keyPath: 'uri', unique: true }] } ], blocked: { alert: true, message: 'Bangumi 社区助手 preview 数据库有更新,请先关闭所有班固米标签页再刷新试试' }, }); const menu = new class { constructor() { window.addEventListener('resize', () => this.#resize()) } #onResize = new Set(); #resize() { for (const fn of this.#onResize) fn(); } #menu = create('ul', ['li', { onClick: () => this.#block() }, ['a', { href: 'javascript:void(0)' }, svg('block'), '屏蔽发言']], ['li', { onClick: () => this.#show() }, ['a', { href: 'javascript:void(0)' }, svg('detail'), '详细信息']] ); #panel = create('div', { id: 'community-helper-user-panel' }, ['div', { class: 'close-mask', onClick: () => this.#close() }]); #style = addStyle() #bfbgi(src) { const randomClass = 'v-rand-' + Math.floor(Math.random() * 100000 + 100000).toString(16); this.#style.innerText = `.${randomClass}::before {background-image: url("${src}");}` return randomClass } #id; id(id) { this.#id = id; return this.#menu; } async #block() { const id = this.#id; if (!confirm('确定要屏蔽吗?')) return false; const data = await db.get('users', id) || { id }; data.block = true; await db.put('users', data); return true } async #unblock() { const id = this.#id; if (!confirm('确定要解除屏蔽吗?')) return false; const data = await db.get('users', id) || { id }; data.block = false; await db.put('users', data); return true } async #connect(nid, gh) { if (!confirm('真的要加好友吗?')) return false; const ret = await fetch(`/connect/${nid}?gh=${gh}`); return ret.ok } async #disconnect(nid, gh) { if (!confirm('真的要解除好友吗?')) return false; const ret = await fetch(`/disconnect/${nid}?gh=${gh}`); return ret.ok } async #loadUserData(id) { return await db.get('users', id) || { id, names: new Set() }; } async #loadUsedname(id) { const data = await this.#loadUserData(id); const { names, namesUpdate, namesTml } = data; if (namesUpdate < Date.now() - 3600_000) return names; const getUsedNames = async (end, tml, ret = [], page = 1) => { const res = await fetch(`/user/${id}/timeline?type=say&ajax=1&page=${page}`); const html = await res.text(); const names = Array.from(html.matchAll(/从 \(?.*?)\<\/strong\> 改名为/g), m => m.groups.from); const tmls = Array.from(html.matchAll(/\

(?\d{4}\-\d{1,2}\-\d{1,2})\<\/h4\>/g), m => m.groups.tml); if (!tml) tml = tmls[0]; ret.push(...names); if (tmls.includes(end) || !html.includes('>下一页 ››')) return { ret, tml }; return getUsedNames(end, tml, ret, page + 1); }; const { ret, tml } = await getUsedNames(namesTml); const namesN = new Set(ret).union(names); namesN.delete(''); if (namesTml && names.size == namesN.size) return names; const save = await this.#loadUserData(id); save.names = namesN; save.namesUpdate = Date.now(); save.namesTml = tml; await db.put('users', save); return namesN; } async #loadHomepage(id) { const res = await fetch('/user/' + id); const me = whoami(); if (!res.ok) return null; const html = await res.text(); const element = document.createElement('html'); element.innerHTML = html.replace(/<(img|script|link)/g, ' .item'), e => { const name = e.lastElementChild.innerText; let type; switch (name) { case '收藏': type = 'coll'; break; case '完成': type = 'done'; break; case '完成率': type = 'rate'; break; case '平均分': type = 'avg'; break; case '标准差': type = 'std'; break; case '评分数': type = 'cnt'; break; } return { type, name, value: e.firstElementChild.innerText } }) const chart = Array.from(pinnedLayout.querySelectorAll('#ChartWarpper li > a'), e => { return { label: e.firstElementChild.innerText, value: parseInt(e.lastElementChild.innerText.replace(/[\(\)]/g, '')), } }) if (me.nid == 0) return { type: 'guest', name, src, bio, stats, chart }; if (me.id == id) return { type: 'self', name, src, bio, stats, chart }; const actions = nameSingle.querySelectorAll('#headerProfile .actions a.chiiBtn'); const nid = actions[1].href.split('/').pop().replace('.chii', '') const friend = actions[0].innerText == '解除好友'; const gh = friend ? actions[0].getAttribute('onclick').split(',').pop().split(/['"]/)[1] : actions[0].href.split('gh=').pop(); if (bio) bio.classList.remove('bio'); const type = friend ? 'friend' : 'normal'; return { type, name, src, bio, nid, gh, stats, chart } } async #niceIt(element) { const opts = { cursorcolor: "rgb(from var(--color-bangumi) r g b / .5)", cursorwidth: "4px", cursorborder: "none" }; await LoadScript.load('https://cdn.jsdelivr.net/npm/jquery.nicescroll@3.7/jquery.nicescroll.min.js'); const nice = $(element).niceScroll(opts); this.#onResize.add(() => nice.resize()); return nice; } #scrollTo(element, { x, y, d }) { const nice = $(element).getNiceScroll(0) if (!nice) return; if (typeof x === 'number') nice.doScrollLeft(x, d ?? 0); if (typeof y === 'number') nice.doScrollTop(y, d ?? 0); } #isShow = false; #close() { this.#isShow = false; this.#panel.remove(); this.#onResize.clear(); const close = this.#panel.firstElementChild; while (this.#panel.lastElementChild != close) { this.#panel.lastElementChild.remove(); } } async #show() { this.#isShow = true; const id = this.#id; const avatar = create('div', { class: ['avatar', 'board', 'loading'] }); const bio = create('fieldset', { class: ['bio', 'board', 'loading'] }, ['legend', svg('user'), 'Bio']); const usedname = create('fieldset', { class: ['usedname', 'board', 'loading', 'tags-field'] }, ['legend', svg('history'), '曾用名']); const tags = create('fieldset', { class: ['tags', 'board', 'loading', 'editable', 'tags-field'] }, ['legend', svg('tag'), '标签']); const note = create('fieldset', { class: ['note', 'board', 'loading', 'editable'] }, ['legend', svg('note'), '备注']); const stats = create('ul', { class: ['stats', 'board', 'loading'] }); const chart = create('ul', { class: ['chart', 'board', 'loading'] }); const homeBtn = create('li', { class: ['home', 'svg-icon'], onClick: () => newTab('/user/' + id) }, svg('home'), ['span', '主页']); const pmBtn = create('li', { class: ['pm', 'svg-icon'] }, svg('message'), ['span', '私信']); const connectBtn = create('li', { class: ['friend', 'svg-icon'] }, svg('connect'), ['span', '加好友']); const disconnectBtn = create('li', { class: ['friend', 'svg-icon'] }, svg('disconnect'), ['span', '解除好友']); const blockedBtn = create('li', { class: ['block', 'svg-icon'] }, svg('block'), ['span', '解除屏蔽']); const unblockBtn = create('li', { class: ['block', 'svg-icon'] }, svg('notify'), ['span', '屏蔽']); const actions = create('ul', { class: ['actions', 'board'] }, homeBtn, pmBtn, connectBtn, unblockBtn); const container = create('div', { class: 'container' }, avatar, actions, stats, chart, note, usedname, tags, bio); append(document.body, [this.#panel, container]); this.#niceIt(container); await Promise.all([ async () => { // 头像、昵称、简介、统计、图表、PM const homepage = await this.#loadHomepage(id); if (!this.#isShow || id != this.#id) return; avatar.classList.remove('loading'); bio.classList.remove('loading'); stats.classList.remove('loading'); chart.classList.remove('loading'); if (!homepage) { avatar.classList.add('failed'); bio.classList.add('failed'); } const { type, name, src, friend, nid, gh, bio: rbio, stats: sts, chart: cht } = homepage; bio.classList.add(this.#bfbgi(src)) append(avatar, ['img', { src }], createTextSVG(name, 'vc-serif'), ['span', id]); append(bio, rbio); if (rbio) this.#niceIt(rbio); append(stats, ...map(sts, v => ['li', { class: ['stat', 'tip-item', v.type] }, ['div', v.value], ['span', v.name]])); const max = Math.max(...cht.map(v => v.value)); append(chart, ...map(cht, v => ['li', { class: 'tip-item' }, ['span', `${v.label}分: ${v.value}`], ['div', { class: 'bar', style: { width: (v.value / max * 100).toFixed(2) + '%' } }], ])); this.#resize(); switch (type) { case 'guest': { const act = () => confirm('暂未登录,是否打开登录页面') && newTab('/login'); pmBtn.addEventListener('click', act); connectBtn.addEventListener('click', act); break; } case 'self': { const act = () => alert('这是自己'); pmBtn.addEventListener('click', act); connectBtn.addEventListener('click', act); break; } case 'friend': connectBtn.replaceWith(disconnectBtn) default: pmBtn.addEventListener('click', () => newTab('/pm/compose/' + nid + '.chii')); if (friend) connectBtn.replaceWith(disconnectBtn) connectBtn.addEventListener('click', async () => { if (await this.#connect(nid, gh)) connectBtn.replaceWith(disconnectBtn); }); disconnectBtn.addEventListener('click', async () => { if (await this.#disconnect(nid, gh)) disconnectBtn.replaceWith(connectBtn); }); } }, async () => { // 曾用名 const names = await this.#loadUsedname(id); if (!this.#isShow || id != this.#id) return; usedname.classList.remove('loading'); const usednameUl = create('ul', ...map(names, v => ['li', v]));; append(usedname, usednameUl); this.#niceIt(usednameUl); this.#resize(); }, async () => { // 屏蔽按钮 const user = await this.#loadUserData(id); if (!this.#isShow || id != this.#id) return; if (user.block) unblockBtn.replaceWith(blockedBtn) blockedBtn.addEventListener('click', async () => { if (await this.#unblock()) blockedBtn.replaceWith(unblockBtn); }); unblockBtn.addEventListener('click', async () => { if (await this.#block()) unblockBtn.replaceWith(blockedBtn); }); this.#resize(); }, async () => { // 标签 const e = tags; const edit = create('div', { class: ['svg-icon', 'action', 'normal'] }, svg('edit'), ['span', '编辑']); const ok = create('div', { class: ['svg-icon', 'action', 'edit'] }, svg('ok'), ['span', '保存']); const close = create('div', { class: ['svg-icon', 'action', 'edit'] }, svg('close'), ['span', '取消']); const content = create('ul', { class: 'normal' }); const textarea = create('textarea', { class: 'edit' }); const wrapper = create('div', { class: 'wrapper' }, content, textarea); const actions = create('div', { class: ['actions'] }, edit, ok, close); append(e, wrapper, actions); this.#niceIt(content); this.#niceIt(textarea); const render = async (save = false, value) => { removeAllChildren(content); e.classList.add('loading'); e.classList.remove('editing'); const data = await this.#loadUserData(id); if (save) { const tags = value.split('\n').map(tag => tag.trim()).filter(tag => tag); if (!tags.length && data.tags) delete data.tags; else data.tags = new Set(tags); await db.put('users', data); data.tags; } if (!this.#isShow || id != this.#id) return; e.classList.remove('loading'); append(content, ...map(data.tags ?? [], tag => ['li', tag])); this.#resize(); } edit.addEventListener('click', () => { e.classList.add('editing'); textarea.value = Array.from(content.children, e => e.innerText).join('\n'); textarea.focus(); textarea.setSelectionRange(0, 0); this.#scrollTo(textarea, { x: 0, y: 0 }); }); ok.addEventListener('click', () => render(true, textarea.value)) close.addEventListener('click', () => render()) await render(); }, async () => { // 备注 const e = note; const edit = create('div', { class: ['svg-icon', 'action', 'normal'] }, svg('edit'), ['span', '编辑']); const ok = create('div', { class: ['svg-icon', 'action', 'edit'] }, svg('ok'), ['span', '保存']); const close = create('div', { class: ['svg-icon', 'action', 'edit'] }, svg('close'), ['span', '取消']); const content = create('div', { class: 'normal' }, ['span']); const textarea = create('textarea', { class: 'edit' }); const wrapper = create('div', { class: 'wrapper' }, content, textarea); const actions = create('div', { class: ['actions'] }, edit, ok, close); append(e, wrapper, actions); this.#niceIt(content); this.#niceIt(textarea); const render = async (save = false, value = '') => { removeAllChildren(content); e.classList.add('loading'); e.classList.remove('editing'); const data = await this.#loadUserData(id); if (save) { data.note = value; await db.put('users', data); } if (!this.#isShow || id != this.#id) return; e.classList.remove('loading'); append(content, ['span', data.note ?? '']) this.#resize(); } edit.addEventListener('click', () => { e.classList.add('editing'); textarea.value = content.innerText; textarea.focus(); textarea.setSelectionRange(0, 0); this.#scrollTo(textarea, { x: 0, y: 0 }); }); ok.addEventListener('click', () => render(true, textarea.value)) close.addEventListener('click', () => render()) await render(); }, ].map(fn => fn())); } } async function injectDock(dock) { if (!dock) return; const robotBtn = await waitElement(dock, 'showrobot'); if (!robotBtn) return; robotBtn.style.display = 'none'; robotBtn.parentElement.append(create('a', { class: ['showrobot', 'svg-icon'], href: 'javascript:void(0)', onClick: () => chiiLib.ukagaka.toggleDisplay() }, svg('robot'), ['span', '春菜'])); const dockUl = robotBtn.parentElement.parentElement; const toggleTheme = dockUl.querySelector('#toggleTheme'); toggleTheme.style.display = 'none'; toggleTheme.parentElement.append(create('a', { class: ['showrobot', 'svg-icon'], href: 'javascript:void(0)', onClick: () => chiiLib.ukagaka.toggleTheme() }, svg('light'), ['span', '开关灯'])); toggleTheme.parentElement.classList.remove('last'); const actions = dockUl.children[1]; for (const action of Array.from(actions.children)) { let icon; switch (action.innerText) { case '提醒': icon = 'notify'; break; case '短信': icon = 'message'; break; case '设置': icon = 'setting'; break; case '登出': icon = 'logout'; break; } if (icon) { const title = action.innerText removeAllChildren(action); action.classList.add('svg-icon'); append(action, svg(icon), ['span', title]); } dockUl.insertBefore(create('li', action), actions); } actions.remove(); } async function injectCommentList(commentList) { if (!commentList) return; const e = commentList.parentElement; if (!e) return; e.classList.add('topic-box'); const first = e.querySelector(':scope>.clearit') const observer = new IntersectionObserver((entries) => { entries.forEach(async entry => { const clearit = entry.target; if (clearit.isVCommed || !entry.isIntersecting) return; clearit.isVCommed = true; const id = clearit.getAttribute('data-item-user') if (!id) return; const data = await db.get('users', id) || { id, names: new Set() }; const inner = clearit.querySelector('.inner'); const icon = create('a', { class: ['icon', 'svg-icon'], href: 'javascript:void(0)' }, svg('mark')); const action = create('div', { class: ['action', 'dropdown', 'vcomm'] }, icon); icon.addEventListener('mouseenter', () => append(action, menu.id(id))); const actionBox = clearit.querySelector('.post_actions'); actionBox.insertBefore(action, actionBox.lastElementChild); if (!data.names) data.names = new Set(); const currentName = inner.querySelector('strong > a').innerText; if (currentName && !data.names.has(currentName)) { data.names.add(currentName); await db.put('users', data); } if (data.block) { const btn = create('div', { class: 'svg-box' }, svg('expand')) const tip = create('span', { class: 'svg-box' }, svg('collapse'), '已折叠') const tips = create('div', { class: ['inner', 'tips'] }, tip, btn); btn.addEventListener('click', () => tips.replaceWith(inner)); inner.replaceWith(tips); } }); }, { root: null, rootMargin: '0px', threshold: [0] }); if (first) observer.observe(first); const ownerItem = e.querySelector('.postTopic'); const owner = ownerItem?.getAttribute('data-item-user'); const self = whoami()?.id; if (owner === self) first.classList.add('self'); observeChildren(commentList, async comment => { observer.observe(comment) const floor = comment.getAttribute('data-item-user'); if (floor === owner) comment.classList.add('owner'); if (floor === self) comment.classList.add('self'); Friends.get().then(friends => friends.has(floor) && comment.classList.add('friend')); const subReply = await waitElement(comment, 'topic_reply_' + comment.id.substr(5)); if (!subReply) return; observeChildren(subReply, clearit => { observer.observe(clearit); const user = clearit.getAttribute('data-item-user'); if (user === owner) clearit.classList.add('owner'); if (user === floor) clearit.classList.add('floor'); if (user === self) clearit.classList.add('self'); Friends.get().then(friends => friends.has(user) && clearit.classList.add('friend')); }) }) Friends.get().then(friends => friends.has(owner) && ownerItem.classList.add('friend')); } async function replyWrapperSicky(replyWrapper) { if (!replyWrapper) return; const placeholder = create('div'); const e = replyWrapper.parentElement; if (!e) return; replyWrapper.replaceWith(placeholder); e.querySelector('#sliderContainer')?.style.setProperty('display', 'none', 'important'); const getSwitch = () => { const raw = localStorage.getItem('sickyReplySwitch') if (!raw) return 1; return Number(raw) || 0; } const swBtn = create('div', { class: 'switch', switch: Number(localStorage.getItem('sickyReplySwitch')) || 1 }); swBtn.addEventListener('click', callNow(sw => { const s = sw ? sw() : getSwitch(); swBtn.setAttribute('switch', s); const sicky = (() => { const q = e.querySelector('.sicky-reply') if (q) return q; const c = create('div', { class: 'sicky-reply' }); e.insertBefore(c, e.querySelector(':scope>.clearit') || e.querySelector(':scope>#comment_list') || placeholder); return c; })(); if (s) { sicky.style.visibility = 'visible'; replyWrapper.replaceWith(placeholder); sicky.append(replyWrapper); } else { sicky.style.visibility = 'hidden'; placeholder.replaceWith(replyWrapper); } }).bind(this, () => { const s = (getSwitch() + 1) % 2; localStorage.setItem('sickyReplySwitch', s) return s; })); append(replyWrapper, swBtn); } class Friends { static #peddings = null; static async get() { const peddings = this.#peddings ?? []; const pedding = new Promise(resolve => peddings.push(resolve)); if (!this.#peddings) { this.#peddings = peddings; this.#trigger(); } return pedding; } static async #get() { const user = whoami(); if (!user) return new Set(); const id = user.id; const cache = await db.get('friends', id); if (cache && cache.timestamp > Date.now() - 3600_000) return cache.friends; const friends = await this.#fetch(id); await db.put('friends', { id, friends, timestamp: Date.now() }); return friends; } static async #fetch(id) { const res = await fetch(`/user/${id}/friends`); if (!res.ok) console.warn(`Error fetching friends: ${res.status}`); const html = await res.text(); const element = document.createElement('html') element.innerHTML = html.replace(/<(img|script|link)/g, '